primarysource 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""RFC Editor subject taxonomy (https://www.rfc-editor.org/subjects/).
|
|
2
|
+
|
|
3
|
+
The RFC Editor curates ~640 nested subjects ("web > http > http2") and
|
|
4
|
+
assigns every document to one or more of them. The taxonomy is only
|
|
5
|
+
published as HTML, so we mirror it into two tables:
|
|
6
|
+
|
|
7
|
+
subjects(slug, name, parent, description, depth, doc_count)
|
|
8
|
+
the tree as shown on /subjects/ (doc_count = upstream count,
|
|
9
|
+
includes descendants)
|
|
10
|
+
doc_subjects(doc_id, slug)
|
|
11
|
+
per-document assignments mined from each /subjects/<slug>/ page
|
|
12
|
+
|
|
13
|
+
`sync()` fetches the index page plus one page per subject (rate-limited,
|
|
14
|
+
HTML cached under meta/subjects/ so re-parses are offline-safe) and
|
|
15
|
+
rebuilds both tables atomically. Query helpers back the MCP browsing
|
|
16
|
+
tools (list_subjects / get_subject / related).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
import time
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from html.parser import HTMLParser
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from .config import BASE_URL, USER_AGENT, Paths, require_network
|
|
33
|
+
|
|
34
|
+
SUBJECTS_URL = f"{BASE_URL}/subjects/"
|
|
35
|
+
|
|
36
|
+
SCHEMA = """
|
|
37
|
+
CREATE TABLE IF NOT EXISTS subjects(
|
|
38
|
+
slug TEXT PRIMARY KEY,
|
|
39
|
+
name TEXT NOT NULL,
|
|
40
|
+
parent TEXT, -- parent slug, NULL = top level
|
|
41
|
+
description TEXT,
|
|
42
|
+
depth INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
doc_count INTEGER, -- upstream count (incl. descendants)
|
|
44
|
+
ord INTEGER -- upstream tree order for browsing
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_subjects_parent ON subjects(parent);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS doc_subjects(
|
|
49
|
+
doc_id TEXT NOT NULL,
|
|
50
|
+
slug TEXT NOT NULL,
|
|
51
|
+
PRIMARY KEY (doc_id, slug)
|
|
52
|
+
);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_docsubj_slug ON doc_subjects(slug, doc_id);
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_TREE_UL_CLASS = "ml-6 border-l-1" # nested child-subject lists in the index
|
|
57
|
+
_SUBJECT_HREF = re.compile(r"^/subjects/([^/]+)/?$")
|
|
58
|
+
_INFO_HREF = re.compile(r"/info/([A-Za-z]{2,4}\d+)/?")
|
|
59
|
+
_SERIES_ID = re.compile(r"^(rfc|bcp|std|fyi|ien)(\d{1,5})$", re.I)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SubjectNotFound(Exception):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_schema(conn: sqlite3.Connection) -> None:
|
|
67
|
+
# Look before writing: this runs on read paths (info, browse, search),
|
|
68
|
+
# where a read-only server must not need a write, and executescript
|
|
69
|
+
# would also commit whatever transaction the caller has open.
|
|
70
|
+
cols = [r[1] for r in conn.execute("PRAGMA table_info(subjects)")]
|
|
71
|
+
if "ord" in cols and conn.execute(
|
|
72
|
+
"SELECT 1 FROM sqlite_master WHERE name='idx_docsubj_slug'"
|
|
73
|
+
).fetchone():
|
|
74
|
+
return
|
|
75
|
+
conn.executescript(SCHEMA)
|
|
76
|
+
cols = [r[1] for r in conn.execute("PRAGMA table_info(subjects)")]
|
|
77
|
+
if "ord" not in cols: # migration: pre-order column for tree browsing
|
|
78
|
+
conn.execute("ALTER TABLE subjects ADD COLUMN ord INTEGER")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def subjects_ready(conn: sqlite3.Connection) -> bool:
|
|
82
|
+
row = conn.execute(
|
|
83
|
+
"SELECT COUNT(*) c FROM sqlite_master WHERE type='table' "
|
|
84
|
+
"AND name='subjects'").fetchone()
|
|
85
|
+
if not row or not row["c"]:
|
|
86
|
+
return False
|
|
87
|
+
return conn.execute("SELECT COUNT(*) c FROM subjects").fetchone()["c"] > 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------
|
|
92
|
+
# HTML parsing
|
|
93
|
+
|
|
94
|
+
class _IndexParser(HTMLParser):
|
|
95
|
+
"""Parse the /subjects/ tree page into subject rows.
|
|
96
|
+
|
|
97
|
+
Only <ul> elements whose class contains the distinctive
|
|
98
|
+
'ml-6 border-l-1' marker (the nested child lists) count toward
|
|
99
|
+
nesting depth, so unrelated page <ul>s are ignored. Each subject
|
|
100
|
+
entry is <a href="/slug/">NAME</a> <span>NN RFCs</span>
|
|
101
|
+
<p class="inline"> : description</p> in document order; the nested
|
|
102
|
+
child <ul> (if any) follows the parent's description inside the
|
|
103
|
+
parent <li>. Vue comment markers (<!--[-->...) split text runs, so
|
|
104
|
+
names/descriptions are buffered until the closing tag.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
super().__init__(convert_charrefs=True)
|
|
109
|
+
self.rows: list[dict] = []
|
|
110
|
+
self._ul_stack: list[bool] = []
|
|
111
|
+
self._depth = 0
|
|
112
|
+
self._last_at_depth: dict[int, Optional[str]] = {}
|
|
113
|
+
self._cur: Optional[dict] = None
|
|
114
|
+
self._in_name = False
|
|
115
|
+
self._in_count = False
|
|
116
|
+
self._in_desc = False
|
|
117
|
+
self._buf = ""
|
|
118
|
+
|
|
119
|
+
def _flush(self) -> None:
|
|
120
|
+
if self._cur is not None:
|
|
121
|
+
self.rows.append(self._cur)
|
|
122
|
+
self._cur = None
|
|
123
|
+
|
|
124
|
+
def handle_starttag(self, tag, attrs):
|
|
125
|
+
a = dict(attrs)
|
|
126
|
+
if tag == "ul":
|
|
127
|
+
is_tree = _TREE_UL_CLASS in (a.get("class") or "")
|
|
128
|
+
self._ul_stack.append(is_tree)
|
|
129
|
+
if is_tree:
|
|
130
|
+
self._depth += 1
|
|
131
|
+
elif tag == "a":
|
|
132
|
+
m = _SUBJECT_HREF.match(a.get("href") or "")
|
|
133
|
+
if m:
|
|
134
|
+
self._flush()
|
|
135
|
+
slug = m.group(1).lower()
|
|
136
|
+
parent = self._last_at_depth.get(self._depth - 1) \
|
|
137
|
+
if self._depth > 0 else None
|
|
138
|
+
self._last_at_depth[self._depth] = slug
|
|
139
|
+
self._cur = {"slug": slug, "name": "", "parent": parent,
|
|
140
|
+
"depth": self._depth, "description": None,
|
|
141
|
+
"doc_count": None}
|
|
142
|
+
self._in_name, self._buf = True, ""
|
|
143
|
+
elif tag == "span" and self._cur is not None and not self._in_name:
|
|
144
|
+
self._in_count, self._buf = True, ""
|
|
145
|
+
elif tag == "p" and self._cur is not None \
|
|
146
|
+
and (a.get("class") or "") == "inline":
|
|
147
|
+
self._in_desc, self._buf = True, ""
|
|
148
|
+
|
|
149
|
+
def handle_endtag(self, tag):
|
|
150
|
+
if tag == "ul" and self._ul_stack:
|
|
151
|
+
if self._ul_stack.pop():
|
|
152
|
+
self._depth -= 1
|
|
153
|
+
elif tag == "a" and self._in_name and self._cur is not None:
|
|
154
|
+
self._cur["name"] = " ".join(self._buf.split())
|
|
155
|
+
self._in_name = False
|
|
156
|
+
elif tag == "span" and self._in_count and self._cur is not None:
|
|
157
|
+
m = re.match(r"(\d+)", self._buf.strip())
|
|
158
|
+
if m:
|
|
159
|
+
self._cur["doc_count"] = int(m.group(1))
|
|
160
|
+
self._in_count = False
|
|
161
|
+
elif tag == "p" and self._in_desc:
|
|
162
|
+
if self._cur is not None:
|
|
163
|
+
desc = " ".join(self._buf.split())
|
|
164
|
+
self._cur["description"] = re.sub(r"^:\s*", "", desc) or None
|
|
165
|
+
self._in_desc = False
|
|
166
|
+
|
|
167
|
+
def handle_data(self, data):
|
|
168
|
+
if self._in_name or self._in_count or self._in_desc:
|
|
169
|
+
self._buf += data
|
|
170
|
+
|
|
171
|
+
def close(self):
|
|
172
|
+
super().close()
|
|
173
|
+
self._flush()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_index(html_text: str) -> list[dict]:
|
|
177
|
+
p = _IndexParser()
|
|
178
|
+
p.feed(html_text)
|
|
179
|
+
p.close()
|
|
180
|
+
seen: set[str] = set()
|
|
181
|
+
out = []
|
|
182
|
+
for r in p.rows:
|
|
183
|
+
if r["slug"] not in seen: # tree order defines hierarchy
|
|
184
|
+
seen.add(r["slug"])
|
|
185
|
+
out.append(r)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_subject_docs(html_text: str) -> list[str]:
|
|
190
|
+
"""Normalized doc_ids listed on a /subjects/<slug>/ page."""
|
|
191
|
+
out: list[str] = []
|
|
192
|
+
seen: set[str] = set()
|
|
193
|
+
for m in _INFO_HREF.finditer(html_text):
|
|
194
|
+
mm = _SERIES_ID.match(m.group(1))
|
|
195
|
+
if not mm:
|
|
196
|
+
continue
|
|
197
|
+
norm = mm.group(1).upper() + mm.group(2)
|
|
198
|
+
if norm not in seen:
|
|
199
|
+
seen.add(norm)
|
|
200
|
+
out.append(norm)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------
|
|
206
|
+
# sync
|
|
207
|
+
|
|
208
|
+
def _subject_dir(paths: Paths) -> Path:
|
|
209
|
+
d = paths.meta / "subjects"
|
|
210
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
return d
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _get(client: httpx.Client, url: str) -> str:
|
|
215
|
+
require_network(f"the RFC Editor subject page {url}")
|
|
216
|
+
r = client.get(url)
|
|
217
|
+
r.raise_for_status()
|
|
218
|
+
return r.text
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def sync(conn: sqlite3.Connection, paths: Paths, force: bool = False,
|
|
222
|
+
delay: float = 0.1, limit: int = 0,
|
|
223
|
+
progress: Optional[Callable[[str], None]] = None) -> dict:
|
|
224
|
+
"""Mirror the subject taxonomy: index page + one page per subject.
|
|
225
|
+
|
|
226
|
+
limit>0 restricts to the first N subjects (smoke runs; the index is
|
|
227
|
+
always fetched). Cached HTML under meta/subjects/ is reused unless
|
|
228
|
+
force=True. Both tables are rebuilt atomically at the end."""
|
|
229
|
+
import httpx # lazy: serving from an existing library never needs it
|
|
230
|
+
say = progress or (lambda m: None)
|
|
231
|
+
ensure_schema(conn)
|
|
232
|
+
cache = _subject_dir(paths)
|
|
233
|
+
|
|
234
|
+
index_path = cache / "_index.html"
|
|
235
|
+
if force or not index_path.exists():
|
|
236
|
+
say("fetching subject index ...")
|
|
237
|
+
with httpx.Client(headers={"User-Agent": USER_AGENT},
|
|
238
|
+
follow_redirects=True, timeout=60.0) as client:
|
|
239
|
+
index_path.write_text(_get(client, SUBJECTS_URL), "utf-8")
|
|
240
|
+
rows = parse_index(index_path.read_text("utf-8"))
|
|
241
|
+
if not rows:
|
|
242
|
+
raise RuntimeError(
|
|
243
|
+
"subject index parsed to 0 rows - upstream layout may have "
|
|
244
|
+
"changed; check data/meta/subjects/_index.html")
|
|
245
|
+
if limit > 0:
|
|
246
|
+
rows = rows[:limit]
|
|
247
|
+
say(f"{len(rows)} subjects to crawl")
|
|
248
|
+
|
|
249
|
+
doc_map: dict[str, list[str]] = {}
|
|
250
|
+
failed: list[str] = []
|
|
251
|
+
with httpx.Client(headers={"User-Agent": USER_AGENT},
|
|
252
|
+
follow_redirects=True, timeout=60.0) as client:
|
|
253
|
+
for i, r in enumerate(rows, 1):
|
|
254
|
+
page = cache / f"{r['slug']}.html"
|
|
255
|
+
try:
|
|
256
|
+
if force or not page.exists():
|
|
257
|
+
page.write_text(
|
|
258
|
+
_get(client, f"{BASE_URL}/subjects/{r['slug']}/"),
|
|
259
|
+
"utf-8")
|
|
260
|
+
time.sleep(delay)
|
|
261
|
+
doc_map[r["slug"]] = parse_subject_docs(
|
|
262
|
+
page.read_text("utf-8"))
|
|
263
|
+
except Exception as exc: # noqa: BLE001 - report, keep crawling
|
|
264
|
+
failed.append(f"{r['slug']}:{type(exc).__name__}")
|
|
265
|
+
doc_map[r["slug"]] = []
|
|
266
|
+
if i % 50 == 0:
|
|
267
|
+
say(f"crawled {i}/{len(rows)} subject pages")
|
|
268
|
+
|
|
269
|
+
conn.execute("DELETE FROM subjects")
|
|
270
|
+
conn.execute("DELETE FROM doc_subjects")
|
|
271
|
+
n_assign = 0
|
|
272
|
+
for i, r in enumerate(rows):
|
|
273
|
+
conn.execute(
|
|
274
|
+
"INSERT INTO subjects(slug,name,parent,description,depth,"
|
|
275
|
+
"doc_count,ord) VALUES(?,?,?,?,?,?,?)",
|
|
276
|
+
(r["slug"], r["name"], r["parent"], r["description"],
|
|
277
|
+
r["depth"], r["doc_count"], i))
|
|
278
|
+
for doc_id in doc_map.get(r["slug"], []):
|
|
279
|
+
conn.execute(
|
|
280
|
+
"INSERT OR IGNORE INTO doc_subjects(doc_id,slug) VALUES(?,?)",
|
|
281
|
+
(doc_id, r["slug"]))
|
|
282
|
+
n_assign += 1
|
|
283
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
284
|
+
conn.execute(
|
|
285
|
+
"INSERT INTO meta(key,value) VALUES(?,?) "
|
|
286
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
287
|
+
("subjects_synced_at", now))
|
|
288
|
+
conn.commit()
|
|
289
|
+
distinct = conn.execute(
|
|
290
|
+
"SELECT COUNT(DISTINCT doc_id) c FROM doc_subjects").fetchone()["c"]
|
|
291
|
+
return {"subjects": len(rows), "assignments": n_assign,
|
|
292
|
+
"distinct_docs": distinct, "failed": failed}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --------------------------------------------------------------------------
|
|
297
|
+
# queries
|
|
298
|
+
|
|
299
|
+
def _subtree_slugs(conn: sqlite3.Connection, slug: str) -> list[str]:
|
|
300
|
+
"""slug plus all descendant slugs (iterative BFS, depth-safe)."""
|
|
301
|
+
out = [slug]
|
|
302
|
+
frontier = [slug]
|
|
303
|
+
while frontier:
|
|
304
|
+
ph = ",".join("?" * len(frontier))
|
|
305
|
+
kids = [r["slug"] for r in conn.execute(
|
|
306
|
+
f"SELECT slug FROM subjects WHERE parent IN ({ph})", frontier)]
|
|
307
|
+
kids = [k for k in kids if k not in out]
|
|
308
|
+
out.extend(kids)
|
|
309
|
+
frontier = kids
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def resolve_subject(conn: sqlite3.Connection, name: str) -> str:
|
|
314
|
+
"""Exact slug > exact name > unique substring. Raises SubjectNotFound."""
|
|
315
|
+
ensure_schema(conn)
|
|
316
|
+
s = name.strip().lower()
|
|
317
|
+
row = conn.execute(
|
|
318
|
+
"SELECT slug FROM subjects WHERE slug=?", (s,)).fetchone()
|
|
319
|
+
if row:
|
|
320
|
+
return row["slug"]
|
|
321
|
+
row = conn.execute(
|
|
322
|
+
"SELECT slug FROM subjects WHERE lower(name)=? LIMIT 1",
|
|
323
|
+
(s,)).fetchone()
|
|
324
|
+
if row:
|
|
325
|
+
return row["slug"]
|
|
326
|
+
like = f"%{s}%"
|
|
327
|
+
rows = conn.execute(
|
|
328
|
+
"SELECT slug, name FROM subjects WHERE slug LIKE ? OR lower(name)"
|
|
329
|
+
" LIKE ? ORDER BY depth LIMIT 6", (like, like)).fetchall()
|
|
330
|
+
if len(rows) == 1:
|
|
331
|
+
return rows[0]["slug"]
|
|
332
|
+
if rows:
|
|
333
|
+
opts = ", ".join(f"{r['slug']} ({r['name']})" for r in rows)
|
|
334
|
+
raise SubjectNotFound(
|
|
335
|
+
f"subject {name!r} is ambiguous; candidates: {opts}")
|
|
336
|
+
raise SubjectNotFound(f"no subject matches {name!r}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def list_subjects(conn: sqlite3.Connection, contains: str | None = None,
|
|
340
|
+
limit: int = 700) -> list[dict]:
|
|
341
|
+
ensure_schema(conn)
|
|
342
|
+
limit = max(1, min(limit, 700))
|
|
343
|
+
if contains:
|
|
344
|
+
like = f"%{contains.strip().lower()}%"
|
|
345
|
+
rows = conn.execute(
|
|
346
|
+
"""SELECT slug, name, parent, depth, doc_count, description
|
|
347
|
+
FROM subjects
|
|
348
|
+
WHERE lower(slug) LIKE ? OR lower(name) LIKE ?
|
|
349
|
+
OR lower(COALESCE(description,'')) LIKE ?
|
|
350
|
+
ORDER BY ord LIMIT ?""",
|
|
351
|
+
(like, like, like, limit)).fetchall()
|
|
352
|
+
else:
|
|
353
|
+
rows = conn.execute(
|
|
354
|
+
"""SELECT slug, name, parent, depth, doc_count, description
|
|
355
|
+
FROM subjects ORDER BY ord LIMIT ?""",
|
|
356
|
+
(limit,)).fetchall()
|
|
357
|
+
return [dict(r) for r in rows]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def get_subject(conn: sqlite3.Connection, name: str,
|
|
361
|
+
include_obsolete: bool = False, limit: int = 50,
|
|
362
|
+
offset: int = 0) -> dict:
|
|
363
|
+
"""Grouped listing for one subject incl. descendant subjects, mirroring
|
|
364
|
+
the upstream /subjects/<slug>/ page but with local status/date data."""
|
|
365
|
+
limit = max(1, min(limit, 200))
|
|
366
|
+
offset = max(0, offset)
|
|
367
|
+
slug = resolve_subject(conn, name)
|
|
368
|
+
info = conn.execute(
|
|
369
|
+
"SELECT * FROM subjects WHERE slug=?", (slug,)).fetchone()
|
|
370
|
+
children = [dict(r) for r in conn.execute(
|
|
371
|
+
"SELECT slug, name, doc_count FROM subjects WHERE parent=? "
|
|
372
|
+
"ORDER BY slug", (slug,))]
|
|
373
|
+
slugs = _subtree_slugs(conn, slug)
|
|
374
|
+
ph = ",".join("?" * len(slugs))
|
|
375
|
+
cond = "" if include_obsolete else "AND d.is_current = 1"
|
|
376
|
+
total = conn.execute(
|
|
377
|
+
f"""SELECT COUNT(DISTINCT ds.doc_id) c FROM doc_subjects ds
|
|
378
|
+
JOIN docs d ON d.doc_id = ds.doc_id
|
|
379
|
+
WHERE ds.slug IN ({ph}) {cond}""", slugs).fetchone()["c"]
|
|
380
|
+
docs = [dict(r) for r in conn.execute(
|
|
381
|
+
f"""SELECT DISTINCT d.doc_id, d.title, d.current_status, d.stream,
|
|
382
|
+
d.pub_date
|
|
383
|
+
FROM doc_subjects ds JOIN docs d ON d.doc_id = ds.doc_id
|
|
384
|
+
WHERE ds.slug IN ({ph}) {cond}
|
|
385
|
+
ORDER BY d.pub_date DESC, d.doc_id DESC
|
|
386
|
+
LIMIT ? OFFSET ?""", [*slugs, limit, offset]).fetchall()]
|
|
387
|
+
return {
|
|
388
|
+
"subject": {"slug": info["slug"], "name": info["name"],
|
|
389
|
+
"parent": info["parent"],
|
|
390
|
+
"description": info["description"],
|
|
391
|
+
"upstream_doc_count": info["doc_count"]},
|
|
392
|
+
"children": children,
|
|
393
|
+
"docs_total": total, "offset": offset, "docs": docs,
|
|
394
|
+
"next_offset": offset + limit if offset + limit < total else None,
|
|
395
|
+
"canonical_url": f"{BASE_URL}/subjects/{slug}/",
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def subjects_of(conn: sqlite3.Connection, doc_id: str) -> list[dict]:
|
|
401
|
+
ensure_schema(conn)
|
|
402
|
+
return [{"slug": r["slug"], "name": r["name"]} for r in conn.execute(
|
|
403
|
+
"SELECT ds.slug, s.name FROM doc_subjects ds "
|
|
404
|
+
"JOIN subjects s ON s.slug = ds.slug "
|
|
405
|
+
"WHERE ds.doc_id = ? ORDER BY s.depth, s.slug", (doc_id,))]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def subjects_map(conn: sqlite3.Connection,
|
|
409
|
+
doc_ids: list[str]) -> dict[str, list[str]]:
|
|
410
|
+
"""doc_id -> [subject names] for a batch (one query; search hits)."""
|
|
411
|
+
ensure_schema(conn)
|
|
412
|
+
if not doc_ids:
|
|
413
|
+
return {}
|
|
414
|
+
out: dict[str, list[str]] = {d: [] for d in doc_ids}
|
|
415
|
+
ph = ",".join("?" * len(doc_ids))
|
|
416
|
+
for r in conn.execute(
|
|
417
|
+
f"""SELECT ds.doc_id, s.name FROM doc_subjects ds
|
|
418
|
+
JOIN subjects s ON s.slug = ds.slug
|
|
419
|
+
WHERE ds.doc_id IN ({ph})
|
|
420
|
+
ORDER BY s.depth, s.slug""", doc_ids):
|
|
421
|
+
out[r["doc_id"]].append(r["name"])
|
|
422
|
+
return out
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def related(conn: sqlite3.Connection, doc_id: str, limit: int = 10) -> dict:
|
|
426
|
+
"""Subject-based neighborhood: docs sharing subjects (ranked by count
|
|
427
|
+
of shared subjects) + sibling subjects popular in that neighborhood.
|
|
428
|
+
Complements get_refs (citation graph) with topical similarity."""
|
|
429
|
+
ensure_schema(conn)
|
|
430
|
+
own = [r["slug"] for r in conn.execute(
|
|
431
|
+
"SELECT slug FROM doc_subjects WHERE doc_id=?", (doc_id,))]
|
|
432
|
+
if not own:
|
|
433
|
+
return {"doc_id": doc_id, "subjects": [], "sibling_subjects": [],
|
|
434
|
+
"nearby_docs": [],
|
|
435
|
+
"note": "no subjects assigned for this doc (or taxonomy "
|
|
436
|
+
"not synced; run `rfclib subjects sync`)"}
|
|
437
|
+
ph = ",".join("?" * len(own))
|
|
438
|
+
siblings = [dict(r) for r in conn.execute(
|
|
439
|
+
f"""SELECT ds.slug, s.name, COUNT(*) AS shares
|
|
440
|
+
FROM doc_subjects ds
|
|
441
|
+
JOIN subjects s ON s.slug = ds.slug
|
|
442
|
+
WHERE ds.slug NOT IN ({ph})
|
|
443
|
+
AND ds.doc_id IN (SELECT doc_id FROM doc_subjects
|
|
444
|
+
WHERE slug IN ({ph}))
|
|
445
|
+
GROUP BY ds.slug ORDER BY shares DESC, ds.slug LIMIT 5""",
|
|
446
|
+
[*own, *own]).fetchall()]
|
|
447
|
+
nearby = [dict(r) for r in conn.execute(
|
|
448
|
+
f"""SELECT d.doc_id, d.title, d.current_status, d.pub_date,
|
|
449
|
+
COUNT(*) AS shared_subjects
|
|
450
|
+
FROM doc_subjects ds
|
|
451
|
+
JOIN docs d ON d.doc_id = ds.doc_id
|
|
452
|
+
WHERE ds.slug IN ({ph}) AND ds.doc_id != ? AND d.is_current = 1
|
|
453
|
+
GROUP BY d.doc_id
|
|
454
|
+
ORDER BY shared_subjects DESC, d.pub_date DESC
|
|
455
|
+
LIMIT ?""", [*own, doc_id, max(1, min(limit, 50))]).fetchall()]
|
|
456
|
+
return {"doc_id": doc_id, "subjects": own,
|
|
457
|
+
"sibling_subjects": siblings, "nearby_docs": nearby}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sync subsystem: manifest, state DB, downloader, mirror scanner."""
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Async incremental downloader against the official RFC Editor HTTP site.
|
|
2
|
+
|
|
3
|
+
The initial bulk pull is expected to come from the official rsync service
|
|
4
|
+
(see scripts/mirror.ps1); this module handles incremental updates and can do
|
|
5
|
+
a full pull on its own if rsync is unavailable. Files land in the same
|
|
6
|
+
rfc-editor.org layout the rsync modules use, so both producers are compatible.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import hashlib
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ..config import BASE_URL, USER_AGENT, require_network
|
|
22
|
+
from ..fsutil import atomic_write_bytes
|
|
23
|
+
from .state import file_path, register_file
|
|
24
|
+
|
|
25
|
+
VALID_FORMATS = ("txt", "html", "xml", "pdf", "json")
|
|
26
|
+
|
|
27
|
+
_DOC_RE = re.compile(r"^(RFC|BCP|STD|FYI|IEN)(\d+)$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def doc_relpath(doc_id: str, fmt: str) -> str | None:
|
|
31
|
+
"""Local mirror-relative path, matching the rfc-editor module layout."""
|
|
32
|
+
m = _DOC_RE.match(doc_id)
|
|
33
|
+
if not m:
|
|
34
|
+
return None
|
|
35
|
+
series, num = m.group(1).lower(), m.group(2)
|
|
36
|
+
return f"{series}/{series}{num}.{fmt}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def doc_url(doc_id: str, fmt: str) -> str | None:
|
|
40
|
+
m = _DOC_RE.match(doc_id)
|
|
41
|
+
if not m:
|
|
42
|
+
return None
|
|
43
|
+
series, num = m.group(1).lower(), m.group(2)
|
|
44
|
+
return f"{BASE_URL}/{series}/{series}{num}.{fmt}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sha256_file(path: Path) -> str:
|
|
48
|
+
h = hashlib.sha256()
|
|
49
|
+
with path.open("rb") as fh:
|
|
50
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
51
|
+
h.update(chunk)
|
|
52
|
+
return h.hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _has_local(conn: sqlite3.Connection, doc_id: str, fmt: str) -> bool:
|
|
56
|
+
row = conn.execute(
|
|
57
|
+
"SELECT path FROM files WHERE doc_id=? AND fmt=?", (doc_id, fmt)
|
|
58
|
+
).fetchone()
|
|
59
|
+
return bool(row) and file_path(conn, row["path"]).exists()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _fetch_one(client: httpx.AsyncClient, sem: asyncio.Semaphore,
|
|
63
|
+
conn: sqlite3.Connection, mirror_root: Path,
|
|
64
|
+
doc_id: str, fmt: str) -> tuple[str, str, str]:
|
|
65
|
+
url = doc_url(doc_id, fmt)
|
|
66
|
+
rel = doc_relpath(doc_id, fmt)
|
|
67
|
+
if url is None or rel is None:
|
|
68
|
+
return doc_id, fmt, "skipped:unknown-id"
|
|
69
|
+
dest = mirror_root / rel
|
|
70
|
+
try:
|
|
71
|
+
async with sem:
|
|
72
|
+
r = await client.get(url)
|
|
73
|
+
if r.status_code == 404:
|
|
74
|
+
return doc_id, fmt, "missing:404"
|
|
75
|
+
r.raise_for_status()
|
|
76
|
+
atomic_write_bytes(dest, r.content)
|
|
77
|
+
register_file(conn, doc_id, fmt, str(dest), sha256_file(dest),
|
|
78
|
+
len(r.content), url, "http")
|
|
79
|
+
return doc_id, fmt, "ok"
|
|
80
|
+
except Exception as exc: # noqa: BLE001 - report, don't crash the batch
|
|
81
|
+
return doc_id, fmt, f"error:{type(exc).__name__}:{exc}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def fetch_docs_async(conn: sqlite3.Connection, mirror_root: Path,
|
|
85
|
+
doc_ids: list[str], formats: list[str],
|
|
86
|
+
concurrency: int = 8,
|
|
87
|
+
force: bool = False) -> list[tuple[str, str, str]]:
|
|
88
|
+
"""Fetch requested formats for doc_ids, skipping ones already on disk."""
|
|
89
|
+
todo: list[tuple[str, str]] = []
|
|
90
|
+
for doc_id in doc_ids:
|
|
91
|
+
for fmt in formats:
|
|
92
|
+
if force or not _has_local(conn, doc_id, fmt):
|
|
93
|
+
todo.append((doc_id, fmt))
|
|
94
|
+
|
|
95
|
+
results: list[tuple[str, str, str]] = []
|
|
96
|
+
if not todo:
|
|
97
|
+
return results
|
|
98
|
+
require_network(f"{todo[0][0]} ({todo[0][1]})" if len(todo) == 1 else
|
|
99
|
+
f"{len(todo)} document files ({todo[0][0]}, ...)")
|
|
100
|
+
|
|
101
|
+
import httpx # lazy: serving from an existing library never needs it
|
|
102
|
+
sem = asyncio.Semaphore(concurrency)
|
|
103
|
+
limits = httpx.Limits(max_connections=concurrency)
|
|
104
|
+
async with httpx.AsyncClient(
|
|
105
|
+
headers={"User-Agent": USER_AGENT},
|
|
106
|
+
follow_redirects=True,
|
|
107
|
+
timeout=httpx.Timeout(60.0),
|
|
108
|
+
limits=limits,
|
|
109
|
+
) as client:
|
|
110
|
+
tasks = [_fetch_one(client, sem, conn, mirror_root, d, f) for d, f in todo]
|
|
111
|
+
done = 0
|
|
112
|
+
for coro in asyncio.as_completed(tasks):
|
|
113
|
+
results.append(await coro)
|
|
114
|
+
done += 1
|
|
115
|
+
if done % 250 == 0:
|
|
116
|
+
conn.commit() # incremental: crash-safe & resumable
|
|
117
|
+
conn.commit()
|
|
118
|
+
return results
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fetch_docs(conn: sqlite3.Connection, mirror_root: Path, doc_ids: list[str],
|
|
122
|
+
formats: list[str], concurrency: int = 8,
|
|
123
|
+
force: bool = False) -> list[tuple[str, str, str]]:
|
|
124
|
+
"""Sync wrapper around fetch_docs_async."""
|
|
125
|
+
return asyncio.run(fetch_docs_async(conn, mirror_root, doc_ids, formats,
|
|
126
|
+
concurrency=concurrency, force=force))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def fetch_doc_sync(conn: sqlite3.Connection, mirror_root: Path, doc_id: str,
|
|
130
|
+
fmt: str) -> tuple[str, str, str]:
|
|
131
|
+
"""On-demand single-document fetch (used by the MCP server)."""
|
|
132
|
+
return fetch_docs(conn, mirror_root, [doc_id], [fmt])
|