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,664 @@
1
+ require "mechanize"
2
+ require_relative "../itu"
3
+ require_relative "../core/pacer"
4
+ require_relative "data_parser_r"
5
+ require_relative "governor"
6
+
7
+ module Relaton
8
+ module Itu
9
+ # The ITU-R harvester (issue #75) — the replacement for the decommissioned
10
+ # RunSearch bulk enumeration.
11
+ #
12
+ # ITU-R metadata is still fully server-rendered under `/pub` + `/rec`, in
13
+ # three levels — Recommendations:
14
+ #
15
+ # /pub/R-REC/en -> the 16 series
16
+ # /rec/R-REC-BO/en -> one row per document (54 for BO)
17
+ # /rec/R-REC-BO.1130/en -> one row per edition (Main + Previous)
18
+ # /rec/R-REC-BO.1130-5-202602-I/en -> approval day + the PDF href
19
+ #
20
+ # and Reports, the same shape under a different root (see FAMILIES):
21
+ #
22
+ # /pub/R-REP/en -> the 14 report series
23
+ # /pub/R-REP-BO/en -> one row per document (36 for BO)
24
+ # /pub/R-REP-BO.1227/en -> one row per edition
25
+ # /pub/R-REP-BO.1227-2-1998/en -> the files' posted date + PDF href
26
+ #
27
+ # `DataFetcher#fetch_publications` drives it, requiring this file lazily so
28
+ # an ITU-T-only crawl never loads it. Standalone use — note the write goes
29
+ # through DataMergeR, never straight to #write_file, so a partial harvest
30
+ # can't overwrite what it cannot see:
31
+ #
32
+ # require "relaton/itu/data_merge_r"
33
+ # fetcher = Relaton::Itu::DataFetcher.new("data", "yaml")
34
+ # crawler = Relaton::Itu::DataCrawlerR.new
35
+ # items = crawler.harvest("BO") + crawler.harvest("BO", family: "R-REP")
36
+ # Relaton::Itu::DataMergeR.write_all items, fetcher
37
+ # fetcher.index.save
38
+ #
39
+ # **Politeness is not this class's `sleep` any more.** Levels 2 and 3 run on
40
+ # a small worker pool (`#in_parallel`, one Mechanize agent per thread), and
41
+ # the rate is set by a shared `Relaton::Core::Pacer` — `delay:` is the
42
+ # minimum gap between request *starts*, so ITU's own latency counts toward it
43
+ # instead of being added on top. A `Relaton::Itu::Governor` gives the pool one
44
+ # shared cooldown for a WAF block. See `#get` and lib/relaton/itu/CLAUDE.md.
45
+ # The crawl is parallel **within** a series only: `DataFetcher#harvest_family`
46
+ # rescues per series and `DataMergeR` writes per series, and the data repo's
47
+ # collapse guard is calibrated on that granularity.
48
+ #
49
+ class DataCrawlerR
50
+ DOMAIN = "https://www.itu.int".freeze
51
+
52
+ # Both families are the same three-level tree with different URL roots and
53
+ # a different date field. `date:` is the substantive difference, not a
54
+ # detail: a Recommendation page publishes only its **approval** date,
55
+ # while a Report page carries the "Posted" date of its files — which is
56
+ # the **publication** date the preserved records hold. So a harvested
57
+ # Report reproduces the published `date:`, and a harvested Recommendation
58
+ # cannot (see DataMergeR, which is why the merge never rewrites one).
59
+ FAMILIES = {
60
+ "R-REC" => {
61
+ index: "#{DOMAIN}/pub/R-REC/en",
62
+ page: "#{DOMAIN}/rec/%<id>s/en",
63
+ pdf: "#{DOMAIN}/dms_pubrec/itu-r/rec/%<series>s/%<id>s!!PDF-E.pdf",
64
+ date: :approved,
65
+ group: :series,
66
+ }.freeze,
67
+ "R-REP" => {
68
+ index: "#{DOMAIN}/pub/R-REP/en",
69
+ page: "#{DOMAIN}/pub/%<id>s/en",
70
+ pdf: "#{DOMAIN}/dms_pub/itu-r/opb/rep/%<id>s-PDF-E.pdf",
71
+ date: :posted,
72
+ group: :series,
73
+ }.freeze,
74
+ # Questions nest one level deeper: the family index lists **study
75
+ # groups** (R-QUE-SG01), not series letters. No `pdf:` template — a
76
+ # Question edition page usually offers no PDF of its own (verified on
77
+ # R-QUE-SG01.202-2-2002), and deriving a URL that 404s is worse than
78
+ # leaving the record sourceless.
79
+ "R-QUE" => {
80
+ index: "#{DOMAIN}/pub/R-QUE/en",
81
+ page: "#{DOMAIN}/pub/%<id>s/en",
82
+ date: :posted,
83
+ group: :sub_index,
84
+ }.freeze,
85
+ # Handbooks are flat like Resolutions, but they publish less per page
86
+ # than any other family, so two things move: the **date** comes from the
87
+ # id (an edition page carries no posted date, and the displayed codes
88
+ # disagree with each other — `R-HDB-43-2013` renders "2014" against its
89
+ # siblings' "2026" and "Edition of 2002"), and the **title** comes from
90
+ # the level-1 row, the only place ITU prints it.
91
+ "R-HDB" => {
92
+ index: "#{DOMAIN}/pub/R-HDB/en",
93
+ page: "#{DOMAIN}/pub/%<id>s/en",
94
+ pdf: "#{DOMAIN}/dms_pub/itu-r/opb/hdb/%<id>s-PDF-E.pdf",
95
+ date: :id,
96
+ title: :document,
97
+ group: :flat,
98
+ }.freeze,
99
+ # Resolutions are **flat**: /pub/R-RES/en links all 75 documents
100
+ # directly, with no grouping level at all.
101
+ "R-RES" => {
102
+ index: "#{DOMAIN}/pub/R-RES/en",
103
+ page: "#{DOMAIN}/pub/%<id>s/en",
104
+ pdf: "#{DOMAIN}/dms_pub/itu-r/opb/res/%<id>s-PDF-E.pdf",
105
+ date: :posted,
106
+ group: :flat,
107
+ }.freeze,
108
+ }.freeze
109
+ DEFAULT_FAMILY = "R-REC".freeze
110
+
111
+ # Every level is addressable by its id. The pages' own links are
112
+ # `./recommendation.asp?…&parent=<id>` / `publications.aspx?…&parent=<id>`,
113
+ # which resolve against the series directory and do NOT reach the
114
+ # canonical page — so the id is extracted from `parent=` and the URL
115
+ # rebuilt, never followed.
116
+ PARENT_RE = /[?&]parent=(?<id>[^"&\s]+)/.freeze
117
+ APPROVED_RE = /Approved in\s*(\d{4}-\d{2}(?:-\d{2})?)/.freeze
118
+ # A Report edition page dates each downloadable file ("Posted"). The day is
119
+ # the file's posting stamp; the corpus records these at month precision.
120
+ POSTED_RE = /\b((?:19|20)\d{2}-\d{2})-\d{2}\b/.freeze
121
+ # "R-REC-BO.1130-3-200007-S" -> "2000-07"
122
+ ID_DATE_RE = /-(\d{4})(\d{2})-[A-Za-z]+\z/.freeze
123
+ # "R-REP-BO.1227-2-1998" -> "1998"
124
+ ID_YEAR_RE = /-((?:19|20)\d{2})\z/.freeze
125
+ # Where a throttled /pub request lands instead of erroring.
126
+ NOTFOUND = "notfound.aspx".freeze
127
+ RETRIES = 3
128
+ RETRY_BACKOFF = 5 # seconds, multiplied by the attempt number
129
+
130
+ # The `/pub` soft block: a 302 to notfound.aspx, which Mechanize follows
131
+ # to a perfectly good 200. It stays a `Mechanize::ResponseCodeError`
132
+ # subclass so #get's rescue list is unchanged, and it mixes in the
133
+ # governor's marker so a rate limit can be told apart from the 404 it
134
+ # otherwise looks exactly like — which is the whole point: a 404 must cost
135
+ # one document, a soft block must pause the pool.
136
+ class SoftBlock < Mechanize::ResponseCodeError
137
+ include Governor::SoftBlock
138
+ end
139
+
140
+ # Worker threads for the two request-heavy levels of the walk. Deliberately
141
+ # small, and deliberately not the same knob as ITU-T's: the two halves have
142
+ # different bottlenecks. ITU-T is latency-bound. ITU-R is *pacer*-bound —
143
+ # throughput is min(1/delay, concurrency/latency), so at the 1 s default
144
+ # and ITU's ~1.1 s latency two workers already saturate the contract and
145
+ # four is only headroom for a slow page. More would buy nothing and only
146
+ # widen the burst the F5 WAF sees.
147
+ DEFAULT_CONCURRENCY = 4
148
+
149
+ # Each worker owns its agent, stashed here rather than passed down: every
150
+ # level of the walk calls #get, and threading an agent through
151
+ # #series/#documents/#editions/#edition would touch every signature for a
152
+ # value only #get uses.
153
+ AGENT_KEY = :relaton_itu_r_agent
154
+
155
+ # @param agent [Mechanize] one agent per crawler (Mechanize is not
156
+ # thread-safe, so a parallel harvester needs one per worker)
157
+ # @param delay [Numeric] politeness pause before each request — www.itu.int
158
+ # sits behind an F5 WAF that throttles, and a full crawl is ~10^4 pages
159
+ # @param concurrency [Integer] worker threads for the level-2/3 fetches
160
+ # @param pace_mode [Symbol] :slot (default) or :fixed — see Core::Pacer
161
+ # @param pacer [Relaton::Core::Pacer, nil] shared request pacer
162
+ # @param governor [Relaton::Itu::Governor, nil] pool-wide back-pressure
163
+ def initialize(agent: nil, delay: 0.5, concurrency: 1,
164
+ pace_mode: Core::Pacer::DEFAULT_MODE, pacer: nil, governor: nil)
165
+ @agent = agent
166
+ @delay = delay
167
+ @concurrency = [concurrency.to_i, 1].max
168
+ # The pacer, not the pool, sets the rate: `delay` is the minimum gap
169
+ # between request *starts*, shared by every worker, so ITU's own latency
170
+ # counts toward it instead of being added on top.
171
+ @pacer = pacer || Core::Pacer.new(gap: delay, mode: pace_mode)
172
+ @governor = governor || Governor.new
173
+ end
174
+
175
+ # This thread's agent: a pool worker's own, or the crawler's shared one for
176
+ # the serial walk (and for every spec and direct #get). Built lazily so a
177
+ # crawler that only ever runs in workers never makes one.
178
+ def agent
179
+ Thread.current[AGENT_KEY] || (@agent ||= self.class.agent)
180
+ end
181
+
182
+ # @return [Integer] rate-limit responses seen this crawl, for the summary
183
+ def throttle_count
184
+ @governor.throttle_count
185
+ end
186
+
187
+ # @return [Boolean] the crawl was declared rate-limited and gave up
188
+ def abandoned?
189
+ @governor.exhausted?
190
+ end
191
+
192
+ # Release the pool's Mechanize agents and their persistent connections.
193
+ # The pool is memoised for the whole crawl (deliberately — rebuilding it
194
+ # per series would churn ~300 agents), so nothing else can close them;
195
+ # DataFetcher#fetch_publications calls this in an ensure, mirroring what
196
+ # #fetch_recommendations already does for the ITU-T pool.
197
+ def shutdown
198
+ @worker_agents&.each(&:shutdown)
199
+ @worker_agents = nil
200
+ end
201
+
202
+ # Same hardening as DataFetcher#rec_agent: the browser UA is mandatory (the
203
+ # WAF rejects non-browser clients) and max_history keeps a long crawl from
204
+ # retaining every page it has read.
205
+ def self.agent
206
+ Mechanize.new.tap do |a|
207
+ a.user_agent_alias = "Mac Safari"
208
+ a.max_history = 1
209
+ a.open_timeout = 15
210
+ a.read_timeout = 60
211
+ end
212
+ end
213
+
214
+ # The grouping level between a family index and its documents. Three
215
+ # topologies, all real:
216
+ #
217
+ # :series R-REC/R-REP — 16 and 14 series letters (BO, BR, …)
218
+ # :sub_index R-QUE — 6 study groups (SG01, SG03, …)
219
+ # :flat R-RES — none; the index links all 75 documents
220
+ #
221
+ # A flat family yields a single nil group so the walk below stays one
222
+ # shape: index -> group -> document -> edition.
223
+ #
224
+ # @param family [String]
225
+ # @return [Array<String, nil>]
226
+ def series(family = DEFAULT_FAMILY)
227
+ conf = config(family)
228
+ return [nil] if conf[:group] == :flat
229
+
230
+ pattern = conf[:group] == :sub_index ? "(SG\\d+)" : "([A-Z]+)"
231
+ found = get(conf[:index]).search("//a").filter_map do |a|
232
+ a[:href].to_s[%r{/(?:rec|pub)/#{Regexp.escape family}-#{pattern}/en\z}, 1]
233
+ end.uniq
234
+ warn_if_empty found, "#{family} groups"
235
+ end
236
+
237
+ # Level 1 — every document in a series.
238
+ #
239
+ # @param series [String] e.g. "BO"
240
+ # @param family [String] "R-REC" or "R-REP"
241
+ # @return [Array<Hash>] { id:, code:, title: }
242
+ def documents(series, family: DEFAULT_FAMILY)
243
+ conf = config(family)
244
+ # A flat family lists its documents on the family index itself; the
245
+ # others list them on the group page.
246
+ url, prefix = series ? [page_url("#{family}-#{series}"), "#{family}-#{series}."] : [conf[:index], "#{family}-"]
247
+ found = rows(url).filter_map do |id, anchor, cells|
248
+ # Every one of these pages links itself (parent=R-REC-BO,
249
+ # parent=R-QUE-SG01) for the language switcher, and a flat index links
250
+ # only documents — so the prefix test is what separates the two.
251
+ next unless id.start_with?(prefix) && id != prefix.chomp(".")
252
+
253
+ # The Handbook index lists *titles* where every other family lists
254
+ # numbers — its number is in the id alone and its title cell is empty.
255
+ name = squish(anchor.text)
256
+ { id: id, code: name, title: conf[:title] == :document ? name : title_text(cells[1]) }
257
+ end
258
+ warn_if_empty found, "documents in #{series ? "#{family}-#{series}" : family}"
259
+ end
260
+
261
+ # Level 2 — every edition of a document ("Main" first, then "Previous
262
+ # versions"; the page is already newest-first).
263
+ #
264
+ # @param doc_id [String] e.g. "R-REC-BO.1130"
265
+ # @return [Array<Hash>] { id:, code:, title:, status: }
266
+ def editions(doc_id)
267
+ number = doc_id[/\AR-[A-Z]+-(.+)\z/, 1]
268
+ found = rows(page_url(doc_id)).filter_map do |id, anchor, cells|
269
+ next unless id.start_with? "#{doc_id}-" # skip the page's self-link
270
+
271
+ code = squish(anchor.text)
272
+ # An edition's code must be the document's own number. ITU lists a
273
+ # second row for some editions under a `…-P` id whose displayed Number
274
+ # is the associated *Question* (`/rec/R-REC-M.2083/en` shows both
275
+ # "M.2083-0 (09/2015)" and "M.5/BL/22 (09/2015)" for one edition) —
276
+ # taking that at face value mints a Recommendation docid out of a
277
+ # question number. Only the `/rec` pages carry those rows, and only
278
+ # there does the code start with the document number: a Resolution
279
+ # renders "Res.1-9 (2023)" against the id `R-RES-R.1-9-2023`.
280
+ next if family_of(doc_id) == "R-REC" && !code.start_with?(number)
281
+
282
+ { id: id, code: code, title: title_text(cells[1]), status: squish(cells[2]&.text) }
283
+ end
284
+ # A Handbook edition is listed twice — once with an empty code, once with
285
+ # its year — so keep the richer row per id rather than harvesting the
286
+ # same edition twice.
287
+ found = found.group_by { |e| e[:id] }.map { |_, dupes| dupes.max_by { |e| e[:code].to_s.size } }
288
+ warn_if_empty found, "editions of #{doc_id}"
289
+ end
290
+
291
+ # Level 3 — the per-edition page. One request per edition, so this is what
292
+ # makes a full crawl expensive; it buys the day-precision approval date and
293
+ # a PDF href read off the page instead of derived from the id.
294
+ #
295
+ # @param edition_id [String] e.g. "R-REC-BO.1130-5-202602-I"
296
+ # @return [Hash] { date:, pdf: }
297
+ def edition(edition_id)
298
+ page = get(page_url(edition_id))
299
+ # Anchored on the edition id, which is what makes "no PDF ⇒ no source"
300
+ # true. The link shape differs per family ("…!!PDF-E.pdf" for a rec,
301
+ # "…-PDF-E.pdf" for a report) and every /pub page carries a QUICK LINKS
302
+ # sidebar whose Publication Catalogue entry is itself a `…-PDF-E.pdf` —
303
+ # an unanchored match would hand that catalogue URL to any report
304
+ # edition that has no English PDF of its own. Resolved against the page,
305
+ # not concatenated: ITU serves root-relative hrefs today, but an
306
+ # absolute one would give "https://www.itu.inthttps://…".
307
+ # `.pdf`-terminated, because /pub pages also carry an "add to cart"
308
+ # control whose href is `javascript:addcart(…,'R-REP-BT.2526-1-2024-PDF-E',…)`
309
+ # — it contains both the edition id and PDF-E, and feeding it to
310
+ # URI#merge raises URI::InvalidURIError, which once killed a whole series.
311
+ href = page.search("//a[contains(@href,'#{edition_id}') and contains(@href,'PDF-E')]")
312
+ .map { |a| a[:href].to_s.strip }
313
+ .find { |h| h.end_with? ".pdf" }
314
+ { date: page_date(page, edition_id), pdf: href && page.uri.merge(href).to_s }
315
+ end
316
+
317
+ # End-to-end: a series -> parsed ItemData, one per edition.
318
+ #
319
+ # @param series [String] e.g. "BO"
320
+ # @param family [String] "R-REC" or "R-REP"
321
+ # @param only [Array<String>, nil] restrict to these document ids (specs,
322
+ # sampling runs)
323
+ # @param deep [Boolean] fetch level 3. false costs `1 + documents` requests
324
+ # and yields month-precision dates and a derived PDF URL; true adds one
325
+ # request per edition for the page's own date and PDF href.
326
+ # @param errors [Hash] shared error tally, as DataParserR expects
327
+ # @return [Array<Relaton::Itu::ItemData>]
328
+ def harvest(series, family: DEFAULT_FAMILY, only: nil, deep: true, errors: Hash.new(true), skip: nil)
329
+ docs = documents(series, family: family)
330
+ docs = docs.select { |d| only.include? d[:id] } if only
331
+ # A Handbook edition inherits the document's title (`title: :document`):
332
+ # its own row shows a year and nothing else, so this is the only chance
333
+ # to carry the title down — the level-3 fetch never sees the index.
334
+ inherited = config(family)[:title] == :document
335
+ # Level 2 — one request per document. Parallel, but only *within* this
336
+ # series: #harvest_family rescues per series and DataMergeR writes per
337
+ # series, and the data repo's guard_itur_harvest is calibrated on that
338
+ # granularity, so the series loop above stays strictly serial.
339
+ editions = in_parallel(docs) do |d|
340
+ editions(d[:id]).map do |ed|
341
+ ed.merge(family: family_of(ed[:id]), **(inherited ? { title: d[:title] } : {}))
342
+ end
343
+ end.flatten(1)
344
+ # `skip` is what makes a top-up cheap: it decides from the level-2 row,
345
+ # before the per-edition page — the expensive half — is ever requested.
346
+ # It also needs the complete level-2 set, which is why the two levels are
347
+ # two phases with a barrier rather than one pool.
348
+ editions = editions.reject { |ed| skip.call ed } if skip
349
+ # Level 3 — one request per edition, ~70% of a full crawl.
350
+ #
351
+ # Each parse gets its OWN errors hash, folded on this thread after the
352
+ # join. `errors[k] &&= v` from four workers is exactly the hazard
353
+ # DataParserT documents at data_parser_t.rb:33-36; folding afterwards
354
+ # makes the tally deterministic and needs no lock.
355
+ parsed = in_parallel(editions) do |ed|
356
+ own = Hash.new(true)
357
+ [DataParserR.parse(row(ed, deep: deep), own), own]
358
+ end
359
+ parsed.each { |_, own| own.each { |k, v| errors[k] &&= v } }
360
+ parsed.filter_map(&:first)
361
+ end
362
+ private
363
+
364
+ # Map `items` through the block on the worker pool, results index-aligned
365
+ # with the input.
366
+ #
367
+ # Serial and thread-free at concurrency 1 or on a one-item list, so every
368
+ # existing single-threaded caller — and every cassette-backed spec — stays
369
+ # on exactly the code path it is on today.
370
+ #
371
+ # Failure semantics are deliberately today's: the first error aborts the
372
+ # whole call, because #harvest already loses the series on a raise and the
373
+ # data repo's guard_itur_harvest is calibrated on that. Returning the
374
+ # partial result instead would shrink a collapse below its 50% threshold
375
+ # and publish the hole the guard exists to catch. What changes is only the
376
+ # blast radius — the producer stops enqueueing at the first error, so at
377
+ # most `concurrency` further requests go out rather than a whole queue's.
378
+ #
379
+ # @param items [Array]
380
+ # @return [Array] one entry per item, in input order
381
+ def in_parallel(items)
382
+ return items.map { |i| yield i } if @concurrency < 2 || items.size < 2
383
+
384
+ # Pre-sized, so workers only ever assign to distinct existing indices —
385
+ # no lock, and no chance of racing a resize. That is the one thing that
386
+ # makes Core::WorkersPool unusable here.
387
+ results = Array.new(items.size)
388
+ error = nil
389
+ guard = Mutex.new
390
+ queue = SizedQueue.new(@concurrency * 2)
391
+ workers = worker_agents.map do |worker_agent|
392
+ Thread.new do
393
+ Thread.current[AGENT_KEY] = worker_agent
394
+ while (item = queue.pop)
395
+ value, idx = item
396
+ # `next`, never `break`: a worker that stopped popping would let
397
+ # the SizedQueue fill and deadlock the producer on its poison
398
+ # pills — a hung CI job, the worst failure available here.
399
+ next if guard.synchronize { !error.nil? }
400
+
401
+ begin
402
+ results[idx] = yield value
403
+ rescue StandardError => e
404
+ guard.synchronize { error ||= e }
405
+ end
406
+ end
407
+ end
408
+ end
409
+ items.each_with_index do |item, idx|
410
+ break if guard.synchronize { !error.nil? }
411
+
412
+ queue << [item, idx]
413
+ end
414
+ @concurrency.times { queue << nil }
415
+ workers.each(&:join)
416
+ raise error if error
417
+
418
+ results
419
+ end
420
+
421
+ # One agent per worker, built once for the crawler rather than per phase:
422
+ # Mechanize is not thread-safe, and a per-worker agent also keeps one
423
+ # worker's cookie and history state out of another's.
424
+ def worker_agents
425
+ @worker_agents ||= Array.new(@concurrency) { self.class.agent }
426
+ end
427
+
428
+ # In deep mode the edition page is the **authority**: an edition that
429
+ # offers no `!!PDF-E.pdf` (older ones are Word-only) gets no source rather
430
+ # than a guessed URL that 404s, and a page that came back empty — ITU does
431
+ # serve truncated 200s — is logged instead of being silently backfilled
432
+ # with shallow data, which would make a degraded deep crawl look like a
433
+ # successful one. The derived values are the *shallow* mode's answer.
434
+ #
435
+ # @param ed [Hash] a level-2 row
436
+ # @return [Hash] the normalized row DataParserR consumes
437
+ def row(ed, deep:)
438
+ return ed.merge(base_row(ed), date: id_date(ed[:id]), pdf: pdf_url(ed[:id])) unless deep
439
+
440
+ detail = edition(ed[:id])
441
+ # A family whose date lives in the id (Handbooks) is expected to find
442
+ # none on the page; for the others a missing date means a degraded fetch.
443
+ if detail[:date].nil? && config(family_of(ed[:id]))[:date] != :id
444
+ Util.warn "No date on #{page_url ed[:id]} — falling back to the id's date"
445
+ end
446
+ ed.merge(base_row(ed), date: detail[:date] || id_date(ed[:id]), pdf: detail[:pdf])
447
+ end
448
+
449
+ # @param ed [Hash]
450
+ # @return [Hash]
451
+ def base_row(ed)
452
+ { family: family_of(ed[:id]), url: page_url(ed[:id]) }
453
+ end
454
+
455
+ # @param family [String]
456
+ # @return [Hash] the family's URL templates and date field
457
+ # @raise [ArgumentError] for a family this crawler doesn't implement
458
+ def config(family)
459
+ FAMILIES[family] or
460
+ raise ArgumentError, "unknown ITU-R family #{family.inspect} (known: #{FAMILIES.keys.join ', '})"
461
+ end
462
+
463
+ # Every id names its own family, so the level-2/3 methods take an id alone.
464
+ #
465
+ # @param id [String] e.g. "R-REP-BO.1227-2-1998"
466
+ # @return [String] e.g. "R-REP"
467
+ def family_of(id)
468
+ id.to_s[/\AR-[A-Z]+/].to_s
469
+ end
470
+
471
+ # @param id [String]
472
+ # @return [String]
473
+ def page_url(id)
474
+ format config(family_of(id))[:page], id: id
475
+ end
476
+
477
+ # Recommendation pages carry an approval date; report pages date each
478
+ # downloadable file instead ("Posted"), which is the publication date the
479
+ # corpus records — at month precision, so the day is dropped.
480
+ #
481
+ # @param page [Mechanize::Page]
482
+ # @param id [String]
483
+ # @return [String, nil]
484
+ def page_date(page, id)
485
+ case config(family_of(id))[:date]
486
+ when :approved then page_text(page)[APPROVED_RE, 1]
487
+ when :posted then posted_date(page, id)
488
+ # :id — the page has no date to offer, so #row falls back to the id's
489
+ # year without treating it as a degraded crawl.
490
+ end
491
+ end
492
+
493
+ # A report's files can be re-posted after publication, in which case the
494
+ # posted date is no longer the publication date the id names. Say so
495
+ # rather than quietly dating the record by a re-upload.
496
+ #
497
+ # @param page [Mechanize::Page]
498
+ # @param id [String]
499
+ # @return [String, nil]
500
+ def posted_date(page, id)
501
+ date = page_text(page)[POSTED_RE, 1]
502
+ year = id[ID_YEAR_RE, 1]
503
+ if date && year && date[0, 4] != year
504
+ Util.warn "Posted date #{date} on #{page_url id} disagrees with the id's year #{year}"
505
+ end
506
+ date
507
+ end
508
+
509
+ # The decoded document text minus scripts: the raw body is the response's
510
+ # bytes (Latin-1 here, ASCII-8BIT when replayed from a cassette), and the
511
+ # SharePoint chrome embeds JavaScript full of date literals that a bare
512
+ # date match would find first.
513
+ #
514
+ # @param page [Mechanize::Page]
515
+ # @return [String]
516
+ def page_text(page)
517
+ doc = page.parser.dup
518
+ doc.search("script", "style").each(&:remove)
519
+ squish doc.text
520
+ end
521
+
522
+ # An ITU restyle would leave every level matching nothing, and the crawl
523
+ # would return an empty corpus that looks like a successful run — the
524
+ # silent failure ITU_R_DISABLED exists to prevent for RunSearch. Say so.
525
+ #
526
+ # @param found [Array] the level's rows
527
+ # @param what [String] what was being enumerated
528
+ # @return [Array] found, unchanged
529
+ def warn_if_empty(found, what)
530
+ Util.warn "ITU-R crawl: no #{what} found — the page layout may have changed" if found.empty?
531
+ found
532
+ end
533
+
534
+ # Shallow mode's date. A Recommendation id ends in YYYYMM (the displayed
535
+ # code renders pre-2000 editions with a two-digit year, so the id is the
536
+ # only unambiguous source); a Report id ends in the publication year, which
537
+ # gives no month.
538
+ #
539
+ # @param id [String]
540
+ # @return [String, nil]
541
+ def id_date(id)
542
+ m = id.match(ID_DATE_RE)
543
+ return "#{m[1]}-#{m[2]}" if m
544
+
545
+ id[ID_YEAR_RE, 1]
546
+ end
547
+
548
+ # nil rather than a malformed URL when the series can't be read off the id
549
+ # — DataParserR then records the missing source in the error tally instead
550
+ # of writing `…/rec//R-REC-…` into the data file.
551
+ #
552
+ # @param id [String]
553
+ # @return [String, nil]
554
+ def pdf_url(id)
555
+ template = config(family_of(id))[:pdf]
556
+ return nil unless template # R-QUE editions rarely have one; never guess
557
+ return format(template, id: id) unless template.include? "%<series>s"
558
+
559
+ series = id[/-([A-Za-z]+)\./, 1]
560
+ series && format(template, series: series.downcase, id: id)
561
+ end
562
+
563
+ # Every level's table rows share one shape: a `parent=`-linked anchor in
564
+ # the first cell, the title in the second.
565
+ #
566
+ # @param url [String]
567
+ # @return [Array<Array(String, Nokogiri::XML::Element, Nokogiri::XML::NodeSet)>]
568
+ def rows(url)
569
+ get(url).search("//a[contains(@href,'parent=R-')]").filter_map do |a|
570
+ id = a[:href].to_s[PARENT_RE, :id]
571
+ tr = a.ancestors("tr").first
572
+ [id, a, tr.search("td")] if id && tr
573
+ end
574
+ end
575
+
576
+ # The title cell carries an optional red annotation ("Note - Suppressed on
577
+ # …"), which is not part of the title.
578
+ #
579
+ # @param cell [Nokogiri::XML::Element, nil]
580
+ # @return [String]
581
+ def title_text(cell)
582
+ return "" unless cell
583
+
584
+ cell = cell.dup
585
+ cell.search("font").each(&:remove)
586
+ squish(cell.text)
587
+ end
588
+
589
+ # The cells are padded with `&nbsp;`, which `\s` does not match — so the
590
+ # non-breaking spaces are folded in before collapsing. `scrub` first: the
591
+ # /rec pages declare no charset in Content-Type, so a page whose encoding
592
+ # Nokogiri sniffs as UTF-8 can hand back Latin-1 bytes, and `gsub` on an
593
+ # invalid-encoding String raises — which would kill the whole series.
594
+ #
595
+ # @param text [String, nil]
596
+ # @return [String]
597
+ def squish(text)
598
+ text.to_s.scrub.tr(" ", " ").gsub(/\s+/, " ").strip
599
+ end
600
+
601
+ # Retried, because a corpus-scale crawl *will* be throttled: measured over
602
+ # a 49-minute full run, ITU rate-limits `/rec` with HTTP 503 and `/pub`
603
+ # with a **302 to `/en/publications/pages/notfound.aspx`** — a soft block
604
+ # that is indistinguishable from "no such document" unless you look. That
605
+ # second form silently emptied all 14 report series of one run. So a
606
+ # notfound landing is treated as a retryable failure like any 5xx, and
607
+ # only a persistent one raises.
608
+ #
609
+ # @param url [String]
610
+ # @return [Mechanize::Page]
611
+ # @raise [Relaton::RequestError] after RETRIES attempts
612
+ def get(url)
613
+ attempt = 0
614
+ begin
615
+ attempt += 1
616
+ # A crawl the governor has abandoned must stop *now*: grinding out the
617
+ # remaining hours against a WAF ban is what put the job over the 6 h
618
+ # Actions cap in the first place.
619
+ raise Relaton::RequestError, "ITU-R crawl abandoned: rate-limited by www.itu.int" if @governor.exhausted?
620
+
621
+ @governor.wait # the pool-wide cooldown, if any worker opened one
622
+ @pacer.wait # the shared minimum gap between request starts
623
+ page = agent.get url
624
+ raise SoftBlock.new(page), "redirected to notfound" if notfound? page
625
+
626
+ @governor.succeeded!
627
+ page
628
+ rescue Mechanize::ResponseCodeError, SocketError, Timeout::Error, Errno::ECONNRESET,
629
+ EOFError, Net::ProtocolError, OpenSSL::SSL::SSLError => e
630
+ # A rate limit is a *pool* event and a transport error is not, so they
631
+ # get different treatment. Four workers each running their own 5/10/15 s
632
+ # ladder against a WAF ban keeps the limiter engaged; one shared
633
+ # cooldown on the ban's own minute scale is what outlasts it.
634
+ if Governor.throttle?(e)
635
+ gave_up = @governor.throttled!(retry_after: Governor.retry_after(e))
636
+ Util.warn "ITU-R crawl: #{url} throttled (#{e.message}); " \
637
+ "pool cooldown round #{@governor.throttle_rounds}"
638
+ retry if !gave_up && attempt <= RETRIES
639
+ elsif attempt <= RETRIES
640
+ Util.warn "ITU-R crawl: #{url} failed (#{e.message}); retry #{attempt}/#{RETRIES} in #{backoff attempt}s"
641
+ sleep backoff(attempt)
642
+ retry
643
+ end
644
+ raise Relaton::RequestError, "Could not access #{url}: #{e.message}"
645
+ end
646
+ end
647
+
648
+ # @param page [Mechanize::Page]
649
+ # @return [Boolean]
650
+ def notfound?(page)
651
+ page.uri.to_s.include? NOTFOUND
652
+ end
653
+
654
+ # Linear backoff — the block is a rate limit, so the point is to wait out
655
+ # a window, not to hammer a dead host.
656
+ #
657
+ # @param attempt [Integer]
658
+ # @return [Integer] seconds
659
+ def backoff(attempt)
660
+ attempt * RETRY_BACKOFF
661
+ end
662
+ end
663
+ end
664
+ end