2026-05-19 实战:1 小时从"WebFetch 超时"摸索到完整 pipeline,跑通 1135 + 2530 + 9448 三阶段。 5 phase 脚本在
~/kb/sources/reference/36kr-pitchhub-2026/_scraper/,resumable。
心法
SPA 站点抓数据的标准三连:
- 找 SSR 注入(列表页空 → 跳过,详情页 fat → 直接 curl 拆
window.__*__) - 找 JSON API(没 SSR 时,反向
bundle.web.js里的fetch("gateway", "/path")调用,看 chunk 里的请求包装函数) - 找 cookie / signature(filter 报 NPE 或 422 = 缺包装字段或 cookie session)
反爬不一定全站统一。本案最大的坑:36kr.com/p/<id> 触发 captcha,但 www.36kr.com/p/<id> 完全 open。同一份内容,加个 www. 子域就放行。
API 反向工程的 5 步
Step 1: 看主 HTML 有没有 SSR 数据
curl -sL -A "Mozilla/5.0" "https://pitchhub.36kr.com/investevent?financingTimeList[0]=2026" | \
grep -oE 'window\.__[A-Z_]+__\s*=' | sort -u
输出 window.__INIT_PROPS__、window.__INIT_STORE__ 等注入点。用 bracket matching 提完整 JSON(不能用 regex,JSON 内部嵌套花括号会断):
def extract_init_props(html):
marker = "window.__INIT_PROPS__ = "
start = html.find(marker) + len(marker)
depth = 0; in_str = False; esc = False
for i in range(start, len(html)):
c = html[i]
if esc: esc = False; continue
if c == "\\": esc = True; continue
if c == '"': in_str = not in_str; continue
if in_str: continue
if c == "{": depth += 1
elif c == "}":
depth -= 1
if depth == 0: return json.loads(html[start:i+1])
列表页:__INIT_PROPS__ 只有布局配置,无数据 → SPA + 异步 fetch
详情页:__INIT_PROPS__ 包含 projectData / krReportData / mentionData / industryData / projectRecomData 全量数据 ← 直接 curl 就够
Step 2: 反向 JS bundle 找 API 路径
下载页面引用的 chunk:
curl -sL "https://static.36krcdn.com/36kr-pitchhub/web/bundle.web.<hash>.js" -o bundle.js
curl -sL "https://static.36krcdn.com/36kr-pitchhub/web/entrypoints_financingLibrary_Pc.web.<hash>.js" -o finpc.js
在 chunk 里 grep fetch("gateway" 类调用:
grep -oE '"/[a-zA-Z][a-zA-Z0-9_/.-]{4,80}"' finpc.js | sort -u | grep -iE 'invest|financ|project'
# 输出:
# "/pms/project/financing/list" ← 这就是列表 API
# "/pms/investor/project/detail"
更精确:找 class 定义 class extends r.a { request() { return this.fetch("gateway","/pms/project/financing/list").post(this.params)... }} — 含路径 + 方法 + 参数。
Step 3: 反向请求包装格式
第一次 raw POST → 500 NPE。需要找 JS 里的 wrapper:
// 在 bundle.js 找到 partner_id 包装函数:
function u(t, r="deep", n="") {
let i = {
partner_id: n || "web",
timestamp: (new Date).getTime(),
partner_version: e.env.VERSION || "1.0.0",
param: t, // 用户参数嵌套在 param 字段
};
if (r === "deep") {
if (!t.siteId) i.param.siteId = 1;
if (!t.platformId) i.param.platformId = 2;
}
return i;
}
按这个格式发,还是 NPE。Server 端 SignFilter / partner 校验通过了,但 controller 内部 NPE — 缺什么不明。这时候不要瞎猜参数,直接走 Step 4 实测。
Step 4: Playwright 抓真实请求做对照
from playwright.sync_api import sync_playwright
events = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, proxy={"server": "http://127.0.0.1:7890"})
ctx = browser.new_context()
page = ctx.new_page()
def on_req(r):
if "gateway.36kr.com" in r.url:
events.append({"type":"REQ","url":r.url,"headers":dict(r.headers),"body":r.post_data})
def on_resp(r):
if "gateway.36kr.com" in r.url:
events.append({"type":"RESP","url":r.url,"status":r.status,"body":r.text()[:2000]})
page.on("request", on_req); page.on("response", on_resp)
page.goto("https://pitchhub.36kr.com/investevent", wait_until="networkidle")
看真实请求体 vs 我自造的 → 一模一样。差别在 cookie:Playwright 走过 list 页时,Tencent SLB 设了 SERVERID sticky cookie,后续 API 调用走同一台 gateway,controller 内部状态 OK。
解决:不在 curl 里手搓 cookie(SERVERID 短时效),改成 Playwright 走一次 list 页 + 用 page.evaluate("fetch(..., credentials:'include')") 让 fetch 自动带 cookie。
Step 5: 50 页硬上限破解(industry 分桶)
{"pageNo":"1","pageSize":20,"financingTimeList":["2026"],...}
# totalCount=1136 totalPage=50 pageSize=20
# pageNo>50 → "param.project.FinancingListReqForm.pageNo.Range"(422)
# pageSize>20 → "param.project.FinancingListReqForm.pageSize.Range"
API 硬限 1000 条。Workaround:按 industryIdList 分桶,每桶都 < 1000:
# 暴力探 industryId 1-27,每个都返非空即有效
for iid in range(1, 28):
body = {"param":{"industryIdList":[iid], "financingTimeList":["2026"], ...}}
# 各 industry totalCount: 1=19, 6=106, 9=210, 10=355(最大,但仍 <1000)...
Union 26 个有效 industry bucket + overseas bucket(ifOverseas:1,5 条) → 实拿 1135 / 官方 1136(industry 14 解包错误丢 1)。
Captcha 子域差异(本案最大坑)
| URL | 结果 |
|---|---|
https://36kr.com/p/<id>(裸主域) |
TTGCaptcha 滑块,即使 Playwright 也卡(headless 检测) |
https://www.36kr.com/p/<id> |
完全 open,SSR window.initialState 直接 fetch |
https://m.36kr.com/p/<id> |
同样 open(文章页)— 但 /newsflashes/<id> 触发 captcha |
https://www.36kr.com/newsflashes/<id> |
open(快讯页只 www. 路径不触发) |
判断不要靠直觉,每条都 curl 验证。captcha 的内容:<script>window.TTGCaptcha.init(...)</script>,字节跳动 lf-cdn-tos.bytescm.com/obj/static/sec_sdk_build/3.3.4/captcha/index.js。
抓取架构(混合 Playwright + curl)
graph LR
A[Playwright<br>open list] -->|SERVERID cookie<br>+ fetch evaluate| B[API list 1135 条]
B --> C[curl<br>project detail<br>SSR fast]
C --> D[curl<br>www. article<br>SSR fast]
D --> E[curl<br>image binary<br>concurrent]
classDef pw fill:#e3f2fd,stroke:#1976d2
classDef cu fill:#f1f8e9,stroke:#558b2f
class A pw
class B,C,D,E cu
Playwright 只做 1 件事:第一次访问 list 页,拿 SERVERID cookie + 调 list API。 其余全 curl(并行 8-16 线程):因为 detail/article 都是 SSR,直接 curl 比 Playwright 快 5-10 倍。
Phase pipeline:
| Phase | 工具 | 并发 | 输出 |
|---|---|---|---|
| 1: list | Playwright | 串行 50 次 API | _all_events.json 1000 条 |
| 1b: 分桶补缺 | Playwright | 串行 27 次 industry | 补 135 条到 1135 |
| 2: detail | curl + ThreadPool | 8 | events/<pid>/detail.json |
| 3: article | curl + ThreadPool | 10 | events/<pid>/articles/*.{md,json} |
| 3b: 共享软链 | 纯 Python | — | 280 个跨项目 symlink |
| 4: summarize | 纯 Python | — | 1041 summary.md + 顶层 INDEX |
| 5: image | curl + ThreadPool | 16 | _images/<sha1>.<ext> 9448 张 |
重要细节
detail.json 字段反向解析
projectData 是 nested dict,有些字段是 {name, route} 包装而不是裸字符串:
| 字段 | 看似 | 实际 |
|---|---|---|
industryList[] |
["先进制造"] |
[{"name":"先进制造","route":"list_project?..."}] |
currentFinancing |
{round, money} |
{"name":"B轮","route":"list_project?..."}(无 money) |
establishTime |
"2024-08-21" |
{"name":"2024年01月"} |
business |
字符串 | dict(legalPersonName / regLocation / shareholder 等工商数据) |
financingList[].vc |
字符串 | 字符串(以"、"分隔)或 [{name, id}] 投资方列表 |
通用 names_of() 拆包:
def names_of(lst):
out = []
for x in (lst or []):
if isinstance(x, dict): n = x.get("name") or x.get("tagName") or ""; n and out.append(n)
elif isinstance(x, str): out.append(x)
return out
article SSR 路径
initialState.articleDetail.articleDetailData.data.widgetContent ← 全文 HTML
.widgetTitle
.publishTime
.widgetSummary
.articleRecommendData.authorName
.nextItem
newsflash 用同 path,只是 widgetContent 较短。
共享报道用软链(140 跨项目)
一篇 36 氪报道常覆盖多个项目(如某行业分析提 5 家公司)。抓取时用 (itemId, itemType) 去重(实抓 2530),保存在第一个引用项目的 articles/ 下。
Phase 3b 扫所有 articles_index.json,为其他引用项目创建相对 symlink:
target = events/<其他pid>/articles/<itemId>-p.md
src = events/<canonical pid>/articles/<itemId>-p.md
target.symlink_to(Path("..") / ".." / canonical_pid / "articles" / fname)
git 跟踪 symlink 没问题(只存目标路径,不复制内容)。
图片去重 + md 改写
每张图按 URL sha1 命名(扩展从 URL format,jpg 推),9590 唯一 URL → 9448 成功。
Phase 5 改写:
md_text = md_text.replace(f"", f"")
注意:别在 inbox 阶段改写(目录还要 mv),改写后 path 就锁死了。本案 phase5 是在 mv 之前跑的,改完恰好 ../../../_images/ 路径在 sources 下也对(因为 events/<pid>/articles/ → events → root → _images,3 层上)。
反 pattern
- ❌ 每篇文章都用 Playwright:慢 5-10 倍,实际只有 list 页需要 Playwright + cookie warmup
- ❌ 猜 captcha bypass:浪费时间;先试 www. / m. / 不同 UA / Googlebot,90% 概率有一个 open
- ❌ WebFetch 抓 SPA:WebFetch 不跑 JS,SPA 列表页一定是空的,直接跳过
- ❌ 手动维护 cookie 字符串:cookie 短时效,跑 1 小时就失效;Playwright
credentials:'include'自动续 - ❌ 没看 API 错误码就改包:
partner_id 不可以为空≠ NPE,前者缺包装、后者缺 cookie/状态,药方完全不同 - ❌ 凭单页详情判断字段类型:不同项目的同字段可能 nested 也可能裸字符串(industryList、currentFinancing 都遇到),写改写代码先用 names_of() 类的 polymorphic 拆包
安全/合规边界
- 36 氪 pitchhub 数据公开可查,无登录墙
- 本项目用本地代理(clash 7890),不走专用爬虫 IP,模拟正常浏览器节奏(并发 8-16)
- 抓取频率温和(总耗时 ~30 分钟跑完 1.5 GB),未触发任何额外限流
- 用途:个人 kb 长期档案,不分发给第三方,不商用
重跑 / 维护
详见 ~/kb/sources/reference/36kr-pitchhub-2026/_index.md "重跑 pipeline" 节。
下次抓 2027 数据时,只改 financingTimeList:["2027"] + industry totals 重测(可能新增 industry)即可。
2026-05-19 实测,1 小时摸索 + 30 分钟完整 pipeline 跑通,1.5 GB 数据。
2026-09-26 补充:2025 批次的成功率必须按内容核验
本次只整理已有材料,没有重新联网抓取。2025 目录有 2973 条列表记录、2514 个项目 ID,但只有 42 份项目摘要;135 个文章路径中 39 个快讯实际为空,96 个正文路径又包含一份跨目录重复。phase3_failed=0 因而不能证明全文完整。元数据的目标 3002 与实际 2973 的差额 29,也不能等价为已定位的 29 个缺失事件。
验收需要分别记录列表覆盖、详情覆盖、正文非空、去重后正文、图片及外链读取状态。失败日志含重试,应按实体 ID 归并;正文要检查标题和正文,不只看 HTTP 成功或文件存在。跨批比较时可先规范图片地址再比正文,避免因绝对路径改成相对路径而误判为新文章。
关联报道可能属于投资方、被投企业或周报中的一句提及。目录年份只代表融资列表筛选,不代表文章年份;列表条数更不代表全文篇数。53 篇新增唯一 Markdown 已分批全文阅读并写 digest;其中图片表格尚未逐行识别,不能据此宣称相应图片明细已消化。参见 2025 证据边界与案例 与 原始列表 (本地参考资料)。
旧归档也要验证消化状态
本轮对 42 篇已有 sources 副本重新读取,连同 53 篇新增唯一正文,共形成 95 份逐篇 digest。已有文件不能证明已进入 wiki;检查要分别记录“归档存在”“正文已读”“机制进入 wiki”。96 个正文路径包含一处同文重复,数量应去重。这里的全文覆盖仅指已保存的 Markdown,图片与外链仍需单列。
关联目录也不保证主体、年份与文章类型匹配。2025 列表能连到 2020—2026 年报道、招聘和股市晚报;融资额、估值、合同额、确认收入及 ARR 不可共用同一金额栏。空壳与索引保持相应状态,不通过写一篇综述把它们改标为已消化全文。详见 2025 列表与关联案例。