← Knowledge Notes
Indie Building / Knowledge note · Chinese

36 氪 Pitchhub 抓取方法论

SPA 反向 API + cookie warmup + 分桶破限 + captcha 子域差异 + Playwright/curl 混合,抓 1135 条 2026 融资事件 + 2530 篇报道 + 9448 张图的完整 SOP

Source collection:跨来源主题整理 · Published here:2026-09-26 · Note updated:2026-05-19

网页抓取反向 API自动化采集

2026-05-19 实战:1 小时从"WebFetch 超时"摸索到完整 pipeline,跑通 1135 + 2530 + 9448 三阶段。 5 phase 脚本在 ~/kb/sources/reference/36kr-pitchhub-2026/_scraper/,resumable。

心法

SPA 站点抓数据的标准三连:

  1. 找 SSR 注入(列表页空 → 跳过,详情页 fat → 直接 curl 拆 window.__*__)
  2. 找 JSON API(没 SSR 时,反向 bundle.web.js 里的 fetch("gateway", "/path") 调用,看 chunk 里的请求包装函数)
  3. 找 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"![]({url})", f"![](../../../_images/{sha1}.{ext})")

注意:别在 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 列表与关联案例。

来源与关联资料