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
@@ -75,12 +75,20 @@ module Relaton
75
75
  Bib::Status.new(stage: stage)
76
76
  end
77
77
 
78
- # @param hit [RelatonCen::Hit]
78
+ # The number and part come from the pubid. `#root` is the accessor that
79
+ # answers for every form: an adopted norm keeps both on the adopted ISO
80
+ # document, and a supplement keeps both on the document it supplements.
81
+ # pubid holds a sub-part inside `part` (`61375-2-3` gives `"2-3"`), so
82
+ # the separator swap reproduces the old `"2:3"`.
83
+ #
84
+ # @param hit [Relaton::Cen::Hit]
79
85
  # @return [Relaton::Bib::StructuredIdentifier]
80
86
  def fetch_structuredid(hit)
81
- %r{(?<docnum>\d+)(?:-(?<part>\d+))?(?:-(?<subpart>\d+))?} =~ hit[:code]
82
- partnumber = [part, subpart].compact.join(":")
83
- StructuredIdentifier.new(docnumber: docnum, partnumber: partnumber, agency: ["CEN"])
87
+ root = hit.pubid&.root
88
+ docnumber = root&.number&.to_s
89
+ partnumber = root&.part.to_s.split("-").join(":")
90
+ StructuredIdentifier.new(docnumber: docnumber, partnumber: partnumber,
91
+ agency: ["CEN"])
84
92
  end
85
93
 
86
94
  # Fetch relations.
@@ -201,7 +209,7 @@ module Relaton
201
209
  doctype: Bib::Doctype.new(content: "international-standard"),
202
210
  flavor: "cen",
203
211
  ics: fetch_ics(doc),
204
- structuredidentifier: fetch_structuredid(hit.hit),
212
+ structuredidentifier: fetch_structuredid(hit),
205
213
  )
206
214
  end
207
215
  end
data/lib/relaton/cen.rb CHANGED
@@ -4,6 +4,7 @@ require "digest/md5"
4
4
  require "mechanize"
5
5
  require "relaton/core"
6
6
  require "isoics"
7
+ require "pubid"
7
8
  require "relaton/bib"
8
9
  require_relative "version"
9
10
  require_relative "cen/util"
@@ -4,6 +4,7 @@ require "English"
4
4
  require "fileutils"
5
5
  require "ferrum"
6
6
  require "nokogiri"
7
+ require "pubid"
7
8
  require "relaton/index"
8
9
  require "relaton/bib"
9
10
  require "relaton/core/data_fetcher"
@@ -21,6 +22,12 @@ module Relaton
21
22
  CHALLENGE_MARKERS = ["Just a moment", "challenge-platform"].freeze
22
23
  MAX_CHALLENGE_WAIT = 30
23
24
 
25
+ # Raised when the Cloudflare challenge ("Just a moment…") is still showing
26
+ # after MAX_CHALLENGE_WAIT seconds. It's a retriable signal (see
27
+ # DataFetcher::RETRIABLE_ERRORS): rather than parse the challenge HTML as a
28
+ # document, the caller (#time_req) backs the worker off and retries.
29
+ class ChallengeError < StandardError; end
30
+
24
31
  def initialize
25
32
  @browser = Ferrum::Browser.new(
26
33
  headless: true,
@@ -68,18 +75,106 @@ module Relaton
68
75
 
69
76
  sleep 1
70
77
  end
78
+ raise ChallengeError, "Cloudflare challenge did not clear after #{MAX_CHALLENGE_WAIT}s"
71
79
  end
72
80
  end
73
81
 
74
82
  class DataFetcher < Relaton::Core::DataFetcher
75
83
  URL = "https://www.techstreet.com/cie/searches/31156444?page=1&per_page=100"
76
84
 
85
+ # Default worker-pool size for the parallel detail-fetch phase. Kept small
86
+ # so N stealth Chrome instances don't burst requests fast enough to trip
87
+ # Cloudflare; tune via RELATON_CIE_CONCURRENCY (see .concurrency).
88
+ DEFAULT_CONCURRENCY = 5
89
+
90
+ # Per-worker pacing bounds (seconds). Each worker starts at BASE_GAP and
91
+ # doubles its own gap up to MAX_GAP on trouble (see Pacing / #time_req).
92
+ BASE_GAP = 1
93
+ MAX_GAP = 32
94
+
95
+ # Number of detail-fetch worker threads. Tunable via env var so
96
+ # relaton-data-cie's crawler workflow can dial it up for speed or down to
97
+ # lighten load on techstreet without a code change. Never below 1.
98
+ def self.concurrency
99
+ [(ENV["RELATON_CIE_CONCURRENCY"] || DEFAULT_CONCURRENCY).to_i, 1].max
100
+ end
101
+
102
+ def initialize(output, format)
103
+ super
104
+ # Guards the shared bookkeeping (@files, @errors, @seen) and the index
105
+ # mutation while workers fetch detail pages in parallel. The slow
106
+ # agent.get runs outside this lock; only the cheap build+write is held.
107
+ @mutex = Mutex.new
108
+ # output file => catalogue position of the hit that last claimed it, so a
109
+ # duplicate primary id resolves to the same last-by-position winner the
110
+ # serial crawl would pick, regardless of worker completion order.
111
+ @seen = {}
112
+ end
113
+
114
+ # Factory for a fresh stealth browser agent. The pool builds one per worker
115
+ # (each its own Chrome, each with the full UA/header/`navigator` masking);
116
+ # #agent memoizes a single one for the serial listing phase. Specs stub
117
+ # this so one double can back every worker.
118
+ def build_agent
119
+ BrowserAgent.new
120
+ end
121
+
77
122
  def agent
78
- @agent ||= BrowserAgent.new
123
+ @agent ||= build_agent
124
+ end
125
+
126
+ # Per-worker adaptive request pacing. Replaces the old single global 4 s
127
+ # gap: each worker owns one Pacing, starts at BASE_GAP, and doubles its gap
128
+ # (capped at MAX_GAP) whenever a request hits trouble (a Cloudflare
129
+ # challenge or a Ferrum/socket error), so a struggling worker slows itself
130
+ # without throttling the healthy ones.
131
+ class Pacing
132
+ def initialize(base: BASE_GAP, max: MAX_GAP)
133
+ @base = base
134
+ @max = max
135
+ @gap = base
136
+ @last = nil
137
+ end
138
+
139
+ attr_reader :gap
140
+
141
+ # Sleep off whatever remains of this worker's gap since its last request,
142
+ # then stamp the clock (in ensure, mirroring the old #time_req).
143
+ def throttle
144
+ sleep [@gap - (Time.now - @last), 0].max if @last
145
+ ensure
146
+ @last = Time.now
147
+ end
148
+
149
+ # Exponential backoff, capped at @max. Called before a retry on trouble.
150
+ def backoff
151
+ @gap = [@gap * 2, @max].min
152
+ end
79
153
  end
80
154
 
81
155
  def index
82
- @index ||= Index.find_or_create :cie, file: "index-v1.yaml"
156
+ @index ||= Index.find_or_create :cie, file: "#{INDEXFILE}.yaml",
157
+ pubid_class: ::Pubid::Cie::Identifier
158
+ end
159
+
160
+ # Parse a docidentifier string into a Pubid::Cie::Identifier, or nil if
161
+ # pubid can't parse it or it won't survive the structured index — so a
162
+ # single bad id never aborts the crawl or corrupts index-v2. The caller
163
+ # (#write_file) records the skip.
164
+ #
165
+ # The guard mirrors the read side's acceptance test
166
+ # (Index::FileIO#id_supported?, `from_hash(to_hash) == to_hash`): the
167
+ # loader raises InvalidIndexError and rejects the WHOLE index on the first
168
+ # id that doesn't round-trip, so an id that serializes but can't be
169
+ # deserialized back must be dropped here rather than poison every lookup.
170
+ def pubid(id)
171
+ pid = ::Pubid::Cie.parse id
172
+ hash = pid.to_hash
173
+ return nil unless ::Pubid::Cie::Identifier.from_hash(hash).to_hash == hash
174
+
175
+ pid
176
+ rescue StandardError
177
+ nil
83
178
  end
84
179
 
85
180
  def log_error(msg)
@@ -273,9 +368,24 @@ module Relaton
273
368
  end
274
369
 
275
370
  # @param bib [RelatonCie::BibliographicItem]
276
- def write_file(bib) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
371
+ # @param pos [Integer, nil] the hit's catalogue position, used only by the
372
+ # parallel pool to keep a deterministic last-by-position winner when two
373
+ # hits map to the same output file. nil (direct calls) keeps the
374
+ # historical warn-and-overwrite behavior.
375
+ def write_file(bib, pos = nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
277
376
  id = bib.docidentifier[0].content
278
377
  file = output_file id
378
+ # Index every distinct id (the serial crawl indexes each one, even two
379
+ # that resolve to the same file), before the content-dedup gate below.
380
+ pid = pubid id
381
+ if pid
382
+ index.add_or_update pid, file
383
+ else
384
+ Util.warn { "Unparseable id `#{id}` was not indexed (#{file})" }
385
+ end
386
+ # Only the last-by-position hit writes the shared file's content.
387
+ return if superseded? file, pos
388
+
279
389
  if @files.include? file
280
390
  Util.warn do
281
391
  "File #{file} exists. Docid: #{bib.docidentifier[0].content}\n" \
@@ -283,71 +393,146 @@ module Relaton
283
393
  end
284
394
  else @files << file
285
395
  end
286
- index.add_or_update bib.docidentifier[0].content, file
287
396
  File.write file, serialize(bib), encoding: "UTF-8"
288
397
  end
289
398
 
399
+ # True when another hit with a *later* catalogue position has already
400
+ # claimed `file`. The serial crawl is last-writer-wins, so that later hit
401
+ # is canonical and this (earlier or duplicate) content write must be
402
+ # dropped — making the output independent of worker completion order. With
403
+ # pos nil there's no position to compare, so nothing is superseded.
404
+ def superseded?(file, pos)
405
+ return false if pos.nil?
406
+
407
+ prev = @seen[file]
408
+ return true if prev && prev >= pos
409
+
410
+ @seen[file] = pos
411
+ false
412
+ end
413
+
290
414
  def to_xml(bib) = bib.to_xml(bibdata: true)
291
415
  def to_yaml(bib) = bib.to_yaml
292
416
  def to_bibxml(bib) = bib.to_rfcxml
293
417
 
418
+ # Fetch and store one document. The slow detail-page load
419
+ # (`worker_agent.get`, throttled by this worker's `pacing`) runs outside
420
+ # the lock — that's the parallel win; the cheap build + write is done under
421
+ # @mutex so @files/@errors/@seen and the index stay consistent across
422
+ # workers. Called directly (single stubbed agent) by specs and from the
423
+ # pool with a per-worker agent/pacing/position.
424
+ #
294
425
  # @param hit [Nokogiri::HTML::Element]
295
- def parse_page(hit) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
426
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
427
+ def parse_page(hit, pos = nil, worker_agent = agent, pacing = nil)
296
428
  url = hit.at('h3/a')[:href]
297
- doc = time_req { agent.get url }
298
- item = ItemData.new(
299
- type: "standard", source: fetch_source(url), docnumber: fetch_docnumber(hit),
300
- docidentifier: fetch_docid(hit, doc), title: fetch_title(doc),
301
- abstract: fetch_abstract(doc), date: fetch_date(doc),
302
- edition: fetch_edition(doc), contributor: fetch_contributor(doc),
303
- relation: fetch_relation(doc), language: "en", script: "Latn",
304
- ext: fetch_ext
305
- )
306
- write_file item
429
+ doc = time_req(pacing) { worker_agent.get url }
430
+ @mutex.synchronize do
431
+ item = ItemData.new(
432
+ type: "standard", source: fetch_source(url), docnumber: fetch_docnumber(hit),
433
+ docidentifier: fetch_docid(hit, doc), title: fetch_title(doc),
434
+ abstract: fetch_abstract(doc), date: fetch_date(doc),
435
+ edition: fetch_edition(doc), contributor: fetch_contributor(doc),
436
+ relation: fetch_relation(doc), language: "en", script: "Latn",
437
+ ext: fetch_ext
438
+ )
439
+ write_file item, pos
440
+ end
307
441
  rescue StandardError => e
308
442
  Util.error do
309
443
  "Document: #{url}\n#{e.message}\n#{e.backtrace}"
310
444
  end
311
445
  end
446
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
312
447
 
313
448
  def fetch(_source = nil)
314
- fetch_doc
449
+ process_hits collect_hits
450
+ index.save
315
451
  report_errors
316
452
  ensure
317
453
  @agent&.quit
318
454
  end
319
455
 
320
- def fetch_doc(url = URL)
321
- result = time_req { agent.get url }
322
- result.xpath("//li[@data-product]").each { |hit| parse_page hit }
456
+ # Phase 1 (serial): walk the search-result pages, following the "next"
457
+ # link, and collect every //li[@data-product] hit into one list (~12 page
458
+ # loads at per_page=100, so serial pacing here is cheap). The detail-page
459
+ # fetches happen in #process_hits.
460
+ def collect_hits(url = URL, hits = [])
461
+ result = time_req(listing_pacing) { agent.get url }
462
+ hits.concat result.xpath("//li[@data-product]").to_a
323
463
  np = result.at '//a[@class="next_page"]'
324
- if np
325
- next_href = np[:href]
326
- next_url = next_href.start_with?("http") ? next_href : "https://www.techstreet.com#{next_href}"
327
- fetch_doc next_url
328
- else
329
- index.save
464
+ return hits unless np
465
+
466
+ next_href = np[:href]
467
+ next_url = next_href.start_with?("http") ? next_href : "https://www.techstreet.com#{next_href}"
468
+ collect_hits next_url, hits
469
+ end
470
+
471
+ # Phase 2 (parallel): fan the collected hits out across a bounded worker
472
+ # pool. Each worker owns its own stealth browser agent and its own adaptive
473
+ # pacing. The agents are built up front on this thread so a Chrome-launch
474
+ # failure aborts fast (before any hit is enqueued and before the index is
475
+ # saved) rather than leaving a dead worker that can't drain the queue; and
476
+ # every created agent is quit in the ensure even if enqueuing or a worker
477
+ # raises. Hits carry their catalogue position so #write_file keeps a
478
+ # deterministic winner for any duplicate output file — byte-identical
479
+ # output regardless of completion order.
480
+ def process_hits(hits)
481
+ return if hits.empty?
482
+
483
+ n = self.class.concurrency
484
+ agents = []
485
+ queue = SizedQueue.new(n * 2)
486
+ n.times { agents << build_agent }
487
+ workers = agents.map { |worker_agent| spawn_worker(queue, worker_agent) }
488
+ hits.each_with_index { |hit, pos| queue << [hit, pos] }
489
+ n.times { queue << nil } # poison pills
490
+ workers.each(&:join)
491
+ ensure
492
+ agents&.each(&:quit)
493
+ end
494
+
495
+ # One pool worker: drains the queue with its own browser agent + pacing
496
+ # until the poison pill (nil). parse_page swallows per-document errors, so
497
+ # the thread runs to the pill; the agent is quit by #process_hits.
498
+ def spawn_worker(queue, worker_agent)
499
+ pacing = Pacing.new
500
+ Thread.new do
501
+ while (item = queue.pop)
502
+ hit, pos = item
503
+ parse_page hit, pos, worker_agent, pacing
504
+ end
330
505
  end
331
506
  end
332
507
 
508
+ # Pacing for the serial listing phase (Phase 1). Also the fallback pacing
509
+ # for #time_req when no per-worker pacing is passed (direct calls / specs).
510
+ def listing_pacing
511
+ @listing_pacing ||= Pacing.new
512
+ end
513
+
333
514
  RETRIABLE_ERRORS = [
334
515
  SocketError,
335
516
  Ferrum::TimeoutError,
336
517
  Ferrum::PendingConnectionsError,
337
- Ferrum::StatusError
518
+ Ferrum::StatusError,
519
+ BrowserAgent::ChallengeError
338
520
  ].freeze
339
521
 
340
- def time_req
522
+ # Run a throttled request through the given worker's pacing, retrying up to
523
+ # 4 times on a retriable error (challenge / Ferrum / socket) and backing
524
+ # that worker's gap off before each retry.
525
+ def time_req(pacing = listing_pacing)
526
+ pacing ||= listing_pacing
341
527
  tries = 0
342
528
  begin
343
529
  tries += 1
344
- sleep [4 - (Time.now - @last_request_time).to_i, 0].max if @last_request_time
530
+ pacing.throttle
345
531
  yield
346
532
  rescue *RETRIABLE_ERRORS => e
533
+ pacing.backoff
347
534
  retry if tries < 4
348
535
  raise e
349
- ensure
350
- @last_request_time = Time.now
351
536
  end
352
537
  end
353
538
  end
@@ -59,7 +59,9 @@ module Relaton
59
59
  #
60
60
  def remove_index_file
61
61
  require_relative "../cie"
62
- Relaton::Index.find_or_create(:cie, url: true, file: "#{INDEXFILE}.yaml").remove_file
62
+ Relaton::Index.find_or_create(
63
+ :cie, url: true, file: "#{INDEXFILE}.yaml"
64
+ ).remove_file
63
65
  end
64
66
  end
65
67
  end
@@ -9,8 +9,21 @@ module Relaton
9
9
  # @param code [String]
10
10
  # @return [Relaton::Cie::ItemData]
11
11
  def scrape_page(code)
12
- index = Index.find_or_create :cie, url: "#{ENDPOINT}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml"
13
- row = index.search(code).min_by { |r| r[:id] }
12
+ # An unrecognized reference raises; like ISO and 3GPP we let it
13
+ # propagate -- relaton-cli rescues Pubid::Errors::Error and renders
14
+ # "... is not a recognized standards identifier". Partial refs
15
+ # (`CIE 001`, `CIE 15`) parse, so nothing valid is lost.
16
+ pubid = ::Pubid::Cie.parse code
17
+ index = Index.find_or_create :cie, url: "#{ENDPOINT}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml",
18
+ pubid_class: ::Pubid::Cie::Identifier
19
+ # Pass the parsed pubid (not the raw String) so index-v2 narrows
20
+ # candidates by number via binary search before the block runs; the
21
+ # block keeps the broad substring match the string index gave.
22
+ # Rows are Pubid::Cie::Identifier objects (not Comparable), so pick by
23
+ # the string form.
24
+ needle = pubid.to_s
25
+ row = index.search(pubid) { |r| r[:id].to_s.include?(needle) }
26
+ .min_by { |r| r[:id].to_s }
14
27
  return unless row
15
28
 
16
29
  parse_page "#{ENDPOINT}#{row[:file]}", code
data/lib/relaton/cie.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "nokogiri"
2
2
  # require "parslet"
3
+ require "pubid"
3
4
  require "relaton/index"
4
5
  require "relaton/bib"
5
6
  # require "relaton_bib/name_parser"
@@ -14,7 +15,7 @@ require_relative "cie/scrapper"
14
15
 
15
16
  module Relaton
16
17
  module Cie
17
- INDEXFILE = "index-v1".freeze
18
+ INDEXFILE = "index-v2".freeze
18
19
 
19
20
  # Returns hash of XML reammar
20
21
  # @return [String]
@@ -15,6 +15,16 @@ module Relaton
15
15
  @format = format
16
16
  @ext = format.sub "bibxml", "xml"
17
17
  @files = Set.new
18
+ # path => docid that reserved it, for #unique_output_file. Distinct
19
+ # from @files, which flavors use for their own duplicate handling:
20
+ # this one has to know WHICH document owns a path, not just that it
21
+ # is taken.
22
+ @file_docids = {}
23
+ # Paths this process wrote during this run; see #write_unique.
24
+ @written = Set.new
25
+ # Set true by a fetcher that writes from forked worker processes,
26
+ # where @file_docids cannot see a peer's claim. See #write_unique.
27
+ @cross_process = false
18
28
  # @docs = []
19
29
  @errors = Hash.new(true)
20
30
  end
@@ -55,7 +65,14 @@ module Relaton
55
65
 
56
66
  def report_errors
57
67
  gh_issue # register the channel before logging
58
- @errors.select { |_, v| v }.each_key { |k| log_error "Failed to fetch #{k}" }
68
+ @errors.select { |_, v| v }.each do |key, value|
69
+ # A String value IS the message: a specific, per-document failure
70
+ # such as an unparseable identifier, reported through this same
71
+ # channel without needing a per-flavor override. A boolean means
72
+ # "this field failed for every record" — what the flavors' own
73
+ # ERROR_KEYS track — and its message is derived from the key.
74
+ log_error value.is_a?(String) ? value : "Failed to fetch #{key}"
75
+ end
59
76
  @gh_issue&.create_issue
60
77
  end
61
78
 
@@ -63,11 +80,111 @@ module Relaton
63
80
  raise NoMatchingPatternError, "#{self.class}#log_error method must be implemented"
64
81
  end
65
82
 
83
+ # Most filesystems cap a single path component at 255 bytes.
84
+ MAX_BASENAME_BYTES = 255
85
+
86
+ # Create-or-fail. The failure is the point: it is how one process learns
87
+ # that another already holds a path. See #write_unique.
88
+ EXCLUSIVE = File::WRONLY | File::CREAT | File::EXCL
89
+
66
90
  # @param [String] document ID
67
91
  # @return [String] filename based on PubID identifier
92
+ #
93
+ # A docid can be pathologically long (pubid's `to_s` for amendment docs
94
+ # embeds the full "(Amendment to … as amended by …)" clause), which would
95
+ # make the basename exceed the OS limit and raise Errno::ENAMETOOLONG on
96
+ # write. When that happens, truncate the sanitized id and append a short
97
+ # digest of the full docid so the filename stays bounded, unique, and
98
+ # deterministic (every call site round-trips through this method).
68
99
  def output_file(docid)
69
- id = docid.downcase.gsub(/[.\s\/:()-]+/, "-").delete_suffix("-")
70
- File.join @output, "#{id}.#{@ext}"
100
+ id = docid.downcase.gsub(/[.,\s\/:()-]+/, "-").delete_suffix("-")
101
+ ext = ".#{@ext}"
102
+ limit = MAX_BASENAME_BYTES - ext.bytesize
103
+ if id.bytesize > limit
104
+ require "digest"
105
+ suffix = "-#{Digest::SHA1.hexdigest(docid)[0, 12]}"
106
+ # `id` has no consecutive "-" (gsub collapsed runs above), so
107
+ # truncation leaves at most one trailing "-" — delete_suffix is
108
+ # enough and avoids a polynomial-ReDoS regex on the docid.
109
+ id = id.byteslice(0, limit - suffix.bytesize).scrub("").delete_suffix("-") + suffix
110
+ end
111
+ File.join @output, "#{id}#{ext}"
112
+ end
113
+
114
+ #
115
+ # Reserve a unique output path for `docid`.
116
+ #
117
+ # `output_file` sanitizes ".", ",", "/", ":", "(", ")", "-" and whitespace
118
+ # all to "-", so two DISTINCT docids can map to one path — live instance in
119
+ # relaton-data-iana: `rpki/signed-objects` and `rpki-signed-objects` both
120
+ # give `data/rpki-signed-objects.yaml`. A caller that merely warns and
121
+ # writes anyway leaves one file holding the wrong document for one of two
122
+ # index ids: a wrong answer, not a missing one.
123
+ #
124
+ # Returns `output_file(docid)` when that path is free, or when it is
125
+ # already held by this SAME docid — a genuine duplicate is the caller's
126
+ # business (skip / merge / last-wins), and this method must not turn one
127
+ # into two files. Only a real clash with a DIFFERENT docid gets a variant,
128
+ # suffixed with a digest of this docid so the name depends on the document
129
+ # and not on encounter order: adding a record never renames an existing
130
+ # file. (Which member of a clashing pair keeps the plain path does follow
131
+ # write order, which is stable for a given corpus.)
132
+ #
133
+ # @param [String] docid
134
+ # @return [String] path, reserved for this docid
135
+ #
136
+ def unique_output_file(docid)
137
+ file = output_file docid
138
+ owner = @file_docids[file]
139
+ file = digest_output_file(docid) unless owner.nil? || owner == docid
140
+ reserve file, docid
141
+ end
142
+
143
+ #
144
+ # Write `content` for `docid`, never clobbering a different document.
145
+ #
146
+ # `output_file` is not injective, so the plain path may belong to someone
147
+ # else. `unique_output_file` settles that within this process; the file is
148
+ # then created with `O_EXCL` so a peer *process* cannot be clobbered
149
+ # either. Returns the path actually written.
150
+ #
151
+ # The four outcomes, each load-bearing:
152
+ #
153
+ # * **We already wrote this path for this docid** — plain overwrite, so a
154
+ # genuine duplicate stays one file and the flavor's own duplicate
155
+ # handling (skip / merge / last-wins) still decides what happens.
156
+ # * **`O_EXCL` succeeds** — the normal case.
157
+ # * **`EEXIST`, single process** — `unique_output_file` has already proved
158
+ # that no peer of ours holds this path, so the file can only be a
159
+ # leftover from an earlier crawl. Overwrite it. Every single-process
160
+ # flavor takes this branch, and it involves no guesswork.
161
+ # * **`EEXIST`, `@cross_process`** — a peer's file and a leftover are
162
+ # indistinguishable, so take a path of our own rather than risk
163
+ # destroying a record. The caller reconciles the names afterwards, in
164
+ # the parent, where it can see every docid (see
165
+ # `Relaton::Ietf::DataFetcher#reconcile_output_files`).
166
+ #
167
+ # Deliberately NOT a wall-clock "is this file older than the crawl?" test.
168
+ # A crawl runs into a populated `data/`, so `EEXIST` is the common case on
169
+ # a re-run and such a test would decide it for every record; its
170
+ # false-"stale" direction is the silent overwrite this method exists to
171
+ # prevent, and coarse filesystem mtime granularity makes that reachable.
172
+ #
173
+ # @param [String] docid
174
+ # @param [String] content serialized document
175
+ # @return [String] path written
176
+ #
177
+ def write_unique(docid, content)
178
+ file = unique_output_file docid
179
+ return force_write(file, content) if @written.include?(file)
180
+
181
+ File.write file, content, mode: EXCLUSIVE, encoding: "UTF-8"
182
+ @written << file
183
+ file
184
+ rescue Errno::EEXIST
185
+ return force_write(file, content) unless @cross_process
186
+
187
+ force_write reserve(digest_output_file(docid), docid), content
71
188
  end
72
189
 
73
190
  #
@@ -92,6 +209,36 @@ module Relaton
92
209
  def to_bibxml(bib)
93
210
  raise NotImplementedError, "#{self.class}#to_bibxml method must be implemented"
94
211
  end
212
+
213
+ private
214
+
215
+ # Write, clobbering whatever is there. The caller has already established
216
+ # that the path is ours to take.
217
+ def force_write(file, content)
218
+ File.write file, content, encoding: "UTF-8"
219
+ @written << file
220
+ file
221
+ end
222
+
223
+ # Record `docid` as the owner of `file`, unless someone got there first,
224
+ # and return `file`. The reservation table must never hand one path to two
225
+ # documents; see #unique_output_file.
226
+ def reserve(file, docid)
227
+ @file_docids[file] ||= docid
228
+ file
229
+ end
230
+
231
+ # `output_file`'s path with a docid digest appended to the basename, kept
232
+ # inside the same byte cap. Deterministic: same docid, same name.
233
+ def digest_output_file(docid)
234
+ require "digest"
235
+ ext = ".#{@ext}"
236
+ suffix = "-#{Digest::SHA1.hexdigest(docid)[0, 12]}"
237
+ stem = File.basename output_file(docid), ext
238
+ limit = MAX_BASENAME_BYTES - ext.bytesize - suffix.bytesize
239
+ stem = stem.byteslice(0, limit).scrub("").delete_suffix("-") if stem.bytesize > limit
240
+ File.join @output, "#{stem}#{suffix}#{ext}"
241
+ end
95
242
  end
96
243
  end
97
244
  end