分类:项目实战 · 标签:GitHub Actions Python 大模型 数据管道 爬虫
适用读者:想用 GitHub Actions 做长期数据采集、又不想买服务器的人;正在设计「抓取、去重、渲染、LLM 归纳」流水线的人;好奇免费额度下怎样把一次分析拆成多天跑完的人 结论先行:InterviewAtlas 的难点不在写爬虫,而在三件事——把增量判断做在请求之前、把落盘做成幂等、把模型调用做成可预算可缓存的两阶段流水线。真正拖住它的也不是反爬,而是两处静默失败:采集端吞掉异常,分析端被 Python 3.11 的 f-string 限制挡在编译期。 阅读时间:约 28 分钟 | 难度:进阶 | 收获:一条零服务器数据流水线的完整拆解、十余个真实故障的现象与代价、一套可照搬的增量、去重与预算设计目录
data/interviews 下有 158 个 .json.gz(35.9 MB),interviews/ 下有 1,791 个 Markdown(92.3 MB);源码 22 个 Python 文件,运行期依赖只有 requests、bs4、lxml、PyYAML 四个。下文不复述功能清单,只讲实现思路和真实踩过的坑。
src/main.py 里没有采集逻辑,它只做编排:按配置顺序调用各数据源,把结果交给归一化、存储、渲染,最后打印本次新增条数与耗时。真正的代码分布在两层:
src/main.py 编排与计时
├─ sources/nowcoder.py 牛客:搜索 + 详情
├─ sources/mianshiya.py 面试鸭:题库 → 题目
├─ sources/juejin.py 掘金:深分页 + 正文
├─ normalize.py 公司/岗位识别、正文清洗、相关性判断
├─ storage.py 去重合并、按公司分片落盘
└─ render.py README 与分文件 Markdown
src/analyze.py 独立进程:读落盘数据,调 GitHub Models
analysis/ 并提交,和采集数据一样是仓库状态,不该依赖一次运行的内存。
不用数据库也是同一个思路。Actions 的 runner 是一次性的,状态只能落在 git 里;41,771 条记录全量读进内存也远没到需要索引的程度。这个选择的代价会在「写放大」一节展开。
编排层还承担一个容易被忽略的职责:把每个源的耗时单独打出来。日更日志里能看到牛客、面试鸭、掘金各自的条数与秒数,这既是性能观测,也是故障线索。面试鸭只有 1 秒,与另外两个源十几分钟的耗时形成刺眼对比,这个对比本身就是异常信号;如果只看总时长,少掉的这段时间混在三十分钟里,很难察觉。
| 字段 | 含义 | 用途 |
|---|---|---|
id |
URL 摘要或来源加外部 ID | 稳定标识 |
source |
nowcoder、mianshiya、juejin | 来源统计与展示 |
external_id |
站点内部 ID | 面试鸭跨题库合并 |
title / url |
标题与原始链接 | 展示、去重键 |
company / position |
归一化后的公司与岗位 | 分片、分组分析 |
content |
清洗后的正文 | 去重、喂模型 |
published_at |
发布日期 | 样本「最新一半」排序 |
kind |
面经、面试题、题库 | 区分内容形态 |
first_seen / last_seen |
首次与最近出现 | 保留期裁剪 |
COMPANY_ALIASES 按字符串长度从长到短匹配,避免「阿里」抢在「阿里云」前面命中;第二层是岗位,按预设顺序识别后端、前端、算法、测试、产品等,识别不出就落到「其他岗位」;第三层是兜底,公司和岗位都归到「其他公司」「其他岗位」,这两类不进入 LLM 分析,因为它们无法支撑有意义的归纳。最终 157 家公司、1,634 个公司岗位组合,就是这三层过滤后的结果。
别名表看着笨,却是整个数据集可用的前提。同一家公司在三个站点会写成「字节」「字节跳动」「bytedance」,不统一就只能得到三份各自很薄的统计;而「阿里云」和「阿里巴巴」又必须分开,否则阿里云的岗位会被集团总部的面经稀释。按长度倒序匹配解决的正是这类包含关系:先试长别名,命中就停,短别名只在前者都不匹配时才有机会。
| 来源 | 入口 | 翻页上限 | 正文来源 |
|---|---|---|---|
| 牛客 | POST gw-c.nowcoder.com/api/sparta/pc/search |
单关键词 3 页 × 20 条 | 搜索接口截断,详情接口补全 |
| 掘金 | search_api 的 cursor 深分页 |
可一直翻到没有下一页 | 搜索接口里的 web_html_content |
| 面试鸭 | 题库列表 → 题目列表 | 单查询 5 页硬上限 | 题干 + 题库描述 |
web_html_content 是一段转义过的字符串,引号、换行、反斜杠都套了一层,标准 JSON 解码拿不到可直接用的 HTML。代码里为此写了一个手写的反转义流程,按转义序列逐段还原,再交给统一的清洗函数。这一步没有现成库可用,因为字符串里的转义和 JSON 自身的转义叠在一起,直接再解一次会把正文里的合法反斜杠吃掉;只能按平台输出的固定形态逐段处理,属于「知道它长什么样才敢写」的代码。
面试鸭是另一种形态:先遍历题库列表,再对每个题库展开题目。题目接口有一个硬限制——同一个查询最多只能翻 5 页。为了多拿数据,采集器对每个题库跑三个变体:不带筛选、difficulty:1、difficulty:3,用难度分组把「单查询 5 页」变成「三组各 5 页」。代价是请求数变成三倍,而且三组结果之间仍有重叠,需要在本地按题目 ID 去重。这个绕法也只缓解了上限,没有真正解决它。
反爬策略是统一的:Chrome/124 的 User-Agent、按站点设置 Referer、Retry(total=3, backoff_factor=1.0, status_forcelist=[429, 5xx])、全局请求间隔 0.5 秒。没有用浏览器,也没有处理登录态,因此只覆盖公开可见的内容;这是能力边界,也是不被封的前提。
load_records() 把历史数据全量读回来,构造两个集合:known_urls 和 known_content(正文长度达到 300 字才进正文集合)。
采集器拿到一条搜索结果时,先算规范化 URL。命中 known_urls 就直接跳过,连详情请求都不发;URL 没命中、但正文与历史某条完全一致,也跳过。这一步省掉的是最贵的详情请求,而不是搜索请求——搜索仍然要翻,但详情调用只落在新增或正文不完整的内容上。
合并阶段按 URL 合并,merge_records() 的行为是「旧记录为主、新记录补空」:标题、来源、公司、岗位缺失才用新值覆盖,正文只在新的更长时替换;last_seen 不是每天刷新,而是隔三十天才刷一次,避免所有分片因为一个时间戳字段日日重写。返回的 new_count 与 new_records 供 README 的「本次更新」区块使用。保留期 retention_days: 365,超过一年没再出现过的记录会被裁掉。
去重一共三层。第一层是 URL 规范化:去掉 # 锚点、去掉查询串、去掉尾部斜杠、统一小写,同一个帖子的分享链接、带跟踪参数的链接会收敛成同一个键。第二层是面试鸭的跨题库合并:同一道题可能被多个题库收录,按 external_id 合并,并优先保留能识别出公司的那个题库归属。第三层发生在喂模型之前:_dedupe() 按「标题 + 正文前 120 字」再筛一遍,因为不同来源可能转载同一篇面经,URL 不同但内容几乎一样,前两层拦不住。
def record_key(record: dict) -> str:
url = (record.get("url") or "").split("#")[0].split("?")[0].rstrip("/").lower()
if url:
return url
source = record.get("source") or ""
external_id = record.get("external_id") or ""
return f"{source}:{external_id}" if (source or external_id) else (record.get("id") or "")
.json.gz,而不是一个全量 JSON。原因很现实:GitHub 对单文件有 100 MB 上限,41,771 条记录塞进一个文件迟早会顶到线;分片后每个文件都很小,_meta.json 只存总数与公司数。load_records() 同时兼容旧的单文件格式和新的目录格式,读取时优先选 .json.gz,避免迁移期同一公司被读两遍。
写入做了两层幂等。第一层是 _write_if_changed():内容相同就不落盘,不产生新的 git 变更。第二层是 gzip.compress(raw, mtime=0):固定压缩头里的时间戳,保证「同样的内容」生成「同样的字节」,否则每次压缩出来的二进制都不同,git 每天都会认为所有分片被改过。
raw = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
data = gzip.compress(raw, mtime=0) # 同内容同字节,避免无意义 diff
if path.exists() and path.read_bytes() == data:
return
interviews/<公司>/<岗位>.md。README 是索引页,包含公司列表、岗位列表、总数、来源统计和「本次更新」;分文件是给人读的正文页。问题在于 README 是每天全量重排的:一条新记录可能改变公司排序、岗位计数和更新时间,于是整页都要重写。日更实测 README 的 diff 约 3,404 行,这个数字比数据本身的增量大得多。
写放大是这套仓库最贵的隐性成本。日更提交是 53,614 行新增、41,513 行删除;周更因为 --full 重抓,达到 541,773 行新增、372,746 行删除;首版一次提交就有 203 万行。仓库 348 MB,而当前快照只有约 128 MB,差额基本都是历史里被反复整文件重写的 blob。git 会压缩,但不会忘记。
值得强调的是,这些 diff 里真正的新信息很少。一天新增几百条面经,落到文件上可能只有几百行;剩下五万多行是排序位置变化、计数变化、时间戳变化导致的整段搬移。git 的 diff 算法会把它们记成删除加新增,历史因此越滚越大。幂等只能保证「内容没变就不写文件」,管不住「内容变了一点、整页重排」。
min_records: 20:一个公司岗位组合至少二十条面经才值得归纳,否则模型只能编。满足条件的组先做组级报告,再把组级报告汇总成岗位级报告,是典型的 map-reduce。
这里没有选「把所有面经一次性塞给模型」的省事做法。41,771 条正文按平均长度算远超任何模型的上下文,即便能塞下,成本和延迟也不可控。map-reduce 把问题拆成「每家公司每个岗位看八条」和「每个岗位看十六家公司的摘要」,两次输入都被压在可预算的范围内;代价是信息在两层截断中损耗,组级报告没写到的细节,岗位级永远拿不到。
组级 prompt 的输入是有选择的:公司、岗位、总条数、样本时间跨度,加最多 8 条样本。样本一半取正文最长的(信息密度高),一半取最新的(流程更贴近当下);每条截断到 1,200 字,整段样本不超过 16,000 字。prompt 强制四节标题,并要求材料不足时写「样本不足」,不许补全不存在的流程、薪资和轮次。
要求:
1. 只依据给定材料,不要编造公司流程、薪资、面试轮次或材料中没有出现的题目;材料不足时直接写「样本不足」。
2. 使用二级标题(##),依次输出以下四节:面试流程与轮次、高频考点、典型题目、难度与准备建议。
3. 「典型题目」用无序列表列出 5-10 条,尽量保留原题的表述。
temperature: 0.3、max_tokens: 1600。低温度让同一批材料两次运行的输出更接近,也方便缓存判断;1600 个 token 刚好够四节 400 到 800 字,也限制了模型跑题。截断函数 _clip() 按段落边界切,优先找换行、句号、叹号、问号,切完补一句「(后文略)」,避免把一句话拦腰截断。
质量兜底有两层。_missing_sections() 检查四个二级标题是否齐全;缺节就带着「必须严格包含这四个标题」的追加指令重试一次;重试后仍缺,不再重试,而是在正文前加一行注记,说明本次生成缺了哪几节,让读者知道这是降级产物,而不是完整报告。
岗位级是第二阶段:按岗位收集各公司的组级报告,每份截断到 700 字,最多纳入 16 家公司,少于 3 家就放弃这个岗位。它不再看原始面经,只看已经归纳过的摘要,这样既控制了输入长度,也避免第二阶段把同一批原文再读一遍。
样本选择还有一个兜底:按「最长一半 + 最新一半」取完之后,如果还不够 8 条,就从剩下的最长记录里继续补,直到上限。这样小样本组不会因为「最新的一半」里全是短帖而只喂进去两三条;同时补入顺序仍然是长度优先,信息密度不会掉得太厉害。8 条、1,200 字、16,000 字这三个上限不是拍脑袋:它们共同决定了单次 prompt 的 token 量,也决定了 100 次调用能覆盖多少个组。
analysis/manifest.json 记录 PROMPT_VERSION、模型名、每个组的条数与生成时间。PROMPT_VERSION 当前是 2026-10-29.1;只要提示词版本或模型名任一变化,整份缓存失效,全部重算。这个设计很粗暴,但正确:提示词改了,旧报告就不再代表当前口径,局部复用只会让仓库里同时存在两套标准。
单组的重算条件是 refresh_after_days: 30,或者条数增长达到 max(5, 10%)。前者防止材料过期,后者保证新增样本足够多时才值得再花一次调用;只有一两条新面经时,重算的收益低于成本。
预算是另一条硬约束。免费额度大约 15 RPM,配置里保守设成 rpm: 12、workers: 2、max_calls_per_run: 100。100 次调用意味着一天只能覆盖一部分组,剩下顺延到次日。为此有一个组预算分配:先给岗位级汇总留出 reserve = min(岗位数, max_calls // 6) 次调用,剩下的才分给公司组,避免公司组把额度吃光、岗位报告永远排不上。
失败处理按状态码分流。429 先看 x-ratelimit-remaining,为 0 说明当日额度用尽,抛 QuotaExceeded 立即停整轮,不再做无谓重试;不为 0 则读 Retry-After,最多等 60 秒再试。403 且响应体含 rate limit,同样按额度耗尽处理;401、403 的其他情况按权限错误处理,提示检查 models: read 权限。5xx 走指数退避,最多三次。这套分流的意义是把「等一等还能好」和「今天别再试了」区分开。
缓存判断发生在调用之前,顺序是:先读 manifest,比提示词版本与模型名;再逐组比条数与生成日期;只有两项都满足复用条件才跳过。这个顺序保证「版本变了」时不会因为条数没变而误用旧报告。manifest 同时是观测面:它记录了哪些组分析过、用的什么模型、什么时候生成的,出问题时不用去翻 Actions 日志。
daily.yml 的 cron 是 0 1 * * *,即北京时间 09:00,timeout-minutes: 60;周更 weekly-full.yml 在周日 10:00 跑,带 --full --limit 150,超时 180 分钟。--full 打开更多翻页与更多公司查询模板,--limit 把每个来源的详情补全数量压住,避免周更把额度一次打空。
权限只有两项:contents: write 用于提交,models: read 用于 GitHub Models。凭据只用仓库自带的 GITHUB_TOKEN,不额外配 secret,这样 fork 和 PR 都不会拿到写权限。并发用 group: interview-crawl 加 cancel-in-progress: false,日更和周更撞车时排队而不是互相取消——采集是幂等的,但中途取消会留下半套数据。
permissions:
contents: write
models: read
concurrency:
group: interview-crawl
cancel-in-progress: false
git status --porcelain -- README.md data/ interviews/ analysis/,没有变更就 exit 0;有变更才 git add 这四类路径并提交。注意它没有用 git add -A,避免把临时文件、日志、本地配置带进仓库。分析步骤标了 continue-on-error: true,理由是额度或权限异常不该阻断已采数据的提交——这个决定本身合理,但它后来成了第二起事故的放大器。
步骤顺序也有讲究:先采集、再分析、最后统一提交。这样即使分析失败,采集结果仍然会被提交;如果反过来,分析失败可能让整次运行提前结束,连数据一起丢。代价是分析和采集共用同一个 job,分析的实际耗时挤在采集之后,60 分钟的作业超时对两者是共享的。日更目前 30 分钟出头,余量够,但不是一个可以无限加源的结构。
面试鸭共 0 个题库,开始展开题目 和 面试鸭完成:0 个题库共 0 条记录。从提交 116976f8 之后,这条日志每天都出现,连续多日。更迷惑的是整条流水线是绿色的:没有失败步骤,没有告警,README 照常更新,来源统计里面试鸭仍然显示 1,962 条。
排查是从「不像」开始的:一次正常的全量题库遍历不可能在 1 秒内结束,而日更总时长仍然有三十分钟,说明另外两个源在正常工作,只有面试鸭这一段空转。向上翻历史日志,发现同样的两行从某个提交之后每天重复;用 commit hash 去查,落点在面试鸭相关的改动范围内。再对照代码路径,请求失败、捕获、break、返回空列表,链条完整,但没有任何一处会让作业失败。
MianshiyaSource.fetch() 对题库列表的请求包在 try/except Exception 里,捕获后只写一条 warning 然后 break,函数继续往下走,banks 为空,fetch() 返回空列表。上游 main.py 把「返回空列表」当成正常结果,继续合并、渲染、提交。历史数据不会被删除,于是 README 的来源统计继续沿用旧的 1,962 条,页面上完全看不出这个源已经停更。
真正的问题是错误语义:采集器用「空结果」同时表达了两种完全不同的状态——「这个源今天确实没有新内容」和「这个源坏了」。前者应该成功,后者应该失败,但代码把它们压成了同一个返回值。再加上 except Exception 把 HTTP 错误、解析错误、结构变化全都吃掉,日志里只剩一行 warning,运行层面没有任何断言去检查「题库数大于 0」这个最基本的健康条件。
break 也仍然在,所以现状是:面试鸭继续每天返回 0 条,历史数据继续留在仓库里,README 的数字看起来没变。这不是「已修复」,而是一个被记录下来的未修复缺陷。
应当落地的改法是三条。第一,采集器的异常不再吞掉:题库列表第一页失败就抛 SourceError,让 main.py 捕获并标记该源 failed,而不是返回空列表。第二,给每个源加最低产出断言,比如题库数大于 0、题目数大于 0,不满足就记为异常;「今天没新内容」应该是「请求成功但新增为 0」,而不是「请求失败所以为 0」。第三,README 增加每个源的最后成功时间与本次状态,让停更在页面上可见,而不是只藏在日志里。
--full 重跑补,而重跑的请求量和时间都远高于日更。更贵的代价是信任:一个每天绿色、数字不变的流水线,会让维护者和读者都以为一切正常。这类静默失败发现得越晚,回填成本越高,且中间这段时间的所有统计口径都带着一个已知的缺口。
analysis/README.md 长期显示「已分析 0/237」,analysis/ 下没有任何公司报告。Actions 里名为 Analyze with GitHub Models 的步骤,每次都以 0 秒、成功状态结束。237 是满足 min_records: 20 的组数,说明数据侧没有问题,问题在分析侧从来没真正启动过。
排查顺序是反的:先怀疑模型、再怀疑额度,最后才怀疑代码有没有跑。日志里没有一条模型请求记录,没有 401、没有 429、没有超时,连「跳过缓存」的日志都没有,这不符合任何调用失败的特征;0 秒的耗时也不符合「遍历 237 个组、逐个判断缓存」的预期。把同一份代码在本地跑 python -m src.analyze,才拿到真正的错误:SyntaxError,位置在 _dedupe() 的那行 f-string。错误不在运行期,而在导入期,所以所有运行期日志都不会出现。
src/analyze.py 的 _dedupe():
key = f"{title}|{re.sub(r'\s+', '', (record.get('content') or '')[:120])}"
r'\s+' 会让整个模块在编译期报 SyntaxError。workflow 固定 python-version: "3.11",所以 python -m src.analyze 连第一行业务代码都没执行就退出了。
为什么一直没被发现:分析步骤设了 continue-on-error: true,编译失败被当成「可忽略的分析异常」,作业仍然是绿色;0 秒的耗时又容易被当成「没有需要分析的组,快速返回」。本地如果用的是更新的 Python,同一行代码完全正常,环境差异把这个错误藏得更深。三个因素叠在一起,一个编译期错误伪装成了「分析任务正常但无结果」。
_dedupe() 里的写法还在,workflow 仍是 3.11,分析步骤仍标着 continue-on-error: true。修法有两条,任选其一即可:把 workflow 升到 3.12;或者把 re.sub 提到 f-string 外面先算好。
normalized = re.sub(r"\s+", "", (record.get("content") or "")[:120])
key = f"{title}|{normalized}"
continue-on-error 的初衷是「模型挂了也别丢数据」,这个诉求应当保留,只是不能让失败以「0 秒成功」的形式消失。更稳的做法是把分析拆成独立 job,失败时照常提交已有数据,同时让 job 标红并在 summary 里写明失败原因;或者在同一个 job 里读取 steps.analyze.outcome,失败时输出注解。核心是:可以允许失败,但不能允许失败不可见。
continue-on-error 包住的步骤,失败信号会沿着最不希望的方向衰减。
_is_truncated() 判断,把「正文少于 300 字且以省略号结尾」视为抓取失败并直接丢弃,这个启发式能挡住大部分残缺数据,但也会误伤真正短小、恰好以省略号结尾的帖子。
解决:对没见过的记录补一次详情请求,拿全文后再判断;已经抓过的记录用正文集合跳过,不重复请求。现状是这套机制已经落地,日更里 2,900 多次详情请求、18 分 46 秒,主要时间就花在这里。代价是「短而完整」的内容仍有被误删的可能,且丢弃没有单独计数,属于看不见的损失。
#、查询参数、尾斜杠等变体;面试鸭同一道题被多个题库收录。解决是前面说的三层去重:URL 规范化、跨题库按 external_id 合并、喂模型前按「标题 + 正文前 120 字」再筛。代价是规范化如果过激,可能把查询参数确实代表不同内容的页面合并掉;标题加开头 120 字的判据,也可能把两篇开头相同、后文不同的面经误判为一篇。
、零宽空格、连续空行,句子被 <br> 粘在一起。原因:三个站点返回的都是 HTML 片段或富文本,标签、实体、平台注入的不可见字符混在一起。解决是统一的 clean_content:先把 <br>、</p> 之类的换行标签替换成换行,再做 html.unescape,清掉 \u200b 和 \xa0,最后压缩空白。代价是清洗顺序很敏感:先反转义再删标签,会把原本只想显示的文字当成真标签删掉;清洗又发生在去重之前,清洗规则一改,正文哈希与去重结果都会变。
.json.gz,落到 158 个文件、35.9 MB;读取兼容旧的单文件,写入后删除不再存在的公司分片。代价是每次运行要打开 158 个文件,公司名 slug 冲突需要加摘要后缀,分片的增删逻辑本身也成了新的故障面。
max_calls_per_run: 100、rpm: 12、workers: 2,加 429 的 Retry-After 与 x-ratelimit-remaining 判断、403 限流直接抛 QuotaExceeded 停整轮,再用 reserve = min(岗位数, max_calls // 6) 给岗位汇总留额度。代价是完整分析要跨多天,且「先公司组、后岗位汇总」的顺序意味着新岗位的报告永远滞后;预算分配只能保证不饿死汇总,不能保证公平。
exclude_foreign_companies: true,采回来的内容大部分被过滤,性价比很低。原因:来源价值与目标不一致,继续跑只是浪费请求。解决是 prachub: enabled: false 加 exclude_sources: [prachub],下次运行按 dropped 把它的历史记录一并清理。代价是不可逆:历史数据删除后,重新启用只能从头抓,且旧链接可能已经失效。这个决定本质上是数据资产与运行成本的取舍,没有两全。
--limit 150 压住了详情补全数量。两条工作流的超时分别是 60 与 180 分钟,实测都留了一倍以上余量,主要风险不在时长,而在接口变更和额度。
从耗时结构能看出瓶颈在哪:牛客占了六成以上时间,其中绝大部分是 2,900 多次详情请求;掘金 3,333 篇正文抓取只用了十分钟出头,因为它的搜索接口一次就给全文,详情请求少得多。这也解释了为什么增量设计必须优先省详情请求:搜索翻页是固定成本,详情才是随新增条数线性增长的部分。
几个判断。第一,这套架构在「零服务器、免费额度、每天一次」的约束下是成立的:增量判断省掉了大部分详情请求,幂等落盘避免了无意义提交,两阶段分析把一次做不完的工作拆成了可续跑的任务。第二,它最脆弱的地方不是反爬,而是失败的表达方式:一个源坏了、一个步骤编译失败了,流水线都可以是绿的。第三,仓库体积会持续增长,README 全量重排和整文件重写的历史 blob 是主要来源,未来要么接受,要么把渲染改成增量。
如果要继续改进,优先级应该是:先修 analyze.py 的 f-string 与工作流版本,让分析真正跑起来;再给每个源加健康断言和最后成功时间;然后才是 robots.txt、掘金签名、mtime 这些正确性问题。采集量已经不是瓶颈,可见性才是。
continue-on-error 伪装成正常运行;截断、重复、脏字符说明数据清洗的每个启发式都有误伤;写放大说明 git 仓库的存储成本不在快照,而在历史。
这两个最严重的问题当前都还没有修完:面试鸭的接口未恢复、静默 break 仍在;分析侧的 f-string 与 3.11 组合仍在,0/237 不会自己变成 237/237。把修法写清楚,比声称已经解决更诚实。
如果重来一次,我会先写「失败可见」再写采集:每个源必须有独立的成功、失败、产出为零三种状态,每个后台步骤的退出码必须进入汇总,而不是被 continue-on-error 抹平。数据管道的大部分事故不是抓不到,而是抓不到的时候没人知道。
专题:项目实战 — 从零实现:桌面客户端、数据流水线与本地 Agent