我把审计 Actor 接入了 Claude,它审计了三个没人要求审查的包
我在 Apify 上维护着 22 个审计 Actor,它们做的事情大同小异。它们取来一份公开
记录,核实其中声称的信息如今是否仍然属实。
上周,我通过 Apify 的模型上下文协议(MCP)服务器,把其中一个接入了 Claude。我本以为
有趣的部分会是智能体,结果并不是——真正有意思的是我自己的输入
schema。从发布那天起,它就在对每一个非人类调用者撒谎。
接着我又做了第二个假设:我认定自己知道智能体会如何解读结果。于是我把这个
问题交给其中四个智能体验证,结果四个都得出了相反的结论。
本文要讲的,就是我的发现、智能体的实际表现,以及我为此做的改动。
这个 Actor 做什么
github-repository-audit 接收包名,也可以接收
仓库名。针对每一个名称,它会向两个数据源提出同一个问题,再比对两边的答案。
注册表记录着一个包指向哪个仓库,仓库则记录它是否已被归档、迁移,
还是更换了许可证。这两份记录不一致的情况,比你想象的要多。
我开发这个工具,针对的正是那种悄无声息的问题。下面是一次真实运行的结果:
npm仍在正常分发cross-env@10.1.0,没有任何弃用提示,而它对应的仓库
kentcdodds/cross-env早已被归档。安装过程看起来完全正常。
终端里不会给你任何提示,npm install 也不打印警告。归档横幅就挂在
仓库页面上,而安装这个包的人根本不会去那里看。维护者只能在唯一能发声的地方道别,注册表却从未把这条消息传递出去。
通过 Apify MCP 服务器将它接入
Apify MCP 服务器能把 Actor 变成 Agent 可调用的工具。
整个连接过程只需一条命令:
claude mcp add --transport http apify \
"https://mcp.apify.com?tools=aiqlabs/github-repository-audit"
?tools= 这个参数比看起来重要得多。没有它,服务器会把全部接口都暴露出来。
有了它,智能体就只能看到一个工具。我想要一个干净的实验环境,所以把它限定到了一个 Actor 上。
认证走的是 OAuth,带动态客户端注册和 PKCE,我全程没碰过任何 token。
授权确认页面值得认真读一读,别直接一路点过去。Apify 显示的提示相当实在:
此应用为动态注册,未经 Apify 验证。请确保你信任它。
它只允许将你重定向到以下 URL:http://localhost/callback
这才是该有的警示。动态注册意味着没有任何一方审查过这个客户端。
一段弯路
我最开始的三次尝试都失败了,但原因和 Apify 毫无关系。
我是在非交互式 shell 里操作 CLI 的。claude mcp login 打印出授权 URL,
开始等待,然后直接放弃:
Waiting for authorization… (^C to cancel)
Couldn't complete authentication: stdin isn't a terminal.
登录流程要求一个真实的终端,哪怕回调本来可以通过网络送达。我接着试了
winpty,结果它以同样的理由拒绝了,只是问题往下深了一层:它自己也需要一个控制台。
真正管用的办法是一个批处理文件:在一个全新的控制台窗口里启动,并把 stdout 重定向到文件。
控制台满足了终端检查,重定向则让我能读到授权 URL。接着我打开那个 URL 完成授权,回调就落在了
localhost 上。
如果你也要把这套流程自动化,请记下这一条:拦住你的不是网络,而是终端。
问的是一个仓库,首次运行却返回了四行结果
在把工具交给 agent 之前,我想先看看最小的一次调用长什么样。于是我只发了一个字段,其他什么都不带:
{"repos":["facebook/create-react-app"]}
总共才 39 字节,可这次运行记录到的 inputBodyLen 却是 328。
有什么东西把我的输入撑大了整整八倍。下面就是平台实际存储的内容:
{
"repos": ["facebook/create-react-app"],
"packages": ["npm:request", "npm:babel-eslint", "npm:left-pad"],
"defaultRegistry": "npm",
"manifestType": "",
"manifestGroups": ["dependencies"],
"staleAfterDays": 365,
"includeContributors": false,
"includeReleases": false,
"onlyIssues": false,
"maxTargets": 200,
"maxConcurrency": 4,
"requestTimeoutSecs": 30
}
三个我从未提及的包就这样进入了这次运行。返回的结果如下:
input |
source |
riskLevel |
issueCodes |
|---|---|---|---|
facebook/create-react-app |
repos |
中等 |
repo_moved, stale_no_push
|
npm:request |
packages |
高 |
deprecated_on_registry, stale_no_push
|
npm:babel-eslint |
packages |
严重 |
deprecated_on_registry, repo_archived
|
npm:left-pad |
packages |
严重 |
deprecated_on_registry, repo_moved, repo_archived, license_mismatch
|
我只查询了一个仓库,却拿到了四行结果。每一行高风险和严重
风险的结果,对应的都是调用方从未提到过的东西。
request 和
left-pad 也绝不是无关紧要的凑数包。它们是 npm 仓库中最
广为人知的两个废弃包,产生这些发现本就是设计使然。
prefill 和 default 并不是同一个词
问题的根源在于我自己输入 schema 里的一行代码,那还是几个月前写下的。
{
"repos": { "prefill": ["facebook/create-react-app", "babel/babel-eslint"] },
"packages": { "default": ["npm:request", "npm:babel-eslint", "npm:left-pad"] }
}
此前我一直混用这两个键,以为它们都只是表单的示例。
Input Schema 规范
本身写得清清楚楚,只是我从未仔细读过这一段:
Default — 无论用户以哪种方式(API、CLI、
调度器还是用户界面)启动 Actor,只要省略了该值,平台就会自动把这个默认值传给 Actor。Prefill — 这个字段只在用户界面中起作用,
不会影响 Actor 的功能和 API。
prefill 只是个建议,而 default 是平台替你兑现的承诺。
这件事还有另一面,直到把 Actor 变成工具之后我才注意到。MCP 服务器会把 Input Schema 转换成 JSON Schema,再交给模型。模型实际收到的内容如下:
{
"packages": { "default": ["npm:request", "npm:babel-eslint", "npm:left-pad"] },
"repos": { "prefill": ["facebook/create-react-app", "babel/babel-eslint"] }
}
default 是 JSON Schema 中的关键字,意思是“本字段缺省时所用的值”,模型读取工具定义时
可以据此行动。prefill 则完全不是 JSON Schema 关键字,只会作为一个
无法识别的键原样透传。
于是,没人问起的那个字段反而用规范的语言宣告了自己的存在。
真正回答问题的字段,带的却是一个模型毫无规则可循的词。我把标准关键字放到了
错误的字段上。
我的 Actor 自身代码在这里并无问题,它是用 packages = [] 来解构的。注入发生在它上游,
早在 Actor.getInput() 返回之前就已发生。
我原以为接下来会发生什么
在 Apify Console 中打开这个 Actor,问题一眼可见:Packages 输入框里
静静躺着三个值。你可以删掉,也可以保留——不管怎样,决定权都在你手里。
表单其实在做一件重要的事:它让你看到完整的输入,
包括你没有提供的那部分。
智能体则永远看不到表单。它只发送自己决定发送的字段,然后接收数据行。
我的数据行确实带有 source 字段,所以区分各行所需的信息是存在的。读了这个字段的智能体
就能分辨出那四行。这一点我要说得准确些:这纯属碰巧做对,
因为当初加 source 并不是出于这个考虑。
但智能体不会把每行五十四个字段一一复述给用户,它读的是摘要。于是
我打开了我的 Actor 专门为此写入的那两条记录。
SUMMARY 负责统计这次运行的情况:
{
"checked": 4,
"byRiskLevel": { "critical": 2, "high": 1, "medium": 1, "low": 0, "ok": 0 },
"licenseComparison": { "comparable": 3, "mismatchRate": 0.333, "notComparable": 1 }
}
两个严重、一个高危,而提问针对的只是一个仓库。该区块中没有任何计数说明哪些发现
属于调用者的目标。错配率更糟。那是一个分数,
分母却由不得调用者来选。
ACTION_LIST 是我为“下一步该做什么”命名的那条记录。它的条目带有 target、registry、
repo、riskLevel 和 issues 字段。它完全没有 source 字段,而且按严重程度排序:
| # | target |
riskLevel |
调用者是否要求过它? |
|---|---|---|---|
| 1 | babel-eslint |
严重 | 否 |
| 2 | left-pad |
严重 | 否 |
| 3 | request |
高危 | 否 |
| 4 | facebook/create-react-app |
中危 | 是 |
真正能回答提问的那一条排在最底下。它上面的三条,调用者从未
提及。而这条记录中没有任何字段能把它们区分开。
陷阱就是这个形状。归属信息在原始数据行里还留得住——智能体扫读的正是它们。
可到了两份专为阅读而构建的记录里,它就消失了。
于是我有了一个预测,还挺规整的。问一个智能体 create-react-app 作为依赖是否安全,
它会读到 critical: 2,然后原样转述出去。
我连续错了四次
schema 我早就
这就是某个智能体为我的 Actor 做出的变通方案,写给我的用户看。
这个陷阱依然造成了损失
没有人被误导——这一点我不想轻描淡写。但给出四个正确的答案,
并不等于没有造成伤害。
四个智能体里有两个把审计跑了两遍。计算单元翻倍,GitHub 请求翻倍——
而未认证配额每小时只有 60 次,等待时间也随之翻倍。调用方为纠正我的错误买了单。
有一个直接告诉用户:这个工具有缺陷。那段话从此成了我的 Actor 留给人的印象的一部分,
而读到它的人,正在决定要不要使用这个工具。
有一个则只字未提。第四个智能体给出了干净、完全正确的答案,却从未提起自己丢弃的
那三行数据。调用方按四行付了账,实际能用的只有一行。整个交互过程没有向他们透露半个字。
而那个错误答案从头到尾都摆在那里。byRiskLevel 依然写着 critical: 2,
旁边没有任何说明指出这 2 个 critical 属于谁。四个智能体都拒绝采用它,
但这不代表它不存在。
其中一个还发现了我漏掉的第二个 bug
那个把审计跑了两遍的智能体解释了原因:
我之所以第二次运行审计,是因为只针对仓库的那次运行让
registryDeprecated、licenseMatch
和silentAbandonment全部返回null——它从头到尾没有查询过 npm,
根本不可能按要求回答“是否可以安全依赖”这个问题。
它说得对。仓库不涉及 npm 注册表,所以有三项检查根本无从执行。其中一项就是
silent_abandonment——这个 Actor 的存在,恰恰就是为了报告这项发现。只要调用方指定的是仓库,
就永远拿不到它,也没有任何地方说明这一点。这些字段只是直接返回了 null。
我明明在自己的 README 里写下过防范此类问题的规则:把“损坏”、“正常”和“无法检查”
作为三种不同的取值返回。结果我却把第三种写成了裸的 我让空调用大声地失败。没有目标时,Actor 现在会停止运行,并给出一条消息,写明
三种可用的入口。以前根本走不到这条路径,因为默认值保证了永远有东西可供审计。那正是掩盖 Bug 的那个 Bug。
我把相关性规则挪进了描述里。source 字段的文档,现在就放在 Agent 能读到的位置,
而不只是躺在 README 里。
最后这条改动还藏着我写到这儿才发现的第二层问题。source 在我的
dataset_schema.json 里同样不见踪影。于是默认的 Console 表格不显示它,“仅问题”视图同样不显示。
打开那张表格,情况更糟。我的视图把 packageName 放在第一列,而我要查的那一行来自
repos,本来就没有包名。Console 便把它打印成 null。
| # | 包名 | 注册表 | 风险 | 解析出的仓库 |
|---|---|---|---|---|
| 1 | null | null | 中 | react/create-react-app |
| 2 | request | npm | 高 | request/request |
| 3 | babel-eslint | npm | 严重 | babel/babel-eslint |
| 4 | left-pad | npm | 严重 | left-pad/left-pad |
真正能回答问题的那一行,看上去反倒像坏掉的一行;没人要的那三行,看起来反而
权威十足。我等于亲手搭了一个视图,按读者有多不需要来给自己输出排序。
现在两个视图都把回答“这一行是我要查的吗?”的那两列放在最前面:input 和 source。
我让摘要也带上出处。如果当时止步于
schema,这条改动我就会漏掉。移除默认值只能堵住这一种注入,却不能让输出变得可归因。
ACTION_LIST 现在每一条都以 input 和 source 开头。SUMMARY 现在在平铺计数之外,还带有一个 bySource
块:
{
"checked": 4,
"bySource": {
"repos": { "checked": 1, "critical": 0, "high": 0, "medium": 1, "low": 0, "ok": 0 },
"packages": { "checked": 3, "critical": 2, "high": 1, "medium": 0, "low": 0, "ok": 0 }
},
"byRiskLevel": { "critical": 2, "high": 1, "medium": 1, "low": 0, "ok": 0 }
}
那条平线依然写着两个 critical。现在,它旁边多了一样东西:说明这两处属于谁。
这段逻辑位于 src/audit.js 中,一共八行代码,就在构建 SUMMARY 的那个函数里:
// A single call can mix targets the caller typed with targets that arrived some
// other way - a manifest that expanded into forty dependencies, or a schema
// default. A flat count of "2 critical" cannot be acted on, because it does not
// say whose.
const bySource = {};
for (const row of rows) {
const key = row.source ?? 'unknown';
bySource[key] ??= { checked: 0, critical: 0, high: 0, medium: 0, low: 0, ok: 0 };
bySource[key].checked += 1;
if (row.riskLevel in bySource[key]) bySource[key][row.riskLevel] += 1;
}
ACTION_LIST 的改动只有两行:input 和 source 移到了每条记录的开头,排在
target 之前。这次顺序调整就是全部改动。如果读的人答不上"这是我要查的吗?",
就无从处理这条记录。
这件事的意义不止于最初引发它的那个 bug。我的 Actor 在一次调用中可以同时接收仓库、软件包和清单。
一个 manifest URL 就能从单个字段展开出四十个依赖。无论哪种组合,得出的计数
调用方都无法拆开。默认的分组方式只是其中一种,并非唯一。
把输出其余部分过一遍,又发现一处。LICENSE_REPORT 末尾会列出
无法解析的许可证。这份列表实际上是在指示读者手动打开文件核对,里面却只有光秃秃的名字。于是
我也给这些条目加上了 input 和 source。让人去读一个他从未指定过的依赖的许可证,
和误报一样,会白白浪费一个下午。
我让"无法检查"这件事明明白白说了出来。这一条是 agent 替我发现的。现在,
仅有仓库来源的记录会在 SUMMARY 中生成一条说明:
1 个目标以仓库形式传入,因此没有为它们查询注册表。这些记录上的 registryDeprecated、
licenseMatch 和 silentAbandonment 为 null,是因为无法检查它们,
而不是因为检查结果正常。传入包名即可检查。
同一句话也写进了 repos 字段的描述里,agent 在调用之前就能读到。
null 本来就是诚实的取值,只是不够明白易懂。
四个测试守护着这一点。三个检查这三条记录上的新字段,第四个是对照:单一来源的一次运行
必须只报出一个分组,且其数字必须与扁平总数一致。这样,
分组结果就不可能凭空捏造出并不存在的结构。测试套件从 48 个增加到了 52 个。
用同样的口径度量,实际变化了什么
我推送了 0.1.8 构建,然后再次发送了同样的单字段调用。
| 改动前 | 改动后 | |
|---|---|---|
| 返回行数 | 4 | 1 |
存储的 INPUT
|
328 字节 | 267 字节 |
byRiskLevel |
critical 2, high 1 |
critical 0, high 0 |
bySource |
缺失 | 存在 |
第一条 ACTION_LIST 记录 |
babel-eslint,严重级 |
我查询的那个仓库 |
接着我又跑了一遍 agent 试验:同样的问题,同样朴素的提示词,两个全新的 agent。
两个 agent 都答对了,与之前一致。谁都没有提到工具有缺陷。而在改动之前,
四次试验里有三次提到过。这就是全部可度量的结果,而且相当有限。从来没有谁拿到过错误答案,
所以正确性方面并无任何提升。
真正改善的是:我的 Actor 不再要求调用方来迁就它了。
有一件事没有变化。两个 agent 仍然把审计跑了两遍:一遍按仓库,一遍按软件包。
这正是如今正确的行为,而且输出说明里也白纸黑字地要求这么做。不过我得说明,我新加的备注可能
是在促使第二次调用发生,而不只是允许它。有一个 agent 在备注出现之前就做过同样的第二次
调用,这一点反驳了上述猜测。但仅凭四次试验,我还无法把这两种可能区分
开来。
接着我检查了其余的二十一个
一处坏字段也许只是笔误。我想知道这是不是一种习惯。于是我把发布过的每个输入 schema
都读了一遍,把里面的默认值分成两堆。
一个默认值,决定的是工作怎么做,那它是安全的;决定的是工作对什么
做,那它就是危险的。
已发布的 Actor 共有二十二个,每一个都在某处设置了 default。这些默认值里有十个
仍指向某个具体目标,而我刚刚移除的正是第十一个。
| Actor | 字段 | 调用方不传任何参数时默认审计的对象 |
|---|---|---|
seo-ai-visibility-auditor |
startUrls |
apify.com |
bulk-domain-checker |
domains |
apify.com |
domain-availability-checker |
domains |
apify.com |
sitemap-checker |
domains |
apify.com |
tech-stack-detector |
domains |
apify.com |
dead-link-checker |
domains |
docs.apify.com |
http-status-checker |
urls |
apify.com/store |
pdf-inspector |
pdfUrls |
一份美国税表 |
pdf-to-text-markdown |
pdfUrls |
同一份税表 |
hacker-news-link-rot |
list |
Hacker News 的 topstories 列表 |
随便挑前九个中的任何一个,传入空输入,你都会得到一份现成的报告。它审计的是 Apify 自家的
官网,或者一份美国税表。响应里没有任何迹象表明这只是一个示例。没人问过这个问题,Agent 却握着
一份格式规范的答案。
接着,我注意到了最让我难堪的地方:那十个字段中,有九个在同一个 schema 里同样出现在
required 之下。
我一直把 required 理解为一种承诺——调用方已经指定了目标。于是我对这个理解
做了验证:传入一个空对象,调用 seo-ai-visibility-auditor。
echo '{}' > empty.json
apify call GuuMKUiWcaUUqGGhG --input-file=empty.json
运行成功,并对 apify.com 完成了审计。平台为我存储的输入内容如下:
{
"startUrls": [{ "url": "https://apify.com" }],
"crawlSite": false,
"maxPages": 10,
"checkBrokenLinks": false,
"maxLinksToCheck": 50,
"proxyConfiguration": { "useApifyProxy": false }
}
我发送了 {}。Actor 收到了目标,也没有任何环节拒绝这次调用。
规范文档
把这两个选项视为互斥的选择,而不是一对组合。required 用于那些
没有合理默认值的字段。default 则表示只要调用方省略了该字段,平台就会填入它,
“无论用什么方式”。两个字段同时设置时,起作用的是后者。required 只剩个名头,算是给
读表单的人留个提示。
其余十二个 Actor 都没问题。country、outputFormats、robotsAgent、manifestGroups——
省略这些并不会凭空造出工作量。
表格里有一行让我认真想了想。hacker-news-link-rot 把 list 字段的默认值设为
topstories,而这个默认值确实会决定目标。但这个 Actor 没有其他输入入口。一次空调用必须
有明确的含义。它也是我唯一没标 required 的那一行。
所以规则并不是“永远别用 default”。它比这更窄:
一言不发的调用方,绝不该收到与
其主动请求的结果无从区分的结果。
hacker-news-link-rot 靠描述里的一句话就满足了这条。其余九个
则完全没做到。
在 Agent 登场之前,同样的错误我犯了四次
真正扎心的是:这对我来说并不是一类新错误,已经是第四次了。
我写过的每一个审计 Actor,都产出过一个看似板上钉钉、实则源于我自身知识盲区的结论。
而每一次的补救都是同一个套路:把一个值拆成两个。
一场看起来像删除的限流。我的 Chrome 扩展审计器从
Google 官方的 sitemap 抓取了 130 个条目,每 600 毫秒一个。其中九十三个返回“商店从未
听说过这个 ID”。而它们每一个,都是我几分钟前刚上架的健康扩展。
Google 限流时会把访客重定向到商店之外。一个只会问
“我落在条目页上了吗?”的读取器,会把眼前的场景当成扩展已被删除。这个 Actor 现在会首先
检查这种情况。它绝不再把限流页当作关于扩展的事实。它直接终止运行,
而不是再多产出九十三条误报。
一个实为广告服务器拒绝服务的 404。我的播客审计器报告了 1.7% 的单集死亡率。
其中九次失败来自同一家托管商,而那九档节目全都运行正常。
音频由一个按听众分配的重定向服务提供,播放途中还会动态插入广告。它
不会为自动化请求构建重定向,于是返回了 404,响应体是 Missing。这样一来,我测得的真实死亡率是 0.5%,虚假死亡率却是它的三倍。
redirect URL
这类情况最终被记为 undetermined(未定),而不是“已死亡”。发现项正文所写的,正是我实际知道的东西:
不模拟真实听众的行为,就无从判断音频是否还在——
而本 Actor 并不这么做。
lodash 身上的许可证警告。GitHub 无法把 lodash 的 LICENSE 匹配到任何标准许可证,于是
返回了 NOASSERTION。我的第一版代码把这种情况命名为 license_non_standard,并当作发现项上报。
lodash、jQuery UI 和 UglifyJS 全都命中了这一条。它们其实都是普通的 MIT、BSD 项目,只是 LICENSE
里多了一段附加文字。一个对健康条目也会触发的警告提供不了任何信息,只会把
真正重要的条目淹没。我在代码里留的注释,比我转述得更到位:
上报这一项,会让健康的条目也挂上警告,从而淹没真正重要的那些。
在测量任何数据之前就定下的严重级别。我曾把“三年没有更新”作为高
严重级别的发现项发布出去。后来我统计了 1,312 款 App Store 应用的基线比例:18.4%。
一个每五行就触发一次的发现项,描述的是整个生态的常态,而不是
你的依赖出了问题。它现在的级别是中等。
至于 Shopify 审计器,我干脆整个放弃了这项检查。Shopify 只公布上线日期,不公布
更新日期。硬要做这个检查,就只能靠猜。
我最终定下的规则
以上四个例子背后是同一条规则,只不过我每次都是绕了远路才抵达的。
把“已损坏”“正常”“无法检查”作为三个不同的值返回。
我过去给自己的辩解是:agent 分不清第三种和第二种。但我自己的试验给出了
相反的结论。四个 agent 从一个我根本没写过文档的 source 字段里推断出了这一区别。其中两个
还自己想明白了:只来自代码仓库的条目,根本不会去碰注册表。
所以原因不在于 agent 应付不了,而在于应付要付出什么代价。
你在输出里留下的每一处含糊,都是你转嫁给调用者的工作。有时他们付出的代价,是顶着限流的 API
再跑一遍;有时是写上一段话,向自己的用户解释
你工具的怪癖;有时他们默默付账,把你收费项目里的
四分之三直接扔掉。
这些都不会以报错的形式出现。它们只表现为你的 Actor 比隔壁那个稍贵一点,
可信度也稍低一点。
输入 schema 则是另一半。凡是能改变审计对象的字段,调用方都必须显式设置它。
留空就必须真正意味着留空。
你自己的 schema 该检查什么
一共五件事,按耗费我时间从多到少的顺序排列。
-
在你的 schema 里搜索
"default"。对每一条命中结果都问一句:API 调用方省略这个字段时会怎样?如果答案是会改变 Actor 处理的对象,就把它挪到prefill里。把字段列进required并不能替你做到这一点。 -
用最小可用的输入调用你的 Actor,然后读取保存下来的
INPUT记录。不要去读你发送的那份输入,要读平台实际保存的内容——那才是你的代码真正收到的。 -
确保空调用会失败。如果你的 Actor 总能找到事情做,你就没法区分空调用和真实调用。我查过这样做的代价,因为平台会按自己的计划运行已发布的 Actor。我的 22 个 Actor 里已有 12 个在空调用时无事可做,其中 3 个是因为某个
required字段根本没有default。过去三十天内,22 个 Actor 在平台的 159 次运行中,失败次数全部为零。 -
把限制写进
description,而不是写进 README。description 才是 agent 会读的内容。我的 description 现在写明了为什么不支持pyproject.toml:对清单文件只解析一半,会报告出一堆你实际并不具备的依赖问题。 - 把工具交给一个 agent,读读它是怎么评价你的。这一步找到的问题比我亲自审查还多。凡是不得不绕开你那些怪癖才能干活的 agent,通常都会在回答里白纸黑字地向用户解释这个怪癖。那段话就是一份免费的 bug 报告,也是潜在用户眼中你的 Actor 的真实模样。
代码
本文 Actor 的完整源码在 GitHub 上:
ai-q-labs/github-repository-audit。这
就是那个已发布 Actor 背后的代码,发布于
apify.com/aiqlabs/github-repository-audit。它
包含本文讨论的输入 schema、52 个单元测试,以及针对真实
GitHub、npm 和 PyPI API 的实时检查。
把它克隆下来,运行 npm install,再运行 npm test 跑单元测试,或用 npm run test:live 做实时
检查。实时检查不需要任何密钥,但 GitHub 给未认证调用方的限额是每小时 60 次请求,所以
如果要跑不止一次,请设置 GITHUB_TOKEN。
本文所有截图的全尺寸版本都存放在同一个代码仓库中,路径为
docs/screenshots。



