Skip to content

接入浏览器抓取 - #77

Merged
JunieXD merged 5 commits into
mousebaoyan:mainfrom
lym12321:browser-crawl
Nov 9, 2026
Merged

JunieXD merged 5 commits into
mousebaoyan:mainfrom
lym12321:browser-crawl

Conversation

@lym12321

@lym12321 lym12321 commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

关联

Closes #41

改了什么

需要执行脚本才能读到内容的来源,使用 Playwright Chromium 无头精简版抓取。每轮使用独立的无痕会话,列表、正文和附件下载共用 Cookie。

Browser 进入时启动 Chromium、读取 UA,启动和新建会话失败直接抛出;页面和附件访问失败记入抓取记录。Fetcher 管理 HTTP 请求和网站许可。一次页面访问持有许可到页面关闭,同时最多打开 2 个页面;跨网站跳转沿用起始网站的许可。列表解析已接入 PageSession,正文和附件文字解析仍由 #40 实现。

怎么验证的

  • crawler/./check.sh 通过:格式、lint、严格类型检查、生成合约一致性和 197 项测试。将浏览器路径指向未安装目录,确认测试不依赖 Chromium。
  • web/pnpm check 通过:类型检查、lint、格式检查和 60 项测试。
  • 真实 Chromium 配合本机合成页面,验证了启动和关闭、挑战刷新、渲染结果、UA、图片拦截、带 Cookie 下载附件、会话隔离和日志字段。
  • 手动抓取电科计算机学院的列表页和通知页,均返回 200;同一会话下载的附件为 581,642 字节,PDF 签名有效。

影响

  • MCP 工具、抓取节点 HTTP 接口和表结构:无变化。
  • 抓取节点需安装 Playwright 和 Chromium 无头精简版,命令见开发约定。crawl_source 接收共享的 Browser,列表和正文步骤接收本轮的 PageSession。

Copilot AI balanced review requested due to automatic review settings November 6, 2026 05:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@JunieXD JunieXD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢!浏览器抓取难度不小,你做得很扎实。我们用 PR 里的 PageSession 原样跑了一遍电科计算机学院:列表页和你举例的那条通知都拿到了 200,附件是 581,642 字节的 PDF,和你写的一致。./check.sh 的 77 项、pnpm check 的 42 项也都通过了。你手动试了 45 个网站,验证码和错误页的情况都考虑到了,很用心。

主要问题有两个:

  • PR 里加了 #41 以外的「附件下载失败上报」,改了接口、表和重抓规则;
  • 浏览器这部分用 CDP 自己拦截每一次文档请求,还手写了导航状态机,比 #41 要的复杂得多。

按下面改完,这个 PR 能小一大半。

方向:浏览器的一次页面访问算一个请求。打开页面前取得这个网站的许可,一直拿到页面关闭;页面里的脚本、样式这些子资源,照浏览器正常的方式加载。这和 #41 范围第 2 条一致。

必须改

  1. 把附件下载失败的上报拆出去。你想解决的是个真问题:现在一个附件下载不了,整条通知就交不上快照,正文也抽取不了,每一轮都会重抓。但怎么处理,要改接口、表和抓取频率,不在 #41 的范围里,按 AGENTS.md「先讨论再动手」要先商量。PR 里的做法也还有问题:重抓没有终止条件,一个要验证码的附件(比如你试过的杭州高研院),会让这条通知在项目截止以后也每天被抓一次。

    请把下面这些全部删掉:

    • attachments.py 整个文件:根据 HTML 判断错误页、把源站的提示存成原因。Attachment 放回 rounds.py,data 还是 bytes;
    • 快照接口的 error、上传接口的 url 参数、attachments.error 列,以及「最新快照有附件失败,就每天重抓」;
    • 连带的 ingest 改动、迁移、openapi/crawler.json、contract.py、db/types.ts,和 api.md、data-model.md、design.md §7.4 里的相关文字。

    带 Cookie 下载附件的 PageSession.download 留着,这是 #41 要的。这个 PR 里附件下载不成,照开发约定 §2 抛 FetchError 或 StepError,记进抓取记录。附件失败怎么处理,欢迎另开 Issue,把你的方案和试过的例子写进去,我们在那里商量。

  2. 浏览器页面用 Playwright 自己的接口,不走 CDP。browser.py 里这些都可以不要:

    • Fetch.enable 拦截;
    • _Paused、_Frames、_Version 这些 CDP 模型;
    • _generation 一类的导航状态;
    • _source_error 按错误文案的分类。

    照上面的方向,get 大致是:

    • 取得页面名额和网站许可;
    • new_page(),再 goto(url, wait_until="networkidle");
    • 读 page.content();
    • 关掉页面,放回许可。

    瑞数这类挑战会自己刷新页面,等到网络空闲时,就已经是最终页面了。我们审 #66 时用这个办法打开过 15 个挑战页,网址见 #41 的评论。状态码用 page.on("response") 记下主框架最后一次导航的响应,还是 202 或 412 就当失败。

    另外:

    • 跨网站跳转沿用起始网站的许可,不另外排队。
    • UA 开一个空白页读 navigator.userAgent 就行。
    • Playwright 的错误统一转成 FetchError:超时是 timeout,其余是 unreachable,不按文案细分。
  3. 其他防御性的代码一起删:

    • 新建 context 和页面时套了 create_task、asyncio.wait,防的是取消(browser.py:161、:238)。一轮结束时整个 context 都会关掉,里面的页面跟着关,不用逐个回收。
    • PageSession._closed 和 Permit._released 的检查也删掉:关闭以后再用、重复释放,都不会发生。
    • 弹窗:在 context.on("page") 里,有 opener 的直接关掉,不用另存任务再 gather。
    • 浏览器来源在进入 PageSession 时就开好会话,不用 _lock 懒加载:每一轮的第一步就要用它。
  4. 测试跟着精简。browser_test.py 的 1183 行,大多是用假的 CDP 事件,测上面要删掉的状态机和取消回收。改完以后,用很薄的假 Page 和 Context,测下面这几件事就够了:

    • 同一网站的页面访问和普通请求互相等,间隔 5 秒;
    • 同时最多 2 个页面;
    • 一轮一个会话,结束后关掉;
    • 附件下载用的是这一轮的 context。
  5. 文档写规矩,不写实现细节。

    • development.md §2 新加的几段写的是实现,删掉:30 秒和 300 秒的期限、DOMContentLoaded、许可的释放顺序。
    • 浏览器的安装命令保留。
    • 原来「浏览器抓取打开页面前,也要先过这些规矩」那句,改成「一次页面访问取一次许可,拿到页面关闭」。
    • design.md §7.3 改回原来的话,在后面补半句「浏览器的一次页面访问算一个请求」就够了。

以后提 PR 前请先自查

这次要改的几处都不是小问题,而且 AGENTS.md 里都写明了:

  • 一个 PR 只做 Issue 里商定的事,影响接口和抓取频率的先讨论(「只做要求的事」「先讨论再动手」);
  • 用库自带的接口,不为不会发生的情况写防御和回收(「用现成的」「只在边界校验」);
  • 文档写规矩,不写实现细节(「写文档」)。

如果代码是用 Codex 这类 agent 写的,转为待审前,请让它对照 AGENTS.md 和 Issue 的范围逐条自查几遍;你自己也把 diff 从头读一遍,看每一处是不是 Issue 要的、能不能写得更短更直接。这样能省下好几轮来回,审查也快得多。

说明

  • 列表、正文和附件文字的解析还归 #6、#40,这次不用做。
  • 改动比较大。改完把 PR 转为待审,或者在这里说一声,我们再看一轮。你推了新提交以后,我们再批准 CI。

@lym12321 lym12321 changed the title 接入浏览器抓取并上报附件下载失败 接入浏览器抓取 Nov 7, 2026

@JunieXD JunieXD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢!这一版照上次的意见改得很彻底:附件失败的上报整个拆掉了,浏览器部分换成了 Playwright 自己的接口,这次的提交删掉了 2400 多行,读起来清楚多了。

我们是这样核对的:

  • 本地跑了 ./check.sh,37 项都通过;
  • 用 PR 里的代码原样打开了北邮计算机学院和电科计算机学院的列表页,两个都过了瑞数的挑战、拿到 200,分别用了 5.1 秒和 2.3 秒;拦掉图片和字体不影响挑战。

必须改

  1. fetch.py 和 browser.py 互相引用:browser.py 从 fetch.py 导入 FetchError、Response 等,fetch.py 又在 session() 里导入 Browser,类型靠 TYPE_CHECKING 导入(fetch.py:13-14、:79)。这是在绕开循环导入,而 AGENTS.md「按领域组织」要求依赖单向、不循环。请改成只有 browser.py 依赖 fetch.py:
    • fetch.py 回到只管 HTTP 请求,保留新加的 permit,不再知道浏览器;
    • PageSession 和按来源开会话挪进 browser.py。比如 Browser(fetcher) 持有共享的浏览器,browser.session(source) 给出这一轮的 PageSession:浏览器来源用这一轮的 context,其他来源直接用 fetcher.get;
    • crawl_source 改收 Browser,不再收 Fetcher,一轮在 async with browser.session(source) 里跑完。
  2. 测试精简到上次说的那几件:
    • 删掉这 4 个:browser_test.py 里测错误转换和初始化失败的两个(:320、:354),以及 fetch_test.py 新加的取消、失败后放回许可的两个(:91、:122)。它们测的是 try/finally 本身,或者是上次说过不用管的取消。
    • 留下这 2 个:测主框架最后一次导航的(:273)和挑战没过就失败的(:299)。前一个保证跨网站跳转沿用起始网站的许可,后一个保证瑞数没过时不会被当成抓取成功。

建议一起改

  • configure() 合进 Browser.session(),去掉包着它的 try(browser.py:79-83):注册路由和事件不会单独出错;浏览器出了问题,外层已经转成 FetchError。
  • development.md 只改上次说的那一句:「所有对来源网站的请求都经过……」和「一轮里同一个网站共用一个无痕会话……」这两条原来的写法没问题,请改回原文。只把「浏览器抓取打开页面前,也要先过这些规矩」换成「浏览器一次页面访问取一次许可,持有到页面关闭;页面的子资源正常加载,跨网站跳转沿用起始网站的许可」。

说明

  • #82 刚合并,它也改了 rounds.py(Steps.screen 的注释),这个 PR 现在和 main 有冲突,改完请先 rebase 到最新的 main。#83 合了的话,它新加的 crawl/listing.py 里 list_items 的第一个参数也跟着换成 PageSession。
  • 这次的 CI 我们接着就批准。改完转回待审,或者在这里说一声。

@JunieXD JunieXD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢!这一版把上次的意见都改到位了:fetch.py 和 browser.py 只剩 browser → fetch 一个方向;Browser(fetcher)、session(source) 的形状,crawl_source 和 list_items 的参数都和我们说的一样;测试精简到了约定的 6 个,开发约定那句话也一字不差。

我们是这样核对的:

  • 本地跑了 ./check.sh,198 项测试都通过;
  • 用 PR 里的代码原样抓了电科计算机学院:列表页和通知页都是 200,分别用了 3.9 秒和 6.5 秒;同一会话下载的附件 581,642 字节,是有效的 PDF。

还剩一处,改完就能合并。

必须改

  1. Browser 进入时就启动 Chromium,去掉懒加载:现在第一次用到浏览器来源时才启动(_start 加 _lock,browser.py:74-91)。可浏览器起来以后一直开到进程退出,来源清单的 45 个里又有 15 个要用浏览器,每次领活都会用到,懒加载只是把启动推迟了一会儿。第一轮说 context 不用 _lock 懒加载,也是这个道理。另外,浏览器起不来(比如没装 Chromium)是抓取节点自己的问题,现在会被 session() 转成 FetchError("unreachable", source.url),记成这个学院的网站打不开。

    请改成:

    • __aenter__ 里启动 Chromium、读 UA,失败就直接抛出,抓取节点一启动就能发现;删掉 _start 和 _lock。
    • session() 去掉包着整段的 try/except:页面和附件的错误已经在 BrowserSession 里转成 FetchError 了;新建 context 出错是 bug,照常抛出(开发约定 §2「别的异常是 bug,照常抛出」)。
    • crawl_source 直接用 async with browser.session(source) as pages:,去掉 AsyncExitStack,try 还是只包读列表的那几行。
    • 删掉 rounds_test.py 的 test_a_session_that_cant_start_is_reported。rounds_test.py 里剩下的都是普通来源,用不到 Chromium,可以不进入 Browser,免得 CI 要装浏览器;browser_test.py 的假浏览器改成替换 async_playwright。

建议一起改

  • 日志照 rounds.py 的写法:browser.py:44 的 _logger 改叫 logger,网址、状态码和耗时放进 extra,不拼在文字里(browser.py:149-154)。

说明

  • 改完在这里说一声,或者转回待审,我们看完这一处就合并。推了新提交以后我们再批准 CI。

@JunieXD JunieXD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这一处改到位了:Browser 进入时就启动 Chromium,起不来直接报错;session() 去掉了包着整段的 try,crawl_source 也不再用 AsyncExitStack;日志的字段放进了 extra。

我们本地跑了 ./check.sh,197 项都通过;用真实的 Chromium 进入 Browser,UA 也是对的。感谢这几轮耐心的修改,合并了!

@JunieXD
JunieXD merged commit 1b2933a into mousebaoyan:main Nov 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[任务] 浏览器抓取

3 participants