relaton 3.0.1 → 3.0.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton/bib/converter/csl.rb +3 -2
  3. data/lib/relaton/bib/model/docidentifier.rb +61 -0
  4. data/lib/relaton/bib.rb +2 -1
  5. data/lib/relaton/bipm/CLAUDE.md +14 -0
  6. data/lib/relaton/bsi/model/docidentifier.rb +0 -4
  7. data/lib/relaton/calconnect/data_fetcher.rb +2 -2
  8. data/lib/relaton/ccsds/data/fetcher.rb +2 -1
  9. data/lib/relaton/ccsds/model/docidentifier.rb +0 -4
  10. data/lib/relaton/core/data_fetcher.rb +2 -1
  11. data/lib/relaton/core.rb +7 -0
  12. data/lib/relaton/db/cache.rb +3 -3
  13. data/lib/relaton/db.rb +20 -1
  14. data/lib/relaton/doi/crossref.rb +2 -1
  15. data/lib/relaton/doi/parser.rb +3 -2
  16. data/lib/relaton/etsi/data_fetcher.rb +3 -3
  17. data/lib/relaton/gb/sec_scraper.rb +3 -3
  18. data/lib/relaton/iec/data_fetcher.rb +3 -2
  19. data/lib/relaton/iec/model/docidentifier.rb +0 -4
  20. data/lib/relaton/ietf/CLAUDE.md +27 -2
  21. data/lib/relaton/ietf/bibxml_parser.rb +38 -0
  22. data/lib/relaton/ietf/data_fetcher.rb +3 -30
  23. data/lib/relaton/ietf/scraper.rb +60 -3
  24. data/lib/relaton/ietf/wg_name_resolver.rb +2 -2
  25. data/lib/relaton/iho/CLAUDE.md +4 -0
  26. data/lib/relaton/iho/bibliography.rb +7 -1
  27. data/lib/relaton/index/dataset_lock.rb +4 -3
  28. data/lib/relaton/index/shard_source.rb +3 -2
  29. data/lib/relaton/index/sqlite_backend.rb +5 -3
  30. data/lib/relaton/isbn/open_library.rb +2 -1
  31. data/lib/relaton/iso/data_fetcher.rb +6 -6
  32. data/lib/relaton/iso/model/docidentifier.rb +0 -4
  33. data/lib/relaton/itu/data_fetcher.rb +2 -2
  34. data/lib/relaton/itu/hit_collection.rb +3 -3
  35. data/lib/relaton/itu/recommendation_fields.rb +2 -2
  36. data/lib/relaton/jis/data_fetcher.rb +2 -1
  37. data/lib/relaton/json.rb +21 -0
  38. data/lib/relaton/logger/channels/gh_issue.rb +3 -3
  39. data/lib/relaton/logger/formatter_json.rb +2 -1
  40. data/lib/relaton/nist/CLAUDE.md +6 -0
  41. data/lib/relaton/nist/docidentifier.rb +0 -4
  42. data/lib/relaton/nist/pubs_export.rb +2 -1
  43. data/lib/relaton/ogc/data_fetcher.rb +2 -2
  44. data/lib/relaton/omg/scraper.rb +3 -3
  45. data/lib/relaton/plateau/data_fetcher.rb +2 -1
  46. data/lib/relaton/un/hit_collection.rb +4 -3
  47. data/lib/relaton/version.rb +1 -1
  48. data/lib/relaton.rb +1 -0
  49. metadata +19 -26
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f81699e1df94f1c570f01c07b4114492b7cc5ae1e45715a4ced3ef51c1ba3fb6
4
- data.tar.gz: 1ea38d455d16b512807a505bd558757ea0509a9392cf41c120f3fc10801beb87
3
+ metadata.gz: f15745a534625b27b3d013d7b3d6edd7e0185f1bcc1c545f7b44dc6059d9d113
4
+ data.tar.gz: ac15b5259abb3c980cd6bee9d10c5f2f0ae12fea955478ca28f7da82027ab6f6
5
5
  SHA512:
6
- metadata.gz: 4d27294461f66031023053e1ec70a4c26b0c8fa068e6ed9d1224d1ab9c6cb366a2b2d8f85cd50305c4826733ab028817f70b96aebc267346d224b9be1fdf29be
7
- data.tar.gz: a3c00ef5a93352f08a492624b531955494c8530a6b0d68f01dc748d58d8599304fa9ac743d157cfd2a6cc37b63664c781da75de5e199de70035ed133e70d0305
6
+ metadata.gz: de3f2c7182f6a2b1737a54e9168bcab856dbc81fe169f293b5b932f0224e8cb95187f506829230068ebc5308884e89a918515b03838f969c9c9870020442efff
7
+ data.tar.gz: a4bfe1d14667d8907eecd43fe880b02e7d15ceab20d2c6302c8f5c220d6c4398b3adefa056ff8815639686ad85674713c5c3a4759bd5ad57ffdd1e42b9bc39e1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require "yeptris"
4
+
4
5
 
5
6
  module Relaton
6
7
  module Bib
@@ -43,7 +44,7 @@ module Relaton
43
44
  keywords = Array(@item.keyword).map { |k| k.content.to_s }.reject(&:empty?)
44
45
  out[:keyword] = keywords unless keywords.empty?
45
46
  out[:URL] = source_uri if source_uri
46
- [out].to_json + "\n"
47
+ Yeptris::JSON.dump([out]) + "\n"
47
48
  end
48
49
 
49
50
  private
@@ -44,6 +44,67 @@ module Relaton
44
44
  def to_all_parts!; end
45
45
 
46
46
  def remove_date!; end
47
+
48
+ # Docidentifier types with an SDO-specific :external and :internal form
49
+ # (relaton#49), mapped to the pubid flavor that renders both. The
50
+ # :external form carries the publisher ("IETF RFC 8341",
51
+ # "BSI PAS 2031:2019"); the :internal form is the one the SDO uses for its
52
+ # own documents, without it ("SP 800-113" for "NIST SP 800-113"). pubid
53
+ # decides per identifier, so "BS EN ISO 8848:2021" and "CGPM RES (1889)"
54
+ # are the same in both forms. Every other type answers its content for
55
+ # both forms: pubid would drop "ISO" from an ISO identifier.
56
+ #
57
+ # The table keys on `type`, not on a flavor subclass, so it also covers a
58
+ # relation's nested `Bib::ItemData` and the `Bib::Docidentifier` objects
59
+ # BIPM and IETF build (a subclass-typed collection would make lutaml's
60
+ # YAML serializer reject those).
61
+ FORM_PUBID = {
62
+ "NIST" => :Nist,
63
+ "BIPM" => :Bipm,
64
+ "IETF" => :Ietf,
65
+ "Internet-Draft" => :Ietf,
66
+ "BSI" => :Bsi,
67
+ }.freeze
68
+
69
+ FORMS = %i[external internal].freeze
70
+
71
+ # The parsed identifier. A pubid-backed flavor subclass overrides it.
72
+ def pubid; end
73
+
74
+ # @param form [Symbol, nil] nil for the stored content, :external for the
75
+ # identifier with its publisher, :internal for the form the SDO uses
76
+ # for its own documents
77
+ # @raise [ArgumentError] for an unknown form
78
+ def to_s(form: nil)
79
+ return content.to_s unless form
80
+
81
+ form = form.to_sym
82
+ unless FORMS.include?(form)
83
+ raise ArgumentError, "unknown identifier form: #{form.inspect}"
84
+ end
85
+
86
+ form_content(form).to_s
87
+ end
88
+
89
+ private
90
+
91
+ # pubid renders the form (`to_s(with_publisher:)`, pubid#620). A
92
+ # docidentifier with no parsed pubid (a plain `Bib::Docidentifier`) is
93
+ # parsed here; content that pubid cannot parse is returned unchanged.
94
+ def form_content(form)
95
+ flavor = FORM_PUBID[type]
96
+ id = flavor && (pubid || parse_form_pubid(flavor))
97
+ id ? id.to_s(with_publisher: form == :external) : content
98
+ end
99
+
100
+ def parse_form_pubid(flavor)
101
+ return if content.to_s.empty?
102
+
103
+ require "pubid"
104
+ ::Pubid.const_get(flavor).parse(content)
105
+ rescue StandardError
106
+ nil
107
+ end
47
108
  end
48
109
  end
49
110
  end
data/lib/relaton/bib.rb CHANGED
@@ -5,6 +5,7 @@ require "bibtex"
5
5
  require "iso639"
6
6
  require "rfcxml"
7
7
  require "relaton/core"
8
+ require "yeptris"
8
9
  require_relative "version"
9
10
  require_relative "bib/util"
10
11
  require_relative "bib/sanitizer"
@@ -41,7 +42,7 @@ module Relaton
41
42
  # @return [Hash{String=>String}] schema versions
42
43
  #
43
44
  def schema_versions
44
- @@schema_versions ||= JSON.parse File.read(File.join(__dir__, "bib/versions.json"))
45
+ @@schema_versions ||= Yeptris::JSON.load File.read(File.join(__dir__, "bib/versions.json"))
45
46
  end
46
47
  end
47
48
 
@@ -144,6 +144,20 @@ demonstrated rather than argued.
144
144
  builds `index-v1` with them, and `id_parser_spec.rb` still covers them. Only
145
145
  `bibliography.rb` stopped requiring the file.
146
146
 
147
+ ### Internal identifier
148
+
149
+ `docid.to_s(form: :internal)` drops the
150
+ `BIPM` publisher (relaton#49): `BIPM SI Brochure Part 1` →
151
+ `SI Brochure Part 1`. Committee ids (`CGPM RES (1889)`, `CCTF REC 2 (2009)`)
152
+ have no publisher token and are the same in both forms. The rule is the `"BIPM"` row of
153
+ `Bib::Docidentifier::FORM_PUBID`: BIPM docidentifiers carry no pubid,
154
+ so the content is parsed with `Pubid::Bipm` at call time and rendered with
155
+ `to_s(with_publisher: false)`; unparseable content is returned unchanged. It
156
+ also holds for a relation's nested item. Do not move it into a `Bipm::Docidentifier`
157
+ subclass: typing the item's `docidentifier` collection with a subclass makes
158
+ YAML serialization reject the `Bib::Docidentifier` objects the parsers build
159
+ (`IncorrectModelError`).
160
+
147
161
  ### Data Sources
148
162
 
149
163
  The gem fetches from three external datasets:
@@ -44,10 +44,6 @@ module Relaton
44
44
  c if c.is_a?(::Pubid::Bsi::Identifier) || c.is_a?(::Pubid::AllParts)
45
45
  end
46
46
 
47
- def to_s
48
- content.to_s
49
- end
50
-
51
47
  # Strip the publication date to build a most-recent (undated) reference.
52
48
  # pubid's `exclude` propagates into nested identifiers (so a consolidated
53
49
  # base date is dropped while the amendment is kept); `:month` is excluded
@@ -1,6 +1,6 @@
1
+ require "yeptris"
1
2
  # frozen_string_literal:true
2
3
 
3
- require "json"
4
4
  require "mechanize"
5
5
  require "relaton/core"
6
6
  require "relaton/index"
@@ -54,7 +54,7 @@ module Relaton::Calconnect
54
54
  # 304 Not Modified — nothing changed since the last fetch
55
55
  return if resp.code == "304"
56
56
 
57
- data = JSON.parse resp.body
57
+ data = Relaton::Json.parse_external resp.body
58
58
  all_success = true
59
59
  Array(data["documents"]).each { |doc| all_success &&= parse_page doc }
60
60
  self.etag = resp.response["etag"] if all_success
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "json"
2
3
  require "mechanize"
3
4
  require "relaton/index"
@@ -43,7 +44,7 @@ module Relaton
43
44
  #
44
45
  def fetch_docs(url)
45
46
  resp = agent.get(url)
46
- json = JSON.parse resp.body.match(/const config = (.*);/)[1]
47
+ json = Relaton::Json.parse_external resp.body.match(/const config = (.*);/)[1]
47
48
  @array = json["data"].map { |doc| parse_and_save doc, json["data"] }
48
49
  end
49
50
 
@@ -60,10 +60,6 @@ module Relaton
60
60
  original_content
61
61
  end
62
62
 
63
- def to_s
64
- content.to_s
65
- end
66
-
67
63
  def to_all_parts!
68
64
  return if !@pubid || @pubid.all_parts?
69
65
 
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "fileutils"
2
3
 
3
4
  module Relaton
@@ -361,7 +362,7 @@ module Relaton
361
362
  def entry_digest(entry)
362
363
  require "digest"
363
364
  require "json"
364
- Digest::SHA256.hexdigest JSON.generate(entry)
365
+ Digest::SHA256.hexdigest Yeptris::JSON.dump(entry)
365
366
  end
366
367
 
367
368
  # Write, clobbering whatever is there. The caller has already established
data/lib/relaton/core.rb CHANGED
@@ -1,4 +1,11 @@
1
1
  require "relaton/logger"
2
+
3
+ module Relaton
4
+ # Registered here as well as in the root entry: flavor files and their
5
+ # specs load directly via require "relaton/<flavor>", never through
6
+ # relaton.rb, and the boundary parsers need this constant either way.
7
+ autoload :Json, "relaton/json"
8
+ end
2
9
  require_relative "version"
3
10
  require_relative "core/request_error"
4
11
  require_relative "core/array_wrapper"
@@ -1,6 +1,6 @@
1
+ require "yeptris"
1
2
  require "fileutils"
2
3
  require "digest"
3
- require "json"
4
4
  require "date"
5
5
  require "lutaml/store"
6
6
  require_relative "cache_entry"
@@ -520,7 +520,7 @@ module Relaton
520
520
  def canonical(key)
521
521
  return key if key.is_a? String
522
522
 
523
- JSON.parse JSON.generate(key.to_hash)
523
+ Yeptris::JSON.load Yeptris::JSON.dump(key.to_hash)
524
524
  end
525
525
 
526
526
  def row_key(row)
@@ -556,7 +556,7 @@ module Relaton
556
556
 
557
557
  # A digest of the canonical key, independent of the hash order.
558
558
  def digest(key)
559
- Digest::SHA256.hexdigest JSON.generate(canonical_sorted(key))
559
+ Digest::SHA256.hexdigest Yeptris::JSON.dump(canonical_sorted(key))
560
560
  end
561
561
 
562
562
  def canonical_sorted(key)
data/lib/relaton/db.rb CHANGED
@@ -127,7 +127,15 @@ module Relaton
127
127
  rescue Relaton::RequestError => e
128
128
  args[3].call e
129
129
  rescue StandardError => e
130
- Util.error "`#{args[0]}` -- #{e.message}"
130
+ # An unparseable reference ("ISO Standards Tag Set" in a
131
+ # vocabulary corpus) is a not-found input, not a failure: the
132
+ # citation keeps its literal text and the build continues
133
+ # (relaton#412). Real errors keep the ERROR level.
134
+ if unreadable_reference?(e)
135
+ Util.info "`#{args[0]}` -- #{e.message}"
136
+ else
137
+ Util.error "`#{args[0]}` -- #{e.message}"
138
+ end
131
139
  args[3].call nil
132
140
  end
133
141
  @queues[stdclass] =
@@ -213,6 +221,14 @@ module Relaton
213
221
 
214
222
  private
215
223
 
224
+ # True when the error is a flavor's grammar refusing to read the
225
+ # reference. Both are raised by the flavors' pubid/parslet parses; a
226
+ # reference they cannot read is not-found, not a failure (relaton#412).
227
+ def unreadable_reference?(error)
228
+ (defined?(Pubid::Errors::Error) && error.is_a?(Pubid::Errors::Error)) ||
229
+ (defined?(Parslet::ParseFailed) && error.is_a?(Parslet::ParseFailed))
230
+ end
231
+
216
232
  # The flavor routing picks for `fetch_async`'s per-flavor queue; nil
217
233
  # (logged) when no flavor recognizes the reference.
218
234
  def async_route(ref)
@@ -309,6 +325,9 @@ module Relaton
309
325
  )
310
326
  end
311
327
  end
328
+ # Nothing resolved: answer not-found, not a stub that holds only the
329
+ # reference (relaton-un#14, metanorma-ietf#205).
330
+ doc.relation.empty? ? nil : doc
312
331
  end
313
332
 
314
333
  # The legacy string cache key, for a processor with no pubid class. The
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "mechanize"
2
3
 
3
4
  module Relaton
@@ -58,7 +59,7 @@ module Relaton
58
59
  url = "https://api.crossref.org/works/#{CGI.escape(id)}"
59
60
  loop do
60
61
  resp = agent.get url
61
- work = JSON.parse resp.body
62
+ work = Relaton::Json.parse_external resp.body
62
63
  return work["message"] if work["status"] == "ok"
63
64
 
64
65
  if n > 1
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  module Relaton
2
3
  module Doi
3
4
  class Parser
@@ -856,7 +857,7 @@ module Relaton
856
857
  throttled = 0
857
858
  begin
858
859
  resp = Crossref.agent.get url
859
- JSON.parse(resp.body).dig("message", "items")
860
+ Relaton::Json.parse_external(resp.body).dig("message", "items")
860
861
  rescue Mechanize::ResponseCodeError => e
861
862
  # 429 must be handled before the 4xx nil return: a throttle is not
862
863
  # "no such record". Returning nil here made a rate-limited lookup look
@@ -881,7 +882,7 @@ module Relaton
881
882
  retries += 1
882
883
  retry if retries <= MAX_RETRIES
883
884
  raise Relaton::RequestError, "Crossref network error after #{MAX_RETRIES} retries: #{e.message}"
884
- rescue JSON::ParserError => e
885
+ rescue ::Yeptris::JSON::ParseError => e
885
886
  raise Relaton::RequestError, "Crossref JSON parsing error: #{e.message}"
886
887
  end
887
888
  end
@@ -1,5 +1,5 @@
1
+ require "yeptris"
1
2
  require "date"
2
- require "json"
3
3
  require "mechanize"
4
4
  require "relaton/core"
5
5
  require "relaton/index"
@@ -146,11 +146,11 @@ module Relaton
146
146
  # @raise [BadPage] when the body is not a JSON array
147
147
  #
148
148
  def parse_page(page, body)
149
- records = JSON.parse(body)
149
+ records = Relaton::Json.parse_external(body)
150
150
  return records if records.is_a?(Array)
151
151
 
152
152
  raise BadPage, bad_page_message(page, body)
153
- rescue JSON::ParserError
153
+ rescue ::Yeptris::JSON::ParseError
154
154
  raise BadPage, bad_page_message(page, body)
155
155
  end
156
156
 
@@ -1,8 +1,8 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+
4
5
  require "net/http"
5
- require "json"
6
6
  require "moxml"
7
7
  require_relative "scraper"
8
8
  require_relative "item"
@@ -24,8 +24,8 @@ module Relaton
24
24
  uri = URI "https://hbba.sacinfo.org.cn/stdQueryList"
25
25
  resp = Net::HTTP.post uri, URI.encode_www_form({ key: text }),
26
26
  "Content-Type" => "application/x-www-form-urlencoded"
27
- # res = JSON.parse Net::HTTP.get(uri)
28
- json = JSON.parse resp.body
27
+ # res = Relaton::Json.parse_external Net::HTTP.get(uri)
28
+ json = Relaton::Json.parse_external resp.body
29
29
  hits = json["records"].map do |h|
30
30
  Hit.new pid: h["pk"], docref: h["code"], status: h["status"], scraper: self
31
31
  end
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "relaton/core"
2
3
  require_relative "../iec"
3
4
  require_relative "data_parser"
@@ -135,7 +136,7 @@ module Relaton
135
136
  Util.warn "#{res.body}"
136
137
  break
137
138
  end
138
- json = JSON.parse res.body
139
+ json = Relaton::Json.parse_external res.body
139
140
  json[list_key].each { |pub| fetch_pub pub }
140
141
  page += 1
141
142
  next_page = res["link"]&.include? "rel=\"last\""
@@ -191,7 +192,7 @@ module Relaton
191
192
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
192
193
  http.request req
193
194
  end
194
- JSON.parse(res.body)["access_token"]
195
+ Relaton::Json.parse_external(res.body)["access_token"]
195
196
  end
196
197
  end
197
198
 
@@ -46,10 +46,6 @@ module Relaton
46
46
  original_content
47
47
  end
48
48
 
49
- def to_s
50
- content.to_s
51
- end
52
-
53
49
  # `remove_part!`/`remove_date!` are unnecessary here: `to_all_parts`'s
54
50
  # own identity computation already excludes part/date (down the whole
55
51
  # base chain, so a supplement's own year is dropped too — a disclosed
@@ -36,7 +36,7 @@ Converter classes use `Bib::NamespaceHelper` which resolves `namespace` by takin
36
36
 
37
37
  ### Key Flows
38
38
 
39
- 1. **Single document lookup**: `Processor#get` → `Bibliography.get(code)` → `Scraper.scrape_page` → parses the reference with `Pubid::Ietf` → searches the one combined `index-v2` from `relaton-data-ietf` → fetches that row's YAML
39
+ 1. **Single document lookup**: `Processor#get` → `Bibliography.get(code)` → `Scraper.scrape_page` → parses the reference with `Pubid::Ietf` → searches the one combined `index-v2` from `relaton-data-ietf` → fetches that row's YAML; a versioned draft the index lacks is read from bib.ietf.org (see below)
40
40
 
41
41
  2. **Bulk data fetching**: `DataFetcher` extends `Relaton::Core::DataFetcher` with three datasets:
42
42
  - `ietf-rfcsubseries` / `ietf-rfc-entries`: parse `rfc-index.xml` via `Rfc::Index` / `Rfc::Entry#to_item`
@@ -119,6 +119,30 @@ The old plain-string index matched that by substring; matching is exact now.
119
119
  A reference pubid cannot parse returns nil, so `Bibliography.get "CN 8341"` logs
120
120
  "Not found." rather than raising.
121
121
 
122
+ ### An index miss on a versioned draft reads bib.ietf.org (relaton-ietf#125)
123
+
124
+ The data repo crawls the rsync `bibxml-ids` snapshot, and that snapshot keeps
125
+ only some versions of each draft: `draft-ietf-vcarddav-vcardrev` has only `-22`,
126
+ so `-06` was "Not found." When `fetch_doc` misses and the query is a
127
+ `Pubid::Ietf::Identifiers::InternetDraft` **with a version**, `fetch_bibxml` reads
128
+ `https://bib.ietf.org/public/rfc/bibxml-ids/reference.I-D.<draft>-NN.xml`
129
+ (`BIBXML_IDS`). That service resolves any version the datatracker knows and
130
+ answers 404 otherwise; the datatracker's own `doc/bibxml3/...` URL answers 404
131
+ for these old versions. The parse is the crawler's: `BibXMLParser.to_utf8`
132
+ (the CP1252 repair, shared with `DataFetcher#read_bibxml`), `BibXMLParser.parse`,
133
+ and the version from the identifier, as `process_singleton` sets it.
134
+
135
+ Only a 404 is "not found". Any other non-200 (429, 5xx) raises
136
+ `Relaton::RequestError`, so `Db#net_retry` retries it and `Db::Cache` does not
137
+ store an outage as a not-found row. A 200 whose body does not parse logs a
138
+ warning and returns nil; that rescue names the parse errors and is not
139
+ `StandardError`, so a transport error still becomes `RequestError`.
140
+
141
+ RFCs, sub-series and unversioned drafts do **not** fall back: the index holds
142
+ all of them, so a miss is a real "not found". A record read this way has no
143
+ `updates`/`updatedBy` neighbours — the crawl links those, and one fetch cannot.
144
+ Old bibxml carries no `target`, so such a record has a `TXT` URI and no `src`.
145
+
122
146
  ### The producer: `DataFetcher` writes `index-v2`
123
147
 
124
148
  `DataFetcher` builds the index during the crawl, from the identifier it already
@@ -294,7 +318,8 @@ smart quotes and accented Latin letters. `File.read(encoding: "UTF-8")` only
294
318
  *tags* a string; it validates nothing, so those reached lutaml as invalid UTF-8
295
319
  and `Rfcxml::V3::Reference.from_xml` raised `Lutaml::Model::InvalidFormatError`.
296
320
 
297
- `read_bibxml` transcodes them (`data_fetcher.rb`). Three things make that the
321
+ `read_bibxml` transcodes them through `BibXMLParser.to_utf8`
322
+ (`bibxml_parser.rb`, shared with the scraper's bib.ietf.org fallback). Three things make that the
298
323
  right call rather than `scrub`:
299
324
 
300
325
  - **All of them decode losslessly as CP1252** — 125 of the ~122k published files
@@ -13,6 +13,44 @@ module Relaton
13
13
  FromRfc.new(rfc).transform
14
14
  end
15
15
 
16
+ #
17
+ # Decode bibxml bytes as UTF-8, recovering Windows-1252 bytes.
18
+ #
19
+ # These files declare `encoding='UTF-8'` but some carry CP1252 — smart
20
+ # quotes and accented Latin letters. `File.read(encoding: "UTF-8")` only
21
+ # *tags* the string, so those reach lutaml as invalid UTF-8 and it raises.
22
+ #
23
+ # `scrub` with a block transcodes each invalid *run* as CP1252 while
24
+ # leaving valid UTF-8 untouched. Both halves matter:
25
+ #
26
+ # * Not plain `scrub`, which substitutes U+FFFD: `client’s` would become
27
+ # `client�s` and `Muñoz` `Mu�oz` — author surnames included.
28
+ # The damage is length-preserving, so a length check will not catch it.
29
+ # * Not a whole-file CP1252 re-decode, which mangles a file that is
30
+ # genuinely UTF-8 apart from one stray byte: `Muñoz café ’` would come
31
+ # back as `Muñoz café ’`, silently, since the result is valid UTF-8 and
32
+ # nothing raises. No such file exists in today's corpus (0 of the 125
33
+ # affected contain valid multi-byte UTF-8) but it grows daily, and
34
+ # "decodes losslessly as CP1252" is weak evidence of correctness —
35
+ # CP1252 maps 251 of 256 byte values.
36
+ #
37
+ # `undef: :replace` covers the five bytes CP1252 leaves undefined
38
+ # (0x81 0x8D 0x8F 0x90 0x9D), so this returns valid UTF-8 rather than
39
+ # raising and costing the whole file.
40
+ #
41
+ # @param bytes [String] raw bytes, any encoding tag
42
+ # @return [String] UTF-8, valid encoding
43
+ #
44
+ def to_utf8(bytes)
45
+ utf8 = bytes.dup.force_encoding(Encoding::UTF_8)
46
+ return utf8 if utf8.valid_encoding?
47
+
48
+ utf8.scrub do |bad|
49
+ bad.force_encoding(Encoding::WINDOWS_1252)
50
+ .encode(Encoding::UTF_8, undef: :replace)
51
+ end
52
+ end
53
+
16
54
  def pubid_type(id)
17
55
  pref = id.match(/^(\S+)/)[1]
18
56
  case pref
@@ -216,41 +216,14 @@ module Relaton
216
216
  end
217
217
 
218
218
  #
219
- # Read a bibxml file as UTF-8, recovering Windows-1252 bytes.
220
- #
221
- # These files declare `encoding='UTF-8'` but some carry CP1252 — smart
222
- # quotes and accented Latin letters. `File.read(encoding: "UTF-8")` only
223
- # *tags* the string, so those reach lutaml as invalid UTF-8 and it raises.
224
- #
225
- # `scrub` with a block transcodes each invalid *run* as CP1252 while
226
- # leaving valid UTF-8 untouched. Both halves matter:
227
- #
228
- # * Not plain `scrub`, which substitutes U+FFFD: `client’s` would become
229
- # `client\uFFFDs` and `Muñoz` `Mu\uFFFDoz` — author surnames included.
230
- # The damage is length-preserving, so a length check will not catch it.
231
- # * Not a whole-file CP1252 re-decode, which mangles a file that is
232
- # genuinely UTF-8 apart from one stray byte: `Muñoz café ’` would come
233
- # back as `Muñoz café ’`, silently, since the result is valid UTF-8 and
234
- # nothing raises. No such file exists in today's corpus (0 of the 125
235
- # affected contain valid multi-byte UTF-8) but it grows daily, and
236
- # "decodes losslessly as CP1252" is weak evidence of correctness —
237
- # CP1252 maps 251 of 256 byte values.
238
- #
239
- # `undef: :replace` covers the five bytes CP1252 leaves undefined
240
- # (0x81 0x8D 0x8F 0x90 0x9D), so this returns valid UTF-8 rather than
241
- # raising and costing the whole file.
219
+ # Read a bibxml file as UTF-8, recovering Windows-1252 bytes. The repair
220
+ # is `BibXMLParser.to_utf8`, shared with `Scraper`'s bib.ietf.org fallback.
242
221
  #
243
222
  # @param path [String]
244
223
  # @return [String] UTF-8, valid encoding
245
224
  #
246
225
  def read_bibxml(path)
247
- utf8 = File.binread(path).force_encoding(Encoding::UTF_8)
248
- return utf8 if utf8.valid_encoding?
249
-
250
- utf8.scrub do |bad|
251
- bad.force_encoding(Encoding::WINDOWS_1252)
252
- .encode(Encoding::UTF_8, undef: :replace)
253
- end
226
+ BibXMLParser.to_utf8 File.binread(path)
254
227
  end
255
228
 
256
229
  #
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "pubid"
4
4
  require "pubid/ietf"
5
+ require_relative "bibxml_parser"
5
6
 
6
7
  module Relaton
7
8
  module Ietf
@@ -15,6 +16,13 @@ module Relaton
15
16
  # publishing their `index-v1` for released relatons, untouched.
16
17
  IETF = "https://raw.githubusercontent.com/relaton/relaton-data-ietf/main/"
17
18
 
19
+ # The IETF bibxml service, read when the index has no row for a versioned
20
+ # Internet-Draft (relaton-ietf#125). The data repo crawls the rsync
21
+ # `bibxml-ids` snapshot, which keeps only some versions of each draft
22
+ # (`draft-ietf-vcarddav-vcardrev`: only -22); this service resolves any
23
+ # version the datatracker knows, and answers 404 for one it does not.
24
+ BIBXML_IDS = "https://bib.ietf.org/public/rfc/bibxml-ids/"
25
+
18
26
  # @param ref [String, Pubid::Ietf::Identifier] a reference, or its parse
19
27
  # from Relaton::Db (relaton#205), which is used as it is
20
28
  # @return [Relaton::Ietf::ItemData, nil]
@@ -24,8 +32,9 @@ module Relaton
24
32
 
25
33
  fetch_doc id
26
34
  rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
27
- Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
28
- Net::ProtocolError, SocketError
35
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
36
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse,
37
+ Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError
29
38
  raise Relaton::RequestError, "No document found for #{ref} reference"
30
39
  end
31
40
 
@@ -44,7 +53,55 @@ module Relaton
44
53
  # such pairs in the first 40k rows of the index fixture).
45
54
  def fetch_doc(id)
46
55
  row = index.search(id, exact: true).first
47
- get_page "#{IETF}#{row[:file]}" if row
56
+ return get_page "#{IETF}#{row[:file]}" if row
57
+
58
+ fetch_bibxml id if versioned_draft? id
59
+ end
60
+
61
+ # Only a versioned draft falls back. The index holds every RFC, sub-series
62
+ # and unversioned draft aggregator the data repo has, so a miss on one of
63
+ # those is a real "not found", and bib.ietf.org has no aggregator to offer.
64
+ def versioned_draft?(id)
65
+ id.is_a?(::Pubid::Ietf::Identifiers::InternetDraft) && !id.version.to_s.empty?
66
+ end
67
+
68
+ #
69
+ # Read one draft version from bib.ietf.org and parse it the way
70
+ # `DataFetcher#process_singleton` parses a crawled file: the same
71
+ # CP1252 repair, the same parser, and the version from the identifier.
72
+ #
73
+ # A 404 is the service's "no such version". Any other non-200 (a 429, a
74
+ # 5xx) is push-back or an outage, so it raises `Relaton::RequestError`:
75
+ # `Db#net_retry` then retries it, and the Db cache does not store it as a
76
+ # not-found.
77
+ #
78
+ # @param id [Pubid::Ietf::Identifiers::InternetDraft]
79
+ # @return [Relaton::Ietf::ItemData, nil] nil on a 404 or an unreadable body
80
+ #
81
+ def fetch_bibxml(id)
82
+ res = Net::HTTP.get_response URI("#{BIBXML_IDS}reference.I-D.#{id}.xml")
83
+ return if res.code == "404"
84
+ raise Relaton::RequestError, "#{BIBXML_IDS} answered #{res.code} for #{id}" unless res.code == "200"
85
+
86
+ bib = parse_bibxml(res.body, id)
87
+ return unless bib
88
+
89
+ bib.version = [Bib::Version.new(draft: id.version.to_s)]
90
+ bib.fetched = Date.today.to_s
91
+ bib
92
+ end
93
+
94
+ # A 200 that is not bibxml (an HTML error page, a truncated body) costs
95
+ # this one document, not the caller's compile. These are the errors the
96
+ # parse raises on such a body: lutaml's `InvalidFormatError`, and the
97
+ # converter's `NoMethodError` on a document with no `<front>` and
98
+ # `ArgumentError` from its regexes. They are named, not `StandardError`,
99
+ # so a transport error still reaches `scrape_page`'s rescue.
100
+ def parse_bibxml(body, id)
101
+ BibXMLParser.parse BibXMLParser.to_utf8(body)
102
+ rescue Lutaml::Model::InvalidFormatError, NoMethodError, ArgumentError => e
103
+ Util.warn "Unreadable bibxml for `#{id}`: #{e.message.to_s.lines.first.to_s.strip}"
104
+ nil
48
105
  end
49
106
 
50
107
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+
4
4
  require "net/http"
5
5
 
6
6
  module Relaton
@@ -20,7 +20,7 @@ module Relaton
20
20
  response = Net::HTTP.get_response(uri)
21
21
  break unless response.is_a?(Net::HTTPSuccess)
22
22
 
23
- data = JSON.parse(response.body)
23
+ data = Relaton::Json.parse_external(response.body)
24
24
  objects = data["objects"] || []
25
25
  break if objects.empty?
26
26
 
@@ -55,6 +55,10 @@ In `item.rb`, `require_relative "relation"` is placed **after** the `Item` class
55
55
 
56
56
  `Bibliography.search`/`.get` fetches YAML from the relaton-data-iho GitHub repo via `Relaton::Index`, then deserializes into model objects.
57
57
 
58
+ **Editions.** The pubid `version` is the edition (`IHO B-12 3.0.0`; the grammar takes `a.b.c` only). A reference with no version answers with the **latest** edition: `version_key` compares the segments as integers (`2.10.0` after `2.9.0`). It used to be `min_by` on the string, which returned the oldest.
59
+
60
+ **The edition reaches the index only through the committed row.** A record prints `B-12` and keeps its edition in `edition:`, so `relaton index` cannot read a version from the docid. `MachineIndex#refine` (relaton-cli) takes the committed `index-v3.yaml` row when it adds only a `version` to the parse. Before that, the Pages index (generated 2026-10-29) had no `version` in any of its 325 rows, so every versioned reference answered not-found (relaton-data-iho#20). The suite did not see it: `spec/fixtures/index-v3.zip` is the committed index, which has versions.
61
+
58
62
  ### Test Infrastructure
59
63
 
60
64
  - **VCR cassettes** (`spec/vcr_cassettes/`): recorded HTTP responses for offline testing
@@ -21,7 +21,7 @@ module Relaton
21
21
  # Pubid::Iho::Identifiers::* (Relaton::Index deserialized it via the
22
22
  # `pubid_class` passed in `#index`), so `pubid_match?` compares pubids.
23
23
  row = index.search(pubid) { |r| pubid_match?(r[:id], pubid) }
24
- .min_by { |r| r[:id].version.to_s }
24
+ .max_by { |r| version_key(r[:id]) }
25
25
  unless row
26
26
  Util.info "Not found.", key: pubid.to_s
27
27
  return
@@ -96,6 +96,12 @@ module Relaton
96
96
  )
97
97
  end
98
98
 
99
+ # The sort key for the latest edition: segments compare as integers,
100
+ # so 2.10.0 is later than 2.9.0. A row with no version is the oldest.
101
+ def version_key(id)
102
+ id.version.to_s.split(".").map(&:to_i)
103
+ end
104
+
99
105
  # Both `row_id` and `query` are Pubid::Iho::Identifiers::* instances.
100
106
  # Class identity pins the series (S/P/M/B/C). Subdivision keys
101
107
  # (part/appendix/annex/supplement) use strict equality — a nil query
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yeptris"
4
+
3
5
  require "digest"
4
- require "json"
5
6
  require "net/http"
6
7
  require "time"
7
8
  require "uri"
@@ -91,8 +92,8 @@ module Relaton
91
92
  end
92
93
  raise Error, "cannot fetch #{uri}: HTTP #{res.code}" unless res.code == "200"
93
94
 
94
- JSON.parse(res.body)
95
- rescue JSON::ParserError => e
95
+ Yeptris::JSON.load(res.body)
96
+ rescue Yeptris::JSON::ParseError => e
96
97
  raise Error, "malformed manifest at #{uri}: #{e.message}"
97
98
  end
98
99
 
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "json"
2
3
  require "net/http"
3
4
  require "openssl"
@@ -174,8 +175,8 @@ module Relaton
174
175
  end
175
176
 
176
177
  def parse_json(body, path)
177
- JSON.parse(body)
178
- rescue JSON::ParserError => e
178
+ Yeptris::JSON.load(body)
179
+ rescue Yeptris::JSON::ParseError => e
179
180
  raise Error, "cannot parse `#{url(path)}`: #{e.message}"
180
181
  end
181
182
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yeptris"
4
+
3
5
  require "sqlite3"
4
6
 
5
7
  module Relaton
@@ -34,7 +36,7 @@ module Relaton
34
36
  @db.transaction do
35
37
  @db.execute("DELETE FROM index_rows")
36
38
  @db.prepare("INSERT INTO index_rows (number, sort_key, id_json, file) VALUES (?, ?, ?, ?)") do |stmt|
37
- rows_enum.each { |(number, sort_key, id_hash, file)| stmt.execute(number, sort_key, JSON.generate(id_hash), file) }
39
+ rows_enum.each { |(number, sort_key, id_hash, file)| stmt.execute(number, sort_key, Yeptris::JSON.dump(id_hash), file) }
38
40
  end
39
41
  meta_set("schema_version", SCHEMA_VERSION)
40
42
  end
@@ -44,14 +46,14 @@ module Relaton
44
46
  def bucket(number)
45
47
  rows = []
46
48
  @db.execute("SELECT id_json, file FROM index_rows WHERE number = ? ORDER BY rowid", [number]) do |row|
47
- rows << { id: JSON.parse(row[0]), file: row[1] }
49
+ rows << { id: Yeptris::JSON.load(row[0]), file: row[1] }
48
50
  end
49
51
  rows
50
52
  end
51
53
 
52
54
  def each_row
53
55
  @db.execute("SELECT id_json, file FROM index_rows ORDER BY rowid") do |row|
54
- yield({ id: JSON.parse(row[0]), file: row[1] })
56
+ yield({ id: Yeptris::JSON.load(row[0]), file: row[1] })
55
57
  end
56
58
  end
57
59
 
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  module Relaton
2
3
  module Isbn
3
4
  #
@@ -37,7 +38,7 @@ module Relaton
37
38
  response = Net::HTTP.get_response uri
38
39
  return unless response.is_a? Net::HTTPSuccess
39
40
 
40
- data = JSON.parse response.body
41
+ data = Relaton::Json.parse_external response.body
41
42
  return unless data["records"]&.any?
42
43
 
43
44
  data["records"].first.last
@@ -1,5 +1,5 @@
1
+ require "yeptris"
1
2
  require "fileutils"
2
- require "json"
3
3
  require "net/http"
4
4
  require "tmpdir"
5
5
  require_relative "../iso"
@@ -168,7 +168,7 @@ module Relaton
168
168
  amend_map = Hash.new { |h, k| h[k] = [] }
169
169
  date_map = {}
170
170
  File.foreach(path, encoding: "UTF-8") do |line|
171
- rec = JSON.parse(line)
171
+ rec = Relaton::Json.parse_external(line)
172
172
  id = rec["id"]
173
173
  ref = normalize_reference(rec["reference"])
174
174
  next unless ref
@@ -179,7 +179,7 @@ module Relaton
179
179
  if rec["supplementType"] && (base = amend_base(ref))
180
180
  amend_map[base] << ref
181
181
  end
182
- rescue JSON::ParserError
182
+ rescue ::JSON::ParserError
183
183
  next
184
184
  end
185
185
  Util.info "Indexed #{ref_map.size} references; " \
@@ -216,11 +216,11 @@ module Relaton
216
216
  path = download_tc_dataset
217
217
  map = {}
218
218
  File.foreach(path, encoding: "UTF-8") do |line|
219
- rec = JSON.parse(line)
219
+ rec = Relaton::Json.parse_external(line)
220
220
  ref = rec["reference"]
221
221
  title = rec["title"]
222
222
  map[ref] = title if ref && title.is_a?(Hash)
223
- rescue JSON::ParserError
223
+ rescue ::JSON::ParserError
224
224
  next
225
225
  end
226
226
  Util.info "Indexed #{map.size} committees."
@@ -231,7 +231,7 @@ module Relaton
231
231
  Util.info "Parsing records..."
232
232
  count = 0
233
233
  File.foreach(path, encoding: "UTF-8") do |line|
234
- rec = JSON.parse(line)
234
+ rec = Relaton::Json.parse_external(line)
235
235
  next unless ingestable?(rec["reference"])
236
236
 
237
237
  fetch_pub(rec, ref_index, tc_index, amend_index, date_index)
@@ -64,10 +64,6 @@ module Relaton
64
64
  original_content
65
65
  end
66
66
 
67
- def to_s
68
- content.to_s
69
- end
70
-
71
67
  # `remove_part!`/`remove_date!` are unnecessary here: `to_all_parts`'s
72
68
  # own identity computation already excludes part/date (down the whole
73
69
  # base chain, so a supplement's own year is dropped too — a disclosed
@@ -1,5 +1,5 @@
1
+ require "yeptris"
1
2
  require "net/http"
2
- require "json"
3
3
  require "uri"
4
4
  require "mechanize"
5
5
  require_relative "../itu"
@@ -542,7 +542,7 @@ module Relaton
542
542
  request["User-Agent"] = USER_AGENT
543
543
 
544
544
  response = http.request(request)
545
- json = JSON.parse(response.body)
545
+ json = Relaton::Json.parse_external(response.body)
546
546
  json["Data"] || []
547
547
  end
548
548
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+
3
4
  require "cgi"
4
- require "json"
5
5
  require_relative "hit"
6
6
 
7
7
  module Relaton
@@ -239,8 +239,8 @@ module Relaton
239
239
  # @return [Array<Hash>] editions of the recommendation
240
240
  def editions(idrec)
241
241
  # a `null` body parses to nil, so `|| []` is not redundant
242
- JSON.parse(agent.get(format(RECEDITIONS_URL, idrec: idrec)).body) || []
243
- rescue JSON::ParserError
242
+ Relaton::Json.parse_external(agent.get(format(RECEDITIONS_URL, idrec: idrec)).body) || []
243
+ rescue ::JSON::ParserError
244
244
  []
245
245
  end
246
246
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+
4
4
  require_relative "family_cache"
5
5
 
6
6
  module Relaton
@@ -251,7 +251,7 @@ module Relaton
251
251
  end
252
252
 
253
253
  def get_data(url)
254
- JSON.parse request_document(url).body
254
+ Relaton::Json.parse_external request_document(url).body
255
255
  end
256
256
 
257
257
  def request_document(url)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+
3
4
  require_relative "../jis"
4
5
  require_relative "scraper"
5
6
 
@@ -92,7 +93,7 @@ module Relaton
92
93
  body = { record: 0, dantai: "JIS", searchtype2: 1,
93
94
  status_1: 1, status_2: 2 } # rubocop:disable Naming/VariableNumber
94
95
  resp = agent.post "#{URL}W11M0270/index", body
95
- disp = JSON.parse resp.body
96
+ disp = Relaton::Json.parse_external resp.body
96
97
  @initial_time = Time.now
97
98
  disp["status"] || Util.warn("No results found for JIS")
98
99
  end
@@ -0,0 +1,21 @@
1
+ require "yeptris"
2
+
3
+ module Relaton
4
+ # JSON at the system boundary — data we do not control. Everything we
5
+ # generate or own (index manifests, shards, the db cache, request bodies,
6
+ # the schema-version table) parses with Yeptris::JSON's default strict
7
+ # surface.
8
+ module Json
9
+ module_function
10
+
11
+ # Third-party feeds carry duplicate keys in the wild (Crossref book
12
+ # records keyed by ISBN, NIST's scraped pubs export keyed by "&nbsp",
13
+ # the UN search API's blank keys, CCSDS's embedded config); parsing them
14
+ # the historical way is last-wins. yeptris 0.6.51 carries the boundary
15
+ # opt-out (yeptris-ruby#335/#339), so the whole stack stays on yeptris —
16
+ # no stdlib fallback anywhere.
17
+ def parse_external(source)
18
+ ::Yeptris::JSON.load(source, allow_duplicate_key: true)
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
+ require "yeptris"
1
2
  require "net/http"
2
- require "json"
3
3
 
4
4
  module Relaton
5
5
  module Logger
@@ -70,7 +70,7 @@ module Relaton
70
70
  responce = get(uri)
71
71
  return nil unless responce.code.to_i == 200
72
72
 
73
- items = JSON.parse(responce.body)["items"] || []
73
+ items = Yeptris::JSON.load(responce.body)["items"] || []
74
74
  items.find { |i| i["title"] == @title }&.fetch("number", nil)
75
75
  rescue StandardError => e
76
76
  puts "Failed to search for an open `#{@title}` issue: #{e.message}"
@@ -88,7 +88,7 @@ module Relaton
88
88
  http.use_ssl = true
89
89
 
90
90
  request = Net::HTTP::Post.new(uri.request_uri, headers)
91
- request.body = body.to_json
91
+ request.body = Yeptris::JSON.dump(body)
92
92
 
93
93
  http.request(request)
94
94
  end
@@ -1,8 +1,9 @@
1
+ require "yeptris"
1
2
  module Relaton::Logger
2
3
  class FormatterJSON < ::Logger::Formatter
3
4
  def call(severity, _datetime, progname, msg, **args)
4
5
  hash = { prog: progname, message: msg, severity: severity }.merge(args)
5
- "#{hash.to_json}\n"
6
+ Yeptris::JSON.dump(hash) + "\n"
6
7
  end
7
8
  end
8
9
  end
@@ -64,6 +64,7 @@ All data models inherit from `Lutaml::Model::Serializable` and use declarative a
64
64
  - **Per-record resilience (mirrors the ISO flavor).** A single bad record must never abort the crawl. `#write_file` skips (never `id.content`-crashes on) a record whose MODS entry yields **no docidentifier** (e.g. a DOI stored only in `<identifier type="doi">`, which `ModsParser#parse_doi` doesn't read), and records ids that `#pubid` can't parse (new series like `NIST RB`/`NIST CHIPS`) as "not indexed" — the YAML is still written, only the index-v2 entry is skipped. Both classes accumulate in `#failures`; the `#report_errors` override emits each at `:error` (via `#log_error` → `Util.error`), which the shared `Core::DataFetcher` gh_issue machinery turns into one "Error fetching documents" GitHub issue in CI (`GITHUB_REPOSITORY` + `GITHUB_TOKEN`). `#pubid` no longer warns per-failure; reporting is centralized in `#report_errors`.
65
65
  - **Draft-stage rows come from CSRC (relaton#313).** The MODS collection has no draft records, so `#fetch_csrc` adds the CSRC pubs-export feed (`PubsExport.instance.data`) after the MODS pass. It builds each row **exactly as the runtime builds a CSRC hit** — `HitCollection#hit_data` (which calls `#pubs_export_id`) → `Scraper.parse_page` — so the crawled id and the live id are one string, with the stage, revision and update from the row's uri and DOI (`NIST FIPS 197/Upd1 ipd`, `NIST SP 800-108r1 ipd`, `NIST SP 1800-12 2pd`). Only a row whose id carries a **stage** is written; finals come from MODS, so a CSRC row never shadows a base document. On the 914-row fixture feed this writes 302 staged records. Don't go back to a hand-built `"NIST #{series} #{docnumber} #{stage}"` id: it dropped the numbered drafts (`2pd`…), the `/UpdN`, and spelled revisions `Rev. 1`. A stage that CSRC does not list (e.g. `NIST FIPS 197 fpd`) still answers through the consumer's #303 family fallback. Three more rules: `#csrc_bib` clears the `fetched` date the runtime stamps, or every draft changes each day and `Core#force_write` rewrites ~300 files per incremental crawl; CSRC lists a republished draft twice (`…/ipd` and `…/ipd-(1)`, one DOI, so one id), so each id is written once and the plain uri wins; and only the build step is rescued (to a `#failures` entry) — a write error stops the crawl, as in the MODS pass. `#pubs_export_id` must not mutate its row (`sub`, not `sub!`): the crawler and the runtime share the `PubsExport` data.
66
66
  - **A CSRC relation names the related document (relaton#314).** `Scraper#related_id` finds the related feed row by its uri and derives its id with `HitCollection.pubs_export_id` (the class method the instance method delegates to). The entry's own `docidentifier` drops the update and the part, and the hit's code is the record's own id, so neither is the related id. `HitCollection#search_filter` ends in an `ensure` that returns `arr`: every branch must set `arr`, because an earlier `return` is overridden there.
67
+ - **CSWP comes from CSRC, not the NIST Library (relaton-nist#104).** The NIST Library announced CSWP data, but its MODS release (`allrecords-MODS.xml`, and the MARC `allrecords.xml`) holds **no** CSWP record — 0 on the Sept2026 release. It publishes CSWPs only as RIS/BibTeX files under `bib/` on its `nist-pages` branch. So the index has only the CSRC draft CSWP rows (`#fetch_csrc`), and a final CSWP answers from CSRC at runtime: `HitCollection#search` asks the CSRC feed (`from_json`) and asks the index (`from_ga`) only when CSRC gives no hit. That is the chosen rule — CSRC wins, no merge — because CSRC lists every numbered CSWP with more metadata (affiliations, status, supersedes, comment period). The known gap: the NIST Library also has about 35 **legacy dated ids** (`NIST CSWP 01142020`, the same paper as `CSWP 9`, from before NIST renumbered the series) and translations (`NIST.CSWP.6.spa`). CSRC lists neither, so they answer not-found; a fix needs a mapping built from those RIS files. `hit_collection_spec.rb` pins the CSRC answer.
67
68
  - **`#write_file` serializes before it indexes**, so a record that fails to serialize leaves no index row naming a file that was never written. That is how a `Bib::Date` in `Scraper.fetch_dates` hid: `Nist::ItemData` takes `Nist::Date`, and YAML serialization rejects the parent class (the same strictness as the docidentifier note below).
68
69
  - `ModsParser` — maps MODS XML (via `loc_mods`) to `ItemData`
69
70
  - `Scraper` — fetches items from GitHub YAML or CSRC JSON
@@ -118,6 +119,11 @@ NIST-specific gotchas (why this isn't a straight copy of IEC/CCSDS):
118
119
  `@raw_content` before wrapping `@pubid` in pubid's `AllParts` — matching
119
120
  IEC/CCSDS. `#pubid` itself, read directly, now answers `all_parts? ==
120
121
  true` and renders WITH pubid's generic "(all parts)" marker.
122
+ - **The internal form drops `NIST `/`NBS `** (relaton#49), so
123
+ `docid.to_s(form: :internal)` is `SP 800-113` for `NIST SP 800-113`. The rule
124
+ is the `"NIST"` row of `Bib::Docidentifier::FORM_PUBID`: `@pubid`
125
+ renders with `to_s(with_publisher: false)`. A DOI docid has type `DOI` and is
126
+ unchanged. `to_s` itself is inherited from `Bib::Docidentifier`.
121
127
 
122
128
  ### Serialization Round-Trip Pattern
123
129
 
@@ -74,10 +74,6 @@ module Relaton
74
74
  original_content
75
75
  end
76
76
 
77
- def to_s
78
- content.to_s
79
- end
80
-
81
77
  # `#exclude` (no args) clones the whole graph (incl. a supplement's
82
78
  # base), since `walk_chain` mutates it in place. `@raw_content` freezes
83
79
  # the stripped rendering — `content` is live from `@pubid`, and the
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "singleton"
2
3
 
3
4
  module Relaton
@@ -61,7 +62,7 @@ module Relaton
61
62
  #
62
63
  def unzip
63
64
  Zip::File.open(DATAFILE) do |zf|
64
- JSON.parse zf.first.get_input_stream.read
65
+ Relaton::Json.parse_external zf.first.get_input_stream.read
65
66
  end
66
67
  end
67
68
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+
3
4
  require "fileutils"
4
5
  require "faraday"
5
- require "json"
6
6
  require_relative "../ogc"
7
7
  require_relative "scraper"
8
8
 
@@ -126,7 +126,7 @@ module Relaton
126
126
  resp = Faraday.new(ENDPOINT, headers: h).get
127
127
  case resp.status
128
128
  when 200
129
- json = JSON.parse(resp.body)
129
+ json = Relaton::Json.parse_external(resp.body)
130
130
  block_given? ? yield(resp[:etag], json) : json
131
131
  when 304 then []
132
132
  else raise Relaton::RequestError, "Could not access #{ENDPOINT}"
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+
4
4
  require "mechanize"
5
5
  require "pubid"
6
6
 
@@ -118,10 +118,10 @@ module Relaton
118
118
  script = @doc.at_xpath('//script[@type="application/ld+json"]')
119
119
  return unless script
120
120
 
121
- node = JSON.parse(script.text).find { |e| e.is_a?(Hash) && e[LD_DATE] }
121
+ node = Relaton::Json.parse_external(script.text).find { |e| e.is_a?(Hash) && e[LD_DATE] }
122
122
  value = node && node[LD_DATE].first["@value"]
123
123
  value && ::Date.parse(value)
124
- rescue JSON::ParserError, ::Date::Error
124
+ rescue ::JSON::ParserError, ::Date::Error
125
125
  nil
126
126
  end
127
127
 
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "json"
2
3
  require "relaton/core"
3
4
  require_relative "../plateau"
@@ -78,7 +79,7 @@ module Relaton
78
79
  body = hadle_response(response)
79
80
 
80
81
  # Parse the JSON response
81
- JSON.parse(body)
82
+ Relaton::Json.parse_external(body)
82
83
  rescue StandardError => e
83
84
  # Handle any errors during the fetching process
84
85
  Util.error "Error fetching JSON data from #{url}: #{e.message}"
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yeptris"
4
+
3
5
  require "faraday"
4
- require "json"
5
6
  require "securerandom"
6
7
 
7
8
  module Relaton
@@ -28,11 +29,11 @@ module Relaton
28
29
  def search_api(text) # rubocop:disable Metrics/MethodLength
29
30
  resp = connection.post("search?l=en&rid=#{SecureRandom.uuid}") do |req|
30
31
  req.headers["Content-Type"] = "application/json"
31
- req.body = JSON.generate(search_body(text))
32
+ req.body = Yeptris::JSON.dump(search_body(text))
32
33
  end
33
34
  return unless resp.success?
34
35
 
35
- body = JSON.parse(resp.body)
36
+ body = Relaton::Json.parse_external(resp.body)
36
37
  return unless body["status"] == 1
37
38
 
38
39
  body.dig("body", "data")
@@ -1,3 +1,3 @@
1
1
  module Relaton
2
- VERSION = "3.0.1".freeze
2
+ VERSION = "3.0.2".freeze
3
3
  end
data/lib/relaton.rb CHANGED
@@ -22,6 +22,7 @@ module Relaton
22
22
  autoload :Logger, "relaton/logger"
23
23
  autoload :Equivalence, "relaton/equivalence"
24
24
  autoload :Core, "relaton/core"
25
+ autoload :Json, "relaton/json"
25
26
  autoload :Index, "relaton/index"
26
27
  autoload :Bib, "relaton/bib"
27
28
  autoload :ThreeGpp, "relaton/3gpp"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-29 00:00:00.000000000 Z
11
+ date: 2026-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -128,14 +128,14 @@ dependencies:
128
128
  requirements:
129
129
  - - "~>"
130
130
  - !ruby/object:Gem::Version
131
- version: 2.7.0
131
+ version: '2.7'
132
132
  type: :runtime
133
133
  prerelease: false
134
134
  version_requirements: !ruby/object:Gem::Requirement
135
135
  requirements:
136
136
  - - "~>"
137
137
  - !ruby/object:Gem::Version
138
- version: 2.7.0
138
+ version: '2.7'
139
139
  - !ruby/object:Gem::Dependency
140
140
  name: faraday-net_http_persistent
141
141
  requirement: !ruby/object:Gem::Requirement
@@ -248,20 +248,6 @@ dependencies:
248
248
  - - "~>"
249
249
  - !ruby/object:Gem::Version
250
250
  version: 0.1.6
251
- - !ruby/object:Gem::Dependency
252
- name: json
253
- requirement: !ruby/object:Gem::Requirement
254
- requirements:
255
- - - "<"
256
- - !ruby/object:Gem::Version
257
- version: '3'
258
- type: :runtime
259
- prerelease: false
260
- version_requirements: !ruby/object:Gem::Requirement
261
- requirements:
262
- - - "<"
263
- - !ruby/object:Gem::Version
264
- version: '3'
265
251
  - !ruby/object:Gem::Dependency
266
252
  name: loc_mods
267
253
  requirement: !ruby/object:Gem::Requirement
@@ -428,7 +414,7 @@ dependencies:
428
414
  requirements:
429
415
  - - ">="
430
416
  - !ruby/object:Gem::Version
431
- version: 1.3.112
417
+ version: 1.3.114
432
418
  - - "<"
433
419
  - !ruby/object:Gem::Version
434
420
  version: '1.4'
@@ -438,7 +424,7 @@ dependencies:
438
424
  requirements:
439
425
  - - ">="
440
426
  - !ruby/object:Gem::Version
441
- version: 1.3.112
427
+ version: 1.3.114
442
428
  - - "<"
443
429
  - !ruby/object:Gem::Version
444
430
  version: '1.4'
@@ -460,30 +446,36 @@ dependencies:
460
446
  name: pubid
461
447
  requirement: !ruby/object:Gem::Requirement
462
448
  requirements:
463
- - - "~>"
449
+ - - ">="
450
+ - !ruby/object:Gem::Version
451
+ version: 2.0.1
452
+ - - "<"
464
453
  - !ruby/object:Gem::Version
465
- version: 2.0.0.pre.alpha.62
454
+ version: '2.1'
466
455
  type: :runtime
467
456
  prerelease: false
468
457
  version_requirements: !ruby/object:Gem::Requirement
469
458
  requirements:
470
- - - "~>"
459
+ - - ">="
460
+ - !ruby/object:Gem::Version
461
+ version: 2.0.1
462
+ - - "<"
471
463
  - !ruby/object:Gem::Version
472
- version: 2.0.0.pre.alpha.62
464
+ version: '2.1'
473
465
  - !ruby/object:Gem::Dependency
474
466
  name: yeptris
475
467
  requirement: !ruby/object:Gem::Requirement
476
468
  requirements:
477
469
  - - ">="
478
470
  - !ruby/object:Gem::Version
479
- version: '0.4'
471
+ version: 0.6.51
480
472
  type: :runtime
481
473
  prerelease: false
482
474
  version_requirements: !ruby/object:Gem::Requirement
483
475
  requirements:
484
476
  - - ">="
485
477
  - !ruby/object:Gem::Version
486
- version: '0.4'
478
+ version: 0.6.51
487
479
  - !ruby/object:Gem::Dependency
488
480
  name: relaton-render
489
481
  requirement: !ruby/object:Gem::Requirement
@@ -1149,6 +1141,7 @@ files:
1149
1141
  - lib/relaton/jis/scraper.rb
1150
1142
  - lib/relaton/jis/structured_identifier.rb
1151
1143
  - lib/relaton/jis/util.rb
1144
+ - lib/relaton/json.rb
1152
1145
  - lib/relaton/logger.rb
1153
1146
  - lib/relaton/logger/CLAUDE.md
1154
1147
  - lib/relaton/logger/README.adoc