From c955b411b38eebd29e363144ba7f24f3011a170b Mon Sep 17 00:00:00 2001 From: lym12321 <41183806+lym12321@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:08:55 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E6=8A=93=E5=8F=96=E5=B9=B6=E4=B8=8A=E6=8A=A5=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/pyproject.toml | 1 + crawler/src/crawler/crawl/attachments.py | 115 ++ crawler/src/crawler/crawl/attachments_test.py | 157 +++ crawler/src/crawler/crawl/browser.py | 530 ++++++++ crawler/src/crawler/crawl/browser_test.py | 1183 +++++++++++++++++ crawler/src/crawler/crawl/contract.py | 10 +- crawler/src/crawler/crawl/fetch.py | 119 +- crawler/src/crawler/crawl/fetch_test.py | 90 +- crawler/src/crawler/crawl/rounds.py | 123 +- crawler/src/crawler/crawl/rounds_test.py | 170 ++- crawler/src/crawler/crawl/website.py | 3 +- docs/api.md | 8 +- docs/data-model.md | 2 +- docs/design.md | 4 +- docs/development.md | 20 +- web/db/migrations/0001_init.sql | 1 + web/db/types.ts | 1 + web/openapi/crawler.json | 18 +- web/server/modules/ingest/routes.test.ts | 311 ++++- web/server/modules/ingest/routes.ts | 14 +- web/server/modules/ingest/service.ts | 137 +- 21 files changed, 2850 insertions(+), 167 deletions(-) create mode 100644 crawler/src/crawler/crawl/attachments.py create mode 100644 crawler/src/crawler/crawl/attachments_test.py create mode 100644 crawler/src/crawler/crawl/browser.py create mode 100644 crawler/src/crawler/crawl/browser_test.py diff --git a/crawler/pyproject.toml b/crawler/pyproject.toml index c640430..ac2d3b4 100644 --- a/crawler/pyproject.toml +++ b/crawler/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.14" dependencies = [ "httpx>=0.28", "lxml>=6.1.3", + "playwright>=1.64.1", "pydantic>=2.14.1", ] diff --git a/crawler/src/crawler/crawl/attachments.py b/crawler/src/crawler/crawl/attachments.py new file mode 100644 index 0000000..2dbcc32 --- /dev/null +++ b/crawler/src/crawler/crawl/attachments.py @@ -0,0 +1,115 @@ +import logging +import re +from dataclasses import dataclass +from html.parser import HTMLParser + +from crawler.crawl.contract import AttachmentInfo +from crawler.crawl.fetch import FetchError, PageSession, Response + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Attachment: + info: AttachmentInfo + data: bytes | None + + +class _DownloadPage(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.parts: list[str] = [] + self.hidden = 0 + self.office = False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "html": + self.office = any( + (name == "xmlns" or name.startswith("xmlns:")) + and value is not None + and value.startswith("urn:schemas-microsoft-com:office:") + for name, value in attrs + ) + if tag in {"title", "script", "style"}: + self.hidden += 1 + + def handle_endtag(self, tag: str) -> None: + if tag in {"title", "script", "style"}: + self.hidden = max(0, self.hidden - 1) + + def handle_data(self, data: str) -> None: + if not self.hidden: + self.parts.append(data) + + +def _download_error(response: Response) -> str | None: + status = f"HTTP {response.status}" if not 200 <= response.status < 300 else None + # Some download endpoints label Office and PDF files as HTML. + if status is None and response.body.startswith((b"%PDF-", b"\xd0\xcf\x11\xe0", b"PK\x03\x04")): + return None + content_type = response.content_type or "" + charset = re.search(r"charset\s*=\s*['\"]?([\w.-]+)", content_type, re.IGNORECASE) + meta = re.search( + rb"]*\bcharset\s*=\s*['\"]?([\w.-]+)", response.body[:1024], re.IGNORECASE + ) + if response.body.startswith(b"\xef\xbb\xbf"): + encoding = "utf-8-sig" + elif response.body.startswith((b"\xff\xfe", b"\xfe\xff")): + encoding = "utf-16" + elif charset: + encoding = charset[1] + elif meta: + encoding = meta[1].decode("ascii") + else: + encoding = "utf-8-sig" + try: + prefix = response.body[:1024].decode(encoding, errors="replace") + except LookupError: + encoding = "utf-8-sig" + prefix = response.body[:1024].decode(encoding, errors="replace") + html = content_type.split(";")[0].strip().lower() in {"text/html", "application/xhtml+xml"} + html = ( + html + or re.match( + r"<(?:!doctype\s+html|html|head|body|title|meta|script|style|form|div|p|span|pre|table|h[1-6])" + r"(?:\s|/?>)", + prefix.lstrip(), + re.IGNORECASE, + ) + is not None + ) + if not html: + return status or ("附件内容为空" if not response.body else None) + + page = _DownloadPage() + page.feed(response.body.decode(encoding, errors="replace")) + if status is None and page.office: + return None + text = " ".join(" ".join(page.parts).split()) + if "验证码" in text or "captcha" in text.lower(): + return "下载附件需要验证码" + # A whole notice or login page is not a useful attachment failure message. + detail = text if 0 < len(text) <= 1000 else "附件地址未返回文件" + return detail if status is None else f"{status}: {detail}" + + +async def download_attachment( + pages: PageSession, url: str, *, name: str, page_url: str +) -> Attachment: + content_type = "application/octet-stream" + data: bytes | None = None + try: + response = await pages.download(url, page_url=page_url) + except FetchError as failure: + error = failure.failure + else: + content_type = response.content_type or content_type + error = _download_error(response) + if error is None: + data = response.body + if error is not None: + logger.warning("Attachment download failed", extra={"url": url}) + return Attachment( + AttachmentInfo(name=name, url=url, content_type=content_type, text="", error=error), + data, + ) diff --git a/crawler/src/crawler/crawl/attachments_test.py b/crawler/src/crawler/crawl/attachments_test.py new file mode 100644 index 0000000..1b27116 --- /dev/null +++ b/crawler/src/crawler/crawl/attachments_test.py @@ -0,0 +1,157 @@ +import asyncio + +import httpx +import pytest + +from crawler.crawl.attachments import download_attachment +from crawler.crawl.fetch import Fetcher + +URL = "https://school.test/download.jsp?file=1" +PAGE_URL = "https://school.test/notice.htm" + + +@pytest.mark.parametrize( + ("body", "content_type", "status", "error"), + [ + ( + '
请输入验证码下载附件
', + "text/html;charset=UTF-8", + 200, + "下载附件需要验证码", + ), + ("
获取文件错误
", "text/html;charset=UTF-8", 200, "获取文件错误"), + ("

附件已删除

", "text/html;charset=gb2312", 200, "附件已删除"), + ( + '

文件已删除

', + "text/html;charset=gb2312", + 200, + "文件已删除", + ), + ( + "

文件已删除

".encode("utf-8-sig"), + "text/html;charset=gb2312", + 200, + "文件已删除", + ), + ( + ( + '请输入验证码下载附件' + ).encode("gb2312"), + "text/html", + 200, + "下载附件需要验证码", + ), + ( + ( + '' + "

文件已删除

" + ).encode("gb2312"), + "text/html", + 200, + "文件已删除", + ), + ( + "

请输入验证码下载附件

".encode("utf-16"), + None, + 200, + "下载附件需要验证码", + ), + ( + "下载请输入验证码下载附件", + "text/html;charset=UTF-8", + 200, + "下载附件需要验证码", + ), + ( + "下载

文件已删除

", + "text/html;charset=UTF-8", + 200, + "文件已删除", + ), + ("

文件不存在

", "text/html", 404, "HTTP 404: 文件不存在"), + ("", None, 200, "附件地址未返回文件"), + ( + "文件已删除", + "application/octet-stream", + 200, + "文件已删除", + ), + ("\ufeff\n
获取文件错误
", None, 200, "获取文件错误"), + ("", "application/pdf", 200, "附件内容为空"), + ("denied", "text/plain", 403, "HTTP 403"), + ], +) +def test_download_errors_keep_the_link_and_never_keep_an_error_page_as_a_file( + body: str | bytes, content_type: str | None, status: int, error: str +) -> None: + encoding = "gb2312" if content_type and "gb2312" in content_type else "utf-8" + content = body.encode(encoding) if isinstance(body, str) else body + headers = {"content-type": content_type} if content_type else {} + + async def main() -> None: + transport = httpx.MockTransport( + lambda _: httpx.Response(status, content=content, headers=headers) + ) + async with Fetcher(transport) as fetcher, fetcher.session(False) as pages: + attachment = await download_attachment(pages, URL, name="申请表.doc", page_url=PAGE_URL) + assert attachment.info.url == URL + assert attachment.info.name == "申请表.doc" + assert attachment.info.text == "" + assert attachment.info.error == error + assert attachment.data is None + + asyncio.run(main()) + + +@pytest.mark.parametrize( + ("body", "content_type"), + [ + (b"%PDF-1.7\n", "text/html"), + (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "text/html"), + (b"PK\x03\x04", "text/html"), + ( + '' + '' + '申请表' + "
".encode(), + "application/vnd.ms-excel", + ), + ( + '' + "申请表" + "
申请表
".encode(), + "application/vnd.ms-excel", + ), + ], +) +def test_downloads_keep_binary_and_office_files_after_redirects( + body: bytes, content_type: str +) -> None: + async def main() -> None: + def handle(request: httpx.Request) -> httpx.Response: + if request.url.path == "/download.jsp": + return httpx.Response(302, headers={"location": "https://files.test/form"}) + return httpx.Response(200, content=body, headers={"content-type": content_type}) + + async with Fetcher(httpx.MockTransport(handle)) as fetcher, fetcher.session(False) as pages: + attachment = await download_attachment(pages, URL, name="申请表", page_url=PAGE_URL) + assert attachment.info.url == URL + assert attachment.info.error is None + assert attachment.data == body + + asyncio.run(main()) + + +def test_a_transport_failure_is_an_attachment_error() -> None: + async def main() -> None: + def handle(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("timed out", request=request) + + async with Fetcher(httpx.MockTransport(handle)) as fetcher, fetcher.session(False) as pages: + attachment = await download_attachment(pages, URL, name="申请表", page_url=PAGE_URL) + assert attachment.info.url == URL + assert attachment.info.error == "timeout" + assert attachment.data is None + + asyncio.run(main()) diff --git a/crawler/src/crawler/crawl/browser.py b/crawler/src/crawler/crawl/browser.py new file mode 100644 index 0000000..63ff05c --- /dev/null +++ b/crawler/src/crawler/crawl/browser.py @@ -0,0 +1,530 @@ +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable +from typing import Protocol +from urllib.parse import urljoin, urlsplit + +from playwright.async_api import ( + Browser as PlaywrightBrowser, +) +from playwright.async_api import ( + BrowserContext, + Download, + Frame, + Page, + Playwright, + Route, + async_playwright, +) +from playwright.async_api import ( + Error as PlaywrightError, +) +from playwright.async_api import ( + Request as PlaywrightRequest, +) +from playwright.async_api import ( + Response as PlaywrightResponse, +) +from playwright.async_api import ( + TimeoutError as PlaywrightTimeoutError, +) +from pydantic import BaseModel, Field, JsonValue, TypeAdapter, ValidationError + +from crawler.crawl.fetch import ( + MAX_REDIRECTS, + TIMEOUT, + USER_AGENT, + FetchError, + Permit, + Response, + site_key, +) + +PAGE_TIMEOUT = 300.0 +PAGE_LIMIT = 2 +_REDIRECTS = {301, 302, 303, 307, 308} +_BLOCKED = {"image", "font", "media"} +_CHALLENGES = {202, 412} +_PARAMS = TypeAdapter(dict[str, JsonValue]) +_logger = logging.getLogger(__name__) + + +class _Version(BaseModel): + user_agent: str = Field(alias="userAgent") + + +class _Request(BaseModel): + url: str + + +class _Paused(BaseModel): + request_id: str = Field(alias="requestId") + request: _Request + frame_id: str = Field(alias="frameId") + redirected_request_id: str | None = Field(default=None, alias="redirectedRequestId") + + +class _Frame(BaseModel): + id: str + + +class _FrameTree(BaseModel): + frame: _Frame + + +class _Frames(BaseModel): + frame_tree: _FrameTree = Field(alias="frameTree") + + +# Playwright leaves CDPSession.send's dictionaries untyped; validate its JSON at this boundary. +class _Commands(Protocol): + async def send(self, method: str, params: dict[str, JsonValue] | None = None) -> object: ... + + def on(self, event: str, f: Callable[[object], None]) -> None: ... + + async def detach(self) -> None: ... + + +async def _command( + connection: _Commands, method: str, params: dict[str, JsonValue] | None = None +) -> dict[str, JsonValue]: + return _PARAMS.validate_python(await connection.send(method, params)) + + +def _source_error(error: PlaywrightError, url: str) -> FetchError | None: + if isinstance(error, PlaywrightTimeoutError): + return FetchError("timeout", url) + message = str(error).split("\n", 1)[0] + if "Download is starting" in message: + return FetchError("unreachable", url) + if "net::ERR_TOO_MANY_REDIRECTS" in message: + return FetchError("too_many_redirects", url) + if "net::ERR_TIMED_OUT" in message or "Request timed out" in message: + return FetchError("timeout", url) + if any( + marker in message + for marker in ("net::ERR_", "ECONN", "ENOTFOUND", "EAI_AGAIN", "socket hang up") + ): + return FetchError("unreachable", url) + if message.startswith( + ( + "APIRequestContext.get: self-signed certificate", + "APIRequestContext.get: certificate has expired", + "APIRequestContext.get: unable to verify the first certificate", + "APIRequestContext.get: Hostname/IP does not match certificate's altnames:", + "APIRequestContext.get: failed to decompress 'gzip' encoding:", + ) + ): + return FetchError("unreachable", url) + return None + + +class Browser: + def __init__(self, permit: Callable[[str], Awaitable[Permit]]) -> None: + self.permit = permit + self.slots = asyncio.Semaphore(PAGE_LIMIT) + self._lock = asyncio.Lock() + self._playwright: Playwright | None = None + self._browser: PlaywrightBrowser | None = None + self._user_agent = "" + self.sessions: set[BrowserSession] = set() + + async def _start(self) -> PlaywrightBrowser: + async with self._lock: + if self._browser is not None: + return self._browser + playwright = await async_playwright().start() + try: + browser = await playwright.chromium.launch( + headless=True, args=["--disable-blink-features=AutomationControlled"] + ) + connection: _Commands = await browser.new_browser_cdp_session() + try: + version = _Version.model_validate( + await _command(connection, "Browser.getVersion") + ) + finally: + await connection.detach() + except BaseException: + await playwright.stop() + raise + self._user_agent = ( + version.user_agent.replace("HeadlessChrome", "Chrome") + " " + USER_AGENT + ) + self._playwright = playwright + self._browser = browser + return browser + + async def session(self) -> BrowserSession: + browser = await self._start() + creating = asyncio.create_task( + browser.new_context( + user_agent=self._user_agent, service_workers="block", accept_downloads=False + ) + ) + try: + # Cancelling a protocol call does not prevent the browser from creating its resource. + await asyncio.wait((creating,)) + context = creating.result() + session = BrowserSession(self, context) + await session.configure() + except BaseException: + context = await creating + await context.close() + raise + self.sessions.add(session) + return session + + async def close(self) -> None: + try: + for session in tuple(self.sessions): + await session.close() + finally: + try: + if self._browser is not None: + await self._browser.close() + self._browser = None + finally: + if self._playwright is not None: + await self._playwright.stop() + self._playwright = None + + +class BrowserSession: + def __init__(self, owner: Browser, context: BrowserContext) -> None: + self._owner = owner + self._context = context + self._pages: set[Page] = set() + self._popup_tasks: set[asyncio.Task[None]] = set() + self._closed = False + + async def configure(self) -> None: + await self._context.route("**/*", self._route) + self._context.on("page", self._new_page) + + def _new_page(self, page: Page) -> None: + task = asyncio.create_task(self._popup(page)) + self._popup_tasks.add(task) + + async def _popup(self, page: Page) -> None: + if await page.opener() is not None: + await page.close() + + async def _route(self, route: Route) -> None: + request = route.request + if request.resource_type in _BLOCKED: + await route.abort() + return + try: + allowed = request.frame.page in self._pages + except PlaywrightError as error: + if "Frame for this navigation request is not available" not in str(error): + raise + allowed = False + if not allowed: + await route.abort() + return + await route.continue_() + + async def get(self, url: str) -> Response: + async with self._owner.slots: + page: Page | None = None + creating: asyncio.Task[Page] | None = None + request: _Page | None = None + started = time.monotonic() + try: + async with asyncio.timeout(PAGE_TIMEOUT): + creating = asyncio.create_task(self._context.new_page()) + await asyncio.wait((creating,)) + page = creating.result() + self._pages.add(page) + request = _Page(page, self._context, self._owner.permit, url) + response = await request.run() + _logger.info( + "Browser fetched %s status=%s elapsed=%.3fs", + response.url, + response.status, + time.monotonic() - started, + ) + return response + except TimeoutError as error: + raise FetchError("timeout", url) from error + finally: + if request is not None: + await request.close() + elif creating is not None: + page = await creating + await page.close() + if page is not None: + self._pages.discard(page) + + async def download(self, url: str, *, page_url: str) -> Response: + for _ in range(MAX_REDIRECTS + 1): + permit = await self._owner.permit(url) + try: + response = await self._context.request.get( + url, + headers={"Referer": page_url}, + timeout=TIMEOUT * 1000, + max_redirects=0, + max_retries=0, + fail_on_status_code=False, + ) + try: + location = response.headers.get("location") + if response.status in _REDIRECTS and location is not None: + url = urljoin(response.url, location) + continue + return Response( + response.url, + response.status, + response.headers.get("content-type"), + await response.body(), + ) + finally: + await response.dispose() + except PlaywrightError as error: + failure = _source_error(error, url) + if failure is not None: + raise failure from error + raise + finally: + permit.release() + raise FetchError("too_many_redirects", url) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + try: + await self._context.close() + finally: + try: + if self._popup_tasks: + await asyncio.gather(*self._popup_tasks) + finally: + self._owner.sessions.discard(self) + + +class _Page: + def __init__( + self, + page: Page, + context: BrowserContext, + permit: Callable[[str], Awaitable[Permit]], + url: str, + ) -> None: + self._context = context + self._permit = permit + self._page = page + self._url = url + self._connection: _Commands | None = None + self._frame_id = "" + self._tasks: set[asyncio.Task[None]] = set() + self._gate = asyncio.Lock() + self._current_permit: Permit | None = None + self._site: str | None = None + self._redirects: dict[str, int] = {} + self._changed = asyncio.Event() + self._failure: asyncio.Future[BaseException] = asyncio.get_running_loop().create_future() + self._main: PlaywrightResponse | None = None + self._current_request: PlaywrightRequest | None = None + self._generation = 0 + self._committed_generation = -1 + self._loaded_generation = -1 + self._document_pending = False + self._closing = False + + def _spawn(self, operation: Awaitable[None]) -> None: + task = asyncio.ensure_future(operation) + self._tasks.add(task) + task.add_done_callback(self._done) + + def _done(self, task: asyncio.Task[None]) -> None: + self._tasks.discard(task) + if not task.cancelled(): + error = task.exception() + if error is not None: + self._fail(error) + self._changed.set() + + def _fail(self, error: BaseException) -> None: + if not self._closing and not self._failure.done(): + self._failure.set_result(error) + + async def _send( + self, method: str, params: dict[str, JsonValue] | None = None + ) -> dict[str, JsonValue]: + if self._connection is None: + raise RuntimeError("CDP connection has not started") + return await _command(self._connection, method, params) + + async def _configure(self) -> None: + frames = _Frames.model_validate(await self._send("Page.getFrameTree")) + self._frame_id = frames.frame_tree.frame.id + await self._send( + "Fetch.enable", + { + "patterns": [ + {"urlPattern": "*", "resourceType": "Document", "requestStage": "Request"} + ] + }, + ) + + def _request_paused(self, value: object) -> None: + if self._closing: + return + try: + event = _Paused.model_validate(value) + except ValidationError as error: + self._fail(error) + return + if event.frame_id == self._frame_id: + self._document_pending = True + self._changed.set() + self._spawn(self._paused(event)) + + def _release(self) -> None: + if self._current_permit is not None: + self._current_permit.release() + self._current_permit = None + self._site = None + + async def _paused(self, event: _Paused) -> None: + if event.frame_id == self._frame_id: + async with self._gate: + redirects = ( + self._redirects.pop(event.redirected_request_id, 0) + 1 + if event.redirected_request_id is not None + else 0 + ) + if redirects > MAX_REDIRECTS: + self._fail(FetchError("too_many_redirects", event.request.url)) + return + self._redirects[event.request_id] = redirects + url = event.request.url + if urlsplit(url).scheme in {"http", "https"}: + site = site_key(url) + if site != self._site: + # Release before waiting at the destination so two visits crossing + # between the same sites cannot hold each other's permits. + self._release() + permit = await self._permit(url) + if self._closing: + permit.release() + return + self._current_permit = permit + self._site = site + await self._send("Fetch.continueRequest", {"requestId": event.request_id}) + else: + await self._send("Fetch.continueRequest", {"requestId": event.request_id}) + + def _requested(self, request: PlaywrightRequest) -> None: + if request.is_navigation_request() and request.frame == self._page.main_frame: + self._current_request = request + self._generation += 1 + self._main = None + self._document_pending = True + self._changed.set() + + def _response(self, response: PlaywrightResponse) -> None: + if response.request is self._current_request: + self._main = response + self._changed.set() + + def _request_failed(self, request: PlaywrightRequest) -> None: + if request is not self._current_request: + return + reason = request.failure + if reason == "net::ERR_ABORTED": + return + error = PlaywrightError(reason or "Main navigation failed without a reason") + self._fail(_source_error(error, request.url) or error) + + def _navigated(self, frame: Frame) -> None: + if frame == self._page.main_frame: + # History/hash changes have no new response or DOMContentLoaded event. + if self._main is not None and self._committed_generation != self._generation: + self._committed_generation = self._generation + self._document_pending = False + self._changed.set() + + def _domcontentloaded(self, _: Page) -> None: + if self._committed_generation == self._generation: + self._loaded_generation = self._generation + self._changed.set() + + def _download(self, download: Download) -> None: + self._fail(FetchError("unreachable", download.url)) + self._spawn(download.cancel()) + + def _ready(self) -> bool: + return ( + self._main is not None + and self._committed_generation == self._generation + and self._loaded_generation == self._generation + and not self._document_pending + and not self._tasks + ) + + async def _navigate(self) -> Response: + await self._page.goto(self._url, wait_until="commit", timeout=0) + while True: + self._changed.clear() + response = self._main + if response is not None and response.status not in _CHALLENGES and self._ready(): + generation = self._generation + try: + body = await self._page.content() + except PlaywrightError as error: + if "page is navigating" not in str(error): + raise + continue + if generation == self._generation and response is self._main and self._ready(): + return Response( + self._page.url, + response.status, + "text/html; charset=utf-8", + body.encode("utf-8"), + ) + await self._changed.wait() + + async def run(self) -> Response: + self._connection = await self._context.new_cdp_session(self._page) + self._connection.on("Fetch.requestPaused", self._request_paused) + self._page.on("request", self._requested) + self._page.on("requestfailed", self._request_failed) + self._page.on("response", self._response) + self._page.on("download", self._download) + self._page.on("framenavigated", self._navigated) + self._page.on("domcontentloaded", self._domcontentloaded) + navigation: asyncio.Task[Response] | None = None + try: + await self._configure() + navigation = asyncio.create_task(self._navigate()) + await asyncio.wait((navigation, self._failure), return_when=asyncio.FIRST_COMPLETED) + if self._failure.done(): + raise self._failure.result() + return await navigation + except PlaywrightError as error: + failure = _source_error(error, self._page.url or self._url) + if failure is not None: + raise failure from error + raise + finally: + if navigation is not None and not navigation.done(): + navigation.cancel() + await asyncio.gather(navigation, return_exceptions=True) + + async def close(self) -> None: + self._closing = True + try: + await self._page.close() + finally: + tasks = tuple(self._tasks) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._release() diff --git a/crawler/src/crawler/crawl/browser_test.py b/crawler/src/crawler/crawl/browser_test.py new file mode 100644 index 0000000..3298802 --- /dev/null +++ b/crawler/src/crawler/crawl/browser_test.py @@ -0,0 +1,1183 @@ +import asyncio +from collections.abc import Awaitable, Callable + +import httpx +import pytest +from playwright.async_api import Error as PlaywrightError +from playwright.async_api import TimeoutError as PlaywrightTimeoutError +from pydantic import JsonValue + +from crawler.crawl.browser import Browser +from crawler.crawl.fetch import ( + INTERVAL, + MAX_REDIRECTS, + Failure, + Fetcher, + FetchError, + Permit, + Response, +) + + +def run(main: Callable[[], Awaitable[None]]) -> None: + async def bounded() -> None: + async with asyncio.timeout(5): + await main() + + asyncio.run(bounded()) + + +class FakeSession: + def __init__(self, number: int) -> None: + self.number = number + self.closed = False + + async def get(self, url: str) -> Response: + return Response(url, 200, "text/html", f"session {self.number}".encode()) + + async def download(self, url: str, *, page_url: str) -> Response: + return Response(url, 200, "application/pdf", f"session {self.number}: {page_url}".encode()) + + async def close(self) -> None: + self.closed = True + + +class FakeSessions: + def __init__(self) -> None: + self.sessions: list[FakeSession] = [] + + async def session(self) -> FakeSession: + await asyncio.sleep(0) + session = FakeSession(len(self.sessions) + 1) + self.sessions.append(session) + return session + + +class FakeClock: + def __init__(self) -> None: + self.now = 0.0 + + def clock(self) -> float: + return self.now + + async def sleep(self, seconds: float) -> None: + self.now += seconds + await asyncio.sleep(0) + + +class FakeAPIResponse: + def __init__(self, response: httpx.Response) -> None: + self.response = response + self.url = str(response.url) + self.status = response.status_code + self.headers = dict(response.headers) + self.disposed = False + + async def body(self) -> bytes: + return self.response.content + + async def dispose(self) -> None: + self.disposed = True + await self.response.aclose() + + +class FakeDownloads: + def __init__(self, client: httpx.AsyncClient) -> None: + self.client = client + self.responses: list[FakeAPIResponse] = [] + self.error: PlaywrightError | None = None + + async def get( + self, + url: str, + *, + headers: dict[str, str], + **options: object, + ) -> FakeAPIResponse: + if self.error is not None: + raise self.error + assert options["max_redirects"] == options["max_retries"] == 0 + assert options["fail_on_status_code"] is False + request_timeout = options["timeout"] + assert isinstance(request_timeout, int | float) + response = FakeAPIResponse( + await self.client.get(url, headers=headers, timeout=request_timeout / 1000) + ) + self.responses.append(response) + return response + + +class FakeCDP: + def __init__(self) -> None: + self.handlers: dict[str, Callable[..., None]] = {} + self.commands: asyncio.Queue[tuple[str, dict[str, JsonValue]]] = asyncio.Queue() + self.continued: dict[str, asyncio.Event] = {} + + def on(self, event: str, callback: Callable[..., None]) -> None: + self.handlers[event] = callback + + async def send( + self, method: str, params: dict[str, JsonValue] | None = None + ) -> dict[str, JsonValue]: + self.commands.put_nowait((method, params or {})) + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "main"}}} + if method == "Fetch.continueRequest": + assert params is not None + request_id = params["requestId"] + assert isinstance(request_id, str) + self.continued[request_id].set() + return {} + + async def next_command( + self, method: str, *, request_id: str | None = None + ) -> dict[str, JsonValue]: + while True: + name, params = await self.commands.get() + if name == method and (request_id is None or params.get("requestId") == request_id): + return params + + def pause( + self, + request_id: str, + url: str, + *, + frame_id: str = "main", + redirected: str | None = None, + ) -> None: + self.continued[request_id] = asyncio.Event() + value: dict[str, JsonValue] = { + "requestId": request_id, + "request": {"url": url, "method": "GET"}, + "resourceType": "Document", + "frameId": frame_id, + } + if redirected is not None: + value["redirectedRequestId"] = redirected + self.handlers["Fetch.requestPaused"](value) + + +class FakeFrame: + def __init__(self, page: FakePage) -> None: + self.page = page + + +class FakeNavigation: + def __init__(self, frame: FakeFrame, url: str, *, navigation: bool = True) -> None: + self.frame = frame + self.url = url + self.navigation = navigation + self.failure: str | None = None + + def is_navigation_request(self) -> bool: + return self.navigation + + +class FakeDocument: + def __init__(self, status: int, request: FakeNavigation) -> None: + self.request = request + self.status = status + + +class FakePage: + def __init__(self, context: FakeContext) -> None: + self.context = context + self.main_frame = FakeFrame(self) + self.navigation = FakeNavigation(self.main_frame, "about:blank") + self.url = "about:blank" + self.html = "" + self.closed = False + self.close_started = asyncio.Event() + self.close_gate: asyncio.Event | None = None + self.started = asyncio.Event() + self.content_started = asyncio.Event() + self.content_gate: asyncio.Event | None = None + self.navigation_error: PlaywrightError | None = None + self.connection = FakeCDP() + self.handlers: dict[str, Callable[..., None]] = {} + + def on(self, event: str, callback: Callable[..., None]) -> None: + self.handlers[event] = callback + + def begin_navigation(self, url: str) -> FakeNavigation: + self.navigation = FakeNavigation(self.main_frame, url) + self.handlers["request"](self.navigation) + return self.navigation + + def respond(self, status: int, *, request: FakeNavigation | None = None) -> None: + self.handlers["response"](FakeDocument(status, request or self.navigation)) + + def fail(self, request: FakeNavigation, reason: str) -> None: + request.failure = reason + self.handlers["requestfailed"](request) + + def commit(self, url: str, html: str, *, loaded: bool = True) -> None: + self.url = url + self.html = html + if callback := self.handlers.get("framenavigated"): + callback(self.main_frame) + if loaded: + self.load() + + def load(self) -> None: + self.handlers["domcontentloaded"](self) + + async def goto(self, url: str, **_options: object) -> None: + self.begin_navigation(url) + self.connection.pause("initial", url) + await self.connection.continued["initial"].wait() + if self.navigation_error is not None: + raise self.navigation_error + self.respond(self.context.driver.initial_status) + self.commit(url, "challenge") + self.started.set() + + async def content(self) -> str: + html = self.html + self.content_started.set() + if self.content_gate is not None: + await self.content_gate.wait() + return html + + async def close(self) -> None: + if not self.closed: + self.close_started.set() + if self.close_gate is not None: + await self.close_gate.wait() + self.closed = True + self.context.driver.active_pages -= 1 + + +class FakeRouteRequest: + def __init__(self, page: FakePage, resource_type: str) -> None: + self.frame = FakeFrame(page) + self.resource_type = resource_type + + +class FakeRoute: + def __init__(self, page: FakePage, resource_type: str) -> None: + self.request = FakeRouteRequest(page, resource_type) + self.continued = False + self.aborted = False + + async def continue_(self) -> None: + self.continued = True + + async def abort(self) -> None: + self.aborted = True + + +class FakeContext: + def __init__(self, driver: FakeBrowser) -> None: + self.driver = driver + self.client = httpx.AsyncClient(transport=driver.transport) + self.request = FakeDownloads(self.client) + self.pages: list[FakePage] = [] + self.closed = False + self.route_handler: Callable[[FakeRoute], Awaitable[None]] | None = None + self.page_gate: asyncio.Event | None = None + + async def route(self, pattern: str, handler: Callable[[FakeRoute], Awaitable[None]]) -> None: + self.route_handler = handler + + def on(self, event: str, handler: object) -> None: + pass + + async def new_page(self) -> FakePage: + page = FakePage(self) + self.pages.append(page) + self.driver.active_pages += 1 + self.driver.peak_pages = max(self.driver.peak_pages, self.driver.active_pages) + self.driver.created.put_nowait(page) + if self.page_gate is not None: + await self.page_gate.wait() + return page + + async def new_cdp_session(self, page: FakePage) -> FakeCDP: + return page.connection + + async def close(self) -> None: + self.closed = True + for page in self.pages: + await page.close() + await self.client.aclose() + + +class FakeBrowser: + def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: + self.transport = transport or httpx.MockTransport(lambda _: httpx.Response(404)) + self.contexts: list[FakeContext] = [] + self.created: asyncio.Queue[FakePage] = asyncio.Queue() + self.active_pages = 0 + self.peak_pages = 0 + self.initial_status = 202 + self.context_created = asyncio.Event() + self.context_gate: asyncio.Event | None = None + + async def new_context( + self, *, user_agent: str, service_workers: str, accept_downloads: bool + ) -> FakeContext: + context = FakeContext(self) + self.contexts.append(context) + self.context_created.set() + if self.context_gate is not None: + await self.context_gate.wait() + return context + + +def use_browser(monkeypatch: pytest.MonkeyPatch, driver: FakeBrowser) -> None: + async def start(_owner: object) -> FakeBrowser: + return driver + + monkeypatch.setattr("crawler.crawl.browser.Browser._start", start) + + +def test_plain_pages_and_unused_browser_sessions_do_not_start_a_browser( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def unexpected_browser(_fetcher: Fetcher) -> None: + pytest.fail("A browser was started without a browser page request") + + monkeypatch.setattr(Fetcher, "browser", unexpected_browser) + + async def main() -> None: + transport = httpx.MockTransport( + lambda request: httpx.Response(200, content=request.url.path) + ) + async with Fetcher(transport) as fetcher: + async with fetcher.session(False) as pages: + listing = await pages.get("https://a/list") + attachment = await pages.download("https://b/file.pdf", page_url=listing.url) + async with fetcher.session(True): + pass + assert listing.body == b"/list" + assert attachment.body == b"/file.pdf" + + run(main) + + +def test_round_reuses_its_browser_session_and_closes_it_before_the_next_round( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = FakeSessions() + + def browser_service(_fetcher: Fetcher) -> FakeSessions: + return service + + monkeypatch.setattr(Fetcher, "browser", browser_service) + + async def main() -> None: + async with Fetcher(httpx.MockTransport(lambda _: httpx.Response(500))) as fetcher: + async with fetcher.session(True) as pages: + assert service.sessions == [] + listing, notice = await asyncio.gather( + pages.get("https://a/list"), pages.get("https://a/notice") + ) + attachment = await pages.download("https://a/file.pdf", page_url=notice.url) + assert listing.body == notice.body == b"session 1" + assert attachment.body == b"session 1: https://a/notice" + assert not service.sessions[0].closed + + with pytest.raises(RuntimeError, match="closed"): + await pages.get("https://a/after-close") + with pytest.raises(RuntimeError, match="closed"): + await pages.download("https://a/file.pdf", page_url=notice.url) + assert service.sessions[0].closed + + async with fetcher.session(True) as next_round: + assert (await next_round.get("https://a/list")).body == b"session 2" + assert service.sessions[0].closed + assert all(session.closed for session in service.sessions) + + run(main) + + +@pytest.mark.parametrize("challenge", [202, 412]) +def test_challenge_waits_for_the_final_document_to_commit_before_reading_html( + monkeypatch: pytest.MonkeyPatch, challenge: int +) -> None: + driver = FakeBrowser() + driver.initial_status = challenge + use_browser(monkeypatch, driver) + + async def main() -> None: + async with Fetcher() as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + await page.started.wait() + await asyncio.sleep(0) + assert not task.done() + + # A response event precedes commit, even when both documents use the same URL. + page.begin_navigation("https://a.test/list") + page.connection.pause("challenge", "https://a.test/list") + await page.connection.next_command("Fetch.continueRequest", request_id="challenge") + page.respond(200) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert not task.done() + + page.commit("https://a.test/list", "script pending", loaded=False) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert not task.done() + page.html = "rendered notice" + page.load() + result = await task + assert result.status == 200 + assert result.body == b"rendered notice" + assert page.closed + assert driver.active_pages == 0 + finally: + await browser.close() + + run(main) + + +def test_page_limit_is_shared_across_rounds_and_cancellation_frees_a_slot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + finish = asyncio.Event() + + async def render(_request: object) -> Response: + await finish.wait() + return Response("https://a.test/list", 200, "text/html", b"rendered") + + monkeypatch.setattr("crawler.crawl.browser._Page.run", render) + + async def main() -> None: + async with Fetcher() as fetcher: + browser = Browser(fetcher.permit) + try: + first, second = await asyncio.gather(browser.session(), browser.session()) + tasks = [ + asyncio.create_task(first.get("https://a.test/1")), + asyncio.create_task(second.get("https://b.test/2")), + asyncio.create_task(first.get("https://a.test/3")), + ] + page_one = await driver.created.get() + await driver.created.get() + await asyncio.sleep(0) + assert driver.active_pages == driver.peak_pages == 2 + assert driver.created.empty() + + tasks[0].cancel() + with pytest.raises(asyncio.CancelledError): + await tasks[0] + page_three = await driver.created.get() + assert page_one.closed + assert not page_three.closed + assert driver.active_pages == driver.peak_pages == 2 + + finish.set() + assert [response.body for response in await asyncio.gather(*tasks[1:])] == [ + b"rendered", + b"rendered", + ] + assert driver.active_pages == 0 + finally: + finish.set() + await browser.close() + assert all(context.closed for context in driver.contexts) + + run(main) + + +def test_navigation_during_html_read_discards_the_previous_document( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + driver.initial_status = 200 + use_browser(monkeypatch, driver) + + async def main() -> None: + async with Fetcher() as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + page.content_gate = asyncio.Event() + await page.content_started.wait() + previous = page.navigation + page.begin_navigation("https://a.test/final") + page.content_gate.set() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert not task.done() + + page.respond(200, request=previous) + await asyncio.sleep(0) + assert not task.done() + page.connection.pause("navigate", "https://a.test/final") + await page.connection.next_command("Fetch.continueRequest", request_id="navigate") + page.respond(200) + await asyncio.sleep(0) + assert not task.done() + page.commit("https://a.test/final", "final document") + result = await task + assert result.url == "https://a.test/final" + assert result.body == b"final document" + finally: + await browser.close() + + run(main) + + +def test_attachment_cookies_are_scoped_to_the_round_and_each_redirect_destination( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requested: list[tuple[str, str, str]] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requested.append( + ( + str(request.url), + request.headers.get("cookie", ""), + request.headers.get("referer", ""), + ) + ) + if request.url.host == "a.test": + if request.headers.get("cookie") != "challenge=passed": + return httpx.Response(403, content=b"challenge required") + return httpx.Response(302, headers={"location": "https://b.test/file.pdf"}) + assert "challenge" not in request.headers.get("cookie", "") + return httpx.Response(200, headers={"content-type": "application/pdf"}, content=b"PDF") + + driver = FakeBrowser(httpx.MockTransport(handle)) + use_browser(monkeypatch, driver) + clock = FakeClock() + + async def main() -> None: + async with Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + context = driver.contexts[0] + context.client.cookies.set("challenge", "passed", domain="a.test", path="/private") + result = await session.download( + "https://a.test/private/file.pdf", page_url="https://a.test/notice" + ) + assert (result.url, result.status, result.body) == ( + "https://b.test/file.pdf", + 200, + b"PDF", + ) + assert all(response.disposed for response in context.request.responses) + outside_cookie_path = await session.download( + "https://a.test/public/file.pdf", page_url="https://a.test/notice" + ) + assert outside_cookie_path.status == 403 + await session.close() + + next_round = await browser.session() + missing_cookie = await next_round.download( + "https://a.test/private/file.pdf", page_url="https://a.test/notice" + ) + assert missing_cookie.status == 403 + assert clock.now >= INTERVAL + finally: + await browser.close() + + assert requested == [ + ("https://a.test/private/file.pdf", "challenge=passed", "https://a.test/notice"), + ("https://b.test/file.pdf", "", "https://a.test/notice"), + ("https://a.test/public/file.pdf", "", "https://a.test/notice"), + ("https://a.test/private/file.pdf", "", "https://a.test/notice"), + ] + + run(main) + + +def test_page_permit_covers_challenge_refresh_and_subresources_until_the_page_closes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + clock = FakeClock() + ordinary_started = asyncio.Event() + permits: list[str] = [] + + def handle(_request: httpx.Request) -> httpx.Response: + ordinary_started.set() + return httpx.Response(200, content=b"next request") + + async def main() -> None: + async with Fetcher(httpx.MockTransport(handle), clock.clock, clock.sleep) as fetcher: + + async def permit(url: str) -> Permit: + permits.append(url) + return await fetcher.permit(url) + + browser = Browser(permit) + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + await page.started.wait() + configuration = await page.connection.next_command("Fetch.enable") + assert configuration == { + "patterns": [ + {"urlPattern": "*", "requestStage": "Request", "resourceType": "Document"} + ] + } + + route = driver.contexts[0].route_handler + assert route is not None + for resource_type in ("script", "stylesheet", "xhr", "document"): + resource = FakeRoute(page, resource_type) + await route(resource) + assert resource.continued + assert not resource.aborted + image = FakeRoute(page, "image") + await route(image) + assert image.aborted + + page.connection.pause("iframe", "https://b.test/frame", frame_id="child") + await page.connection.next_command("Fetch.continueRequest", request_id="iframe") + page.begin_navigation("https://a.test/list") + page.connection.pause("challenge", "https://a.test/list") + await page.connection.next_command("Fetch.continueRequest", request_id="challenge") + assert permits == ["https://a.test/list"] + assert clock.now == 0 + + ordinary = asyncio.create_task(fetcher.get("https://a.test/other")) + await asyncio.sleep(0) + assert not ordinary_started.is_set() + page.respond(200) + page.commit("https://a.test/list", "ready") + assert (await task).body == b"ready" + assert page.closed + assert (await ordinary).body == b"next request" + assert clock.now == INTERVAL + finally: + await browser.close() + + run(main) + + +def test_opposite_cross_site_redirects_release_old_permits_before_waiting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + clock = FakeClock() + + async def main() -> None: + async with Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + first = asyncio.create_task(session.get("https://a.test/list")) + second = asyncio.create_task(session.get("https://b.test/list")) + page_a = await driver.created.get() + page_b = await driver.created.get() + await asyncio.gather(page_a.started.wait(), page_b.started.wait()) + page_a.begin_navigation("https://b.test/final") + page_b.begin_navigation("https://a.test/final") + page_a.connection.pause("to-b", "https://b.test/final", redirected="initial") + page_b.connection.pause("to-a", "https://a.test/final", redirected="initial") + await asyncio.gather( + page_a.connection.next_command("Fetch.continueRequest", request_id="to-b"), + page_b.connection.next_command("Fetch.continueRequest", request_id="to-a"), + ) + + page_a.respond(200) + page_a.commit("https://b.test/final", "from b") + page_b.respond(200) + page_b.commit("https://a.test/final", "from a") + responses = await asyncio.gather(first, second) + assert [(response.url, response.body) for response in responses] == [ + ("https://b.test/final", b"from b"), + ("https://a.test/final", b"from a"), + ] + assert page_a.closed + assert page_b.closed + assert clock.now >= INTERVAL + finally: + await browser.close() + + run(main) + + +def test_same_document_url_change_during_html_read_keeps_document_readiness( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + driver.initial_status = 200 + use_browser(monkeypatch, driver) + + async def main() -> None: + async with Fetcher() as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + page.content_gate = asyncio.Event() + await page.content_started.wait() + page.commit("https://a.test/list?view=all#notices", page.html, loaded=False) + page.content_gate.set() + result = await task + assert result.url == "https://a.test/list?view=all#notices" + assert result.body == b"challenge" + assert page.closed + finally: + await browser.close() + + run(main) + + +@pytest.mark.parametrize( + ("error", "failure"), + [ + (None, None), + (PlaywrightTimeoutError("Page.goto timed out"), "timeout"), + (PlaywrightError("net::ERR_CONNECTION_RESET"), "unreachable"), + (PlaywrightError("Page.goto: Download is starting"), "unreachable"), + (PlaywrightError("Unexpected browser error"), None), + ], + ids=["cancel", "timeout", "unreachable", "unexpected_download", "unexpected_error"], +) +def test_navigation_failure_or_cancellation_closes_the_page_and_releases_its_permit( + monkeypatch: pytest.MonkeyPatch, + error: PlaywrightError | None, + failure: Failure | None, +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + clock = FakeClock() + + async def main() -> None: + transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"still usable")) + async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + if error is None: + await page.started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + page.navigation_error = error + if failure is None: + with pytest.raises(PlaywrightError) as browser_error: + await task + assert browser_error.value is error + else: + with pytest.raises(FetchError) as source_failure: + await task + assert source_failure.value.failure == failure + assert page.closed + assert driver.active_pages == 0 + assert (await fetcher.get("https://a.test/after-failure")).body == b"still usable" + assert clock.now == INTERVAL + finally: + await browser.close() + + run(main) + + +def test_cancelling_a_cross_site_wait_leaves_both_sites_usable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + clock = FakeClock() + destination_requested = asyncio.Event() + + async def main() -> None: + transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"available")) + async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: + + async def permit(url: str) -> Permit: + if url == "https://b.test/final": + destination_requested.set() + return await fetcher.permit(url) + + browser = Browser(permit) + destination = await fetcher.permit("https://b.test/held") + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + await page.started.wait() + page.begin_navigation("https://b.test/final") + page.connection.pause("redirect", "https://b.test/final", redirected="initial") + await destination_requested.wait() + assert (await fetcher.get("https://a.test/other")).body == b"available" + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert page.closed + destination.release() + assert (await fetcher.get("https://b.test/other")).body == b"available" + finally: + destination.release() + await browser.close() + + run(main) + + +def test_same_site_http_redirect_limit_does_not_count_challenge_refreshes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + clock = FakeClock() + + async def main() -> None: + transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"available")) + async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + await page.started.wait() + for index in range(MAX_REDIRECTS + 2): + request_id = f"challenge-{index}" + page.begin_navigation("https://a.test/list") + page.connection.pause(request_id, "https://a.test/list") + await page.connection.next_command( + "Fetch.continueRequest", request_id=request_id + ) + previous = "initial" + for index in range(MAX_REDIRECTS): + request_id = f"redirect-{index}" + page.begin_navigation(f"https://a.test/redirect/{index}") + page.connection.pause( + request_id, f"https://a.test/redirect/{index}", redirected=previous + ) + await page.connection.next_command( + "Fetch.continueRequest", request_id=request_id + ) + previous = request_id + page.begin_navigation("https://a.test/too-far") + page.connection.pause("over-limit", "https://a.test/too-far", redirected=previous) + with pytest.raises(FetchError) as caught: + await task + assert caught.value.failure == "too_many_redirects" + assert not page.connection.continued["over-limit"].is_set() + assert page.closed + assert (await fetcher.get("https://a.test/other")).body == b"available" + assert clock.now == INTERVAL + finally: + await browser.close() + + run(main) + + +def test_challenge_navigation_network_failure_releases_the_page_without_waiting_for_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + clock = FakeClock() + + async def main() -> None: + transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"available")) + async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + await page.started.wait() + page.begin_navigation("https://a.test/after-challenge") + page.connection.pause("challenge", "https://a.test/after-challenge") + await page.connection.next_command("Fetch.continueRequest", request_id="challenge") + page.fail(page.navigation, "net::ERR_CONNECTION_REFUSED") + + with pytest.raises(FetchError) as caught: + await task + assert caught.value.failure == "unreachable" + assert caught.value.url == "https://a.test/after-challenge" + assert page.closed + assert (await fetcher.get("https://a.test/other")).body == b"available" + assert clock.now == INTERVAL + finally: + await browser.close() + + run(main) + + +def test_aborted_navigation_and_old_or_subresource_failures_allow_the_replacement_document( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + + async def main() -> None: + async with Fetcher() as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + await page.started.wait() + previous = page.navigation + page.fail(previous, "net::ERR_ABORTED") + await asyncio.sleep(0) + assert not task.done() + + page.begin_navigation("https://a.test/final") + page.fail(previous, "net::ERR_CONNECTION_REFUSED") + script = FakeNavigation( + page.main_frame, "https://a.test/optional.js", navigation=False + ) + page.fail(script, "net::ERR_CONNECTION_REFUSED") + iframe = FakeNavigation(FakeFrame(page), "https://b.test/optional-frame") + page.fail(iframe, "net::ERR_CONNECTION_REFUSED") + await asyncio.sleep(0) + assert not task.done() + + page.connection.pause("replacement", "https://a.test/final") + await page.connection.next_command( + "Fetch.continueRequest", request_id="replacement" + ) + page.respond(200) + page.commit("https://a.test/final", "replacement document") + assert (await task).body == b"replacement document" + assert page.closed + finally: + await browser.close() + + run(main) + + +def test_cancelling_context_creation_waits_for_and_closes_the_unclaimed_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeBrowser() + driver.context_gate = asyncio.Event() + use_browser(monkeypatch, driver) + + async def main() -> None: + async with Fetcher() as fetcher: + browser = Browser(fetcher.permit) + task = asyncio.create_task(browser.session()) + try: + await driver.context_created.wait() + task.cancel() + await asyncio.sleep(0) + assert not task.done() + assert not driver.contexts[0].closed + assert driver.context_gate is not None + driver.context_gate.set() + with pytest.raises(asyncio.CancelledError): + await task + assert driver.contexts[0].closed + assert not browser.sessions + finally: + assert driver.context_gate is not None + driver.context_gate.set() + await asyncio.gather(task, return_exceptions=True) + await browser.close() + for context in driver.contexts: + await context.close() + + run(main) + + +@pytest.mark.parametrize("expired", [False, True], ids=["cancel", "timeout"]) +def test_interrupted_page_creation_closes_the_page_before_releasing_its_slot( + monkeypatch: pytest.MonkeyPatch, expired: bool +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + + async def main() -> None: + async with Fetcher() as fetcher: + browser = Browser(fetcher.permit) + session = await browser.session() + context = driver.contexts[0] + context.page_gate = asyncio.Event() + deadlines: list[asyncio.Timeout] = [] + timeout = asyncio.timeout + + def deadline(_seconds: float) -> asyncio.Timeout: + timer = timeout(None) + deadlines.append(timer) + return timer + + monkeypatch.setattr("crawler.crawl.browser.asyncio.timeout", deadline) + task = asyncio.create_task(session.get("https://a.test/list")) + page = await driver.created.get() + page.close_gate = asyncio.Event() + blocked: asyncio.Task[bool] | None = None + try: + if expired: + deadlines[0].reschedule(asyncio.get_running_loop().time()) + else: + task.cancel() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert not task.done() + context.page_gate.set() + await page.close_started.wait() + await browser.slots.acquire() + blocked = asyncio.create_task(browser.slots.acquire()) + await asyncio.sleep(0) + assert not blocked.done() + assert not page.closed + page.close_gate.set() + if expired: + with pytest.raises(FetchError) as caught: + await task + assert caught.value.failure == "timeout" + else: + with pytest.raises(asyncio.CancelledError): + await task + await blocked + browser.slots.release() + browser.slots.release() + assert page.closed + assert driver.active_pages == 0 + finally: + context.page_gate.set() + page.close_gate.set() + if blocked is not None and not blocked.done(): + blocked.cancel() + await asyncio.gather(task, return_exceptions=True) + if blocked is not None: + await asyncio.gather(blocked, return_exceptions=True) + await browser.close() + + run(main) + + +@pytest.mark.parametrize("page_creation", [False, True], ids=["context", "page"]) +def test_creation_failure_after_cancellation_is_propagated_once( + monkeypatch: pytest.MonkeyPatch, page_creation: bool +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + + async def main() -> None: + started, finish = asyncio.Event(), asyncio.Event() + error = RuntimeError("Resource creation failed") + unhandled: list[dict[str, object]] = [] + loop = asyncio.get_running_loop() + previous_handler = loop.get_exception_handler() + + def unexpected(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: + unhandled.append(context) + + loop.set_exception_handler(unexpected) + async with Fetcher() as fetcher: + browser = Browser(fetcher.permit) + if page_creation: + session = await browser.session() + + async def create_page() -> FakePage: + started.set() + await finish.wait() + raise error + + monkeypatch.setattr(driver.contexts[0], "new_page", create_page) + + async def operation() -> object: + return await session.get("https://a.test/list") + + else: + + async def create_context(**_options: object) -> FakeContext: + started.set() + await finish.wait() + raise error + + monkeypatch.setattr(driver, "new_context", create_context) + + async def operation() -> object: + return await browser.session() + + task = asyncio.create_task(operation()) + try: + await started.wait() + task.cancel() + await asyncio.sleep(0) + assert not task.done() + finish.set() + with pytest.raises(RuntimeError) as caught: + await task + assert caught.value is error + await asyncio.sleep(0) + assert not unhandled + assert driver.active_pages == 0 + finally: + finish.set() + await asyncio.gather(task, return_exceptions=True) + loop.set_exception_handler(previous_handler) + await browser.close() + + run(main) + + +@pytest.mark.parametrize( + ("message", "failure"), + [ + ("APIRequestContext.get: self-signed certificate", "unreachable"), + ("APIRequestContext.get: certificate has expired", "unreachable"), + ("APIRequestContext.get: unable to verify the first certificate", "unreachable"), + ( + "APIRequestContext.get: Hostname/IP does not match certificate's altnames:" + " Host: a.test", + "unreachable", + ), + ( + "APIRequestContext.get: failed to decompress 'gzip' encoding: incorrect header", + "unreachable", + ), + ("APIRequestContext.get: Unknown internal failure", None), + ( + "APIRequestContext.get: Unknown internal failure\nCall log:\n" + " - User-Agent: ECONNRESET self-signed certificate\n", + None, + ), + ], + ids=["self_signed", "expired", "unverified", "hostname", "compression", "unknown", "call_log"], +) +def test_attachment_transport_errors_release_the_permit_and_preserve_unknown_errors( + monkeypatch: pytest.MonkeyPatch, message: str, failure: Failure | None +) -> None: + driver = FakeBrowser() + use_browser(monkeypatch, driver) + clock = FakeClock() + + async def main() -> None: + transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"still available")) + async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: + browser = Browser(fetcher.permit) + try: + session = await browser.session() + error = PlaywrightError(message) + driver.contexts[0].request.error = error + if failure is None: + with pytest.raises(PlaywrightError) as caught: + await session.download( + "https://a.test/file.pdf", page_url="https://a.test/notice" + ) + assert caught.value is error + else: + with pytest.raises(FetchError) as source_failure: + await session.download( + "https://a.test/file.pdf", page_url="https://a.test/notice" + ) + assert source_failure.value.failure == failure + assert ( + await fetcher.get("https://a.test/after-failure") + ).body == b"still available" + assert clock.now == INTERVAL + finally: + await browser.close() + + run(main) diff --git a/crawler/src/crawler/crawl/contract.py b/crawler/src/crawler/crawl/contract.py index 925d454..e3cce86 100644 --- a/crawler/src/crawler/crawl/contract.py +++ b/crawler/src/crawler/crawl/contract.py @@ -29,7 +29,7 @@ class PendingNotice(BaseModel): step: Annotated[ Literal["screen", "fetch"], Field( - description="screen:还没初筛;fetch:要抓正文,初筛通过还没抽取的,或者项目还没截止、一天没抓过的" + description="screen:还没初筛;fetch:初筛通过但最新快照还没抽取的,或者项目还没截止、最新快照有附件下载失败或文件未补传,且一天没抓过的" ), ] @@ -114,6 +114,14 @@ class AttachmentInfo(BaseModel): url: Annotated[str, Field(max_length=2000)] content_type: Annotated[str, Field(max_length=200, min_length=1)] text: Annotated[str, Field(max_length=2097152)] + error: Annotated[ + str | None, + Field( + description="附件下载失败原因;不写表示下载正常,不影响通知正文和其他附件", + max_length=2000, + min_length=1, + ), + ] = None type ValueItem = Annotated[str, Field(max_length=500, min_length=1)] diff --git a/crawler/src/crawler/crawl/fetch.py b/crawler/src/crawler/crawl/fetch.py index 35a43b4..9897916 100644 --- a/crawler/src/crawler/crawl/fetch.py +++ b/crawler/src/crawler/crawl/fetch.py @@ -1,18 +1,19 @@ -"""Every request to a source site goes through here, so the crawl keeps its manners in one place -(design §7.3): it says who it is and sends one request at a time to a site with a pause between -them.""" +"""Source access shares one policy here (design §7.3): identify the crawler and allow one +page visit or HTTP request at a time per site, with a pause between them.""" import asyncio import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import Literal, Self -from urllib.parse import urlsplit +from typing import TYPE_CHECKING, Literal, Self import httpx +if TYPE_CHECKING: + from crawler.crawl.browser import Browser, BrowserSession + USER_AGENT = "mouseclock/0.1 (+https://github.com/mousebaoyan/mouseclock)" -# Seconds from the end of one request to a site to the start of the next. +# Seconds from the end of one page visit or HTTP request to the start of the next. INTERVAL = 5.0 MAX_REDIRECTS = 5 TIMEOUT = 30.0 @@ -41,6 +42,23 @@ class _Site: finished_at: float = float("-inf") +def site_key(url: str) -> str: + return httpx.URL(url).netloc.decode("ascii") + + +@dataclass +class Permit: + _site: _Site + _clock: Callable[[], float] + _released: bool = False + + def release(self) -> None: + if not self._released: + self._released = True + self._site.finished_at = self._clock() + self._site.lock.release() + + class Fetcher: """One per crawler process, shared by everything that crawls, so the rules hold across tasks.""" @@ -56,12 +74,40 @@ def __init__( self._clock = clock self._sleep = sleep self._sites: dict[str, _Site] = {} + self._browser: Browser | None = None async def __aenter__(self) -> Self: return self async def __aexit__(self, *_: object) -> None: - await self._client.aclose() + try: + if self._browser is not None: + await self._browser.close() + finally: + await self._client.aclose() + + def session(self, browser: bool) -> PageSession: + return PageSession(self, browser) + + def browser(self) -> Browser: + if self._browser is None: + # Keep the browser library unloaded until a source actually needs it. + from crawler.crawl.browser import Browser + + self._browser = Browser(self.permit) + return self._browser + + async def permit(self, url: str) -> Permit: + site = self._sites.setdefault(site_key(url), _Site()) + await site.lock.acquire() + try: + wait = site.finished_at + INTERVAL - self._clock() + if wait > 0: + await self._sleep(wait) + return Permit(site, self._clock) + except BaseException: + site.lock.release() + raise async def get(self, url: str) -> Response: # Each hop on its own, so a redirect to another site waits its turn there too. @@ -78,16 +124,49 @@ async def get(self, url: str) -> Response: raise FetchError("too_many_redirects", url) async def _send(self, url: str) -> httpx.Response: - site = self._sites.setdefault(urlsplit(url).netloc, _Site()) - async with site.lock: - wait = site.finished_at + INTERVAL - self._clock() - if wait > 0: - await self._sleep(wait) - try: - return await self._client.get(url) - except httpx.TimeoutException as error: - raise FetchError("timeout", url) from error - except httpx.TransportError as error: - raise FetchError("unreachable", url) from error - finally: - site.finished_at = self._clock() + permit = await self.permit(url) + try: + return await self._client.get(url) + except httpx.TimeoutException as error: + raise FetchError("timeout", url) from error + except httpx.TransportError as error: + raise FetchError("unreachable", url) from error + finally: + permit.release() + + +class PageSession: + def __init__(self, fetcher: Fetcher, browser: bool) -> None: + self._fetcher = fetcher + self._use_browser = browser + self._browser_session: BrowserSession | None = None + self._lock = asyncio.Lock() + self._closed = False + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> None: + self._closed = True + if self._browser_session is not None: + await self._browser_session.close() + + async def _open_browser(self) -> BrowserSession: + async with self._lock: + if self._browser_session is None: + self._browser_session = await self._fetcher.browser().session() + return self._browser_session + + async def get(self, url: str) -> Response: + if self._closed: + raise RuntimeError("Page session is closed") + if self._use_browser: + return await (await self._open_browser()).get(url) + return await self._fetcher.get(url) + + async def download(self, url: str, *, page_url: str) -> Response: + if self._closed: + raise RuntimeError("Page session is closed") + if self._use_browser: + return await (await self._open_browser()).download(url, page_url=page_url) + return await self._fetcher.get(url) diff --git a/crawler/src/crawler/crawl/fetch_test.py b/crawler/src/crawler/crawl/fetch_test.py index 01de10d..4b2f516 100644 --- a/crawler/src/crawler/crawl/fetch_test.py +++ b/crawler/src/crawler/crawl/fetch_test.py @@ -5,8 +5,9 @@ from urllib.parse import urlsplit import httpx +import pytest -from crawler.crawl.fetch import INTERVAL, USER_AGENT, Fetcher +from crawler.crawl.fetch import INTERVAL, USER_AGENT, Fetcher, FetchError class FakeSites: @@ -85,3 +86,90 @@ def test_a_redirect_to_another_site_waits_its_turn_there() -> None: assert moved.url == "http://b/new" assert len(sites.times("b")) == 2 assert all(gap >= INTERVAL for gap in gaps(sites.times("b"))) + + +def test_http_and_browser_permissions_share_the_completion_interval() -> None: + sites = FakeSites({"http://a/notice": httpx.Response(200)}) + + async def use(fetcher: Fetcher) -> None: + browser_request = await fetcher.permit("http://A:80/list.htm") + http_request = asyncio.create_task(fetcher.get("http://a/notice")) + await asyncio.sleep(0) + assert not sites.requests + sites.now = 3.0 + browser_request.release() + # A late terminal event for the old request must not release another one's permit. + browser_request.release() + await http_request + + sites.run(use) + assert sites.times("a") == [3.0 + INTERVAL] + + +def test_cancelling_the_interval_wait_does_not_mark_a_request_finished() -> None: + async def run() -> None: + sites = FakeSites({}) + waiting = asyncio.Event() + waits: list[float] = [] + block = True + + async def sleep(seconds: float) -> None: + waits.append(seconds) + if block: + waiting.set() + await asyncio.Future[None]() + sites.now += seconds + + async with Fetcher(clock=sites.clock, sleep=sleep) as fetcher: + first = await fetcher.permit("http://a/first") + first.release() + queued = asyncio.create_task(fetcher.permit("http://a/cancelled")) + await waiting.wait() + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + block = False + async with asyncio.timeout(1): + next_request = await fetcher.permit("http://a/next") + next_request.release() + assert waits == [INTERVAL, INTERVAL] + + asyncio.run(run()) + + +@pytest.mark.parametrize("failure", ["timeout", "unreachable", "cancelled"]) +def test_failed_or_cancelled_http_requests_release_the_shared_permission(failure: str) -> None: + async def run() -> None: + sites = FakeSites({}) + started = asyncio.Event() + requests = 0 + + async def handle(request: httpx.Request) -> httpx.Response: + nonlocal requests + requests += 1 + if requests == 1: + started.set() + if failure == "cancelled": + await asyncio.Future[None]() + if failure == "timeout": + raise httpx.ReadTimeout("Timed out", request=request) + raise httpx.ConnectError("Connection failed", request=request) + return httpx.Response(200) + + async with Fetcher(httpx.MockTransport(handle), sites.clock, sites.sleep) as fetcher: + task = asyncio.create_task(fetcher.get("http://a/first")) + await started.wait() + if failure == "cancelled": + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + with pytest.raises(FetchError) as caught: + await task + assert caught.value.failure == failure + async with asyncio.timeout(1): + response = await fetcher.get("http://a/next") + assert response.status == 200 + assert sites.now == INTERVAL + + asyncio.run(run()) diff --git a/crawler/src/crawler/crawl/rounds.py b/crawler/src/crawler/crawl/rounds.py index 78d87d2..beef517 100644 --- a/crawler/src/crawler/crawl/rounds.py +++ b/crawler/src/crawler/crawl/rounds.py @@ -7,8 +7,8 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass +from crawler.crawl.attachments import Attachment from crawler.crawl.contract import ( - AttachmentInfo, ExtractedProgram, Extraction, ExtractionContext, @@ -24,7 +24,7 @@ Usage, WorkSource, ) -from crawler.crawl.fetch import Fetcher, FetchError +from crawler.crawl.fetch import Fetcher, FetchError, PageSession from crawler.crawl.website import Website logger = logging.getLogger(__name__) @@ -39,12 +39,6 @@ def __init__(self, failure: Failure, detail: str | None = None) -> None: self.detail = detail -@dataclass(frozen=True) -class Attachment: - info: AttachmentInfo - data: bytes - - @dataclass(frozen=True) class NoticePage: html: str @@ -57,14 +51,14 @@ class NoticePage: @dataclass(frozen=True) class Steps: # The items on the source's list page, paging back two years when it is new (design §7.1). - list_items: Callable[[Fetcher, WorkSource], Awaitable[list[ListedItem]]] + list_items: Callable[[PageSession, WorkSource], Awaitable[list[ListedItem]]] # A score for each notice by its title alone (design §8.1). screen: Callable[ [WorkSource, list[PendingNotice]], Awaitable[tuple[list[ScreeningResult], list[Usage]]] ] # A notice's page with its attachments downloaded and read; None when the page answers # 404 or 410 (design §7.4). - read: Callable[[Fetcher, WorkSource, str], Awaitable[NoticePage | None]] + read: Callable[[PageSession, WorkSource, str], Awaitable[NoticePage | None]] # The programs a notice is about (design §8.2). extract: Callable[ [NoticePage, ExtractionContext], Awaitable[tuple[list[ExtractedProgram], list[Usage]]] @@ -84,62 +78,63 @@ async def crawl_source( steps: Steps, clock: Callable[[], float] = time.time, ) -> None: - started_at = int(clock()) - try: - items = await steps.list_items(fetcher, source) - if not items: - raise StepError("no_items") - except (FetchError, StepError) as error: - logger.warning("Listing failed: %s", error, extra={"source_id": source.id}) - failure, detail = _failure(error) + async with fetcher.session(source.browser) as pages: + started_at = int(clock()) + try: + items = await steps.list_items(pages, source) + if not items: + raise StepError("no_items") + except (FetchError, StepError) as error: + logger.warning("Listing failed: %s", error, extra={"source_id": source.id}) + failure, detail = _failure(error) + await website.run( + Run( + source_id=source.id, + started_at=started_at, + finished_at=int(clock()), + failure=failure, + detail=detail, + listed=0, + new_count=0, + useful_count=0, + notice_failures=[], + ) + ) + return + + listing = await website.listing(source.id, Listing(items=items)) + new = [notice for notice in listing.pending if notice.step == "screen"] + to_fetch = [notice for notice in listing.pending if notice.step == "fetch"] + if new: + results, usage = await steps.screen(source, new) + await website.screening(source.id, Screening(results=results, usage=usage)) + screened_in = {result.notice_id for result in results if result.screened_in} + to_fetch += [notice for notice in new if notice.id in screened_in] + + useful = 0 + failures: list[NoticeFailure] = [] + for notice in to_fetch: + try: + programs = await _fetch_notice(notice, source, pages, website, steps, clock) + except (FetchError, StepError) as error: + logger.warning("Fetching a notice failed: %s", error, extra={"url": notice.url}) + failure, detail = _failure(error) + failures.append(NoticeFailure(url=notice.url, failure=failure, detail=detail)) + continue + if programs and notice.step == "screen": + useful += 1 + await website.run( Run( source_id=source.id, started_at=started_at, finished_at=int(clock()), - failure=failure, - detail=detail, - listed=0, - new_count=0, - useful_count=0, - notice_failures=[], + listed=len(items), + new_count=listing.new_count, + useful_count=useful, + notice_failures=failures, ) ) - return - - listing = await website.listing(source.id, Listing(items=items)) - new = [notice for notice in listing.pending if notice.step == "screen"] - to_fetch = [notice for notice in listing.pending if notice.step == "fetch"] - if new: - results, usage = await steps.screen(source, new) - await website.screening(source.id, Screening(results=results, usage=usage)) - screened_in = {result.notice_id for result in results if result.screened_in} - to_fetch += [notice for notice in new if notice.id in screened_in] - - useful = 0 - failures: list[NoticeFailure] = [] - for notice in to_fetch: - try: - programs = await _fetch_notice(notice, source, fetcher, website, steps, clock) - except (FetchError, StepError) as error: - logger.warning("Fetching a notice failed: %s", error, extra={"url": notice.url}) - failure, detail = _failure(error) - failures.append(NoticeFailure(url=notice.url, failure=failure, detail=detail)) - continue - if programs and notice.step == "screen": - useful += 1 - - await website.run( - Run( - source_id=source.id, - started_at=started_at, - finished_at=int(clock()), - listed=len(items), - new_count=listing.new_count, - useful_count=useful, - notice_failures=failures, - ) - ) # Returns how many programs the notice turned out to be about; none when it was gone, @@ -147,12 +142,12 @@ async def crawl_source( async def _fetch_notice( notice: PendingNotice, source: WorkSource, - fetcher: Fetcher, + pages: PageSession, website: Website, steps: Steps, clock: Callable[[], float], ) -> int: - page = await steps.read(fetcher, source, notice.url) + page = await steps.read(pages, source, notice.url) if page is None: await website.removed(notice.id) return 0 @@ -166,7 +161,11 @@ async def _fetch_notice( ), ) for position in result.missing_files: - await website.attachment(result.snapshot_id, position, page.attachments[position].data) + attachment = page.attachments[position] + if attachment.data is not None: + await website.attachment( + result.snapshot_id, position, attachment.data, url=attachment.info.url + ) if result.extraction is None: return 0 programs, usage = await steps.extract(page, result.extraction) diff --git a/crawler/src/crawler/crawl/rounds_test.py b/crawler/src/crawler/crawl/rounds_test.py index 0b06365..620976b 100644 --- a/crawler/src/crawler/crawl/rounds_test.py +++ b/crawler/src/crawler/crawl/rounds_test.py @@ -1,10 +1,14 @@ import asyncio import json import re +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from typing import Any import httpx +import pytest +from crawler.crawl.attachments import Attachment, download_attachment from crawler.crawl.contract import ( AttachmentInfo, ExtractedFields, @@ -16,8 +20,8 @@ Usage, WorkSource, ) -from crawler.crawl.fetch import Fetcher, FetchError -from crawler.crawl.rounds import Attachment, NoticePage, StepError, Steps, crawl_source +from crawler.crawl.fetch import Fetcher, FetchError, PageSession, Response +from crawler.crawl.rounds import NoticePage, StepError, Steps, crawl_source from crawler.crawl.website import Website SOURCE = WorkSource( @@ -41,11 +45,14 @@ def __init__(self, pending: list[dict[str, Any]], extracted: frozenset[int]) -> self.pending = pending self.extracted = extracted self.calls: list[tuple[str, Any]] = [] + self.upload_urls: list[str] = [] def handle(self, request: httpx.Request) -> httpx.Response: path = request.url.path.removeprefix("/api/v1/crawler") sent_json = request.headers.get("content-type") == "application/json" body: Any = json.loads(request.content) if sent_json else request.content + if request.method == "PUT": + self.upload_urls.append(request.url.params["url"]) self.calls.append((f"{request.method} {path}", body)) if path.endswith("/listing"): return httpx.Response(200, json={"new_count": 2, "pending": self.pending}) @@ -63,14 +70,19 @@ def handle(self, request: httpx.Request) -> httpx.Response: ) return httpx.Response(204) - def run(self, steps: Steps) -> None: + def run( + self, + steps: Steps, + source: WorkSource = SOURCE, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: async def main() -> None: - nowhere = httpx.MockTransport(lambda _: httpx.Response(500)) + nowhere = transport or httpx.MockTransport(lambda _: httpx.Response(500)) async with ( Fetcher(nowhere) as fetcher, Website("http://site", "token", httpx.MockTransport(self.handle)) as website, ): - await crawl_source(SOURCE, fetcher, website, steps, clock=lambda: 1_800_000_000) + await crawl_source(source, fetcher, website, steps, clock=lambda: 1_800_000_000) asyncio.run(main()) @@ -78,7 +90,7 @@ def called(self, prefix: str) -> list[Any]: return [body for call, body in self.calls if call.startswith(prefix)] -async def list_items(_fetcher: Fetcher, _source: WorkSource) -> list[ListedItem]: +async def list_items(_pages: PageSession, _source: WorkSource) -> list[ListedItem]: return ITEMS @@ -124,7 +136,7 @@ def test_a_round_screens_the_new_fetches_what_needs_it_and_reports() -> None: extracted=frozenset({3}), ) - async def read(_fetcher: Fetcher, _source: WorkSource, url: str) -> NoticePage | None: + async def read(_pages: PageSession, _source: WorkSource, url: str) -> NoticePage | None: return {"http://a/1.htm": page("冬令营", files=1), "http://a/3.htm": page("夏令营")}.get( url ) @@ -164,33 +176,80 @@ async def read(_fetcher: Fetcher, _source: WorkSource, url: str) -> NoticePage | } +def test_attachment_errors_are_reported_without_uploading_error_pages_or_losing_the_notice() -> ( + None +): + site = FakeWebsite([pending(1, "fetch")], extracted=frozenset()) + files = { + "https://captcha.test/download": "
请输入验证码下载附件
".encode(), + "https://deleted.test/download": "
获取文件错误
".encode(), + "https://files.test/download?file=form.pdf&owner=2": b"%PDF-1.7\n", + } + + def handle(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=files[str(request.url)], + headers={"content-type": "text/html;charset=utf-8"}, + ) + + async def read(pages: PageSession, _source: WorkSource, url: str) -> NoticePage: + attachments = [ + await download_attachment(pages, link, name=f"申请表 {n}", page_url=url) + for n, link in enumerate(files) + ] + return NoticePage("

通知

", "通知", attachments) + + site.run(Steps(list_items, screen, read, extract), transport=httpx.MockTransport(handle)) + + [snapshot] = site.called("POST /notices/1/snapshot") + assert [a["url"] for a in snapshot["attachments"]] == list(files) + assert [a.get("error") for a in snapshot["attachments"]] == [ + "下载附件需要验证码", + "获取文件错误", + None, + ] + assert [a["text"] for a in snapshot["attachments"]] == ["", "", ""] + uploads = [(call, body) for call, body in site.calls if call.startswith("PUT ")] + assert uploads == [("PUT /snapshots/10/attachments/2", b"%PDF-1.7\n")] + assert site.upload_urls == ["https://files.test/download?file=form.pdf&owner=2"] + assert len(site.called("POST /snapshots/10/extraction")) == 1 + [run] = site.called("POST /runs") + assert run["notice_failures"] == [] + + def test_notices_that_fail_are_reported_and_the_round_goes_on() -> None: - site = FakeWebsite( - [pending(1, "fetch"), pending(2, "fetch"), pending(3, "fetch")], extracted=frozenset() - ) + site = FakeWebsite([pending(n, "fetch") for n in range(1, 5)], extracted=frozenset()) - async def read(_fetcher: Fetcher, _source: WorkSource, url: str) -> NoticePage | None: + async def read(_pages: PageSession, _source: WorkSource, url: str) -> NoticePage | None: if url == "http://a/1.htm": raise FetchError("timeout", url) if url == "http://a/2.htm": raise StepError("http_status", "HTTP 403") + if url == "http://a/3.htm": + raise FetchError("unreachable", "http://a/files/application.pdf") return page("冬令营") site.run(Steps(list_items, screen, read, extract)) - assert len(site.called("POST /snapshots/30/extraction")) == 1 + assert len(site.called("POST /snapshots/40/extraction")) == 1 [run] = site.called("POST /runs") assert run["notice_failures"] == [ {"url": "http://a/1.htm", "failure": "timeout", "detail": "http://a/1.htm"}, {"url": "http://a/2.htm", "failure": "http_status", "detail": "HTTP 403"}, + { + "url": "http://a/3.htm", + "failure": "unreachable", + "detail": "http://a/files/application.pdf", + }, ] def test_a_list_that_cant_be_read_ends_the_round_with_the_reason() -> None: - async def unreachable(_fetcher: Fetcher, source: WorkSource) -> list[ListedItem]: + async def unreachable(_pages: PageSession, source: WorkSource) -> list[ListedItem]: raise FetchError("unreachable", source.url) - async def empty(_fetcher: Fetcher, _source: WorkSource) -> list[ListedItem]: + async def empty(_pages: PageSession, _source: WorkSource) -> list[ListedItem]: return [] async def unused(*_: object) -> Any: @@ -204,3 +263,86 @@ async def unused(*_: object) -> Any: [run] = site.called("POST /runs") assert run["failure"] == failure assert run["listed"] == 0 + + +@pytest.fixture +def sessions( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[list[tuple[bool, PageSession]], list[PageSession]]: + entered: list[tuple[bool, PageSession]] = [] + exited: list[PageSession] = [] + original_session = Fetcher.session + + @asynccontextmanager + async def session(fetcher: Fetcher, browser: bool) -> AsyncGenerator[PageSession]: + async with original_session(fetcher, browser) as pages: + entered.append((browser, pages)) + try: + yield pages + finally: + exited.append(pages) + + monkeypatch.setattr(Fetcher, "session", session) + return entered, exited + + +def test_a_round_shares_one_session_and_reports_the_rendered_notice( + monkeypatch: pytest.MonkeyPatch, + sessions: tuple[list[tuple[bool, PageSession]], list[PageSession]], +) -> None: + entered, exited = sessions + source = SOURCE.model_copy(update={"browser": True}) + site = FakeWebsite([pending(1, "fetch")], extracted=frozenset({1})) + rendered = "

合成的渲染正文

" + seen: list[PageSession] = [] + + async def get(pages: PageSession, url: str) -> Response: + assert not exited + seen.append(pages) + html = "" if url == source.url else rendered + return Response(url, 200, "text/html; charset=utf-8", html.encode()) + + async def listing(pages: PageSession, source: WorkSource) -> list[ListedItem]: + response = await pages.get(source.url) + assert "合成的列表" in response.body.decode() + return ITEMS + + async def read(pages: PageSession, _source: WorkSource, url: str) -> NoticePage: + response = await pages.get(url) + return NoticePage(html=response.body.decode(), text="合成的渲染正文", attachments=[]) + + monkeypatch.setattr(PageSession, "get", get) + site.run(Steps(listing, screen, read, extract), source) + + assert len(seen) == 2 + assert seen[0] is seen[1] + assert entered == [(True, seen[0])] + assert exited == [seen[0]] + [snapshot] = site.called("POST /notices/1/snapshot") + assert snapshot["html"] == rendered + assert snapshot["text"] == "合成的渲染正文" + + +@pytest.mark.parametrize("failure", [ValueError, asyncio.CancelledError]) +def test_a_session_exits_when_a_step_fails_or_is_cancelled( + sessions: tuple[list[tuple[bool, PageSession]], list[PageSession]], + failure: type[BaseException], +) -> None: + entered, exited = sessions + site = FakeWebsite([], extracted=frozenset()) + + async def interrupted(pages: PageSession, _source: WorkSource) -> list[ListedItem]: + assert pages is entered[0][1] + raise failure("interrupted") + + async def read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage: + raise AssertionError + + with pytest.raises(failure, match="interrupted"): + site.run(Steps(interrupted, screen, read, extract)) + + assert len(entered) == 1 + assert exited == [entered[0][1]] + assert not site.calls + with pytest.raises(RuntimeError, match="Page session is closed"): + asyncio.run(entered[0][1].get(SOURCE.url)) diff --git a/crawler/src/crawler/crawl/website.py b/crawler/src/crawler/crawl/website.py index 2df1a7e..2cfbe41 100644 --- a/crawler/src/crawler/crawl/website.py +++ b/crawler/src/crawler/crawl/website.py @@ -52,9 +52,10 @@ async def snapshot(self, notice_id: int, snapshot: Snapshot) -> SnapshotResult: body = await self._call("POST", f"/notices/{notice_id}/snapshot", snapshot) return SnapshotResult.model_validate_json(body) - async def attachment(self, snapshot_id: int, position: int, data: bytes) -> None: + async def attachment(self, snapshot_id: int, position: int, data: bytes, *, url: str) -> None: response = await self._client.put( f"/snapshots/{snapshot_id}/attachments/{position}", + params={"url": url}, content=data, headers={"Content-Type": "application/octet-stream"}, ) diff --git a/docs/api.md b/docs/api.md index e78d8bb..57e1728 100644 --- a/docs/api.md +++ b/docs/api.md @@ -101,13 +101,15 @@ | 接口 | 说明 | | --- | --- | | `GET /crawler/work` | 领活:到期要抓的来源和配置,管理员要试抓的地址。发出去的活带租约,到期前不再发;抓取节点中途断了,租约过期后重新发 | -| `POST /crawler/sources/{id}/listing` | 交来源列表页上的条目(网址、标题、日期)。返回这个来源要接着处理的通知:没初筛的;要抓正文的,也就是初筛通过、还没抽取的,以及项目还没截止、一天没抓过的([总体设计 §7.4](design.md#74-发现变化)) | +| `POST /crawler/sources/{id}/listing` | 交来源列表页上的条目(网址、标题、日期)。返回这个来源要接着处理的通知:没初筛的;要抓正文的,也就是初筛通过但最新快照还没抽取的,以及项目还没截止、最新快照有附件下载失败或文件未补传,且一天没抓过的([总体设计 §7.4](design.md#74-发现变化)) | | `POST /crawler/sources/{id}/screening` | 交一批通知的初筛分数和是否通过,带模型用量 | -| `POST /crawler/notices/{id}/snapshot` | 交抓到的正文、附件的名称、地址和文字。文字和上一份快照相同就不存新的。返回快照的编号和还缺文件的附件;要抽取时(有了新快照,或者上一份还没抽取),再附上抽取要对照的东西:通知的发布日期、这所学校在院校清单里的学院、这所学校发布那年到后两年这几届的项目(编号、学院、届、类型、名称、报名时间) | -| `PUT /crawler/snapshots/{id}/attachments/{position}` | 上传一个附件的文件,请求体就是文件本身 | +| `POST /crawler/notices/{id}/snapshot` | 交抓到的正文、附件的名称、地址和文字,附件下载失败时附上原因。文字和上一份快照相同就不存新的,下载错误等元数据仍更新。返回快照的编号和还缺文件的附件;要抽取时(有了新快照,或者上一份还没抽取),再附上抽取要对照的东西:通知的发布日期、这所学校在院校清单里的学院、这所学校发布那年到后两年这几届的项目(编号、学院、届、类型、名称、报名时间) | +| `PUT /crawler/snapshots/{id}/attachments/{position}` | 上传一个附件的文件,请求体就是文件本身,查询参数带原下载链接;链接已更新时拒绝旧文件上传 | | `POST /crawler/snapshots/{id}/extraction` | 交抽取的结果,带模型用量。结果是通知涉及的项目,每个带编号(新建的没有)、学院、届、类型、名称、作用和字段,格式见[总体设计 §8.2](design.md#82-抽取);网站按 §8.3、§8.4 校验和归并 | | `POST /crawler/notices/{id}/removed` | 通知打不开(404、410),标成原文已删除;之后又能打开的,交新快照时取消 | | `POST /crawler/runs` | 交一个来源这一轮的结果:开始和结束时间、成功还是失败和原因、列表里几条、新通知几条、其中有用的几条、哪些通知没抓成和原因。交了才算这一轮结束,失败了也等下一轮再抓 | | `POST /crawler/probes/{id}` | 交试抓的结果:抓到的条目和生成的配置,或者失败的原因,带模型用量 | 一个来源的一轮:领活 → 抓列表页、交列表 → 对没初筛的初筛、交分数 → 对要抓正文的抓正文和附件、交快照、上传附件 → 要抽取的抽取、交结果 → 交这一轮的结果,没抓成的通知和原因写在里面。重抓正文也在来源的这一轮里,守同样的频率,失败了一样记进抓取记录。 + +单个附件下载失败仍保留名称、原下载链接和顺序,附件文字留空,原因写入 `error`:验证码明确标注需要验证码,接口的短错误提示照原文保存,取不到提示时记录 HTTP 状态或传输失败原因。网站不要求上传这些附件;最新快照仍有下载错误或缺文件的通知,即使抽取没有项目,也按一天一次的间隔在来源后续轮次里重抓,下载成功后清除错误并补传文件。同位置、同链接已有的文字和原件在下载失败时保留;仅下载错误变化不创建新快照、不重复抽取。文字不变而下载链接变化时刷新元数据,清除原链接的文件,等待补传。 diff --git a/docs/data-model.md b/docs/data-model.md index 2ba1c2d..c259e1a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -36,7 +36,7 @@ | `crawl_runs` | `ingest` | 一个来源每一轮的结果,抓取看板用:`failure` 为空就是列表读到了,`detail` 写具体原因;各项计数;`notice_failures` 是没抓成的通知和原因 | | `notices` | `ingest` | 列表里的一条通知,`(source_id, url)` 唯一。`screening_score` 和 `screened_in` 是初筛的分数和结论,为空就是还没初筛;`checked_at` 是上次抓正文的时间,重抓用;`removed_at` 有值是原文已删除。抽取出项目的才有版本,没有版本的就是无关 | | `snapshots` | `ingest` | 某次抓到的正文,文字和上一份相同就不存新的;`extracted_at` 为空说明还没交抽取的结果 | -| `attachments` | `ingest` | 快照里的附件:名称、地址、类型、提取出的文字和文件本身。`position` 是在页面上的顺序,证据里的 `attachment` 指的就是它;文件在抓取节点上传后才有 | +| `attachments` | `ingest` | 快照里的附件:名称、地址、类型、提取出的文字和文件本身。`position` 是在页面上的顺序,证据里的 `attachment` 指的就是它;文件在抓取节点上传后才有。`error` 记录下载失败原因,成功时为空,和原文文字分开保存 | | `model_calls` | `ingest` | 判别模型和 LLM 的用量,随产出的结果一起交来 | ## 项目 diff --git a/docs/design.md b/docs/design.md index ebfbbd2..0a495e4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -160,7 +160,7 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。 ### 7.3 频率和礼貌 - 平时每个来源每天抓 2 次;3–9 月和 12–1 月是旺季,每 4 小时一次。 -- 同一个网站同一时间只发一个请求,两次请求之间隔 5 秒;请求头写明是鼠钟,附上联系方式。 +- 同一个网站同时只进行一次普通请求、附件下载或完整的浏览器页面访问;一次完成后等 5 秒,再开始下一次。浏览器的一次访问包含同站的脚本挑战、重载和页面子资源,子资源按浏览器正常方式加载;跨站跳转的排队规则见[开发约定 §2](development.md#2-抓取和判别)。请求头写明是鼠钟,附上联系方式。 - 不读 robots.txt:抓的都是公开的通知列表,礼貌靠上面这几条保证;有的学院官网一请求 robots.txt 就断开连接(比如西电),照它的规矩反而一页都抓不了。 - 失败了等下一轮再抓,一轮里不反复重试。 @@ -169,7 +169,7 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。 - **新通知**:列表里出现没见过的链接,直接比网址。 - **正文变化**:还没截止的项目,每天重抓一次它的通知,在它的来源那一轮里一起抓;正文的文字和上一份快照不同,就存一份新快照,再交给抽取。 - **删除**:重抓时通知打不开(404、410),标成「原文已删除」,快照照样能看。从列表里消失不算:列表会翻页,旧通知本来就会被挤下去。 -- **附件**:PDF、Word、Excel 和图片都下载下来,解析出文字,图片做文字识别;附件和正文一起存进快照。 +- **附件**:PDF、Word、Excel 和图片都下载下来,解析出文字,图片做文字识别;附件和正文一起存进快照。单个附件下载失败不影响通知:保留名称、原下载链接和失败原因;需要验证码的明确标注,不破解。接口返回的短错误提示作为原因保存,不作为附件正文或文件。最新快照仍有下载错误或缺文件的通知,即使抽取没有项目,也在来源后续轮次里按一天一次的间隔重抓。 ### 7.5 抓取看板 diff --git a/docs/development.md b/docs/development.md index e8b195f..e957a29 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,13 +59,29 @@ crawler/ 抓取节点 - 接入来源时按通用抓取 → 配置 → 浏览器抓取 → 专用适配器的顺序试,前一种能抓就不用后一种([总体设计 §7.2](design.md#72-怎么抓))。 - 建站系统的模板放在代码里,和它的测试在一起;单个来源的配置存在网站的数据库里,在管理页面改,抓取节点领活时一起拿到。 -- 所有对来源网站的请求都经过 `crawl/fetch.py` 的 `Fetcher`,整个进程共用一个,[总体设计 §7.3](design.md#73-频率和礼貌)的规矩由它保证:UA 写明鼠钟;同一个网站一次一个请求,多个任务同时抓也一样;跳转的每一步单独发,跳到别的网站按那个网站的间隔排队。浏览器抓取打开页面前,也要先过这些规矩。 +- 所有对来源网站的访问都经过 `crawl/fetch.py` 的 `Fetcher`,整个进程共用一个,[总体设计 §7.3](design.md#73-频率和礼貌)的规矩由它保证:UA 写明鼠钟;普通 HTTP 请求、完整的浏览器页面访问和程序下载的附件共用按网站排队的许可,多个任务同时抓也一样;上一次访问完成并释放许可后,再等 5 秒才开始下一次。 +- 浏览器的 `get(url)` 在顶层导航前取得网站许可,持有到页面关闭,覆盖同站的 HTTP 跳转、脚本挑战和重载;页面子资源按浏览器正常方式加载。顶层导航跨 host 时,先释放原站许可,再等待目标站的许可。普通 HTTP 请求和附件的跳转每一步单独取得许可,跳到别的网站按那个网站的间隔排队。 +- `crawl_source` 每轮通过 `fetcher.session(source.browser)` 创建一个 `PageSession`,交给 `Steps.list_items` 和 `Steps.read`:`get(url)` 取页面,`download(url, page_url=原文网址)` 下载附件,两者都返回 `Response`。普通来源直接请求;`browser` 为真的来源用浏览器取渲染后的 HTML,以 UTF-8 字节返回。列表、正文和附件的解析仍由对应步骤负责。 +- 读取步骤用 `crawl/attachments.py` 的 `download_attachment` 下载附件:识别验证码、HTTP 和传输错误、接口返回的 HTML 错误提示,失败时返回原链接和独立的 `error`,文件为空;成功时保留原始字节,文字交给附件解析。错误页不上传为文件,错误原因不进入抽取用的 `text`,也不把整条通知标成已删除。日志只记下载失败和链接,不写源站返回的文字。 - 浏览器抓取启动时关掉 Chromium 的自动化标记(`--disable-blink-features=AutomationControlled`),UA 用浏览器自己的,把 `HeadlessChrome` 写成 `Chrome`,末尾加上鼠钟的标识;瑞数这类脚本挑战两处都检查([总体设计 §5.2](design.md#52-实测))。不冒充别的浏览器和系统。 -- 一轮里同一个网站共用一个无痕会话,抓完就关:脚本挑战设的 Cookie 在会话里有效,后面的页面不用再过挑战;不同网站、不同轮次之间不共享 Cookie。同时开的浏览器页面有上限,多的排队;拒绝弹窗和下载,附件由程序另外下载;不加载图片、字体和音视频,省对方的流量,也省内存。 +- 浏览器来源每一轮共用一个无痕会话,列表、通知、跨 host 跳转和附件沿用它,抓完就关,失败或取消也关闭;不同来源、不同轮次不共享会话。Cookie 由浏览器按 domain、path 和 secure 规则筛选;附件使用原文所在会话的请求客户端下载,共享浏览器管理的 Cookie。 +- 整个进程共用一个 Chromium 无头精简版,首次读取浏览器页面时才启动;同时最多开 2 个页面,多的排队。拒绝弹窗和浏览器下载;不加载图片、字体和音视频,省对方的流量,也省内存。 +- 浏览器页面等到最终文档已提交并触发 `DOMContentLoaded` 后读取 HTML,并确认读取期间未开始新的主文档导航。快照对应这一时刻的 DOM,列表和正文是否有效由解析步骤检查。 +- 普通 HTTP 请求(含普通来源的附件)使用 httpx 的网络操作超时:连接、连接池等待、每次读取和写入最多各等 30 秒,不是整次传输的总期限。浏览器来源的附件每个跳转请求总期限为 30 秒,从取得许可后开始,不含等待站点许可和间隔。 +- 浏览器页面总期限为 300 秒,从取得页面名额后开始,包含页面创建、站点许可排队、脚本挑战刷新和 DOM 就绪;等待页面名额不计入。期限内等不到最终页面就按超时记录,不把最初的 202、412 挑战页当成成功结果。成功、失败或取消后都先回收已发出的创建操作及页面,再释放网站许可和页面名额。 - 解析和模型分工:`parse` 用程序取出能确定的东西,模型只做判断([总体设计 §8.2](design.md#82-抽取))。解析不猜:判断不了的交给模型或人,不硬配。 - 判别模型和 LLM 的提示词放在调用它的代码旁边。输出都按固定的结构解析,解析不了的当失败处理。 - 每一轮抓取都交一份抓取记录,失败也交,写明原因;不在一轮里反复重试。各步抓不成时抛 `FetchError`(`Fetcher` 抛的)或 `StepError`(写明失败类别,比如 `http_status` 和「HTTP 403」),一轮的流程把它记进抓取记录;别的异常是 bug,照常抛出。 +本地使用浏览器抓取时,在 `crawler/` 运行: + +```sh +uv sync +uv run playwright install --with-deps --only-shell chromium +``` + +第二条安装 Chromium 无头精简版及系统依赖。CI 不安装浏览器:自动测试使用假的浏览器事件、固定响应和假时钟,不连接真实网站;真实浏览器和来源网站的验证在本地进行,结果写进 PR。 + ### 2.1 列表配置 特殊列表通过 `ListConfig` 校验 `WorkSource.config`,管理员与 #46 的试抓生成使用同一格式。例如只能用 HTTP 的浙大计算机学院列表: diff --git a/web/db/migrations/0001_init.sql b/web/db/migrations/0001_init.sql index 257a762..04f2329 100644 --- a/web/db/migrations/0001_init.sql +++ b/web/db/migrations/0001_init.sql @@ -141,6 +141,7 @@ CREATE TABLE attachments ( url text NOT NULL, content_type text NOT NULL, text text NOT NULL, -- extracted, or recognised in images + error text, -- why the attachment could not be downloaded data bytea, -- NULL until the crawler uploads the file PRIMARY KEY (snapshot_id, position) ); diff --git a/web/db/types.ts b/web/db/types.ts index b8a7cce..83832fe 100644 --- a/web/db/types.ts +++ b/web/db/types.ts @@ -26,6 +26,7 @@ export type Timestamp = ColumnType; export interface Attachments { content_type: string; data: Buffer | null; + error: string | null; name: string; position: number; snapshot_id: number; diff --git a/web/openapi/crawler.json b/web/openapi/crawler.json index 0ed5c1c..83a94a7 100644 --- a/web/openapi/crawler.json +++ b/web/openapi/crawler.json @@ -191,7 +191,7 @@ "screen", "fetch" ], - "description": "screen:还没初筛;fetch:要抓正文,初筛通过还没抽取的,或者项目还没截止、一天没抓过的" + "description": "screen:还没初筛;fetch:初筛通过但最新快照还没抽取的,或者项目还没截止、最新快照有附件下载失败或文件未补传,且一天没抓过的" } }, "required": [ @@ -600,6 +600,12 @@ "text": { "type": "string", "maxLength": 2097152 + }, + "error": { + "type": "string", + "minLength": 1, + "maxLength": 2000, + "description": "附件下载失败原因;不写表示下载正常,不影响通知正文和其他附件" } }, "required": [ @@ -1552,6 +1558,16 @@ "required": false, "name": "position", "in": "path" + }, + { + "schema": { + "type": "string", + "maxLength": 2000, + "format": "uri" + }, + "required": true, + "name": "url", + "in": "query" } ], "requestBody": { diff --git a/web/server/modules/ingest/routes.test.ts b/web/server/modules/ingest/routes.test.ts index e91771a..8a683d8 100644 --- a/web/server/modules/ingest/routes.test.ts +++ b/web/server/modules/ingest/routes.test.ts @@ -1,3 +1,4 @@ +import type { InferRequestType } from "hono/client"; import { testClient } from "hono/testing"; import { beforeAll, expect, test } from "vitest"; import { app } from "../../app.ts"; @@ -5,6 +6,15 @@ import { db } from "../../platform/db.ts"; import { createCrawlerToken } from "./service.ts"; const api = testClient(app).api.v1.crawler; +type AttachmentInfo = InferRequestType< + (typeof api.notices)[":id"]["snapshot"]["$post"] +>["json"]["attachments"][number]; +const file = { + name: "申请表.docx", + url: "https://x.edu.cn/f.docx", + content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + text: "表", +}; let crawler = { headers: {} }; @@ -45,25 +55,15 @@ async function list(sourceId: number, items: { url: string; title: string }[]) { return res.json(); } -async function snapshot(noticeId: number, attachmentText?: string) { +async function snapshot(noticeId: number, attachments: AttachmentInfo[] = [], text = "正文") { const res = await api.notices[":id"].snapshot.$post( { param: { id: String(noticeId) }, json: { fetched_at: 1780000000, html: "

正文

", - text: "正文", - attachments: - attachmentText === undefined - ? [] - : [ - { - name: "申请表.docx", - url: "https://x.edu.cn/f.docx", - content_type: "x", - text: attachmentText, - }, - ], + text, + attachments, }, }, crawler, @@ -222,10 +222,289 @@ test("the same words make no new snapshot, changed attachment text does", async const { pending } = await list(source.id, [{ url: "https://c.edu.cn/1.htm", title: "推免通知" }]); const noticeId = pending[0].id; - const first = await snapshot(noticeId, "表"); + const first = await snapshot(noticeId, [file]); expect(first).toMatchObject({ missing_files: [0], extraction: { colleges: [{}] } }); - expect(await snapshot(noticeId, "表")).toEqual(first); - expect((await snapshot(noticeId, "新表")).snapshot_id).not.toBe(first.snapshot_id); + expect(await snapshot(noticeId, [file])).toEqual(first); + expect((await snapshot(noticeId, [{ ...file, text: "新表" }])).snapshot_id).not.toBe( + first.snapshot_id, + ); +}); + +test("attachment download errors keep their links and do not require files", async () => { + const source = await newSource("attachments-error.edu.cn"); + const [notice] = ( + await list(source.id, [{ url: "https://attachments-error.edu.cn/1.htm", title: "通知" }]) + ).pending; + const attachments = [ + { ...file, text: "", error: "需要验证码" }, + { ...file, url: "https://x.edu.cn/deleted.docx", text: "", error: "获取文件错误" }, + ]; + const first = await snapshot(notice.id, attachments); + expect(first).toMatchObject({ missing_files: [], extraction: { colleges: [{}] } }); + expect(await snapshot(notice.id, attachments)).toEqual(first); + expect( + await db.selectFrom("snapshots").select("id").where("notice_id", "=", notice.id).execute(), + ).toHaveLength(1); + const stored = await db + .selectFrom("attachments") + .select(["position", "url", "text", "error", "data"]) + .where("snapshot_id", "=", first.snapshot_id) + .orderBy("position") + .execute(); + expect(stored).toEqual( + attachments.map((attachment, position) => ({ + position, + url: attachment.url, + text: "", + error: attachment.error, + data: null, + })), + ); +}); + +test("error updates and recovery reuse a text snapshot and request recovered files", async () => { + const source = await newSource("attachments-recovery.edu.cn"); + const [notice] = ( + await list(source.id, [{ url: "https://attachments-recovery.edu.cn/1.htm", title: "通知" }]) + ).pending; + const attachment = { ...file, text: "" }; + const first = await snapshot(notice.id, [{ ...attachment, error: "需要验证码" }]); + await db + .updateTable("snapshots") + .set({ extracted_at: new Date() }) + .where("id", "=", first.snapshot_id) + .execute(); + expect(await snapshot(notice.id, [{ ...attachment, error: "HTTP 503" }])).toEqual({ + snapshot_id: first.snapshot_id, + missing_files: [], + extraction: null, + }); + expect( + await db + .selectFrom("attachments") + .select("error") + .where("snapshot_id", "=", first.snapshot_id) + .executeTakeFirstOrThrow(), + ).toEqual({ error: "HTTP 503" }); + expect(await snapshot(notice.id, [attachment])).toEqual({ + snapshot_id: first.snapshot_id, + missing_files: [0], + extraction: null, + }); + expect( + await db + .selectFrom("attachments") + .select("error") + .where("snapshot_id", "=", first.snapshot_id) + .executeTakeFirstOrThrow(), + ).toEqual({ error: null }); + await db + .updateTable("attachments") + .set({ data: Buffer.from("original file") }) + .where("snapshot_id", "=", first.snapshot_id) + .execute(); + expect(await snapshot(notice.id, [attachment])).toEqual({ + snapshot_id: first.snapshot_id, + missing_files: [], + extraction: null, + }); +}); + +test("failed downloads preserve known attachment text and files without changing old snapshots", async () => { + const source = await newSource("attachments-known.edu.cn"); + const [notice] = ( + await list(source.id, [{ url: "https://attachments-known.edu.cn/1.htm", title: "通知" }]) + ).pending; + const first = await snapshot(notice.id, [file]); + const data = Buffer.from("original file"); + await db + .updateTable("attachments") + .set({ data }) + .where("snapshot_id", "=", first.snapshot_id) + .execute(); + await db + .updateTable("snapshots") + .set({ extracted_at: new Date() }) + .where("id", "=", first.snapshot_id) + .execute(); + const failed = { ...file, text: "", content_type: "text/html", error: "需要验证码" }; + expect(await snapshot(notice.id, [failed])).toEqual({ + snapshot_id: first.snapshot_id, + missing_files: [], + extraction: null, + }); + expect( + await db + .selectFrom("attachments") + .select(["text", "content_type", "error", "data"]) + .where("snapshot_id", "=", first.snapshot_id) + .executeTakeFirstOrThrow(), + ).toEqual({ text: file.text, content_type: file.content_type, error: failed.error, data }); + const next = await snapshot(notice.id, [{ ...failed, error: "HTTP 503" }], "修改后的正文"); + expect(next.snapshot_id).not.toBe(first.snapshot_id); + expect(next.missing_files).toEqual([]); + expect( + await db + .selectFrom("attachments") + .select(["text", "error", "data"]) + .where("snapshot_id", "=", next.snapshot_id) + .executeTakeFirstOrThrow(), + ).toEqual({ text: file.text, error: "HTTP 503", data }); + expect( + await db + .selectFrom("attachments") + .select("error") + .where("snapshot_id", "=", first.snapshot_id) + .executeTakeFirstOrThrow(), + ).toEqual({ error: failed.error }); +}); + +test("attachment URL and order updates keep text versions and discard mismatched files", async () => { + const source = await newSource("attachments-order.edu.cn"); + const [notice] = ( + await list(source.id, [{ url: "https://attachments-order.edu.cn/1.htm", title: "通知" }]) + ).pending; + const attachments = [ + { ...file, text: "" }, + { ...file, url: "https://x.edu.cn/g.docx", text: "" }, + ]; + const first = await snapshot(notice.id, attachments); + await db + .updateTable("attachments") + .set({ data: Buffer.from("original file") }) + .where("snapshot_id", "=", first.snapshot_id) + .execute(); + const reordered = [{ ...attachments[1], error: "需要验证码" }, attachments[0]]; + expect(await snapshot(notice.id, reordered)).toMatchObject({ + snapshot_id: first.snapshot_id, + missing_files: [1], + }); + expect( + await db + .selectFrom("attachments") + .select(["position", "url", "error", "data"]) + .where("snapshot_id", "=", first.snapshot_id) + .orderBy("position") + .execute(), + ).toEqual( + reordered.map((attachment, position) => ({ + position, + url: attachment.url, + error: "error" in attachment ? attachment.error : null, + data: null, + })), + ); +}); + +test("a delayed attachment upload cannot overwrite a replacement URL", async () => { + const source = await newSource("attachments-stale.edu.cn"); + const [notice] = ( + await list(source.id, [{ url: "https://attachments-stale.edu.cn/1.htm", title: "通知" }]) + ).pending; + const original = { ...file, text: "" }; + const first = await snapshot(notice.id, [original]); + const replacement = { ...original, url: "https://x.edu.cn/replacement.docx" }; + expect(await snapshot(notice.id, [replacement])).toMatchObject({ + snapshot_id: first.snapshot_id, + missing_files: [0], + }); + const upload = (url: string, data: string) => + app.request( + `/api/v1/crawler/snapshots/${first.snapshot_id}/attachments/0?${new URLSearchParams({ url })}`, + { + method: "PUT", + headers: { ...crawler.headers, "content-type": "application/octet-stream" }, + body: data, + }, + ); + expect((await upload(original.url, "stale file")).status).toBe(404); + expect( + await db + .selectFrom("attachments") + .select("data") + .where("snapshot_id", "=", first.snapshot_id) + .executeTakeFirstOrThrow(), + ).toEqual({ data: null }); + expect((await upload(replacement.url, "replacement file")).status).toBe(204); + expect( + await db + .selectFrom("attachments") + .select("data") + .where("snapshot_id", "=", first.snapshot_id) + .executeTakeFirstOrThrow(), + ).toEqual({ data: Buffer.from("replacement file") }); +}); + +test("failed attachments are fetched once a day even when extraction found no programs", async () => { + const source = await newSource("attachments-retry.edu.cn"); + const items = [{ url: "https://attachments-retry.edu.cn/1.htm", title: "通知" }]; + const [notice] = (await list(source.id, items)).pending; + await api.sources[":id"].screening.$post( + { + param: { id: String(source.id) }, + json: { results: [{ notice_id: notice.id, score: 0.9, screened_in: true }], usage: [] }, + }, + crawler, + ); + const failed = { ...file, text: "", error: "HTTP 503" }; + const first = await snapshot(notice.id, [failed]); + const extracted = await api.snapshots[":id"].extraction.$post( + { param: { id: String(first.snapshot_id) }, json: { programs: [], usage: [] } }, + crawler, + ); + expect(extracted.status).toBe(204); + expect((await list(source.id, items)).pending).toEqual([]); + + const dayAgo = new Date(Date.now() - 25 * 3600_000); + await db.updateTable("notices").set({ checked_at: dayAgo }).where("id", "=", notice.id).execute(); + expect((await list(source.id, items)).pending).toMatchObject([{ id: notice.id, step: "fetch" }]); + + const recovered = await snapshot(notice.id, [{ ...file, text: "" }]); + expect(recovered).toEqual({ + snapshot_id: first.snapshot_id, + missing_files: [0], + extraction: null, + }); + await db.updateTable("notices").set({ checked_at: dayAgo }).where("id", "=", notice.id).execute(); + expect((await list(source.id, items)).pending).toMatchObject([{ id: notice.id, step: "fetch" }]); + const uploaded = await app.request( + `/api/v1/crawler/snapshots/${first.snapshot_id}/attachments/0?${new URLSearchParams({ url: file.url })}`, + { + method: "PUT", + headers: { ...crawler.headers, "content-type": "application/octet-stream" }, + body: "recovered file", + }, + ); + expect(uploaded.status).toBe(204); + expect((await list(source.id, items)).pending).toEqual([]); + + await snapshot(notice.id, [failed]); + const next = await snapshot(notice.id, [file]); + expect(next.snapshot_id).not.toBe(first.snapshot_id); + const nextUploaded = await app.request( + `/api/v1/crawler/snapshots/${next.snapshot_id}/attachments/0?${new URLSearchParams({ url: file.url })}`, + { + method: "PUT", + headers: { ...crawler.headers, "content-type": "application/octet-stream" }, + body: "new file", + }, + ); + expect(nextUploaded.status).toBe(204); + expect((await list(source.id, items)).pending).toMatchObject([{ id: notice.id, step: "fetch" }]); + const nextExtracted = await api.snapshots[":id"].extraction.$post( + { param: { id: String(next.snapshot_id) }, json: { programs: [], usage: [] } }, + crawler, + ); + expect(nextExtracted.status).toBe(204); + expect( + await db + .selectFrom("attachments") + .select("error") + .where("snapshot_id", "=", first.snapshot_id) + .executeTakeFirstOrThrow(), + ).toEqual({ error: failed.error }); + await db.updateTable("notices").set({ checked_at: dayAgo }).where("id", "=", notice.id).execute(); + expect((await list(source.id, items)).pending).toEqual([]); }); test("a notice of a program still open comes back to be fetched once a day", async () => { diff --git a/web/server/modules/ingest/routes.ts b/web/server/modules/ingest/routes.ts index 1dad207..bc03217 100644 --- a/web/server/modules/ingest/routes.ts +++ b/web/server/modules/ingest/routes.ts @@ -130,7 +130,7 @@ const ListingResult = z published_on: IsoDate.nullable(), step: z.enum(["screen", "fetch"]).openapi({ description: - "screen:还没初筛;fetch:要抓正文,初筛通过还没抽取的,或者项目还没截止、一天没抓过的", + "screen:还没初筛;fetch:初筛通过但最新快照还没抽取的,或者项目还没截止、最新快照有附件下载失败或文件未补传,且一天没抓过的", }), }) .openapi("PendingNotice"), @@ -287,6 +287,10 @@ export const ingestRoutes = router url: Url, content_type: z.string().min(1).max(200), text: z.string().max(2 * MB), + error: z.string().min(1).max(2000).optional().openapi({ + description: + "附件下载失败原因;不写表示下载正常,不影响通知正文和其他附件", + }), }) .openapi("AttachmentInfo"), ) @@ -323,6 +327,7 @@ export const ingestRoutes = router security, request: { params: z.object({ id: IntParam, position: IntParam }), + query: z.object({ url: Url }), body: { content: { "application/octet-stream": { schema: z.string().openapi({ format: "binary" }) }, @@ -338,7 +343,12 @@ export const ingestRoutes = router }), async (c) => { const { id, position } = c.req.valid("param"); - await storeAttachmentFile(id, position, Buffer.from(await c.req.arrayBuffer())); + await storeAttachmentFile( + id, + position, + c.req.valid("query").url, + Buffer.from(await c.req.arrayBuffer()), + ); return c.body(null, 204); }, ) diff --git a/web/server/modules/ingest/service.ts b/web/server/modules/ingest/service.ts index 3c9e39b..c201494 100644 --- a/web/server/modules/ingest/service.ts +++ b/web/server/modules/ingest/service.ts @@ -20,8 +20,8 @@ const LEASE_MS = 2 * 3600_000; const INTERVAL_MS = 12 * 3600_000; const BUSY_INTERVAL_MS = 4 * 3600_000; const BUSY_MONTHS = new Set([3, 4, 5, 6, 7, 8, 9, 12, 1]); -// Notices of programs still open are fetched again once a day to catch changes (design §7.4), -// in their source's round; those without a deadline, for this long after the program appeared. +// Incomplete attachments and notices of programs still open are fetched daily (design §7.4), +// in their source's round; programs without a deadline are watched for this long after creation. const RECHECK_MS = 24 * 3600_000; const UNDATED_RECHECK_MS = 90 * 24 * 3600_000; const MAX_SOURCES = 50; @@ -108,7 +108,7 @@ async function recordUsage( // Saves what the list showed and returns the source's notices that still need work: new ones // to screen; screened-in ones not yet extracted, which a round cut short leaves behind; and -// those of programs still open, not fetched for a day (design §7.4). +// those with incomplete attachments or programs still open, not fetched for a day (design §7.4). export async function recordListing(sourceId: number, items: ListedItem[]) { const now = new Date(); return db.transaction().execute(async (trx) => { @@ -131,44 +131,53 @@ export async function recordListing(sourceId: number, items: ListedItem[]) { .execute(); const pending = await trx .selectFrom("notices as n") + .leftJoinLateral( + (eb) => + eb + .selectFrom("snapshots") + .select(["id", "extracted_at"]) + .whereRef("notice_id", "=", "n.id") + .orderBy("id", "desc") + .limit(1) + .as("latest"), + (join) => join.onTrue(), + ) .select(["n.id", "n.url", "n.title", "n.published_on", "n.screening_score"]) .where("n.source_id", "=", sourceId) .where("n.removed_at", "is", null) .where((eb) => eb.or([ eb("n.screening_score", "is", null), + eb.and([eb("n.screened_in", "=", true), eb("latest.extracted_at", "is", null)]), eb.and([ - eb("n.screened_in", "=", true), - eb.not( + eb("n.checked_at", "<", new Date(now.getTime() - RECHECK_MS)), + eb.or([ eb.exists( eb .selectFrom("snapshots as s") - .select("s.id") + .innerJoin("program_versions as v", "v.snapshot_id", "s.id") + .innerJoin("programs as p", "p.id", "v.program_id") + .select("p.id") .whereRef("s.notice_id", "=", "n.id") - .where("s.extracted_at", "is not", null), - ), - ), - ]), - eb.and([ - eb("n.checked_at", "<", new Date(now.getTime() - RECHECK_MS)), - eb.exists( - eb - .selectFrom("snapshots as s") - .innerJoin("program_versions as v", "v.snapshot_id", "s.id") - .innerJoin("programs as p", "p.id", "v.program_id") - .select("p.id") - .whereRef("s.notice_id", "=", "n.id") - .where("p.deleted_at", "is", null) - .where((qb) => - qb.or([ - qb("p.apply_deadline", ">", now), - qb.and([ - qb("p.apply_deadline", "is", null), - qb("p.created_at", ">", new Date(now.getTime() - UNDATED_RECHECK_MS)), + .where("p.deleted_at", "is", null) + .where((qb) => + qb.or([ + qb("p.apply_deadline", ">", now), + qb.and([ + qb("p.apply_deadline", "is", null), + qb("p.created_at", ">", new Date(now.getTime() - UNDATED_RECHECK_MS)), + ]), ]), - ]), - ), - ), + ), + ), + eb.exists( + eb + .selectFrom("attachments as a") + .select("a.position") + .whereRef("a.snapshot_id", "=", "latest.id") + .where((qb) => qb.or([qb("a.error", "is not", null), qb("a.data", "is", null)])), + ), + ]), ]), ]), ) @@ -203,7 +212,13 @@ export async function recordScreening( }); } -type AttachmentSubmission = { name: string; url: string; content_type: string; text: string }; +type AttachmentSubmission = { + name: string; + url: string; + content_type: string; + text: string; + error?: string; +}; // A new snapshot only when the words changed, the attachments' included (design §7.4). export async function recordSnapshot( @@ -226,21 +241,28 @@ export async function recordSnapshot( .orderBy("id", "desc") .limit(1) .executeTakeFirst(); - const latestTexts = - latest && - ( - await trx + const latestAttachments = latest + ? await trx .selectFrom("attachments") - .select("text") + .select(["url", "content_type", "text"]) .where("snapshot_id", "=", latest.id) .orderBy("position") .execute() - ).map((a) => a.text); + : []; + const attachments = submission.attachments.map((attachment, position) => { + const previous = latestAttachments.at(position); + // A failed download tells us nothing new about the file's existing words or type. + return attachment.error !== undefined && + attachment.text === "" && + previous?.url === attachment.url + ? { ...attachment, text: previous.text, content_type: previous.content_type } + : attachment; + }); const unchanged = latest !== undefined && latest.text === submission.text && - latestTexts?.length === submission.attachments.length && - latestTexts.every((text, i) => text === submission.attachments[i].text); + latestAttachments.length === attachments.length && + latestAttachments.every((attachment, i) => attachment.text === attachments[i].text); const snapshot = unchanged ? latest @@ -254,17 +276,43 @@ export async function recordSnapshot( }) .returning(["id", "extracted_at"]) .executeTakeFirstOrThrow(); - if (!unchanged && submission.attachments.length > 0) { + if (unchanged) { + for (const [position, attachment] of attachments.entries()) { + await trx + .updateTable("attachments") + .set({ + name: attachment.name, + url: attachment.url, + content_type: attachment.content_type, + error: attachment.error ?? null, + ...(latestAttachments[position].url !== attachment.url ? { data: null } : {}), + }) + .where("snapshot_id", "=", snapshot.id) + .where("position", "=", position) + .execute(); + } + } else if (attachments.length > 0) { await trx .insertInto("attachments") .values( - submission.attachments.map((a, position) => ({ + attachments.map((a, position) => ({ snapshot_id: snapshot.id, position, ...a, })), ) .execute(); + if (latest) { + await sql` + UPDATE attachments AS current SET data = previous.data + FROM attachments AS previous + WHERE current.snapshot_id = ${snapshot.id} + AND previous.snapshot_id = ${latest.id} + AND current.position = previous.position + AND current.url = previous.url AND current.text = previous.text + AND current.error IS NOT NULL + `.execute(trx); + } } await trx .updateTable("notices") @@ -277,6 +325,7 @@ export async function recordSnapshot( .select("position") .where("snapshot_id", "=", snapshot.id) .where("data", "is", null) + .where("error", "is", null) .orderBy("position") .execute(); return { @@ -336,12 +385,18 @@ async function extractionContext( return { published_on: publishedOn, colleges, programs }; } -export async function storeAttachmentFile(snapshotId: number, position: number, data: Buffer) { +export async function storeAttachmentFile( + snapshotId: number, + position: number, + url: string, + data: Buffer, +) { const result = await db .updateTable("attachments") .set({ data }) .where("snapshot_id", "=", snapshotId) .where("position", "=", position) + .where("url", "=", url) .executeTakeFirst(); if (result.numUpdatedRows === 0n) throw new AppError(404, "not_found", "附件不存在"); } From cce0e190665a50d00143d3f2f855fa51e7805922 Mon Sep 17 00:00:00 2001 From: lym12321 <41183806+lym12321@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:49:04 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E7=AE=80=E5=8C=96=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E6=8A=93=E5=8F=96=E5=B9=B6=E7=A7=BB=E9=99=A4=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E5=A4=B1=E8=B4=A5=E4=B8=8A=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/src/crawler/crawl/attachments.py | 115 -- crawler/src/crawler/crawl/attachments_test.py | 157 -- crawler/src/crawler/crawl/browser.py | 451 +----- crawler/src/crawler/crawl/browser_test.py | 1259 +++-------------- crawler/src/crawler/crawl/contract.py | 10 +- crawler/src/crawler/crawl/fetch.py | 43 +- crawler/src/crawler/crawl/fetch_test.py | 18 - crawler/src/crawler/crawl/rounds.py | 20 +- crawler/src/crawler/crawl/rounds_test.py | 151 +- crawler/src/crawler/crawl/website.py | 3 +- docs/api.md | 8 +- docs/data-model.md | 2 +- docs/design.md | 4 +- docs/development.md | 15 +- web/db/migrations/0001_init.sql | 1 - web/db/types.ts | 1 - web/openapi/crawler.json | 18 +- web/server/modules/ingest/routes.test.ts | 311 +--- web/server/modules/ingest/routes.ts | 14 +- web/server/modules/ingest/service.ts | 137 +- 20 files changed, 400 insertions(+), 2338 deletions(-) delete mode 100644 crawler/src/crawler/crawl/attachments.py delete mode 100644 crawler/src/crawler/crawl/attachments_test.py diff --git a/crawler/src/crawler/crawl/attachments.py b/crawler/src/crawler/crawl/attachments.py deleted file mode 100644 index 2dbcc32..0000000 --- a/crawler/src/crawler/crawl/attachments.py +++ /dev/null @@ -1,115 +0,0 @@ -import logging -import re -from dataclasses import dataclass -from html.parser import HTMLParser - -from crawler.crawl.contract import AttachmentInfo -from crawler.crawl.fetch import FetchError, PageSession, Response - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class Attachment: - info: AttachmentInfo - data: bytes | None - - -class _DownloadPage(HTMLParser): - def __init__(self) -> None: - super().__init__() - self.parts: list[str] = [] - self.hidden = 0 - self.office = False - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - if tag == "html": - self.office = any( - (name == "xmlns" or name.startswith("xmlns:")) - and value is not None - and value.startswith("urn:schemas-microsoft-com:office:") - for name, value in attrs - ) - if tag in {"title", "script", "style"}: - self.hidden += 1 - - def handle_endtag(self, tag: str) -> None: - if tag in {"title", "script", "style"}: - self.hidden = max(0, self.hidden - 1) - - def handle_data(self, data: str) -> None: - if not self.hidden: - self.parts.append(data) - - -def _download_error(response: Response) -> str | None: - status = f"HTTP {response.status}" if not 200 <= response.status < 300 else None - # Some download endpoints label Office and PDF files as HTML. - if status is None and response.body.startswith((b"%PDF-", b"\xd0\xcf\x11\xe0", b"PK\x03\x04")): - return None - content_type = response.content_type or "" - charset = re.search(r"charset\s*=\s*['\"]?([\w.-]+)", content_type, re.IGNORECASE) - meta = re.search( - rb"]*\bcharset\s*=\s*['\"]?([\w.-]+)", response.body[:1024], re.IGNORECASE - ) - if response.body.startswith(b"\xef\xbb\xbf"): - encoding = "utf-8-sig" - elif response.body.startswith((b"\xff\xfe", b"\xfe\xff")): - encoding = "utf-16" - elif charset: - encoding = charset[1] - elif meta: - encoding = meta[1].decode("ascii") - else: - encoding = "utf-8-sig" - try: - prefix = response.body[:1024].decode(encoding, errors="replace") - except LookupError: - encoding = "utf-8-sig" - prefix = response.body[:1024].decode(encoding, errors="replace") - html = content_type.split(";")[0].strip().lower() in {"text/html", "application/xhtml+xml"} - html = ( - html - or re.match( - r"<(?:!doctype\s+html|html|head|body|title|meta|script|style|form|div|p|span|pre|table|h[1-6])" - r"(?:\s|/?>)", - prefix.lstrip(), - re.IGNORECASE, - ) - is not None - ) - if not html: - return status or ("附件内容为空" if not response.body else None) - - page = _DownloadPage() - page.feed(response.body.decode(encoding, errors="replace")) - if status is None and page.office: - return None - text = " ".join(" ".join(page.parts).split()) - if "验证码" in text or "captcha" in text.lower(): - return "下载附件需要验证码" - # A whole notice or login page is not a useful attachment failure message. - detail = text if 0 < len(text) <= 1000 else "附件地址未返回文件" - return detail if status is None else f"{status}: {detail}" - - -async def download_attachment( - pages: PageSession, url: str, *, name: str, page_url: str -) -> Attachment: - content_type = "application/octet-stream" - data: bytes | None = None - try: - response = await pages.download(url, page_url=page_url) - except FetchError as failure: - error = failure.failure - else: - content_type = response.content_type or content_type - error = _download_error(response) - if error is None: - data = response.body - if error is not None: - logger.warning("Attachment download failed", extra={"url": url}) - return Attachment( - AttachmentInfo(name=name, url=url, content_type=content_type, text="", error=error), - data, - ) diff --git a/crawler/src/crawler/crawl/attachments_test.py b/crawler/src/crawler/crawl/attachments_test.py deleted file mode 100644 index 1b27116..0000000 --- a/crawler/src/crawler/crawl/attachments_test.py +++ /dev/null @@ -1,157 +0,0 @@ -import asyncio - -import httpx -import pytest - -from crawler.crawl.attachments import download_attachment -from crawler.crawl.fetch import Fetcher - -URL = "https://school.test/download.jsp?file=1" -PAGE_URL = "https://school.test/notice.htm" - - -@pytest.mark.parametrize( - ("body", "content_type", "status", "error"), - [ - ( - '
请输入验证码下载附件
', - "text/html;charset=UTF-8", - 200, - "下载附件需要验证码", - ), - ("
获取文件错误
", "text/html;charset=UTF-8", 200, "获取文件错误"), - ("

附件已删除

", "text/html;charset=gb2312", 200, "附件已删除"), - ( - '

文件已删除

', - "text/html;charset=gb2312", - 200, - "文件已删除", - ), - ( - "

文件已删除

".encode("utf-8-sig"), - "text/html;charset=gb2312", - 200, - "文件已删除", - ), - ( - ( - '请输入验证码下载附件' - ).encode("gb2312"), - "text/html", - 200, - "下载附件需要验证码", - ), - ( - ( - '' - "

文件已删除

" - ).encode("gb2312"), - "text/html", - 200, - "文件已删除", - ), - ( - "

请输入验证码下载附件

".encode("utf-16"), - None, - 200, - "下载附件需要验证码", - ), - ( - "下载请输入验证码下载附件", - "text/html;charset=UTF-8", - 200, - "下载附件需要验证码", - ), - ( - "下载

文件已删除

", - "text/html;charset=UTF-8", - 200, - "文件已删除", - ), - ("

文件不存在

", "text/html", 404, "HTTP 404: 文件不存在"), - ("", None, 200, "附件地址未返回文件"), - ( - "文件已删除", - "application/octet-stream", - 200, - "文件已删除", - ), - ("\ufeff\n
获取文件错误
", None, 200, "获取文件错误"), - ("", "application/pdf", 200, "附件内容为空"), - ("denied", "text/plain", 403, "HTTP 403"), - ], -) -def test_download_errors_keep_the_link_and_never_keep_an_error_page_as_a_file( - body: str | bytes, content_type: str | None, status: int, error: str -) -> None: - encoding = "gb2312" if content_type and "gb2312" in content_type else "utf-8" - content = body.encode(encoding) if isinstance(body, str) else body - headers = {"content-type": content_type} if content_type else {} - - async def main() -> None: - transport = httpx.MockTransport( - lambda _: httpx.Response(status, content=content, headers=headers) - ) - async with Fetcher(transport) as fetcher, fetcher.session(False) as pages: - attachment = await download_attachment(pages, URL, name="申请表.doc", page_url=PAGE_URL) - assert attachment.info.url == URL - assert attachment.info.name == "申请表.doc" - assert attachment.info.text == "" - assert attachment.info.error == error - assert attachment.data is None - - asyncio.run(main()) - - -@pytest.mark.parametrize( - ("body", "content_type"), - [ - (b"%PDF-1.7\n", "text/html"), - (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "text/html"), - (b"PK\x03\x04", "text/html"), - ( - '' - '' - '申请表' - "
".encode(), - "application/vnd.ms-excel", - ), - ( - '' - "申请表" - "
申请表
".encode(), - "application/vnd.ms-excel", - ), - ], -) -def test_downloads_keep_binary_and_office_files_after_redirects( - body: bytes, content_type: str -) -> None: - async def main() -> None: - def handle(request: httpx.Request) -> httpx.Response: - if request.url.path == "/download.jsp": - return httpx.Response(302, headers={"location": "https://files.test/form"}) - return httpx.Response(200, content=body, headers={"content-type": content_type}) - - async with Fetcher(httpx.MockTransport(handle)) as fetcher, fetcher.session(False) as pages: - attachment = await download_attachment(pages, URL, name="申请表", page_url=PAGE_URL) - assert attachment.info.url == URL - assert attachment.info.error is None - assert attachment.data == body - - asyncio.run(main()) - - -def test_a_transport_failure_is_an_attachment_error() -> None: - async def main() -> None: - def handle(request: httpx.Request) -> httpx.Response: - raise httpx.ReadTimeout("timed out", request=request) - - async with Fetcher(httpx.MockTransport(handle)) as fetcher, fetcher.session(False) as pages: - attachment = await download_attachment(pages, URL, name="申请表", page_url=PAGE_URL) - assert attachment.info.url == URL - assert attachment.info.error == "timeout" - assert attachment.data is None - - asyncio.run(main()) diff --git a/crawler/src/crawler/crawl/browser.py b/crawler/src/crawler/crawl/browser.py index 63ff05c..97603eb 100644 --- a/crawler/src/crawler/crawl/browser.py +++ b/crawler/src/crawler/crawl/browser.py @@ -2,16 +2,13 @@ import logging import time from collections.abc import Awaitable, Callable -from typing import Protocol -from urllib.parse import urljoin, urlsplit +from urllib.parse import urljoin from playwright.async_api import ( Browser as PlaywrightBrowser, ) from playwright.async_api import ( BrowserContext, - Download, - Frame, Page, Playwright, Route, @@ -20,16 +17,12 @@ from playwright.async_api import ( Error as PlaywrightError, ) -from playwright.async_api import ( - Request as PlaywrightRequest, -) from playwright.async_api import ( Response as PlaywrightResponse, ) from playwright.async_api import ( TimeoutError as PlaywrightTimeoutError, ) -from pydantic import BaseModel, Field, JsonValue, TypeAdapter, ValidationError from crawler.crawl.fetch import ( MAX_REDIRECTS, @@ -38,7 +31,6 @@ FetchError, Permit, Response, - site_key, ) PAGE_TIMEOUT = 300.0 @@ -46,80 +38,9 @@ _REDIRECTS = {301, 302, 303, 307, 308} _BLOCKED = {"image", "font", "media"} _CHALLENGES = {202, 412} -_PARAMS = TypeAdapter(dict[str, JsonValue]) _logger = logging.getLogger(__name__) -class _Version(BaseModel): - user_agent: str = Field(alias="userAgent") - - -class _Request(BaseModel): - url: str - - -class _Paused(BaseModel): - request_id: str = Field(alias="requestId") - request: _Request - frame_id: str = Field(alias="frameId") - redirected_request_id: str | None = Field(default=None, alias="redirectedRequestId") - - -class _Frame(BaseModel): - id: str - - -class _FrameTree(BaseModel): - frame: _Frame - - -class _Frames(BaseModel): - frame_tree: _FrameTree = Field(alias="frameTree") - - -# Playwright leaves CDPSession.send's dictionaries untyped; validate its JSON at this boundary. -class _Commands(Protocol): - async def send(self, method: str, params: dict[str, JsonValue] | None = None) -> object: ... - - def on(self, event: str, f: Callable[[object], None]) -> None: ... - - async def detach(self) -> None: ... - - -async def _command( - connection: _Commands, method: str, params: dict[str, JsonValue] | None = None -) -> dict[str, JsonValue]: - return _PARAMS.validate_python(await connection.send(method, params)) - - -def _source_error(error: PlaywrightError, url: str) -> FetchError | None: - if isinstance(error, PlaywrightTimeoutError): - return FetchError("timeout", url) - message = str(error).split("\n", 1)[0] - if "Download is starting" in message: - return FetchError("unreachable", url) - if "net::ERR_TOO_MANY_REDIRECTS" in message: - return FetchError("too_many_redirects", url) - if "net::ERR_TIMED_OUT" in message or "Request timed out" in message: - return FetchError("timeout", url) - if any( - marker in message - for marker in ("net::ERR_", "ECONN", "ENOTFOUND", "EAI_AGAIN", "socket hang up") - ): - return FetchError("unreachable", url) - if message.startswith( - ( - "APIRequestContext.get: self-signed certificate", - "APIRequestContext.get: certificate has expired", - "APIRequestContext.get: unable to verify the first certificate", - "APIRequestContext.get: Hostname/IP does not match certificate's altnames:", - "APIRequestContext.get: failed to decompress 'gzip' encoding:", - ) - ): - return FetchError("unreachable", url) - return None - - class Browser: def __init__(self, permit: Callable[[str], Awaitable[Permit]]) -> None: self.permit = permit @@ -128,7 +49,6 @@ def __init__(self, permit: Callable[[str], Awaitable[Permit]]) -> None: self._playwright: Playwright | None = None self._browser: PlaywrightBrowser | None = None self._user_agent = "" - self.sessions: set[BrowserSession] = set() async def _start(self) -> PlaywrightBrowser: async with self._lock: @@ -139,108 +59,91 @@ async def _start(self) -> PlaywrightBrowser: browser = await playwright.chromium.launch( headless=True, args=["--disable-blink-features=AutomationControlled"] ) - connection: _Commands = await browser.new_browser_cdp_session() + page = await browser.new_page() try: - version = _Version.model_validate( - await _command(connection, "Browser.getVersion") - ) + user_agent: str = await page.evaluate("navigator.userAgent") finally: - await connection.detach() + await page.close() except BaseException: await playwright.stop() raise - self._user_agent = ( - version.user_agent.replace("HeadlessChrome", "Chrome") + " " + USER_AGENT - ) + self._user_agent = user_agent.replace("HeadlessChrome", "Chrome") + " " + USER_AGENT self._playwright = playwright self._browser = browser return browser - async def session(self) -> BrowserSession: - browser = await self._start() - creating = asyncio.create_task( - browser.new_context( + async def session(self, url: str) -> BrowserSession: + try: + browser = await self._start() + context = await browser.new_context( user_agent=self._user_agent, service_workers="block", accept_downloads=False ) - ) - try: - # Cancelling a protocol call does not prevent the browser from creating its resource. - await asyncio.wait((creating,)) - context = creating.result() session = BrowserSession(self, context) - await session.configure() - except BaseException: - context = await creating - await context.close() - raise - self.sessions.add(session) - return session + try: + await session.configure() + except BaseException: + await context.close() + raise + return session + except PlaywrightTimeoutError as error: + raise FetchError("timeout", url) from error + except PlaywrightError as error: + raise FetchError("unreachable", url) from error async def close(self) -> None: try: - for session in tuple(self.sessions): - await session.close() + if self._browser is not None: + await self._browser.close() finally: - try: - if self._browser is not None: - await self._browser.close() - self._browser = None - finally: - if self._playwright is not None: - await self._playwright.stop() - self._playwright = None + if self._playwright is not None: + await self._playwright.stop() class BrowserSession: def __init__(self, owner: Browser, context: BrowserContext) -> None: self._owner = owner self._context = context - self._pages: set[Page] = set() - self._popup_tasks: set[asyncio.Task[None]] = set() - self._closed = False async def configure(self) -> None: await self._context.route("**/*", self._route) self._context.on("page", self._new_page) - def _new_page(self, page: Page) -> None: - task = asyncio.create_task(self._popup(page)) - self._popup_tasks.add(task) - - async def _popup(self, page: Page) -> None: + async def _new_page(self, page: Page) -> None: if await page.opener() is not None: await page.close() async def _route(self, route: Route) -> None: - request = route.request - if request.resource_type in _BLOCKED: + if route.request.resource_type in _BLOCKED: await route.abort() - return - try: - allowed = request.frame.page in self._pages - except PlaywrightError as error: - if "Frame for this navigation request is not available" not in str(error): - raise - allowed = False - if not allowed: - await route.abort() - return - await route.continue_() + else: + await route.continue_() async def get(self, url: str) -> Response: async with self._owner.slots: - page: Page | None = None - creating: asyncio.Task[Page] | None = None - request: _Page | None = None + permit = await self._owner.permit(url) started = time.monotonic() try: - async with asyncio.timeout(PAGE_TIMEOUT): - creating = asyncio.create_task(self._context.new_page()) - await asyncio.wait((creating,)) - page = creating.result() - self._pages.add(page) - request = _Page(page, self._context, self._owner.permit, url) - response = await request.run() + page = await self._context.new_page() + try: + last_response: PlaywrightResponse | None = None + + def record_response(response: PlaywrightResponse) -> None: + nonlocal last_response + request = response.request + if request.is_navigation_request() and request.frame == page.main_frame: + last_response = response + + page.on("response", record_response) + await page.goto(url, wait_until="networkidle", timeout=PAGE_TIMEOUT * 1000) + if last_response is None or last_response.status in _CHALLENGES: + raise FetchError("unreachable", page.url) + body = await page.content() + response = Response( + page.url, + last_response.status, + "text/html; charset=utf-8", + body.encode("utf-8"), + ) _logger.info( "Browser fetched %s status=%s elapsed=%.3fs", response.url, @@ -248,16 +151,14 @@ async def get(self, url: str) -> Response: time.monotonic() - started, ) return response - except TimeoutError as error: + finally: + await page.close() + except PlaywrightTimeoutError as error: raise FetchError("timeout", url) from error + except PlaywrightError as error: + raise FetchError("unreachable", url) from error finally: - if request is not None: - await request.close() - elif creating is not None: - page = await creating - await page.close() - if page is not None: - self._pages.discard(page) + permit.release() async def download(self, url: str, *, page_url: str) -> Response: for _ in range(MAX_REDIRECTS + 1): @@ -284,247 +185,13 @@ async def download(self, url: str, *, page_url: str) -> Response: ) finally: await response.dispose() + except PlaywrightTimeoutError as error: + raise FetchError("timeout", url) from error except PlaywrightError as error: - failure = _source_error(error, url) - if failure is not None: - raise failure from error - raise + raise FetchError("unreachable", url) from error finally: permit.release() raise FetchError("too_many_redirects", url) async def close(self) -> None: - if self._closed: - return - self._closed = True - try: - await self._context.close() - finally: - try: - if self._popup_tasks: - await asyncio.gather(*self._popup_tasks) - finally: - self._owner.sessions.discard(self) - - -class _Page: - def __init__( - self, - page: Page, - context: BrowserContext, - permit: Callable[[str], Awaitable[Permit]], - url: str, - ) -> None: - self._context = context - self._permit = permit - self._page = page - self._url = url - self._connection: _Commands | None = None - self._frame_id = "" - self._tasks: set[asyncio.Task[None]] = set() - self._gate = asyncio.Lock() - self._current_permit: Permit | None = None - self._site: str | None = None - self._redirects: dict[str, int] = {} - self._changed = asyncio.Event() - self._failure: asyncio.Future[BaseException] = asyncio.get_running_loop().create_future() - self._main: PlaywrightResponse | None = None - self._current_request: PlaywrightRequest | None = None - self._generation = 0 - self._committed_generation = -1 - self._loaded_generation = -1 - self._document_pending = False - self._closing = False - - def _spawn(self, operation: Awaitable[None]) -> None: - task = asyncio.ensure_future(operation) - self._tasks.add(task) - task.add_done_callback(self._done) - - def _done(self, task: asyncio.Task[None]) -> None: - self._tasks.discard(task) - if not task.cancelled(): - error = task.exception() - if error is not None: - self._fail(error) - self._changed.set() - - def _fail(self, error: BaseException) -> None: - if not self._closing and not self._failure.done(): - self._failure.set_result(error) - - async def _send( - self, method: str, params: dict[str, JsonValue] | None = None - ) -> dict[str, JsonValue]: - if self._connection is None: - raise RuntimeError("CDP connection has not started") - return await _command(self._connection, method, params) - - async def _configure(self) -> None: - frames = _Frames.model_validate(await self._send("Page.getFrameTree")) - self._frame_id = frames.frame_tree.frame.id - await self._send( - "Fetch.enable", - { - "patterns": [ - {"urlPattern": "*", "resourceType": "Document", "requestStage": "Request"} - ] - }, - ) - - def _request_paused(self, value: object) -> None: - if self._closing: - return - try: - event = _Paused.model_validate(value) - except ValidationError as error: - self._fail(error) - return - if event.frame_id == self._frame_id: - self._document_pending = True - self._changed.set() - self._spawn(self._paused(event)) - - def _release(self) -> None: - if self._current_permit is not None: - self._current_permit.release() - self._current_permit = None - self._site = None - - async def _paused(self, event: _Paused) -> None: - if event.frame_id == self._frame_id: - async with self._gate: - redirects = ( - self._redirects.pop(event.redirected_request_id, 0) + 1 - if event.redirected_request_id is not None - else 0 - ) - if redirects > MAX_REDIRECTS: - self._fail(FetchError("too_many_redirects", event.request.url)) - return - self._redirects[event.request_id] = redirects - url = event.request.url - if urlsplit(url).scheme in {"http", "https"}: - site = site_key(url) - if site != self._site: - # Release before waiting at the destination so two visits crossing - # between the same sites cannot hold each other's permits. - self._release() - permit = await self._permit(url) - if self._closing: - permit.release() - return - self._current_permit = permit - self._site = site - await self._send("Fetch.continueRequest", {"requestId": event.request_id}) - else: - await self._send("Fetch.continueRequest", {"requestId": event.request_id}) - - def _requested(self, request: PlaywrightRequest) -> None: - if request.is_navigation_request() and request.frame == self._page.main_frame: - self._current_request = request - self._generation += 1 - self._main = None - self._document_pending = True - self._changed.set() - - def _response(self, response: PlaywrightResponse) -> None: - if response.request is self._current_request: - self._main = response - self._changed.set() - - def _request_failed(self, request: PlaywrightRequest) -> None: - if request is not self._current_request: - return - reason = request.failure - if reason == "net::ERR_ABORTED": - return - error = PlaywrightError(reason or "Main navigation failed without a reason") - self._fail(_source_error(error, request.url) or error) - - def _navigated(self, frame: Frame) -> None: - if frame == self._page.main_frame: - # History/hash changes have no new response or DOMContentLoaded event. - if self._main is not None and self._committed_generation != self._generation: - self._committed_generation = self._generation - self._document_pending = False - self._changed.set() - - def _domcontentloaded(self, _: Page) -> None: - if self._committed_generation == self._generation: - self._loaded_generation = self._generation - self._changed.set() - - def _download(self, download: Download) -> None: - self._fail(FetchError("unreachable", download.url)) - self._spawn(download.cancel()) - - def _ready(self) -> bool: - return ( - self._main is not None - and self._committed_generation == self._generation - and self._loaded_generation == self._generation - and not self._document_pending - and not self._tasks - ) - - async def _navigate(self) -> Response: - await self._page.goto(self._url, wait_until="commit", timeout=0) - while True: - self._changed.clear() - response = self._main - if response is not None and response.status not in _CHALLENGES and self._ready(): - generation = self._generation - try: - body = await self._page.content() - except PlaywrightError as error: - if "page is navigating" not in str(error): - raise - continue - if generation == self._generation and response is self._main and self._ready(): - return Response( - self._page.url, - response.status, - "text/html; charset=utf-8", - body.encode("utf-8"), - ) - await self._changed.wait() - - async def run(self) -> Response: - self._connection = await self._context.new_cdp_session(self._page) - self._connection.on("Fetch.requestPaused", self._request_paused) - self._page.on("request", self._requested) - self._page.on("requestfailed", self._request_failed) - self._page.on("response", self._response) - self._page.on("download", self._download) - self._page.on("framenavigated", self._navigated) - self._page.on("domcontentloaded", self._domcontentloaded) - navigation: asyncio.Task[Response] | None = None - try: - await self._configure() - navigation = asyncio.create_task(self._navigate()) - await asyncio.wait((navigation, self._failure), return_when=asyncio.FIRST_COMPLETED) - if self._failure.done(): - raise self._failure.result() - return await navigation - except PlaywrightError as error: - failure = _source_error(error, self._page.url or self._url) - if failure is not None: - raise failure from error - raise - finally: - if navigation is not None and not navigation.done(): - navigation.cancel() - await asyncio.gather(navigation, return_exceptions=True) - - async def close(self) -> None: - self._closing = True - try: - await self._page.close() - finally: - tasks = tuple(self._tasks) - for task in tasks: - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - self._release() + await self._context.close() diff --git a/crawler/src/crawler/crawl/browser_test.py b/crawler/src/crawler/crawl/browser_test.py index 3298802..0574f87 100644 --- a/crawler/src/crawler/crawl/browser_test.py +++ b/crawler/src/crawler/crawl/browser_test.py @@ -1,22 +1,14 @@ import asyncio from collections.abc import Awaitable, Callable +from dataclasses import dataclass import httpx import pytest from playwright.async_api import Error as PlaywrightError from playwright.async_api import TimeoutError as PlaywrightTimeoutError -from pydantic import JsonValue from crawler.crawl.browser import Browser -from crawler.crawl.fetch import ( - INTERVAL, - MAX_REDIRECTS, - Failure, - Fetcher, - FetchError, - Permit, - Response, -) +from crawler.crawl.fetch import INTERVAL, Failure, Fetcher, FetchError def run(main: Callable[[], Awaitable[None]]) -> None: @@ -27,32 +19,6 @@ async def bounded() -> None: asyncio.run(bounded()) -class FakeSession: - def __init__(self, number: int) -> None: - self.number = number - self.closed = False - - async def get(self, url: str) -> Response: - return Response(url, 200, "text/html", f"session {self.number}".encode()) - - async def download(self, url: str, *, page_url: str) -> Response: - return Response(url, 200, "application/pdf", f"session {self.number}: {page_url}".encode()) - - async def close(self) -> None: - self.closed = True - - -class FakeSessions: - def __init__(self) -> None: - self.sessions: list[FakeSession] = [] - - async def session(self) -> FakeSession: - await asyncio.sleep(0) - session = FakeSession(len(self.sessions) + 1) - self.sessions.append(session) - return session - - class FakeClock: def __init__(self) -> None: self.now = 0.0 @@ -65,670 +31,277 @@ async def sleep(self, seconds: float) -> None: await asyncio.sleep(0) -class FakeAPIResponse: - def __init__(self, response: httpx.Response) -> None: - self.response = response - self.url = str(response.url) - self.status = response.status_code - self.headers = dict(response.headers) - self.disposed = False - - async def body(self) -> bytes: - return self.response.content - - async def dispose(self) -> None: - self.disposed = True - await self.response.aclose() - - -class FakeDownloads: - def __init__(self, client: httpx.AsyncClient) -> None: - self.client = client - self.responses: list[FakeAPIResponse] = [] - self.error: PlaywrightError | None = None - - async def get( - self, - url: str, - *, - headers: dict[str, str], - **options: object, - ) -> FakeAPIResponse: - if self.error is not None: - raise self.error - assert options["max_redirects"] == options["max_retries"] == 0 - assert options["fail_on_status_code"] is False - request_timeout = options["timeout"] - assert isinstance(request_timeout, int | float) - response = FakeAPIResponse( - await self.client.get(url, headers=headers, timeout=request_timeout / 1000) - ) - self.responses.append(response) - return response - - -class FakeCDP: - def __init__(self) -> None: - self.handlers: dict[str, Callable[..., None]] = {} - self.commands: asyncio.Queue[tuple[str, dict[str, JsonValue]]] = asyncio.Queue() - self.continued: dict[str, asyncio.Event] = {} - - def on(self, event: str, callback: Callable[..., None]) -> None: - self.handlers[event] = callback - - async def send( - self, method: str, params: dict[str, JsonValue] | None = None - ) -> dict[str, JsonValue]: - self.commands.put_nowait((method, params or {})) - if method == "Page.getFrameTree": - return {"frameTree": {"frame": {"id": "main"}}} - if method == "Fetch.continueRequest": - assert params is not None - request_id = params["requestId"] - assert isinstance(request_id, str) - self.continued[request_id].set() - return {} - - async def next_command( - self, method: str, *, request_id: str | None = None - ) -> dict[str, JsonValue]: - while True: - name, params = await self.commands.get() - if name == method and (request_id is None or params.get("requestId") == request_id): - return params - - def pause( - self, - request_id: str, - url: str, - *, - frame_id: str = "main", - redirected: str | None = None, - ) -> None: - self.continued[request_id] = asyncio.Event() - value: dict[str, JsonValue] = { - "requestId": request_id, - "request": {"url": url, "method": "GET"}, - "resourceType": "Document", - "frameId": frame_id, - } - if redirected is not None: - value["redirectedRequestId"] = redirected - self.handlers["Fetch.requestPaused"](value) - - -class FakeFrame: - def __init__(self, page: FakePage) -> None: - self.page = page - - -class FakeNavigation: - def __init__(self, frame: FakeFrame, url: str, *, navigation: bool = True) -> None: - self.frame = frame - self.url = url - self.navigation = navigation - self.failure: str | None = None +@dataclass +class FakeRequest: + frame: object + navigation: bool def is_navigation_request(self) -> bool: return self.navigation -class FakeDocument: - def __init__(self, status: int, request: FakeNavigation) -> None: - self.request = request - self.status = status +@dataclass +class FakeResponse: + status: int + request: FakeRequest class FakePage: def __init__(self, context: FakeContext) -> None: self.context = context - self.main_frame = FakeFrame(self) - self.navigation = FakeNavigation(self.main_frame, "about:blank") + self.main_frame = object() self.url = "about:blank" - self.html = "" self.closed = False - self.close_started = asyncio.Event() - self.close_gate: asyncio.Event | None = None - self.started = asyncio.Event() - self.content_started = asyncio.Event() - self.content_gate: asyncio.Event | None = None - self.navigation_error: PlaywrightError | None = None - self.connection = FakeCDP() - self.handlers: dict[str, Callable[..., None]] = {} - - def on(self, event: str, callback: Callable[..., None]) -> None: - self.handlers[event] = callback + self.ready = asyncio.Event() + self.handlers: dict[str, Callable[[FakeResponse], None]] = {} - def begin_navigation(self, url: str) -> FakeNavigation: - self.navigation = FakeNavigation(self.main_frame, url) - self.handlers["request"](self.navigation) - return self.navigation - - def respond(self, status: int, *, request: FakeNavigation | None = None) -> None: - self.handlers["response"](FakeDocument(status, request or self.navigation)) - - def fail(self, request: FakeNavigation, reason: str) -> None: - request.failure = reason - self.handlers["requestfailed"](request) + def on(self, event: str, callback: Callable[[FakeResponse], None]) -> None: + self.handlers[event] = callback - def commit(self, url: str, html: str, *, loaded: bool = True) -> None: - self.url = url - self.html = html - if callback := self.handlers.get("framenavigated"): - callback(self.main_frame) - if loaded: - self.load() - - def load(self) -> None: - self.handlers["domcontentloaded"](self) - - async def goto(self, url: str, **_options: object) -> None: - self.begin_navigation(url) - self.connection.pause("initial", url) - await self.connection.continued["initial"].wait() - if self.navigation_error is not None: - raise self.navigation_error - self.respond(self.context.driver.initial_status) - self.commit(url, "challenge") - self.started.set() + async def goto(self, url: str, *, wait_until: str, **_options: object) -> None: + assert wait_until == "networkidle" + driver = self.context.driver + self.url = driver.destination or url + driver.started.put_nowait(self) + if driver.block_pages: + await self.ready.wait() + if driver.error is not None: + raise driver.error + for status, navigation, main_frame in driver.responses: + frame = self.main_frame if main_frame else object() + self.handlers["response"](FakeResponse(status, FakeRequest(frame, navigation))) async def content(self) -> str: - html = self.html - self.content_started.set() - if self.content_gate is not None: - await self.content_gate.wait() - return html + return "保研通知" async def close(self) -> None: - if not self.closed: - self.close_started.set() - if self.close_gate is not None: - await self.close_gate.wait() - self.closed = True - self.context.driver.active_pages -= 1 + self.closed = True -class FakeRouteRequest: - def __init__(self, page: FakePage, resource_type: str) -> None: - self.frame = FakeFrame(page) - self.resource_type = resource_type +class FakeDownload: + def __init__(self, url: str, body: bytes) -> None: + self.url = url + self.status = 200 + self.headers = {"content-type": "application/pdf"} + self.data = body + self.disposed = False + async def body(self) -> bytes: + return self.data + + async def dispose(self) -> None: + self.disposed = True -class FakeRoute: - def __init__(self, page: FakePage, resource_type: str) -> None: - self.request = FakeRouteRequest(page, resource_type) - self.continued = False - self.aborted = False - async def continue_(self) -> None: - self.continued = True +class FakeDownloads: + def __init__(self, context: FakeContext) -> None: + self.context = context + self.responses: list[FakeDownload] = [] - async def abort(self) -> None: - self.aborted = True + async def get(self, url: str, *, headers: dict[str, str], **_options: object) -> FakeDownload: + if self.context.driver.error is not None: + raise self.context.driver.error + response = FakeDownload( + url, f"%PDF context {self.context.number}: {headers['Referer']}".encode() + ) + self.responses.append(response) + return response class FakeContext: - def __init__(self, driver: FakeBrowser) -> None: + def __init__(self, driver: FakeBrowser, number: int) -> None: self.driver = driver - self.client = httpx.AsyncClient(transport=driver.transport) - self.request = FakeDownloads(self.client) + self.number = number + self.request = FakeDownloads(self) self.pages: list[FakePage] = [] self.closed = False - self.route_handler: Callable[[FakeRoute], Awaitable[None]] | None = None - self.page_gate: asyncio.Event | None = None - async def route(self, pattern: str, handler: Callable[[FakeRoute], Awaitable[None]]) -> None: - self.route_handler = handler + async def route(self, _pattern: str, _handler: object) -> None: + pass - def on(self, event: str, handler: object) -> None: + def on(self, _event: str, _handler: object) -> None: pass async def new_page(self) -> FakePage: page = FakePage(self) self.pages.append(page) - self.driver.active_pages += 1 - self.driver.peak_pages = max(self.driver.peak_pages, self.driver.active_pages) - self.driver.created.put_nowait(page) - if self.page_gate is not None: - await self.page_gate.wait() return page - async def new_cdp_session(self, page: FakePage) -> FakeCDP: - return page.connection - async def close(self) -> None: self.closed = True - for page in self.pages: - await page.close() - await self.client.aclose() class FakeBrowser: - def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: - self.transport = transport or httpx.MockTransport(lambda _: httpx.Response(404)) + def __init__(self) -> None: self.contexts: list[FakeContext] = [] - self.created: asyncio.Queue[FakePage] = asyncio.Queue() - self.active_pages = 0 - self.peak_pages = 0 - self.initial_status = 202 - self.context_created = asyncio.Event() - self.context_gate: asyncio.Event | None = None - - async def new_context( - self, *, user_agent: str, service_workers: str, accept_downloads: bool - ) -> FakeContext: - context = FakeContext(self) + self.started: asyncio.Queue[FakePage] = asyncio.Queue() + self.block_pages = False + self.destination: str | None = None + self.error: PlaywrightError | None = None + self.responses = [(200, True, True)] + + async def new_context(self, **_options: object) -> FakeContext: + context = FakeContext(self, len(self.contexts) + 1) self.contexts.append(context) - self.context_created.set() - if self.context_gate is not None: - await self.context_gate.wait() return context -def use_browser(monkeypatch: pytest.MonkeyPatch, driver: FakeBrowser) -> None: +@pytest.fixture +def driver(monkeypatch: pytest.MonkeyPatch) -> FakeBrowser: + browser = FakeBrowser() + async def start(_owner: object) -> FakeBrowser: - return driver + return browser monkeypatch.setattr("crawler.crawl.browser.Browser._start", start) + return browser -def test_plain_pages_and_unused_browser_sessions_do_not_start_a_browser( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def unexpected_browser(_fetcher: Fetcher) -> None: - pytest.fail("A browser was started without a browser page request") - - monkeypatch.setattr(Fetcher, "browser", unexpected_browser) - - async def main() -> None: - transport = httpx.MockTransport( - lambda request: httpx.Response(200, content=request.url.path) - ) - async with Fetcher(transport) as fetcher: - async with fetcher.session(False) as pages: - listing = await pages.get("https://a/list") - attachment = await pages.download("https://b/file.pdf", page_url=listing.url) - async with fetcher.session(True): - pass - assert listing.body == b"/list" - assert attachment.body == b"/file.pdf" - - run(main) - - -def test_round_reuses_its_browser_session_and_closes_it_before_the_next_round( - monkeypatch: pytest.MonkeyPatch, -) -> None: - service = FakeSessions() - - def browser_service(_fetcher: Fetcher) -> FakeSessions: - return service - - monkeypatch.setattr(Fetcher, "browser", browser_service) - - async def main() -> None: - async with Fetcher(httpx.MockTransport(lambda _: httpx.Response(500))) as fetcher: - async with fetcher.session(True) as pages: - assert service.sessions == [] - listing, notice = await asyncio.gather( - pages.get("https://a/list"), pages.get("https://a/notice") - ) - attachment = await pages.download("https://a/file.pdf", page_url=notice.url) - assert listing.body == notice.body == b"session 1" - assert attachment.body == b"session 1: https://a/notice" - assert not service.sessions[0].closed - - with pytest.raises(RuntimeError, match="closed"): - await pages.get("https://a/after-close") - with pytest.raises(RuntimeError, match="closed"): - await pages.download("https://a/file.pdf", page_url=notice.url) - assert service.sessions[0].closed - - async with fetcher.session(True) as next_round: - assert (await next_round.get("https://a/list")).body == b"session 2" - assert service.sessions[0].closed - assert all(session.closed for session in service.sessions) - - run(main) - - -@pytest.mark.parametrize("challenge", [202, 412]) -def test_challenge_waits_for_the_final_document_to_commit_before_reading_html( - monkeypatch: pytest.MonkeyPatch, challenge: int -) -> None: - driver = FakeBrowser() - driver.initial_status = challenge - use_browser(monkeypatch, driver) - - async def main() -> None: - async with Fetcher() as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - await page.started.wait() - await asyncio.sleep(0) - assert not task.done() - - # A response event precedes commit, even when both documents use the same URL. - page.begin_navigation("https://a.test/list") - page.connection.pause("challenge", "https://a.test/list") - await page.connection.next_command("Fetch.continueRequest", request_id="challenge") - page.respond(200) - await asyncio.sleep(0) - await asyncio.sleep(0) - assert not task.done() - - page.commit("https://a.test/list", "script pending", loaded=False) - await asyncio.sleep(0) - await asyncio.sleep(0) - assert not task.done() - page.html = "rendered notice" - page.load() - result = await task - assert result.status == 200 - assert result.body == b"rendered notice" - assert page.closed - assert driver.active_pages == 0 - finally: - await browser.close() - - run(main) - - -def test_page_limit_is_shared_across_rounds_and_cancellation_frees_a_slot( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - finish = asyncio.Event() - - async def render(_request: object) -> Response: - await finish.wait() - return Response("https://a.test/list", 200, "text/html", b"rendered") +def test_page_visits_and_http_requests_wait_for_each_other(driver: FakeBrowser) -> None: + driver.block_pages = True + clock = FakeClock() + http_started = asyncio.Event() + finish_http = asyncio.Event() + request_times: list[float] = [] - monkeypatch.setattr("crawler.crawl.browser._Page.run", render) + async def handle(_request: httpx.Request) -> httpx.Response: + request_times.append(clock.now) + http_started.set() + await finish_http.wait() + return httpx.Response(200) async def main() -> None: - async with Fetcher() as fetcher: - browser = Browser(fetcher.permit) - try: - first, second = await asyncio.gather(browser.session(), browser.session()) - tasks = [ - asyncio.create_task(first.get("https://a.test/1")), - asyncio.create_task(second.get("https://b.test/2")), - asyncio.create_task(first.get("https://a.test/3")), - ] - page_one = await driver.created.get() - await driver.created.get() - await asyncio.sleep(0) - assert driver.active_pages == driver.peak_pages == 2 - assert driver.created.empty() - - tasks[0].cancel() - with pytest.raises(asyncio.CancelledError): - await tasks[0] - page_three = await driver.created.get() - assert page_one.closed - assert not page_three.closed - assert driver.active_pages == driver.peak_pages == 2 - - finish.set() - assert [response.body for response in await asyncio.gather(*tasks[1:])] == [ - b"rendered", - b"rendered", - ] - assert driver.active_pages == 0 - finally: - finish.set() - await browser.close() - assert all(context.closed for context in driver.contexts) + async with ( + Fetcher(httpx.MockTransport(handle), clock.clock, clock.sleep) as fetcher, + fetcher.session(True, url="https://a/list") as session, + ): + first_http = asyncio.create_task(fetcher.get("https://a/first")) + await http_started.wait() + visit = asyncio.create_task(session.get("https://a/list")) + await asyncio.sleep(0) + assert driver.started.empty() + clock.now = 2 + finish_http.set() + await first_http + page = await driver.started.get() + assert clock.now == 2 + INTERVAL + second_http = asyncio.create_task(fetcher.get("https://a/second")) + await asyncio.sleep(0) + assert request_times == [0] + clock.now = 10 + page.ready.set() + await visit + await second_http + assert page.closed + assert request_times == [0, 10 + INTERVAL] run(main) -def test_navigation_during_html_read_discards_the_previous_document( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - driver.initial_status = 200 - use_browser(monkeypatch, driver) +def test_page_limit_is_shared_by_rounds(driver: FakeBrowser) -> None: + driver.block_pages = True async def main() -> None: - async with Fetcher() as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - page.content_gate = asyncio.Event() - await page.content_started.wait() - previous = page.navigation - page.begin_navigation("https://a.test/final") - page.content_gate.set() - await asyncio.sleep(0) - await asyncio.sleep(0) - assert not task.done() - - page.respond(200, request=previous) - await asyncio.sleep(0) - assert not task.done() - page.connection.pause("navigate", "https://a.test/final") - await page.connection.next_command("Fetch.continueRequest", request_id="navigate") - page.respond(200) - await asyncio.sleep(0) - assert not task.done() - page.commit("https://a.test/final", "final document") - result = await task - assert result.url == "https://a.test/final" - assert result.body == b"final document" - finally: - await browser.close() + async with ( + Fetcher() as fetcher, + fetcher.session(True, url="https://a/list") as first, + fetcher.session(True, url="https://b/list") as second, + ): + tasks = [ + asyncio.create_task(first.get("https://a/list")), + asyncio.create_task(second.get("https://b/list")), + asyncio.create_task(first.get("https://c/list")), + ] + first_page = await driver.started.get() + second_page = await driver.started.get() + await asyncio.sleep(0) + assert sum(len(context.pages) for context in driver.contexts) == 2 + first_page.ready.set() + third_page = await driver.started.get() + assert first_page.closed + second_page.ready.set() + third_page.ready.set() + await asyncio.gather(*tasks) + assert all(page.closed for context in driver.contexts for page in context.pages) run(main) -def test_attachment_cookies_are_scoped_to_the_round_and_each_redirect_destination( - monkeypatch: pytest.MonkeyPatch, -) -> None: - requested: list[tuple[str, str, str]] = [] - - def handle(request: httpx.Request) -> httpx.Response: - requested.append( - ( - str(request.url), - request.headers.get("cookie", ""), - request.headers.get("referer", ""), - ) - ) - if request.url.host == "a.test": - if request.headers.get("cookie") != "challenge=passed": - return httpx.Response(403, content=b"challenge required") - return httpx.Response(302, headers={"location": "https://b.test/file.pdf"}) - assert "challenge" not in request.headers.get("cookie", "") - return httpx.Response(200, headers={"content-type": "application/pdf"}, content=b"PDF") - - driver = FakeBrowser(httpx.MockTransport(handle)) - use_browser(monkeypatch, driver) +def test_each_round_opens_reuses_and_closes_its_context(driver: FakeBrowser) -> None: clock = FakeClock() async def main() -> None: async with Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - context = driver.contexts[0] - context.client.cookies.set("challenge", "passed", domain="a.test", path="/private") - result = await session.download( - "https://a.test/private/file.pdf", page_url="https://a.test/notice" - ) - assert (result.url, result.status, result.body) == ( - "https://b.test/file.pdf", - 200, - b"PDF", - ) - assert all(response.disposed for response in context.request.responses) - outside_cookie_path = await session.download( - "https://a.test/public/file.pdf", page_url="https://a.test/notice" - ) - assert outside_cookie_path.status == 403 - await session.close() - - next_round = await browser.session() - missing_cookie = await next_round.download( - "https://a.test/private/file.pdf", page_url="https://a.test/notice" - ) - assert missing_cookie.status == 403 - assert clock.now >= INTERVAL - finally: - await browser.close() - - assert requested == [ - ("https://a.test/private/file.pdf", "challenge=passed", "https://a.test/notice"), - ("https://b.test/file.pdf", "", "https://a.test/notice"), - ("https://a.test/public/file.pdf", "", "https://a.test/notice"), - ("https://a.test/private/file.pdf", "", "https://a.test/notice"), - ] + async with fetcher.session(False, url="https://a/list"): + assert driver.contexts == [] + async with fetcher.session(True, url="https://a/list") as session: + assert len(driver.contexts) == 1 + await session.get("https://a/list") + await session.get("https://a/notice") + assert len(driver.contexts[0].pages) == 2 + assert not driver.contexts[0].closed + assert driver.contexts[0].closed + async with fetcher.session(True, url="https://a/list"): + assert len(driver.contexts) == 2 + assert driver.contexts[1].closed run(main) -def test_page_permit_covers_challenge_refresh_and_subresources_until_the_page_closes( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) +def test_downloads_use_the_rounds_context_request_client(driver: FakeBrowser) -> None: clock = FakeClock() - ordinary_started = asyncio.Event() - permits: list[str] = [] - - def handle(_request: httpx.Request) -> httpx.Response: - ordinary_started.set() - return httpx.Response(200, content=b"next request") async def main() -> None: - async with Fetcher(httpx.MockTransport(handle), clock.clock, clock.sleep) as fetcher: - - async def permit(url: str) -> Permit: - permits.append(url) - return await fetcher.permit(url) - - browser = Browser(permit) - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - await page.started.wait() - configuration = await page.connection.next_command("Fetch.enable") - assert configuration == { - "patterns": [ - {"urlPattern": "*", "requestStage": "Request", "resourceType": "Document"} - ] - } - - route = driver.contexts[0].route_handler - assert route is not None - for resource_type in ("script", "stylesheet", "xhr", "document"): - resource = FakeRoute(page, resource_type) - await route(resource) - assert resource.continued - assert not resource.aborted - image = FakeRoute(page, "image") - await route(image) - assert image.aborted - - page.connection.pause("iframe", "https://b.test/frame", frame_id="child") - await page.connection.next_command("Fetch.continueRequest", request_id="iframe") - page.begin_navigation("https://a.test/list") - page.connection.pause("challenge", "https://a.test/list") - await page.connection.next_command("Fetch.continueRequest", request_id="challenge") - assert permits == ["https://a.test/list"] - assert clock.now == 0 - - ordinary = asyncio.create_task(fetcher.get("https://a.test/other")) - await asyncio.sleep(0) - assert not ordinary_started.is_set() - page.respond(200) - page.commit("https://a.test/list", "ready") - assert (await task).body == b"ready" - assert page.closed - assert (await ordinary).body == b"next request" - assert clock.now == INTERVAL - finally: - await browser.close() + async with Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher: + for number in (1, 2): + async with fetcher.session(True, url="https://a/list") as session: + await session.get("https://a/notice") + attachment = await session.download( + "https://a/file.pdf", page_url="https://a/notice" + ) + assert attachment.body == f"%PDF context {number}: https://a/notice".encode() + assert attachment.content_type == "application/pdf" + assert driver.contexts[number - 1].request.responses[0].disposed run(main) -def test_opposite_cross_site_redirects_release_old_permits_before_waiting( - monkeypatch: pytest.MonkeyPatch, +def test_last_main_navigation_response_is_used_without_a_cross_site_permit( + driver: FakeBrowser, ) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - clock = FakeClock() + driver.destination = "https://b/notice" + driver.responses = [ + (202, True, True), + (200, True, True), + (404, False, True), + (500, True, False), + ] async def main() -> None: - async with Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher: - browser = Browser(fetcher.permit) + async with Fetcher() as fetcher: + destination_permit = await fetcher.permit("https://b/busy") try: - session = await browser.session() - first = asyncio.create_task(session.get("https://a.test/list")) - second = asyncio.create_task(session.get("https://b.test/list")) - page_a = await driver.created.get() - page_b = await driver.created.get() - await asyncio.gather(page_a.started.wait(), page_b.started.wait()) - page_a.begin_navigation("https://b.test/final") - page_b.begin_navigation("https://a.test/final") - page_a.connection.pause("to-b", "https://b.test/final", redirected="initial") - page_b.connection.pause("to-a", "https://a.test/final", redirected="initial") - await asyncio.gather( - page_a.connection.next_command("Fetch.continueRequest", request_id="to-b"), - page_b.connection.next_command("Fetch.continueRequest", request_id="to-a"), - ) - - page_a.respond(200) - page_a.commit("https://b.test/final", "from b") - page_b.respond(200) - page_b.commit("https://a.test/final", "from a") - responses = await asyncio.gather(first, second) - assert [(response.url, response.body) for response in responses] == [ - ("https://b.test/final", b"from b"), - ("https://a.test/final", b"from a"), - ] - assert page_a.closed - assert page_b.closed - assert clock.now >= INTERVAL + async with fetcher.session(True, url="https://a/list") as session: + response = await session.get("https://a/notice") + assert response.status == 200 + assert response.url == "https://b/notice" + assert response.body.decode() == "保研通知" finally: - await browser.close() + destination_permit.release() run(main) -def test_same_document_url_change_during_html_read_keeps_document_readiness( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - driver.initial_status = 200 - use_browser(monkeypatch, driver) +@pytest.mark.parametrize("status", [202, 412]) +def test_unresolved_challenges_fail(driver: FakeBrowser, status: int) -> None: + driver.responses = [(status, True, True)] async def main() -> None: - async with Fetcher() as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - page.content_gate = asyncio.Event() - await page.content_started.wait() - page.commit("https://a.test/list?view=all#notices", page.html, loaded=False) - page.content_gate.set() - result = await task - assert result.url == "https://a.test/list?view=all#notices" - assert result.body == b"challenge" - assert page.closed - finally: - await browser.close() + async with Fetcher() as fetcher, fetcher.session(True, url="https://a/list") as session: + with pytest.raises(FetchError) as caught: + await session.get("https://a/list") + assert caught.value.failure == "unreachable" + assert driver.contexts[0].pages[0].closed run(main) @@ -736,448 +309,68 @@ async def main() -> None: @pytest.mark.parametrize( ("error", "failure"), [ - (None, None), - (PlaywrightTimeoutError("Page.goto timed out"), "timeout"), - (PlaywrightError("net::ERR_CONNECTION_RESET"), "unreachable"), - (PlaywrightError("Page.goto: Download is starting"), "unreachable"), - (PlaywrightError("Unexpected browser error"), None), + (PlaywrightTimeoutError("Timed out"), "timeout"), + (PlaywrightError("Browser request failed"), "unreachable"), ], - ids=["cancel", "timeout", "unreachable", "unexpected_download", "unexpected_error"], ) -def test_navigation_failure_or_cancellation_closes_the_page_and_releases_its_permit( - monkeypatch: pytest.MonkeyPatch, - error: PlaywrightError | None, - failure: Failure | None, +@pytest.mark.parametrize("download", [False, True]) +def test_playwright_errors_become_fetch_errors( + driver: FakeBrowser, error: PlaywrightError, failure: Failure, *, download: bool ) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) + driver.error = error clock = FakeClock() async def main() -> None: - transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"still usable")) - async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - if error is None: - await page.started.wait() - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - else: - page.navigation_error = error - if failure is None: - with pytest.raises(PlaywrightError) as browser_error: - await task - assert browser_error.value is error - else: - with pytest.raises(FetchError) as source_failure: - await task - assert source_failure.value.failure == failure - assert page.closed - assert driver.active_pages == 0 - assert (await fetcher.get("https://a.test/after-failure")).body == b"still usable" - assert clock.now == INTERVAL - finally: - await browser.close() - - run(main) - - -def test_cancelling_a_cross_site_wait_leaves_both_sites_usable( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - clock = FakeClock() - destination_requested = asyncio.Event() - - async def main() -> None: - transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"available")) - async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: - - async def permit(url: str) -> Permit: - if url == "https://b.test/final": - destination_requested.set() - return await fetcher.permit(url) - - browser = Browser(permit) - destination = await fetcher.permit("https://b.test/held") - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - await page.started.wait() - page.begin_navigation("https://b.test/final") - page.connection.pause("redirect", "https://b.test/final", redirected="initial") - await destination_requested.wait() - assert (await fetcher.get("https://a.test/other")).body == b"available" - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - assert page.closed - destination.release() - assert (await fetcher.get("https://b.test/other")).body == b"available" - finally: - destination.release() - await browser.close() - - run(main) - - -def test_same_site_http_redirect_limit_does_not_count_challenge_refreshes( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - clock = FakeClock() - - async def main() -> None: - transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"available")) - async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - await page.started.wait() - for index in range(MAX_REDIRECTS + 2): - request_id = f"challenge-{index}" - page.begin_navigation("https://a.test/list") - page.connection.pause(request_id, "https://a.test/list") - await page.connection.next_command( - "Fetch.continueRequest", request_id=request_id - ) - previous = "initial" - for index in range(MAX_REDIRECTS): - request_id = f"redirect-{index}" - page.begin_navigation(f"https://a.test/redirect/{index}") - page.connection.pause( - request_id, f"https://a.test/redirect/{index}", redirected=previous - ) - await page.connection.next_command( - "Fetch.continueRequest", request_id=request_id - ) - previous = request_id - page.begin_navigation("https://a.test/too-far") - page.connection.pause("over-limit", "https://a.test/too-far", redirected=previous) - with pytest.raises(FetchError) as caught: - await task - assert caught.value.failure == "too_many_redirects" - assert not page.connection.continued["over-limit"].is_set() - assert page.closed - assert (await fetcher.get("https://a.test/other")).body == b"available" - assert clock.now == INTERVAL - finally: - await browser.close() - - run(main) - - -def test_challenge_navigation_network_failure_releases_the_page_without_waiting_for_deadline( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - clock = FakeClock() - - async def main() -> None: - transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"available")) - async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - await page.started.wait() - page.begin_navigation("https://a.test/after-challenge") - page.connection.pause("challenge", "https://a.test/after-challenge") - await page.connection.next_command("Fetch.continueRequest", request_id="challenge") - page.fail(page.navigation, "net::ERR_CONNECTION_REFUSED") - - with pytest.raises(FetchError) as caught: - await task - assert caught.value.failure == "unreachable" - assert caught.value.url == "https://a.test/after-challenge" - assert page.closed - assert (await fetcher.get("https://a.test/other")).body == b"available" - assert clock.now == INTERVAL - finally: - await browser.close() - - run(main) - - -def test_aborted_navigation_and_old_or_subresource_failures_allow_the_replacement_document( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - - async def main() -> None: - async with Fetcher() as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - await page.started.wait() - previous = page.navigation - page.fail(previous, "net::ERR_ABORTED") - await asyncio.sleep(0) - assert not task.done() - - page.begin_navigation("https://a.test/final") - page.fail(previous, "net::ERR_CONNECTION_REFUSED") - script = FakeNavigation( - page.main_frame, "https://a.test/optional.js", navigation=False - ) - page.fail(script, "net::ERR_CONNECTION_REFUSED") - iframe = FakeNavigation(FakeFrame(page), "https://b.test/optional-frame") - page.fail(iframe, "net::ERR_CONNECTION_REFUSED") - await asyncio.sleep(0) - assert not task.done() - - page.connection.pause("replacement", "https://a.test/final") - await page.connection.next_command( - "Fetch.continueRequest", request_id="replacement" - ) - page.respond(200) - page.commit("https://a.test/final", "replacement document") - assert (await task).body == b"replacement document" - assert page.closed - finally: - await browser.close() - - run(main) - - -def test_cancelling_context_creation_waits_for_and_closes_the_unclaimed_context( - monkeypatch: pytest.MonkeyPatch, -) -> None: - driver = FakeBrowser() - driver.context_gate = asyncio.Event() - use_browser(monkeypatch, driver) - - async def main() -> None: - async with Fetcher() as fetcher: - browser = Browser(fetcher.permit) - task = asyncio.create_task(browser.session()) - try: - await driver.context_created.wait() - task.cancel() - await asyncio.sleep(0) - assert not task.done() - assert not driver.contexts[0].closed - assert driver.context_gate is not None - driver.context_gate.set() - with pytest.raises(asyncio.CancelledError): - await task - assert driver.contexts[0].closed - assert not browser.sessions - finally: - assert driver.context_gate is not None - driver.context_gate.set() - await asyncio.gather(task, return_exceptions=True) - await browser.close() - for context in driver.contexts: - await context.close() - - run(main) - - -@pytest.mark.parametrize("expired", [False, True], ids=["cancel", "timeout"]) -def test_interrupted_page_creation_closes_the_page_before_releasing_its_slot( - monkeypatch: pytest.MonkeyPatch, expired: bool -) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - - async def main() -> None: - async with Fetcher() as fetcher: - browser = Browser(fetcher.permit) - session = await browser.session() - context = driver.contexts[0] - context.page_gate = asyncio.Event() - deadlines: list[asyncio.Timeout] = [] - timeout = asyncio.timeout - - def deadline(_seconds: float) -> asyncio.Timeout: - timer = timeout(None) - deadlines.append(timer) - return timer - - monkeypatch.setattr("crawler.crawl.browser.asyncio.timeout", deadline) - task = asyncio.create_task(session.get("https://a.test/list")) - page = await driver.created.get() - page.close_gate = asyncio.Event() - blocked: asyncio.Task[bool] | None = None - try: - if expired: - deadlines[0].reschedule(asyncio.get_running_loop().time()) - else: - task.cancel() - await asyncio.sleep(0) - await asyncio.sleep(0) - assert not task.done() - context.page_gate.set() - await page.close_started.wait() - await browser.slots.acquire() - blocked = asyncio.create_task(browser.slots.acquire()) - await asyncio.sleep(0) - assert not blocked.done() - assert not page.closed - page.close_gate.set() - if expired: - with pytest.raises(FetchError) as caught: - await task - assert caught.value.failure == "timeout" - else: - with pytest.raises(asyncio.CancelledError): - await task - await blocked - browser.slots.release() - browser.slots.release() - assert page.closed - assert driver.active_pages == 0 - finally: - context.page_gate.set() - page.close_gate.set() - if blocked is not None and not blocked.done(): - blocked.cancel() - await asyncio.gather(task, return_exceptions=True) - if blocked is not None: - await asyncio.gather(blocked, return_exceptions=True) - await browser.close() - - run(main) - - -@pytest.mark.parametrize("page_creation", [False, True], ids=["context", "page"]) -def test_creation_failure_after_cancellation_is_propagated_once( - monkeypatch: pytest.MonkeyPatch, page_creation: bool -) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - - async def main() -> None: - started, finish = asyncio.Event(), asyncio.Event() - error = RuntimeError("Resource creation failed") - unhandled: list[dict[str, object]] = [] - loop = asyncio.get_running_loop() - previous_handler = loop.get_exception_handler() - - def unexpected(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: - unhandled.append(context) - - loop.set_exception_handler(unexpected) - async with Fetcher() as fetcher: - browser = Browser(fetcher.permit) - if page_creation: - session = await browser.session() - - async def create_page() -> FakePage: - started.set() - await finish.wait() - raise error - - monkeypatch.setattr(driver.contexts[0], "new_page", create_page) - - async def operation() -> object: - return await session.get("https://a.test/list") - - else: - - async def create_context(**_options: object) -> FakeContext: - started.set() - await finish.wait() - raise error - - monkeypatch.setattr(driver, "new_context", create_context) - - async def operation() -> object: - return await browser.session() - - task = asyncio.create_task(operation()) - try: - await started.wait() - task.cancel() - await asyncio.sleep(0) - assert not task.done() - finish.set() - with pytest.raises(RuntimeError) as caught: - await task - assert caught.value is error - await asyncio.sleep(0) - assert not unhandled - assert driver.active_pages == 0 - finally: - finish.set() - await asyncio.gather(task, return_exceptions=True) - loop.set_exception_handler(previous_handler) - await browser.close() + async with ( + Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher, + fetcher.session(True, url="https://a/list") as session, + ): + operation = ( + session.download("https://a/file.pdf", page_url="https://a/notice") + if download + else session.get("https://a/notice") + ) + with pytest.raises(FetchError) as caught: + await operation + assert caught.value.failure == failure + driver.error = None + assert (await session.get("https://a/retry")).status == 200 + assert clock.now == INTERVAL run(main) +@pytest.mark.parametrize("stage", ["start", "context", "configure"]) @pytest.mark.parametrize( - ("message", "failure"), + ("error", "failure"), [ - ("APIRequestContext.get: self-signed certificate", "unreachable"), - ("APIRequestContext.get: certificate has expired", "unreachable"), - ("APIRequestContext.get: unable to verify the first certificate", "unreachable"), - ( - "APIRequestContext.get: Hostname/IP does not match certificate's altnames:" - " Host: a.test", - "unreachable", - ), - ( - "APIRequestContext.get: failed to decompress 'gzip' encoding: incorrect header", - "unreachable", - ), - ("APIRequestContext.get: Unknown internal failure", None), - ( - "APIRequestContext.get: Unknown internal failure\nCall log:\n" - " - User-Agent: ECONNRESET self-signed certificate\n", - None, - ), + (PlaywrightTimeoutError("Timed out"), "timeout"), + (PlaywrightError("Browser initialization failed"), "unreachable"), ], - ids=["self_signed", "expired", "unverified", "hostname", "compression", "unknown", "call_log"], ) -def test_attachment_transport_errors_release_the_permit_and_preserve_unknown_errors( - monkeypatch: pytest.MonkeyPatch, message: str, failure: Failure | None +def test_initialization_errors_include_the_source_url( + monkeypatch: pytest.MonkeyPatch, + driver: FakeBrowser, + stage: str, + error: PlaywrightError, + failure: Failure, ) -> None: - driver = FakeBrowser() - use_browser(monkeypatch, driver) - clock = FakeClock() + async def fail(*_args: object, **_options: object) -> None: + raise error + + if stage == "start": + monkeypatch.setattr(Browser, "_start", fail) + elif stage == "context": + monkeypatch.setattr(FakeBrowser, "new_context", fail) + else: + monkeypatch.setattr(FakeContext, "route", fail) async def main() -> None: - transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"still available")) - async with Fetcher(transport, clock.clock, clock.sleep) as fetcher: - browser = Browser(fetcher.permit) - try: - session = await browser.session() - error = PlaywrightError(message) - driver.contexts[0].request.error = error - if failure is None: - with pytest.raises(PlaywrightError) as caught: - await session.download( - "https://a.test/file.pdf", page_url="https://a.test/notice" - ) - assert caught.value is error - else: - with pytest.raises(FetchError) as source_failure: - await session.download( - "https://a.test/file.pdf", page_url="https://a.test/notice" - ) - assert source_failure.value.failure == failure - assert ( - await fetcher.get("https://a.test/after-failure") - ).body == b"still available" - assert clock.now == INTERVAL - finally: - await browser.close() + async with Fetcher() as fetcher: + with pytest.raises(FetchError) as caught: + await fetcher.browser().session("https://a/list") + assert caught.value.failure == failure + assert caught.value.url == "https://a/list" + assert all(context.closed for context in driver.contexts) run(main) diff --git a/crawler/src/crawler/crawl/contract.py b/crawler/src/crawler/crawl/contract.py index e3cce86..925d454 100644 --- a/crawler/src/crawler/crawl/contract.py +++ b/crawler/src/crawler/crawl/contract.py @@ -29,7 +29,7 @@ class PendingNotice(BaseModel): step: Annotated[ Literal["screen", "fetch"], Field( - description="screen:还没初筛;fetch:初筛通过但最新快照还没抽取的,或者项目还没截止、最新快照有附件下载失败或文件未补传,且一天没抓过的" + description="screen:还没初筛;fetch:要抓正文,初筛通过还没抽取的,或者项目还没截止、一天没抓过的" ), ] @@ -114,14 +114,6 @@ class AttachmentInfo(BaseModel): url: Annotated[str, Field(max_length=2000)] content_type: Annotated[str, Field(max_length=200, min_length=1)] text: Annotated[str, Field(max_length=2097152)] - error: Annotated[ - str | None, - Field( - description="附件下载失败原因;不写表示下载正常,不影响通知正文和其他附件", - max_length=2000, - min_length=1, - ), - ] = None type ValueItem = Annotated[str, Field(max_length=500, min_length=1)] diff --git a/crawler/src/crawler/crawl/fetch.py b/crawler/src/crawler/crawl/fetch.py index 9897916..540769a 100644 --- a/crawler/src/crawler/crawl/fetch.py +++ b/crawler/src/crawler/crawl/fetch.py @@ -42,21 +42,14 @@ class _Site: finished_at: float = float("-inf") -def site_key(url: str) -> str: - return httpx.URL(url).netloc.decode("ascii") - - @dataclass class Permit: _site: _Site _clock: Callable[[], float] - _released: bool = False def release(self) -> None: - if not self._released: - self._released = True - self._site.finished_at = self._clock() - self._site.lock.release() + self._site.finished_at = self._clock() + self._site.lock.release() class Fetcher: @@ -86,8 +79,8 @@ async def __aexit__(self, *_: object) -> None: finally: await self._client.aclose() - def session(self, browser: bool) -> PageSession: - return PageSession(self, browser) + def session(self, browser: bool, *, url: str) -> PageSession: + return PageSession(self, browser, url) def browser(self) -> Browser: if self._browser is None: @@ -98,7 +91,7 @@ def browser(self) -> Browser: return self._browser async def permit(self, url: str) -> Permit: - site = self._sites.setdefault(site_key(url), _Site()) + site = self._sites.setdefault(httpx.URL(url).netloc.decode("ascii"), _Site()) await site.lock.acquire() try: wait = site.finished_at + INTERVAL - self._clock() @@ -136,37 +129,27 @@ async def _send(self, url: str) -> httpx.Response: class PageSession: - def __init__(self, fetcher: Fetcher, browser: bool) -> None: + def __init__(self, fetcher: Fetcher, browser: bool, url: str) -> None: self._fetcher = fetcher self._use_browser = browser + self._url = url self._browser_session: BrowserSession | None = None - self._lock = asyncio.Lock() - self._closed = False async def __aenter__(self) -> Self: + if self._use_browser: + self._browser_session = await self._fetcher.browser().session(self._url) return self async def __aexit__(self, *_: object) -> None: - self._closed = True if self._browser_session is not None: await self._browser_session.close() - async def _open_browser(self) -> BrowserSession: - async with self._lock: - if self._browser_session is None: - self._browser_session = await self._fetcher.browser().session() - return self._browser_session - async def get(self, url: str) -> Response: - if self._closed: - raise RuntimeError("Page session is closed") - if self._use_browser: - return await (await self._open_browser()).get(url) + if self._browser_session is not None: + return await self._browser_session.get(url) return await self._fetcher.get(url) async def download(self, url: str, *, page_url: str) -> Response: - if self._closed: - raise RuntimeError("Page session is closed") - if self._use_browser: - return await (await self._open_browser()).download(url, page_url=page_url) + if self._browser_session is not None: + return await self._browser_session.download(url, page_url=page_url) return await self._fetcher.get(url) diff --git a/crawler/src/crawler/crawl/fetch_test.py b/crawler/src/crawler/crawl/fetch_test.py index 4b2f516..2522dba 100644 --- a/crawler/src/crawler/crawl/fetch_test.py +++ b/crawler/src/crawler/crawl/fetch_test.py @@ -88,24 +88,6 @@ def test_a_redirect_to_another_site_waits_its_turn_there() -> None: assert all(gap >= INTERVAL for gap in gaps(sites.times("b"))) -def test_http_and_browser_permissions_share_the_completion_interval() -> None: - sites = FakeSites({"http://a/notice": httpx.Response(200)}) - - async def use(fetcher: Fetcher) -> None: - browser_request = await fetcher.permit("http://A:80/list.htm") - http_request = asyncio.create_task(fetcher.get("http://a/notice")) - await asyncio.sleep(0) - assert not sites.requests - sites.now = 3.0 - browser_request.release() - # A late terminal event for the old request must not release another one's permit. - browser_request.release() - await http_request - - sites.run(use) - assert sites.times("a") == [3.0 + INTERVAL] - - def test_cancelling_the_interval_wait_does_not_mark_a_request_finished() -> None: async def run() -> None: sites = FakeSites({}) diff --git a/crawler/src/crawler/crawl/rounds.py b/crawler/src/crawler/crawl/rounds.py index beef517..334bf21 100644 --- a/crawler/src/crawler/crawl/rounds.py +++ b/crawler/src/crawler/crawl/rounds.py @@ -5,10 +5,11 @@ import logging import time from collections.abc import Awaitable, Callable +from contextlib import AsyncExitStack from dataclasses import dataclass -from crawler.crawl.attachments import Attachment from crawler.crawl.contract import ( + AttachmentInfo, ExtractedProgram, Extraction, ExtractionContext, @@ -39,6 +40,12 @@ def __init__(self, failure: Failure, detail: str | None = None) -> None: self.detail = detail +@dataclass(frozen=True) +class Attachment: + info: AttachmentInfo + data: bytes + + @dataclass(frozen=True) class NoticePage: html: str @@ -78,9 +85,10 @@ async def crawl_source( steps: Steps, clock: Callable[[], float] = time.time, ) -> None: - async with fetcher.session(source.browser) as pages: - started_at = int(clock()) + started_at = int(clock()) + async with AsyncExitStack() as stack: try: + pages = await stack.enter_async_context(fetcher.session(source.browser, url=source.url)) items = await steps.list_items(pages, source) if not items: raise StepError("no_items") @@ -161,11 +169,7 @@ async def _fetch_notice( ), ) for position in result.missing_files: - attachment = page.attachments[position] - if attachment.data is not None: - await website.attachment( - result.snapshot_id, position, attachment.data, url=attachment.info.url - ) + await website.attachment(result.snapshot_id, position, page.attachments[position].data) if result.extraction is None: return 0 programs, usage = await steps.extract(page, result.extraction) diff --git a/crawler/src/crawler/crawl/rounds_test.py b/crawler/src/crawler/crawl/rounds_test.py index 620976b..a54b3fe 100644 --- a/crawler/src/crawler/crawl/rounds_test.py +++ b/crawler/src/crawler/crawl/rounds_test.py @@ -1,14 +1,11 @@ import asyncio import json import re -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager from typing import Any import httpx import pytest -from crawler.crawl.attachments import Attachment, download_attachment from crawler.crawl.contract import ( AttachmentInfo, ExtractedFields, @@ -20,8 +17,8 @@ Usage, WorkSource, ) -from crawler.crawl.fetch import Fetcher, FetchError, PageSession, Response -from crawler.crawl.rounds import NoticePage, StepError, Steps, crawl_source +from crawler.crawl.fetch import Fetcher, FetchError, PageSession +from crawler.crawl.rounds import Attachment, NoticePage, StepError, Steps, crawl_source from crawler.crawl.website import Website SOURCE = WorkSource( @@ -45,14 +42,11 @@ def __init__(self, pending: list[dict[str, Any]], extracted: frozenset[int]) -> self.pending = pending self.extracted = extracted self.calls: list[tuple[str, Any]] = [] - self.upload_urls: list[str] = [] def handle(self, request: httpx.Request) -> httpx.Response: path = request.url.path.removeprefix("/api/v1/crawler") sent_json = request.headers.get("content-type") == "application/json" body: Any = json.loads(request.content) if sent_json else request.content - if request.method == "PUT": - self.upload_urls.append(request.url.params["url"]) self.calls.append((f"{request.method} {path}", body)) if path.endswith("/listing"): return httpx.Response(200, json={"new_count": 2, "pending": self.pending}) @@ -70,19 +64,14 @@ def handle(self, request: httpx.Request) -> httpx.Response: ) return httpx.Response(204) - def run( - self, - steps: Steps, - source: WorkSource = SOURCE, - transport: httpx.AsyncBaseTransport | None = None, - ) -> None: + def run(self, steps: Steps) -> None: async def main() -> None: - nowhere = transport or httpx.MockTransport(lambda _: httpx.Response(500)) + nowhere = httpx.MockTransport(lambda _: httpx.Response(500)) async with ( Fetcher(nowhere) as fetcher, Website("http://site", "token", httpx.MockTransport(self.handle)) as website, ): - await crawl_source(source, fetcher, website, steps, clock=lambda: 1_800_000_000) + await crawl_source(SOURCE, fetcher, website, steps, clock=lambda: 1_800_000_000) asyncio.run(main()) @@ -176,48 +165,6 @@ async def read(_pages: PageSession, _source: WorkSource, url: str) -> NoticePage } -def test_attachment_errors_are_reported_without_uploading_error_pages_or_losing_the_notice() -> ( - None -): - site = FakeWebsite([pending(1, "fetch")], extracted=frozenset()) - files = { - "https://captcha.test/download": "
请输入验证码下载附件
".encode(), - "https://deleted.test/download": "
获取文件错误
".encode(), - "https://files.test/download?file=form.pdf&owner=2": b"%PDF-1.7\n", - } - - def handle(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - content=files[str(request.url)], - headers={"content-type": "text/html;charset=utf-8"}, - ) - - async def read(pages: PageSession, _source: WorkSource, url: str) -> NoticePage: - attachments = [ - await download_attachment(pages, link, name=f"申请表 {n}", page_url=url) - for n, link in enumerate(files) - ] - return NoticePage("

通知

", "通知", attachments) - - site.run(Steps(list_items, screen, read, extract), transport=httpx.MockTransport(handle)) - - [snapshot] = site.called("POST /notices/1/snapshot") - assert [a["url"] for a in snapshot["attachments"]] == list(files) - assert [a.get("error") for a in snapshot["attachments"]] == [ - "下载附件需要验证码", - "获取文件错误", - None, - ] - assert [a["text"] for a in snapshot["attachments"]] == ["", "", ""] - uploads = [(call, body) for call, body in site.calls if call.startswith("PUT ")] - assert uploads == [("PUT /snapshots/10/attachments/2", b"%PDF-1.7\n")] - assert site.upload_urls == ["https://files.test/download?file=form.pdf&owner=2"] - assert len(site.called("POST /snapshots/10/extraction")) == 1 - [run] = site.called("POST /runs") - assert run["notice_failures"] == [] - - def test_notices_that_fail_are_reported_and_the_round_goes_on() -> None: site = FakeWebsite([pending(n, "fetch") for n in range(1, 5)], extracted=frozenset()) @@ -265,84 +212,20 @@ async def unused(*_: object) -> Any: assert run["listed"] == 0 -@pytest.fixture -def sessions( - monkeypatch: pytest.MonkeyPatch, -) -> tuple[list[tuple[bool, PageSession]], list[PageSession]]: - entered: list[tuple[bool, PageSession]] = [] - exited: list[PageSession] = [] - original_session = Fetcher.session - - @asynccontextmanager - async def session(fetcher: Fetcher, browser: bool) -> AsyncGenerator[PageSession]: - async with original_session(fetcher, browser) as pages: - entered.append((browser, pages)) - try: - yield pages - finally: - exited.append(pages) - - monkeypatch.setattr(Fetcher, "session", session) - return entered, exited - - -def test_a_round_shares_one_session_and_reports_the_rendered_notice( - monkeypatch: pytest.MonkeyPatch, - sessions: tuple[list[tuple[bool, PageSession]], list[PageSession]], -) -> None: - entered, exited = sessions - source = SOURCE.model_copy(update={"browser": True}) - site = FakeWebsite([pending(1, "fetch")], extracted=frozenset({1})) - rendered = "

合成的渲染正文

" - seen: list[PageSession] = [] - - async def get(pages: PageSession, url: str) -> Response: - assert not exited - seen.append(pages) - html = "
  • 合成的列表
" if url == source.url else rendered - return Response(url, 200, "text/html; charset=utf-8", html.encode()) - - async def listing(pages: PageSession, source: WorkSource) -> list[ListedItem]: - response = await pages.get(source.url) - assert "合成的列表" in response.body.decode() - return ITEMS - - async def read(pages: PageSession, _source: WorkSource, url: str) -> NoticePage: - response = await pages.get(url) - return NoticePage(html=response.body.decode(), text="合成的渲染正文", attachments=[]) - - monkeypatch.setattr(PageSession, "get", get) - site.run(Steps(listing, screen, read, extract), source) - - assert len(seen) == 2 - assert seen[0] is seen[1] - assert entered == [(True, seen[0])] - assert exited == [seen[0]] - [snapshot] = site.called("POST /notices/1/snapshot") - assert snapshot["html"] == rendered - assert snapshot["text"] == "合成的渲染正文" - - -@pytest.mark.parametrize("failure", [ValueError, asyncio.CancelledError]) -def test_a_session_exits_when_a_step_fails_or_is_cancelled( - sessions: tuple[list[tuple[bool, PageSession]], list[PageSession]], - failure: type[BaseException], -) -> None: - entered, exited = sessions +def test_a_session_that_cant_start_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: site = FakeWebsite([], extracted=frozenset()) - async def interrupted(pages: PageSession, _source: WorkSource) -> list[ListedItem]: - assert pages is entered[0][1] - raise failure("interrupted") + async def unavailable(_pages: PageSession) -> PageSession: + raise FetchError("unreachable", SOURCE.url) - async def read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage: - raise AssertionError + async def unused_read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage: + raise AssertionError("Listing failed before notices could be read") - with pytest.raises(failure, match="interrupted"): - site.run(Steps(interrupted, screen, read, extract)) + monkeypatch.setattr(PageSession, "__aenter__", unavailable) + site.run(Steps(list_items, screen, unused_read, extract)) - assert len(entered) == 1 - assert exited == [entered[0][1]] - assert not site.calls - with pytest.raises(RuntimeError, match="Page session is closed"): - asyncio.run(entered[0][1].get(SOURCE.url)) + assert [call for call, _ in site.calls] == ["POST /runs"] + [run] = site.called("POST /runs") + assert run["failure"] == "unreachable" + assert run["detail"] == SOURCE.url + assert run["listed"] == 0 diff --git a/crawler/src/crawler/crawl/website.py b/crawler/src/crawler/crawl/website.py index 2cfbe41..2df1a7e 100644 --- a/crawler/src/crawler/crawl/website.py +++ b/crawler/src/crawler/crawl/website.py @@ -52,10 +52,9 @@ async def snapshot(self, notice_id: int, snapshot: Snapshot) -> SnapshotResult: body = await self._call("POST", f"/notices/{notice_id}/snapshot", snapshot) return SnapshotResult.model_validate_json(body) - async def attachment(self, snapshot_id: int, position: int, data: bytes, *, url: str) -> None: + async def attachment(self, snapshot_id: int, position: int, data: bytes) -> None: response = await self._client.put( f"/snapshots/{snapshot_id}/attachments/{position}", - params={"url": url}, content=data, headers={"Content-Type": "application/octet-stream"}, ) diff --git a/docs/api.md b/docs/api.md index 57e1728..e78d8bb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -101,15 +101,13 @@ | 接口 | 说明 | | --- | --- | | `GET /crawler/work` | 领活:到期要抓的来源和配置,管理员要试抓的地址。发出去的活带租约,到期前不再发;抓取节点中途断了,租约过期后重新发 | -| `POST /crawler/sources/{id}/listing` | 交来源列表页上的条目(网址、标题、日期)。返回这个来源要接着处理的通知:没初筛的;要抓正文的,也就是初筛通过但最新快照还没抽取的,以及项目还没截止、最新快照有附件下载失败或文件未补传,且一天没抓过的([总体设计 §7.4](design.md#74-发现变化)) | +| `POST /crawler/sources/{id}/listing` | 交来源列表页上的条目(网址、标题、日期)。返回这个来源要接着处理的通知:没初筛的;要抓正文的,也就是初筛通过、还没抽取的,以及项目还没截止、一天没抓过的([总体设计 §7.4](design.md#74-发现变化)) | | `POST /crawler/sources/{id}/screening` | 交一批通知的初筛分数和是否通过,带模型用量 | -| `POST /crawler/notices/{id}/snapshot` | 交抓到的正文、附件的名称、地址和文字,附件下载失败时附上原因。文字和上一份快照相同就不存新的,下载错误等元数据仍更新。返回快照的编号和还缺文件的附件;要抽取时(有了新快照,或者上一份还没抽取),再附上抽取要对照的东西:通知的发布日期、这所学校在院校清单里的学院、这所学校发布那年到后两年这几届的项目(编号、学院、届、类型、名称、报名时间) | -| `PUT /crawler/snapshots/{id}/attachments/{position}` | 上传一个附件的文件,请求体就是文件本身,查询参数带原下载链接;链接已更新时拒绝旧文件上传 | +| `POST /crawler/notices/{id}/snapshot` | 交抓到的正文、附件的名称、地址和文字。文字和上一份快照相同就不存新的。返回快照的编号和还缺文件的附件;要抽取时(有了新快照,或者上一份还没抽取),再附上抽取要对照的东西:通知的发布日期、这所学校在院校清单里的学院、这所学校发布那年到后两年这几届的项目(编号、学院、届、类型、名称、报名时间) | +| `PUT /crawler/snapshots/{id}/attachments/{position}` | 上传一个附件的文件,请求体就是文件本身 | | `POST /crawler/snapshots/{id}/extraction` | 交抽取的结果,带模型用量。结果是通知涉及的项目,每个带编号(新建的没有)、学院、届、类型、名称、作用和字段,格式见[总体设计 §8.2](design.md#82-抽取);网站按 §8.3、§8.4 校验和归并 | | `POST /crawler/notices/{id}/removed` | 通知打不开(404、410),标成原文已删除;之后又能打开的,交新快照时取消 | | `POST /crawler/runs` | 交一个来源这一轮的结果:开始和结束时间、成功还是失败和原因、列表里几条、新通知几条、其中有用的几条、哪些通知没抓成和原因。交了才算这一轮结束,失败了也等下一轮再抓 | | `POST /crawler/probes/{id}` | 交试抓的结果:抓到的条目和生成的配置,或者失败的原因,带模型用量 | 一个来源的一轮:领活 → 抓列表页、交列表 → 对没初筛的初筛、交分数 → 对要抓正文的抓正文和附件、交快照、上传附件 → 要抽取的抽取、交结果 → 交这一轮的结果,没抓成的通知和原因写在里面。重抓正文也在来源的这一轮里,守同样的频率,失败了一样记进抓取记录。 - -单个附件下载失败仍保留名称、原下载链接和顺序,附件文字留空,原因写入 `error`:验证码明确标注需要验证码,接口的短错误提示照原文保存,取不到提示时记录 HTTP 状态或传输失败原因。网站不要求上传这些附件;最新快照仍有下载错误或缺文件的通知,即使抽取没有项目,也按一天一次的间隔在来源后续轮次里重抓,下载成功后清除错误并补传文件。同位置、同链接已有的文字和原件在下载失败时保留;仅下载错误变化不创建新快照、不重复抽取。文字不变而下载链接变化时刷新元数据,清除原链接的文件,等待补传。 diff --git a/docs/data-model.md b/docs/data-model.md index c259e1a..2ba1c2d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -36,7 +36,7 @@ | `crawl_runs` | `ingest` | 一个来源每一轮的结果,抓取看板用:`failure` 为空就是列表读到了,`detail` 写具体原因;各项计数;`notice_failures` 是没抓成的通知和原因 | | `notices` | `ingest` | 列表里的一条通知,`(source_id, url)` 唯一。`screening_score` 和 `screened_in` 是初筛的分数和结论,为空就是还没初筛;`checked_at` 是上次抓正文的时间,重抓用;`removed_at` 有值是原文已删除。抽取出项目的才有版本,没有版本的就是无关 | | `snapshots` | `ingest` | 某次抓到的正文,文字和上一份相同就不存新的;`extracted_at` 为空说明还没交抽取的结果 | -| `attachments` | `ingest` | 快照里的附件:名称、地址、类型、提取出的文字和文件本身。`position` 是在页面上的顺序,证据里的 `attachment` 指的就是它;文件在抓取节点上传后才有。`error` 记录下载失败原因,成功时为空,和原文文字分开保存 | +| `attachments` | `ingest` | 快照里的附件:名称、地址、类型、提取出的文字和文件本身。`position` 是在页面上的顺序,证据里的 `attachment` 指的就是它;文件在抓取节点上传后才有 | | `model_calls` | `ingest` | 判别模型和 LLM 的用量,随产出的结果一起交来 | ## 项目 diff --git a/docs/design.md b/docs/design.md index 0a495e4..1bb2df8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -160,7 +160,7 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。 ### 7.3 频率和礼貌 - 平时每个来源每天抓 2 次;3–9 月和 12–1 月是旺季,每 4 小时一次。 -- 同一个网站同时只进行一次普通请求、附件下载或完整的浏览器页面访问;一次完成后等 5 秒,再开始下一次。浏览器的一次访问包含同站的脚本挑战、重载和页面子资源,子资源按浏览器正常方式加载;跨站跳转的排队规则见[开发约定 §2](development.md#2-抓取和判别)。请求头写明是鼠钟,附上联系方式。 +- 同一个网站同一时间只发一个请求,两次请求之间隔 5 秒;请求头写明是鼠钟,附上联系方式。浏览器的一次页面访问算一个请求。 - 不读 robots.txt:抓的都是公开的通知列表,礼貌靠上面这几条保证;有的学院官网一请求 robots.txt 就断开连接(比如西电),照它的规矩反而一页都抓不了。 - 失败了等下一轮再抓,一轮里不反复重试。 @@ -169,7 +169,7 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。 - **新通知**:列表里出现没见过的链接,直接比网址。 - **正文变化**:还没截止的项目,每天重抓一次它的通知,在它的来源那一轮里一起抓;正文的文字和上一份快照不同,就存一份新快照,再交给抽取。 - **删除**:重抓时通知打不开(404、410),标成「原文已删除」,快照照样能看。从列表里消失不算:列表会翻页,旧通知本来就会被挤下去。 -- **附件**:PDF、Word、Excel 和图片都下载下来,解析出文字,图片做文字识别;附件和正文一起存进快照。单个附件下载失败不影响通知:保留名称、原下载链接和失败原因;需要验证码的明确标注,不破解。接口返回的短错误提示作为原因保存,不作为附件正文或文件。最新快照仍有下载错误或缺文件的通知,即使抽取没有项目,也在来源后续轮次里按一天一次的间隔重抓。 +- **附件**:PDF、Word、Excel 和图片都下载下来,解析出文字,图片做文字识别;附件和正文一起存进快照。 ### 7.5 抓取看板 diff --git a/docs/development.md b/docs/development.md index e957a29..668d8c3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,16 +59,11 @@ crawler/ 抓取节点 - 接入来源时按通用抓取 → 配置 → 浏览器抓取 → 专用适配器的顺序试,前一种能抓就不用后一种([总体设计 §7.2](design.md#72-怎么抓))。 - 建站系统的模板放在代码里,和它的测试在一起;单个来源的配置存在网站的数据库里,在管理页面改,抓取节点领活时一起拿到。 -- 所有对来源网站的访问都经过 `crawl/fetch.py` 的 `Fetcher`,整个进程共用一个,[总体设计 §7.3](design.md#73-频率和礼貌)的规矩由它保证:UA 写明鼠钟;普通 HTTP 请求、完整的浏览器页面访问和程序下载的附件共用按网站排队的许可,多个任务同时抓也一样;上一次访问完成并释放许可后,再等 5 秒才开始下一次。 -- 浏览器的 `get(url)` 在顶层导航前取得网站许可,持有到页面关闭,覆盖同站的 HTTP 跳转、脚本挑战和重载;页面子资源按浏览器正常方式加载。顶层导航跨 host 时,先释放原站许可,再等待目标站的许可。普通 HTTP 请求和附件的跳转每一步单独取得许可,跳到别的网站按那个网站的间隔排队。 -- `crawl_source` 每轮通过 `fetcher.session(source.browser)` 创建一个 `PageSession`,交给 `Steps.list_items` 和 `Steps.read`:`get(url)` 取页面,`download(url, page_url=原文网址)` 下载附件,两者都返回 `Response`。普通来源直接请求;`browser` 为真的来源用浏览器取渲染后的 HTML,以 UTF-8 字节返回。列表、正文和附件的解析仍由对应步骤负责。 -- 读取步骤用 `crawl/attachments.py` 的 `download_attachment` 下载附件:识别验证码、HTTP 和传输错误、接口返回的 HTML 错误提示,失败时返回原链接和独立的 `error`,文件为空;成功时保留原始字节,文字交给附件解析。错误页不上传为文件,错误原因不进入抽取用的 `text`,也不把整条通知标成已删除。日志只记下载失败和链接,不写源站返回的文字。 +- 所有对来源网站的请求都经过 `crawl/fetch.py` 的 `Fetcher`,整个进程共用一个。UA 和请求间隔遵守[总体设计 §7.3](design.md#73-频率和礼貌),多个任务也不能同时请求同一个网站。普通 HTTP 请求和附件下载逐次处理跳转,跳到别的网站就按那个网站的间隔排队。 +- 浏览器一次页面访问取一次许可,拿到页面关闭。页面子资源正常加载,跨网站跳转沿用起始网站的许可。 - 浏览器抓取启动时关掉 Chromium 的自动化标记(`--disable-blink-features=AutomationControlled`),UA 用浏览器自己的,把 `HeadlessChrome` 写成 `Chrome`,末尾加上鼠钟的标识;瑞数这类脚本挑战两处都检查([总体设计 §5.2](design.md#52-实测))。不冒充别的浏览器和系统。 -- 浏览器来源每一轮共用一个无痕会话,列表、通知、跨 host 跳转和附件沿用它,抓完就关,失败或取消也关闭;不同来源、不同轮次不共享会话。Cookie 由浏览器按 domain、path 和 secure 规则筛选;附件使用原文所在会话的请求客户端下载,共享浏览器管理的 Cookie。 -- 整个进程共用一个 Chromium 无头精简版,首次读取浏览器页面时才启动;同时最多开 2 个页面,多的排队。拒绝弹窗和浏览器下载;不加载图片、字体和音视频,省对方的流量,也省内存。 -- 浏览器页面等到最终文档已提交并触发 `DOMContentLoaded` 后读取 HTML,并确认读取期间未开始新的主文档导航。快照对应这一时刻的 DOM,列表和正文是否有效由解析步骤检查。 -- 普通 HTTP 请求(含普通来源的附件)使用 httpx 的网络操作超时:连接、连接池等待、每次读取和写入最多各等 30 秒,不是整次传输的总期限。浏览器来源的附件每个跳转请求总期限为 30 秒,从取得许可后开始,不含等待站点许可和间隔。 -- 浏览器页面总期限为 300 秒,从取得页面名额后开始,包含页面创建、站点许可排队、脚本挑战刷新和 DOM 就绪;等待页面名额不计入。期限内等不到最终页面就按超时记录,不把最初的 202、412 挑战页当成成功结果。成功、失败或取消后都先回收已发出的创建操作及页面,再释放网站许可和页面名额。 +- 每个浏览器来源每轮使用一个无痕会话,列表、正文和附件共用其中的 Cookie,抓完就关。脚本挑战设好 Cookie 后,后面的页面不用再过挑战;不同来源、不同轮次不共享会话。 +- 浏览器同时最多开 2 个页面,多的排队。拒绝弹窗和浏览器下载,附件由程序另外下载;不加载图片、字体和音视频,省对方的流量,也省内存。 - 解析和模型分工:`parse` 用程序取出能确定的东西,模型只做判断([总体设计 §8.2](design.md#82-抽取))。解析不猜:判断不了的交给模型或人,不硬配。 - 判别模型和 LLM 的提示词放在调用它的代码旁边。输出都按固定的结构解析,解析不了的当失败处理。 - 每一轮抓取都交一份抓取记录,失败也交,写明原因;不在一轮里反复重试。各步抓不成时抛 `FetchError`(`Fetcher` 抛的)或 `StepError`(写明失败类别,比如 `http_status` 和「HTTP 403」),一轮的流程把它记进抓取记录;别的异常是 bug,照常抛出。 @@ -80,7 +75,7 @@ uv sync uv run playwright install --with-deps --only-shell chromium ``` -第二条安装 Chromium 无头精简版及系统依赖。CI 不安装浏览器:自动测试使用假的浏览器事件、固定响应和假时钟,不连接真实网站;真实浏览器和来源网站的验证在本地进行,结果写进 PR。 +第二条安装 Chromium 无头精简版及系统依赖。CI 不装浏览器,自动测试用假的浏览器对象、固定响应和假时钟,不连真实网站。真实浏览器和来源网站在本地手动验证,结果写进 PR。 ### 2.1 列表配置 diff --git a/web/db/migrations/0001_init.sql b/web/db/migrations/0001_init.sql index 04f2329..257a762 100644 --- a/web/db/migrations/0001_init.sql +++ b/web/db/migrations/0001_init.sql @@ -141,7 +141,6 @@ CREATE TABLE attachments ( url text NOT NULL, content_type text NOT NULL, text text NOT NULL, -- extracted, or recognised in images - error text, -- why the attachment could not be downloaded data bytea, -- NULL until the crawler uploads the file PRIMARY KEY (snapshot_id, position) ); diff --git a/web/db/types.ts b/web/db/types.ts index 83832fe..b8a7cce 100644 --- a/web/db/types.ts +++ b/web/db/types.ts @@ -26,7 +26,6 @@ export type Timestamp = ColumnType; export interface Attachments { content_type: string; data: Buffer | null; - error: string | null; name: string; position: number; snapshot_id: number; diff --git a/web/openapi/crawler.json b/web/openapi/crawler.json index 83a94a7..0ed5c1c 100644 --- a/web/openapi/crawler.json +++ b/web/openapi/crawler.json @@ -191,7 +191,7 @@ "screen", "fetch" ], - "description": "screen:还没初筛;fetch:初筛通过但最新快照还没抽取的,或者项目还没截止、最新快照有附件下载失败或文件未补传,且一天没抓过的" + "description": "screen:还没初筛;fetch:要抓正文,初筛通过还没抽取的,或者项目还没截止、一天没抓过的" } }, "required": [ @@ -600,12 +600,6 @@ "text": { "type": "string", "maxLength": 2097152 - }, - "error": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "description": "附件下载失败原因;不写表示下载正常,不影响通知正文和其他附件" } }, "required": [ @@ -1558,16 +1552,6 @@ "required": false, "name": "position", "in": "path" - }, - { - "schema": { - "type": "string", - "maxLength": 2000, - "format": "uri" - }, - "required": true, - "name": "url", - "in": "query" } ], "requestBody": { diff --git a/web/server/modules/ingest/routes.test.ts b/web/server/modules/ingest/routes.test.ts index 8a683d8..e91771a 100644 --- a/web/server/modules/ingest/routes.test.ts +++ b/web/server/modules/ingest/routes.test.ts @@ -1,4 +1,3 @@ -import type { InferRequestType } from "hono/client"; import { testClient } from "hono/testing"; import { beforeAll, expect, test } from "vitest"; import { app } from "../../app.ts"; @@ -6,15 +5,6 @@ import { db } from "../../platform/db.ts"; import { createCrawlerToken } from "./service.ts"; const api = testClient(app).api.v1.crawler; -type AttachmentInfo = InferRequestType< - (typeof api.notices)[":id"]["snapshot"]["$post"] ->["json"]["attachments"][number]; -const file = { - name: "申请表.docx", - url: "https://x.edu.cn/f.docx", - content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - text: "表", -}; let crawler = { headers: {} }; @@ -55,15 +45,25 @@ async function list(sourceId: number, items: { url: string; title: string }[]) { return res.json(); } -async function snapshot(noticeId: number, attachments: AttachmentInfo[] = [], text = "正文") { +async function snapshot(noticeId: number, attachmentText?: string) { const res = await api.notices[":id"].snapshot.$post( { param: { id: String(noticeId) }, json: { fetched_at: 1780000000, html: "

正文

", - text, - attachments, + text: "正文", + attachments: + attachmentText === undefined + ? [] + : [ + { + name: "申请表.docx", + url: "https://x.edu.cn/f.docx", + content_type: "x", + text: attachmentText, + }, + ], }, }, crawler, @@ -222,289 +222,10 @@ test("the same words make no new snapshot, changed attachment text does", async const { pending } = await list(source.id, [{ url: "https://c.edu.cn/1.htm", title: "推免通知" }]); const noticeId = pending[0].id; - const first = await snapshot(noticeId, [file]); + const first = await snapshot(noticeId, "表"); expect(first).toMatchObject({ missing_files: [0], extraction: { colleges: [{}] } }); - expect(await snapshot(noticeId, [file])).toEqual(first); - expect((await snapshot(noticeId, [{ ...file, text: "新表" }])).snapshot_id).not.toBe( - first.snapshot_id, - ); -}); - -test("attachment download errors keep their links and do not require files", async () => { - const source = await newSource("attachments-error.edu.cn"); - const [notice] = ( - await list(source.id, [{ url: "https://attachments-error.edu.cn/1.htm", title: "通知" }]) - ).pending; - const attachments = [ - { ...file, text: "", error: "需要验证码" }, - { ...file, url: "https://x.edu.cn/deleted.docx", text: "", error: "获取文件错误" }, - ]; - const first = await snapshot(notice.id, attachments); - expect(first).toMatchObject({ missing_files: [], extraction: { colleges: [{}] } }); - expect(await snapshot(notice.id, attachments)).toEqual(first); - expect( - await db.selectFrom("snapshots").select("id").where("notice_id", "=", notice.id).execute(), - ).toHaveLength(1); - const stored = await db - .selectFrom("attachments") - .select(["position", "url", "text", "error", "data"]) - .where("snapshot_id", "=", first.snapshot_id) - .orderBy("position") - .execute(); - expect(stored).toEqual( - attachments.map((attachment, position) => ({ - position, - url: attachment.url, - text: "", - error: attachment.error, - data: null, - })), - ); -}); - -test("error updates and recovery reuse a text snapshot and request recovered files", async () => { - const source = await newSource("attachments-recovery.edu.cn"); - const [notice] = ( - await list(source.id, [{ url: "https://attachments-recovery.edu.cn/1.htm", title: "通知" }]) - ).pending; - const attachment = { ...file, text: "" }; - const first = await snapshot(notice.id, [{ ...attachment, error: "需要验证码" }]); - await db - .updateTable("snapshots") - .set({ extracted_at: new Date() }) - .where("id", "=", first.snapshot_id) - .execute(); - expect(await snapshot(notice.id, [{ ...attachment, error: "HTTP 503" }])).toEqual({ - snapshot_id: first.snapshot_id, - missing_files: [], - extraction: null, - }); - expect( - await db - .selectFrom("attachments") - .select("error") - .where("snapshot_id", "=", first.snapshot_id) - .executeTakeFirstOrThrow(), - ).toEqual({ error: "HTTP 503" }); - expect(await snapshot(notice.id, [attachment])).toEqual({ - snapshot_id: first.snapshot_id, - missing_files: [0], - extraction: null, - }); - expect( - await db - .selectFrom("attachments") - .select("error") - .where("snapshot_id", "=", first.snapshot_id) - .executeTakeFirstOrThrow(), - ).toEqual({ error: null }); - await db - .updateTable("attachments") - .set({ data: Buffer.from("original file") }) - .where("snapshot_id", "=", first.snapshot_id) - .execute(); - expect(await snapshot(notice.id, [attachment])).toEqual({ - snapshot_id: first.snapshot_id, - missing_files: [], - extraction: null, - }); -}); - -test("failed downloads preserve known attachment text and files without changing old snapshots", async () => { - const source = await newSource("attachments-known.edu.cn"); - const [notice] = ( - await list(source.id, [{ url: "https://attachments-known.edu.cn/1.htm", title: "通知" }]) - ).pending; - const first = await snapshot(notice.id, [file]); - const data = Buffer.from("original file"); - await db - .updateTable("attachments") - .set({ data }) - .where("snapshot_id", "=", first.snapshot_id) - .execute(); - await db - .updateTable("snapshots") - .set({ extracted_at: new Date() }) - .where("id", "=", first.snapshot_id) - .execute(); - const failed = { ...file, text: "", content_type: "text/html", error: "需要验证码" }; - expect(await snapshot(notice.id, [failed])).toEqual({ - snapshot_id: first.snapshot_id, - missing_files: [], - extraction: null, - }); - expect( - await db - .selectFrom("attachments") - .select(["text", "content_type", "error", "data"]) - .where("snapshot_id", "=", first.snapshot_id) - .executeTakeFirstOrThrow(), - ).toEqual({ text: file.text, content_type: file.content_type, error: failed.error, data }); - const next = await snapshot(notice.id, [{ ...failed, error: "HTTP 503" }], "修改后的正文"); - expect(next.snapshot_id).not.toBe(first.snapshot_id); - expect(next.missing_files).toEqual([]); - expect( - await db - .selectFrom("attachments") - .select(["text", "error", "data"]) - .where("snapshot_id", "=", next.snapshot_id) - .executeTakeFirstOrThrow(), - ).toEqual({ text: file.text, error: "HTTP 503", data }); - expect( - await db - .selectFrom("attachments") - .select("error") - .where("snapshot_id", "=", first.snapshot_id) - .executeTakeFirstOrThrow(), - ).toEqual({ error: failed.error }); -}); - -test("attachment URL and order updates keep text versions and discard mismatched files", async () => { - const source = await newSource("attachments-order.edu.cn"); - const [notice] = ( - await list(source.id, [{ url: "https://attachments-order.edu.cn/1.htm", title: "通知" }]) - ).pending; - const attachments = [ - { ...file, text: "" }, - { ...file, url: "https://x.edu.cn/g.docx", text: "" }, - ]; - const first = await snapshot(notice.id, attachments); - await db - .updateTable("attachments") - .set({ data: Buffer.from("original file") }) - .where("snapshot_id", "=", first.snapshot_id) - .execute(); - const reordered = [{ ...attachments[1], error: "需要验证码" }, attachments[0]]; - expect(await snapshot(notice.id, reordered)).toMatchObject({ - snapshot_id: first.snapshot_id, - missing_files: [1], - }); - expect( - await db - .selectFrom("attachments") - .select(["position", "url", "error", "data"]) - .where("snapshot_id", "=", first.snapshot_id) - .orderBy("position") - .execute(), - ).toEqual( - reordered.map((attachment, position) => ({ - position, - url: attachment.url, - error: "error" in attachment ? attachment.error : null, - data: null, - })), - ); -}); - -test("a delayed attachment upload cannot overwrite a replacement URL", async () => { - const source = await newSource("attachments-stale.edu.cn"); - const [notice] = ( - await list(source.id, [{ url: "https://attachments-stale.edu.cn/1.htm", title: "通知" }]) - ).pending; - const original = { ...file, text: "" }; - const first = await snapshot(notice.id, [original]); - const replacement = { ...original, url: "https://x.edu.cn/replacement.docx" }; - expect(await snapshot(notice.id, [replacement])).toMatchObject({ - snapshot_id: first.snapshot_id, - missing_files: [0], - }); - const upload = (url: string, data: string) => - app.request( - `/api/v1/crawler/snapshots/${first.snapshot_id}/attachments/0?${new URLSearchParams({ url })}`, - { - method: "PUT", - headers: { ...crawler.headers, "content-type": "application/octet-stream" }, - body: data, - }, - ); - expect((await upload(original.url, "stale file")).status).toBe(404); - expect( - await db - .selectFrom("attachments") - .select("data") - .where("snapshot_id", "=", first.snapshot_id) - .executeTakeFirstOrThrow(), - ).toEqual({ data: null }); - expect((await upload(replacement.url, "replacement file")).status).toBe(204); - expect( - await db - .selectFrom("attachments") - .select("data") - .where("snapshot_id", "=", first.snapshot_id) - .executeTakeFirstOrThrow(), - ).toEqual({ data: Buffer.from("replacement file") }); -}); - -test("failed attachments are fetched once a day even when extraction found no programs", async () => { - const source = await newSource("attachments-retry.edu.cn"); - const items = [{ url: "https://attachments-retry.edu.cn/1.htm", title: "通知" }]; - const [notice] = (await list(source.id, items)).pending; - await api.sources[":id"].screening.$post( - { - param: { id: String(source.id) }, - json: { results: [{ notice_id: notice.id, score: 0.9, screened_in: true }], usage: [] }, - }, - crawler, - ); - const failed = { ...file, text: "", error: "HTTP 503" }; - const first = await snapshot(notice.id, [failed]); - const extracted = await api.snapshots[":id"].extraction.$post( - { param: { id: String(first.snapshot_id) }, json: { programs: [], usage: [] } }, - crawler, - ); - expect(extracted.status).toBe(204); - expect((await list(source.id, items)).pending).toEqual([]); - - const dayAgo = new Date(Date.now() - 25 * 3600_000); - await db.updateTable("notices").set({ checked_at: dayAgo }).where("id", "=", notice.id).execute(); - expect((await list(source.id, items)).pending).toMatchObject([{ id: notice.id, step: "fetch" }]); - - const recovered = await snapshot(notice.id, [{ ...file, text: "" }]); - expect(recovered).toEqual({ - snapshot_id: first.snapshot_id, - missing_files: [0], - extraction: null, - }); - await db.updateTable("notices").set({ checked_at: dayAgo }).where("id", "=", notice.id).execute(); - expect((await list(source.id, items)).pending).toMatchObject([{ id: notice.id, step: "fetch" }]); - const uploaded = await app.request( - `/api/v1/crawler/snapshots/${first.snapshot_id}/attachments/0?${new URLSearchParams({ url: file.url })}`, - { - method: "PUT", - headers: { ...crawler.headers, "content-type": "application/octet-stream" }, - body: "recovered file", - }, - ); - expect(uploaded.status).toBe(204); - expect((await list(source.id, items)).pending).toEqual([]); - - await snapshot(notice.id, [failed]); - const next = await snapshot(notice.id, [file]); - expect(next.snapshot_id).not.toBe(first.snapshot_id); - const nextUploaded = await app.request( - `/api/v1/crawler/snapshots/${next.snapshot_id}/attachments/0?${new URLSearchParams({ url: file.url })}`, - { - method: "PUT", - headers: { ...crawler.headers, "content-type": "application/octet-stream" }, - body: "new file", - }, - ); - expect(nextUploaded.status).toBe(204); - expect((await list(source.id, items)).pending).toMatchObject([{ id: notice.id, step: "fetch" }]); - const nextExtracted = await api.snapshots[":id"].extraction.$post( - { param: { id: String(next.snapshot_id) }, json: { programs: [], usage: [] } }, - crawler, - ); - expect(nextExtracted.status).toBe(204); - expect( - await db - .selectFrom("attachments") - .select("error") - .where("snapshot_id", "=", first.snapshot_id) - .executeTakeFirstOrThrow(), - ).toEqual({ error: failed.error }); - await db.updateTable("notices").set({ checked_at: dayAgo }).where("id", "=", notice.id).execute(); - expect((await list(source.id, items)).pending).toEqual([]); + expect(await snapshot(noticeId, "表")).toEqual(first); + expect((await snapshot(noticeId, "新表")).snapshot_id).not.toBe(first.snapshot_id); }); test("a notice of a program still open comes back to be fetched once a day", async () => { diff --git a/web/server/modules/ingest/routes.ts b/web/server/modules/ingest/routes.ts index bc03217..1dad207 100644 --- a/web/server/modules/ingest/routes.ts +++ b/web/server/modules/ingest/routes.ts @@ -130,7 +130,7 @@ const ListingResult = z published_on: IsoDate.nullable(), step: z.enum(["screen", "fetch"]).openapi({ description: - "screen:还没初筛;fetch:初筛通过但最新快照还没抽取的,或者项目还没截止、最新快照有附件下载失败或文件未补传,且一天没抓过的", + "screen:还没初筛;fetch:要抓正文,初筛通过还没抽取的,或者项目还没截止、一天没抓过的", }), }) .openapi("PendingNotice"), @@ -287,10 +287,6 @@ export const ingestRoutes = router url: Url, content_type: z.string().min(1).max(200), text: z.string().max(2 * MB), - error: z.string().min(1).max(2000).optional().openapi({ - description: - "附件下载失败原因;不写表示下载正常,不影响通知正文和其他附件", - }), }) .openapi("AttachmentInfo"), ) @@ -327,7 +323,6 @@ export const ingestRoutes = router security, request: { params: z.object({ id: IntParam, position: IntParam }), - query: z.object({ url: Url }), body: { content: { "application/octet-stream": { schema: z.string().openapi({ format: "binary" }) }, @@ -343,12 +338,7 @@ export const ingestRoutes = router }), async (c) => { const { id, position } = c.req.valid("param"); - await storeAttachmentFile( - id, - position, - c.req.valid("query").url, - Buffer.from(await c.req.arrayBuffer()), - ); + await storeAttachmentFile(id, position, Buffer.from(await c.req.arrayBuffer())); return c.body(null, 204); }, ) diff --git a/web/server/modules/ingest/service.ts b/web/server/modules/ingest/service.ts index c201494..3c9e39b 100644 --- a/web/server/modules/ingest/service.ts +++ b/web/server/modules/ingest/service.ts @@ -20,8 +20,8 @@ const LEASE_MS = 2 * 3600_000; const INTERVAL_MS = 12 * 3600_000; const BUSY_INTERVAL_MS = 4 * 3600_000; const BUSY_MONTHS = new Set([3, 4, 5, 6, 7, 8, 9, 12, 1]); -// Incomplete attachments and notices of programs still open are fetched daily (design §7.4), -// in their source's round; programs without a deadline are watched for this long after creation. +// Notices of programs still open are fetched again once a day to catch changes (design §7.4), +// in their source's round; those without a deadline, for this long after the program appeared. const RECHECK_MS = 24 * 3600_000; const UNDATED_RECHECK_MS = 90 * 24 * 3600_000; const MAX_SOURCES = 50; @@ -108,7 +108,7 @@ async function recordUsage( // Saves what the list showed and returns the source's notices that still need work: new ones // to screen; screened-in ones not yet extracted, which a round cut short leaves behind; and -// those with incomplete attachments or programs still open, not fetched for a day (design §7.4). +// those of programs still open, not fetched for a day (design §7.4). export async function recordListing(sourceId: number, items: ListedItem[]) { const now = new Date(); return db.transaction().execute(async (trx) => { @@ -131,53 +131,44 @@ export async function recordListing(sourceId: number, items: ListedItem[]) { .execute(); const pending = await trx .selectFrom("notices as n") - .leftJoinLateral( - (eb) => - eb - .selectFrom("snapshots") - .select(["id", "extracted_at"]) - .whereRef("notice_id", "=", "n.id") - .orderBy("id", "desc") - .limit(1) - .as("latest"), - (join) => join.onTrue(), - ) .select(["n.id", "n.url", "n.title", "n.published_on", "n.screening_score"]) .where("n.source_id", "=", sourceId) .where("n.removed_at", "is", null) .where((eb) => eb.or([ eb("n.screening_score", "is", null), - eb.and([eb("n.screened_in", "=", true), eb("latest.extracted_at", "is", null)]), eb.and([ - eb("n.checked_at", "<", new Date(now.getTime() - RECHECK_MS)), - eb.or([ + eb("n.screened_in", "=", true), + eb.not( eb.exists( eb .selectFrom("snapshots as s") - .innerJoin("program_versions as v", "v.snapshot_id", "s.id") - .innerJoin("programs as p", "p.id", "v.program_id") - .select("p.id") + .select("s.id") .whereRef("s.notice_id", "=", "n.id") - .where("p.deleted_at", "is", null) - .where((qb) => - qb.or([ - qb("p.apply_deadline", ">", now), - qb.and([ - qb("p.apply_deadline", "is", null), - qb("p.created_at", ">", new Date(now.getTime() - UNDATED_RECHECK_MS)), - ]), - ]), - ), - ), - eb.exists( - eb - .selectFrom("attachments as a") - .select("a.position") - .whereRef("a.snapshot_id", "=", "latest.id") - .where((qb) => qb.or([qb("a.error", "is not", null), qb("a.data", "is", null)])), + .where("s.extracted_at", "is not", null), ), - ]), + ), + ]), + eb.and([ + eb("n.checked_at", "<", new Date(now.getTime() - RECHECK_MS)), + eb.exists( + eb + .selectFrom("snapshots as s") + .innerJoin("program_versions as v", "v.snapshot_id", "s.id") + .innerJoin("programs as p", "p.id", "v.program_id") + .select("p.id") + .whereRef("s.notice_id", "=", "n.id") + .where("p.deleted_at", "is", null) + .where((qb) => + qb.or([ + qb("p.apply_deadline", ">", now), + qb.and([ + qb("p.apply_deadline", "is", null), + qb("p.created_at", ">", new Date(now.getTime() - UNDATED_RECHECK_MS)), + ]), + ]), + ), + ), ]), ]), ) @@ -212,13 +203,7 @@ export async function recordScreening( }); } -type AttachmentSubmission = { - name: string; - url: string; - content_type: string; - text: string; - error?: string; -}; +type AttachmentSubmission = { name: string; url: string; content_type: string; text: string }; // A new snapshot only when the words changed, the attachments' included (design §7.4). export async function recordSnapshot( @@ -241,28 +226,21 @@ export async function recordSnapshot( .orderBy("id", "desc") .limit(1) .executeTakeFirst(); - const latestAttachments = latest - ? await trx + const latestTexts = + latest && + ( + await trx .selectFrom("attachments") - .select(["url", "content_type", "text"]) + .select("text") .where("snapshot_id", "=", latest.id) .orderBy("position") .execute() - : []; - const attachments = submission.attachments.map((attachment, position) => { - const previous = latestAttachments.at(position); - // A failed download tells us nothing new about the file's existing words or type. - return attachment.error !== undefined && - attachment.text === "" && - previous?.url === attachment.url - ? { ...attachment, text: previous.text, content_type: previous.content_type } - : attachment; - }); + ).map((a) => a.text); const unchanged = latest !== undefined && latest.text === submission.text && - latestAttachments.length === attachments.length && - latestAttachments.every((attachment, i) => attachment.text === attachments[i].text); + latestTexts?.length === submission.attachments.length && + latestTexts.every((text, i) => text === submission.attachments[i].text); const snapshot = unchanged ? latest @@ -276,43 +254,17 @@ export async function recordSnapshot( }) .returning(["id", "extracted_at"]) .executeTakeFirstOrThrow(); - if (unchanged) { - for (const [position, attachment] of attachments.entries()) { - await trx - .updateTable("attachments") - .set({ - name: attachment.name, - url: attachment.url, - content_type: attachment.content_type, - error: attachment.error ?? null, - ...(latestAttachments[position].url !== attachment.url ? { data: null } : {}), - }) - .where("snapshot_id", "=", snapshot.id) - .where("position", "=", position) - .execute(); - } - } else if (attachments.length > 0) { + if (!unchanged && submission.attachments.length > 0) { await trx .insertInto("attachments") .values( - attachments.map((a, position) => ({ + submission.attachments.map((a, position) => ({ snapshot_id: snapshot.id, position, ...a, })), ) .execute(); - if (latest) { - await sql` - UPDATE attachments AS current SET data = previous.data - FROM attachments AS previous - WHERE current.snapshot_id = ${snapshot.id} - AND previous.snapshot_id = ${latest.id} - AND current.position = previous.position - AND current.url = previous.url AND current.text = previous.text - AND current.error IS NOT NULL - `.execute(trx); - } } await trx .updateTable("notices") @@ -325,7 +277,6 @@ export async function recordSnapshot( .select("position") .where("snapshot_id", "=", snapshot.id) .where("data", "is", null) - .where("error", "is", null) .orderBy("position") .execute(); return { @@ -385,18 +336,12 @@ async function extractionContext( return { published_on: publishedOn, colleges, programs }; } -export async function storeAttachmentFile( - snapshotId: number, - position: number, - url: string, - data: Buffer, -) { +export async function storeAttachmentFile(snapshotId: number, position: number, data: Buffer) { const result = await db .updateTable("attachments") .set({ data }) .where("snapshot_id", "=", snapshotId) .where("position", "=", position) - .where("url", "=", url) .executeTakeFirst(); if (result.numUpdatedRows === 0n) throw new AppError(404, "not_found", "附件不存在"); } From 338df6649b8f42c0cd64aa54786e9e8b48820dc2 Mon Sep 17 00:00:00 2001 From: lym12321 <41183806+lym12321@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:01:11 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E7=AE=80=E5=8C=96=E6=8A=93=E5=8F=96?= =?UTF-8?q?=E8=AE=B8=E5=8F=AF=E5=92=8C=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/src/crawler/crawl/browser.py | 73 +++++++++----------- crawler/src/crawler/crawl/browser_test.py | 28 ++++---- crawler/src/crawler/crawl/fetch.py | 81 +++++++++-------------- crawler/src/crawler/crawl/fetch_test.py | 13 ++-- crawler/src/crawler/crawl/rounds_test.py | 15 +++-- docs/development.md | 10 +-- 6 files changed, 99 insertions(+), 121 deletions(-) diff --git a/crawler/src/crawler/crawl/browser.py b/crawler/src/crawler/crawl/browser.py index 97603eb..970b542 100644 --- a/crawler/src/crawler/crawl/browser.py +++ b/crawler/src/crawler/crawl/browser.py @@ -1,7 +1,8 @@ import asyncio import logging import time -from collections.abc import Awaitable, Callable +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager from urllib.parse import urljoin from playwright.async_api import ( @@ -29,7 +30,6 @@ TIMEOUT, USER_AGENT, FetchError, - Permit, Response, ) @@ -42,7 +42,7 @@ class Browser: - def __init__(self, permit: Callable[[str], Awaitable[Permit]]) -> None: + def __init__(self, permit: Callable[[str], AbstractAsyncContextManager[None]]) -> None: self.permit = permit self.slots = asyncio.Semaphore(PAGE_LIMIT) self._lock = asyncio.Lock() @@ -59,11 +59,8 @@ async def _start(self) -> PlaywrightBrowser: browser = await playwright.chromium.launch( headless=True, args=["--disable-blink-features=AutomationControlled"] ) - page = await browser.new_page() - try: + async with await browser.new_page() as page: user_agent: str = await page.evaluate("navigator.userAgent") - finally: - await page.close() except BaseException: await playwright.stop() raise @@ -119,12 +116,10 @@ async def _route(self, route: Route) -> None: await route.continue_() async def get(self, url: str) -> Response: - async with self._owner.slots: - permit = await self._owner.permit(url) + async with self._owner.slots, self._owner.permit(url): started = time.monotonic() try: - page = await self._context.new_page() - try: + async with await self._context.new_page() as page: last_response: PlaywrightResponse | None = None def record_response(response: PlaywrightResponse) -> None: @@ -151,46 +146,40 @@ def record_response(response: PlaywrightResponse) -> None: time.monotonic() - started, ) return response - finally: - await page.close() except PlaywrightTimeoutError as error: raise FetchError("timeout", url) from error except PlaywrightError as error: raise FetchError("unreachable", url) from error - finally: - permit.release() async def download(self, url: str, *, page_url: str) -> Response: for _ in range(MAX_REDIRECTS + 1): - permit = await self._owner.permit(url) - try: - response = await self._context.request.get( - url, - headers={"Referer": page_url}, - timeout=TIMEOUT * 1000, - max_redirects=0, - max_retries=0, - fail_on_status_code=False, - ) + async with self._owner.permit(url): try: - location = response.headers.get("location") - if response.status in _REDIRECTS and location is not None: - url = urljoin(response.url, location) - continue - return Response( - response.url, - response.status, - response.headers.get("content-type"), - await response.body(), + response = await self._context.request.get( + url, + headers={"Referer": page_url}, + timeout=TIMEOUT * 1000, + max_redirects=0, + max_retries=0, + fail_on_status_code=False, ) - finally: - await response.dispose() - except PlaywrightTimeoutError as error: - raise FetchError("timeout", url) from error - except PlaywrightError as error: - raise FetchError("unreachable", url) from error - finally: - permit.release() + try: + location = response.headers.get("location") + if response.status in _REDIRECTS and location is not None: + url = urljoin(response.url, location) + continue + return Response( + response.url, + response.status, + response.headers.get("content-type"), + await response.body(), + ) + finally: + await response.dispose() + except PlaywrightTimeoutError as error: + raise FetchError("timeout", url) from error + except PlaywrightError as error: + raise FetchError("unreachable", url) from error raise FetchError("too_many_redirects", url) async def close(self) -> None: diff --git a/crawler/src/crawler/crawl/browser_test.py b/crawler/src/crawler/crawl/browser_test.py index 0574f87..1c55339 100644 --- a/crawler/src/crawler/crawl/browser_test.py +++ b/crawler/src/crawler/crawl/browser_test.py @@ -1,6 +1,7 @@ import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Self import httpx import pytest @@ -74,7 +75,10 @@ async def goto(self, url: str, *, wait_until: str, **_options: object) -> None: async def content(self) -> str: return "保研通知" - async def close(self) -> None: + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> None: self.closed = True @@ -278,16 +282,15 @@ def test_last_main_navigation_response_is_used_without_a_cross_site_permit( ] async def main() -> None: - async with Fetcher() as fetcher: - destination_permit = await fetcher.permit("https://b/busy") - try: - async with fetcher.session(True, url="https://a/list") as session: - response = await session.get("https://a/notice") - assert response.status == 200 - assert response.url == "https://b/notice" - assert response.body.decode() == "保研通知" - finally: - destination_permit.release() + async with ( + Fetcher() as fetcher, + fetcher.permit("https://b/busy"), + fetcher.session(True, url="https://a/list") as session, + ): + response = await session.get("https://a/notice") + assert response.status == 200 + assert response.url == "https://b/notice" + assert response.body.decode() == "保研通知" run(main) @@ -368,7 +371,8 @@ async def fail(*_args: object, **_options: object) -> None: async def main() -> None: async with Fetcher() as fetcher: with pytest.raises(FetchError) as caught: - await fetcher.browser().session("https://a/list") + async with fetcher.session(True, url="https://a/list"): + pass assert caught.value.failure == failure assert caught.value.url == "https://a/list" assert all(context.closed for context in driver.contexts) diff --git a/crawler/src/crawler/crawl/fetch.py b/crawler/src/crawler/crawl/fetch.py index 540769a..dbbf1ad 100644 --- a/crawler/src/crawler/crawl/fetch.py +++ b/crawler/src/crawler/crawl/fetch.py @@ -3,7 +3,8 @@ import asyncio import time -from collections.abc import Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal, Self @@ -42,16 +43,6 @@ class _Site: finished_at: float = float("-inf") -@dataclass -class Permit: - _site: _Site - _clock: Callable[[], float] - - def release(self) -> None: - self._site.finished_at = self._clock() - self._site.lock.release() - - class Fetcher: """One per crawler process, shared by everything that crawls, so the rules hold across tasks.""" @@ -79,28 +70,33 @@ async def __aexit__(self, *_: object) -> None: finally: await self._client.aclose() - def session(self, browser: bool, *, url: str) -> PageSession: - return PageSession(self, browser, url) + @asynccontextmanager + async def session(self, browser: bool, *, url: str) -> AsyncGenerator[PageSession]: + browser_session = None + if browser: + if self._browser is None: + # Keep the browser library unloaded until a source actually needs it. + from crawler.crawl.browser import Browser - def browser(self) -> Browser: - if self._browser is None: - # Keep the browser library unloaded until a source actually needs it. - from crawler.crawl.browser import Browser - - self._browser = Browser(self.permit) - return self._browser + self._browser = Browser(self.permit) + browser_session = await self._browser.session(url) + try: + yield PageSession(self, browser_session) + finally: + if browser_session is not None: + await browser_session.close() - async def permit(self, url: str) -> Permit: + @asynccontextmanager + async def permit(self, url: str) -> AsyncGenerator[None]: site = self._sites.setdefault(httpx.URL(url).netloc.decode("ascii"), _Site()) - await site.lock.acquire() - try: + async with site.lock: wait = site.finished_at + INTERVAL - self._clock() if wait > 0: await self._sleep(wait) - return Permit(site, self._clock) - except BaseException: - site.lock.release() - raise + try: + yield + finally: + site.finished_at = self._clock() async def get(self, url: str) -> Response: # Each hop on its own, so a redirect to another site waits its turn there too. @@ -117,32 +113,19 @@ async def get(self, url: str) -> Response: raise FetchError("too_many_redirects", url) async def _send(self, url: str) -> httpx.Response: - permit = await self.permit(url) - try: - return await self._client.get(url) - except httpx.TimeoutException as error: - raise FetchError("timeout", url) from error - except httpx.TransportError as error: - raise FetchError("unreachable", url) from error - finally: - permit.release() + async with self.permit(url): + try: + return await self._client.get(url) + except httpx.TimeoutException as error: + raise FetchError("timeout", url) from error + except httpx.TransportError as error: + raise FetchError("unreachable", url) from error class PageSession: - def __init__(self, fetcher: Fetcher, browser: bool, url: str) -> None: + def __init__(self, fetcher: Fetcher, browser_session: BrowserSession | None) -> None: self._fetcher = fetcher - self._use_browser = browser - self._url = url - self._browser_session: BrowserSession | None = None - - async def __aenter__(self) -> Self: - if self._use_browser: - self._browser_session = await self._fetcher.browser().session(self._url) - return self - - async def __aexit__(self, *_: object) -> None: - if self._browser_session is not None: - await self._browser_session.close() + self._browser_session = browser_session async def get(self, url: str) -> Response: if self._browser_session is not None: diff --git a/crawler/src/crawler/crawl/fetch_test.py b/crawler/src/crawler/crawl/fetch_test.py index 2522dba..9171c5c 100644 --- a/crawler/src/crawler/crawl/fetch_test.py +++ b/crawler/src/crawler/crawl/fetch_test.py @@ -102,19 +102,18 @@ async def sleep(seconds: float) -> None: await asyncio.Future[None]() sites.now += seconds - async with Fetcher(clock=sites.clock, sleep=sleep) as fetcher: - first = await fetcher.permit("http://a/first") - first.release() - queued = asyncio.create_task(fetcher.permit("http://a/cancelled")) + async with Fetcher(httpx.MockTransport(sites.handle), sites.clock, sleep) as fetcher: + await fetcher.get("http://a/first") + queued = asyncio.create_task(fetcher.get("http://a/cancelled")) await waiting.wait() + sites.now = 2 queued.cancel() with pytest.raises(asyncio.CancelledError): await queued block = False async with asyncio.timeout(1): - next_request = await fetcher.permit("http://a/next") - next_request.release() - assert waits == [INTERVAL, INTERVAL] + await fetcher.get("http://a/next") + assert waits == [INTERVAL, INTERVAL - 2] asyncio.run(run()) diff --git a/crawler/src/crawler/crawl/rounds_test.py b/crawler/src/crawler/crawl/rounds_test.py index a54b3fe..0a95aff 100644 --- a/crawler/src/crawler/crawl/rounds_test.py +++ b/crawler/src/crawler/crawl/rounds_test.py @@ -64,14 +64,14 @@ def handle(self, request: httpx.Request) -> httpx.Response: ) return httpx.Response(204) - def run(self, steps: Steps) -> None: + def run(self, steps: Steps, source: WorkSource = SOURCE) -> None: async def main() -> None: nowhere = httpx.MockTransport(lambda _: httpx.Response(500)) async with ( Fetcher(nowhere) as fetcher, Website("http://site", "token", httpx.MockTransport(self.handle)) as website, ): - await crawl_source(SOURCE, fetcher, website, steps, clock=lambda: 1_800_000_000) + await crawl_source(source, fetcher, website, steps, clock=lambda: 1_800_000_000) asyncio.run(main()) @@ -215,14 +215,17 @@ async def unused(*_: object) -> Any: def test_a_session_that_cant_start_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: site = FakeWebsite([], extracted=frozenset()) - async def unavailable(_pages: PageSession) -> PageSession: - raise FetchError("unreachable", SOURCE.url) + async def unavailable(_browser: object, url: str) -> None: + raise FetchError("unreachable", url) async def unused_read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage: raise AssertionError("Listing failed before notices could be read") - monkeypatch.setattr(PageSession, "__aenter__", unavailable) - site.run(Steps(list_items, screen, unused_read, extract)) + monkeypatch.setattr("crawler.crawl.browser.Browser.session", unavailable) + site.run( + Steps(list_items, screen, unused_read, extract), + SOURCE.model_copy(update={"browser": True}), + ) assert [call for call, _ in site.calls] == ["POST /runs"] [run] = site.called("POST /runs") diff --git a/docs/development.md b/docs/development.md index 668d8c3..794674c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,23 +59,23 @@ crawler/ 抓取节点 - 接入来源时按通用抓取 → 配置 → 浏览器抓取 → 专用适配器的顺序试,前一种能抓就不用后一种([总体设计 §7.2](design.md#72-怎么抓))。 - 建站系统的模板放在代码里,和它的测试在一起;单个来源的配置存在网站的数据库里,在管理页面改,抓取节点领活时一起拿到。 -- 所有对来源网站的请求都经过 `crawl/fetch.py` 的 `Fetcher`,整个进程共用一个。UA 和请求间隔遵守[总体设计 §7.3](design.md#73-频率和礼貌),多个任务也不能同时请求同一个网站。普通 HTTP 请求和附件下载逐次处理跳转,跳到别的网站就按那个网站的间隔排队。 -- 浏览器一次页面访问取一次许可,拿到页面关闭。页面子资源正常加载,跨网站跳转沿用起始网站的许可。 +- 整个进程共用一个 `Fetcher`(`crawl/fetch.py`),所有来源请求都经过它,遵守[总体设计 §7.3](design.md#73-频率和礼貌)的 UA 和间隔规则。普通 HTTP 请求和附件下载的每次跳转,都按目标网站的间隔排队。 +- 浏览器一次页面访问取一次许可,持有到页面关闭。页面子资源正常加载,跨网站跳转沿用起始网站的许可。 - 浏览器抓取启动时关掉 Chromium 的自动化标记(`--disable-blink-features=AutomationControlled`),UA 用浏览器自己的,把 `HeadlessChrome` 写成 `Chrome`,末尾加上鼠钟的标识;瑞数这类脚本挑战两处都检查([总体设计 §5.2](design.md#52-实测))。不冒充别的浏览器和系统。 -- 每个浏览器来源每轮使用一个无痕会话,列表、正文和附件共用其中的 Cookie,抓完就关。脚本挑战设好 Cookie 后,后面的页面不用再过挑战;不同来源、不同轮次不共享会话。 +- 每个浏览器来源每轮新建一个无痕会话,列表、正文和附件共用其中的 Cookie,抓完就关。 - 浏览器同时最多开 2 个页面,多的排队。拒绝弹窗和浏览器下载,附件由程序另外下载;不加载图片、字体和音视频,省对方的流量,也省内存。 - 解析和模型分工:`parse` 用程序取出能确定的东西,模型只做判断([总体设计 §8.2](design.md#82-抽取))。解析不猜:判断不了的交给模型或人,不硬配。 - 判别模型和 LLM 的提示词放在调用它的代码旁边。输出都按固定的结构解析,解析不了的当失败处理。 - 每一轮抓取都交一份抓取记录,失败也交,写明原因;不在一轮里反复重试。各步抓不成时抛 `FetchError`(`Fetcher` 抛的)或 `StepError`(写明失败类别,比如 `http_status` 和「HTTP 403」),一轮的流程把它记进抓取记录;别的异常是 bug,照常抛出。 -本地使用浏览器抓取时,在 `crawler/` 运行: +在 `crawler/` 安装项目依赖、Chromium 无头精简版及系统依赖: ```sh uv sync uv run playwright install --with-deps --only-shell chromium ``` -第二条安装 Chromium 无头精简版及系统依赖。CI 不装浏览器,自动测试用假的浏览器对象、固定响应和假时钟,不连真实网站。真实浏览器和来源网站在本地手动验证,结果写进 PR。 +CI 不装浏览器。自动测试用假的浏览器对象、固定响应和假时钟;真实浏览器和来源网站在本地手动验证,结果写进 PR。 ### 2.1 列表配置 From 8858d1a43c24a0ae62d8b58ebcffafc0efda9fdd Mon Sep 17 00:00:00 2001 From: lym12321 <41183806+lym12321@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:39:34 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E7=90=86=E9=A1=BA=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E4=B8=8E=E8=AF=B7=E6=B1=82=E5=B1=82=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/src/crawler/crawl/browser.py | 88 +++++++++------ crawler/src/crawler/crawl/browser_test.py | 125 ++++++---------------- crawler/src/crawler/crawl/fetch.py | 44 +------- crawler/src/crawler/crawl/fetch_test.py | 71 +----------- crawler/src/crawler/crawl/listing.py | 6 +- crawler/src/crawler/crawl/rounds.py | 7 +- crawler/src/crawler/crawl/rounds_test.py | 12 ++- crawler/src/crawler/parse/listing_test.py | 30 +++--- crawler/uv.lock | 80 ++++++++++++++ docs/development.md | 6 +- 10 files changed, 203 insertions(+), 266 deletions(-) diff --git a/crawler/src/crawler/crawl/browser.py b/crawler/src/crawler/crawl/browser.py index 970b542..03aad38 100644 --- a/crawler/src/crawler/crawl/browser.py +++ b/crawler/src/crawler/crawl/browser.py @@ -1,8 +1,9 @@ import asyncio import logging import time -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Self from urllib.parse import urljoin from playwright.async_api import ( @@ -25,10 +26,12 @@ TimeoutError as PlaywrightTimeoutError, ) +from crawler.crawl.contract import WorkSource from crawler.crawl.fetch import ( MAX_REDIRECTS, TIMEOUT, USER_AGENT, + Fetcher, FetchError, Response, ) @@ -41,15 +44,33 @@ _logger = logging.getLogger(__name__) +async def _close_popup(page: Page) -> None: + if await page.opener() is not None: + await page.close() + + +async def _route(route: Route) -> None: + if route.request.resource_type in _BLOCKED: + await route.abort() + else: + await route.continue_() + + class Browser: - def __init__(self, permit: Callable[[str], AbstractAsyncContextManager[None]]) -> None: - self.permit = permit + def __init__(self, fetcher: Fetcher) -> None: + self.fetcher = fetcher self.slots = asyncio.Semaphore(PAGE_LIMIT) self._lock = asyncio.Lock() self._playwright: Playwright | None = None self._browser: PlaywrightBrowser | None = None self._user_agent = "" + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + async def _start(self) -> PlaywrightBrowser: async with self._lock: if self._browser is not None: @@ -69,23 +90,23 @@ async def _start(self) -> PlaywrightBrowser: self._browser = browser return browser - async def session(self, url: str) -> BrowserSession: + @asynccontextmanager + async def session(self, source: WorkSource) -> AsyncGenerator[PageSession]: + if not source.browser: + yield PageSession(self.fetcher, None) + return try: browser = await self._start() - context = await browser.new_context( + async with await browser.new_context( user_agent=self._user_agent, service_workers="block", accept_downloads=False - ) - session = BrowserSession(self, context) - try: - await session.configure() - except BaseException: - await context.close() - raise - return session + ) as context: + await context.route("**/*", _route) + context.on("page", _close_popup) + yield PageSession(self.fetcher, BrowserSession(self, context)) except PlaywrightTimeoutError as error: - raise FetchError("timeout", url) from error + raise FetchError("timeout", source.url) from error except PlaywrightError as error: - raise FetchError("unreachable", url) from error + raise FetchError("unreachable", source.url) from error async def close(self) -> None: try: @@ -101,22 +122,8 @@ def __init__(self, owner: Browser, context: BrowserContext) -> None: self._owner = owner self._context = context - async def configure(self) -> None: - await self._context.route("**/*", self._route) - self._context.on("page", self._new_page) - - async def _new_page(self, page: Page) -> None: - if await page.opener() is not None: - await page.close() - - async def _route(self, route: Route) -> None: - if route.request.resource_type in _BLOCKED: - await route.abort() - else: - await route.continue_() - async def get(self, url: str) -> Response: - async with self._owner.slots, self._owner.permit(url): + async with self._owner.slots, self._owner.fetcher.permit(url): started = time.monotonic() try: async with await self._context.new_page() as page: @@ -153,7 +160,7 @@ def record_response(response: PlaywrightResponse) -> None: async def download(self, url: str, *, page_url: str) -> Response: for _ in range(MAX_REDIRECTS + 1): - async with self._owner.permit(url): + async with self._owner.fetcher.permit(url): try: response = await self._context.request.get( url, @@ -182,5 +189,18 @@ async def download(self, url: str, *, page_url: str) -> Response: raise FetchError("unreachable", url) from error raise FetchError("too_many_redirects", url) - async def close(self) -> None: - await self._context.close() + +class PageSession: + def __init__(self, fetcher: Fetcher, browser_session: BrowserSession | None) -> None: + self._fetcher = fetcher + self._browser_session = browser_session + + async def get(self, url: str) -> Response: + if self._browser_session is not None: + return await self._browser_session.get(url) + return await self._fetcher.get(url) + + async def download(self, url: str, *, page_url: str) -> Response: + if self._browser_session is not None: + return await self._browser_session.download(url, page_url=page_url) + return await self._fetcher.get(url) diff --git a/crawler/src/crawler/crawl/browser_test.py b/crawler/src/crawler/crawl/browser_test.py index 1c55339..682c3c8 100644 --- a/crawler/src/crawler/crawl/browser_test.py +++ b/crawler/src/crawler/crawl/browser_test.py @@ -5,11 +5,14 @@ import httpx import pytest -from playwright.async_api import Error as PlaywrightError -from playwright.async_api import TimeoutError as PlaywrightTimeoutError from crawler.crawl.browser import Browser -from crawler.crawl.fetch import INTERVAL, Failure, Fetcher, FetchError +from crawler.crawl.contract import WorkSource +from crawler.crawl.fetch import INTERVAL, Fetcher, FetchError + +SOURCE = WorkSource( + id=7, url="https://a/list", section="通知公告", browser=True, config=None, backfill=False +) def run(main: Callable[[], Awaitable[None]]) -> None: @@ -66,8 +69,6 @@ async def goto(self, url: str, *, wait_until: str, **_options: object) -> None: driver.started.put_nowait(self) if driver.block_pages: await self.ready.wait() - if driver.error is not None: - raise driver.error for status, navigation, main_frame in driver.responses: frame = self.main_frame if main_frame else object() self.handlers["response"](FakeResponse(status, FakeRequest(frame, navigation))) @@ -103,8 +104,6 @@ def __init__(self, context: FakeContext) -> None: self.responses: list[FakeDownload] = [] async def get(self, url: str, *, headers: dict[str, str], **_options: object) -> FakeDownload: - if self.context.driver.error is not None: - raise self.context.driver.error response = FakeDownload( url, f"%PDF context {self.context.number}: {headers['Referer']}".encode() ) @@ -131,7 +130,10 @@ async def new_page(self) -> FakePage: self.pages.append(page) return page - async def close(self) -> None: + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> None: self.closed = True @@ -141,7 +143,6 @@ def __init__(self) -> None: self.started: asyncio.Queue[FakePage] = asyncio.Queue() self.block_pages = False self.destination: str | None = None - self.error: PlaywrightError | None = None self.responses = [(200, True, True)] async def new_context(self, **_options: object) -> FakeContext: @@ -177,7 +178,8 @@ async def handle(_request: httpx.Request) -> httpx.Response: async def main() -> None: async with ( Fetcher(httpx.MockTransport(handle), clock.clock, clock.sleep) as fetcher, - fetcher.session(True, url="https://a/list") as session, + Browser(fetcher) as browser, + browser.session(SOURCE) as session, ): first_http = asyncio.create_task(fetcher.get("https://a/first")) await http_started.wait() @@ -208,8 +210,9 @@ def test_page_limit_is_shared_by_rounds(driver: FakeBrowser) -> None: async def main() -> None: async with ( Fetcher() as fetcher, - fetcher.session(True, url="https://a/list") as first, - fetcher.session(True, url="https://b/list") as second, + Browser(fetcher) as browser, + browser.session(SOURCE) as first, + browser.session(SOURCE.model_copy(update={"id": 8, "url": "https://b/list"})) as second, ): tasks = [ asyncio.create_task(first.get("https://a/list")), @@ -235,17 +238,20 @@ def test_each_round_opens_reuses_and_closes_its_context(driver: FakeBrowser) -> clock = FakeClock() async def main() -> None: - async with Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher: - async with fetcher.session(False, url="https://a/list"): + async with ( + Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher, + Browser(fetcher) as browser, + ): + async with browser.session(SOURCE.model_copy(update={"browser": False})): assert driver.contexts == [] - async with fetcher.session(True, url="https://a/list") as session: + async with browser.session(SOURCE) as session: assert len(driver.contexts) == 1 await session.get("https://a/list") await session.get("https://a/notice") assert len(driver.contexts[0].pages) == 2 assert not driver.contexts[0].closed assert driver.contexts[0].closed - async with fetcher.session(True, url="https://a/list"): + async with browser.session(SOURCE): assert len(driver.contexts) == 2 assert driver.contexts[1].closed @@ -256,9 +262,12 @@ def test_downloads_use_the_rounds_context_request_client(driver: FakeBrowser) -> clock = FakeClock() async def main() -> None: - async with Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher: + async with ( + Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher, + Browser(fetcher) as browser, + ): for number in (1, 2): - async with fetcher.session(True, url="https://a/list") as session: + async with browser.session(SOURCE) as session: await session.get("https://a/notice") attachment = await session.download( "https://a/file.pdf", page_url="https://a/notice" @@ -284,8 +293,9 @@ def test_last_main_navigation_response_is_used_without_a_cross_site_permit( async def main() -> None: async with ( Fetcher() as fetcher, + Browser(fetcher) as browser, fetcher.permit("https://b/busy"), - fetcher.session(True, url="https://a/list") as session, + browser.session(SOURCE) as session, ): response = await session.get("https://a/notice") assert response.status == 200 @@ -300,81 +310,14 @@ def test_unresolved_challenges_fail(driver: FakeBrowser, status: int) -> None: driver.responses = [(status, True, True)] async def main() -> None: - async with Fetcher() as fetcher, fetcher.session(True, url="https://a/list") as session: + async with ( + Fetcher() as fetcher, + Browser(fetcher) as browser, + browser.session(SOURCE) as session, + ): with pytest.raises(FetchError) as caught: await session.get("https://a/list") assert caught.value.failure == "unreachable" assert driver.contexts[0].pages[0].closed run(main) - - -@pytest.mark.parametrize( - ("error", "failure"), - [ - (PlaywrightTimeoutError("Timed out"), "timeout"), - (PlaywrightError("Browser request failed"), "unreachable"), - ], -) -@pytest.mark.parametrize("download", [False, True]) -def test_playwright_errors_become_fetch_errors( - driver: FakeBrowser, error: PlaywrightError, failure: Failure, *, download: bool -) -> None: - driver.error = error - clock = FakeClock() - - async def main() -> None: - async with ( - Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher, - fetcher.session(True, url="https://a/list") as session, - ): - operation = ( - session.download("https://a/file.pdf", page_url="https://a/notice") - if download - else session.get("https://a/notice") - ) - with pytest.raises(FetchError) as caught: - await operation - assert caught.value.failure == failure - driver.error = None - assert (await session.get("https://a/retry")).status == 200 - assert clock.now == INTERVAL - - run(main) - - -@pytest.mark.parametrize("stage", ["start", "context", "configure"]) -@pytest.mark.parametrize( - ("error", "failure"), - [ - (PlaywrightTimeoutError("Timed out"), "timeout"), - (PlaywrightError("Browser initialization failed"), "unreachable"), - ], -) -def test_initialization_errors_include_the_source_url( - monkeypatch: pytest.MonkeyPatch, - driver: FakeBrowser, - stage: str, - error: PlaywrightError, - failure: Failure, -) -> None: - async def fail(*_args: object, **_options: object) -> None: - raise error - - if stage == "start": - monkeypatch.setattr(Browser, "_start", fail) - elif stage == "context": - monkeypatch.setattr(FakeBrowser, "new_context", fail) - else: - monkeypatch.setattr(FakeContext, "route", fail) - - async def main() -> None: - async with Fetcher() as fetcher: - with pytest.raises(FetchError) as caught: - async with fetcher.session(True, url="https://a/list"): - pass - assert caught.value.failure == failure - assert caught.value.url == "https://a/list" - assert all(context.closed for context in driver.contexts) - - run(main) diff --git a/crawler/src/crawler/crawl/fetch.py b/crawler/src/crawler/crawl/fetch.py index dbbf1ad..742c017 100644 --- a/crawler/src/crawler/crawl/fetch.py +++ b/crawler/src/crawler/crawl/fetch.py @@ -6,13 +6,10 @@ from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal, Self +from typing import Literal, Self import httpx -if TYPE_CHECKING: - from crawler.crawl.browser import Browser, BrowserSession - USER_AGENT = "mouseclock/0.1 (+https://github.com/mousebaoyan/mouseclock)" # Seconds from the end of one page visit or HTTP request to the start of the next. INTERVAL = 5.0 @@ -58,33 +55,12 @@ def __init__( self._clock = clock self._sleep = sleep self._sites: dict[str, _Site] = {} - self._browser: Browser | None = None async def __aenter__(self) -> Self: return self async def __aexit__(self, *_: object) -> None: - try: - if self._browser is not None: - await self._browser.close() - finally: - await self._client.aclose() - - @asynccontextmanager - async def session(self, browser: bool, *, url: str) -> AsyncGenerator[PageSession]: - browser_session = None - if browser: - if self._browser is None: - # Keep the browser library unloaded until a source actually needs it. - from crawler.crawl.browser import Browser - - self._browser = Browser(self.permit) - browser_session = await self._browser.session(url) - try: - yield PageSession(self, browser_session) - finally: - if browser_session is not None: - await browser_session.close() + await self._client.aclose() @asynccontextmanager async def permit(self, url: str) -> AsyncGenerator[None]: @@ -120,19 +96,3 @@ async def _send(self, url: str) -> httpx.Response: raise FetchError("timeout", url) from error except httpx.TransportError as error: raise FetchError("unreachable", url) from error - - -class PageSession: - def __init__(self, fetcher: Fetcher, browser_session: BrowserSession | None) -> None: - self._fetcher = fetcher - self._browser_session = browser_session - - async def get(self, url: str) -> Response: - if self._browser_session is not None: - return await self._browser_session.get(url) - return await self._fetcher.get(url) - - async def download(self, url: str, *, page_url: str) -> Response: - if self._browser_session is not None: - return await self._browser_session.download(url, page_url=page_url) - return await self._fetcher.get(url) diff --git a/crawler/src/crawler/crawl/fetch_test.py b/crawler/src/crawler/crawl/fetch_test.py index 9171c5c..01de10d 100644 --- a/crawler/src/crawler/crawl/fetch_test.py +++ b/crawler/src/crawler/crawl/fetch_test.py @@ -5,9 +5,8 @@ from urllib.parse import urlsplit import httpx -import pytest -from crawler.crawl.fetch import INTERVAL, USER_AGENT, Fetcher, FetchError +from crawler.crawl.fetch import INTERVAL, USER_AGENT, Fetcher class FakeSites: @@ -86,71 +85,3 @@ def test_a_redirect_to_another_site_waits_its_turn_there() -> None: assert moved.url == "http://b/new" assert len(sites.times("b")) == 2 assert all(gap >= INTERVAL for gap in gaps(sites.times("b"))) - - -def test_cancelling_the_interval_wait_does_not_mark_a_request_finished() -> None: - async def run() -> None: - sites = FakeSites({}) - waiting = asyncio.Event() - waits: list[float] = [] - block = True - - async def sleep(seconds: float) -> None: - waits.append(seconds) - if block: - waiting.set() - await asyncio.Future[None]() - sites.now += seconds - - async with Fetcher(httpx.MockTransport(sites.handle), sites.clock, sleep) as fetcher: - await fetcher.get("http://a/first") - queued = asyncio.create_task(fetcher.get("http://a/cancelled")) - await waiting.wait() - sites.now = 2 - queued.cancel() - with pytest.raises(asyncio.CancelledError): - await queued - block = False - async with asyncio.timeout(1): - await fetcher.get("http://a/next") - assert waits == [INTERVAL, INTERVAL - 2] - - asyncio.run(run()) - - -@pytest.mark.parametrize("failure", ["timeout", "unreachable", "cancelled"]) -def test_failed_or_cancelled_http_requests_release_the_shared_permission(failure: str) -> None: - async def run() -> None: - sites = FakeSites({}) - started = asyncio.Event() - requests = 0 - - async def handle(request: httpx.Request) -> httpx.Response: - nonlocal requests - requests += 1 - if requests == 1: - started.set() - if failure == "cancelled": - await asyncio.Future[None]() - if failure == "timeout": - raise httpx.ReadTimeout("Timed out", request=request) - raise httpx.ConnectError("Connection failed", request=request) - return httpx.Response(200) - - async with Fetcher(httpx.MockTransport(handle), sites.clock, sites.sleep) as fetcher: - task = asyncio.create_task(fetcher.get("http://a/first")) - await started.wait() - if failure == "cancelled": - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - else: - with pytest.raises(FetchError) as caught: - await task - assert caught.value.failure == failure - async with asyncio.timeout(1): - response = await fetcher.get("http://a/next") - assert response.status == 200 - assert sites.now == INTERVAL - - asyncio.run(run()) diff --git a/crawler/src/crawler/crawl/listing.py b/crawler/src/crawler/crawl/listing.py index 4eb1bdb..0c51853 100644 --- a/crawler/src/crawler/crawl/listing.py +++ b/crawler/src/crawler/crawl/listing.py @@ -6,14 +6,14 @@ from pydantic import ValidationError +from crawler.crawl.browser import PageSession from crawler.crawl.contract import ListedItem, WorkSource -from crawler.crawl.fetch import Fetcher from crawler.crawl.rounds import StepError from crawler.parse.listing import ListConfig, parse_listing async def list_items( - fetcher: Fetcher, source: WorkSource, clock: Callable[[], float] = time.time + pages: PageSession, source: WorkSource, clock: Callable[[], float] = time.time ) -> list[ListedItem]: try: config = ListConfig.model_validate(source.config or {}) @@ -37,7 +37,7 @@ def request_url(url: str) -> str: if current in visited: break visited.add(current) - response = await fetcher.get(current) + response = await pages.get(current) visited.add(response.url) if not 200 <= response.status < 300: raise StepError("http_status", f"HTTP {response.status}") diff --git a/crawler/src/crawler/crawl/rounds.py b/crawler/src/crawler/crawl/rounds.py index 334bf21..8ea00e5 100644 --- a/crawler/src/crawler/crawl/rounds.py +++ b/crawler/src/crawler/crawl/rounds.py @@ -8,6 +8,7 @@ from contextlib import AsyncExitStack from dataclasses import dataclass +from crawler.crawl.browser import Browser, PageSession from crawler.crawl.contract import ( AttachmentInfo, ExtractedProgram, @@ -25,7 +26,7 @@ Usage, WorkSource, ) -from crawler.crawl.fetch import Fetcher, FetchError, PageSession +from crawler.crawl.fetch import FetchError from crawler.crawl.website import Website logger = logging.getLogger(__name__) @@ -80,7 +81,7 @@ def _failure(error: FetchError | StepError) -> tuple[Failure, str | None]: async def crawl_source( source: WorkSource, - fetcher: Fetcher, + browser: Browser, website: Website, steps: Steps, clock: Callable[[], float] = time.time, @@ -88,7 +89,7 @@ async def crawl_source( started_at = int(clock()) async with AsyncExitStack() as stack: try: - pages = await stack.enter_async_context(fetcher.session(source.browser, url=source.url)) + pages = await stack.enter_async_context(browser.session(source)) items = await steps.list_items(pages, source) if not items: raise StepError("no_items") diff --git a/crawler/src/crawler/crawl/rounds_test.py b/crawler/src/crawler/crawl/rounds_test.py index 0a95aff..4f54249 100644 --- a/crawler/src/crawler/crawl/rounds_test.py +++ b/crawler/src/crawler/crawl/rounds_test.py @@ -6,6 +6,7 @@ import httpx import pytest +from crawler.crawl.browser import Browser, PageSession from crawler.crawl.contract import ( AttachmentInfo, ExtractedFields, @@ -17,7 +18,7 @@ Usage, WorkSource, ) -from crawler.crawl.fetch import Fetcher, FetchError, PageSession +from crawler.crawl.fetch import Fetcher, FetchError from crawler.crawl.rounds import Attachment, NoticePage, StepError, Steps, crawl_source from crawler.crawl.website import Website @@ -69,9 +70,10 @@ async def main() -> None: nowhere = httpx.MockTransport(lambda _: httpx.Response(500)) async with ( Fetcher(nowhere) as fetcher, + Browser(fetcher) as browser, Website("http://site", "token", httpx.MockTransport(self.handle)) as website, ): - await crawl_source(source, fetcher, website, steps, clock=lambda: 1_800_000_000) + await crawl_source(source, browser, website, steps, clock=lambda: 1_800_000_000) asyncio.run(main()) @@ -215,13 +217,13 @@ async def unused(*_: object) -> Any: def test_a_session_that_cant_start_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: site = FakeWebsite([], extracted=frozenset()) - async def unavailable(_browser: object, url: str) -> None: - raise FetchError("unreachable", url) + async def unavailable(_browser: Browser) -> None: + raise FetchError("unreachable", SOURCE.url) async def unused_read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage: raise AssertionError("Listing failed before notices could be read") - monkeypatch.setattr("crawler.crawl.browser.Browser.session", unavailable) + monkeypatch.setattr(Browser, "_start", unavailable) site.run( Steps(list_items, screen, unused_read, extract), SOURCE.model_copy(update={"browser": True}), diff --git a/crawler/src/crawler/parse/listing_test.py b/crawler/src/crawler/parse/listing_test.py index ba5da48..a37ce50 100644 --- a/crawler/src/crawler/parse/listing_test.py +++ b/crawler/src/crawler/parse/listing_test.py @@ -5,6 +5,7 @@ import pytest from pydantic import JsonValue +from crawler.crawl.browser import Browser, PageSession from crawler.crawl.contract import WorkSource from crawler.crawl.fetch import Fetcher from crawler.crawl.fetch_test import FakeSites @@ -107,7 +108,7 @@ def test_documented_http_configuration_reads_the_real_page_structure() -> None: ) result = sites.run( lambda f: list_items( - f, + PageSession(f, None), source(config=config), clock=lambda: datetime(2026, 11, 7, tzinfo=timezone(timedelta(hours=8))).timestamp(), ) @@ -144,7 +145,7 @@ def test_backfill_keeps_boundary_and_unknown_dates_and_ignores_repeated_pinned_r ) result = sites.run( lambda f: list_items( - f, + PageSession(f, None), source(backfill=backfill), clock=lambda: datetime(2026, 11, 7, tzinfo=timezone(timedelta(hours=8))).timestamp(), ) @@ -168,7 +169,7 @@ def test_leap_day_backfill_uses_calendar_years() -> None: ) result = sites.run( lambda f: list_items( - f, + PageSession(f, None), source(backfill=True), clock=lambda: datetime(2024, 2, 29, tzinfo=timezone(timedelta(hours=8))).timestamp(), ) @@ -199,7 +200,7 @@ def test_numbered_pager_advances_one_page_and_stops_at_last() -> None: ) result = sites.run( lambda f: list_items( - f, + PageSession(f, None), source(backfill=True), clock=lambda: datetime(2026, 11, 7, tzinfo=timezone(timedelta(hours=8))).timestamp(), ) @@ -240,7 +241,7 @@ def test_explicit_http_config_and_redirect_base_are_used_for_pagination_and_link config["selectors"] = selectors result = sites.run( lambda f: list_items( - f, + PageSession(f, None), source(backfill=True, config=config), clock=lambda: datetime(2026, 11, 7, tzinfo=timezone(timedelta(hours=8))).timestamp(), ) @@ -274,15 +275,16 @@ def test_bad_configuration_is_recorded_by_the_round( sites = FakeSites({BASE: httpx.Response(200, content=page([("1", "2026-09-10")]))}) website = FakeWebsite([], frozenset()) - async def read(_fetcher: Fetcher, _source: WorkSource, _url: str) -> NoticePage: + async def read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage: raise AssertionError("a failed list must not read notices") async def run(fetcher: Fetcher) -> None: - async with Website( - "http://site", "local-test", httpx.MockTransport(website.handle) - ) as client: + async with ( + Browser(fetcher) as browser, + Website("http://site", "local-test", httpx.MockTransport(website.handle)) as client, + ): await crawl_source( - source(config=config), fetcher, client, Steps(list_items, screen, read, extract) + source(config=config), browser, client, Steps(list_items, screen, read, extract) ) sites.run(run) @@ -303,7 +305,7 @@ def test_historical_http_failure_does_not_return_partial_backfill(status: int) - with pytest.raises(StepError, match=f"HTTP {status}") as caught: sites.run( lambda f: list_items( - f, + PageSession(f, None), source(backfill=True), clock=lambda: datetime( 2026, 11, 7, tzinfo=timezone(timedelta(hours=8)) @@ -332,7 +334,7 @@ def test_cyclic_pagination_finishes_backfill_without_discarding_items() -> None: ) result = sites.run( lambda f: list_items( - f, + PageSession(f, None), source(backfill=True), clock=lambda: datetime(2026, 11, 7, tzinfo=timezone(timedelta(hours=8))).timestamp(), ) @@ -371,7 +373,7 @@ def test_empty_historical_page_finishes_backfill_without_discarding_items() -> N ) result = sites.run( lambda f: list_items( - f, + PageSession(f, None), source(backfill=True), clock=lambda: datetime(2026, 11, 7, tzinfo=timezone(timedelta(hours=8))).timestamp(), ) @@ -451,7 +453,7 @@ def test_page_parse_failure_is_not_reported_as_a_configuration_error() -> None: } ) with pytest.raises(StepError) as caught: - sites.run(lambda f: list_items(f, source())) + sites.run(lambda f: list_items(PageSession(f, None), source())) assert caught.value.detail == "列表页无法解析" diff --git a/crawler/uv.lock b/crawler/uv.lock index fbf61b9..6ecf832 100644 --- a/crawler/uv.lock +++ b/crawler/uv.lock @@ -112,6 +112,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "lxml" }, + { name = "playwright" }, { name = "pydantic" }, ] @@ -128,6 +129,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.28" }, { name = "lxml", specifier = ">=6.1.3" }, + { name = "playwright", specifier = ">=1.64.1" }, { name = "pydantic", specifier = ">=2.14.1" }, ] @@ -178,6 +180,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -486,6 +535,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" }, ] +[[package]] +name = "playwright" +version = "1.64.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/c8/cc677856d89a0c077673b40ab915ed5597bf1347b205cabe70121c431ca9/playwright-1.64.1-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:29453ff76e25c17d0b45658cacd6b92e9eec46e8db21e134fb3b7143df50f013", size = 44508971, upload-time = "2026-10-22T16:29:58.12Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/572bbbe60a5787ca61a84bad934c3ce19c08c2bc9c0e9781bf0d99cad1a6/playwright-1.64.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:59f7b041284ce8d1b72098ae50ca48a2d83692eec25154ab5ba79d4b6b4fe8a9", size = 43155232, upload-time = "2026-10-22T16:30:02.415Z" }, + { url = "https://files.pythonhosted.org/packages/44/fa/468f313a56d33a6420705147c8d47bef558f087856edf38c5b13eef8aa6c/playwright-1.64.1-py3-none-macosx_11_0_universal2.whl", hash = "sha256:6a6f99adad1ae40e6ee62efe89785db861ce7ddd57651521cb31894183cc4ae3", size = 44508977, upload-time = "2026-10-22T16:30:07.632Z" }, + { url = "https://files.pythonhosted.org/packages/15/b9/e481a34572933b5cf179d5637c98da488c732ee339dff5b73a0624d2e42b/playwright-1.64.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:577b8d88778c245cfbd145ae17cf53044f084b1b46c55c69081d4069d56057e2", size = 48471931, upload-time = "2026-10-22T16:30:12.831Z" }, + { url = "https://files.pythonhosted.org/packages/0c/11/545f25e47ec54e1e8a2892ef9994fc2b298f909ab37b88578e3a1f87ac2e/playwright-1.64.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a29fda44725e11b0b625c4bff6458a1f6dbee97b88c9788d12e18a8344458c2d", size = 48150994, upload-time = "2026-10-22T16:30:18.297Z" }, + { url = "https://files.pythonhosted.org/packages/33/87/8ff7af99387ee72ae9b46a66ece7a0c0aa63f73320ecf6dd3dd087fa840a/playwright-1.64.1-py3-none-win32.whl", hash = "sha256:d3c48b31da426c2f3562d0919cb9ea4e5415f6dbd29d7211fa4d66cca24fbd2f", size = 38801828, upload-time = "2026-10-22T16:30:23.154Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/64db6851aa1852a4f76be28eea71d4e2bf9f3ce493ee2e66939426729046/playwright-1.64.1-py3-none-win_amd64.whl", hash = "sha256:5e240a79464434c9260957b9ef59a5808e02e96e3fcabdc717fd7485260b9730", size = 38801836, upload-time = "2026-10-22T16:30:28.092Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4b/d7ddde9467a5fcfe6a0d3c954732332e98a4f4a9025fd42c8d091c16e5f9/playwright-1.64.1-py3-none-win_arm64.whl", hash = "sha256:983ccd085453ea5a7588193657fc349a6ffbf7bf7ba663fd58dc81e4aa3af9d9", size = 34693875, upload-time = "2026-10-22T16:30:32.724Z" }, +] + [[package]] name = "pluggy" version = "1.7.0" @@ -582,6 +650,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, ] +[[package]] +name = "pyee" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/f1/fdedc2c75c3e31a330659c85e5793bb18b3397981fbf0844c6dee5b18926/pyee-14.0.0.tar.gz", hash = "sha256:76dd0f4314ecd27f02dc73589dea7fd3853f9b6176d8ef9b122860657e3602de", size = 98760, upload-time = "2026-08-13T04:26:11.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/12/5347938b1f9a6453f0dbdfcc3e2388a1320ef9b9ec17fbefbc4ab647ea98/pyee-14.0.0-py3-none-any.whl", hash = "sha256:3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", size = 15553, upload-time = "2026-08-13T04:26:09.916Z" }, +] + [[package]] name = "pygments" version = "2.21.0" diff --git a/docs/development.md b/docs/development.md index 794674c..62c08e3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,11 +59,9 @@ crawler/ 抓取节点 - 接入来源时按通用抓取 → 配置 → 浏览器抓取 → 专用适配器的顺序试,前一种能抓就不用后一种([总体设计 §7.2](design.md#72-怎么抓))。 - 建站系统的模板放在代码里,和它的测试在一起;单个来源的配置存在网站的数据库里,在管理页面改,抓取节点领活时一起拿到。 -- 整个进程共用一个 `Fetcher`(`crawl/fetch.py`),所有来源请求都经过它,遵守[总体设计 §7.3](design.md#73-频率和礼貌)的 UA 和间隔规则。普通 HTTP 请求和附件下载的每次跳转,都按目标网站的间隔排队。 -- 浏览器一次页面访问取一次许可,持有到页面关闭。页面子资源正常加载,跨网站跳转沿用起始网站的许可。 +- 所有对来源网站的请求都经过 `crawl/fetch.py` 的 `Fetcher`,整个进程共用一个,[总体设计 §7.3](design.md#73-频率和礼貌)的规矩由它保证:UA 写明鼠钟;同一个网站一次一个请求,多个任务同时抓也一样;跳转的每一步单独发,跳到别的网站按那个网站的间隔排队。浏览器一次页面访问取一次许可,持有到页面关闭;页面的子资源正常加载,跨网站跳转沿用起始网站的许可。 - 浏览器抓取启动时关掉 Chromium 的自动化标记(`--disable-blink-features=AutomationControlled`),UA 用浏览器自己的,把 `HeadlessChrome` 写成 `Chrome`,末尾加上鼠钟的标识;瑞数这类脚本挑战两处都检查([总体设计 §5.2](design.md#52-实测))。不冒充别的浏览器和系统。 -- 每个浏览器来源每轮新建一个无痕会话,列表、正文和附件共用其中的 Cookie,抓完就关。 -- 浏览器同时最多开 2 个页面,多的排队。拒绝弹窗和浏览器下载,附件由程序另外下载;不加载图片、字体和音视频,省对方的流量,也省内存。 +- 一轮里同一个网站共用一个无痕会话,抓完就关:脚本挑战设的 Cookie 在会话里有效,后面的页面不用再过挑战;不同网站、不同轮次之间不共享 Cookie。同时开的浏览器页面有上限,多的排队;拒绝弹窗和下载,附件由程序另外下载;不加载图片、字体和音视频,省对方的流量,也省内存。 - 解析和模型分工:`parse` 用程序取出能确定的东西,模型只做判断([总体设计 §8.2](design.md#82-抽取))。解析不猜:判断不了的交给模型或人,不硬配。 - 判别模型和 LLM 的提示词放在调用它的代码旁边。输出都按固定的结构解析,解析不了的当失败处理。 - 每一轮抓取都交一份抓取记录,失败也交,写明原因;不在一轮里反复重试。各步抓不成时抛 `FetchError`(`Fetcher` 抛的)或 `StepError`(写明失败类别,比如 `http_status` 和「HTTP 403」),一轮的流程把它记进抓取记录;别的异常是 bug,照常抛出。 From 3098223de68be23836790024e79c7df94f5dd20e Mon Sep 17 00:00:00 2001 From: lym12321 <41183806+lym12321@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:11:39 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E5=9C=A8=E6=8A=93=E5=8F=96=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=90=AF=E5=8A=A8=E6=97=B6=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/src/crawler/crawl/browser.py | 83 ++++++++--------------- crawler/src/crawler/crawl/browser_test.py | 33 +++++++-- crawler/src/crawler/crawl/rounds.py | 4 +- crawler/src/crawler/crawl/rounds_test.py | 30 ++------ crawler/src/crawler/parse/listing_test.py | 12 ++-- 5 files changed, 69 insertions(+), 93 deletions(-) diff --git a/crawler/src/crawler/crawl/browser.py b/crawler/src/crawler/crawl/browser.py index 03aad38..833bb2f 100644 --- a/crawler/src/crawler/crawl/browser.py +++ b/crawler/src/crawler/crawl/browser.py @@ -6,13 +6,9 @@ from typing import Self from urllib.parse import urljoin -from playwright.async_api import ( - Browser as PlaywrightBrowser, -) from playwright.async_api import ( BrowserContext, Page, - Playwright, Route, async_playwright, ) @@ -41,7 +37,7 @@ _REDIRECTS = {301, 302, 303, 307, 308} _BLOCKED = {"image", "font", "media"} _CHALLENGES = {202, 412} -_logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) async def _close_popup(page: Page) -> None: @@ -60,61 +56,38 @@ class Browser: def __init__(self, fetcher: Fetcher) -> None: self.fetcher = fetcher self.slots = asyncio.Semaphore(PAGE_LIMIT) - self._lock = asyncio.Lock() - self._playwright: Playwright | None = None - self._browser: PlaywrightBrowser | None = None - self._user_agent = "" async def __aenter__(self) -> Self: + self._playwright = await async_playwright().start() + try: + self._browser = await self._playwright.chromium.launch( + headless=True, args=["--disable-blink-features=AutomationControlled"] + ) + async with await self._browser.new_page() as page: + user_agent: str = await page.evaluate("navigator.userAgent") + except BaseException: + await self._playwright.stop() + raise + self._user_agent = user_agent.replace("HeadlessChrome", "Chrome") + " " + USER_AGENT return self async def __aexit__(self, *_: object) -> None: - await self.close() - - async def _start(self) -> PlaywrightBrowser: - async with self._lock: - if self._browser is not None: - return self._browser - playwright = await async_playwright().start() - try: - browser = await playwright.chromium.launch( - headless=True, args=["--disable-blink-features=AutomationControlled"] - ) - async with await browser.new_page() as page: - user_agent: str = await page.evaluate("navigator.userAgent") - except BaseException: - await playwright.stop() - raise - self._user_agent = user_agent.replace("HeadlessChrome", "Chrome") + " " + USER_AGENT - self._playwright = playwright - self._browser = browser - return browser + try: + await self._browser.close() + finally: + await self._playwright.stop() @asynccontextmanager async def session(self, source: WorkSource) -> AsyncGenerator[PageSession]: if not source.browser: yield PageSession(self.fetcher, None) return - try: - browser = await self._start() - async with await browser.new_context( - user_agent=self._user_agent, service_workers="block", accept_downloads=False - ) as context: - await context.route("**/*", _route) - context.on("page", _close_popup) - yield PageSession(self.fetcher, BrowserSession(self, context)) - except PlaywrightTimeoutError as error: - raise FetchError("timeout", source.url) from error - except PlaywrightError as error: - raise FetchError("unreachable", source.url) from error - - async def close(self) -> None: - try: - if self._browser is not None: - await self._browser.close() - finally: - if self._playwright is not None: - await self._playwright.stop() + async with await self._browser.new_context( + user_agent=self._user_agent, service_workers="block", accept_downloads=False + ) as context: + await context.route("**/*", _route) + context.on("page", _close_popup) + yield PageSession(self.fetcher, BrowserSession(self, context)) class BrowserSession: @@ -146,11 +119,13 @@ def record_response(response: PlaywrightResponse) -> None: "text/html; charset=utf-8", body.encode("utf-8"), ) - _logger.info( - "Browser fetched %s status=%s elapsed=%.3fs", - response.url, - response.status, - time.monotonic() - started, + logger.info( + "Browser fetched", + extra={ + "url": response.url, + "status": response.status, + "elapsed": time.monotonic() - started, + }, ) return response except PlaywrightTimeoutError as error: diff --git a/crawler/src/crawler/crawl/browser_test.py b/crawler/src/crawler/crawl/browser_test.py index 682c3c8..644a5fb 100644 --- a/crawler/src/crawler/crawl/browser_test.py +++ b/crawler/src/crawler/crawl/browser_test.py @@ -76,6 +76,9 @@ async def goto(self, url: str, *, wait_until: str, **_options: object) -> None: async def content(self) -> str: return "保研通知" + async def evaluate(self, _expression: str) -> str: + return "HeadlessChrome/1.0" + async def __aenter__(self) -> Self: return self @@ -139,26 +142,44 @@ async def __aexit__(self, *_: object) -> None: class FakeBrowser: def __init__(self) -> None: + self.running = False self.contexts: list[FakeContext] = [] self.started: asyncio.Queue[FakePage] = asyncio.Queue() self.block_pages = False self.destination: str | None = None self.responses = [(200, True, True)] + async def launch(self, **_options: object) -> Self: + self.running = True + return self + + async def new_page(self) -> FakePage: + return FakePage(FakeContext(self, 0)) + async def new_context(self, **_options: object) -> FakeContext: context = FakeContext(self, len(self.contexts) + 1) self.contexts.append(context) return context + async def close(self) -> None: + self.running = False + + +class FakePlaywright: + def __init__(self, browser: FakeBrowser) -> None: + self.chromium = browser + + async def start(self) -> Self: + return self + + async def stop(self) -> None: + pass + @pytest.fixture def driver(monkeypatch: pytest.MonkeyPatch) -> FakeBrowser: browser = FakeBrowser() - - async def start(_owner: object) -> FakeBrowser: - return browser - - monkeypatch.setattr("crawler.crawl.browser.Browser._start", start) + monkeypatch.setattr("crawler.crawl.browser.async_playwright", lambda: FakePlaywright(browser)) return browser @@ -242,6 +263,7 @@ async def main() -> None: Fetcher(clock=clock.clock, sleep=clock.sleep) as fetcher, Browser(fetcher) as browser, ): + assert driver.running async with browser.session(SOURCE.model_copy(update={"browser": False})): assert driver.contexts == [] async with browser.session(SOURCE) as session: @@ -254,6 +276,7 @@ async def main() -> None: async with browser.session(SOURCE): assert len(driver.contexts) == 2 assert driver.contexts[1].closed + assert not driver.running run(main) diff --git a/crawler/src/crawler/crawl/rounds.py b/crawler/src/crawler/crawl/rounds.py index 8ea00e5..0e55045 100644 --- a/crawler/src/crawler/crawl/rounds.py +++ b/crawler/src/crawler/crawl/rounds.py @@ -5,7 +5,6 @@ import logging import time from collections.abc import Awaitable, Callable -from contextlib import AsyncExitStack from dataclasses import dataclass from crawler.crawl.browser import Browser, PageSession @@ -87,9 +86,8 @@ async def crawl_source( clock: Callable[[], float] = time.time, ) -> None: started_at = int(clock()) - async with AsyncExitStack() as stack: + async with browser.session(source) as pages: try: - pages = await stack.enter_async_context(browser.session(source)) items = await steps.list_items(pages, source) if not items: raise StepError("no_items") diff --git a/crawler/src/crawler/crawl/rounds_test.py b/crawler/src/crawler/crawl/rounds_test.py index 4f54249..f1a2f2a 100644 --- a/crawler/src/crawler/crawl/rounds_test.py +++ b/crawler/src/crawler/crawl/rounds_test.py @@ -4,7 +4,6 @@ from typing import Any import httpx -import pytest from crawler.crawl.browser import Browser, PageSession from crawler.crawl.contract import ( @@ -65,15 +64,16 @@ def handle(self, request: httpx.Request) -> httpx.Response: ) return httpx.Response(204) - def run(self, steps: Steps, source: WorkSource = SOURCE) -> None: + def run(self, steps: Steps) -> None: async def main() -> None: nowhere = httpx.MockTransport(lambda _: httpx.Response(500)) async with ( Fetcher(nowhere) as fetcher, - Browser(fetcher) as browser, Website("http://site", "token", httpx.MockTransport(self.handle)) as website, ): - await crawl_source(source, browser, website, steps, clock=lambda: 1_800_000_000) + await crawl_source( + SOURCE, Browser(fetcher), website, steps, clock=lambda: 1_800_000_000 + ) asyncio.run(main()) @@ -212,25 +212,3 @@ async def unused(*_: object) -> Any: [run] = site.called("POST /runs") assert run["failure"] == failure assert run["listed"] == 0 - - -def test_a_session_that_cant_start_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: - site = FakeWebsite([], extracted=frozenset()) - - async def unavailable(_browser: Browser) -> None: - raise FetchError("unreachable", SOURCE.url) - - async def unused_read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage: - raise AssertionError("Listing failed before notices could be read") - - monkeypatch.setattr(Browser, "_start", unavailable) - site.run( - Steps(list_items, screen, unused_read, extract), - SOURCE.model_copy(update={"browser": True}), - ) - - assert [call for call, _ in site.calls] == ["POST /runs"] - [run] = site.called("POST /runs") - assert run["failure"] == "unreachable" - assert run["detail"] == SOURCE.url - assert run["listed"] == 0 diff --git a/crawler/src/crawler/parse/listing_test.py b/crawler/src/crawler/parse/listing_test.py index a37ce50..9c405f6 100644 --- a/crawler/src/crawler/parse/listing_test.py +++ b/crawler/src/crawler/parse/listing_test.py @@ -279,12 +279,14 @@ async def read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePag raise AssertionError("a failed list must not read notices") async def run(fetcher: Fetcher) -> None: - async with ( - Browser(fetcher) as browser, - Website("http://site", "local-test", httpx.MockTransport(website.handle)) as client, - ): + async with Website( + "http://site", "local-test", httpx.MockTransport(website.handle) + ) as client: await crawl_source( - source(config=config), browser, client, Steps(list_items, screen, read, extract) + source(config=config), + Browser(fetcher), + client, + Steps(list_items, screen, read, extract), ) sites.run(run)