Wiki 内容摄入管道:架构设计与工程实践
Jun 12, 2026 - ⧖ 11 min背景
个人知识库的内容来源分散在 RSS、书签、社交网络时间线。之前的内容归档方式是"手动复制粘贴到编辑器",效率低且质量不可控。
目标:构建一条自动化的 web → wiki 摄入管道,满足:
- 从 Inbox(URL 列表)自动抓取网页内容
- AI 分类并归入正确的知识库主题
- 以结构化 Markdown 写入 wiki
- 整个过程可观测、可审计、失败可回溯
1. Pipeline 全景
flowchart LR
INBOX[Inbox.md<br/>URL List] --> PARSE[Parse Inbox<br/>goldmark + xurls]
PARSE --> SCRAPE[Scrape Layer<br/>Content Acquisition]
SCRAPE --> EXTRACT[Extract Layer<br/>AI Classification]
EXTRACT --> ARCHIVE[Archive Layer<br/>Persistence]
ARCHIVE --> AUDIT[Audit Layer<br/>Validation]
style INBOX fill:#f9f,stroke:#333
style PARSE fill:#bbf,stroke:#333
style SCRAPE fill:#f96,stroke:#333
style EXTRACT fill:#69f,stroke:#333
style ARCHIVE fill:#6c9,stroke:#333
style AUDIT fill:#fc6,stroke:#333
每层职责:
| 层 | 输入 | 输出 | 核心关注点 |
|---|---|---|---|
| Parse | Inbox.md 原始行 | 去重 URL 列表 | 精确 URL 提取、Markdown 链接解析、行索引追踪 |
| Scrape | URL | 洗净的正文(含视频字幕) | 三层降级抓取、内容质量判定、媒体转录 |
| Extract | 正文 + URL + 标题 | 结构化分类结果(topic/type/summary) | 单次 AI 调用、候选白名单校验、JSON schema 约束 |
| Archive | 分类结果 | summary.md / failed-*.md | 两阶段并发模型、per-path 写锁、失败分类收集 |
| Audit | wiki 目录 | 问题列表 | 全量/增量扫描、CI 友好 |
2. Scrape 层 —— 三层降级内容获取
核心问题:网页内容不可靠。HTTP 200 不意味着拿到了可读文章——可能是登录壳、验证页、导航壳、空壳 SPA。必须有一套系统性的质量判定流程,而不是"拿到 HTML 就直接喂给 AI"。
架构
flowchart TD
URL[URL Input] --> CT{Content Type Detection}
CT -->|text/html<br/>default path| HTTP[HTTP GET<br/>timeout: 30s]
HTTP -->|network err| RETRY{Retry?<br/>max 3 attempts<br/>5s backoff}
RETRY -->|yes| HTTP
RETRY -->|exhausted| FALLBACK{OpenCLI<br/>enabled?}
HTTP -->|HTTP 200| READ{readability<br/>extractable?}
READ -->|yes| QG[Quality Gate<br/>assessContentQuality]
READ -->|no body| GQ[goquery → Markdown<br/>整页降级]
GQ --> QG
QG -->|pass| DONE[✅ Clean 正文]
QG -->|fail| FALLBACK
FALLBACK -->|yes| OC[opencli web read<br/>timeout: 45s]
FALLBACK -->|no| FAIL[❌ fetch-failed]
OC -->|success| QG2[Quality Gate<br/>再次判定]
QG2 -->|pass| DONE
QG2 -->|fail| FAIL
OC -->|fail| FAIL
CT -->|video<br/>youtube/bilibili| YT[yt-dlp --dump-json<br/>--skip-download]
YT --> SUB[Subtitle Language Match<br/>优先级: zh → en → any]
SUB --> NORM[Subtitle Normalize<br/>SRT/VTT → Plain Text]
NORM --> DONE
CT -->|audio<br/>xiaoyuzhou/podcast| RSS[Parse RSS Feed<br/>gofeed]
RSS --> ITEM[Match Episode<br/>exact URL → transcript signal → fallback]
ITEM --> TRANS[Transcript Pipeline<br/>RSS tag → description link → fallback]
TRANS --> NORM
路由逻辑
FetchContent 的调用入口 service/wiki/fetch.go:94 按优先级路由:
- GitHub 仓库 — 如果
urlutil.GitHubOwnerRepo(urlStr)返回 true,走 GitHub API (Repositories.Get),返回stars/language/license/topics/description等结构化元数据,不经过网页抓取 - 视频 URL — isVideoURL() 检测 YouTube/Bilibili/Shorts,进入 yt-dlp 字幕管道
- 音频/播客 URL — isPodcastLikeURL() 检测小宇宙/podcast/RSS feed,进入 RSS transcript 管道
- 默认 — 普通网页,走三层降级 HTTP → quality gate → opencli
三层降级细节
| 降级级别 | 触发条件 | 实现 | 典型场景 |
|---|---|---|---|
| 一级 | 默认路径 | Readability.js(Go binding go-readability)提取正文 |
博客、新闻、技术文章(覆盖 80%+) |
| 二级 | Readability 返回 nil 或空正文 | goquery 全文 .ToMarkdown() 并 textutil.TruncateUTF8(body, MaxBodySize) |
文档站、API 文档、非标准 HTML |
| 三级 | Quality Gate 判定不通过 且 OpenCLI 启用 | exec.CommandContext(ctx, "opencli", "web", "read", rawURL) 浏览器驱动获取 |
SPA、需 JS 渲染、Twitter/知乎反爬 |
Quality Gate 判定
assessContentQuality() 是一个多层判定函数,返回 contentQuality{OK bool, Reason string}。判定链路:
第 1 层:空内容。body 为空白直接拒绝。
第 2 层:18 条低质量关键词匹配。lowQualityPatterns() 的规则集分组:
| 类型 | 示例关键词 |
|---|---|
| JS 要求 | "this page requires javascript", "javascript is not available", "enable javascript", "please enable js" |
| 登录壳 | "please log in", "log in to continue", "sign in to continue", "sign up for" |
| 访问拒绝 | "access denied", "forbidden", "captcha", "checking your browser", "just a moment" |
| 错误页 | "400 bad request", "404 not found", "page not found", "something went wrong" |
| 占位符 | "video content requires manual review" |
关键词统一 strings.ToLower 后匹配,大小写不敏感。
第 3 层:社交媒体登录壳检测。对 x.com/twitter/instagram 域名,检查正文是否包含 "log in" / "sign up" / "javascript" 且正文长度 < 500 rune——这种往往是未登录时的空壳页面。
第 4 层:内容长度门禁。正文中文长度 < 120 rune 直接拒绝——Readability 或 goquery 可能只提取到了导航条。
第 5 层:链接密度检测。isLinkHeavy() 统计正文中 Markdown 链接和裸 URL 数量,如果 links * 80 > textLen(超过 80 个字符一个链接),判定为导航壳——这种页面没有一个链接指向自己,全是目录/聚合/导航页。
视频字幕管道细节
yt-dlp 命令行调用(fetch.go:228):
yt-dlp --ignore-config --dump-json --skip-download --no-warnings <URL>
--dump-json输出ytdlpMetadata完整 JSON(包含subtitles、automatic_captions、title、description)--skip-download只取元数据,不下载视频文件- 从 JSON 的
Subtitles和AutomaticCaptions两个字段中按语言优先级选取字幕 URL
语言匹配策略(videoSubtitleLangs):
- Bilibili URL 或 metadata 检测到中文特征时:
["zh-Hans", "zh-CN", "zh", "zh-Hant", "en"] - 其他:
["en", "en-US", "en-GB", "zh-Hans", "en"] langMatches()函数做模糊匹配,比如zh-CN匹配zh,en-US匹配en
字幕获取后通过 transcript.NormalizeContent() 将 SRT/VTT 格式归一化为纯文本,然后嵌入一个 wrapper 前缀:
Title: <title>
URL: <url>
Transcript source: subtitle:manual/<auto>:<lang>
<normalized text>
媒体内容的 MaxBodySize 是普通网页的 4 倍(默认 20,000 rune vs 5,000 byte),因为字幕内容天然更长。
播客音频管道细节
对播客 URL 的 RSS feed 解析策略(fetch.go:424):
- 首选精确匹配 — 遍历 RSS items,按
item.Link或item.GUID与原始 URL 精确匹配的 episode - 次选 transcript 信号 —
hasTranscriptSignal()检测:是否有podcast:transcript标签,或 description/content 包含 "transcript" 关键词 - 兜底 — 全部 RSS items 按顺序尝试
transcript 获取通过 transcript.NewPipeline(providers...) 串联多个 Provider:
RssTranscriptProvider— 提取 RSS 中的podcast:transcript标签DescriptionLinkProvider— 从 description 文本中提取 transcript 链接
3. Extract 层 —— AI 结构化分类
核心问题:LLM 输出不可控。返回的 topic 路径可能不存在、类型可能乱写、格式可能不是预期结构。这次重构的核心思路是:不给 AI 自由发挥空间,用工程约束限制输出边界。
架构
flowchart TD
INPUT[Body + Title + URL<br/>+ ContentType] --> CANIDATES[Load Topic Candidates<br/>从 gh.yml 生成<br/>80~120 paths]
CANIDATES --> PROMPT[rendered prompt<br/>classify-json.txt]
INPUT --> PROMPT
PROMPT --> AI[1x LLM Call<br/>一次性输出结构化 JSON]
AI --> RAW[JSON Response]
RAW --> PARSE{parseAIClassification<br/>strip code fence +<br/>json.Unmarshal}
PARSE -->|Unmarshal 失败| REPAIR{repairInvalidJSON<br/>Escape 修复}
REPAIR -->|成功| PARSE
REPAIR -->|仍然失败| C_FAIL[❌ classify-failed<br/>group-failed.md]
PARSE -->|valid JSON| BASICS{validateBasics<br/>confidence >= 0.45?<br/>type/contentType 合法?<br/>needsManualReview?}
BASICS -->|rejected| C_FAIL
BASICS -->|passed| TOPIC{validateTopic<br/>topicPath 在<br/>candidate set 中?}
TOPIC -->|no| FALLBACK[↩ inbox<br/>needsManualReview=true]
TOPIC -->|yes| SUMMARY{validateSummary<br/>overview/keyPoints<br/>非空?}
SUMMARY -->|empty| C_FAIL
SUMMARY -->|valid| DONE[✅ Archive-Ready<br/>topicPath + wikiType +<br/>summary + metadata]
从 3 次调用到 1 次
这是成本和质量双重提升的关键决策。
之前:每 URL 3 次独立 AI 调用:
classify-topic.txt → topicPath
classify-type.txt → wikiType (repo_eval / deep_dive / inbox)
summarize-text.txt → summary (自由文本)
问题:3 次调用成本高(3× token),且 topic 和 type 的输出可能矛盾(比如 topic 选了 "golang-conc" 但 type 返回了 "deep_dive",实际上 goclang-conc 是一个 golang 代码仓库分类)。
现在:合并为 1 次调用,prompt 要求输出包含所有字段的完整 JSON:
classify-json.txt → {
"topicPath": "...",
"wikiType": "repo_eval|deep_dive|inbox",
"contentType": "text|video|audio",
"summary": {
"overview": "...",
"keyPoints": ["...", "..."],
"actionableAdvice": ["..."],
"worthNoting": "..."
},
"metadata": {
"contentType": "text|media|repo",
"tags": ["tag1", "tag2"],
"quality": "4/5",
"author": "...",
"uncertainties": "...",
"duration": "15:23(仅media)",
"transcriptQuality": "good(仅media)",
"verdict": "watch(仅repo)",
"stars": 2564(仅repo),
"language": "Python(仅repo)"
},
"confidence": 0.0,
"needsManualReview": false
}
AI 呼叫数减少 66% (3 → 1),且 topic、type、summary、metadata 在同一次推理中做一致性决策,不会出现矛盾。
候选白名单
这是分类质量的最关键措施。ghindex/topic_catalog.go 从 gh.yml 的完整配置树中提取所有 topic path:
ConfigRepos[] → TopicCatalog()
│
├── cfg.Topics → appendTopicCandidates(base, "gh:config")
├── cfg.Using.Topics → appendTopicCandidates(base, "gh:using")
└── cfg.Repos[] → appendRepoTopicCandidates()
├── repo.Topics → appendTopicCandidates(base+repoName, "gh:repo")
├── repo.SubRepos[]
├── repo.ReplacedRepos[]
└── repo.RelatedRepos[]
└── (递归)
canonicalTopicPath() 生成唯一 topicPath,去重存入 []TopicCandidate{Path, Display, Source}。最终约 80-120 个候选路径作为 {{.CandidateTree}} 嵌入 prompt。
Prompt 明确要求:"topicPath must exactly match one of the candidate paths;不要创造新 path"。代码层二次校验:candidatePathSet(candidates)[topicPath] 逐字匹配,不存在的路径直接进入 failure 而非静默创建目录。
JSON 解析容错机制
parseAIClassification() 有三层容错:
- Code fence 剥离:自动去除 LLM 经常额外输出的
```json和```包装 - JSON 对象定位:
strings.Index(raw, "{")~strings.LastIndex(raw, "}"),忽略前后的散装文本 - 非法转义修复:
repairInvalidJSONStringEscapes()逐个扫描 JSON 字符串,将非法转义序列(如\1.)自动修复(转义为\\\\1.或丢弃)
这个修复器是必要的——因为 content 中的 Markdown 编号 1. 在 JSON 字符串中不是合法转义,LLM 有时不会正确地加双反斜杠。
三级验证
AI 输出通过 JSON 解析后,走三层校验:
第一层:validateBasics — 字段基本约束
NeedsManualReview= true 直接拒绝(AI 自己判断到内容不可靠)Confidence < 0.45直接拒绝(AI 自评分太低)WikiType必须是枚举值之一(repo_eval/deep_dive/inbox)ContentType如果提供,必须是枚举值(text/video/audio)
第二层:validateTopic — 路径安全 + 候选合法性
ValidateRelativeWikiPath()检查路径安全(防止 path traversal)candidatePathSet(candidates)[topicPath]检查是否在白名单中
第三层:validateSummary — 摘要完整性
Summary不能为 nilrenderStructuredSummary()渲染后不能为空
Prompt 的约束工程
完整 prompt(classify-json.txt)的结构:
## 候选 topic path
(150+ 行候选列表,从 gh.yml 实时生成)
## 待处理条目
URL / 标题 / 内容类型
## 正文内容
(经 truncate 后的正文)
## 分类约束
wikiType 枚举定义、contentType 说明、metadata 字段说明
## 输出要求
完整 JSON schema + 显式枚举值 + 条件约束
关键约束点:
- "不要为了提高归档率而选择泛化、牵强或仅有弱关联的 topic" —— 控制 false positive
- "topicPath must exactly match one of the candidate paths;不要创造新 path"
- "所有字符串值必须是合法 JSON string;不要把 Markdown 编号写成
\1.、\2.、\3.这类非法 JSON 转义" - 条件字段标注:
verdict仅 repo 类型、duration仅 media 类型
4. Archive 层 —— 并发安全的写入模式
核心问题:多个 URL 可能归入同一个 topic,如果每个 goroutine 独立写 summary.md,后写的会覆盖先写的。
架构
sequenceDiagram
participant Main as Main Goroutine
participant Pool as errgroup Pool (N concurrent)
participant File as File System
Main->>Main: ParseInbox → URL entries
Main->>Main: g.SetLimit(concurrency=5)
rect rgb(240, 248, 255)
Note over Pool: Phase 1: Concurrent Prepare
par entry 0 (goroutine)
Pool->>Pool: prepareInboxEntry()
Note over Pool: HTTP fetch + AI classify<br/>per-URL timeout: 180s<br/>retry: 3 attempts, backoff
and entry 1
Pool->>Pool: prepareInboxEntry()
and entry N
Pool->>Pool: prepareInboxEntry()
end
end
Pool-->>Main: pendingURLWrite slice<br/>(收集结果,不写文件)
Note over Main: errgroup.Wait()<br/>— 障碍点 —
rect rgb(255, 248, 240)
Note over Main: Phase 2: Sequential Commit
loop For each entry in inbox order
Main->>File: writePendingURL(entry[i])
Note over Main: lockPath(summaryPath)<br/>sync.Mutex per path
File-->>Main: ✅ / ❌
Note over Main: unlockPath()<br/>defer unlock
end
end
Main->>File: FlushInbox<br/>精确移除已处理 URL
两阶段模型
这是最核心的架构决策。将每条 URL 的处理拆为两个阶段:
| 阶段 | 函数 | 操作 | 执行方式 |
|---|---|---|---|
| Phase 1: prepare | prepareInboxEntry |
HTTP fetch + AI classify | 并发 — errgroup pool, g.SetLimit(N) |
| Phase 2: write | writePendingURL |
写 summary.md / 写 failed 文件 | 顺序 — 单 goroutine 循环 |
为什么不能直接在 goroutine 里写?
同一 topic 的两个 URL 同时处理:
goroutine A: ReadFile summary.md → append entry A → AtomicWrite (OK)
goroutine B: ReadFile summary.md → append entry B → AtomicWrite (overwrites A!)
AtomicWrite 是文件级别的原子操作,但读 → 改 → 写不是原子序列。后完成的 goroutine 会覆盖先完成的成果。
为什么不是 per-goroutine mutex?
同一 topic 的多个 URL 可能分布在不同 goroutine 中(goroutine 0 处理 topic=X 的 URL 5,goroutine 3 也处理 topic=X 的 URL 12),goroutine 之间不共享一个 mutex。如果给每个 topic 路径分配一个全局 mutex(per-path lock 方案),那么需要在 goroutine 之间共享这些锁变量——而这正是 lockPath() 实现的。
双重防护:
- 架构层:所有并发 goroutine 只做 fetch + classify,不写文件。写文件是顺序执行的,从根本上消除竞争。
- 锁层:
WriteSummary和WriteFailureEntry内部各有一个lockPath(path)调用(sync.Mutexper absolute path),防止未来如果再有其他代码路径绕过了两阶段模型、直接调用写函数时的竞争。
Per-path Lock 实现
var pathLocks = struct {
locks map [string ]* sync.Mutex
mu sync.Mutex
}{locks : make (map [string ]* sync.Mutex )}
func lockPath (path string ) func () {
key , _ := filepath .Abs (path )
pathLocks .mu .Lock ()
mu := pathLocks .locks [key ]
if mu == nil {
mu = & sync.Mutex {}
pathLocks .locks [key ] = mu
}
pathLocks .mu .Unlock ()
mu .Lock ()
return mu .Unlock // 返回解锁函数,defer 调用
}
关键设计:返回 func() 作为解锁闭包,调用方 defer lockPath(path)() 即可,保证成对加解锁。
summary.md 格式
每个 topic 目录下的 summary.md 维护 YAML frontmatter + 多日期区间体:
---
title : golang-conc
date : "2026-06-12"
source : rss2nl-wiki
batch_id : wiki-2026-06-12
total_urls : 5
succeeded : 3
failed : 2
---
## 2026-06-12
### 文章标题
```markdown
URL: https://example.com/article
Type: deep_dive
tags: go, concurrency
quality: 4/5
author: John Doe
body content...
另一个条目
...
`WriteSummary` 不是覆盖写,而是读现有的 frontmatter、增加计数器、定位到正确的日期区间插入新条目、再写回。用到 `appendEntryBody` 来维护日期区间的顺序。
### 失败分类
不再全部写到一个文件,而是按失败阶段分类:
| 失败类型 | 语义 | 审计文件 |
|---------|------|---------|
| `fetch` | 网络级错误(DNS 解析失败、连接超时) | `fetch-failed.md` |
| `resolve` | HTTP 级错误(403 反爬、Access Denied),opencli 也拿不到 | `resolve-failed.md` |
| `extract` | HTTP 200 但内容不可用(登录壳、JS 空壳、quality gate 判定不通过) | `extract-failed.md` |
| `classify` | 内容可读但 AI 无法分类(confidence 不足、无匹配 topic、JSON 解析失败) | `group-failed.md` |
fetch vs resolve 的区分逻辑在 `fetchHTTPPage` 末尾:`isHTTPBlockError(err)` 检查 error 是否包含 "HTTP " 字符串——是则说明是 HTTP 状态码错误(403/404),归为 resolve;否则是网络层错误(超时/DNS),归为 fetch。
### Inbox Flush 精确移除
Inbox 清洗不再是按行索引删除整行。新实现(`service/wiki/write.go:FlushInbox`):
1. `parseInboxLineURLRefs()` 解析行内所有 URL 引用及其 `[start, end)` 位置索引(同时处理 Markdown 链接 `[text](url)` 和裸 URL)
2. `normalizedURLSet(handledURLsByLine[i])` 构建本行已处理 URL 的规范化集合
3. `flushInboxLine()` 只移除已处理的 URL,保留未处理的部分(slice `line[:ref.Start] + line[ref.End:]`)
4. 整行为空时才删除该行
这样,一行有两个 URL 的场景下,一个成功、一个失败时,失败 URL 保留,不会被误删。
---
## 5. Audit 层 —— 事后校验
Audit 子系统是 pipeline 之外的校验层,检测 wiki 内容的历史污染。
### 扫描方式
- `AuditWiki(wikiRoot)` — WalkDir 整个 wiki,全量扫描
- `AuditWikiPaths(wikiRoot, paths)` — 只扫描指定路径,配合 ChangedOnly 模式使用
- ChangedOnly 模式通过 `changedWikiMarkdownPaths()` 用 git 命令定位变更文件:
git diff --name-only --cached (staged changes) git diff --name-only (unstaged changes) git ls-files --others --exclude-standard (untracked files)
### 检查项
对每个 Markdown 文件做规格检查:
- **summary.md 检查**:section heading 是否使用了规范化标题(`概述`/`关键要点`/`可执行建议`/`值得关注`),codeblock 字段名是否合法
- **失败文件检查**:failure 文件中的 URL 格式是否正常,是否有截断/编码问题
- **General 检查**:文件读取错误、walk 错误
---
## 6. 配置与参数
通过 `data-cli` 和 `internal/wikiingest/wiki.go` 的 `WikiConfig` 控制全部参数:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `wikiRoot` | `wiki` | wiki 根目录 |
| `ghTopicsURL` | `https://cdn.lucc.dev/gh.yml` | 远端 topic 候选来源 |
| `ghTopicsMaxAge` | `24h` | 远端 gh.yml 缓存 TTL |
| `concurrency` | `5` | 并行 goroutine 数量 |
| `perURLTimeout` | `180s` | 单 URL 处理超时(含重试) |
| `maxRetries` | `3` | 失败重试次数 |
| `opencliFallback` | `true` | 是否启用 opencli 浏览器兜底 |
| `media.enabled` | `true` | 是否启用视频/音频抓取 |
| `media.subtitleCLIPath` | `yt-dlp` | 字幕提取 CLI 路径 |
| `media.subtitleLangs` | (自动) | 字幕语言优先级列表 |
Retry 行为:
```go
retry.Do(
func() error { ... },
retry.Attempts(maxRetries), // 默认 3
retry.Delay(5 * time.Second), // 固定 5s
retry.DelayType(BackOffDelay), // 回退模式
retry.RetryIf(func(err error) bool {
// 只重试 fetch 层错误,不重试 classify 错误
return errors.As(err, &fetchFailureError{})
}),
)
7. 实跑结果
85 条真实 inbox(来自 RSS + 书签 + 社交分享):
| 指标 | 数值 |
|---|---|
| 总计 | 85 |
| ✅ 成功写入 | 50 |
| ❌ Handled failure | 35 |
| ❌ Unhandled error | 0 |
失败分布:
| 类型 | 数量 | 典型原因 |
|---|---|---|
| extract | 12 | 登录壳、导航壳、错误页被 quality gate 判定拦截 |
| classify | 10 | 视频无字幕、内容过短、confidence 不足 |
| fetch | 8 | 网络超时、DNS 解析失败 |
| resolve | 5 | 403 反爬(opencli 也拿不到) |
关键指标:0 条泄漏,没有一条污染 wiki 的错误内容写入了 summary。分类错误页在 prompt 层(quality gate 判定)被拦截,拓扑路径不在白名单的在 validate 层被拦截,confidence 不足的在 validateBasics 层被降级。
8. 总结
这条管道的不同之处在于,AI 不是 pipeline 的终点,而只是 extract 层中的一个组件。真正的架构重心在:
-
Scrape 层的三层降级 + 五层质量判定 —— 用 engineering 解决"网页不可靠"问题,而不是依赖 AI 去判断。质量判定(空内容检测 → 关键词匹配 → 社交壳检测 → 长度门禁 → 链接密度分析)在所有 AI 调用之前拦截低质量内容。
-
Extract 层的前置白名单 + 三级验证 —— 不给 AI 自由发挥的空间,从 prompt 约束到 JSON schema 校验到 candidate path 逐字匹配到 confidence 阈值,全程对 AI 输出设防。
-
Archive 层的两阶段模型 —— 把"并发抓取"和"顺序写入"拆开,从架构层面消除竞争。per-path lock 提供额外的防御深度。
-
失败分类 —— 不吞错误、不混为一谈。fetch/resolve/extract/classify 四类失败各自有独立的审计文件,便于回溯排查。