Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions crawler/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# The crawler with Chromium's headless shell, built from this directory: `docker build -t crawler .`
# Run it with --init, which reaps the processes Chromium leaves behind, and a stop timeout long
# enough for the rounds in progress to finish.
# Run it with --init, which reaps the processes Chromium leaves behind, a stop timeout long
# enough for the rounds in progress to finish, and a volume at /home/crawler/.cache/mouseclock
# that keeps the words the LLM read in attachments (design §7.4).
FROM python:3.14-slim-trixie AS build
COPY --from=ghcr.io/astral-sh/uv:0.13.6 /uv /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_NO_DEV=1 UV_PYTHON_DOWNLOADS=0
Expand All @@ -20,4 +21,6 @@ RUN /app/.venv/bin/playwright install --with-deps --only-shell chromium \
&& useradd --create-home crawler
# The pages it opens are untrusted.
USER crawler
# A new volume takes its owner from here.
RUN mkdir -p /home/crawler/.cache/mouseclock
CMD ["/app/.venv/bin/crawler"]
4 changes: 1 addition & 3 deletions crawler/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ dependencies = [
"httpx>=0.28",
"legacy-doc>=0.2.1",
"lxml>=6.1.3",
"onnxruntime>=1.31.1",
"openpyxl>=3.1.5",
"pillow>=13.0.0",
"playwright>=1.64.1",
"pydantic>=2.14.1",
"pypdf[image]>=7.0.0",
"python-docx>=1.2.0",
"rapidocr>=3.10.0",
"xlrd>=2.0.2",
]

Expand All @@ -23,7 +22,6 @@ crawler = "crawler.main:main"
[dependency-groups]
dev = [
"datamodel-code-generator>=0.86.0",
"pillow>=13.0.0",
"pyright>=1.1.400",
"pytest>=9.1",
"ruff>=0.17",
Expand Down
114 changes: 74 additions & 40 deletions crawler/src/crawler/crawl/notice.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
import logging
import mimetypes
import re
from collections.abc import Awaitable, Callable

from crawler.crawl.browser import PageSession
from crawler.crawl.contract import AttachmentInfo, WorkSource
from crawler.crawl.contract import AttachmentInfo, Usage, WorkSource
from crawler.crawl.fetch import FetchError
from crawler.crawl.readings import Readings
from crawler.crawl.rounds import Attachment, NoticePage, StepError
from crawler.parse.files import file_text
from crawler.parse.files import file_parts
from crawler.parse.image import Picture
from crawler.parse.notice import AttachmentLink, parse_notice

# What the website keeps (docs/api.md §6): 50 attachments to a snapshot, 2 MiB of text to one,
Expand All @@ -23,48 +26,79 @@
logger = logging.getLogger(__name__)


async def read_notice(pages: PageSession, _source: WorkSource, url: str) -> NoticePage | None:
response = await pages.get(url)
if response.status in {404, 410}:
return None
if not 200 <= response.status < 300:
raise StepError("http_status", f"HTTP {response.status}")
try:
notice = parse_notice(response.body, response.url, response.content_type)
except ValueError as error:
raise StepError("error", str(error)) from error
if len(notice.attachments) > MAX_ATTACHMENTS:
logger.warning(
"Keeping %d of %d attachments",
MAX_ATTACHMENTS,
len(notice.attachments),
extra={"url": url},
)
attachments = [
await _attachment(pages, link, response.url)
for link in notice.attachments[:MAX_ATTACHMENTS]
]
return NoticePage(notice.html, notice.text, notice.hyperlinks, attachments)
class ReadNotice:
"""A notice's page with its attachments downloaded and read. The LLM reads the pictures in
them, and the same file at the same address keeps the words it read (design §7.4)."""

def __init__(
self, read_picture: Callable[[Picture], Awaitable[tuple[str, Usage]]], readings: Readings
) -> None:
self.read_picture = read_picture
self.readings = readings

# Listed whatever happens to the file, so the positions evidence refers to stay put.
async def _attachment(pages: PageSession, link: AttachmentLink, page_url: str) -> Attachment:
content_type = (
_TYPES.guess_file_type(link.name)[0]
or _TYPES.guess_type(link.url)[0]
or "application/octet-stream"
)
data = await _download(pages, link.url, page_url)
text = ""
if data is not None:
async def __call__(
self, pages: PageSession, _source: WorkSource, url: str
) -> NoticePage | None:
response = await pages.get(url)
if response.status in {404, 410}:
return None
if not 200 <= response.status < 300:
raise StepError("http_status", f"HTTP {response.status}")
try:
text = await asyncio.to_thread(file_text, data)
notice = parse_notice(response.body, response.url, response.content_type)
except ValueError as error:
logger.warning("Attachment unreadable: %s", error, extra={"url": link.url})
info = AttachmentInfo(
name=link.name, url=link.url, content_type=content_type, text=text[:MAX_TEXT]
)
return Attachment(info, data)
raise StepError("error", str(error)) from error
if len(notice.attachments) > MAX_ATTACHMENTS:
logger.warning(
"Keeping %d of %d attachments",
MAX_ATTACHMENTS,
len(notice.attachments),
extra={"url": url},
)
attachments = [
await self._attachment(pages, link, response.url)
for link in notice.attachments[:MAX_ATTACHMENTS]
]
return NoticePage(notice.html, notice.text, notice.hyperlinks, attachments)

# Listed whatever happens to the file, so the positions evidence refers to stay put.
async def _attachment(
self, pages: PageSession, link: AttachmentLink, page_url: str
) -> Attachment:
content_type = (
_TYPES.guess_file_type(link.name)[0]
or _TYPES.guess_type(link.url)[0]
or "application/octet-stream"
)
data = await _download(pages, link.url, page_url)
text = ""
usage: list[Usage] = []
if data is not None:
try:
text, usage = await self._text(link.url, data)
except ValueError as error:
logger.warning("Attachment unreadable: %s", error, extra={"url": link.url})
info = AttachmentInfo(
name=link.name, url=link.url, content_type=content_type, text=text[:MAX_TEXT]
)
return Attachment(info, data, usage)

async def _text(self, url: str, data: bytes) -> tuple[str, list[Usage]]:
if (kept := self.readings.text(url, data)) is not None:
return kept, []
texts: list[str] = []
usage: list[Usage] = []
for part in await asyncio.to_thread(file_parts, data):
if isinstance(part, str):
texts.append(part)
continue
text, used = await self.read_picture(part)
texts.append(text)
usage.append(used)
text = "\n".join(texts)
if usage:
self.readings.keep(url, data, text)
return text, usage


async def _download(pages: PageSession, url: str, page_url: str) -> bytes | None:
Expand Down
125 changes: 113 additions & 12 deletions crawler/src/crawler/crawl/notice_test.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import io
import json
from collections.abc import Iterator
from contextlib import closing
from pathlib import Path

import docx
import httpx
import pytest
from PIL import Image

from crawler.crawl.browser import PageSession
from crawler.crawl.contract import WorkSource
from crawler.crawl.contract import Usage, WorkSource
from crawler.crawl.fetch import Fetcher
from crawler.crawl.fetch_test import FakeSites
from crawler.crawl.notice import read_notice
from crawler.crawl.notice import ReadNotice
from crawler.crawl.readings import Readings
from crawler.crawl.rounds import NoticePage, StepError
from crawler.extraction.pictures import ReadPicture
from crawler.extraction.responses import Responses

SOURCE = WorkSource(
id=7,
Expand All @@ -29,15 +38,58 @@ def docx_file(*paragraphs: str) -> bytes:
return stream.getvalue()


def read(pages: dict[str, httpx.Response], url: str = PAGE) -> NoticePage | None:
return FakeSites(pages).run(
lambda fetcher: read_notice(PageSession(fetcher, None), SOURCE, url)
)
def png_file(color: str) -> bytes:
stream = io.BytesIO()
Image.new("RGB", (600, 300), color).save(stream, "PNG")
return stream.getvalue()


class FakeModel:
"""Reads each picture as which call it was, and records the images each call had."""

def __init__(self) -> None:
self.calls: list[list[str]] = []

def handle(self, request: httpx.Request) -> httpx.Response:
content = json.loads(request.content)["input"][0]["content"]
self.calls.append([part["image_url"] for part in content])
message = {"type": "output_text", "text": f"第{len(self.calls)}次读的文字"}
return httpx.Response(
200,
json={
"model": "deepseek-flash",
"status": "completed",
"output": [{"type": "message", "content": [message]}],
"usage": {"input_tokens": 1000, "output_tokens": 100},
},
)


@pytest.fixture
def readings(tmp_path: Path) -> Iterator[Readings]:
with closing(Readings(tmp_path / "readings.sqlite3")) as kept:
yield kept


def read(
pages: dict[str, httpx.Response],
readings: Readings,
url: str = PAGE,
model: FakeModel | None = None,
) -> NoticePage | None:
transport = httpx.MockTransport((model or FakeModel()).handle)

async def use(fetcher: Fetcher) -> NoticePage | None:
async with Responses("local-test", transport) as llm:
read_notice = ReadNotice(ReadPicture(llm), readings)
return await read_notice(PageSession(fetcher, None), SOURCE, url)

return FakeSites(pages).run(use)


@pytest.mark.parametrize("status", [404, 410])
def test_a_page_that_is_gone_reads_as_none(status: int) -> None:
assert read({PAGE: httpx.Response(status)}) is None
def test_a_page_that_is_gone_reads_as_none(status: int, readings: Readings) -> None:
assert read({PAGE: httpx.Response(status)}, readings) is None


@pytest.mark.parametrize(
Expand All @@ -48,14 +100,16 @@ def test_a_page_that_is_gone_reads_as_none(status: int) -> None:
],
)
def test_a_page_that_cant_be_read_fails_the_notice(
response: httpx.Response, failure: str, detail: str
response: httpx.Response, failure: str, detail: str, readings: Readings
) -> None:
with pytest.raises(StepError) as caught:
read({PAGE: response})
read({PAGE: response}, readings)
assert (caught.value.failure, caught.value.detail) == (failure, detail)


def test_attachments_keep_their_order_and_the_ones_not_downloaded_stay_listed() -> None:
def test_attachments_keep_their_order_and_the_ones_not_downloaded_stay_listed(
readings: Readings,
) -> None:
body = (
'<div class="v_news_content"><p>自编的通知正文。</p>'
'<p><a href="files/form.docx">申请表.docx</a></p>'
Expand All @@ -74,7 +128,7 @@ def test_attachments_keep_their_order_and_the_ones_not_downloaded_stay_listed()
200, html="<p>请输入验证码下载附件</p>"
),
}
page = read(pages, "https://example.edu.cn/old/101.htm")
page = read(pages, readings, "https://example.edu.cn/old/101.htm")
assert page is not None
assert page.text == "自编的通知正文。\n申请表.docx\n推荐信.doc\n名单.pdf"
assert [(a.info.name, a.info.content_type, a.info.text) for a in page.attachments] == [
Expand All @@ -87,3 +141,50 @@ def test_attachments_keep_their_order_and_the_ones_not_downloaded_stay_listed()
("名单.pdf", "application/pdf", ""),
]
assert [a.data is not None for a in page.attachments] == [True, False, False]


def test_the_llm_reads_a_picture_once_while_the_file_stays_the_same(readings: Readings) -> None:
body = '<div class="v_news_content"><p>自编的通知正文。</p><p><img src="poster.png"></p></div>'
poster = "https://example.edu.cn/info/1001/poster.png"
model = FakeModel()

def read_with(image: bytes) -> NoticePage:
pages = {PAGE: httpx.Response(200, html=body), poster: httpx.Response(200, content=image)}
page = read(pages, readings, model=model)
assert page is not None
return page

first = read_with(png_file("white"))
again = read_with(png_file("white"))
changed = read_with(png_file("gray"))

assert [page.attachments[0].info.text for page in (first, again, changed)] == [
"第1次读的文字",
"第1次读的文字",
"第2次读的文字",
]
assert first.attachments[0].usage == [
Usage(model="deepseek-flash", input_tokens=1000, output_tokens=100)
]
assert again.attachments[0].usage == []
assert [len(images) for images in model.calls] == [1, 1]
assert model.calls[0][0].startswith("data:image/png;base64,")


def test_a_picture_the_llm_cant_read_fails_the_notice(readings: Readings) -> None:
body = '<div class="v_news_content"><p>自编的通知正文。</p><p><img src="poster.png"></p></div>'
pages = {
PAGE: httpx.Response(200, html=body),
"https://example.edu.cn/info/1001/poster.png": httpx.Response(
200, content=png_file("white")
),
}

async def use(fetcher: Fetcher) -> NoticePage | None:
transport = httpx.MockTransport(lambda _: httpx.Response(503))
async with Responses("local-test", transport) as llm:
read_notice = ReadNotice(ReadPicture(llm), readings)
return await read_notice(PageSession(fetcher, None), SOURCE, PAGE)

with pytest.raises(StepError, match="http_503"):
FakeSites(pages).run(use)
39 changes: 39 additions & 0 deletions crawler/src/crawler/crawl/readings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""The words the LLM read in attachments, kept by the attachment's address (design §7.4). Read
again, the same picture may come out in different words, and the notice would look changed."""

import sqlite3
import time
from collections.abc import Callable
from pathlib import Path

# Files no notice has used for this long are dropped.
KEEP = 30 * 24 * 60 * 60


class Readings:
def __init__(self, path: Path, clock: Callable[[], float] = time.time) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
self.db = sqlite3.connect(path, autocommit=True)
self.clock = clock
self.db.execute(
"CREATE TABLE IF NOT EXISTS readings"
" (url TEXT PRIMARY KEY, data BLOB NOT NULL, text TEXT NOT NULL, used_at REAL NOT NULL)"
)

def text(self, url: str, data: bytes) -> str | None:
"""The words read from the file at this address, if it is still byte for byte the same."""
row = self.db.execute(
"UPDATE readings SET used_at = ? WHERE url = ? AND data = ? RETURNING text",
(self.clock(), url, data),
).fetchone()
return None if row is None else row[0]

def keep(self, url: str, data: bytes, text: str) -> None:
now = self.clock()
self.db.execute("DELETE FROM readings WHERE used_at < ?", (now - KEEP,))
self.db.execute(
"INSERT OR REPLACE INTO readings VALUES (?, ?, ?, ?)", (url, data, text, now)
)

def close(self) -> None:
self.db.close()
Loading
Loading