relaton 3.0.0.pre.alpha.17 → 3.0.0.pre.alpha.18
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.
- checksums.yaml +4 -4
- data/data/equivalence/adoption-chains.yaml +38 -0
- data/lib/relaton/3gpp/CLAUDE.md +187 -0
- data/lib/relaton/3gpp/README.adoc +149 -0
- data/lib/relaton/adobe/CLAUDE.md +104 -0
- data/lib/relaton/bib/CLAUDE.md +130 -0
- data/lib/relaton/bib/README.adoc +330 -0
- data/lib/relaton/bib/model/series.rb +1 -1
- data/lib/relaton/bipm/CLAUDE.md +173 -0
- data/lib/relaton/bipm/README.adoc +352 -0
- data/lib/relaton/bipm/data_fetcher.rb +3 -2
- data/lib/relaton/bsi/CLAUDE.md +35 -0
- data/lib/relaton/bsi/README.adoc +112 -0
- data/lib/relaton/calconnect/CLAUDE.md +177 -0
- data/lib/relaton/calconnect/README.adoc +140 -0
- data/lib/relaton/ccsds/CLAUDE.md +74 -0
- data/lib/relaton/ccsds/README.adoc +154 -0
- data/lib/relaton/cen/CLAUDE.md +219 -0
- data/lib/relaton/cen/README.adoc +155 -0
- data/lib/relaton/cie/CLAUDE.md +90 -0
- data/lib/relaton/cie/README.adoc +121 -0
- data/lib/relaton/core/CLAUDE.md +140 -0
- data/lib/relaton/core/README.adoc +64 -0
- data/lib/relaton/core/data_fetcher.rb +50 -3
- data/lib/relaton/db/CLAUDE.md +373 -0
- data/lib/relaton/db.rb +19 -1
- data/lib/relaton/doi/CLAUDE.md +105 -0
- data/lib/relaton/doi/README.adoc +89 -0
- data/lib/relaton/easc/CLAUDE.md +85 -0
- data/lib/relaton/ecma/CLAUDE.md +217 -0
- data/lib/relaton/ecma/README.adoc +178 -0
- data/lib/relaton/equivalence.rb +89 -0
- data/lib/relaton/etsi/CLAUDE.md +219 -0
- data/lib/relaton/etsi/README.adoc +137 -0
- data/lib/relaton/evs/CLAUDE.md +120 -0
- data/lib/relaton/evs/README.adoc +27 -0
- data/lib/relaton/gb/CLAUDE.md +47 -0
- data/lib/relaton/gb/README.adoc +169 -0
- data/lib/relaton/gost/CLAUDE.md +100 -0
- data/lib/relaton/iala/CLAUDE.md +97 -0
- data/lib/relaton/iana/CLAUDE.md +105 -0
- data/lib/relaton/iana/README.adoc +107 -0
- data/lib/relaton/iec/CLAUDE.md +13 -0
- data/lib/relaton/iec/README.adoc +280 -0
- data/lib/relaton/iec/data_fetcher.rb +24 -8
- data/lib/relaton/iec/project_fetcher.rb +48 -0
- data/lib/relaton/iec/project_parser.rb +111 -0
- data/lib/relaton/iec.rb +5 -0
- data/lib/relaton/ieee/CLAUDE.md +176 -0
- data/lib/relaton/ieee/README.adoc +131 -0
- data/lib/relaton/ietf/CLAUDE.md +357 -0
- data/lib/relaton/ietf/README.adoc +167 -0
- data/lib/relaton/iho/CLAUDE.md +70 -0
- data/lib/relaton/iho/README.adoc +119 -0
- data/lib/relaton/index/CLAUDE.md +207 -0
- data/lib/relaton/index/README.adoc +221 -0
- data/lib/relaton/index/dataset_lock.rb +122 -0
- data/lib/relaton/index/family.rb +63 -0
- data/lib/relaton/index/type.rb +32 -0
- data/lib/relaton/index.rb +3 -0
- data/lib/relaton/isbn/CLAUDE.md +47 -0
- data/lib/relaton/isbn/README.adoc +91 -0
- data/lib/relaton/iso/CLAUDE.md +38 -0
- data/lib/relaton/iso/README.adoc +370 -0
- data/lib/relaton/iso/data_fetcher.rb +16 -1
- data/lib/relaton/itu/CLAUDE.md +685 -0
- data/lib/relaton/itu/README.adoc +188 -0
- data/lib/relaton/jcgm/CLAUDE.md +107 -0
- data/lib/relaton/jis/CLAUDE.md +64 -0
- data/lib/relaton/jis/README.adoc +167 -0
- data/lib/relaton/logger/CLAUDE.md +37 -0
- data/lib/relaton/logger/README.adoc +120 -0
- data/lib/relaton/nist/CLAUDE.md +180 -0
- data/lib/relaton/nist/README.adoc +389 -0
- data/lib/relaton/nist/bibliography.rb +17 -1
- data/lib/relaton/nist/data_fetcher.rb +60 -4
- data/lib/relaton/nist/hit_collection.rb +121 -59
- data/lib/relaton/nist/scraper.rb +21 -7
- data/lib/relaton/oasis/CLAUDE.md +251 -0
- data/lib/relaton/oasis/README.adoc +132 -0
- data/lib/relaton/ogc/CLAUDE.md +242 -0
- data/lib/relaton/ogc/README.adoc +134 -0
- data/lib/relaton/oiml/CLAUDE.md +47 -0
- data/lib/relaton/oiml/README.adoc +70 -0
- data/lib/relaton/omg/CLAUDE.md +137 -0
- data/lib/relaton/omg/README.adoc +101 -0
- data/lib/relaton/plateau/CLAUDE.md +126 -0
- data/lib/relaton/plateau/README.adoc +139 -0
- data/lib/relaton/sdo/CLAUDE.md +81 -0
- data/lib/relaton/un/CLAUDE.md +91 -0
- data/lib/relaton/un/README.adoc +125 -0
- data/lib/relaton/version.rb +1 -1
- data/lib/relaton/w3c/CLAUDE.md +369 -0
- data/lib/relaton/w3c/README.adoc +140 -0
- data/lib/relaton/w3c/data_fetcher.rb +1 -1
- data/lib/relaton/xsf/CLAUDE.md +189 -0
- data/lib/relaton/xsf/README.adoc +127 -0
- data/lib/relaton.rb +1 -0
- metadata +83 -2
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
The ITU (International Telecommunication Union) flavor of the combined `relaton`
|
|
8
|
+
gem — retrieval and modelling of ITU-T and ITU-R bibliographic metadata. All of it
|
|
9
|
+
lives under `Relaton::Itu` in `lib/relaton/itu/`; the old flat `RelatonItu`
|
|
10
|
+
namespace is gone.
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
cd spec/itu && bundle exec rspec -I . . # the flavor's suite (run from its own dir)
|
|
16
|
+
bundle exec rake spec:itu # the same, from the repo root
|
|
17
|
+
cd spec/itu && bundle exec rspec -I . relaton/itu/item_spec.rb # one file
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Architecture
|
|
21
|
+
|
|
22
|
+
### Model Layer (Lutaml::Model)
|
|
23
|
+
|
|
24
|
+
All model classes use `Lutaml::Model::Serializable` for XML/YAML serialization:
|
|
25
|
+
|
|
26
|
+
- **`Item`** → extends `Bib::Item` (main bibliographic item)
|
|
27
|
+
- **`ItemData`** → extends `Bib::ItemData` (used by DataParserR for parsed documents)
|
|
28
|
+
- **`Bibitem`** / **`Bibdata`** → extend `Item`, mix in shared behavior from `Bib`
|
|
29
|
+
- **`Ext`** → extends `Bib::Ext` with ITU-specific fields (doctype, structuredidentifier, question, recommendationstatus, ip_notice_received, meeting, meeting_place, meeting_date, intended_type, source)
|
|
30
|
+
- **`Docidentifier`** (`model/docidentifier.rb`) → extends `Bib::Docidentifier`. `#pubid` parses `content` with `Pubid::Itu` on first use (lazy, memoized, soft: nil if it does not parse, no log — the `ISO` co-identifier never parses). `#remove_date!` is `pubid.exclude(:year, :month)` rendered back, so it also clears the dates of the `base` chain and keeps the version (`ITU-T H.264 (V14)`). The result is the pubid canonical spelling (`v10` → `(V10)`). Content that does not parse is left unchanged; there is no regex fallback. `remove_part!`/`to_all_parts!` stay no-ops.
|
|
31
|
+
- **`Doctype`**, **`StructuredIdentifier`**, **`EditorialGroup`**, **`Bureau`**, **`Group`**, **`ApprovalStage`**, **`RecommendationStatus`**, **`Question`**, **`Meeting`**, **`MeetingDate`** — ITU-specific metadata types
|
|
32
|
+
|
|
33
|
+
### Runtime lookup (`HitCollection#search`)
|
|
34
|
+
|
|
35
|
+
ITU removed the `net4/.../GlobalSearch/RunSearch` endpoint (F5 WAF, HTTP 500) that
|
|
36
|
+
used to *discover* every reference. The reference reaches `HitCollection` as a
|
|
37
|
+
`Pubid::Itu` identifier, and its **class and sector** pick one of three routes
|
|
38
|
+
(`#search`; an annex or appendix reads its sector from `root`):
|
|
39
|
+
|
|
40
|
+
| reference | route | source |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `RadioRegulations` (`ITU-R RR (2020)`), `SpecialPublication` (`ITU-T OB.1096 (2016)`) | `#request_publication` | live `www.itu.int/pub/{R-REG-RR-YYYY \| T-SP-OB.N-YYYY}` |
|
|
43
|
+
| sector `T` | `#request_recommendation` | combined `index-v2`, live fallback |
|
|
44
|
+
| sector `R` (recommendation, report, handbook, question) | `#request_document` | combined `index-v2` |
|
|
45
|
+
|
|
46
|
+
The caller's reference is parsed with `::Pubid::Itu.parse` in
|
|
47
|
+
`Bibliography.get`/`.search`, and a `Pubid::Errors::ParseError` **propagates**
|
|
48
|
+
(repo rule "An unrecognized query reference raises"). This replaced a local
|
|
49
|
+
Parslet grammar, `Relaton::Itu::Pubid`, which ended in `any.repeat` and so
|
|
50
|
+
silently dropped what it could not read (`ITU-T H.222.0 v10 (04/2025)` became
|
|
51
|
+
`ITU-T H.222.0`). pubid #460 added the forms the local grammar had and pubid
|
|
52
|
+
lacked: `RadioRegulations`, the OB sector and its ` - 15.III.2016` date, ITU's
|
|
53
|
+
publication ids (`T-REC-T.4-200307-I`, `ITU-T REC T.4`) and `-YYYYMM` read as
|
|
54
|
+
a date rather than a part. Forms neither parses well now **raise**: `ITU G.191`
|
|
55
|
+
(no sector), `ITU-T G.Suppl.47` (the "Incorrect reference" hint is still
|
|
56
|
+
logged first), `ITU-T OB. (2016)`.
|
|
57
|
+
|
|
58
|
+
- **Publications** (Radio Regulations, Operational Bulletins) are **not** in the
|
|
59
|
+
dataset, but their landing-page id is derivable from the reference, so no search
|
|
60
|
+
is needed. No `ref.year` → no hit; a `notfound` redirect or 404 → no hit. The page
|
|
61
|
+
is parsed by `RadioRegulationsParser` as before (its `#doc_url` `dest=` unwrap is
|
|
62
|
+
a no-op for these direct URLs, kept for the legacy shape).
|
|
63
|
+
- **Recommendations** prefer the index (offline, every edition, no scraping) and
|
|
64
|
+
fall back to the live `rec.aspx?rec={code}` → `11.1002/1000/{idrec}` →
|
|
65
|
+
`getRecEditions` pair, which is the discovery half of relaton-itu#89. The
|
|
66
|
+
fallback is a **narrowing** safety net. It used to be load-bearing for 706
|
|
67
|
+
ITU-T records whose ids `Pubid::Itu` could not parse; pubid #325 parses every
|
|
68
|
+
one of them, so after the next crawl **no ITU-T record is unindexed for that
|
|
69
|
+
reason** (see **Index/data gap** below). What still needs it: Implementers'
|
|
70
|
+
Guides (`ITU-T G.Imp712`) aren't harvested at all, and newly approved editions
|
|
71
|
+
appear on `rec.aspx` before the next crawl. It triggers when the index has **no** row *or*
|
|
72
|
+
when the reference names a year no indexed edition has (a heuristic — a hit that
|
|
73
|
+
`Bibliography#search_filter` later discards still counts as satisfying); if the
|
|
74
|
+
live path then finds nothing, the indexed editions are kept so the "no match for
|
|
75
|
+
`<year>` year, though there were matches found for …" hint survives.
|
|
76
|
+
**Known gaps:** a `(V##)` reference resolves live (`getRecEditions` returns the
|
|
77
|
+
versioned `rec_name`) and will resolve from the index after the next crawl; an
|
|
78
|
+
`Annex` reference resolves through **neither** route until then — the published
|
|
79
|
+
index has no annex rows and `getRecEditions` returns no `Annex` entries. Undated
|
|
80
|
+
`ITU-R RR` / `OB.` references are also unresolvable: the `/pub` id needs a year
|
|
81
|
+
and there is no enumeration source left to find the latest.
|
|
82
|
+
- **Selecting the hits (`Bibliography.search_filter`)** keeps a hit when
|
|
83
|
+
`refid.matches?(hit_pubid, ignore: %i[year month day] (+ :version when neither
|
|
84
|
+
the reference nor the recommendation it amends names one))` **and**, for an
|
|
85
|
+
amending supplement, the supplement's **own** stated date equals the hit's
|
|
86
|
+
(`ignore:` drops the date at every level, so without that check
|
|
87
|
+
`ITU-T G.989.2 Amd. 1 (04/2016)` also kept the `(2019) Amd. 1 (10/2020)` row
|
|
88
|
+
and returned it). **Not `===`**: it reads a missing `subseries` as "any
|
|
89
|
+
value", so `ITU-T H.264` would keep `ITU-T H.264.1`. Measured against the old
|
|
90
|
+
local match over the 5,361-row index fixture (every row id as a hit, queried
|
|
91
|
+
by every row id and its undated and version-less forms, per `root.number`
|
|
92
|
+
bucket): the **only** changes are 194 `Cor.`/`Err.` pairs — a base reference
|
|
93
|
+
no longer keeps its own corrigenda and errata, and `Cor. 1` to one edition no
|
|
94
|
+
longer matches `Cor. 1` to another (the old grammar did not model `Cor.`/`Err.`
|
|
95
|
+
and stopped at the base's date — the ETSI-style defect). A hit code that does
|
|
96
|
+
not parse is data and is dropped. ITU-R hits carry no `:code`, so the filter
|
|
97
|
+
never sees them.
|
|
98
|
+
- **The year a reference selects by (`Bibliography.edition_year`)** mirrors the
|
|
99
|
+
hit side, which reads the **first** date of the hit code: for an
|
|
100
|
+
`Amendment`/`Corrigendum`/`Errata`/`Addendum` it is the **base's** year
|
|
101
|
+
(`ITU-T G.989.2 (2014) Amd. 1 (04/2016)` → 2014; `ITU-T G.989.2 Amd. 1
|
|
102
|
+
(04/2016)` → none); for an `AnnexOfRecommendation`/`AppendixOfRecommendation`
|
|
103
|
+
the base's year when it has one, else its own (`ITU-T Z.100 (06/2021) Annex
|
|
104
|
+
F1` and `ITU-T Z.100 Annex F2 (06/2021)` → 2021); else the identifier's own.
|
|
105
|
+
`get(code, year)` adds `year` to a reference that has none, on the base for an
|
|
106
|
+
amending supplement. `Bibliography.document` names the recommendation an
|
|
107
|
+
amending supplement, annex or appendix belongs to — `Amendment` is a
|
|
108
|
+
`Supplement` subclass, so the lists are explicit: a supplement to a
|
|
109
|
+
recommendation (`ITU-T G.989 Suppl. 1`) is a document of its own, and
|
|
110
|
+
`rec.aspx` is asked for it, not for `G.989`.
|
|
111
|
+
- **Other routes that must not reach a search:** an annex to a bulletin
|
|
112
|
+
(`Annex to ITU-T OB.1096 (2016)`, root a `SpecialPublication`) returns no
|
|
113
|
+
hit. A live edition's `rec_name` gets the crawler's
|
|
114
|
+
`DataParserT.normalize_rec_name` (`G 231` → `G.231`) before it becomes a hit
|
|
115
|
+
code, and `Scraper#createdocid` keeps a code pubid cannot parse as ITU wrote
|
|
116
|
+
it — both are data, so a parse failure there must not fail the record.
|
|
117
|
+
- **Index hits are lazy.** `#index_hits` builds one `Hit` per matching row carrying
|
|
118
|
+
`code:` (`row[:id].to_s` — the same dated docidentifier the data record holds),
|
|
119
|
+
`url:` and `file:`; `Hit#item` branches on `:file` and calls
|
|
120
|
+
`HitCollection#fetch_item` only for the edition `Bibliography` actually selects.
|
|
121
|
+
Because the hit's `code` is the dated docid, `Bibliography#search_filter` /
|
|
122
|
+
`#isobib_results_filter` year selection needed no change. Hits are sorted
|
|
123
|
+
newest-first (`#edition_key`, the **first** date in the code, so an amendment
|
|
124
|
+
sorts by the year of the recommendation it amends) because
|
|
125
|
+
`isobib_results_filter` takes the first hit for a year-less reference.
|
|
126
|
+
- Live recommendation hits deliberately carry **no `:ref` key** — that drives
|
|
127
|
+
`Hit#gi_imp`, which would route an Implementers' Guide onto the `getImplGuides`
|
|
128
|
+
endpoints; the live `G.Imp712` record is built from `getRecHdrDetail` like any
|
|
129
|
+
other. (`gi_imp` has been inert since the RunSearch hits never set `:ref`.)
|
|
130
|
+
- **Metadata parity:** an indexed Recommendation is only as rich as the published
|
|
131
|
+
data record. `relaton-data-itu` was re-crawled with the enriched `DataParserT`
|
|
132
|
+
(below) on 2026-08-12, so an indexed record now carries the same abstract, ISO
|
|
133
|
+
co-identifier, contributors, status, edition and relations a live-fallback
|
|
134
|
+
record does — `itu_t_y_3500` and `itu_t_a_13` assert exactly that. A crawl
|
|
135
|
+
predating the enrichment would silently reintroduce the asymmetry, so the data
|
|
136
|
+
repo's own suite guards against republishing thin records.
|
|
137
|
+
|
|
138
|
+
### Data Fetching
|
|
139
|
+
|
|
140
|
+
- **`DataFetcher`** extends `Core::DataFetcher`. `#fetch(source)` routes on the
|
|
141
|
+
dataset name (from `Processor#@datasets = %w[itu-r itu-t]`): `"itu-t"` →
|
|
142
|
+
`#fetch_recommendations`, anything else (`"itu-r"`, `nil`) →
|
|
143
|
+
`#fetch_publications`, then `index.save` + `report_errors` for either.
|
|
144
|
+
- **ITU-R harvester** (`#fetch_publications`) — restored on the crawl that
|
|
145
|
+
replaced the decommissioned RunSearch enumeration (issue #75). It walks
|
|
146
|
+
`DataCrawlerR::FAMILIES` series by series, so a series ITU throttles costs
|
|
147
|
+
that series rather than the run. **The series loop stays serial on purpose**:
|
|
148
|
+
`#harvest_family` rescues per series *and* per family, `DataMergeR.write_all`
|
|
149
|
+
runs per series, and the data repo's `guard_itur_harvest` is calibrated on
|
|
150
|
+
exactly that granularity. Parallelism lives *inside* a series, in
|
|
151
|
+
`DataCrawlerR#in_parallel`, over the two request-heavy levels.
|
|
152
|
+
`RELATON_ITU_MODE` picks the mode, so two scheduled jobs differ
|
|
153
|
+
by environment rather than by code:
|
|
154
|
+
|
|
155
|
+
| mode | cost | what it is for |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `:full` (default) | ~7k requests, ~2 h | the weekly rebuild: wipe `data/itu-r-*` first and let ITU be the sole author of the result |
|
|
158
|
+
| `:top_up` | enumeration + one request per **new** edition | the daily job: same document walk, but `#held?` answers from the level-2 row whether the dataset already has an edition, so the expensive per-edition page is never fetched for one we hold |
|
|
159
|
+
|
|
160
|
+
**A top-up assumes a previous `:full` run built the dataset.** Against the
|
|
161
|
+
pre-#110 dataset it would add every Report a second time under its `Report …`
|
|
162
|
+
name, so the data repo runs the rebuild first and enables the daily job after.
|
|
163
|
+
That ordering lives in the schedule, next to the wipe it depends on, rather
|
|
164
|
+
than in the gem: it can only go wrong in the single window before the first
|
|
165
|
+
rebuild, and it announces itself as a commit adding ~650 files. The rebuild
|
|
166
|
+
*is* the migration, which is why the harvester carries no migration step.
|
|
167
|
+
|
|
168
|
+
**Pacing, and why the numbers changed.** `RELATON_ITU_DELAY` (default 1 s) is
|
|
169
|
+
the **minimum gap between request _starts_**, enforced by one
|
|
170
|
+
`Relaton::Core::Pacer` shared by the pool — not a `sleep` before each request.
|
|
171
|
+
The distinction is the whole speed-up: a sleep *adds* to ITU's own ~1.1 s
|
|
172
|
+
latency, so a 1 s delay yielded one request every ~2.1 s and a ~7k-request
|
|
173
|
+
crawl spent ~2 h of a ~4 h run idle. Under the pacer the latency counts
|
|
174
|
+
*toward* the gap, so www.itu.int sees at most 1/delay req/s however many
|
|
175
|
+
workers there are. Stated plainly: peak rate rises from the old ~0.48 req/s to
|
|
176
|
+
at most 1.0 req/s — the rate this file already records as safe, and 0.4 s as
|
|
177
|
+
not. `RELATON_ITU_R_CONCURRENCY` (default 4) sizes the pool; it is a separate
|
|
178
|
+
knob from ITU-T's `RELATON_ITU_CONCURRENCY` because the bottlenecks differ —
|
|
179
|
+
ITU-R throughput is `min(1/delay, concurrency/latency)`, so **the pacer, not
|
|
180
|
+
the pool, sets the rate** and two workers already saturate it.
|
|
181
|
+
|
|
182
|
+
**Rollback:** `RELATON_ITU_R_CONCURRENCY=1 RELATON_ITU_PACE=fixed` reproduces
|
|
183
|
+
the pre-pool request pattern exactly — one request per (delay + latency), one
|
|
184
|
+
thread.
|
|
185
|
+
|
|
186
|
+
A **scale model** of the crawl (300 requests, gap and latency both scaled by
|
|
187
|
+
1/100 to 0.01 s / 0.011 s, so it is the measured 1.0 s-against-~1.1 s shape)
|
|
188
|
+
gives 39.9 req/s for the old sleep-then-request loop against **99.8 req/s**
|
|
189
|
+
for the shared pacer at two or more workers — a **2.5x** speed-up, with the
|
|
190
|
+
rate pinned exactly at the 1/gap ceiling and never above it. Four workers
|
|
191
|
+
measure the same as two, which is the point: **the pacer, not the pool, sets
|
|
192
|
+
the rate.** This is a model, not ITU: confirm it with a timed dry run of one
|
|
193
|
+
series per family before trusting the projection.
|
|
194
|
+
|
|
195
|
+
**Throttling is a pool event.** `Relaton::Itu::Governor` (a
|
|
196
|
+
`Relaton::Core::Governor` binding) gives the workers one shared cooldown: any
|
|
197
|
+
thread seeing a throttle opens it, every other thread observes it, and it
|
|
198
|
+
escalates per round (60 s → 900 s) on the minute scale a WAF ban actually
|
|
199
|
+
needs, instead of four workers each running their own 5/10/15 s ladder and
|
|
200
|
+
keeping the limiter engaged. After five barren rounds the crawl is declared
|
|
201
|
+
rate-limited and gives up, so a banned run fails in ~15 min rather than
|
|
202
|
+
burning the job's remaining hours. Which failures count: **503** (what `/rec`
|
|
203
|
+
answers) and the gateway codes, plus the `/pub` soft block, which
|
|
204
|
+
`DataCrawlerR::SoftBlock` marks because Mechanize follows the 302 to a
|
|
205
|
+
perfectly good 200 that is otherwise indistinguishable from a 404. **404 and
|
|
206
|
+
a per-resource 500 deliberately do not** — a broken record must cost that
|
|
207
|
+
record, not a pool-wide cooldown.
|
|
208
|
+
|
|
209
|
+
**Measured** on the BO slice of R-REC + R-REP, back to back (2026-08-19): a
|
|
210
|
+
full rebuild is **221 requests / 548 s** for 127 records; the top-up
|
|
211
|
+
immediately after is **94 requests / 268 s** and adds nothing — it saves
|
|
212
|
+
exactly the 127 per-edition pages, 57% of the requests. Corpus-wide that
|
|
213
|
+
scales to roughly **7k requests full** against **~2.5k top-up**. Those request
|
|
214
|
+
counts are unchanged by the pacer; the wall clock is not — at the 1 s gap a full
|
|
215
|
+
ITU-R crawl is ~2 h rather than ~4 h, which is what took the combined
|
|
216
|
+
relaton-data-itu job back under GitHub Actions' 6 h cap (run 32420518511 was
|
|
217
|
+
cancelled at 6h00m19s). A top-up is cheaper, but not cheap — the enumeration still costs one request per
|
|
218
|
+
document, because a new edition cannot be noticed without reading the document
|
|
219
|
+
page. (`/rec/new.asp?lang=en`, linked from every series page, may be a real
|
|
220
|
+
delta feed; unexplored.) Earlier corpus figures still hold: ~2.1 s per request
|
|
221
|
+
at the 1 s delay, and 0 dates rewritten / 0 records lost across 3,600 merged
|
|
222
|
+
records.
|
|
223
|
+
|
|
224
|
+
Pair the modes with the data repo's existing collapse guard
|
|
225
|
+
(`guard_itut_harvest`, which aborts a publish whose file count halves) — a
|
|
226
|
+
wipe makes that guard the only thing between a throttled crawl and a
|
|
227
|
+
published hole, and ITU-R wants its own, per family.
|
|
228
|
+
- **`#index_files(glob)`** indexes records already on disk through the same pubid
|
|
229
|
+
guard and unparseable-id reporting as the harvests. It is what
|
|
230
|
+
`relaton-data-itu`'s crawler used while ITU-R had no harvester; a run that now
|
|
231
|
+
calls `#fetch "itu-r"` gets that indexing as a side effect of the merge.
|
|
232
|
+
- **ITU-R crawler (`DataCrawlerR`, issue #75)** — ITU-R
|
|
233
|
+
metadata is still fully server-rendered, so enumeration *is* possible without
|
|
234
|
+
RunSearch; `data_crawler_r.rb` proves it. Three levels, per family (`FAMILIES`):
|
|
235
|
+
|
|
236
|
+
| family | index | grouping level | page URL | date |
|
|
237
|
+
|---|---|---|---|---|
|
|
238
|
+
| `R-REC` Recommendations | `/pub/R-REC/en` | 16 series letters | `/rec/<id>/en` | `Approved in …` — **approval** |
|
|
239
|
+
| `R-REP` Reports | `/pub/R-REP/en` | 14 series letters | `/pub/<id>/en` | files' "Posted" — **publication** |
|
|
240
|
+
| `R-QUE` Questions | `/pub/R-QUE/en` | 6 study groups (`SG01`…) | `/pub/<id>/en` | files' "Posted" |
|
|
241
|
+
| `R-RES` Resolutions | `/pub/R-RES/en` | **none — flat** | `/pub/<id>/en` | files' "Posted" |
|
|
242
|
+
| `R-HDB` Handbooks | `/pub/R-HDB/en` | **none — flat** | `/pub/<id>/en` | **the id** — no page carries one |
|
|
243
|
+
|
|
244
|
+
e.g. `/rec/R-REC-BO/en` (54 documents) → `/rec/R-REC-BO.1130/en` (Main +
|
|
245
|
+
Previous versions, one row per edition) → `/rec/R-REC-BO.1130-5-202602-I/en`.
|
|
246
|
+
Every id names its own family (`#family_of`), so the level-2/3 methods take an
|
|
247
|
+
id alone. That date column is the whole reason `DataMergeR` exists (below):
|
|
248
|
+
a harvested **report** reproduces the published `date:` exactly (verified for
|
|
249
|
+
`ITU-R BO.1227-2` → `1998-01`), a harvested **recommendation** cannot.
|
|
250
|
+
Load-bearing details:
|
|
251
|
+
- URLs are **rebuilt** from each link's `parent=` id (`/rec/<id>/en` for a
|
|
252
|
+
recommendation, `/pub/<id>/en` for a report); the pages' own
|
|
253
|
+
`./recommendation.asp?…` / `publications.aspx?…` hrefs resolve against the
|
|
254
|
+
series directory and do **not** reach the canonical page.
|
|
255
|
+
- The PDF link is matched **anchored on the edition id**
|
|
256
|
+
(`//a[contains(@href,'<id>') and contains(@href,'PDF-E')]`): every `/pub`
|
|
257
|
+
page carries a QUICK LINKS sidebar whose Publication Catalogue entry is
|
|
258
|
+
itself a `…-PDF-E.pdf`, so an unanchored match would give a report edition
|
|
259
|
+
with no English PDF the catalogue's URL — which `DataMergeR` would then
|
|
260
|
+
backfill into the dataset permanently.
|
|
261
|
+
- **Each family spells its identifier differently**, and the published dataset
|
|
262
|
+
is the authority (`DataParserR#family_docid`). `R-REC` uses the displayed
|
|
263
|
+
code; `R-REP` prefixes it (`Report ITU-R BT.2020-1`, see the collisions note);
|
|
264
|
+
`R-QUE` appends a colon (`202-2/1` → `ITU-R 202-2/1:`); and `R-RES` and
|
|
265
|
+
`R-HDB` cannot use their code at all — a Resolution page renders
|
|
266
|
+
`Res.1-9 (2023)` while the record is `ITU-R R.1-9`, and a Handbook shows its
|
|
267
|
+
*title* on the index and a bare year on the leaf, so both read the **page id**
|
|
268
|
+
(`R-RES-R.1-9-2023`, `R-HDB-43-2013`). The first four reproduce the published
|
|
269
|
+
filenames exactly, verified live; `R-HDB` deliberately does not (below).
|
|
270
|
+
- **`R-HDB` Handbooks are keyed per edition — `ITU-R 43.HDB (2013)`.** ITU
|
|
271
|
+
publishes several editions per handbook (43 has 2002, 2013 and 2026) while the
|
|
272
|
+
published dataset carries **one record per handbook number** (`ITU-R 01.HDB`,
|
|
273
|
+
no edition in the id), so harvesting every edition under the old convention
|
|
274
|
+
would collapse them onto one filename. `Pubid::Itu` models the year, so the
|
|
275
|
+
year is what separates them; the four published records that carry a bare
|
|
276
|
+
`ITU-R 43` are worse than colliding — that parses as a **recommendation**,
|
|
277
|
+
claiming a number that belongs to another document. A full rebuild replaces
|
|
278
|
+
all 60 published handbook files with 63 edition-keyed ones.
|
|
279
|
+
The family publishes less per page than any other, so two things move
|
|
280
|
+
(`FAMILIES["R-HDB"]` carries `date: :id` and `title: :document`):
|
|
281
|
+
- **The date comes from the id.** No handbook page carries a date at all, and
|
|
282
|
+
the displayed codes disagree with each other and with themselves — one
|
|
283
|
+
handbook's three editions render "2026", "2014" (for id `R-HDB-43-2013`) and
|
|
284
|
+
"Edition of 2002". `#row` therefore does not warn about the missing page
|
|
285
|
+
date for this family; for every other family that warning means a degraded
|
|
286
|
+
fetch.
|
|
287
|
+
- **The title comes from the level-1 row**, the only place ITU prints it: the
|
|
288
|
+
index anchor holds the *title* where every other family's holds a number
|
|
289
|
+
(the number is in the id alone), and the edition rows hold only a year. So
|
|
290
|
+
`#harvest` copies the document row's title onto each of its editions —
|
|
291
|
+
nothing downstream sees the index again.
|
|
292
|
+
- **Measured live (2026-08-20):** 45 handbooks → **63 editions**, 46 requests
|
|
293
|
+
in 133 s shallow (`deep: true` adds one request per edition, ~109 / ~4 min).
|
|
294
|
+
All 63 docids are distinct, all pass the index gate as
|
|
295
|
+
`pubid:itu:handbook`, and none is missing a title, date or PDF.
|
|
296
|
+
- The docid comes from the **displayed code** minus ` (MM/YYYY)` — `BO.1212
|
|
297
|
+
(10/95)` → `ITU-R BO.1212`, *not* the page id's `ITU-R BO.1212-0`. That
|
|
298
|
+
reproduces the published filenames and index rows exactly (the spec asserts
|
|
299
|
+
`data/itu-r-bo-1130-5.yaml` / `data/itu-r-bo-1212.yaml`).
|
|
300
|
+
- The date comes from the id (a recommendation id ends in `YYYYMM`, a report id
|
|
301
|
+
in its year; displayed codes use 2-digit years pre-2000, so the id is the only
|
|
302
|
+
unambiguous source), upgraded by the level-3 page. **For recommendations that
|
|
303
|
+
is the approval date, and the preserved records carry RunSearch's publication
|
|
304
|
+
date.** Measured across the whole BO series: the date differs for **82/82**
|
|
305
|
+
records and the *year* differs for **48** of them (`ITU-R BO.600-1`: published
|
|
306
|
+
`2002-01` vs approved `1986-07`). Since references are matched by year, that
|
|
307
|
+
is a regression, not a precision upgrade — hence `DataMergeR`.
|
|
308
|
+
- `deep: false` costs `1 + documents` requests (~1.3k for all 16 REC series)
|
|
309
|
+
with coarser dates and a pattern-derived PDF URL; `deep: true` adds one
|
|
310
|
+
request per edition. **Measured live on BO (2026-08-13):** 54 documents,
|
|
311
|
+
82 editions, 137 requests in 121 s at a 0.4 s delay, no throttling — so the
|
|
312
|
+
whole ~5.3k-record corpus is roughly 7k requests / ~1 h, inside the 6 h
|
|
313
|
+
Actions cap. Coverage on that run: 82 harvested vs 83 published BO
|
|
314
|
+
recommendations (the one gap is `ITU-R BO.4/BL/4`, a BL-form id absent from
|
|
315
|
+
the series page); the other 23 published `itu-r-bo-*` files are reports.
|
|
316
|
+
- **In deep mode the edition page is the authority.** An edition with no
|
|
317
|
+
`!!PDF-E.pdf` (older ones are Word-only, e.g. `R-REC-BO.1130-0-199408-S`) is
|
|
318
|
+
left **sourceless** rather than given a derived URL that 404s, and a page
|
|
319
|
+
that returns no approval date — ITU served an empty 200 for
|
|
320
|
+
`R-REC-BO.1130-4-200104-S` while the cassette was being recorded — is
|
|
321
|
+
**warned about** before falling back to the id's date, so a degraded deep
|
|
322
|
+
crawl can't pass for a clean one — except for a family whose date lives in
|
|
323
|
+
the id by design (`R-HDB`), where finding none on the page is the norm and
|
|
324
|
+
warning would cry wolf on every record. Every level also warns when it finds
|
|
325
|
+
nothing (a layout change would otherwise read as an empty corpus).
|
|
326
|
+
- **Throttling has two shapes, and one is silent.** Measured over a full
|
|
327
|
+
corpus run: `/rec` answers **HTTP 503**, `/pub` answers **302 →
|
|
328
|
+
`/en/publications/pages/notfound.aspx`** — indistinguishable from "no such
|
|
329
|
+
document" unless you look, and it once emptied all 14 report series while
|
|
330
|
+
every level dutifully reported success. `#get` therefore retries both
|
|
331
|
+
(`RETRIES` = 3, linear `RETRY_BACKOFF`) and raises when they persist, so a
|
|
332
|
+
throttled series fails loudly; `#warn_if_empty` is the backstop that caught
|
|
333
|
+
it in the first place. A ~1 s delay ran 13 of 14 report series clean where
|
|
334
|
+
0.4 s did not.
|
|
335
|
+
- **Two row/link shapes that look like data and are not.** ITU lists some
|
|
336
|
+
editions **twice**: a `…-I` row coded `M.2083-0` and a `…-P` row whose
|
|
337
|
+
displayed Number is the associated *Question* (`M.5/BL/22`) — so `#editions`
|
|
338
|
+
keeps only rows whose code starts with the document's own number, or the
|
|
339
|
+
crawl mints Recommendations out of question numbers. And `/pub` pages carry
|
|
340
|
+
an add-to-cart control whose href is `javascript:addcart(…,'R-REP-BT.2526-1-2024-PDF-E',…)`,
|
|
341
|
+
which contains both the edition id and `PDF-E`; the PDF match therefore
|
|
342
|
+
requires a `.pdf`-terminated href (feeding the javascript one to `URI#merge`
|
|
343
|
+
raises `URI::InvalidURIError` and killed a whole series).
|
|
344
|
+
- **Recent reports have no anonymous PDF at all** — `R-REP-BT.2526-1-2024`
|
|
345
|
+
offers only the cart flow — so `source` is legitimately empty for them.
|
|
346
|
+
- **Missing before promotion:** no per-row rescue in `#harvest` (unlike
|
|
347
|
+
`DataFetcher#spawn_rec_worker`), results accumulated in memory rather than
|
|
348
|
+
streamed.
|
|
349
|
+
- `status` is scraped (`In force (Main)` / `Superseded`) but **not** modelled —
|
|
350
|
+
no published ITU-R record has one; it is the first candidate enrichment.
|
|
351
|
+
- Same F5-WAF hardening as `#rec_agent` (browser UA, `max_history = 1`,
|
|
352
|
+
timeouts) plus a `delay:` politeness pause. Driven by
|
|
353
|
+
`DataFetcher#fetch_publications`; `itu.rb` still does not require it, so a
|
|
354
|
+
consumer-only load never pulls the crawler in.
|
|
355
|
+
- **Incremental write path (`DataMergeR`, `data_merge_r.rb`)** — the crawl is a
|
|
356
|
+
*partial, lossier* view of the corpus, so a harvest must never be written as a
|
|
357
|
+
rebuild. `DataMergeR.write_all(items, fetcher)` merges each record into the
|
|
358
|
+
dataset and returns `{added:, backfilled:, unchanged:, skipped:, collisions:}`:
|
|
359
|
+
- **`date` is never rewritten** (the 48/82 year-change measured above), and
|
|
360
|
+
neither is anything else the published record already has. Only `title` and
|
|
361
|
+
`source` are backfilled, and only when absent.
|
|
362
|
+
- An unchanged record is **not rewritten at all** (byte-identical file, so the
|
|
363
|
+
data repo's diff shows only real changes) but is still `index_primary`'d,
|
|
364
|
+
because the index is rebuilt from scratch each run.
|
|
365
|
+
- **Filename-collision guard.** ITU-R report and recommendation docids share a
|
|
366
|
+
namespace (`ITU-R BO.1227-1` is a report; recommendations run through the
|
|
367
|
+
same 1200s), so both map to `data/itu-r-bo-1227-1.yaml`. A harvested record
|
|
368
|
+
whose published counterpart has a **different doctype** — or two harvested
|
|
369
|
+
records claiming one filename — is logged via `Util.error` and **skipped**,
|
|
370
|
+
never silently overwritten (which is what `#write_file`'s last-write-wins
|
|
371
|
+
would do).
|
|
372
|
+
- Verified end-to-end against real records (10 published BO files, both
|
|
373
|
+
families, live harvest): 4 files rewritten, each changing **only** `source`;
|
|
374
|
+
6 byte-identical; every published `date` preserved; no deletions.
|
|
375
|
+
- **Corpus-scale dry run** (2026-08-13, against a writable copy of
|
|
376
|
+
`relaton-data-itu`'s 5,334 ITU-R records; the checkout itself is read-only):
|
|
377
|
+
recommendations 12/16 series in 49 min, reports 13/14 series in 126 min at a
|
|
378
|
+
1 s delay. **213 records added** (34 recommendations — all pre-2024 gaps in
|
|
379
|
+
the RunSearch-era crawl — and 179 reports, 8 of them published *after* the
|
|
380
|
+
dataset froze, e.g. `ITU-R SM.2571-0`, 2026-08); **135 records backfilled, a
|
|
381
|
+
`source` every one**; **0 dates changed and 0 records deleted**, across 3,600
|
|
382
|
+
merged records. That is the invariant holding at corpus scale.
|
|
383
|
+
- **52 collisions — being resolved by giving Reports their own identity.**
|
|
384
|
+
ITU-R Recommendations and Reports number independently but used to share this
|
|
385
|
+
dataset's docid/filename namespace: `ITU-R BT.2020-1` is *both* Rec. BT.2020-1
|
|
386
|
+
(06/2014, UHDTV parameter values) and Report BT.2020-1 (2000, objective
|
|
387
|
+
quality assessment), so the published dataset holds whichever family the old
|
|
388
|
+
crawler wrote last — 34 files hold the report and are missing the
|
|
389
|
+
recommendation (incl. `BT.2020`, `M.2083` IMT Vision, `M.2134`), 18 the
|
|
390
|
+
reverse. Since pubid #327 there is a `pubid:itu:report` type, so
|
|
391
|
+
`DataParserR` now emits **`Report ITU-R BT.2020-1`** for the `R-REP` family
|
|
392
|
+
(`#id_prefix`): the docid says which document it is, `output_file` derives
|
|
393
|
+
`data/report-itu-r-*.yaml` from it, and the two can coexist. `DataMergeR`'s
|
|
394
|
+
guard stays as the backstop for anything that still collides.
|
|
395
|
+
|
|
396
|
+
**Not finished until the dataset migrates.** The published records still
|
|
397
|
+
carry the old bare docids (984 of the 1,001 reports are indexed as
|
|
398
|
+
`pubid:itu:recommendation`), so a lookup for `Report ITU-R BT.2020-1`
|
|
399
|
+
resolves nothing until `relaton-data-itu` re-crawls onto the new names. Note
|
|
400
|
+
the new filenames fall outside `data/itu-r-*.yaml`, the glob that repo's
|
|
401
|
+
crawler passes to `#index_files` — it needs widening at the same time.
|
|
402
|
+
- **ITU-T harvester** (`#fetch_recommendations` + `DataParserT`) — added for issue
|
|
403
|
+
relaton-itu#80. `#search_recs` issues **one** GET to
|
|
404
|
+
`mws/api/recommendations/searchRecs?…&main_edition_flag=0&rows=100000&…`, which
|
|
405
|
+
returns the whole ITU-T corpus as one row per edition (recommendations **and**
|
|
406
|
+
supplements) — `{ "Total", "Data": [ { idrec, rec_name, title, approval_date,
|
|
407
|
+
dms_link, status } ] }`. `DataParserT.parse` maps each row to an `ItemData`
|
|
408
|
+
(`flavor: "itu"`): the primary docid is the **dated** `"ITU-T #{rec_name}"` (e.g.
|
|
409
|
+
`"ITU-T A.1 (10/2000)"`, matching the `ITU-T L.163 (11/2018)` convention), so
|
|
410
|
+
`Pubid::Itu` identifies each **edition** distinctly and index rows/filenames stay
|
|
411
|
+
unique across editions (ITU-T editions differ by approval date, not a `-N` part).
|
|
412
|
+
doctype is derived from `rec_name` markers (`Suppl`/`Amd`/`Cor`/`Annex` →
|
|
413
|
+
`recommendation-{supplement,amendment,corrigendum,annex}`, else `recommendation`).
|
|
414
|
+
A browser `User-Agent` (`USER_AGENT`) is sent because `www.itu.int` sits behind
|
|
415
|
+
the same F5 WAF. Forms `Pubid::Itu` can't parse (e.g. `Annex` variants) are still
|
|
416
|
+
written as data files but left unindexed and surfaced via `#report_errors`.
|
|
417
|
+
- **ITU-T enrichment** (`DataParserT.parse(row, agent, errors)`) — the searchRecs
|
|
418
|
+
row is metadata-thin (docid/title/date/source/doctype), so each record is
|
|
419
|
+
enriched to match the live runtime output: `#fetch_recommendations` builds a
|
|
420
|
+
browser-UA Mechanize `agent` (F5 WAF) and passes it per row; `DataParserT`
|
|
421
|
+
fetches `getRecHdrDetail?idrec=…` (via `RecommendationParser`) and adds the
|
|
422
|
+
**abstract** (`summary`), **ISO/IEC co-identifier** (`iso_number`), **status**,
|
|
423
|
+
**editorial-group + publisher contributors** (the editorial group from the
|
|
424
|
+
`rec.aspx` workgroup page), the **edition** (`getRecEditions` → the row's own
|
|
425
|
+
`Version`) and the **relations** (`hasEdition` per sibling edition,
|
|
426
|
+
`complementOf` per `getRecSupplements` entry). The **source** becomes the
|
|
427
|
+
live path's pair — `src` = `handle_id`, `pdf` = `handle_id_pdf_link`, both from
|
|
428
|
+
the same `getRecHdrDetail` response, so no extra call; a header without
|
|
429
|
+
`handle_id` (or a failed enrichment) keeps the row's `dms_link`. The **date** is upgraded to
|
|
430
|
+
day-precision from the row's `approval_date` (no extra call); **copyright** and
|
|
431
|
+
the Geneva **place** are derived from the row alone (`#fetch_copyright`), so
|
|
432
|
+
even an un-enriched record carries them. Enrichment is **best-effort** — a
|
|
433
|
+
detail-fetch failure is logged and degrades to the thin record rather than
|
|
434
|
+
losing it. It costs up to ~4 calls per record (`getRecHdrDetail`, `getRecEditions`,
|
|
435
|
+
`getRecSupplements`, `rec.aspx`) — the bulk of the crawl — so progress is logged
|
|
436
|
+
every 500 records. This is what makes an indexed runtime lookup as rich as the
|
|
437
|
+
live one; the published dataset predates it (see **Runtime lookup** above).
|
|
438
|
+
- **ITU-T request de-duplication** (`FamilyCache`, `family_cache.rb`) —
|
|
439
|
+
`searchRecs` returns one row per **edition**, but two of those four endpoints
|
|
440
|
+
answer for the whole **recommendation**, so the crawl was asking ITU the same
|
|
441
|
+
question once per edition: H.264 has 21 editions and issued 21 identical
|
|
442
|
+
`getRecEditions` requests. Corpus-wide the fan-out is ~2.1 editions per
|
|
443
|
+
recommendation (16,172 `data/itu-t-*` files over ~7,663 base recommendations).
|
|
444
|
+
Counted against that corpus: **64,688 -> 47,670 requests, ~26% fewer** — and it
|
|
445
|
+
removes them, it does not reorder or accelerate them: the crawl asks ITU for
|
|
446
|
+
*less*. Sharing `getRecSupplements` too (see the table) would take it to
|
|
447
|
+
39,161, ~40% fewer.
|
|
448
|
+
|
|
449
|
+
| endpoint | shared? | evidence |
|
|
450
|
+
|---|---|---|
|
|
451
|
+
| `getRecEditions` | **yes** | **Proven.** `?idrec=16818` and `?idrec=14659` return byte-identical 21-row payloads in `spec/itu/vcr_cassettes/itu_t_h_264.yml`. Structurally safe besides: the response *names every sibling `idrec`*, so one fetch warms the whole family with no name parsing at all. |
|
|
452
|
+
| `rec.aspx` (workgroup) | **yes** | Both recordings in that cassette (`?rec=H.264` and `?rec=14659`) render `ITU-T Study Group 21` — ITU shows the *current* owning study group even on an old edition's page. Also the most expensive request per record: ~90–240 KB of HTML plus a DOM parse. |
|
|
453
|
+
| `getRecSupplements` | **no** | Unverified, so deliberately left per-edition. It very probably is family-invariant, but the suite holds only one recording, and sharing it wrongly would give a record another recommendation's `relation:` list **silently** — the data repo's `guard_enrichment` counts `contributor:` lines, which such a record still has. To settle it: add a `getRecSupplements?idrec=16818` interaction to that cassette, assert the two payloads are equal, then key it on `#family_id`. |
|
|
454
|
+
|
|
455
|
+
**Keys come from ITU, never from parsing a name.** `#family_id` is the smallest
|
|
456
|
+
`idrec` the `getRecEditions` response itself lists. `DataFetcher.family_key`
|
|
457
|
+
(which *does* parse `rec_name`) is used only to order the queue and to group a
|
|
458
|
+
top-up; a wrong guess there costs a cache miss and nothing else.
|
|
459
|
+
`#siblings_of` refuses to warm a response that does not list the `idrec` it
|
|
460
|
+
asked for — losing the de-duplication is the cheap failure.
|
|
461
|
+
|
|
462
|
+
**The cache never touches the runtime path.** `RecommendationFields#cache`
|
|
463
|
+
defaults to `NullCache`, whose `#fetch` is a bare yield and whose `#caching?`
|
|
464
|
+
is false; `Scraper` keeps calling the three-argument
|
|
465
|
+
`RecommendationParser.new`, so no path from `Bibliography.get` can reach a real
|
|
466
|
+
cache, retains no state between lookups, and issues exactly the requests it did
|
|
467
|
+
before. `RELATON_ITU_CACHE_ENTRIES=0` turns it off for the crawl too; the
|
|
468
|
+
cache is otherwise a bounded LRU (512 entries), because ~7.7k families of
|
|
469
|
+
retained JSON would run to hundreds of MB on a CI runner.
|
|
470
|
+
- **ITU-T top-up** (`#held_t?` / `#top_up_rows`) — `RELATON_ITU_MODE=top_up` now
|
|
471
|
+
governs **both** sectors. `#held_t?` is the ITU-T counterpart of `#held?` and is
|
|
472
|
+
HTTP-free: `DataParserT.fetch_docid` reads only `row["rec_name"]`, so the
|
|
473
|
+
decision happens before any of the four detail requests. The unit of work is
|
|
474
|
+
the **family, not the row**: a new edition changes its siblings' `hasEdition`
|
|
475
|
+
relations, so topping up only the new row would leave the rest of the family on
|
|
476
|
+
disk pointing at an incomplete edition list — a staleness the full run's
|
|
477
|
+
wipe-and-rebuild never had. **This needs the data repo to stop wiping
|
|
478
|
+
`data/itu-t-*` unconditionally and to `index_files` them on a top-up**, exactly
|
|
479
|
+
as it already does for ITU-R; until it does, `top_up` is inert for ITU-T
|
|
480
|
+
because every file has just been deleted.
|
|
481
|
+
- **Shared extractor `RecommendationFields`** (`recommendation_fields.rb`) — a
|
|
482
|
+
mixin keyed on `agent`/`idrec`/`imp` hooks. The **`getRecHdrDetail`-sourced field
|
|
483
|
+
extraction** (`fetch_titles`/`fetch_status`/`fetch_dates`/`fetch_abstract`/
|
|
484
|
+
`fetch_source`/`fetch_relations`/`fetch_workgroup`) is **genuinely shared**:
|
|
485
|
+
`RecommendationParser` is now Hit-agnostic (`new(agent, idrec, imp)`), `include`s
|
|
486
|
+
the module, and is used by **both** the live path (`Scraper` builds it with
|
|
487
|
+
`hit.hit_collection.agent`) and the harvester (`DataParserT`, via a
|
|
488
|
+
`RecommendationParser` instance) — one implementation, so those fields can't
|
|
489
|
+
drift. Path-specific bits stay in the concrete classes: row-based
|
|
490
|
+
docid/title/date/doctype in `DataParserT`; `imp` handling in the live path.
|
|
491
|
+
The module also carries `iso_docid`/`editorial_group`/`publisher`/
|
|
492
|
+
`group_subdivision`, used by the **harvester**. NOTE: the live `Scraper` still
|
|
493
|
+
keeps its own equivalent contributor/ISO logic (`fetch_editorial_contributor`/
|
|
494
|
+
`fetch_publisher_contributors`/`group_subdivision`/`createdocid`) because that
|
|
495
|
+
code also serves the RR/OB path — so those four are currently **parallel copies**
|
|
496
|
+
kept in sync, not yet a single source. Fully folding `Scraper`'s recommendation
|
|
497
|
+
contributors onto the module (guarding the RR/OB branch) is a deferred follow-up;
|
|
498
|
+
it also converges naturally once the issue-75 live-path ISO change (`iso_number`)
|
|
499
|
+
lands here.
|
|
500
|
+
- **Hosting:** both sectors live in the combined **`relaton/relaton-data-itu`** repo
|
|
501
|
+
under one flat `data/` and one `pubid:itu` `index-v2` (published on `main`;
|
|
502
|
+
~21.5k records, ~20.5k indexed). Runtime consumers **are** repointed at it —
|
|
503
|
+
`HitCollection::GH_ITU` — so `relaton-data-itu-r` is no longer read.
|
|
504
|
+
|
|
505
|
+
### Processor
|
|
506
|
+
|
|
507
|
+
`Relaton::Itu::Processor` extends `Relaton::Core::Processor` and is the entry point for the Relaton plugin system. Provides `get`, `fetch_data`, `from_xml`, `hash_to_bib`, `grammar_hash`, and `remove_index_file`.
|
|
508
|
+
|
|
509
|
+
### Pubid-backed index-v2
|
|
510
|
+
|
|
511
|
+
The ITU index is **pubid-structured** (`index-v2.yaml`/`.zip`, `INDEXFILE =
|
|
512
|
+
"index-v2"` in `lib/relaton/itu.rb`): each row's `:id` is a `Pubid::Itu::Identifier`
|
|
513
|
+
serialized to its `_type: pubid:itu:{recommendation,handbook,question,…}` hash in the
|
|
514
|
+
**flat, compact** shape (scalar `sector`/`series`/`number`/`parts` directly under
|
|
515
|
+
`_type`, e.g. `sector: R`, `number: '600'`, `parts: ['1']`) that the published
|
|
516
|
+
`relaton-data-itu` index-v2 carries (both sectors; `pubid:itu:supplement` and
|
|
517
|
+
`pubid:itu:amendment` rows come with the ITU-T half). That flat shape + the handbook/question
|
|
518
|
+
identifier types (and pubid #290's `matches?`/`exclude` fix) are in the released
|
|
519
|
+
pubid; the query forms of #460 are not yet, so the root `Gemfile` **temporarily
|
|
520
|
+
pins** pubid to the #460 merge (see the pin note in the root `CLAUDE.md`). A
|
|
521
|
+
mismatched pubid produces a different `to_hash` shape and `Relaton::Index`
|
|
522
|
+
rejects the whole index.
|
|
523
|
+
The wiring mirrors NIST/ETSI/CIE:
|
|
524
|
+
|
|
525
|
+
- **Producer** (`DataFetcher`): `#index` calls `find_or_create(:itu, file:
|
|
526
|
+
"#{INDEXFILE}.yaml", pubid_class: ::Pubid::Itu::Identifier)`; `#write_file`
|
|
527
|
+
routes the primary id through `#index_primary`, which stores the **pubid object**
|
|
528
|
+
(`index.add_or_update pid, file`) so `Relaton::Index` sorts by id number and
|
|
529
|
+
serializes each id to its `_type:` hash on save.
|
|
530
|
+
- **The `#pubid` guard** parses via `::Pubid::Itu.parse` and additionally requires
|
|
531
|
+
a lossless round-trip (`Identifier.from_hash(to_hash).to_hash == to_hash`, the
|
|
532
|
+
index loader's own `Index::FileIO#id_supported?` acceptance test), returning nil
|
|
533
|
+
otherwise. The pinned pubid models recommendations, **handbooks**
|
|
534
|
+
(`ITU-R 23.HDB`), **questions** (`ITU-R 37-7/5:`), ITU-T `(V##)` versions and
|
|
535
|
+
labelled `Annex`es (#320), and — since **#325** — every remaining ITU-T print
|
|
536
|
+
form the corpus carries: `Technical Cor.`, Appendices, `bis`/`ter`
|
|
537
|
+
(`ITU-T V.25 ter`), the D-series `R` suffix, series supplements, joint
|
|
538
|
+
numbering, `Add. N`, and bare `v10`/`V2`/`v.1` versions; since **#460** it also
|
|
539
|
+
parses `ITU-R RR` (`pubid:itu:radio-regulations`, `root.number` `"RR"`), so a
|
|
540
|
+
Radio Regulations record on disk now **indexes** (the consumer still serves RR
|
|
541
|
+
from `/pub`). What the guard still skips is the malformed ITU-R docids
|
|
542
|
+
described under **Index/data gap** below. The **published** index predates #325, so the gap
|
|
543
|
+
closes on the next crawl. A skipped id is **not indexed** but
|
|
544
|
+
its data file is still written; `#index_primary` records it in `#unparseable_ids`,
|
|
545
|
+
and `#report_errors` (ISO-style) surfaces them at `:error` through the `gh_issue`
|
|
546
|
+
channel, raising the "Error fetching documents" GitHub issue in CI
|
|
547
|
+
(`ENV["GITHUB_REPOSITORY"]`). The published index-v2 carries ~5285 rows.
|
|
548
|
+
- **Consumer** (`HitCollection#index`, shared by `#request_document` and
|
|
549
|
+
`#index_hits`): `find_or_create(:itu, url: "#{GH_ITU}#{INDEXFILE}.zip", file:,
|
|
550
|
+
pubid_class: ::Pubid::Itu::Identifier)` against the **combined** repo — one index
|
|
551
|
+
serves both sectors — then `index.search(pubid) { |i| index_match?(pubid, i[:id]) }`.
|
|
552
|
+
The reference is passed as a pubid (not a String) so `Relaton::Index` narrows by
|
|
553
|
+
document number (`id.root.number`) before the block. `HitCollection` receives
|
|
554
|
+
the reference already parsed (see **Runtime lookup**): ITU-R searches with
|
|
555
|
+
`ref` itself, ITU-T with `ref.exclude(:year, :month, :day)`, because every
|
|
556
|
+
edition is a candidate and `Bibliography` selects the year.
|
|
557
|
+
- `#index_match?` delegates to pubid's structured `pubid.matches?(id, ignore:)`,
|
|
558
|
+
ignoring `:parts` **only when the reference omits the part** — so a bare
|
|
559
|
+
`ITU-R P.838` matches **every edition** (all `P.838-N`) while `ITU-R P.838-2`
|
|
560
|
+
matches only that edition, and a bare `ITU-R M.1` does **not** match `ITU-R M.10`
|
|
561
|
+
(a different document number, nor `-1` match `-10`) because pubid compares
|
|
562
|
+
structured ids, not a `-`-anchored string. This mirrors the ETSI flavor's
|
|
563
|
+
`Bibliography#best_match`; it works now that pubid #290 fixed
|
|
564
|
+
`Pubid::Itu::Identifier#matches?`/`#exclude` (previously an
|
|
565
|
+
`exclude`→`self.class.new` kwargs `ArgumentError`), replacing the earlier local
|
|
566
|
+
anchored-`-` stopgap. It additionally ignores **`:year`/`:month`/`:version`**:
|
|
567
|
+
ITU-T rows are one per dated (and possibly versioned) edition, so those are
|
|
568
|
+
separable trailing components the reference is normalised away from — the same
|
|
569
|
+
contract ETSI has (`omits: %i[version date]`) — and `Bibliography` narrows to
|
|
570
|
+
the requested year/version afterwards. There is **no local series guard**: pubid
|
|
571
|
+
#320 fixed `Supplement#==` to compare sector+series (before it, `ITU-T A Suppl. 2`
|
|
572
|
+
matched every series' `Suppl. 2` — 42 rows — and the flavor carried a
|
|
573
|
+
`same_document_family?` workaround). `#request_document`'s
|
|
574
|
+
`max_by { |i| i[:id].code&.parts&.last.to_i }` then returns the latest edition by
|
|
575
|
+
its numeric `code.parts` edition (pubid identifiers aren't Comparable, so a
|
|
576
|
+
numeric key is needed; the index isn't ordered by edition).
|
|
577
|
+
- **Index/data gap — now ITU-R data quality only.** History: 979 unindexed → 755
|
|
578
|
+
after two re-crawls → **47 after pubid #325**, which parses every ITU-T print
|
|
579
|
+
form the dataset carries (`Technical Cor.`, Appendices, `bis`/`ter`, the
|
|
580
|
+
D-series `R` suffix, series supplements, joint numbering, `Add. N`, bare
|
|
581
|
+
`v10`/`V2`/`v.1` versions). Verified against `relaton-data-itu@9ecdaec5` with
|
|
582
|
+
pubid `ac2fd517`: of the 755 then-unindexed records **708 index on the next
|
|
583
|
+
crawl**, and re-parsing all 20,728 already-indexed ids produces **zero** hash
|
|
584
|
+
changes — so no published index row changes shape.
|
|
585
|
+
|
|
586
|
+
The 47 that remain are **all ITU-R**, and none is an identifier problem: they
|
|
587
|
+
are malformed docids left by the decommissioned RunSearch crawler — series
|
|
588
|
+
letters with no document number (`ITU-R BO`, `ITU-R BT`), Roman-numeral
|
|
589
|
+
fragments (`ITU-R IV.IX`), study-group fragments (`ITU-R M.5-BL-13`) and
|
|
590
|
+
trailing colons (`ITU-R 111-2:`). They have no number to key an index row on,
|
|
591
|
+
so they are data to correct in `relaton-data-itu`, not identifiers to parse.
|
|
592
|
+
|
|
593
|
+
relaton keeps exactly **one** normalisation (`DataParserT#normalize_rec_name`):
|
|
594
|
+
a space where the series dot belongs (`G 231` → `G.231`, 4 records). pubid
|
|
595
|
+
rejects that spelling deliberately — a space there is ambiguous against the
|
|
596
|
+
series-only supplement form (`G Suppl. 1`) — so canonicalising it here is the
|
|
597
|
+
agreed division of labour, not a workaround.
|
|
598
|
+
- **Processor** `#remove_index_file` passes `url: true` and `file:` only. It
|
|
599
|
+
needs no `pubid_class:`: the delete never reads the index (see
|
|
600
|
+
`lib/relaton/index/CLAUDE.md`).
|
|
601
|
+
|
|
602
|
+
There is **one** ITU parser, `::Pubid::Itu`: for the caller's reference, the
|
|
603
|
+
hit codes (`Bibliography.search_filter`, `Scraper#createdocid`), the
|
|
604
|
+
`Docidentifier` and the index. The local Parslet grammar `Relaton::Itu::Pubid`
|
|
605
|
+
is gone (see **Runtime lookup**).
|
|
606
|
+
|
|
607
|
+
## Testing
|
|
608
|
+
|
|
609
|
+
- **Index fixture:** `spec/fixtures/index-v2.zip` holds rows copied **verbatim**
|
|
610
|
+
from the published `relaton-data-itu` `index-v2.zip` (`main`), so the specs stay
|
|
611
|
+
honest about the production `_type: pubid:itu:*` shape — but trimmed to 5361 of
|
|
612
|
+
its 20,472 rows, because deserializing the whole index costs ~37 s in
|
|
613
|
+
`before(:suite)`. Kept: **every** ITU-R row (~5285) plus the ITU-T families the
|
|
614
|
+
specs exercise, including deliberate decoys (`L.1630` next to `L.163`;
|
|
615
|
+
`D Suppl. 2`/`G Suppl. 2` next to `A Suppl. 2`; `H.264.1` next to `H.264`) and the
|
|
616
|
+
deliberate **absence** of `H.264 (V14)`, which is what exercises the live
|
|
617
|
+
fallback. Regenerate with:
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
# ruby -rnet/http -ryaml -rzip -rstringio
|
|
621
|
+
url = "https://raw.githubusercontent.com/relaton/relaton-data-itu/refs/heads/main/index-v2.zip"
|
|
622
|
+
keep = %w[itu-t-l-163- itu-t-l-1630- itu-t-z-100- itu-t-y-3500- itu-t-a-13-
|
|
623
|
+
itu-t-a-suppl-2- itu-t-d-suppl-2- itu-t-g-suppl-2- itu-t-g-suppl-47-
|
|
624
|
+
itu-t-g-989-2- itu-t-g-780-y-1351- itu-t-h-264-]
|
|
625
|
+
Zip::File.open_buffer(StringIO.new(Net::HTTP.get(URI(url)))) { |z| @yaml = z.first.get_input_stream.read }
|
|
626
|
+
rows = YAML.safe_load(@yaml, permitted_classes: [Symbol]).select do |r|
|
|
627
|
+
b = File.basename(r[:file]); b.start_with?("itu-r-") || keep.any? { |p| b.start_with?(p) }
|
|
628
|
+
end
|
|
629
|
+
Zip::File.open("spec/itu/fixtures/index-v2.zip", Zip::File::CREATE) do |z|
|
|
630
|
+
z.get_output_stream("index-v2.yaml") { |f| f.write rows.to_yaml }
|
|
631
|
+
end
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
It is loaded into the `Relaton::Index` pool in `before(:suite)`
|
|
635
|
+
(`spec/support/webmock.rb`): the YAML is written to a temp file and read through
|
|
636
|
+
`Relaton::Index::Type.new(:itu, file:, pubid_class: ::Pubid::Itu::Identifier)`, and
|
|
637
|
+
`type.index` forces the offline `pubid_class` deserialize before the net is
|
|
638
|
+
blocked; `actual?` is overridden to match only the remote (`url:`) lookup. The
|
|
639
|
+
fixture is **re-seated in `before(:each)`** because a producer-side
|
|
640
|
+
`find_or_create(:itu, file:)` in another example evicts the url-serving entry
|
|
641
|
+
from the shared pool (`Pool#type` replaces a non-`actual?` entry) — without the
|
|
642
|
+
re-seat a later consumer lookup would rebuild a network-backed `Type` and hit the
|
|
643
|
+
blocked net. The rows must deserialize under the pinned pubid — see the `Gemfile`
|
|
644
|
+
pin note.
|
|
645
|
+
- **Crawler cassettes: one per example, and re-record this suite alone.** The
|
|
646
|
+
`spec/itu/vcr_cassettes/itu_r_re*` cassettes back `data_crawler_r_spec.rb`, and
|
|
647
|
+
each is owned by **exactly one example**. That is not stylistic: VCR re-records
|
|
648
|
+
per cassette *insertion*, so a cassette shared across several examples is
|
|
649
|
+
truncated to the first example's requests the moment the 7-day
|
|
650
|
+
`re_record_interval` fires (verified — `itu_r_rec_bo` went 10 interactions → 1
|
|
651
|
+
and took 15 examples down with it). If you add a crawler example, give it its
|
|
652
|
+
own cassette rather than reusing one.
|
|
653
|
+
|
|
654
|
+
`www.itu.int` sits behind a rate-limiting F5 WAF, so a refresh can bake a
|
|
655
|
+
**block page** into a cassette instead of data — and ITU blocks the two path
|
|
656
|
+
families differently: `/rec` answers **HTTP 503**, `/pub` answers a **302 to
|
|
657
|
+
`notfound.aspx`**, which replays as a page with no rows rather than an error.
|
|
658
|
+
That is the "recorded transport failure" case from the repo-root conventions:
|
|
659
|
+
nothing to reconcile, just re-record. Re-record this suite **alone**
|
|
660
|
+
(`cd spec/itu && bundle exec rspec -I . relaton/itu/data_crawler_r_spec.rb`),
|
|
661
|
+
never under parallel `rake spec`, and sanity-check the counts afterwards — an
|
|
662
|
+
empty series or a document count that collapsed to 0 is a block, not ITU
|
|
663
|
+
deleting its corpus.
|
|
664
|
+
- **Framework:** RSpec with VCR cassettes for HTTP mocking and WebMock
|
|
665
|
+
- **Fixtures:** `spec/fixtures/` contains sample YAML/XML documents for round-trip tests
|
|
666
|
+
- **VCR cassettes:** `spec/vcr_cassettes/` — 19 cassettes recording real HTTP
|
|
667
|
+
responses. VCR ignores only `index-v2.zip` (the index comes from the fixture
|
|
668
|
+
above), so the per-record `raw.githubusercontent.com/…/data/*.yaml` GETs **are**
|
|
669
|
+
recorded. Re-record by deleting the file and running the suite with network
|
|
670
|
+
access; `re_record_interval` is 7 days, so a stale-cassette run refreshes them.
|
|
671
|
+
- **Coverage:** SimpleCov, target near 100%
|
|
672
|
+
|
|
673
|
+
Round-trip tests (serialize → deserialize → compare) are the primary pattern for model classes.
|
|
674
|
+
|
|
675
|
+
## Key Dependencies
|
|
676
|
+
|
|
677
|
+
- `relaton-bib` — base bibliographic model classes (`Bib::Item`, `Bib::Ext`, etc.)
|
|
678
|
+
- `relaton-core` — `Core::Processor`, `Core::DataFetcher` base classes
|
|
679
|
+
- `lutaml-model` — serialization framework (XML/YAML mapping via `Lutaml::Model::Serializable`)
|
|
680
|
+
- `mechanize` — web scraping for data fetching
|
|
681
|
+
- `relaton-index` — document indexing
|
|
682
|
+
|
|
683
|
+
## Ruby Version
|
|
684
|
+
|
|
685
|
+
Requires Ruby >= 3.1.0.
|