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
10 changes: 10 additions & 0 deletions crawler/src/crawler/crawl/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ class Evidence(BaseModel):
] = None


class UnresolvedDate(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
field: Literal["apply.start", "apply.end", "event.start", "event.end", "dates"]
text: Annotated[str, Field(max_length=2000, min_length=1)]
evidence: Annotated[list[Evidence], Field(max_length=20, min_length=1)]


type Failure = Annotated[
Literal["timeout", "unreachable", "too_many_redirects", "http_status", "no_items", "error"],
Field(description="不写就是列表读到了"),
Expand Down Expand Up @@ -408,6 +417,7 @@ class ExtractedProgram(BaseModel):
Field(description="call:报名;update:补充;result:名单"),
]
fields: ExtractedFields
unresolved_dates: Annotated[list[UnresolvedDate] | None, Field(max_length=30)] = None


class ExtractionContext(BaseModel):
Expand Down
4 changes: 2 additions & 2 deletions crawler/src/crawler/crawl/rounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class Steps:
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]]]
[str, NoticePage, ExtractionContext], Awaitable[tuple[list[ExtractedProgram], list[Usage]]]
]


Expand Down Expand Up @@ -176,6 +176,6 @@ async def _fetch_notice(
await website.attachment(result.snapshot_id, position, data)
if result.extraction is None:
return 0
programs, usage = await steps.extract(page, result.extraction)
programs, usage = await steps.extract(notice.title, page, result.extraction)
await website.extraction(result.snapshot_id, Extraction(programs=programs, usage=usage))
return len(programs)
91 changes: 90 additions & 1 deletion crawler/src/crawler/crawl/rounds_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from crawler.crawl.fetch import Fetcher, FetchError
from crawler.crawl.rounds import Attachment, NoticePage, StepError, Steps, crawl_source
from crawler.crawl.website import Website
from crawler.extraction.extract import Extract
from crawler.extraction.numbered import SelectedFields

SOURCE = WorkSource(
id=7, url="http://a/list.htm", section="通知公告", browser=False, config=None, backfill=False
Expand Down Expand Up @@ -107,7 +109,7 @@ def page(text: str, files: int = 0) -> NoticePage:


async def extract(
_page: NoticePage, _context: ExtractionContext
_title: str, _page: NoticePage, _context: ExtractionContext
) -> tuple[list[ExtractedProgram], list[Usage]]:
program = ExtractedProgram(
college_id=1,
Expand Down Expand Up @@ -173,6 +175,93 @@ async def read(_pages: PageSession, _source: WorkSource, url: str) -> NoticePage
}


def test_real_extractor_is_wired_into_a_round_with_notice_title_and_usage() -> None:
site = FakeWebsite([pending(1, "fetch")], frozenset())
context = ExtractionContext.model_validate(
{
"published_on": "2026-06-01",
"colleges": [{"id": 12, "name": "计算机学院"}],
"programs": [],
}
)
fields = dict.fromkeys(SelectedFields.model_fields)
fields["apply"] = {
"value": {"start": None, "end": {"date_id": "D1", "edge": "first"}, "rolling": None},
"evidence": ["S1"],
"confidence": 0.95,
}

def model(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content)
assert json.loads(payload["input"][1]["content"])["title"] == "t"
return httpx.Response(
200,
json={
"model": "deepseek-flash",
"status": "completed",
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": json.dumps(
{
"programs": [
{
"college_id": 12,
"program_id": None,
"cohort": 2027,
"kind": "summer_camp",
"role": "call",
"name": "夏令营",
"fields": fields,
"unresolved_dates": [],
}
]
}
),
}
],
}
],
"usage": {"input_tokens": 200, "output_tokens": 50},
},
)

def website(request: httpx.Request) -> httpx.Response:
response = site.handle(request)
if request.url.path.endswith("/snapshot"):
body = response.json()
body["extraction"] = context.model_dump(mode="json")
return httpx.Response(200, json=body)
return response

async def read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage:
return page("报名截止6月30日24点。")

async def run() -> None:
extractor = Extract("local-test", httpx.MockTransport(model))
async with (
extractor,
Fetcher(httpx.MockTransport(lambda _: httpx.Response(500))) as fetcher,
Website("http://site", "token", httpx.MockTransport(website)) as client,
):
await crawl_source(
SOURCE, Browser(fetcher), client, Steps(list_items, screen, read, extractor)
)

asyncio.run(run())
[posted] = site.called("POST /snapshots/10/extraction")
assert posted["programs"][0]["college_id"] == 12
assert posted["programs"][0]["fields"]["apply"]["value"] == {
"end": {"date": "2026-06-30", "time": "24:00"}
}
assert posted["usage"] == [
{"model": "deepseek-flash", "input_tokens": 200, "output_tokens": 50}
]


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())

Expand Down
Loading
Loading