From 139d1de249a81cbadffad57182db4685217332d7 Mon Sep 17 00:00:00 2001 From: JunieXD <1240840076@qq.com> Date: Sat, 14 Nov 2026 20:37:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=94=B9=E7=94=B1=20DeepSeek?= =?UTF-8?q?=20=E5=88=87=E5=9D=97=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地的 RapidOCR 认不全图片里的截止时间和网址,#102 比较后改由 DeepSeek 切块读(#105)。 - 图片和 PDF 里的扫描页切成每块不超过约 1300×1300 像素,尽量切在没有字的行,一次请求按顺序送完;宽过 2048 像素的先缩小 - 同一张图每次读出的文字可能不一样,读过的文件按附件网址存在本机的 SQLite 里,再下载到一字节不差的文件就用上次的文字,不然通知会被当成变了;30 天没用到的删掉 - 读图的用量和这条通知的抽取一起交;DeepSeek 没读成的,这条通知这一轮算没抓成 - 删掉 RapidOCR 和 ONNX Runtime,连带 numpy、OpenCV 等 15 个包;Pillow 改成直接依赖。镜像里给存储留好目录,部署时挂一个卷 --- crawler/Dockerfile | 7 +- crawler/pyproject.toml | 4 +- crawler/src/crawler/crawl/notice.py | 114 ++++-- crawler/src/crawler/crawl/notice_test.py | 125 +++++- crawler/src/crawler/crawl/readings.py | 39 ++ crawler/src/crawler/crawl/readings_test.py | 27 ++ crawler/src/crawler/crawl/rounds.py | 8 +- crawler/src/crawler/crawl/rounds_test.py | 21 +- .../src/crawler/extraction/extract_test.py | 1 + crawler/src/crawler/extraction/pictures.py | 52 +++ crawler/src/crawler/main.py | 24 +- crawler/src/crawler/parse/files.py | 28 +- crawler/src/crawler/parse/files_test.py | 48 +-- .../crawler/parse/fixtures/NotoSansSC-OFL.txt | 93 ----- .../parse/fixtures/NotoSansSC-test.ttf | Bin 37284 -> 0 bytes crawler/src/crawler/parse/image.py | 103 +++-- crawler/src/crawler/parse/image_test.py | 35 ++ crawler/uv.lock | 370 +----------------- docs/development.md | 6 +- 19 files changed, 484 insertions(+), 621 deletions(-) create mode 100644 crawler/src/crawler/crawl/readings.py create mode 100644 crawler/src/crawler/crawl/readings_test.py create mode 100644 crawler/src/crawler/extraction/pictures.py delete mode 100644 crawler/src/crawler/parse/fixtures/NotoSansSC-OFL.txt delete mode 100644 crawler/src/crawler/parse/fixtures/NotoSansSC-test.ttf create mode 100644 crawler/src/crawler/parse/image_test.py diff --git a/crawler/Dockerfile b/crawler/Dockerfile index 910f4aa..525406f 100644 --- a/crawler/Dockerfile +++ b/crawler/Dockerfile @@ -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 @@ -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"] diff --git a/crawler/pyproject.toml b/crawler/pyproject.toml index 8fba35b..c87ca6a 100644 --- a/crawler/pyproject.toml +++ b/crawler/pyproject.toml @@ -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", ] @@ -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", diff --git a/crawler/src/crawler/crawl/notice.py b/crawler/src/crawler/crawl/notice.py index 5bf6938..4008c57 100644 --- a/crawler/src/crawler/crawl/notice.py +++ b/crawler/src/crawler/crawl/notice.py @@ -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, @@ -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: diff --git a/crawler/src/crawler/crawl/notice_test.py b/crawler/src/crawler/crawl/notice_test.py index 6a7c695..e60eded 100644 --- a/crawler/src/crawler/crawl/notice_test.py +++ b/crawler/src/crawler/crawl/notice_test.py @@ -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, @@ -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( @@ -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 = ( '

自编的通知正文。

' '

申请表.docx

' @@ -74,7 +128,7 @@ def test_attachments_keep_their_order_and_the_ones_not_downloaded_stay_listed() 200, html="

请输入验证码下载附件

" ), } - 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] == [ @@ -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 = '

自编的通知正文。

' + 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 = '

自编的通知正文。

' + 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) diff --git a/crawler/src/crawler/crawl/readings.py b/crawler/src/crawler/crawl/readings.py new file mode 100644 index 0000000..8a6f2ae --- /dev/null +++ b/crawler/src/crawler/crawl/readings.py @@ -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() diff --git a/crawler/src/crawler/crawl/readings_test.py b/crawler/src/crawler/crawl/readings_test.py new file mode 100644 index 0000000..2ab2880 --- /dev/null +++ b/crawler/src/crawler/crawl/readings_test.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from crawler.crawl.readings import KEEP, Readings + + +def test_words_are_kept_for_the_same_file_at_the_same_address(tmp_path: Path) -> None: + now = 1_800_000_000.0 + readings = Readings(tmp_path / "readings.sqlite3", lambda: now) + readings.keep("http://a/1.png", b"png 1", "报名截止9月20日") + + assert readings.text("http://a/1.png", b"png 1") == "报名截止9月20日" + assert readings.text("http://a/1.png", b"png 2") is None + assert readings.text("http://a/2.png", b"png 1") is None + + +def test_files_unused_for_a_while_are_dropped(tmp_path: Path) -> None: + now = 1_800_000_000.0 + readings = Readings(tmp_path / "readings.sqlite3", lambda: now) + readings.keep("http://a/old.png", b"old", "旧") + readings.keep("http://a/used.png", b"used", "常用") + now += KEEP - 1 + assert readings.text("http://a/used.png", b"used") == "常用" + now += 2 + readings.keep("http://a/new.png", b"new", "新") + + assert readings.text("http://a/old.png", b"old") is None + assert readings.text("http://a/used.png", b"used") == "常用" diff --git a/crawler/src/crawler/crawl/rounds.py b/crawler/src/crawler/crawl/rounds.py index fd95112..89f8b4e 100644 --- a/crawler/src/crawler/crawl/rounds.py +++ b/crawler/src/crawler/crawl/rounds.py @@ -47,6 +47,9 @@ class Attachment: info: AttachmentInfo # None when the file couldn't be downloaded; the snapshot lists it all the same. data: bytes | None + # What the LLM's reading its pictures cost, none when the words were kept from before + # (design §7.4); it goes with the notice's extraction (§8.6). + usage: list[Usage] @dataclass(frozen=True) @@ -189,5 +192,8 @@ async def _fetch_notice( if result.extraction is None: return 0 programs, usage = await steps.extract(notice.title, page, result.extraction) - await website.extraction(result.snapshot_id, Extraction(programs=programs, usage=usage)) + reading = [used for attachment in page.attachments for used in attachment.usage] + await website.extraction( + result.snapshot_id, Extraction(programs=programs, usage=reading + usage) + ) return len(programs) diff --git a/crawler/src/crawler/crawl/rounds_test.py b/crawler/src/crawler/crawl/rounds_test.py index 5363261..ae263b9 100644 --- a/crawler/src/crawler/crawl/rounds_test.py +++ b/crawler/src/crawler/crawl/rounds_test.py @@ -101,6 +101,7 @@ def page(text: str, files: int = 0) -> NoticePage: Attachment( AttachmentInfo(name=f"{n}.pdf", url=f"http://a/{n}.pdf", content_type="pdf", text="表"), b"%PDF", + [], ) for n in range(files) ] @@ -129,7 +130,9 @@ def test_a_round_screens_the_new_fetches_what_needs_it_and_reports() -> None: ) behind_captcha = Attachment( - AttachmentInfo(name="表.doc", url="http://a/表.doc", content_type="doc", text=""), None + AttachmentInfo(name="表.doc", url="http://a/表.doc", content_type="doc", text=""), + None, + [], ) with_files = page("冬令营", files=1) with_files.attachments.append(behind_captcha) @@ -236,8 +239,19 @@ def website(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=body) return response + # Reading the picture went before the extraction, and its usage goes with it. + picture = Attachment( + AttachmentInfo( + name="日程.png", url="http://a/日程.png", content_type="image/png", text="日程" + ), + b"png", + [Usage(model="deepseek-flash", input_tokens=6000, output_tokens=1000)], + ) + async def read(_pages: PageSession, _source: WorkSource, _url: str) -> NoticePage: - return page("报名截止6月30日24点。") + notice = page("报名截止6月30日24点。") + notice.attachments.append(picture) + return notice async def run() -> None: async with ( @@ -256,7 +270,8 @@ async def run() -> None: "end": {"date": "2026-06-30", "time": "24:00"} } assert posted["usage"] == [ - {"model": "deepseek-flash", "input_tokens": 200, "output_tokens": 50} + {"model": "deepseek-flash", "input_tokens": 6000, "output_tokens": 1000}, + {"model": "deepseek-flash", "input_tokens": 200, "output_tokens": 50}, ] diff --git a/crawler/src/crawler/extraction/extract_test.py b/crawler/src/crawler/extraction/extract_test.py index 12924e1..8765632 100644 --- a/crawler/src/crawler/extraction/extract_test.py +++ b/crawler/src/crawler/extraction/extract_test.py @@ -119,6 +119,7 @@ def test_unparsed_attachment_date_keeps_raw_evidence_and_has_no_invented_value() name="安排.txt", url="https://example.edu/1.txt", content_type="text/plain", text=text ), b"", + [], ) _, index = prepare( "通知", NoticePage("", "见附件。", hyperlinks=(), attachments=[attachment]), context() diff --git a/crawler/src/crawler/extraction/pictures.py b/crawler/src/crawler/extraction/pictures.py new file mode 100644 index 0000000..7c2cb08 --- /dev/null +++ b/crawler/src/crawler/extraction/pictures.py @@ -0,0 +1,52 @@ +"""Read the words in pictures with the LLM, as they are (design §5.2).""" + +import base64 + +from pydantic import JsonValue + +from crawler.crawl.contract import Usage +from crawler.crawl.rounds import StepError +from crawler.extraction.responses import MODEL, REASONING_EFFORT, TEMPERATURE, Responses +from crawler.parse.image import Picture + +INSTRUCTIONS = ( + "你是文字识别工具。把图片里的全部文字按阅读顺序原样转写成纯文本。" + "要求:一字不差,不改字、不纠错、不补全、不翻译、不总结;保留原文的标点、空格和大小写;" + "每个文本行单独写一行,表格的每一行写成一行、单元格之间用一个制表符分隔;" + "看不清的字写成 □;不用 Markdown,只输出转写的文字,不加任何说明。" + "有几张图片时,它们是同一张长图从上到下依次切开的几段,按顺序转写,不要重复,也不要遗漏。" +) +# A long screenshot can hold more words than an extraction's answer. +MAX_OUTPUT_TOKENS = 16384 + + +class ReadPicture: + def __init__(self, llm: Responses) -> None: + self.llm = llm + + async def __call__(self, picture: Picture) -> tuple[str, Usage]: + images: list[JsonValue] = [ + { + "type": "input_image", + "image_url": "data:image/png;base64," + base64.b64encode(strip).decode(), + "detail": "high", + } + for strip in picture.strips + ] + response = await self.llm.generate( + { + "model": MODEL, + "instructions": INSTRUCTIONS, + "input": [{"role": "user", "content": images}], + "reasoning": {"effort": REASONING_EFFORT}, + "temperature": TEMPERATURE, + "max_output_tokens": MAX_OUTPUT_TOKENS, + "store": False, + } + ) + usage = response.usage + if usage is None: + raise StepError("error", "读图失败: missing_usage") + return response.output_text(), Usage( + model=response.model, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens + ) diff --git a/crawler/src/crawler/main.py b/crawler/src/crawler/main.py index 3907f44..8bbd97a 100644 --- a/crawler/src/crawler/main.py +++ b/crawler/src/crawler/main.py @@ -7,7 +7,8 @@ import signal import sys from collections.abc import Mapping -from contextlib import suppress +from contextlib import closing, suppress +from pathlib import Path from typing import Annotated import httpx @@ -17,11 +18,13 @@ from crawler.crawl.contract import WorkProbe, WorkSource from crawler.crawl.fetch import Fetcher from crawler.crawl.listing import list_items -from crawler.crawl.notice import read_notice +from crawler.crawl.notice import ReadNotice from crawler.crawl.probe import GenerateConfig, probe_source +from crawler.crawl.readings import Readings from crawler.crawl.rounds import Steps, crawl_source from crawler.crawl.website import Website from crawler.extraction.extract import Extract +from crawler.extraction.pictures import ReadPicture from crawler.extraction.probe import ProbeConfig from crawler.extraction.responses import Responses from crawler.log import JsonFormatter @@ -30,6 +33,8 @@ # Design §16: once a minute; the dashboard flags the crawler after ten minutes without asking. POLL = 60.0 +# The container keeps this directory on a volume (Dockerfile); losing it only costs reading again. +READINGS = Path.home() / ".cache" / "mouseclock" / "readings.sqlite3" logger = logging.getLogger(__name__) type Secret = Annotated[str, StringConstraints(min_length=1)] @@ -120,13 +125,14 @@ def stop() -> None: Website(str(config.mouseclock_url), config.mouseclock_token) as website, Responses(config.llm_api_key) as llm, ): - steps = Steps( - list_items=list_items, - screen=Screen(system_one), - read=read_notice, - extract=Extract(llm), - ) - await serve(website, browser, steps, stopping, ProbeConfig(llm).generate) + with closing(Readings(READINGS)) as readings: + steps = Steps( + list_items=list_items, + screen=Screen(system_one), + read=ReadNotice(ReadPicture(llm), readings), + extract=Extract(llm), + ) + await serve(website, browser, steps, stopping, ProbeConfig(llm).generate) finally: await system_one.close() diff --git a/crawler/src/crawler/parse/files.py b/crawler/src/crawler/parse/files.py index a265482..d8602aa 100644 --- a/crawler/src/crawler/parse/files.py +++ b/crawler/src/crawler/parse/files.py @@ -1,5 +1,6 @@ """The words in the files notices attach (design §5.1): images, PDF (scanned pages too), Word and -Excel, including the old binary formats. Archives and other files have none to read.""" +Excel, including the old binary formats. Archives and other files have none to read. The LLM +reads the pictures; this leaves them in their place among the words.""" import io import unicodedata @@ -14,7 +15,7 @@ from legacy_doc import extract_text from pypdf import PageObject, PdfReader -from crawler.parse.image import image_text +from crawler.parse.image import Picture, picture _PDF = b"%PDF" _ZIP = b"PK\x03\x04" @@ -34,32 +35,33 @@ _RADICALS = {code: unicodedata.normalize("NFKC", chr(code)) for code in range(0x2F00, 0x2FD6)} -def file_text(data: bytes) -> str: - """Raises ValueError when the file is broken.""" +def file_parts(data: bytes) -> list[str | Picture]: + """The file's text and pictures in reading order. Raises ValueError when the file is broken.""" try: if data.startswith(_IMAGE_HEADERS) or (data.startswith(b"RIFF") and data[8:12] == b"WEBP"): - return image_text(data) + return [picture(data)] if data.startswith(_PDF): - pages = PdfReader(io.BytesIO(data)).pages - return "\n".join(_page_text(page) for page in pages).translate(_RADICALS) + return [part for page in PdfReader(io.BytesIO(data)).pages for part in _page(page)] if data.startswith(_ZIP): parts = set(zipfile.ZipFile(io.BytesIO(data)).namelist()) if "word/document.xml" in parts: - return _docx(data) + return [_docx(data)] if "xl/workbook.xml" in parts: - return _xlsx(data) + return [_xlsx(data)] elif data.startswith(_OLE): - return extract_text(data).text if _WORD in data else _xls(data) + return [extract_text(data).text if _WORD in data else _xls(data)] # The files come from the colleges' sites, and parsers fail on broken ones in all sorts of ways. except Exception as error: raise ValueError(type(error).__name__) from error - return "" + return [] -def _page_text(page: PageObject) -> str: +def _page(page: PageObject) -> list[str | Picture]: text = page.extract_text() + if text.strip(): + return [text.translate(_RADICALS)] # A scanned page is a picture with no text layer; scanners put one picture on each page. - return text if text.strip() else "\n".join(image_text(image.data) for image in page.images) + return [picture(image.data) for image in page.images] def _lines(lines: Iterable[str]) -> str: diff --git a/crawler/src/crawler/parse/files_test.py b/crawler/src/crawler/parse/files_test.py index a13abc5..ac1e0d0 100644 --- a/crawler/src/crawler/parse/files_test.py +++ b/crawler/src/crawler/parse/files_test.py @@ -1,39 +1,25 @@ import io import zipfile -from pathlib import Path import docx import openpyxl import pytest -from PIL import Image, ImageDraw, ImageFont +from PIL import Image -from crawler.parse.files import file_text +from crawler.parse.files import file_parts +from crawler.parse.image import Picture # Pillow saves a picture as a PDF the way a scanner does: one picture a page, no text layer. -@pytest.mark.parametrize("image_format", ["PNG", "PDF"]) -def test_text_is_recognized_in_images_and_scanned_pdfs(image_format: str) -> None: - image = Image.new("RGB", (1600, 500), "white") - draw = ImageDraw.Draw(image) - font = ImageFont.truetype(Path(__file__).with_name("fixtures") / "NotoSansSC-test.ttf", 64) - draw.text((60, 50), "夏令营报名时间", fill="black", font=font) - draw.text((60, 170), "7月6日10:00至8月10日17:00", fill="black", font=font) - draw.text((60, 290), "请填写申请表", fill="black", font=font) +@pytest.mark.parametrize("image_format", ["PNG", "JPEG", "PDF"]) +def test_images_and_scanned_pdf_pages_are_left_for_the_llm(image_format: str) -> None: buffer = io.BytesIO() - image.save(buffer, format=image_format) + Image.new("RGB", (600, 300), "white").save(buffer, format=image_format) - text = file_text(buffer.getvalue()) + [part] = file_parts(buffer.getvalue()) - assert "夏令营报名时间" in text - assert "7月6日10:00至8月10日17:00" in text - assert "请填写申请表" in text - - -def test_blank_image_has_no_words_to_read() -> None: - buffer = io.BytesIO() - Image.new("RGB", (600, 300), "white").save(buffer, format="JPEG") - - assert file_text(buffer.getvalue()) == "" + assert isinstance(part, Picture) + assert Image.open(io.BytesIO(part.strips[0])).size == (600, 300) def test_word_keeps_paragraphs_and_tables_in_order_with_merged_cells_once() -> None: @@ -49,9 +35,9 @@ def test_word_keeps_paragraphs_and_tables_in_order_with_merged_cells_once() -> N document.add_paragraph("以上名额为预计。") stream = io.BytesIO() document.save(stream) - assert file_text(stream.getvalue()) == ( + assert file_parts(stream.getvalue()) == [ "报名截止6月30日。\n专业与方向\t名额\n计算机\t人工智能\t10 截止9月20日\n以上名额为预计。" - ) + ] def test_excel_reads_each_sheet_by_rows() -> None: @@ -64,9 +50,9 @@ def test_excel_reads_each_sheet_by_rows() -> None: book.create_sheet("联系方式").append(["电话", "010-00000000"]) stream = io.BytesIO() book.save(stream) - assert file_text(stream.getvalue()) == ( + assert file_parts(stream.getvalue()) == [ "专业目录\n专业\t名额\n计算机\t10\n人工智能\t5\n联系方式\n电话\t010-00000000" - ) + ] def pdf_file(content: bytes, to_unicode: bytes) -> bytes: @@ -100,18 +86,18 @@ def test_pdf_characters_mapped_to_look_alike_radicals_read_as_the_common_ones() b" endcmap CMapName currentdict /CMap defineresource pop end end" ) data = pdf_file(b"BT /F1 12 Tf 10 50 Td (AB) Tj ET", to_unicode) - assert file_text(data) == "学生" + assert file_parts(data) == ["学生"] def test_files_with_no_words_to_read_give_none() -> None: archive = io.BytesIO() with zipfile.ZipFile(archive, "w") as files: files.writestr("说明.txt", "自编的说明") - assert file_text(archive.getvalue()) == "" + assert file_parts(archive.getvalue()) == [] def test_a_broken_file_is_an_error() -> None: with pytest.raises(ValueError, match="BadZipFile"): - file_text(b"PK\x03\x04" + b"\x00" * 30) + file_parts(b"PK\x03\x04" + b"\x00" * 30) with pytest.raises(ValueError, match="UnidentifiedImageError"): - file_text(b"\x89PNG\r\n\x1a\n") + file_parts(b"\x89PNG\r\n\x1a\n") diff --git a/crawler/src/crawler/parse/fixtures/NotoSansSC-OFL.txt b/crawler/src/crawler/parse/fixtures/NotoSansSC-OFL.txt deleted file mode 100644 index 79a917b..0000000 --- a/crawler/src/crawler/parse/fixtures/NotoSansSC-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crawler/src/crawler/parse/fixtures/NotoSansSC-test.ttf b/crawler/src/crawler/parse/fixtures/NotoSansSC-test.ttf deleted file mode 100644 index 33c68f493a5536e5c32ea3b5177c3e2b0fd5f566..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37284 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 str: - """Return recognized lines in reading order, with one OCR call at a time.""" - global _engine - - with _lock: - start = time.monotonic() - if _engine is None: - _engine = RapidOCR( - params={ - # Find lines on a smaller copy, then crop the legible text from the original. - "Global.use_preprocess_img": False, - "Det.limit_type": "max", - "Det.limit_side_len": 2000, - # A batch is padded to its widest line; one line at a time keeps the peak low. - "Rec.rec_batch_num": 1, - } - ) - result = _engine(data) - if not isinstance(result, RapidOCROutput): - raise TypeError("OCR did not return recognized text") - logger.info( - "Image OCR completed", - extra={ - "elapsed": time.monotonic() - start, - "peak_rss_kib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, - }, - ) - return "\n".join(result.txts or []) +"""Cut images into strips for the LLM to read (design §5.2). It scales every image down to about +PIXEL_BUDGET pixels, which leaves a long screenshot's characters too small to read.""" + +import io +from dataclasses import dataclass +from itertools import pairwise + +from PIL import Image + +PIXEL_BUDGET = 1300 * 1300 +# Wider images are scaled down to at most this: the characters of a big scan or poster stay +# legible without cutting it into dozens of strips. The model takes at most 4096 pixels a side +# once a request has 15 images. +MAX_WIDTH = 2048 +# How far a pixel's gray is from the background's for it to count as ink. +INK = 40 + + +@dataclass(frozen=True) +class Picture: + """An image as PNG strips from top to bottom, each within the pixel budget.""" + + strips: list[bytes] + + +def picture(data: bytes) -> Picture: + with Image.open(io.BytesIO(data)) as opened: + image = opened.convert("RGB") + if image.width > MAX_WIDTH: + image = image.reduce(-(-image.width // MAX_WIDTH)) + width, height = image.size + step = PIXEL_BUDGET // width + margin = step // 10 + # Even cuts, each free to move by a margin to the row with the least ink; every strip still + # stays within a step. + count = -(-height // (step - 2 * margin)) + ink = _ink(image) + cuts = [0] + for index in range(1, count): + low = index * height // count - margin + window = ink[low : low + 2 * margin] + cuts.append(low + window.index(min(window))) + cuts.append(height) + return Picture([_png(image.crop((0, top, width, bottom))) for top, bottom in pairwise(cuts)]) + + +def _ink(image: Image.Image) -> list[int]: + """How many pixels in each row are unlike the background.""" + gray = image.convert("L") + histogram = gray.histogram() + background = histogram.index(max(histogram)) + marks = gray.tobytes().translate(bytes(abs(value - background) > INK for value in range(256))) + width = image.width + return [marks.count(1, top, top + width) for top in range(0, len(marks), width)] + + +def _png(image: Image.Image) -> bytes: + buffer = io.BytesIO() + image.save(buffer, "PNG") + return buffer.getvalue() diff --git a/crawler/src/crawler/parse/image_test.py b/crawler/src/crawler/parse/image_test.py new file mode 100644 index 0000000..192cc9b --- /dev/null +++ b/crawler/src/crawler/parse/image_test.py @@ -0,0 +1,35 @@ +import io + +from PIL import Image, ImageDraw + +from crawler.parse.image import PIXEL_BUDGET, picture + + +def png(image: Image.Image) -> bytes: + buffer = io.BytesIO() + image.save(buffer, "PNG") + return buffer.getvalue() + + +def test_a_long_screenshot_is_cut_between_lines_into_strips_within_the_budget() -> None: + # Bars for lines of text, 30 pixels high with 20 between them; most pixels are background. + image = Image.new("RGB", (1000, 8000), "#f4f4f4") + draw = ImageDraw.Draw(image) + for top in range(10, 8000, 50): + draw.rectangle((40, top, 400, top + 29), fill="#202020") + + strips = [Image.open(io.BytesIO(strip)) for strip in picture(png(image)).strips] + + assert all(strip.width * strip.height <= PIXEL_BUDGET for strip in strips) + assert sum(strip.height for strip in strips) == 8000 + # No line is cut through: each strip starts on background. + assert len(strips) > 1 + assert all(strip.getpixel((100, 0)) == (244, 244, 244) for strip in strips[1:]) + + +def test_a_small_image_stays_whole_and_a_wide_one_is_scaled_down() -> None: + [small] = picture(png(Image.new("RGB", (600, 400), "white"))).strips + [wide] = picture(png(Image.new("RGB", (4000, 300), "white"))).strips + + assert Image.open(io.BytesIO(small)).size == (600, 400) + assert Image.open(io.BytesIO(wide)).size == (2000, 150) diff --git a/crawler/uv.lock b/crawler/uv.lock index c63eea0..3cd37d4 100644 --- a/crawler/uv.lock +++ b/crawler/uv.lock @@ -87,100 +87,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, - { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, - { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, - { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, - { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, - { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, - { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, - { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, - { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, - { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, - { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, - { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, - { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, - { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, - { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, - { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, - { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, - { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, - { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, - { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, - { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, - { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, - { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, - { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, - { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, - { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, - { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, - { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, - { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, - { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, - { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, - { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, - { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, - { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, - { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, - { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, - { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, - { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, - { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, - { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, - { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, - { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, - { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, - { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, - { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, -] - [[package]] name = "click" version = "8.5.0" @@ -199,18 +105,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "colorlog" -version = "6.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, -] - [[package]] name = "crawler" version = "0.1.0" @@ -219,20 +113,18 @@ dependencies = [ { name = "httpx" }, { name = "legacy-doc" }, { name = "lxml" }, - { name = "onnxruntime" }, { name = "openpyxl" }, + { name = "pillow" }, { name = "playwright" }, { name = "pydantic" }, { name = "pypdf", extra = ["image"] }, { name = "python-docx" }, - { name = "rapidocr" }, { name = "xlrd" }, ] [package.dev-dependencies] dev = [ { name = "datamodel-code-generator" }, - { name = "pillow" }, { name = "pyright" }, { name = "pytest" }, { name = "ruff" }, @@ -246,20 +138,18 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28" }, { name = "legacy-doc", specifier = ">=0.2.1" }, { name = "lxml", specifier = ">=6.1.3" }, - { name = "onnxruntime", specifier = ">=1.31.1" }, { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "pillow", specifier = ">=13.0.0" }, { name = "playwright", specifier = ">=1.64.1" }, { name = "pydantic", specifier = ">=2.14.1" }, { name = "pypdf", extras = ["image"], specifier = ">=7.0.0" }, { name = "python-docx", specifier = ">=1.2.0" }, - { name = "rapidocr", specifier = ">=3.10.0" }, { name = "xlrd", specifier = ">=2.0.2" }, ] [package.metadata.requires-dev] dev = [ { name = "datamodel-code-generator", specifier = ">=0.86.0" }, - { name = "pillow", specifier = ">=13.0.0" }, { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest", specifier = ">=9.1" }, { name = "ruff", specifier = ">=0.17" }, @@ -306,14 +196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, -] - [[package]] name = "genson" version = "1.4.0" @@ -660,108 +542,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] -[[package]] -name = "numpy" -version = "2.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, - { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, - { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, - { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, - { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, - { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, - { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, - { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, - { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, - { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, - { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, - { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, - { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, - { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, - { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, - { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, - { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, - { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, - { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, - { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, - { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, - { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, - { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, - { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, - { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, - { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, -] - -[[package]] -name = "omegaconf" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/10/1cabf3188a4d9684a06568c7fd76f9536eede625733c8399cf1f5337b83e/omegaconf-2.4.0.tar.gz", hash = "sha256:7e219a70bea371da250285b9c01c7db027bc421722799c2cc4ff14bc4a2a753b", size = 3544508, upload-time = "2026-10-09T12:44:02.326Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/bd/0d9f8247b0819385213d09564eda5c52d3673313c2414d51f72883ec24cd/omegaconf-2.4.0-py3-none-any.whl", hash = "sha256:f141a664c777582405e3cc6e0545ecc79429e79f844995b1a6e71c35946a025c", size = 254152, upload-time = "2026-10-09T12:43:59.764Z" }, -] - -[[package]] -name = "onnxruntime" -version = "1.31.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flatbuffers" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "protobuf" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/55/b511099a435972b86b5d04ef374cdca8de920311cc979180029b80145bf5/onnxruntime-1.31.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8536ac43682caa7492c46d6cfd4c3c8db288dcf0619508431a22170b670ee1d0", size = 20883427, upload-time = "2026-10-26T19:26:54.067Z" }, - { url = "https://files.pythonhosted.org/packages/06/ef/98f0060d6cf58f9f663268fef65fe729120b8fbe26ef35eaa2c8d9030d33/onnxruntime-1.31.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:c878686b75cade1ed59131aa018ab004b1e64368b0d54041912450b9642cf04f", size = 21421619, upload-time = "2026-10-26T19:26:57.332Z" }, - { url = "https://files.pythonhosted.org/packages/aa/74/a891a659ccc427469de0d7bdbd5ff770a35f73084ee590ce02456cf61f7f/onnxruntime-1.31.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c20d7f15d84427db070b163f78c2c7146cc93edd6113fa7327671259a24184fe", size = 23762999, upload-time = "2026-10-26T19:27:00.865Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e9/9112e9322aef42bff80e805915b61cdcbb82577589bdd42dedf73a5ff9b6/onnxruntime-1.31.1-cp314-cp314-win_amd64.whl", hash = "sha256:0a3998e18cb667ef54287556bd62291a788ed235723dd90c83b2294b24e71d81", size = 15268752, upload-time = "2026-10-26T19:27:03.629Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ee/b69384d9b94c6bf3beb030bcba25dc573e490d3136117be0f78d79078d35/onnxruntime-1.31.1-cp314-cp314-win_arm64.whl", hash = "sha256:fc01b4e7c0a81778fb741bcf5c16b7871d196275f6240720a8b8bfd9f0c0af14", size = 15153930, upload-time = "2026-10-26T19:27:06.159Z" }, - { url = "https://files.pythonhosted.org/packages/2b/5b/beeb6acb5a9e17311b4ebc36fe91ac4893200ddb290257fb072509a7c26b/onnxruntime-1.31.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:564d7b83fb3e94fb3d18629c6b339e9bce10966dd29a8442c433175aac3d60fd", size = 21432341, upload-time = "2026-10-26T19:27:09.131Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3d/a5b15c4ab9d67a384da5a2c10fb569e643076c28179fe1a265af7d8f1b87/onnxruntime-1.31.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4d5f5fcc01f0c334d282a8991eab44e7752ad01c47b336d848ba43eb38084958", size = 23772582, upload-time = "2026-10-26T19:27:12.025Z" }, -] - -[[package]] -name = "opencv-python-headless" -version = "5.0.0.93" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, - { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, - { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, - { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, - { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, -] - [[package]] name = "openpyxl" version = "3.1.5" @@ -879,39 +659,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ] -[[package]] -name = "protobuf" -version = "7.36.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, - { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, - { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, -] - -[[package]] -name = "pyclipper" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/20/04d58c70f3ccd404f179f8dd81d16722a05a3bf1ab61445ee64e8218c1f8/pyclipper-1.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c", size = 265167, upload-time = "2025-12-01T13:15:20.844Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/a570c1abe69b7260ca0caab4236ce6ea3661193ebf8d1bd7f78ccce537a5/pyclipper-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e", size = 139966, upload-time = "2025-12-01T13:15:22.036Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/e0859e54adabdde8a24a29d3f525ebb31c71ddf2e8d93edce83a3c212ffc/pyclipper-1.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9", size = 968216, upload-time = "2025-12-01T13:15:23.18Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6b/e3c4febf0a35ae643ee579b09988dd931602b5bf311020535fd9e5b7e715/pyclipper-1.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39", size = 954198, upload-time = "2025-12-01T13:15:24.522Z" }, - { url = "https://files.pythonhosted.org/packages/fc/74/728efcee02e12acb486ce9d56fa037120c9bf5b77c54bbdbaa441c14a9d9/pyclipper-1.4.0-cp314-cp314-win32.whl", hash = "sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b", size = 96951, upload-time = "2025-12-01T13:15:25.79Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d7/7f4354e69f10a917e5c7d5d72a499ef2e10945312f5e72c414a0a08d2ae4/pyclipper-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826", size = 106782, upload-time = "2025-12-01T13:15:26.945Z" }, - { url = "https://files.pythonhosted.org/packages/63/60/fc32c7a3d7f61a970511ec2857ecd09693d8ac80d560ee7b8e67a6d268c9/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303", size = 269880, upload-time = "2025-12-01T13:15:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/49/df/c4a72d3f62f0ba03ec440c4fff56cd2d674a4334d23c5064cbf41c9583f6/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9", size = 141706, upload-time = "2025-12-01T13:15:30.134Z" }, - { url = "https://files.pythonhosted.org/packages/c5/0b/cf55df03e2175e1e2da9db585241401e0bc98f76bee3791bed39d0313449/pyclipper-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286", size = 105308, upload-time = "2025-12-01T13:15:31.225Z" }, - { url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608, upload-time = "2025-12-01T13:15:32.69Z" }, -] - [[package]] name = "pydantic" version = "2.14.1" @@ -1121,42 +868,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "rapidocr" -version = "3.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorlog" }, - { name = "numpy" }, - { name = "omegaconf" }, - { name = "opencv-python-headless" }, - { name = "pillow" }, - { name = "pyclipper" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "shapely" }, - { name = "six" }, - { name = "tqdm" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/07/203065283bba0c5e9c10af3602dc25ab3e59ee54dc2d1d22c8faeabf5ed8/rapidocr-3.10.0-py3-none-any.whl", hash = "sha256:2fc34e26cd0f48514804a94f8832283039c7334076bff60768b1fed9ad068050", size = 27288134, upload-time = "2026-10-08T15:07:45.98Z" }, -] - -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - [[package]] name = "ruff" version = "0.17.2" @@ -1182,62 +893,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, ] -[[package]] -name = "shapely" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/ab/924b6e202f796d270a3041a230151f7908db5ea48c74effe6f8023e9bd05/shapely-2.2.0.tar.gz", hash = "sha256:e8865e553d874a1ec4a032057ea81fca9def37b188cd8fb550af3b3480b3f88c", size = 380326, upload-time = "2026-10-07T09:18:01.001Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/a9/83531b7a5349568c507c5701179b1727e21f238af318ac55ba8d0800e764/shapely-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:000c0ce2a3ba49427e6288b7add9de5d8525d4e65d6ebc8840103040d4d57b86", size = 1785489, upload-time = "2026-10-07T09:16:48.795Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/e8117528eb96feafcd5fced50a939ecfdc6242b3782959d202755536bb9e/shapely-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a63e6b68ec785ef3aae3935c4aa9fb8edccced94e23c79d5d85276442c60859", size = 1585345, upload-time = "2026-10-07T09:16:50.527Z" }, - { url = "https://files.pythonhosted.org/packages/53/66/289a7055e3a383680771ba59764712db822fa406bbf52971a76e51d160d6/shapely-2.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:770d4db5cf0bfeed931a1c4aaf4f4eadad0f43f5fc72c27c88fe1f07904ae767", size = 2177801, upload-time = "2026-10-07T09:16:52.393Z" }, - { url = "https://files.pythonhosted.org/packages/d2/54/8f3d60050a703dcab48f7991ec4fb111772731d20ce6a1bf649465033476/shapely-2.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74f4313af38d6e49ea83532d6cedfb4fe5e6c5485d7c40202bd61b19d6ff09bf", size = 2299593, upload-time = "2026-10-07T09:16:54.462Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ec/3389afd3919494f479347a83db7b5672c3c73a339173426a902d9d295152/shapely-2.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9ee11aeba1759d15a525ded58e17916d3edfa60d52110fd8df6a7609a871f066", size = 3246048, upload-time = "2026-10-07T09:16:56.477Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b5/d0d4e3eaf232425a11be7af1a24aaf9c6792bc7a17dd17d722e42892f91a/shapely-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24b175c570efc91d1180ac6cd527dc80e863bb7de37f8b2771703d822c65e023", size = 3389241, upload-time = "2026-10-07T09:16:59.055Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3d/b9626c58982a3cf4278ad978644c7a315968c7e03cd7a8a6aaadde911074/shapely-2.2.0-cp314-cp314-win32.whl", hash = "sha256:4e5830637c080bdc646c5982ad6f7cc296b93038879649f7a6acd8e0f1c4db04", size = 1658287, upload-time = "2026-10-07T09:17:00.857Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/b3acc1c764dff7e47485dd47fc7ff5fdc230257f02006fec049bf2b9449c/shapely-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:48dd1d961391f314ab7fa8812c86ca2a727bee2bdca1478730eacaea007da18e", size = 1843568, upload-time = "2026-10-07T09:17:02.662Z" }, - { url = "https://files.pythonhosted.org/packages/53/12/3b4977cec6bbaee5d4538d8fbd8ffc75bf764fe3519aafb09d5eefb41daa/shapely-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c4127c064bc71f8b7f9b3f341d6627ed39977fd0b61a17c68d09179f5e0089ae", size = 1936825, upload-time = "2026-10-07T09:17:04.886Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0c/8a8f59e344dc3b53c77d99e35e3eb81b8c46cceeb3ffcd44b41ed8d17d7e/shapely-2.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c2915ae1b858e73d5832be7fb5e89497cc5140fa505da40a45223029dc6deace", size = 1793948, upload-time = "2026-10-07T09:17:07.07Z" }, - { url = "https://files.pythonhosted.org/packages/af/1e/76728b192507909866d7eaa398c9a558d326ca9a7dd14bc929362cce99c9/shapely-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:74028f468e05e461b30a479b08c1fb5094fa45062abeeec8e7905a6711761436", size = 1596025, upload-time = "2026-10-07T09:17:09.141Z" }, - { url = "https://files.pythonhosted.org/packages/11/be/e4b4219ab6414fe17f60d064693f31d5acd25bd8c98def75c4c2a1108b18/shapely-2.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ec5178a39803fa8626322f69d298037f182461dd28e3ae96c2c7a4309a6bf30", size = 2177914, upload-time = "2026-10-07T09:17:11.011Z" }, - { url = "https://files.pythonhosted.org/packages/f4/36/c007a564ddfeda1aff3c79435d4beb2c0baf41b37d1ec212269ef62f8da8/shapely-2.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593e51cd04fe1122f1ab3fae87b306c36b2be0184a5e0d9c26849c55ff4580dc", size = 2297494, upload-time = "2026-10-07T09:17:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/dd289ba822b8c50a2b47dc70f87a6f4933a6402f5fe4cc5cb999fdf1ea4a/shapely-2.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3575a323b7665d7a2e391b16a626caa6b6f6348f399183aca3fc656febd7cf04", size = 3245678, upload-time = "2026-10-07T09:17:15.165Z" }, - { url = "https://files.pythonhosted.org/packages/04/d8/bd58de9c4f325369bbc7edc4f7cce1a56cfab61e2b1c534176ea58d89db4/shapely-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:776cc8571d53e42be8fa6d42ad52a599b8e2186dd0c752922831508099af71e2", size = 3387773, upload-time = "2026-10-07T09:17:17.685Z" }, - { url = "https://files.pythonhosted.org/packages/69/4a/6d6e41cab51bb8aa1256ddc28d94d74d016683312e6e0e874afc8b874f3b/shapely-2.2.0-cp314-cp314t-win32.whl", hash = "sha256:f8cd733a66a2a10f461a70dde9fad7b2b62c6a48c7a66cea57ee6f1cd9f2bd2f", size = 1676626, upload-time = "2026-10-07T09:17:19.523Z" }, - { url = "https://files.pythonhosted.org/packages/22/06/6ab21f86fc08aa95b6eb3dc8f8d64701359ce89aae471c15b896e5be5afe/shapely-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7f68c1fbacab81c0c066d1c3051eeb0f680b7a7a2c511e741f77741640187896", size = 1868388, upload-time = "2026-10-07T09:17:21.376Z" }, - { url = "https://files.pythonhosted.org/packages/07/85/5c0452ee08cfd72b8945ac26fbd5ae559a7af7184aa989a0d83f68f07cf9/shapely-2.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9147ebc3b116a0511dca043937f85caf1a41690815643d5b89c8bc472f51c850", size = 1946897, upload-time = "2026-10-07T09:17:23.413Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d0/c994c26df87119e530b715f7109960036242861dba37db17a1b9f44b6e56/shapely-2.2.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:715561ceda03b09ca1c6baf9922179392d8c2bc53a1b877965225f0dfb487a58", size = 1785017, upload-time = "2026-10-07T09:17:25.31Z" }, - { url = "https://files.pythonhosted.org/packages/0b/60/2a8975ee00697cb33b17e140def38f2600323760e52eaa6423183a522f06/shapely-2.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:556f20346a7d96fefbb71b74640d84ca14041703d60f0d2ff47b29d9b3e0093d", size = 1585001, upload-time = "2026-10-07T09:17:27.622Z" }, - { url = "https://files.pythonhosted.org/packages/26/07/45cd192ede49dd821c804fd53177ba5fa2739867ceeb542cfeb259ca4314/shapely-2.2.0-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff9e87b534edf35af65758fafb31ad3b797354cba9323899e263f450c69a2ff2", size = 2177659, upload-time = "2026-10-07T09:17:29.501Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7f/55a7f6ae91c10aa58005e985d01756048b6e4ff82e731ea39003e1eeda3e/shapely-2.2.0-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdb599ec540cea5b635ac47bf24fca4cdfd1c39730ffc0b6cf0d2666b0dd9a33", size = 2299128, upload-time = "2026-10-07T09:17:31.352Z" }, - { url = "https://files.pythonhosted.org/packages/58/2f/49eb352f7c0c0c6ec17bc0bee33f9f449d397f8bfc9c2694c384e752f25e/shapely-2.2.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b8cb04906b74db26f848f76744fa995cd6abeae9145d27cc405277de1f949660", size = 3245354, upload-time = "2026-10-07T09:17:33.291Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c6/3f4f736d615013b2c117cec4d716e775659b2452bb039643c0411d131750/shapely-2.2.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d9b11d712ac72f1d869f2b6964dea5bd9f20b89901adcd796d6712496144ab22", size = 3388587, upload-time = "2026-10-07T09:17:35.261Z" }, - { url = "https://files.pythonhosted.org/packages/27/ea/cb26677d3e34e1663a00a1395fc17c4a2acb5fef638297f94d5cdb9a63f0/shapely-2.2.0-cp315-cp315-win32.whl", hash = "sha256:1af6935acde1db0b6a1bcbea30cbad5ae900723dfd398367ae1488470dc53667", size = 1658324, upload-time = "2026-10-07T09:17:37.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/7d/351c43d812b94197fe279dc3e0defc6886e4be5a144fc191b8635b0fd839/shapely-2.2.0-cp315-cp315-win_amd64.whl", hash = "sha256:96e5101ad2d73df869255bae4c55537f372d32066e2328c376e09841f0f66800", size = 1843506, upload-time = "2026-10-07T09:17:39.336Z" }, - { url = "https://files.pythonhosted.org/packages/82/de/9b62659a23fe8b9d590cf8e4698051d8eab5c5cdddfd86c531019e9d0d2d/shapely-2.2.0-cp315-cp315-win_arm64.whl", hash = "sha256:446b2d5a323bddd1c2a27f41325fdb3a3e8e33c1f8f0f840bdb63e8c1515b29e", size = 1936521, upload-time = "2026-10-07T09:17:41.121Z" }, - { url = "https://files.pythonhosted.org/packages/91/c9/5e16b2ac8853ec587406496a85cbeb1f3465b53c5e8bf0f222b4a39a44a1/shapely-2.2.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c88b21a0e9599ebb741e08f71a95c8f07a434af909efb088828a9874d234d06d", size = 1792605, upload-time = "2026-10-07T09:17:43.211Z" }, - { url = "https://files.pythonhosted.org/packages/19/87/ebaf70f25565ed82d75ab84b1d0eb3a8b803302020c16bb98234f67c0477/shapely-2.2.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:cbe184e1946cfe115a9dfeadd2effd88ab4a237ab1a4335d106defa80fbc2d82", size = 1594776, upload-time = "2026-10-07T09:17:44.972Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a1/e6210ff8aa7d065c2a94d2a3486342675bcb3f4d740ec686a414ace0c3b9/shapely-2.2.0-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bc985ad731da2f2cedde9c3cfb3c3d946fe6fc63d2ca557673dc33dd1e389b9", size = 2175890, upload-time = "2026-10-07T09:17:46.92Z" }, - { url = "https://files.pythonhosted.org/packages/96/19/4df2a474cdc24beb06ef557d433dbe936fa274600d4846ea19877ae47094/shapely-2.2.0-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3caa4c6308e7eaf18f4661134a1575eb290a56df78d0ae1b02f919a4cc7bd9d", size = 2296432, upload-time = "2026-10-07T09:17:48.895Z" }, - { url = "https://files.pythonhosted.org/packages/c5/29/2b38bbe8b9b2dba0838b7718819e8751490e3d946cff232049d0e707f98e/shapely-2.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2fd87e55d7a7d310553b527378545cdc6ef8702473ed9294926b892c3cfb2ba0", size = 3243397, upload-time = "2026-10-07T09:17:50.885Z" }, - { url = "https://files.pythonhosted.org/packages/e8/1c/5430d8d6559c944ac673984f25989121abfd2bac4254ec3fff1d7673b7bc/shapely-2.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7416db8ff3a1003687d4118e741343b3cf9ac2a4a925a59d44d98a865ac4e9e7", size = 3386299, upload-time = "2026-10-07T09:17:52.969Z" }, - { url = "https://files.pythonhosted.org/packages/5b/00/feaeb392e96063717387ec09e6c8e38b29fe4088ddfe976470255c69792e/shapely-2.2.0-cp315-cp315t-win32.whl", hash = "sha256:778421a19085bef1fb38bc0699db1ee9b08fdd0e30a8768788d601a4371f2de0", size = 1675823, upload-time = "2026-10-07T09:17:55.022Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/0b77618f33fecbc2209c767cecca58bbf83947fc0018571542bf2b859865/shapely-2.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:287ec7602f7a114b862ae0123880e57160cebe059843a4c7028aaee9e74287f6", size = 1866197, upload-time = "2026-10-07T09:17:56.991Z" }, - { url = "https://files.pythonhosted.org/packages/06/2b/9837e94408335520f778b09067fced0a5d4b2feffa5ebf7119412eb18b00/shapely-2.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:e414c78bc81aadd76a429111a350f4ef3d05fc13019805617b524951258468e5", size = 1945749, upload-time = "2026-10-07T09:17:59.116Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "soupsieve" version = "4.0.1" @@ -1247,18 +902,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/5c/4f68370847b561a389dd9a2d7eb3d19fd8cbb8acbae543a0c6b8d0d9700b/soupsieve-4.0.1-py3-none-any.whl", hash = "sha256:2e68cf7dd5a0f29d81f9d485275ccf294e6475f615e211895d91cb81f8614f04", size = 50688, upload-time = "2026-11-04T16:36:26.158Z" }, ] -[[package]] -name = "tqdm" -version = "4.70.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, -] - [[package]] name = "typeguard" version = "4.6.0" @@ -1346,15 +989,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] -[[package]] -name = "urllib3" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, -] - [[package]] name = "xlrd" version = "2.0.2" diff --git a/docs/development.md b/docs/development.md index 25aef0b..f9ffd1c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,10 +42,10 @@ crawler/ 抓取节点 | | `reminders` | 订阅、关注、邮件、群汇总 | | | `review` | 反馈、修改申请、修改记录和撤销、争议、操作日志 | | | `agents` | MCP 服务和 `/llms.txt` | -| 抓取节点 | `crawl` | 领活、抓列表页和正文、下载附件、交结果 | -| | `parse` | 从网页和附件里取出文字、链接、附件和日期写法,文字识别 | +| 抓取节点 | `crawl` | 领活、抓列表页和正文、下载附件、存下读过的图片、交结果 | +| | `parse` | 从网页和附件里取出文字、链接、附件和日期写法,把图片切成小块 | | | `screening` | 初筛:调用判别接口 | -| | `extraction` | 抽取和证据校验:调用 LLM | +| | `extraction` | 抽取和证据校验,读图片里的文字:调用 LLM | - 网站和抓取节点只通过[抓取节点接口](api.md#6-抓取节点)交接:不共享数据库,不互相引用代码。接口的类型由网站的定义生成,抓取节点不手写([总体设计 §5.1](design.md#51-选定的方案)):改了接口的 Zod 定义,运行 `pnpm openapi` 和 `uv run datamodel-codegen`,两边的 CI 都检查生成的文件是最新的。 - 抓取节点交的请求里,没有的值不写这个键,不传 `null`:Zod 用 `.optional()`,抓取节点用 Pydantic 的 `model_dump(mode="json", exclude_none=True)`。网站返回的空值写成 `null`;引用了命名 schema 的可空字段写成 `z.union([Schema, z.null()])`,`.nullable()` 会把空值并进那个 schema 本身。