relaton 3.0.1 → 3.0.3
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.
- checksums.yaml +4 -4
- data/lib/relaton/bib/converter/csl.rb +3 -2
- data/lib/relaton/bib/model/docidentifier.rb +61 -0
- data/lib/relaton/bib.rb +2 -1
- data/lib/relaton/bipm/CLAUDE.md +14 -0
- data/lib/relaton/bipm.rb +0 -1
- data/lib/relaton/bsi/model/docidentifier.rb +0 -4
- data/lib/relaton/calconnect/data_fetcher.rb +2 -2
- data/lib/relaton/ccsds/data/fetcher.rb +2 -1
- data/lib/relaton/ccsds/model/docidentifier.rb +0 -4
- data/lib/relaton/core/data_fetcher.rb +2 -1
- data/lib/relaton/core/processor.rb +1 -1
- data/lib/relaton/core.rb +7 -0
- data/lib/relaton/db/cache.rb +3 -3
- data/lib/relaton/db/registry.rb +1 -1
- data/lib/relaton/db.rb +22 -3
- data/lib/relaton/doi/crossref.rb +2 -1
- data/lib/relaton/doi/parser.rb +3 -2
- data/lib/relaton/etsi/data_fetcher.rb +3 -3
- data/lib/relaton/gb/sec_scraper.rb +3 -3
- data/lib/relaton/iec/data_fetcher.rb +3 -2
- data/lib/relaton/iec/model/docidentifier.rb +0 -4
- data/lib/relaton/ietf/CLAUDE.md +27 -2
- data/lib/relaton/ietf/bibxml_parser.rb +38 -0
- data/lib/relaton/ietf/data_fetcher.rb +3 -30
- data/lib/relaton/ietf/scraper.rb +60 -3
- data/lib/relaton/ietf/wg_name_resolver.rb +2 -2
- data/lib/relaton/iho/CLAUDE.md +4 -0
- data/lib/relaton/iho/bibliography.rb +7 -1
- data/lib/relaton/index/dataset_lock.rb +4 -3
- data/lib/relaton/index/shard_source.rb +3 -2
- data/lib/relaton/index/sqlite_backend.rb +5 -3
- data/lib/relaton/isbn/open_library.rb +2 -1
- data/lib/relaton/iso/data_fetcher.rb +6 -6
- data/lib/relaton/iso/model/docidentifier.rb +0 -4
- data/lib/relaton/itu/data_fetcher.rb +2 -2
- data/lib/relaton/itu/hit_collection.rb +3 -3
- data/lib/relaton/itu/recommendation_fields.rb +2 -2
- data/lib/relaton/jis/data_fetcher.rb +2 -1
- data/lib/relaton/json.rb +21 -0
- data/lib/relaton/logger/channels/gh_issue.rb +3 -3
- data/lib/relaton/logger/formatter_json.rb +2 -1
- data/lib/relaton/nist/CLAUDE.md +7 -0
- data/lib/relaton/nist/bibliography.rb +31 -0
- data/lib/relaton/nist/cswp.rb +137 -0
- data/lib/relaton/nist/cswp.yaml +155 -0
- data/lib/relaton/nist/docidentifier.rb +0 -4
- data/lib/relaton/nist/processor.rb +20 -0
- data/lib/relaton/nist/pubs_export.rb +2 -1
- data/lib/relaton/nist.rb +1 -0
- data/lib/relaton/ogc/data_fetcher.rb +2 -2
- data/lib/relaton/omg/scraper.rb +3 -3
- data/lib/relaton/plateau/data_fetcher.rb +2 -1
- data/lib/relaton/un/hit_collection.rb +4 -3
- data/lib/relaton/version.rb +1 -1
- data/lib/relaton.rb +1 -0
- metadata +45 -88
data/lib/relaton/ietf/scraper.rb
CHANGED
|
@@ -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
|
-
|
|
28
|
-
Net::
|
|
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
|
-
|
|
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 =
|
|
23
|
+
data = Relaton::Json.parse_external(response.body)
|
|
24
24
|
objects = data["objects"] || []
|
|
25
25
|
break if objects.empty?
|
|
26
26
|
|
data/lib/relaton/iho/CLAUDE.md
CHANGED
|
@@ -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
|
-
.
|
|
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.
|
|
95
|
-
rescue JSON::
|
|
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.
|
|
178
|
-
rescue JSON::
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
data/lib/relaton/json.rb
ADDED
|
@@ -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 " ",
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
6
|
+
Yeptris::JSON.dump(hash) + "\n"
|
|
6
7
|
end
|
|
7
8
|
end
|
|
8
9
|
end
|
data/lib/relaton/nist/CLAUDE.md
CHANGED
|
@@ -64,6 +64,8 @@ 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). `hit_collection_spec.rb` pins the CSRC answer.
|
|
68
|
+
- **Legacy dated CSWP ids and CSWP translations come from a static table (relaton#406).** CSRC lists neither the ~30 **legacy dated ids** (`NIST CSWP 01142020`, the same paper as `CSWP 9`, from before NIST numbered the series) nor the translations (`NIST.CSWP.6.spa`). `cswp.yaml` holds both, built once from the 84 RIS files (header of the file says how), and `Cswp` reads it. `Bibliography.get` asks `Cswp.lookup` **first** — the parenthetical-date regex after it would strip `(spa)`. A legacy id fetches the numbered id through the normal CSRC path and adds the dated id as a non-primary docidentifier (`06222021-draft` → the CSRC `CSWP 19 ipd`); a translation, dated or numbered, answers an offline record from the table (`NIST CSWP 6(spa)`, the RIS title/authors/year/DOI, `language`/`script` from `Cswp::LANGUAGES`, a `translatedFrom` relation to the original). `normalize` reads `NIST CSWP 6 spa`, `NIST CSWP 6(spa)` and `NIST.CSWP.6.spa` as one key. `Processor#cache_pubid` returns nil (so `Db` hands `get` the String and does not cache) for **every legacy id** and for a translation that pubid does not render back. A legacy answer is the numbered record plus the dated id, and `Db` would store it under the numbered id's row, so a later `NIST CSWP 9` would carry `NIST CSWP 01142020`. pubid reads `04162018ar` as `04162018a`, `6 spa` as `6(spa)`, and fails on `04162018bg`/`02122014pl`. A translation answers only for its own RIS year. Not mapped, on purpose: `02042022-1`…`-5` have no SN and share the title "Cybersecurity White Paper: EO Response", so they still answer not-found. The CSRC `ipd` rows of `27`/`29`/`32` already answer, so the table leaves out their RIS files. A new translation means a new table row.
|
|
67
69
|
- **`#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
70
|
- `ModsParser` — maps MODS XML (via `loc_mods`) to `ItemData`
|
|
69
71
|
- `Scraper` — fetches items from GitHub YAML or CSRC JSON
|
|
@@ -118,6 +120,11 @@ NIST-specific gotchas (why this isn't a straight copy of IEC/CCSDS):
|
|
|
118
120
|
`@raw_content` before wrapping `@pubid` in pubid's `AllParts` — matching
|
|
119
121
|
IEC/CCSDS. `#pubid` itself, read directly, now answers `all_parts? ==
|
|
120
122
|
true` and renders WITH pubid's generic "(all parts)" marker.
|
|
123
|
+
- **The internal form drops `NIST `/`NBS `** (relaton#49), so
|
|
124
|
+
`docid.to_s(form: :internal)` is `SP 800-113` for `NIST SP 800-113`. The rule
|
|
125
|
+
is the `"NIST"` row of `Bib::Docidentifier::FORM_PUBID`: `@pubid`
|
|
126
|
+
renders with `to_s(with_publisher: false)`. A DOI docid has type `DOI` and is
|
|
127
|
+
unchanged. `to_s` itself is inherited from `Bib::Docidentifier`.
|
|
121
128
|
|
|
122
129
|
### Serialization Round-Trip Pattern
|
|
123
130
|
|
|
@@ -39,6 +39,9 @@ module Relaton
|
|
|
39
39
|
# read back as its printed form; the pubid itself is not changed.
|
|
40
40
|
ref = ref.to_s unless ref.is_a?(String)
|
|
41
41
|
return fetch_ref_err(ref, year, []) if ref.match?(/\sEP$/)
|
|
42
|
+
# Before the parsing below, which reads "(spa)" as a date.
|
|
43
|
+
cswp = Cswp.lookup(ref)
|
|
44
|
+
return cswp_get(ref, cswp, year, opts) if cswp
|
|
42
45
|
|
|
43
46
|
/^(?<code2>[^(]+)(?:\((?<date2>\w+\s(?:\d{2},\s)?\d{4})\))?\s?\(?(?:(?<=\()(?<stage>(?:I|F|\d)PD))?/ =~ ref
|
|
44
47
|
stage ||= /(?<=\.)PD-\w+(?=\.)/.match(ref)&.to_s
|
|
@@ -62,6 +65,34 @@ module Relaton
|
|
|
62
65
|
|
|
63
66
|
private
|
|
64
67
|
|
|
68
|
+
#
|
|
69
|
+
# Get a CSWP that CSRC does not list (relaton#406). A legacy dated id
|
|
70
|
+
# answers the numbered CSRC record, with the dated id as an extra
|
|
71
|
+
# docidentifier; a translation answers its record from the RIS table.
|
|
72
|
+
#
|
|
73
|
+
# @param ref [String] reference
|
|
74
|
+
# @param cswp [Array] the Cswp.lookup result
|
|
75
|
+
# @param year [String, nil] year
|
|
76
|
+
# @param opts [Hash] options
|
|
77
|
+
#
|
|
78
|
+
# @return [Relaton::Nist::ItemData, nil] bibliographic item
|
|
79
|
+
#
|
|
80
|
+
def cswp_get(ref, cswp, year, opts)
|
|
81
|
+
type, id, dated = cswp
|
|
82
|
+
if type == :translation
|
|
83
|
+
return fetch_ref_err(ref, year, [id[:year]]) if year && year.to_s != id[:year]
|
|
84
|
+
|
|
85
|
+
item = Cswp.translation_item(id)
|
|
86
|
+
Util.info "Found: `#{item.docidentifier.first.content}`", key: ref
|
|
87
|
+
return item
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Util.info "Legacy dated CSWP id, fetching `#{id}`", key: ref
|
|
91
|
+
item = nistbib_get(id, year, opts)
|
|
92
|
+
item&.docidentifier&.<< Docidentifier.new(content: dated, type: "NIST")
|
|
93
|
+
item
|
|
94
|
+
end
|
|
95
|
+
|
|
65
96
|
#
|
|
66
97
|
# Get NIST document by reference
|
|
67
98
|
#
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Relaton
|
|
4
|
+
module Nist
|
|
5
|
+
#
|
|
6
|
+
# CSWP ids the CSRC pubs-export feed does not list (relaton#406): the
|
|
7
|
+
# legacy dated ids from before the series was numbered, and the
|
|
8
|
+
# translations. The table (`cswp.yaml`) is built from the NIST Library RIS
|
|
9
|
+
# files; see the header of that file.
|
|
10
|
+
#
|
|
11
|
+
# This file loads no flavor model at require time, so the processor can
|
|
12
|
+
# ask #lookup on the cold path. #translation_item needs the models.
|
|
13
|
+
#
|
|
14
|
+
module Cswp
|
|
15
|
+
# ISO 639-2 code of a translation => [ISO 639-1 code, ISO 15924 script]
|
|
16
|
+
LANGUAGES = {
|
|
17
|
+
"ara" => %w[ar Arab], "bul" => %w[bg Cyrl], "fre" => %w[fr Latn],
|
|
18
|
+
"gre" => %w[el Grek], "ind" => %w[id Latn], "jpn" => %w[ja Jpan],
|
|
19
|
+
"kor" => %w[ko Kore], "may" => %w[ms Latn], "nor" => %w[no Latn],
|
|
20
|
+
"pol" => %w[pl Latn], "por" => %w[pt Latn], "spa" => %w[es Latn],
|
|
21
|
+
"ukr" => %w[uk Cyrl]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# @return [Hash] the table as written in cswp.yaml
|
|
26
|
+
def table
|
|
27
|
+
@table ||= YAML.load_file File.expand_path("cswp.yaml", __dir__)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
#
|
|
31
|
+
# The comparable form of a CSWP reference: no publisher, the
|
|
32
|
+
# separators (".", "(...)") read as spaces, upper case.
|
|
33
|
+
# "NIST CSWP 6(spa)", "NIST.CSWP.6.spa" and "NIST CSWP 6 spa" all
|
|
34
|
+
# give "CSWP 6 SPA".
|
|
35
|
+
#
|
|
36
|
+
# @param ref [String]
|
|
37
|
+
# @return [String]
|
|
38
|
+
def normalize(ref)
|
|
39
|
+
ref.to_s.sub(/\ANIST[.\s]+/i, "").gsub(/[().]/, " ").squeeze(" ").strip.upcase
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#
|
|
43
|
+
# Find a reference in the table.
|
|
44
|
+
#
|
|
45
|
+
# @param ref [String]
|
|
46
|
+
# @return [Array, nil] `[:legacy, numbered id, dated id]` for a
|
|
47
|
+
# legacy id of an English CSWP, `[:translation, entry]` for a
|
|
48
|
+
# translation (dated or numbered), nil otherwise
|
|
49
|
+
def lookup(ref)
|
|
50
|
+
key = normalize ref
|
|
51
|
+
return unless key.start_with? "CSWP "
|
|
52
|
+
|
|
53
|
+
key = key.delete_prefix "CSWP "
|
|
54
|
+
dated, target = legacy[key]
|
|
55
|
+
entry = translations[(target || key).upcase]
|
|
56
|
+
return [:translation, entry] if entry
|
|
57
|
+
|
|
58
|
+
[:legacy, "NIST CSWP #{target}", "NIST CSWP #{dated}"] if target
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#
|
|
62
|
+
# Build the record of a translation from its RIS fields.
|
|
63
|
+
#
|
|
64
|
+
# @param entry [Hash] a translation entry from #lookup
|
|
65
|
+
# @return [Relaton::Nist::ItemData]
|
|
66
|
+
def translation_item(entry) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
67
|
+
number, lang_code = entry[:id].split
|
|
68
|
+
language, script = LANGUAGES.fetch lang_code
|
|
69
|
+
id = "NIST CSWP #{number}(#{lang_code})"
|
|
70
|
+
ItemData.new(
|
|
71
|
+
type: "standard",
|
|
72
|
+
docidentifier: [
|
|
73
|
+
Docidentifier.new(content: id, type: "NIST", primary: true),
|
|
74
|
+
Docidentifier.new(content: entry[:doi].split("/").last, type: "DOI"),
|
|
75
|
+
],
|
|
76
|
+
title: [Bib::Title.new(content: entry[:title], language: "en", script: "Latn")],
|
|
77
|
+
source: [Bib::Uri.new(type: "doi", content: "https://doi.org/#{entry[:doi]}")],
|
|
78
|
+
date: [Date.new(type: "published", at: entry[:year])],
|
|
79
|
+
contributor: entry[:authors].map { |name| author name } << publisher,
|
|
80
|
+
language: [language],
|
|
81
|
+
script: [script],
|
|
82
|
+
relation: [original_relation("NIST CSWP #{number}")],
|
|
83
|
+
place: [Bib::Place.new(city: "Gaithersburg", region: [Bib::Place::RegionType.new(iso: "MD")])],
|
|
84
|
+
ext: Ext.new(doctype: Doctype.new(content: "standard"), flavor: "nist"),
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# dated id (upper case) => [dated id, numbered id]
|
|
91
|
+
def legacy
|
|
92
|
+
@legacy ||= table["legacy"].to_h { |dated, numbered| [dated.upcase, [dated, numbered]] }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# translation id (upper case) => entry
|
|
96
|
+
def translations
|
|
97
|
+
@translations ||= table["translations"].to_h do |id, fields|
|
|
98
|
+
[id.upcase, fields.transform_keys(&:to_sym).merge(id: id, authors: fields["authors"] || [])]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param name [String] RIS author, "Surname, Forename"
|
|
103
|
+
# @return [Bib::Contributor]
|
|
104
|
+
def author(name)
|
|
105
|
+
surname, forename = name.split(/,\s*/, 2)
|
|
106
|
+
fullname = Bib::FullName.new(
|
|
107
|
+
surname: Bib::LocalizedString.new(content: surname, language: "en", script: "Latn"),
|
|
108
|
+
forename: [Bib::FullNameType::Forename.new(content: forename, language: "en", script: "Latn")],
|
|
109
|
+
)
|
|
110
|
+
Bib::Contributor.new(
|
|
111
|
+
person: Bib::Person.new(name: fullname),
|
|
112
|
+
role: [Bib::Contributor::Role.new(type: "author")],
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Bib::Contributor]
|
|
117
|
+
def publisher
|
|
118
|
+
org = Bib::Organization.new(
|
|
119
|
+
name: [Bib::TypedLocalizedString.new(content: "National Institute of Standards and Technology")],
|
|
120
|
+
abbreviation: Bib::LocalizedString.new(content: "NIST"),
|
|
121
|
+
)
|
|
122
|
+
Bib::Contributor.new(organization: org, role: [Bib::Contributor::Role.new(type: "publisher")])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param id [String] the original's id
|
|
126
|
+
# @return [Relaton::Nist::Relation]
|
|
127
|
+
def original_relation(id)
|
|
128
|
+
bib = ItemData.new(
|
|
129
|
+
formattedref: Bib::Formattedref.new(content: id),
|
|
130
|
+
docidentifier: [Docidentifier.new(content: id, type: "NIST", primary: true)],
|
|
131
|
+
)
|
|
132
|
+
Relation.new(type: "translatedFrom", bibitem: bib)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|