relaton 3.0.0 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton/3gpp/CLAUDE.md +31 -0
  3. data/lib/relaton/3gpp/bibliography.rb +1 -1
  4. data/lib/relaton/3gpp/data_fetcher.rb +32 -9
  5. data/lib/relaton/3gpp/date.rb +13 -0
  6. data/lib/relaton/3gpp/item.rb +3 -0
  7. data/lib/relaton/3gpp/parser.rb +9 -6
  8. data/lib/relaton/bib/converter/csl.rb +3 -2
  9. data/lib/relaton/bib/model/docidentifier.rb +61 -0
  10. data/lib/relaton/bib/model/item_shared.rb +12 -1
  11. data/lib/relaton/bib.rb +2 -1
  12. data/lib/relaton/bipm/CLAUDE.md +14 -0
  13. data/lib/relaton/bsi/model/docidentifier.rb +0 -4
  14. data/lib/relaton/calconnect/data_fetcher.rb +11 -3
  15. data/lib/relaton/ccsds/data/fetcher.rb +2 -1
  16. data/lib/relaton/ccsds/model/docidentifier.rb +0 -4
  17. data/lib/relaton/cie/authors_parser.rb +78 -0
  18. data/lib/relaton/cie/data_fetcher.rb +23 -18
  19. data/lib/relaton/cie.rb +1 -0
  20. data/lib/relaton/core/data_fetcher.rb +23 -1
  21. data/lib/relaton/core.rb +7 -0
  22. data/lib/relaton/db/cache.rb +3 -3
  23. data/lib/relaton/db.rb +20 -1
  24. data/lib/relaton/doi/crossref.rb +2 -1
  25. data/lib/relaton/doi/parser.rb +3 -2
  26. data/lib/relaton/etsi/data_fetcher.rb +3 -3
  27. data/lib/relaton/gb/sec_scraper.rb +3 -3
  28. data/lib/relaton/iana/data_fetcher.rb +53 -4
  29. data/lib/relaton/iec/data_fetcher.rb +3 -2
  30. data/lib/relaton/iec/model/docidentifier.rb +0 -4
  31. data/lib/relaton/ieee/CLAUDE.md +30 -4
  32. data/lib/relaton/ieee/bibliography.rb +87 -32
  33. data/lib/relaton/ieee/data_fetcher.rb +1 -1
  34. data/lib/relaton/ieee/docidentifier.rb +37 -0
  35. data/lib/relaton/ieee/idams_parser.rb +2 -2
  36. data/lib/relaton/ieee/item.rb +3 -0
  37. data/lib/relaton/ieee/item_data.rb +7 -1
  38. data/lib/relaton/ietf/CLAUDE.md +27 -2
  39. data/lib/relaton/ietf/bibxml_parser.rb +38 -0
  40. data/lib/relaton/ietf/data_fetcher.rb +3 -30
  41. data/lib/relaton/ietf/scraper.rb +60 -3
  42. data/lib/relaton/ietf/wg_name_resolver.rb +2 -2
  43. data/lib/relaton/iho/CLAUDE.md +4 -0
  44. data/lib/relaton/iho/bibliography.rb +7 -1
  45. data/lib/relaton/index/dataset_lock.rb +4 -3
  46. data/lib/relaton/index/shard_source.rb +3 -2
  47. data/lib/relaton/index/sqlite_backend.rb +5 -3
  48. data/lib/relaton/isbn/open_library.rb +2 -1
  49. data/lib/relaton/iso/data_fetcher.rb +6 -6
  50. data/lib/relaton/iso/model/docidentifier.rb +0 -4
  51. data/lib/relaton/itu/data_fetcher.rb +2 -2
  52. data/lib/relaton/itu/hit_collection.rb +3 -3
  53. data/lib/relaton/itu/recommendation_fields.rb +2 -2
  54. data/lib/relaton/jis/data_fetcher.rb +2 -1
  55. data/lib/relaton/jis/hit_collection.rb +1 -2
  56. data/lib/relaton/jis.rb +1 -0
  57. data/lib/relaton/json.rb +21 -0
  58. data/lib/relaton/logger/channels/gh_issue.rb +3 -3
  59. data/lib/relaton/logger/formatter_json.rb +2 -1
  60. data/lib/relaton/nist/CLAUDE.md +6 -0
  61. data/lib/relaton/nist/docidentifier.rb +0 -4
  62. data/lib/relaton/nist/hit_collection.rb +1 -1
  63. data/lib/relaton/nist/pubs_export.rb +2 -1
  64. data/lib/relaton/nist.rb +1 -0
  65. data/lib/relaton/ogc/data_fetcher.rb +2 -2
  66. data/lib/relaton/omg/scraper.rb +3 -3
  67. data/lib/relaton/plateau/data_fetcher.rb +2 -1
  68. data/lib/relaton/plateau/hit_collection.rb +1 -1
  69. data/lib/relaton/plateau.rb +1 -0
  70. data/lib/relaton/un/hit_collection.rb +4 -3
  71. data/lib/relaton/version.rb +1 -1
  72. data/lib/relaton.rb +1 -0
  73. metadata +40 -24
@@ -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
@@ -95,8 +95,7 @@ module Relaton
95
95
  def index
96
96
  @index ||= Relaton::Index.find_or_create(
97
97
  :jis,
98
- url: "#{GH_URL}#{INDEXFILE}.zip",
99
- file: "#{INDEXFILE}.yaml",
98
+ pages_url: PAGES_URL,
100
99
  pubid_class: ::Pubid::Jis::Identifier,
101
100
  )
102
101
  end
data/lib/relaton/jis.rb CHANGED
@@ -17,6 +17,7 @@ module Relaton
17
17
  module Jis
18
18
  # The pubid index. relaton-data-jis rebuilds the legacy index-v1 from data/.
19
19
  INDEXFILE = "index-v2"
20
+ PAGES_URL = "https://relaton.github.io/relaton-data-jis/"
20
21
 
21
22
  class Error < StandardError; end
22
23
 
@@ -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
@@ -356,7 +356,7 @@ module Relaton
356
356
  end
357
357
 
358
358
  index = Relaton::Index.find_or_create(
359
- :nist, url: "#{GHNISTDATA}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml",
359
+ :nist, pages_url: PAGES_URL,
360
360
  pubid_class: ::Pubid::Nist::Identifier
361
361
  )
362
362
  # search(r) narrows candidates by number via binary search; the block
@@ -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
data/lib/relaton/nist.rb CHANGED
@@ -22,6 +22,7 @@ module Relaton
22
22
  # (for older gem versions) is rebuilt separately by the relaton-data-nist
23
23
  # crawler, not by this gem's DataFetcher.
24
24
  INDEXFILE = "index-v2"
25
+ PAGES_URL = "https://relaton.github.io/relaton-data-nist/"
25
26
 
26
27
  class Error < StandardError; end
27
28
 
@@ -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}"
@@ -30,7 +30,7 @@ module Relaton
30
30
 
31
31
  def index
32
32
  @index ||= Relaton::Index.find_or_create(
33
- :plateau, url: "#{ENDPOINT}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml",
33
+ :plateau, pages_url: PAGES_URL,
34
34
  pubid_class: ::Pubid::Plateau::Identifier
35
35
  )
36
36
  end
@@ -19,6 +19,7 @@ require_relative "plateau/processor"
19
19
  module Relaton
20
20
  module Plateau
21
21
  INDEXFILE = "index-v2"
22
+ PAGES_URL = "https://relaton.github.io/relaton-data-plateau/"
22
23
 
23
24
  class Error < StandardError; end
24
25