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,472 @@
|
|
|
1
|
+
"""The ITU-T catalogue: which recommendations exist, which editions are in force, and in which formats.
|
|
2
|
+
|
|
3
|
+
ITU publishes no bulk download and no machine-readable index of recommendations, so this reads the same public
|
|
4
|
+
HTML pages a person would, politely: one request per second, an identifying User-Agent, a disk cache, and only
|
|
5
|
+
paths that robots.txt does not disallow (`/rec/`). Three page types matter:
|
|
6
|
+
|
|
7
|
+
- the **series listing** (`/rec/T-REC-X/en`): every recommendation number with its title and, for withdrawn
|
|
8
|
+
ones, a `[Withdrawn]` marker;
|
|
9
|
+
- the **recommendation page** (`/rec/T-REC-X.509/en`): its components, in two sections ("In force components",
|
|
10
|
+
"Superseded and Withdrawn components"), each an edition, corrigendum, amendment, erratum or collective file;
|
|
11
|
+
- the **component page** (`/rec/T-REC-X.509-201910-I/en`): the assets of one component, each with a format,
|
|
12
|
+
language, size, posting date and an access class (freely available, or TIES users only).
|
|
13
|
+
|
|
14
|
+
Only metadata is kept: numbers, titles, statuses, sizes, dates and URLs. No recommendation text, and nothing is
|
|
15
|
+
written into the repository: everything lives under `ITULIB_HOME/catalog/`. The parsers are pure functions of
|
|
16
|
+
HTML so they are tested on invented pages; the shapes they expect were read from live pages on 2026-11-07, and a
|
|
17
|
+
page that no longer matches yields an empty result that `sync` reports, never a silent partial catalogue.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import html
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from collections.abc import Callable, Iterable
|
|
30
|
+
from dataclasses import asdict, dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from . import __version__, corpus
|
|
34
|
+
|
|
35
|
+
BASE = "https://www.itu.int"
|
|
36
|
+
USER_AGENT = f"itulib-catalog/{__version__} (+https://github.com/b1tninja/itulib)"
|
|
37
|
+
MIN_INTERVAL = 1.0
|
|
38
|
+
|
|
39
|
+
# Asset format ids as they appear in download links, with what they are.
|
|
40
|
+
FORMATS = {
|
|
41
|
+
"PDF": "PDF (acrobat)",
|
|
42
|
+
"EPB": "EPUB",
|
|
43
|
+
"MSW": "Word 2007",
|
|
44
|
+
"ZWD": "zip of Word files",
|
|
45
|
+
"SUM-HTM": "summary (HTML)",
|
|
46
|
+
"TOC-HTM": "table of contents (HTML)",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Listing:
|
|
52
|
+
rec: str # "X.509"
|
|
53
|
+
title: str
|
|
54
|
+
status: str # "active" | "withdrawn" | other marker, lowercased
|
|
55
|
+
note: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Component:
|
|
60
|
+
id: str # "T-REC-X.509-201910-I", "T-REC-X.509-200003-S!Cor1"
|
|
61
|
+
label: str # "X.509 (10/19)"
|
|
62
|
+
kind: str # edition | corrigendum | amendment | erratum | supplement | collection | other
|
|
63
|
+
status: str # "in force" | "superseded" | "withdrawn" | as printed, lowercased
|
|
64
|
+
section: str # "in force" | "superseded"
|
|
65
|
+
title: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Asset:
|
|
70
|
+
fmt: str # "PDF", "EPB", "MSW", "ZWD", "SUM-HTM", "TOC-HTM"
|
|
71
|
+
lang: str # "E", "F", "S", ...
|
|
72
|
+
url: str
|
|
73
|
+
size: int | None # octets, when the page prints one
|
|
74
|
+
posted: str # ISO date, or ""
|
|
75
|
+
access: str # "free" | "ties" | "unknown"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ComponentPage:
|
|
80
|
+
id: str
|
|
81
|
+
title: str
|
|
82
|
+
status: str
|
|
83
|
+
approved: str
|
|
84
|
+
assets: tuple[Asset, ...] = field(default_factory=tuple)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- parsing ------------------------------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
_TAG = re.compile(r"<[^>]+>")
|
|
90
|
+
_SPACE = re.compile(r"\s+")
|
|
91
|
+
_ROW = re.compile(r"<tr\b[^>]*>(.*?)</tr>", re.S | re.I)
|
|
92
|
+
_CELLS = re.compile(r"<td\b[^>]*>(.*?)</td>", re.S | re.I)
|
|
93
|
+
_REC_LINK = re.compile(r'href="\./recommendation\.asp\?lang=en&parent=(T-REC-[^"&]+)"', re.I)
|
|
94
|
+
_COMPONENT = re.compile(r"^T-REC-([A-Z]\.[\w]+(?:-[A-Z]\.[\w]+)?)-(\d{6})-([A-Z])(?:!(.+))?$")
|
|
95
|
+
_DOWNLOAD = re.compile(r'href="([^"]*dologin_pub\.asp\?[^"]*?id=([^"&]+)[^"]*)"', re.I)
|
|
96
|
+
_STATIC = re.compile(r'href="(/dms_pubrec/[^"]+!!(SUM-HTM|TOC-HTM)-([A-Z])\.htm)"', re.I)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def text_of(fragment: str) -> str:
|
|
100
|
+
cleaned = html.unescape(_TAG.sub(" ", fragment)).replace("�", " ").replace("\xa0", " ")
|
|
101
|
+
return _SPACE.sub(" ", cleaned).strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def decode(body: bytes) -> str:
|
|
105
|
+
"""ITU's pages are a mix of UTF-8 and cp1252. Try the strict one first so the fallback is never silent."""
|
|
106
|
+
try:
|
|
107
|
+
return body.decode("utf-8")
|
|
108
|
+
except UnicodeDecodeError:
|
|
109
|
+
return body.decode("cp1252", errors="replace")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_series(page: str) -> list[Listing]:
|
|
113
|
+
"""One entry per recommendation number on a series listing page."""
|
|
114
|
+
found: list[Listing] = []
|
|
115
|
+
for row in _ROW.findall(page):
|
|
116
|
+
link = _REC_LINK.search(row)
|
|
117
|
+
cells = _CELLS.findall(row)
|
|
118
|
+
if not link or len(cells) < 2 or _COMPONENT.match(link.group(1)): # no link, or a component row
|
|
119
|
+
continue
|
|
120
|
+
ident = link.group(1).removeprefix("T-REC-")
|
|
121
|
+
rendered = text_of(cells[1])
|
|
122
|
+
marker = re.match(r"\[([^\]]+)\]\s*", rendered)
|
|
123
|
+
status = marker.group(1).lower() if marker else "active"
|
|
124
|
+
rendered = rendered[marker.end() :] if marker else rendered
|
|
125
|
+
note = ""
|
|
126
|
+
if "<br" in cells[1].lower():
|
|
127
|
+
head, _, tail = cells[1].partition("<br")
|
|
128
|
+
rendered = text_of(head).removeprefix(marker.group(0)) if marker else text_of(head)
|
|
129
|
+
note = text_of("<br" + tail)
|
|
130
|
+
found.append(Listing(rec=ident, title=rendered, status=status, note=note))
|
|
131
|
+
return found
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _kind(suffix: str | None, collection: bool) -> str:
|
|
135
|
+
if collection:
|
|
136
|
+
return "collection"
|
|
137
|
+
if not suffix:
|
|
138
|
+
return "edition"
|
|
139
|
+
lowered = suffix.lower()
|
|
140
|
+
for word, kind in (("cor", "corrigendum"), ("amd", "amendment"), ("err", "erratum"), ("sup", "supplement")):
|
|
141
|
+
if lowered.startswith(word):
|
|
142
|
+
return kind
|
|
143
|
+
return "other"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_recommendation(page: str) -> list[Component]:
|
|
147
|
+
"""The components listed on a recommendation page, with the section each one sits under."""
|
|
148
|
+
out: list[Component] = []
|
|
149
|
+
section = "in force"
|
|
150
|
+
for row in _ROW.findall(page):
|
|
151
|
+
header = text_of(row).lower()
|
|
152
|
+
if 'colspan="3"' in row.lower() or "colspan='3'" in row.lower():
|
|
153
|
+
section = "superseded" if "superseded" in header or "withdrawn" in header else "in force"
|
|
154
|
+
continue
|
|
155
|
+
link = _REC_LINK.search(row)
|
|
156
|
+
cells = _CELLS.findall(row)
|
|
157
|
+
if not link or len(cells) < 3:
|
|
158
|
+
continue
|
|
159
|
+
match = _COMPONENT.match(link.group(1))
|
|
160
|
+
if not match:
|
|
161
|
+
continue
|
|
162
|
+
status = text_of(cells[2]).lower()
|
|
163
|
+
out.append(
|
|
164
|
+
Component(
|
|
165
|
+
id=link.group(1),
|
|
166
|
+
label=text_of(cells[0]),
|
|
167
|
+
kind=_kind(match.group(4), "-" in match.group(1)),
|
|
168
|
+
status=status,
|
|
169
|
+
section=section,
|
|
170
|
+
title=text_of(cells[1]),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _size(near: str) -> int | None:
|
|
177
|
+
m = re.search(r"(\d[\d,]*)\s*bytes", near)
|
|
178
|
+
return int(m.group(1).replace(",", "")) if m else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse_component(page: str, component_id: str) -> ComponentPage:
|
|
182
|
+
"""Title, status, approval date and every downloadable asset of one component page."""
|
|
183
|
+
plain = text_of(re.sub(r"<script.*?</script>|<style.*?</style>", " ", page, flags=re.S | re.I))
|
|
184
|
+
status = re.search(r"Status\s*:\s*([A-Za-z ]+?)(?:\s{2,}|\s+(?:Table|Summary|Access)\b|$)", plain)
|
|
185
|
+
approved = re.search(r"Approved in\s*(\d{4}-\d{2}-\d{2})", plain)
|
|
186
|
+
title = re.search(r"<title>(.*?)</title>", page, re.S | re.I)
|
|
187
|
+
# Access labels in document order ("Access : Freely available items", "Access : TIES users"). The label's
|
|
188
|
+
# words can sit outside the tag that holds "Access :", so classify the text that follows it, not the tag.
|
|
189
|
+
labels = [
|
|
190
|
+
(m.start(), _access_class(text_of(page[m.end() : m.end() + 240])[:40]))
|
|
191
|
+
for m in re.finditer(r"Access\s*:", page)
|
|
192
|
+
]
|
|
193
|
+
assets: list[Asset] = []
|
|
194
|
+
for m in _DOWNLOAD.finditer(page):
|
|
195
|
+
# editions use "!!PDF-E", corrigenda/amendments/errata a single "!" ("...!Err1!PDF-E")
|
|
196
|
+
parsed = re.search(r"!([A-Z]+(?:-[A-Z]+)?)-([A-Z])$", m.group(2))
|
|
197
|
+
if not parsed:
|
|
198
|
+
continue
|
|
199
|
+
# The size and posting date are cells of the same table row as the download link, before it in some
|
|
200
|
+
# tables and after it in others, so read the whole enclosing row.
|
|
201
|
+
stop = page.find("</tr>", m.end())
|
|
202
|
+
row = text_of(page[max(page.rfind("<tr", 0, m.start()), 0) : stop if stop > 0 else m.end()])
|
|
203
|
+
date = re.search(r"(\d{4}-\d{2}-\d{2})", row)
|
|
204
|
+
before = [kind for pos, kind in labels if pos < m.start()]
|
|
205
|
+
assets.append(
|
|
206
|
+
Asset(
|
|
207
|
+
fmt=parsed.group(1),
|
|
208
|
+
lang=parsed.group(2),
|
|
209
|
+
url=_absolute(html.unescape(m.group(1)).strip()),
|
|
210
|
+
size=_size(row),
|
|
211
|
+
posted=date.group(1) if date else "",
|
|
212
|
+
access=before[-1] if before else "unknown",
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
for m in _STATIC.finditer(page):
|
|
216
|
+
assets.append(Asset(m.group(2), m.group(3), _absolute(m.group(1)), None, "", "free"))
|
|
217
|
+
unique = {(a.fmt, a.lang, a.url): a for a in assets}
|
|
218
|
+
return ComponentPage(
|
|
219
|
+
id=component_id,
|
|
220
|
+
title=text_of(title.group(1)) if title else "",
|
|
221
|
+
status=status.group(1).strip().lower() if status else "",
|
|
222
|
+
approved=approved.group(1) if approved else "",
|
|
223
|
+
assets=tuple(unique.values()),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _access_class(label: str) -> str:
|
|
228
|
+
lowered = label.lower()
|
|
229
|
+
if "free" in lowered:
|
|
230
|
+
return "free"
|
|
231
|
+
if "ties" in lowered:
|
|
232
|
+
return "ties"
|
|
233
|
+
return "unknown"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _absolute(href: str) -> str:
|
|
237
|
+
return href if href.startswith("http") else BASE + (href if href.startswith("/") else "/rec/" + href)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- fetching -----------------------------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Fetcher:
|
|
244
|
+
"""GET with a minimum interval between requests, an identifying User-Agent and a disk cache.
|
|
245
|
+
|
|
246
|
+
`opener` and the clock are injectable so the pacing and the cache are tested without a network.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(
|
|
250
|
+
self,
|
|
251
|
+
cache: Path | None = None,
|
|
252
|
+
*,
|
|
253
|
+
interval: float = MIN_INTERVAL,
|
|
254
|
+
opener: Callable[[urllib.request.Request], bytes] | None = None,
|
|
255
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
256
|
+
clock: Callable[[], float] = time.monotonic,
|
|
257
|
+
) -> None:
|
|
258
|
+
self.cache = cache
|
|
259
|
+
self.interval = interval
|
|
260
|
+
self._opener = opener or self._open
|
|
261
|
+
self._sleep = sleep
|
|
262
|
+
self._clock = clock
|
|
263
|
+
self._last = float("-inf")
|
|
264
|
+
self.requests = 0
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def _open(request: urllib.request.Request) -> bytes:
|
|
268
|
+
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
|
|
269
|
+
return bytes(response.read())
|
|
270
|
+
|
|
271
|
+
def get(self, url: str, *, refresh: bool = False) -> str:
|
|
272
|
+
if not url.startswith(BASE + "/rec/") and not url.startswith(BASE + "/dms_pubrec/"):
|
|
273
|
+
raise ValueError(f"refusing to fetch {url}: the catalogue reads only /rec/ and /dms_pubrec/")
|
|
274
|
+
slot = self.cache / (hashlib.sha256(url.encode()).hexdigest()[:24] + ".html") if self.cache else None
|
|
275
|
+
if slot is not None and slot.is_file() and not refresh:
|
|
276
|
+
return decode(slot.read_bytes())
|
|
277
|
+
wait = self.interval - (self._clock() - self._last)
|
|
278
|
+
if wait > 0:
|
|
279
|
+
self._sleep(wait)
|
|
280
|
+
self._last = self._clock()
|
|
281
|
+
self.requests += 1
|
|
282
|
+
body = self._opener(urllib.request.Request(url, headers={"User-Agent": USER_AGENT})) # noqa: S310
|
|
283
|
+
if slot is not None:
|
|
284
|
+
slot.parent.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
slot.write_bytes(body)
|
|
286
|
+
return decode(body)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --- manifest -----------------------------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def catalog_dir() -> Path:
|
|
293
|
+
return corpus.home() / "catalog"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _manifest(series: str) -> Path:
|
|
297
|
+
return catalog_dir() / f"series-{series}.json"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def series_url(letter: str) -> str:
|
|
301
|
+
return f"{BASE}/rec/T-REC-{letter}/en"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def load(series: str) -> dict[str, dict[str, object]]:
|
|
305
|
+
path = _manifest(series)
|
|
306
|
+
if not path.is_file():
|
|
307
|
+
raise corpus.CorpusMissing(
|
|
308
|
+
f"no catalogue for series {series}",
|
|
309
|
+
"",
|
|
310
|
+
f"primarysource itu catalog sync {series} --details",
|
|
311
|
+
)
|
|
312
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
313
|
+
recs = loaded.get("recs", {}) if isinstance(loaded, dict) else {}
|
|
314
|
+
return recs if isinstance(recs, dict) else {}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _save(series: str, recs: dict[str, dict[str, object]]) -> None:
|
|
318
|
+
path = _manifest(series)
|
|
319
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
321
|
+
path.write_text(json.dumps({"series": series, "updated": stamp, "recs": recs}, indent=1), encoding="utf-8")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def sync_listing(letter: str, fetcher: Fetcher, *, refresh: bool = False) -> int:
|
|
325
|
+
"""Read the series listing and merge it into the manifest, keeping any details already fetched."""
|
|
326
|
+
listings = parse_series(fetcher.get(series_url(letter), refresh=refresh))
|
|
327
|
+
if not listings:
|
|
328
|
+
raise RuntimeError(
|
|
329
|
+
f"series {letter}: the listing page parsed to 0 recommendations. The page layout probably changed; "
|
|
330
|
+
f"refusing to write an empty catalogue."
|
|
331
|
+
)
|
|
332
|
+
try:
|
|
333
|
+
recs = load(letter)
|
|
334
|
+
except corpus.CorpusMissing:
|
|
335
|
+
recs = {}
|
|
336
|
+
for item in listings:
|
|
337
|
+
entry = recs.setdefault(item.rec, {})
|
|
338
|
+
entry.update(asdict(item))
|
|
339
|
+
_save(letter, recs)
|
|
340
|
+
return len(listings)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def sync_details(
|
|
344
|
+
letter: str,
|
|
345
|
+
fetcher: Fetcher,
|
|
346
|
+
*,
|
|
347
|
+
limit: int | None = None,
|
|
348
|
+
include_withdrawn: bool = False,
|
|
349
|
+
refresh: bool = False,
|
|
350
|
+
reparse: bool = False,
|
|
351
|
+
only: Iterable[str] | None = None,
|
|
352
|
+
progress: Callable[[str], None] = lambda _message: None,
|
|
353
|
+
) -> tuple[int, int]:
|
|
354
|
+
"""Fetch the components, and the assets of the in-force ones, for recommendations without details yet.
|
|
355
|
+
|
|
356
|
+
Resumable: a recommendation with details is skipped unless `refresh` (also ignore the page cache) or
|
|
357
|
+
`reparse` (redo the details from the cached pages, with no requests: for after a parser change). The
|
|
358
|
+
manifest is saved after each one, so an interrupted run loses nothing. Returns (recommendations done,
|
|
359
|
+
component pages read).
|
|
360
|
+
"""
|
|
361
|
+
recs = load(letter)
|
|
362
|
+
wanted = set(only) if only is not None else None
|
|
363
|
+
done = pages = 0
|
|
364
|
+
for rec, entry in list(recs.items()):
|
|
365
|
+
if wanted is not None and rec not in wanted:
|
|
366
|
+
continue
|
|
367
|
+
if entry.get("status") != "active" and not include_withdrawn:
|
|
368
|
+
continue
|
|
369
|
+
if entry.get("components") is not None and not (refresh or reparse):
|
|
370
|
+
continue
|
|
371
|
+
if limit is not None and done >= limit:
|
|
372
|
+
break
|
|
373
|
+
components = parse_recommendation(fetcher.get(f"{BASE}/rec/T-REC-{rec}/en", refresh=refresh))
|
|
374
|
+
details: list[dict[str, object]] = []
|
|
375
|
+
for component in components:
|
|
376
|
+
record: dict[str, object] = asdict(component)
|
|
377
|
+
if component.section == "in force" and component.kind != "collection":
|
|
378
|
+
page = parse_component(fetcher.get(f"{BASE}/rec/{component.id}/en", refresh=refresh), component.id)
|
|
379
|
+
pages += 1
|
|
380
|
+
record.update(approved=page.approved, assets=[asdict(a) for a in page.assets])
|
|
381
|
+
details.append(record)
|
|
382
|
+
entry["components"] = details
|
|
383
|
+
_save(letter, recs)
|
|
384
|
+
done += 1
|
|
385
|
+
progress(f"{rec}: {len(components)} components")
|
|
386
|
+
return done, pages
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def flatten(recs: dict[str, dict[str, object]]) -> Iterable[tuple[str, dict[str, object], dict[str, object]]]:
|
|
390
|
+
"""(rec, recommendation entry, component) for every component that has details."""
|
|
391
|
+
for rec, entry in recs.items():
|
|
392
|
+
components = entry.get("components")
|
|
393
|
+
if isinstance(components, list):
|
|
394
|
+
for component in components:
|
|
395
|
+
if isinstance(component, dict):
|
|
396
|
+
yield rec, entry, component
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def assets_of(component: dict[str, object]) -> list[dict[str, object]]:
|
|
400
|
+
"""The asset records of a component, or [] when its assets were not fetched."""
|
|
401
|
+
raw = component.get("assets")
|
|
402
|
+
return [a for a in raw if isinstance(a, dict)] if isinstance(raw, list) else []
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def best_source(component: dict[str, object]) -> dict[str, object]:
|
|
406
|
+
"""Which assets to use for one in-force component, in order of how much structure they carry.
|
|
407
|
+
|
|
408
|
+
EPUB carries the document's own structure but is rare; the PDF is nearly always there and free; the static
|
|
409
|
+
table-of-contents page is an independent check on the PDF's bookmarks. Word is never preferred: it is
|
|
410
|
+
TIES-only for most documents.
|
|
411
|
+
"""
|
|
412
|
+
assets = assets_of(component)
|
|
413
|
+
free = [a for a in assets if a.get("access") == "free" and a.get("lang") == "E"]
|
|
414
|
+
by_fmt = {str(a["fmt"]): a for a in free}
|
|
415
|
+
return {
|
|
416
|
+
"primary": by_fmt.get("EPB") or by_fmt.get("PDF"),
|
|
417
|
+
"pdf": by_fmt.get("PDF"),
|
|
418
|
+
"epub": by_fmt.get("EPB"),
|
|
419
|
+
"toc": by_fmt.get("TOC-HTM"),
|
|
420
|
+
"summary": by_fmt.get("SUM-HTM"),
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass(frozen=True)
|
|
425
|
+
class Row:
|
|
426
|
+
rec: str
|
|
427
|
+
title: str
|
|
428
|
+
component: str
|
|
429
|
+
label: str
|
|
430
|
+
kind: str # edition | corrigendum | amendment | erratum | supplement | other
|
|
431
|
+
approved: str
|
|
432
|
+
formats: tuple[str, ...] # format ids; a trailing * marks one that is not freely available
|
|
433
|
+
|
|
434
|
+
def summary(self) -> str:
|
|
435
|
+
return " ".join(self.formats)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def rows(
|
|
439
|
+
recs: dict[str, dict[str, object]],
|
|
440
|
+
*,
|
|
441
|
+
text: str = "",
|
|
442
|
+
fmt: str = "",
|
|
443
|
+
status: str = "in force",
|
|
444
|
+
free_only: bool = False,
|
|
445
|
+
) -> list[Row]:
|
|
446
|
+
"""One row per in-force component with details (editions, and their corrigenda, amendments and errata)."""
|
|
447
|
+
wanted = text.lower()
|
|
448
|
+
found: list[Row] = []
|
|
449
|
+
for rec, entry, component in flatten(recs):
|
|
450
|
+
# Collective files ("X.680-X.693") are Word archives of several recommendations, not a document of this one.
|
|
451
|
+
if component.get("section") != "in force" or component.get("kind") == "collection":
|
|
452
|
+
continue
|
|
453
|
+
if status and not str(component.get("status", "")).startswith(status):
|
|
454
|
+
continue
|
|
455
|
+
assets = assets_of(component)
|
|
456
|
+
if fmt and not any(a.get("fmt") == fmt and (a.get("access") == "free" or not free_only) for a in assets):
|
|
457
|
+
continue
|
|
458
|
+
haystack = f"{rec} {entry.get('title', '')} {component.get('label', '')}".lower()
|
|
459
|
+
if wanted and wanted not in haystack:
|
|
460
|
+
continue
|
|
461
|
+
found.append(
|
|
462
|
+
Row(
|
|
463
|
+
rec=rec,
|
|
464
|
+
title=str(entry.get("title", "")),
|
|
465
|
+
component=str(component["id"]),
|
|
466
|
+
label=str(component.get("label", "")),
|
|
467
|
+
kind=str(component.get("kind", "")),
|
|
468
|
+
approved=str(component.get("approved", "")),
|
|
469
|
+
formats=tuple(sorted({f"{a['fmt']}{'' if a.get('access') == 'free' else '*'}" for a in assets})),
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return found
|