本地摄入
在不触发远程提取路径的情况下接入更新日志。远程路径(releases admin source fetch → POST /v1/workflows/update → API worker的 DeterministicUpdateWorkflow, #1946)在服务端执行正文→记录提取(增量Haiku或工具循环),即使写入零条发布也要为推理付费。而本地Claude Code代理本身就是具备网络抓取和文件工具的强大模型,因此可以在当前会话内内联执行逐页提取,仅需一个确定性的写入路径。
核心原则:代理即提取器。 你负责抓取、解析、结构化记录,然后通过批量端点进行POST,该端点在插入时仅执行纯幂等更新 ,无需任何AI。远程提取循环永远不会运行。
仅限本地Claude Code。 该技能使用Agent工具进行子代理扇出,并假设存在持久化的本地文件系统和CLI的RELEASES_API_*环境变量。它作为仅限本地的操作者技能,与seeding-playbooks并列——不会部署到托管代理集群。
成本契约
在大型运行前向操作者明确说明并遵守:
- 消耗: 你的代理自身用于抓取和提取的token,以及你扇出的任何子代理的token(模型由你选择——质量用
sonnet,批量用haiku)。 - 不消耗: 无服务端提取(更新工作流的增量Haiku/工具循环流程),无远程
web_fetch提取。绝不调用POST /v1/workflows/update。 - 批量端点在插入时不运行任何AI——无摘要生成、无营销分类器、无feed丰富化。它只执行
insert加上一个即发即忘的向量嵌入(waitUntil)。AI字段(title_generated/title_short/summary)有意留空;参见下方的 手动丰富化 了解后续的刻意处理路径。
确认未发生远程运行的负面信号:在Axiom的 releases-cloudflare-logs 中,没有 POST .../fetch?sessionId=det-…,也没有针对该源的 extract-deps-worker 事件。
何时使用
- 在Claude Code中交互式接入新源(或回填现有源),并且希望跳过远程提取成本。
- 远程抓取消耗了整个提取循环但写出了0条发布(Conductor/大正文/
max_tokens失败模式)——内联提取完全绕过了这个循环。 - 页面可以通过你自己的网络工具抓取(下面的拒绝是策略关口,而非抓取限制)。
何时不使用:
- 你在托管代理会话中。 此技能仅限本地(Agent工具扇出 + 本地文件系统 + CLI环境)。托管代理会话应继续使用正常的抓取路径。
- 源具备干净、内容丰富的feed。 直接添加它(
managing-sources),让cron流水线抓取即可。本地摄入适用于代理内联提取是正确工具的场景,而不是feed/GitHub适配器的替代品。 - 发布者选择退出(预检拒绝——参见步骤1)。
工作流
步骤 1 — 预检(强制安全门控)
在抓取或写入任何内容之前运行退出检查:
bun .claude/skills/local-ingest/preflight.ts <url> --json
它会抓取 robots.txt(遵循顶级域→www重定向),解析Cloudflare Content Signals 策略,并根据退出码进行门控:
| 退出码 | 判定 | 行动 |
|---|---|---|
0 | proceed | 允许或缺失——继续。 |
1 | refuse | 声明了 ai-input=no。停止。向用户呈现。 不要抓取或写入。 |
2 | unknown | 无法抓取/解析 robots.txt。重试一次;如果仍然未知,向用户呈现——不要假定为允许。 |
门控仅针对 ai-input。 本地摄入是搜索/输入摄入——用于填充搜索引擎——这正是 ai-input 所管辖的。ai-train=no 选择退出模型训练,该路径不执行此操作,因此单独的 ai-train=no(例如 vercel.com)正确地允许继续。
这是Conductor的教训。 conductor.build 提供 Content-Signal: ai-train=no, search=yes, ai-input=no ——其组织/源已经因此原因在注册表中处于暂停状态(releases admin playbook conductor)。本地接入绝不能默默消耗token或摄入违反发布者退出政策的内容。必须拒绝 conductor.build(通过其 ai-input=no)——它是此门控的回归测试目标。
拒绝是一个政策选择,而非技术限制:web_fetch 和 CF Browser Rendering 仍然可以检索这些页面(只有Cloudflare的 /crawl 端点为Conductor强制实施了该信号)。无论哪种情况,都要尊重退出政策。仅在操作者记录了明确发布者许可的情况下才可覆盖——没有静默绕过。
预检还会打印 robots.txt 中的任何 Sitemap: URL——为步骤3保留它们。
步骤 2 — 解析目标
在写入之前确保组织(org)和源(source)存在——发布记录附加到 source_id。关于创建/命名/主要规则的详细信息,请参考 managing-sources;关于是否存在结构化feed(如果存在,优先使用正常流水线)的详细信息,请参考 finding-changelogs。然后:
- 阅读组织playbook获取解析提示:
releases admin playbook <org>(参见managing-sources→ Playbooks)。 - 从已创建/已解析的行中捕获
sourceSlug和src_…id——批量URL需要其中一个。
如果源是全新的,创建它(例如 releases admin source create "<name>" --url <url> --org <org> [--primary]),但不要触发抓取——那会触发你想要避免的远程更新工作流。
步骤 3 — 映射页面
对页面形态进行分类(releases admin discovery evaluate <url> --json 报告 pageStructure: single-page | index | unknown):
- 单页更新日志 → 步骤4中抓取一次。
- 索引 → 详情 → 枚举每个发布的URL。优先使用
/sitemap.xml(预检已提供它)并过滤到更新日志路径;否则解析索引HTML以获取每个发布的链接。 - 客户端渲染的索引 → 索引是一个JS外壳,其卡片通过客户端抓取加载,因此
curl/非JS抓取返回零条目。通过渲染来枚举:在Chrome中使用Claude驱动索引(或扇出一个渲染子代理),如果列表是分页的则进行翻页,收集每个发布的链接。然后检查_详情_页面是否为静态渲染——即使索引不是,详情页面通常也是静态渲染的——如果是,直接批量curl它们(本地HTML→markdown,无需渲染,无需AI费用)。Harvey的help.harvey.ai/release-notes正是这种情况:客户端渲染的索引,静态详情页面。不要因为索引的非JS空抓取结果就断言“没有发布”。
限制在最近时间窗口内(默认 约25-50个 最近的发布),并记录你跳过了什么——永远不要静默截断。重新运行是幂等的,因此更深的回填稍后只会添加行。
步骤 4 — 抓取 + 提取
复用 parsing-changelogs 中关于类型/版本/日期/媒体的约定(参见下面的_一致性_)。
- 少量页面: 在此代理中内联抓取和提取。
- 大量页面: 扇出并行子代理——每个代理处理一个URL分片,每个代理返回符合批量模式的记录。使用来自
seeding-playbooks(以及 superpowers:dispatching-parallel-agents 中的扇出原则)的Agent工具调度模式。不要在父代理中抓取发布URL——委托给子代理以保持父代理的上下文干净。 - 父代理保存的注意事项: 子代理可能被阻止进行批量写入(权限限制)。为此做好规划——让每个子代理在其结果中返回记录,并让父代理(你)在步骤5中执行写入。与
seeding-playbooks用于保存笔记的模式相同。 - 提取媒体——不要只提交纯文本。 从每个发布正文中提取图像/视频并填充
media,解开_next/image/_vercel/image优化器包装,还原为底层CDN URL(normalizeMediaUrl来自packages/rendering/src/media-url.ts)。纯文本第一遍是常见失误,仅在操作者审查时才会被发现(Harvey提交了纯文本,然后不得不重新提取_next/image→cdn.sanity.io并重新播种);丰富化重新POST使用mode: "upsert-content"(步骤5)。
每条记录必须符合批量模式(参见步骤5)和一致性规则。
可选——确定性提取(方法A,仅限monorepo): 除了手动提取,你可以调用 packages/adapters/src/extract/ 中的生产提取库——extractFromBody、extractWithTools、runDirectFetchExtraction。ExtractDeps.cloudflare 可为null,repo 可以是noop,因此它们仅凭Anthropic密钥即可运行(证明:scripts/smoke-toolloop.ts)。这用代理token换取直接的Anthropic API token,但精确复现了生产工具循环。这些包是 private: true ——仅可从monorepo访问,永远不能从精简CLI访问。默认仍是方法B(代理即提取器)。
步骤 5 — 写入(批量幂等更新)
将记录以块的形式POST到批量端点(每请求约50条保持请求适中;处理器在服务端执行D1绑定限制分块):
# 使用组织作用域路径。裸路径 /v1/sources/:slug 仅接受类型化的
# `src_…` id——人性化slug在那里返回400 bare_slug_rejected(slug是
# 组织作用域的,#690)。使用类型化id时,裸路径也有效:
# POST $RELEASES_API_URL/v1/orgs/<orgSlug>/sources/<sourceSlug>/releases/batch
# POST $RELEASES_API_URL/v1/sources/<src_…>/releases/batch
curl -sS -X POST "$RELEASES_API_URL/v1/orgs/<orgSlug>/sources/<sourceSlug>/releases/batch" \
-H "Authorization: Bearer $RELEASES_API_KEY" \
-H "Content-Type: application/json" \
-d @chunk.json # { "releases": [ … ] }
每条发布的字段(实际接受的形状——workers/api/src/routes/sources.ts,postReleasesBatchHandler):
{
"version": "1.4.0", // 可选;对于以日期为标题或博客类源,可省略/null
"title": "深色模式", // 必需
"content": "Markdown正文…", // 必需
"url": "https://…/changelog/dark-mode", // 可选但始终填充——它是去重键
"contentHash": "…", // 可选
"publishedAt": "2026-05-30", // 可选;ISO-8601(参见一致性)
"media": "https://…/shot.png", // 可选
"type": "feature", // 可选;"feature" | "rollup"
"prerelease": false, // 可选
}
- 幂等。 基于
UNIQUE(source_id, url)进行幂等更新,填充而非覆盖:在冲突时,仅当存储的行中字段为_空_时才回填内容/媒体,因此重新运行不会覆盖有效的行。可以安全地重新运行和分页——相同的url会更新而非产生重复。 - 丰富化重新POST(
mode: "upsert-content")。 由于默认是只填充,对已有内容的URL重新POST更丰富的内容是空操作——存根保持不变。对于有意的第二轮处理(先播种索引摘要,然后完整的详情页正文+媒体),在批量正文的顶层添加"mode": "upsert-content":它会在相同URL冲突时覆盖内容/媒体,并跳过抓取标题去重预过滤器(#1526)。仅在有意的丰富化时使用;默认路径保持只填充,以便常规重新抓取不会覆盖。url必须与已播种的行相同——丰富化通过URL键匹配,因此更改的URL方案会插入重复项。如果URL方案已更改,改为重新播种:releases release delete --source <src_…> --hard然后新的/batch(默认模式)。 - 认证。 需要
write作用域;静态RELEASES_API_KEY(root)满足此要求。在此仓库中,.env自动加载RELEASES_API_URL+RELEASES_API_KEY。 - 插入时无AI(参见成本契约)。
分块帮助脚本(读取 records.json 数组,以每批50条发布):
bun -e '
const orgSlug = process.argv[1], sourceSlug = process.argv[2], file = process.argv[3];
const all = JSON.parse(await Bun.file(file).text());
const base = process.env.RELEASES_API_URL, key = process.env.RELEASES_API_KEY;
for (let i = 0; i < all.length; i += 50) {
const releases = all.slice(i, i + 50);
const res = await fetch(`${base}/v1/orgs/${orgSlug}/sources/${sourceSlug}/releases/batch`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ releases }),
});
console.log(`chunk ${i / 50}: ${res.status} ${(await res.text()).slice(0, 200)}`);
}
' <orgSlug> <sourceSlug> records.json
步骤 6 — 验证
releases tail <slug> --json # 或: get_latest_releases (类型化工具)
确认发布具有非空的标题、日期、内容和媒体。如果某行为_空_,修复提取并重新运行——默认的幂等更新会安全地回填空行。如果某行已有_薄但有内容_的内容(例如你现想用完整正文替换的索引摘要存根),普通的重新运行是空操作——使用步骤5中的 mode: "upsert-content" 丰富化重新POST。
正在恢复一个源,而不是接入一个新的? 此步骤仅确认已写入的内容——它未说明哪些内容未写入。在告知任何人某个源已“恢复”或“没有遗漏内容”之前,请参见 backfilling-sources → 在声明某源“干净”或“已恢复”之前 了解所需的存活页面现场抽查。
步骤 7 — Playbook记录
记录此源是如何被摄入的,以便未来的代理无需重新推导。通过 manage_playbook(动作 update_notes)或 releases admin playbook <org> --notes-file - 完成。注明该源是本地摄入的(尚未在cron路径上)、其观察到的发布节奏以及任何提取中的注意事项。遵循 managing-sources 中playbook编写规范(祈使语气、保持测试、### 抓取说明 / ### 陷阱 / ### 覆盖范围)。请勿
…
[已截断以进行翻译]