relaton 3.0.0.pre.alpha.1 → 3.0.0.pre.alpha.2

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.
Files changed (270) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +57 -1
  3. data/bin/console +0 -1
  4. data/lib/relaton/3gpp/bibliography.rb +82 -7
  5. data/lib/relaton/3gpp/data_fetcher.rb +51 -3
  6. data/lib/relaton/3gpp/docidentifier.rb +114 -0
  7. data/lib/relaton/3gpp/item.rb +6 -0
  8. data/lib/relaton/3gpp/parser.rb +1 -1
  9. data/lib/relaton/3gpp/processor.rb +4 -1
  10. data/lib/relaton/3gpp.rb +5 -1
  11. data/lib/relaton/adobe/bibdata.rb +8 -0
  12. data/lib/relaton/adobe/bibitem.rb +8 -0
  13. data/lib/relaton/adobe/bibliography.rb +92 -0
  14. data/lib/relaton/adobe/docidentifier.rb +49 -0
  15. data/lib/relaton/adobe/doctype.rb +14 -0
  16. data/lib/relaton/adobe/ext.rb +32 -0
  17. data/lib/relaton/adobe/item.rb +15 -0
  18. data/lib/relaton/adobe/item_base.rb +18 -0
  19. data/lib/relaton/adobe/item_data.rb +6 -0
  20. data/lib/relaton/adobe/processor.rb +45 -0
  21. data/lib/relaton/adobe/util.rb +8 -0
  22. data/lib/relaton/adobe.rb +37 -0
  23. data/lib/relaton/bib/model/address.rb +2 -2
  24. data/lib/relaton/bib/model/docidentifier.rb +24 -9
  25. data/lib/relaton/bib/model/localized_string.rb +1 -1
  26. data/lib/relaton/bib/model/structured_identifier.rb +10 -9
  27. data/lib/relaton/bib/sanitizer.rb +202 -6
  28. data/lib/relaton/bib.rb +0 -2
  29. data/lib/relaton/bipm/bibliography.rb +159 -10
  30. data/lib/relaton/bipm/data_fetcher.rb +26 -2
  31. data/lib/relaton/bipm/data_outcomes_parser.rb +3 -4
  32. data/lib/relaton/bipm/id_parser.rb +5 -4
  33. data/lib/relaton/bipm/model/structured_identifier.rb +21 -0
  34. data/lib/relaton/bipm/processor.rb +2 -2
  35. data/lib/relaton/bipm/rawdata_bipm_metrologia/fetcher.rb +2 -4
  36. data/lib/relaton/bipm/si_brochure_parser.rb +5 -3
  37. data/lib/relaton/bipm.rb +7 -1
  38. data/lib/relaton/bsi/bibliography.rb +115 -43
  39. data/lib/relaton/bsi/hit.rb +14 -0
  40. data/lib/relaton/bsi/hit_collection.rb +15 -16
  41. data/lib/relaton/bsi/model/docidentifier.rb +99 -1
  42. data/lib/relaton/bsi/processor.rb +1 -0
  43. data/lib/relaton/calconnect/bibliography.rb +12 -14
  44. data/lib/relaton/calconnect/data_fetcher.rb +77 -9
  45. data/lib/relaton/calconnect/docidentifier.rb +80 -0
  46. data/lib/relaton/calconnect/hit_collection.rb +65 -57
  47. data/lib/relaton/calconnect/model/item.rb +7 -0
  48. data/lib/relaton/calconnect/processor.rb +7 -1
  49. data/lib/relaton/calconnect.rb +11 -1
  50. data/lib/relaton/ccsds/data/fetcher.rb +17 -12
  51. data/lib/relaton/ccsds/data/parser.rb +1 -1
  52. data/lib/relaton/ccsds/hit_collection.rb +6 -1
  53. data/lib/relaton/ccsds/model/docidentifier.rb +121 -0
  54. data/lib/relaton/ccsds/model/item.rb +2 -0
  55. data/lib/relaton/cen/bibliography.rb +75 -47
  56. data/lib/relaton/cen/hit.rb +16 -1
  57. data/lib/relaton/cen/hit_collection.rb +59 -11
  58. data/lib/relaton/cen/model/docidentifier.rb +92 -1
  59. data/lib/relaton/cen/processor.rb +13 -8
  60. data/lib/relaton/cen/scraper.rb +13 -5
  61. data/lib/relaton/cen.rb +1 -0
  62. data/lib/relaton/cie/data_fetcher.rb +215 -30
  63. data/lib/relaton/cie/processor.rb +3 -1
  64. data/lib/relaton/cie/scrapper.rb +15 -2
  65. data/lib/relaton/cie.rb +2 -1
  66. data/lib/relaton/core/data_fetcher.rb +150 -3
  67. data/lib/relaton/core/governor.rb +320 -0
  68. data/lib/relaton/core/pacer.rb +134 -0
  69. data/lib/relaton/core/processor.rb +19 -0
  70. data/lib/relaton/core/request_error.rb +14 -0
  71. data/lib/relaton/core.rb +3 -0
  72. data/lib/relaton/db/registry.rb +41 -1
  73. data/lib/relaton/doi/crossref.rb +19 -2
  74. data/lib/relaton/doi/parser.rb +109 -15
  75. data/lib/relaton/easc/bibdata.rb +8 -0
  76. data/lib/relaton/easc/bibitem.rb +8 -0
  77. data/lib/relaton/easc/bibliography.rb +95 -0
  78. data/lib/relaton/easc/docidentifier.rb +100 -0
  79. data/lib/relaton/easc/doctype.rb +14 -0
  80. data/lib/relaton/easc/ext.rb +44 -0
  81. data/lib/relaton/easc/item.rb +13 -0
  82. data/lib/relaton/easc/item_base.rb +18 -0
  83. data/lib/relaton/easc/item_data.rb +6 -0
  84. data/lib/relaton/easc/processor.rb +46 -0
  85. data/lib/relaton/easc/util.rb +8 -0
  86. data/lib/relaton/easc.rb +35 -0
  87. data/lib/relaton/ecma/bibliography.rb +93 -25
  88. data/lib/relaton/ecma/data_fetcher.rb +71 -12
  89. data/lib/relaton/ecma/docidentifier.rb +124 -0
  90. data/lib/relaton/ecma/item.rb +2 -0
  91. data/lib/relaton/ecma/memento_parser.rb +1 -1
  92. data/lib/relaton/ecma/page_fetcher.rb +15 -3
  93. data/lib/relaton/ecma/parser_common.rb +2 -2
  94. data/lib/relaton/ecma/processor.rb +4 -1
  95. data/lib/relaton/ecma/standard_parser.rb +2 -2
  96. data/lib/relaton/ecma.rb +10 -1
  97. data/lib/relaton/etsi/bibliography.rb +67 -2
  98. data/lib/relaton/etsi/data_fetcher.rb +43 -4
  99. data/lib/relaton/etsi/processor.rb +3 -1
  100. data/lib/relaton/etsi.rb +2 -1
  101. data/lib/relaton/gb/bibliography.rb +55 -29
  102. data/lib/relaton/gb/docidentifier.rb +58 -9
  103. data/lib/relaton/gb/processor.rb +3 -0
  104. data/lib/relaton/gb/scraper.rb +27 -10
  105. data/lib/relaton/gost/bibdata.rb +8 -0
  106. data/lib/relaton/gost/bibitem.rb +8 -0
  107. data/lib/relaton/gost/bibliography.rb +107 -0
  108. data/lib/relaton/gost/docidentifier.rb +80 -0
  109. data/lib/relaton/gost/doctype.rb +16 -0
  110. data/lib/relaton/gost/ext.rb +46 -0
  111. data/lib/relaton/gost/item.rb +15 -0
  112. data/lib/relaton/gost/item_base.rb +18 -0
  113. data/lib/relaton/gost/item_data.rb +6 -0
  114. data/lib/relaton/gost/processor.rb +49 -0
  115. data/lib/relaton/gost/util.rb +8 -0
  116. data/lib/relaton/gost.rb +36 -0
  117. data/lib/relaton/iala/bibdata.rb +8 -0
  118. data/lib/relaton/iala/bibitem.rb +8 -0
  119. data/lib/relaton/iala/bibliography.rb +146 -0
  120. data/lib/relaton/iala/docidentifier.rb +89 -0
  121. data/lib/relaton/iala/doctype.rb +18 -0
  122. data/lib/relaton/iala/ext.rb +32 -0
  123. data/lib/relaton/iala/item.rb +21 -0
  124. data/lib/relaton/iala/item_base.rb +18 -0
  125. data/lib/relaton/iala/item_data.rb +6 -0
  126. data/lib/relaton/iala/processor.rb +43 -0
  127. data/lib/relaton/iala/relation.rb +7 -0
  128. data/lib/relaton/iala/util.rb +8 -0
  129. data/lib/relaton/iala.rb +35 -0
  130. data/lib/relaton/iana/bibliography.rb +67 -14
  131. data/lib/relaton/iana/data_fetcher.rb +35 -5
  132. data/lib/relaton/iana/processor.rb +3 -1
  133. data/lib/relaton/iana.rb +12 -1
  134. data/lib/relaton/iec/data_fetcher.rb +7 -1
  135. data/lib/relaton/iec/hit_collection.rb +1 -1
  136. data/lib/relaton/iec/model/docidentifier.rb +9 -5
  137. data/lib/relaton/iec/model/ext.rb +2 -2
  138. data/lib/relaton/iec/processor.rb +1 -0
  139. data/lib/relaton/ieee/bibliography.rb +25 -3
  140. data/lib/relaton/ieee/data_fetcher.rb +158 -17
  141. data/lib/relaton/ieee/idams_parser.rb +18 -11
  142. data/lib/relaton/ieee/processor.rb +4 -1
  143. data/lib/relaton/ieee/rawbib_id_parser.rb +291 -86
  144. data/lib/relaton/ieee.rb +2 -1
  145. data/lib/relaton/ietf/data_fetcher.rb +295 -12
  146. data/lib/relaton/ietf/processor.rb +7 -3
  147. data/lib/relaton/ietf/rfc/entry.rb +39 -3
  148. data/lib/relaton/ietf/scraper.rb +69 -36
  149. data/lib/relaton/ietf.rb +4 -1
  150. data/lib/relaton/iho/bibliography.rb +1 -1
  151. data/lib/relaton/iho/docidentifier.rb +1 -1
  152. data/lib/relaton/index/file_io.rb +11 -11
  153. data/lib/relaton/index/file_storage.rb +6 -1
  154. data/lib/relaton/index/pool.rb +6 -1
  155. data/lib/relaton/index/shard_source.rb +201 -0
  156. data/lib/relaton/index/type.rb +63 -12
  157. data/lib/relaton/index.rb +2 -1
  158. data/lib/relaton/iso/bibliography.rb +20 -15
  159. data/lib/relaton/iso/data_fetcher.rb +3 -3
  160. data/lib/relaton/iso/data_parser.rb +17 -3
  161. data/lib/relaton/iso/hit_collection.rb +27 -15
  162. data/lib/relaton/iso/item_data.rb +22 -0
  163. data/lib/relaton/iso/model/docidentifier.rb +24 -12
  164. data/lib/relaton/iso/processor.rb +1 -0
  165. data/lib/relaton/iso/scraper.rb +19 -3
  166. data/lib/relaton/itu/bibliography.rb +9 -4
  167. data/lib/relaton/itu/data_crawler_r.rb +664 -0
  168. data/lib/relaton/itu/data_fetcher.rb +496 -50
  169. data/lib/relaton/itu/data_merge_r.rb +149 -0
  170. data/lib/relaton/itu/data_parser_r.rb +163 -89
  171. data/lib/relaton/itu/data_parser_t.rb +228 -0
  172. data/lib/relaton/itu/family_cache.rb +177 -0
  173. data/lib/relaton/itu/governor.rb +56 -0
  174. data/lib/relaton/itu/hit.rb +9 -3
  175. data/lib/relaton/itu/hit_collection.rb +258 -86
  176. data/lib/relaton/itu/model/docidentifier.rb +67 -1
  177. data/lib/relaton/itu/model/structured_identifier.rb +19 -0
  178. data/lib/relaton/itu/processor.rb +10 -4
  179. data/lib/relaton/itu/pubid.rb +27 -5
  180. data/lib/relaton/itu/recommendation_fields.rb +334 -0
  181. data/lib/relaton/itu/recommendation_parser.rb +18 -149
  182. data/lib/relaton/itu/scraper.rb +13 -3
  183. data/lib/relaton/itu.rb +2 -1
  184. data/lib/relaton/jcgm/bibdata.rb +8 -0
  185. data/lib/relaton/jcgm/bibitem.rb +8 -0
  186. data/lib/relaton/jcgm/bibliography.rb +97 -0
  187. data/lib/relaton/jcgm/data_fetcher.rb +81 -0
  188. data/lib/relaton/jcgm/docidentifier.rb +102 -0
  189. data/lib/relaton/jcgm/doctype.rb +12 -0
  190. data/lib/relaton/jcgm/ext.rb +23 -0
  191. data/lib/relaton/jcgm/item.rb +20 -0
  192. data/lib/relaton/jcgm/item_base.rb +18 -0
  193. data/lib/relaton/jcgm/item_data.rb +6 -0
  194. data/lib/relaton/jcgm/meetings_parser.rb +175 -0
  195. data/lib/relaton/jcgm/processor.rb +71 -0
  196. data/lib/relaton/jcgm/relation.rb +9 -0
  197. data/lib/relaton/jcgm/structured_identifier.rb +40 -0
  198. data/lib/relaton/jcgm/util.rb +8 -0
  199. data/lib/relaton/jcgm.rb +24 -0
  200. data/lib/relaton/jis/bibliography.rb +8 -10
  201. data/lib/relaton/jis/data_fetcher.rb +21 -19
  202. data/lib/relaton/jis/docidentifier.rb +104 -5
  203. data/lib/relaton/jis/hit.rb +18 -23
  204. data/lib/relaton/jis/hit_collection.rb +19 -18
  205. data/lib/relaton/jis/processor.rb +1 -1
  206. data/lib/relaton/jis.rb +2 -3
  207. data/lib/relaton/logger/channels/gh_issue.rb +78 -13
  208. data/lib/relaton/nist/data_fetcher.rb +63 -13
  209. data/lib/relaton/nist/docidentifier.rb +165 -0
  210. data/lib/relaton/nist/item.rb +2 -0
  211. data/lib/relaton/nist/item_base.rb +16 -0
  212. data/lib/relaton/nist/mods_parser.rb +38 -12
  213. data/lib/relaton/nist/processor.rb +2 -1
  214. data/lib/relaton/nist/relation.rb +3 -0
  215. data/lib/relaton/nist/scraper.rb +6 -3
  216. data/lib/relaton/oasis/bibliography.rb +147 -6
  217. data/lib/relaton/oasis/data_fetcher.rb +41 -5
  218. data/lib/relaton/oasis/data_parser_utils.rb +37 -3
  219. data/lib/relaton/oasis/docidentifier.rb +54 -0
  220. data/lib/relaton/oasis/item.rb +3 -0
  221. data/lib/relaton/oasis/processor.rb +7 -1
  222. data/lib/relaton/oasis.rb +14 -1
  223. data/lib/relaton/ogc/data_fetcher.rb +23 -2
  224. data/lib/relaton/ogc/docidentifier.rb +105 -0
  225. data/lib/relaton/ogc/hit_collection.rb +78 -3
  226. data/lib/relaton/ogc/processor.rb +2 -1
  227. data/lib/relaton/ogc.rb +5 -1
  228. data/lib/relaton/oiml/bibliography.rb +90 -15
  229. data/lib/relaton/oiml/docidentifier.rb +18 -3
  230. data/lib/relaton/omg/docidentifier.rb +67 -0
  231. data/lib/relaton/omg/item.rb +1 -0
  232. data/lib/relaton/omg/processor.rb +1 -0
  233. data/lib/relaton/omg/scraper.rb +61 -16
  234. data/lib/relaton/omg.rb +1 -0
  235. data/lib/relaton/plateau/bibliography.rb +10 -3
  236. data/lib/relaton/plateau/data_fetcher.rb +25 -2
  237. data/lib/relaton/plateau/handbook_parser.rb +8 -1
  238. data/lib/relaton/plateau/hit.rb +10 -2
  239. data/lib/relaton/plateau/hit_collection.rb +31 -11
  240. data/lib/relaton/plateau/processor.rb +3 -1
  241. data/lib/relaton/plateau/technical_report_parser.rb +8 -1
  242. data/lib/relaton/plateau.rb +2 -1
  243. data/lib/relaton/sdo/config.rb +34 -0
  244. data/lib/relaton/sdo/fetcher.rb +52 -0
  245. data/lib/relaton/sdo/logo.rb +95 -0
  246. data/lib/relaton/sdo/name.rb +26 -0
  247. data/lib/relaton/sdo/organization.rb +71 -0
  248. data/lib/relaton/sdo/store.rb +49 -0
  249. data/lib/relaton/sdo.rb +29 -0
  250. data/lib/relaton/version.rb +1 -1
  251. data/lib/relaton/w3c/bibliography.rb +132 -12
  252. data/lib/relaton/w3c/data_fetcher.rb +194 -16
  253. data/lib/relaton/w3c/data_parser.rb +3 -3
  254. data/lib/relaton/w3c/docidentifier.rb +48 -0
  255. data/lib/relaton/w3c/governor.rb +32 -0
  256. data/lib/relaton/w3c/item.rb +3 -0
  257. data/lib/relaton/w3c/pubid.rb +12 -0
  258. data/lib/relaton/w3c/safe_realize.rb +110 -21
  259. data/lib/relaton/w3c.rb +12 -1
  260. data/lib/relaton/xsf/bibliography.rb +61 -1
  261. data/lib/relaton/xsf/data_fetcher.rb +55 -5
  262. data/lib/relaton/xsf/docidentifier.rb +46 -0
  263. data/lib/relaton/xsf/hit_collection.rb +31 -3
  264. data/lib/relaton/xsf/item.rb +6 -0
  265. data/lib/relaton/xsf/processor.rb +1 -0
  266. data/lib/relaton/xsf.rb +5 -1
  267. data/lib/relaton.rb +42 -0
  268. metadata +135 -24
  269. data/lib/relaton/ieee/pub_id.rb +0 -161
  270. data/lib/relaton/index/id_number.rb +0 -30
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relaton
4
+ module Itu
5
+ #
6
+ # Cross-row cache for the ITU-T detail endpoints that answer the same thing
7
+ # for every edition of one recommendation.
8
+ #
9
+ # `searchRecs` runs with `main_edition_flag=0`, so it returns one row **per
10
+ # edition** — ~16k rows over ~7.7k recommendations. Enrichment then costs
11
+ # four www.itu.int requests per row, and three of them are keyed by `idrec`
12
+ # but answer for the whole recommendation. Verified from
13
+ # `spec/itu/vcr_cassettes/itu_t_h_264.yml`: `getRecEditions?idrec=16818` and
14
+ # `?idrec=14659` return **byte-identical** 21-row payloads. H.264's 21
15
+ # editions therefore issue 21 identical requests, and the corpus average is
16
+ # ~2.1 editions per recommendation.
17
+ #
18
+ # The cache is created per crawl by `DataFetcher#fetch_recommendations` and
19
+ # threaded down as a parameter — never global state, and never reachable
20
+ # from the live `Bibliography.get` path, which gets {NullCache} instead.
21
+ #
22
+ # Two locks, and the split matters. `@mutex` guards only the entry *table*;
23
+ # each entry carries its own lock, which the block (an HTTP request) is run
24
+ # under. Holding `@mutex` across the request would serialize all eight
25
+ # workers onto one connection's latency and collapse the pool to one thread;
26
+ # the per-entry lock is also what makes two workers racing on two editions
27
+ # of one recommendation issue **one** request rather than two.
28
+ #
29
+ class FamilyCache
30
+ # Entries resident at once. Bounded because an unbounded cache over ~7.7k
31
+ # recommendations, each holding an editions list and a supplements list,
32
+ # runs to hundreds of MB on a CI runner alongside eight Mechanize agents.
33
+ # With rows enqueued family-by-family only a handful are ever live, so the
34
+ # cap costs no hit rate.
35
+ DEFAULT_MAX_ENTRIES = 512
36
+
37
+ Entry = Struct.new(:mutex, :value, :computed)
38
+
39
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES)
40
+ @max = [max_entries.to_i, 1].max
41
+ @entries = {}
42
+ @mutex = Mutex.new
43
+ @hits = 0
44
+ @misses = 0
45
+ @evictions = 0
46
+ end
47
+
48
+ # Value for `key`, computing it under that key's own lock when absent.
49
+ #
50
+ # A block that raises leaves the entry uncached, so a transient failure is
51
+ # retried by the next caller instead of being served to the whole family
52
+ # as a permanent nil.
53
+ #
54
+ # @param key [Object]
55
+ # @return [Object]
56
+ def fetch(key)
57
+ entry = @mutex.synchronize { touch key }
58
+ entry.mutex.synchronize do
59
+ if entry.computed
60
+ @mutex.synchronize { @hits += 1 }
61
+ next entry.value
62
+ end
63
+
64
+ value = yield
65
+ entry.value = value
66
+ entry.computed = true
67
+ @mutex.synchronize do
68
+ @misses += 1
69
+ # Re-publish. The table lock is released between #touch and the
70
+ # entry lock above, and in that window this entry is unlocked and so
71
+ # a valid eviction candidate for another thread's #touch. Nothing
72
+ # wrong is ever served — this thread holds the Entry directly — but
73
+ # without this the write is lost and the next caller pays for the
74
+ # request again. `unless key?` so a replacement entry another thread
75
+ # has since created is not discarded.
76
+ @entries[key] = entry unless @entries.key? key
77
+ end
78
+ value
79
+ end
80
+ end
81
+
82
+ # Publish an already-computed value under every other key it also answers.
83
+ #
84
+ # This is what makes one request warm a whole family: a `getRecEditions`
85
+ # response names every sibling `idrec`, and by the endpoint's own testimony
86
+ # the payload for each of them is the same one. An entry whose lock is held
87
+ # is skipped — it is mid-fetch and will land on this value anyway.
88
+ #
89
+ # @param keys [Array<Object>]
90
+ # @param value [Object]
91
+ # @return [Object] value
92
+ def warm(keys, value)
93
+ keys.each do |key|
94
+ entry = @mutex.synchronize { touch key }
95
+ next unless entry.mutex.try_lock
96
+
97
+ begin
98
+ next if entry.computed
99
+
100
+ entry.value = value
101
+ entry.computed = true
102
+ ensure
103
+ entry.mutex.unlock
104
+ end
105
+ end
106
+ value
107
+ end
108
+
109
+ # @return [Boolean] whether this cache actually caches. Callers use it to
110
+ # skip building a key whose computation would itself cost a request.
111
+ def caching?
112
+ true
113
+ end
114
+
115
+ # @return [Hash] for the run summary
116
+ def stats
117
+ @mutex.synchronize do
118
+ { hits: @hits, misses: @misses, entries: @entries.size, evictions: @evictions }
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # Fetch-or-create `key` and mark it most recently used. Callers hold
125
+ # @mutex. Eviction skips an entry currently being computed, so a worker
126
+ # can never lose the entry it is holding.
127
+ def touch(key)
128
+ if @entries.key? key
129
+ entry = @entries.delete key
130
+ return @entries[key] = entry
131
+ end
132
+
133
+ evict while @entries.size >= @max
134
+ @entries[key] = Entry.new(Mutex.new, nil, false)
135
+ end
136
+
137
+ def evict
138
+ victim = @entries.each_key.find { |k| !@entries[k].mutex.locked? }
139
+ # Everything resident is in flight: grow rather than block a worker.
140
+ return @max = @entries.size + 1 unless victim
141
+
142
+ @entries.delete victim
143
+ @evictions += 1
144
+ end
145
+ end
146
+
147
+ #
148
+ # The no-op cache: `#fetch` is a bare yield.
149
+ #
150
+ # What the live `Bibliography.get` path gets, and the default everywhere, so
151
+ # runtime behaviour and runtime request count are byte-identical to what
152
+ # they were before the cache existed and no state survives a lookup. Only
153
+ # `DataParserT`, walking one corpus of 16k rows, injects a real one.
154
+ #
155
+ class NullCache
156
+ def self.instance
157
+ @instance ||= new
158
+ end
159
+
160
+ def fetch(_key)
161
+ yield
162
+ end
163
+
164
+ def warm(_keys, value)
165
+ value
166
+ end
167
+
168
+ def caching?
169
+ false
170
+ end
171
+
172
+ def stats
173
+ { hits: 0, misses: 0, entries: 0, evictions: 0 }
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mechanize"
4
+ require_relative "../core/governor"
5
+
6
+ module Relaton
7
+ module Itu
8
+ #
9
+ # ITU binding of {Relaton::Core::Governor}, for the ITU-R crawl.
10
+ #
11
+ # ITU does not signal throttling with a distinct exception class the way
12
+ # api.w3.org does, so membership is decided by status *code* and by one
13
+ # marker module rather than by an error hierarchy. Measured over a
14
+ # 49-minute full run, the F5 WAF in front of www.itu.int answers `/rec`
15
+ # with **HTTP 503** and `/pub` with a **302 to notfound.aspx** — a soft
16
+ # block Mechanize follows to a perfectly good 200, indistinguishable from
17
+ # "no such document" unless the crawler marks it (see
18
+ # `DataCrawlerR::SoftBlock`, which includes {SoftBlock} for exactly this).
19
+ #
20
+ # Why a governor at all, when `DataCrawlerR#get` already retries: a rate
21
+ # limit is a *pool* event. One worker's 503 must pause every other worker,
22
+ # for a window that outlasts the ban, instead of four workers each running
23
+ # their own 5/10/15 s ladder and keeping the limiter engaged. That is the
24
+ # failure the W3C crawl spent 4 h 56 m in before this class existed.
25
+ #
26
+ class Governor < Relaton::Core::Governor
27
+ # Mixed into the crawler's soft-block error. A module rather than a class
28
+ # so the exception can stay a `Mechanize::ResponseCodeError` subclass —
29
+ # which is what keeps `#get`'s existing rescue list unchanged — while
30
+ # still being distinguishable from the 404 it otherwise looks like.
31
+ module SoftBlock; end
32
+
33
+ # Response codes that mean "the WAF is pushing back", as opposed to "that
34
+ # document is not there" or "that record is broken".
35
+ #
36
+ # **500 is deliberately absent.** Same call the W3C governor makes: a
37
+ # persistent 5xx on one resource is a broken record, and routing it here
38
+ # would open a pool-wide cooldown for every bad page in the corpus. Only
39
+ # 503 (measured, on /rec) and the gateway codes, which are infrastructure
40
+ # rather than content, get the pool treatment.
41
+ THROTTLE_CODES = %w[429 502 503 504].freeze
42
+
43
+ # RELATON_ITU_THROTTLE_BASE / _MAX / _GIVEUP.
44
+ ENV_PREFIX = "RELATON_ITU"
45
+
46
+ # @param error [StandardError]
47
+ # @return [Boolean]
48
+ def self.throttle?(error)
49
+ return true if error.is_a? SoftBlock
50
+ return false unless error.is_a? ::Mechanize::ResponseCodeError
51
+
52
+ THROTTLE_CODES.include? error.response_code.to_s
53
+ end
54
+ end
55
+ end
56
+ end
@@ -6,10 +6,16 @@ module Relaton
6
6
  class Hit < Relaton::Core::Hit
7
7
  attr_writer :item
8
8
 
9
- # Parse page.
10
- # @return [Relaton::Itu::ItemData]
9
+ # The document itself. An index-derived hit (`:file`) is loaded from the
10
+ # data repository on demand — a reference matching many editions builds a
11
+ # hit per edition, and only the one Bibliography selects is downloaded.
12
+ # Everything else is scraped from www.itu.int.
13
+ #
14
+ # @return [Relaton::Itu::ItemData, nil]
11
15
  def item
12
- @item ||= Scraper.parse_page self, imp: gi_imp
16
+ @item ||= if hit[:file] then hit_collection.fetch_item(hit[:url])
17
+ else Scraper.parse_page self, imp: gi_imp
18
+ end
13
19
  end
14
20
 
15
21
  private
@@ -1,20 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+ require "json"
3
5
  require_relative "hit"
4
6
 
5
7
  module Relaton
6
8
  module Itu
7
9
  # Page of hit collection.
10
+ #
11
+ # Reference discovery has three sources, because ITU decommissioned the
12
+ # `net4/.../GlobalSearch/RunSearch` endpoint that used to serve all of them:
13
+ #
14
+ # - Recommendations (`ITU-T …`) and ITU-R publications come from the combined
15
+ # `relaton-data-itu` `index-v2`; an ITU-T reference the index can't serve
16
+ # falls back to the live `rec.aspx` + `getRecEditions` pair.
17
+ # - Radio Regulations (`ITU-R RR …`) and Operational Bulletins (`… OB.N …`)
18
+ # are not in the dataset; their landing page URL is derivable from the
19
+ # reference, so they are resolved directly against `www.itu.int/pub/…`.
8
20
  class HitCollection < Relaton::Core::HitCollection
9
21
  DOMAIN = "https://www.itu.int"
10
- GH_ITU_R = "https://raw.githubusercontent.com/relaton/relaton-data-itu-r/refs/heads/v2/"
22
+ GH_ITU = "https://raw.githubusercontent.com/relaton/relaton-data-itu/refs/heads/main/"
23
+ REC_URL = "#{DOMAIN}/ITU-T/recommendations/rec.aspx?rec=%<rec>s&lang=en".freeze
24
+ RECEDITIONS_URL = "#{DOMAIN}/mws/api/recommendations/getRecEditions?idrec=%<idrec>s&lang=en".freeze
25
+ HANDLE_URL = "http://handle.itu.int/11.1002/1000/%<idrec>s-en".freeze
11
26
 
12
27
  def search
13
28
  case ref.to_ref
14
- when /^(ITU-T|ITU-R\sRR)/
15
- request_search
16
- when /^ITU-R\s/
17
- request_document
29
+ # `ref` is a parsed Pubid, so only the abbreviated `OB.N` form survives
30
+ # here — the local grammar reduces "Operational Bulletin 1096" to the
31
+ # code "Operational" and drops the year, which no route can resolve.
32
+ when %r{^ITU-R\sRR}, %r{\bOB\.} then request_publication
33
+ when /^ITU-T/ then request_recommendation
34
+ # A Report reference renders "Report ITU-R …" (the discriminator leads,
35
+ # as ITU cites it), so it no longer starts with the sector — but it is
36
+ # still served from the index like any other ITU-R document.
37
+ when /^ITU-R\s/, /^Report\sITU-R\s/ then request_document
18
38
  end
19
39
  rescue Mechanize::ResponseCodeError, SocketError, Timeout::Error, Errno::ECONNRESET,
20
40
  EOFError, Net::ProtocolError, OpenSSL::SSL::SSLError => e
@@ -25,102 +45,254 @@ module Relaton
25
45
  @agent ||= Mechanize.new.tap { |agent| agent.user_agent_alias = "Mac Safari" }
26
46
  end
27
47
 
48
+ # Fetch and parse one document from the data repository. Public because
49
+ # `Hit#item` calls it to load an index-derived hit lazily — a reference
50
+ # that matches many editions must not download them all.
51
+ #
52
+ # @param url [String] raw URL of the document's YAML
53
+ # @return [Relaton::Itu::ItemData, nil] nil when the file is missing
54
+ def fetch_item(url)
55
+ resp = agent.get url
56
+ return if resp.code == "404"
57
+
58
+ Item.from_yaml(resp.body).tap { |i| i.fetched = Date.today.to_s }
59
+ rescue Mechanize::ResponseCodeError, SocketError, Timeout::Error, Errno::ECONNRESET,
60
+ EOFError, Net::ProtocolError, OpenSSL::SSL::SSLError => e
61
+ # Mechanize *raises* on 404 rather than returning the response, so an
62
+ # index row whose data file has gone missing is caught here —
63
+ # Bibliography then moves on to the next edition instead of failing.
64
+ return if e.is_a?(Mechanize::ResponseCodeError) && e.response_code == "404"
65
+
66
+ # `#search` wraps its own failures, but a lazy hit is loaded later, from
67
+ # `Hit#item` — outside that rescue.
68
+ raise Relaton::RequestError, "Could not access #{url}: #{e.message}"
69
+ end
70
+
28
71
  private
29
72
 
30
- def request_search
31
- Util.info "Fetching from www.itu.int ...", key: ref.to_s
32
- url = "#{DOMAIN}/net4/ITU-T/search/GlobalSearch/RunSearch"
33
- data = { json: params.to_json }
34
- resp = agent.post url, data
35
- @array = hits JSON.parse(resp.body)
73
+ # ITU-T Recommendations and supplements. The index is preferred (offline,
74
+ # every edition, no scraping); the live path covers what the dataset does
75
+ # not have — Implementers' Guides, the `(V##)`/`Annex` forms the data
76
+ # repo's crawler leaves unindexed, and editions approved since its last
77
+ # crawl. Indexed editions are kept when the live path finds nothing, so a
78
+ # wrong-year reference still reports the years that do exist.
79
+ def request_recommendation
80
+ hits = index_hits
81
+ return @array = hits if year_satisfied? hits
82
+
83
+ live = live_hits
84
+ @array = live.empty? ? hits : live
85
+ end
86
+
87
+ # @return [Array<Relaton::Itu::Hit>] newest edition first
88
+ def index_hits
89
+ pubid = index_pubid_ref
90
+ return [] unless pubid
91
+
92
+ Util.info "Fetching from Relaton repository ...", key: ref.to_s
93
+ index.search(pubid) { |i| index_match?(pubid, i[:id]) }
94
+ .map { |r| index_hit r }.sort_by { |h| edition_key h.hit[:code] }.reverse
95
+ end
96
+
97
+ # @param row [Hash] an index row
98
+ # @return [Relaton::Itu::Hit]
99
+ def index_hit(row)
100
+ # `row[:id].to_s` renders the dated docidentifier the data record itself
101
+ # carries ("ITU-T L.163 (11/2018)"), which is what Bibliography's year
102
+ # filtering parses — so no separate edition bookkeeping is needed.
103
+ Hit.new({ code: row[:id].to_s, url: "#{GH_ITU}#{row[:file]}", file: row[:file] }, self)
104
+ end
105
+
106
+ # Can the index answer the reference? A year-less reference is satisfied by
107
+ # any hit; a dated one needs the year to be present, otherwise a newer (or
108
+ # unindexed) edition has to come from www.itu.int.
109
+ #
110
+ # @param hits [Array<Relaton::Itu::Hit>]
111
+ # @return [Boolean]
112
+ def year_satisfied?(hits)
113
+ return false if hits.empty?
114
+
115
+ ref.year.nil? || hits.any? { |h| edition_key(h.hit[:code]).first == ref.year.to_i }
116
+ end
117
+
118
+ # The edition date of a rendered docidentifier, as `[year, month]`. Uses
119
+ # the **first** date in the string, mirroring Bibliography's year filter:
120
+ # an amendment ("ITU-T G.989.2 (2014) Amd 1 (04/2016)") is identified by
121
+ # the year of the recommendation it amends.
122
+ #
123
+ # @param code [String, nil]
124
+ # @return [Array<Integer>]
125
+ def edition_key(code)
126
+ month, year = code.to_s.match(%r{\((?:(\d{2})/)?(\d{4})\)})&.captures
127
+ [year.to_i, month.to_i]
128
+ end
129
+
130
+ def index
131
+ Relaton::Index.find_or_create :itu, url: "#{GH_ITU}#{INDEXFILE}.zip",
132
+ file: "#{INDEXFILE}.yaml",
133
+ pubid_class: ::Pubid::Itu::Identifier
36
134
  end
37
135
 
38
136
  def request_document # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
39
137
  Util.info "Fetching from Relaton repository ...", key: ref.to_s
40
- index = Relaton::Index.find_or_create :itu, url: "#{GH_ITU_R}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml"
41
- row = index.search(ref.to_ref).max_by { |i| i[:id] }
138
+ # Pass the reference's parsed pubid (not a String) so Relaton::Index can
139
+ # narrow candidates by document number before the block; `index_match?`
140
+ # then matches every edition when the reference omits the part and the
141
+ # exact edition when it names one. Rows are Pubid::Itu objects (not
142
+ # Comparable), so rank by the numeric edition in `code.parts` (`["3"]` → 3)
143
+ # to return the latest — index order isn't by edition.
144
+ pubid = pubid_ref
145
+ row = index.search(pubid) { |i| index_match?(pubid, i[:id]) }
146
+ .max_by { |i| i[:id].code&.parts&.last.to_i }
42
147
  return unless row
43
148
 
44
- url = "#{GH_ITU_R}#{row[:file]}"
45
- resp = agent.get url
46
- return if resp.code == "404"
149
+ url = "#{GH_ITU}#{row[:file]}"
150
+ item = fetch_item url
151
+ return unless item
47
152
 
48
- item = Item.from_yaml(resp.body).tap { |i| i.fetched = Date.today.to_s }
49
153
  hit = Hit.new({ url: url, ref: ref }, self)
50
154
  hit.item = item
51
155
  @array = [hit]
52
156
  end
53
157
 
54
- # @return [String]
55
- def group
56
- @group ||= case ref.to_ref
57
- when %r{OB|Operational Bulletin}, %r{^ITU-R\sRR}
58
- "Publications"
59
- when %r{^ITU-T} then "Recommendations"
60
- end
61
- end
62
-
63
- # @return [Hash]
64
- def params # rubocop:disable Metrics/MethodLength
65
- input = ref.dup
66
- input.year = nil
67
- {
68
- "Input" => input.to_s,
69
- "Start" => 0,
70
- "Rows" => 20,
71
- "SortBy" => "RELEVANCE",
72
- "ExactPhrase" => false,
73
- "CollectionName" => "General",
74
- "CollectionGroup" => group,
75
- "Sector" => ref.to_ref.match(/(?<=^ITU-)\w/).to_s.downcase,
76
- "Criterias" => [{
77
- "Name" => "Search in",
78
- "Criterias" => [
79
- {
80
- "Selected" => false,
81
- "Value" => "",
82
- "Label" => "Name",
83
- "Target" => "/name_s",
84
- "TypeName" => "CHECKBOX",
85
- "GetCriteriaType" => 0,
86
- },
87
- {
88
- "Selected" => false,
89
- "Value" => "",
90
- "Label" => "Short description",
91
- "Target" => "/short_description_s",
92
- "TypeName" => "CHECKBOX",
93
- "GetCriteriaType" => 0,
94
- },
95
- {
96
- "Selected" => false,
97
- "Value" => "",
98
- "Label" => "File content",
99
- "Target" => "/file",
100
- "TypeName" => "CHECKBOX",
101
- "GetCriteriaType" => 0,
102
- },
103
- ],
104
- "ShowCheckbox" => true,
105
- "Selected" => false,
106
- }],
107
- "Topics" => "",
108
- "ClientData" => {},
109
- "Language" => "en",
110
- "SearchType" => "All",
111
- }
112
- end
113
-
114
- # @param data [Hash]
158
+ # Parse the reference into a Pubid::Itu identifier for the index lookup. A
159
+ # ref pubid can't parse raises (a `Pubid`/`Parslet` error) and propagates to
160
+ # the caller (relaton-cli / API callers rescue it), mirroring the ETSI
161
+ # flavor — the consumer no longer degrades to a raw-string substring search.
162
+ #
163
+ # @return [::Pubid::Itu::Identifier]
164
+ def pubid_ref
165
+ ::Pubid::Itu.parse ref.to_ref
166
+ end
167
+
168
+ # The reference as a pubid for an ITU-T index lookup, stripped of its
169
+ # edition date (every edition is a candidate; Bibliography selects the
170
+ # year). Unlike `#pubid_ref` this **rescues** an unparseable reference: the
171
+ # ITU-T path has a live fallback to degrade to, and a malformed reference
172
+ # (e.g. `ITU-T G.Suppl.47`) should warn and report "Not found", not raise.
173
+ #
174
+ # @return [::Pubid::Itu::Identifier, nil]
175
+ def index_pubid_ref
176
+ undated = ref.dup.tap { |r| r.year = r.month = r.day = nil }
177
+ ::Pubid::Itu.parse undated.to_ref
178
+ rescue StandardError
179
+ nil
180
+ end
181
+
182
+ # Does an index row's id match the reference? When the reference omits the
183
+ # part/edition (a bare `ITU-R P.838`), every edition of that exact document
184
+ # matches — "search all parts"; when it names a part (`ITU-R P.838-2`), only
185
+ # that edition matches. Delegates to pubid's structured `matches?`, ignoring
186
+ # `:parts` only when the reference omits the part — so a bare `ITU-R M.1`
187
+ # does not match `ITU-R M.10` (a different document number) and `-1` differs
188
+ # from `-10`, without the local `-`-separator anchor. Mirrors the ETSI
189
+ # flavor's `Bibliography#best_match`. Both ids are `Pubid::Itu` identifiers.
190
+ #
191
+ # `:year`/`:month`/`:version` are always ignored because ITU-T rows are one
192
+ # per **edition** (`ITU-T Z.100 (06/2021)`, `ITU-T H.264 (V14) (08/2021)`)
193
+ # while the reference is normalised to the undated form. They are separable
194
+ # trailing components, the same contract ETSI has (`omits: %i[version date]`);
195
+ # Bibliography then picks the requested year and version.
196
+ #
197
+ # @param pubid [::Pubid::Itu::Identifier] the reference
198
+ # @param id [::Pubid::Itu::Identifier] an index row's id
199
+ # @return [Boolean]
200
+ def index_match?(pubid, id)
201
+ ignore = %i[year month version]
202
+ ignore << :parts if pubid.code&.parts.to_a.empty?
203
+ pubid.matches?(id, ignore: ignore)
204
+ end
205
+
206
+ # Resolve an ITU-T Recommendation to its editions via the public rec.aspx
207
+ # page (which exposes the record's handle/idrec) and the getRecEditions
208
+ # API. One hit is built per edition, mirroring the multi-result shape the
209
+ # removed RunSearch endpoint returned, so year filtering keeps working.
210
+ #
115
211
  # @return [Array<Relaton::Itu::Hit>]
116
- def hits(data)
117
- data["results"].map do |h|
118
- code = h["Media"]["Name"]
119
- title = h["Title"]
120
- url = "#{DOMAIN}#{h['Redirection']}"
121
- type = h["Collection"]["Group"].downcase[0...-1]
122
- Hit.new({ code: code, title: title, url: url, type: type }, self)
123
- end
212
+ def live_hits
213
+ Util.info "Fetching from www.itu.int ...", key: ref.to_s
214
+ idrec = fetch_idrec
215
+ return [] unless idrec
216
+
217
+ editions(idrec).map { |ed| recommendation_hit ed }
218
+ end
219
+
220
+ # @return [String, nil] the record's idrec, or nil when the code is unknown
221
+ def fetch_idrec
222
+ url = format(REC_URL, rec: CGI.escape(rec_query))
223
+ agent.get(url).body[%r{11\.1002/1000/(\d+)}, 1]
224
+ rescue Mechanize::ResponseCodeError => e
225
+ raise unless e.response_code == "404" # unknown code => treat as not found
226
+
227
+ nil
228
+ end
229
+
230
+ # @return [String] the `rec=` value for the rec.aspx lookup
231
+ def rec_query
232
+ ref.suppl ? "#{ref.code} Suppl. #{ref.suppl}" : ref.code
233
+ end
234
+
235
+ # @param idrec [String]
236
+ # @return [Array<Hash>] editions of the recommendation
237
+ def editions(idrec)
238
+ # a `null` body parses to nil, so `|| []` is not redundant
239
+ JSON.parse(agent.get(format(RECEDITIONS_URL, idrec: idrec)).body) || []
240
+ rescue JSON::ParserError
241
+ []
242
+ end
243
+
244
+ # @param edition [Hash] a getRecEditions entry
245
+ # @return [Relaton::Itu::Hit]
246
+ def recommendation_hit(edition)
247
+ # No `:ref` key: it drives Hit#gi_imp, which would route an Implementers'
248
+ # Guide reference onto the getImplGuides endpoints.
249
+ Hit.new({
250
+ code: "ITU-T #{edition['rec_name']}",
251
+ title: edition["title"],
252
+ url: format(HANDLE_URL, idrec: edition["idrec"]),
253
+ type: "recommendation",
254
+ }, self)
255
+ end
256
+
257
+ # Resolve an ITU-R Radio Regulation or Operational Bulletin to its stable
258
+ # /pub landing page, whose id is derivable from the reference. They are not
259
+ # part of the relaton-data-itu dataset.
260
+ def request_publication
261
+ Util.info "Fetching from www.itu.int ...", key: ref.to_s
262
+ id = ref.year && publication_id
263
+ return @array = [] unless id
264
+
265
+ url = "#{DOMAIN}/pub/#{id}"
266
+ page = fetch_publication_page url
267
+ return @array = [] if page.nil? || page.uri.to_s.match?(/notfound/i)
268
+
269
+ @array = [Hit.new({ code: publication_code, url: url, type: "publication" }, self)]
270
+ end
271
+
272
+ # @return [Mechanize::Page, nil] nil when the publication does not exist
273
+ def fetch_publication_page(url)
274
+ agent.get url
275
+ rescue Mechanize::ResponseCodeError => e
276
+ raise unless e.response_code == "404" # unknown publication => not found
277
+
278
+ nil
279
+ end
280
+
281
+ # The /pub identifier for a Radio Regulation or an Operational Bulletin
282
+ # (`OB.1096` → `T-SP-OB.1096-2016`). nil — no hit — rather than a bogus URL
283
+ # when the bulletin reference carries no number.
284
+ #
285
+ # @return [String, nil]
286
+ def publication_id
287
+ return "R-REG-RR-#{ref.year}" if ref.code == "RR"
288
+
289
+ num = ref.code[/\d+/]
290
+ "T-SP-OB.#{num}-#{ref.year}" if num
291
+ end
292
+
293
+ # @return [String] the docidentifier-friendly code (year only, no month)
294
+ def publication_code
295
+ "#{ref.prefix}-#{ref.sector} #{ref.code} (#{ref.year})"
124
296
  end
125
297
  end
126
298
  end