relaton 3.0.8 → 3.1.1
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/3gpp/processor.rb +1 -1
- data/lib/relaton/3gpp.rb +3 -3
- data/lib/relaton/adobe/processor.rb +1 -1
- data/lib/relaton/adobe.rb +3 -3
- data/lib/relaton/bib/item_data.rb +2 -2
- data/lib/relaton/bib.rb +2 -2
- data/lib/relaton/bibcollection.rb +1 -1
- data/lib/relaton/bipm/processor.rb +1 -1
- data/lib/relaton/bipm.rb +3 -3
- data/lib/relaton/bsi/processor.rb +1 -1
- data/lib/relaton/bsi.rb +1 -1
- data/lib/relaton/calconnect/data_fetcher.rb +2 -2
- data/lib/relaton/calconnect/model/item.rb +1 -1
- data/lib/relaton/calconnect/processor.rb +1 -1
- data/lib/relaton/calconnect.rb +2 -2
- data/lib/relaton/ccsds/data/fetcher.rb +1 -1
- data/lib/relaton/ccsds/model/item.rb +1 -1
- data/lib/relaton/ccsds/processor.rb +1 -1
- data/lib/relaton/ccsds/util.rb +1 -1
- data/lib/relaton/ccsds.rb +1 -1
- data/lib/relaton/cen/processor.rb +1 -1
- data/lib/relaton/cen.rb +2 -2
- data/lib/relaton/cie/data_fetcher.rb +3 -3
- data/lib/relaton/cie/processor.rb +1 -1
- data/lib/relaton/cie.rb +2 -2
- data/lib/relaton/cli/base_convertor.rb +3 -3
- data/lib/relaton/cli/command.rb +19 -11
- data/lib/relaton/cli/index_site_generator.rb +34 -6
- data/lib/relaton/cli/relaton_file.rb +4 -4
- data/lib/relaton/cli/subcommand_collection.rb +1 -1
- data/lib/relaton/cli/xml_convertor.rb +1 -1
- data/lib/relaton/cli/yaml_convertor.rb +2 -2
- data/lib/relaton/cli.rb +1 -11
- data/lib/relaton/core/processor.rb +1 -1
- data/lib/relaton/core.rb +1 -1
- data/lib/relaton/db/registry.rb +1 -1
- data/lib/relaton/db.rb +1 -1
- data/lib/relaton/doi/parser.rb +2 -1
- data/lib/relaton/doi/processor.rb +1 -1
- data/lib/relaton/doi.rb +4 -4
- data/lib/relaton/easc/processor.rb +1 -1
- data/lib/relaton/easc.rb +3 -3
- data/lib/relaton/ecma/data_fetcher.rb +1 -1
- data/lib/relaton/ecma/processor.rb +1 -1
- data/lib/relaton/ecma.rb +2 -2
- data/lib/relaton/etsi/data_fetcher.rb +2 -2
- data/lib/relaton/etsi/item.rb +1 -1
- data/lib/relaton/etsi/processor.rb +2 -2
- data/lib/relaton/etsi.rb +1 -1
- data/lib/relaton/evs/processor.rb +1 -1
- data/lib/relaton/evs.rb +2 -2
- data/lib/relaton/gb/bibliography.rb +1 -1
- data/lib/relaton/gb/processor.rb +1 -1
- data/lib/relaton/gb.rb +2 -2
- data/lib/relaton/gost/processor.rb +1 -1
- data/lib/relaton/gost.rb +3 -3
- data/lib/relaton/iala/processor.rb +1 -1
- data/lib/relaton/iala.rb +3 -3
- data/lib/relaton/iana/data_fetcher.rb +1 -1
- data/lib/relaton/iana/processor.rb +1 -1
- data/lib/relaton/iana.rb +2 -2
- data/lib/relaton/iec/bibliography.rb +32 -20
- data/lib/relaton/iec/data_fetcher.rb +1 -1
- data/lib/relaton/iec/processor.rb +1 -1
- data/lib/relaton/iec.rb +2 -2
- data/lib/relaton/ieee/processor.rb +1 -1
- data/lib/relaton/ieee.rb +2 -2
- data/lib/relaton/ietf/data_fetcher.rb +1 -1
- data/lib/relaton/ietf/processor.rb +1 -1
- data/lib/relaton/ietf.rb +2 -2
- data/lib/relaton/iho/hash_parser_v1.rb +1 -1
- data/lib/relaton/iho/processor.rb +1 -1
- data/lib/relaton/iho.rb +2 -2
- data/lib/relaton/index.rb +1 -1
- data/lib/relaton/isbn/processor.rb +1 -1
- data/lib/relaton/isbn.rb +1 -1
- data/lib/relaton/iso/hash_parser_v1.rb +1 -1
- data/lib/relaton/iso/processor.rb +1 -1
- data/lib/relaton/iso.rb +3 -3
- data/lib/relaton/itu/processor.rb +1 -1
- data/lib/relaton/itu.rb +3 -3
- data/lib/relaton/jcgm/processor.rb +1 -1
- data/lib/relaton/jcgm.rb +2 -2
- data/lib/relaton/jis/processor.rb +1 -1
- data/lib/relaton/jis.rb +2 -2
- data/lib/relaton/nist/processor.rb +1 -1
- data/lib/relaton/nist.rb +2 -2
- data/lib/relaton/oasis/processor.rb +1 -1
- data/lib/relaton/oasis.rb +2 -2
- data/lib/relaton/ogc/processor.rb +1 -1
- data/lib/relaton/ogc.rb +2 -2
- data/lib/relaton/oiml/processor.rb +1 -1
- data/lib/relaton/oiml.rb +2 -2
- data/lib/relaton/omg/processor.rb +1 -1
- data/lib/relaton/omg.rb +1 -1
- data/lib/relaton/plateau/data_fetcher.rb +1 -1
- data/lib/relaton/plateau/processor.rb +1 -1
- data/lib/relaton/plateau.rb +2 -2
- data/lib/relaton/render/iso690/elements/extent.rb +1 -1
- data/lib/relaton/render.rb +17 -0
- data/lib/relaton/sae/CLAUDE.md +91 -0
- data/lib/relaton/sae/bibdata.rb +8 -0
- data/lib/relaton/sae/bibitem.rb +8 -0
- data/lib/relaton/sae/bibliography.rb +128 -0
- data/lib/relaton/sae/data_fetcher.rb +224 -0
- data/lib/relaton/sae/doi_code.rb +83 -0
- data/lib/relaton/sae/item.rb +10 -0
- data/lib/relaton/sae/item_data.rb +6 -0
- data/lib/relaton/sae/processor.rb +62 -0
- data/lib/relaton/sae/util.rb +9 -0
- data/lib/relaton/sae.rb +37 -0
- data/lib/relaton/un/processor.rb +1 -1
- data/lib/relaton/un.rb +1 -1
- data/lib/relaton/version.rb +1 -1
- data/lib/relaton/w3c/bibliography.rb +1 -1
- data/lib/relaton/w3c/data_fetcher.rb +1 -1
- data/lib/relaton/w3c/processor.rb +1 -1
- data/lib/relaton/w3c.rb +2 -2
- data/lib/relaton/xsf/data_fetcher.rb +1 -1
- data/lib/relaton/xsf/processor.rb +1 -1
- data/lib/relaton/xsf.rb +2 -2
- data/lib/relaton-cli.rb +2 -2
- data/lib/relaton-render.rb +4 -11
- data/lib/relaton.rb +3 -2
- metadata +18 -8
- data/lib/relaton/cli/version.rb +0 -11
- data/lib/relaton/render/version.rb +0 -5
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# CLAUDE.md — SAE flavor
|
|
2
|
+
|
|
3
|
+
`Relaton::Sae` fetches SAE International standards (relaton/relaton-sae#1).
|
|
4
|
+
|
|
5
|
+
## Development
|
|
6
|
+
|
|
7
|
+
- `bundle exec rake spec:sae` — this flavor's suite (from the repo root).
|
|
8
|
+
- No VCR: the specs stub HTTP with WebMock. `spec/sae/fixtures/crossref/`
|
|
9
|
+
holds three Crossref cursor pages built from real records (11 works:
|
|
10
|
+
six J3016/J3194 editions, three AMS4121 revisions, a part and a
|
|
11
|
+
letter-pair number). `spec/sae/fixtures/index-v2.yaml` and `fixtures/data/` are the
|
|
12
|
+
**output of `DataFetcher` over those pages**: regenerate both together by
|
|
13
|
+
running the crawler over the page fixtures, never by hand.
|
|
14
|
+
|
|
15
|
+
## Where the data comes from
|
|
16
|
+
|
|
17
|
+
**sae.org answers every script request with a CAPTCHA** (product pages and
|
|
18
|
+
search alike), so no scraper is possible. SAE deposits every standard edition
|
|
19
|
+
with Crossref instead: prefix `10.4271`, type `standard`, ~47,700 records. The
|
|
20
|
+
DOI suffix is SAE's own edition code in lowercase:
|
|
21
|
+
|
|
22
|
+
| DOI | SAE code | scheme |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `10.4271/j3016_202104` | `SAE J3016_202104` | J-series: `_YYYYMM` edition date |
|
|
25
|
+
| `10.4271/ams4121j` | `SAE AMS4121J` | aerospace: revision letter, no date |
|
|
26
|
+
| `10.4271/j2337/2_201702` | `SAE J2337/2_201702` | a part |
|
|
27
|
+
|
|
28
|
+
Crossref's text search does **not** find a standard by its id (it matches
|
|
29
|
+
titles), so an undated reference cannot be answered live. The flavor is
|
|
30
|
+
therefore index-based, the ECMA/IALA shape:
|
|
31
|
+
|
|
32
|
+
- **Producer** — `DataFetcher` (`relaton fetch-data sae-crossref`) walks
|
|
33
|
+
`api.crossref.org/prefixes/10.4271/works?filter=type:standard` with the
|
|
34
|
+
deep-paging cursor (1000 a request, one request a second through
|
|
35
|
+
`Core::Pacer`; 429/5xx and transport errors retried with back-off, five
|
|
36
|
+
attempts, then `RequestError`, because
|
|
37
|
+
a partial crawl would publish a partial index). Each work goes through
|
|
38
|
+
`Relaton::Doi::Parser`, then gets SAE's code as its **primary**
|
|
39
|
+
docidentifier (type `SAE`), the DOI beside it, and `ext` doctype
|
|
40
|
+
`standard`, flavor `sae`. `fetched` is cleared: a data file carries no
|
|
41
|
+
fetch date, or every daily crawl would rewrite and commit all ~47,700
|
|
42
|
+
files. `Bibliography#fetch_doc` sets it when it reads a document.
|
|
43
|
+
- **Consumer** — `Bibliography.get` reads the `relaton-data-sae` Pages index
|
|
44
|
+
(`PAGES_URL`) and the document from `ENDPOINT` (raw GitHub).
|
|
45
|
+
|
|
46
|
+
## Three identifier forms
|
|
47
|
+
|
|
48
|
+
`DoiCode` gives each record three strings:
|
|
49
|
+
|
|
50
|
+
- `#native` — the code in the DOI's spelling, `SAE J3016_202104`,
|
|
51
|
+
`SAE AMSQQA250/4B`. The output file is named after it
|
|
52
|
+
(`data/sae-amsqqa250-4b.yaml`), so a file name never depends on pubid.
|
|
53
|
+
- `#printed` — the code as SAE prints it (`SAE AMS-QQ-A-250/4B`): pubid's
|
|
54
|
+
render of the native code, or the native code when pubid cannot read it.
|
|
55
|
+
It is the primary docidentifier.
|
|
56
|
+
- `#reference` — what the index row is parsed from: the native code when the
|
|
57
|
+
DOI has a date, else the code with the year of the record's `approved` date
|
|
58
|
+
(`SAE AMS4121J:2017`), so a dated reference also reaches an aerospace
|
|
59
|
+
revision.
|
|
60
|
+
|
|
61
|
+
`Pubid::Sae` (pubid >= 2.0.9, pubid#665) reads all three, the month
|
|
62
|
+
included, so the `Db` keys the query and the fetched document with the
|
|
63
|
+
default `cache_pubid`. A reference pubid cannot read (`SAE J3016_2021`,
|
|
64
|
+
`SAE CPGM2016`) answers nil with a log line (relaton#235); it does not raise.
|
|
65
|
+
|
|
66
|
+
## Selection
|
|
67
|
+
|
|
68
|
+
`index.search(pubid)` with no block, so the subset match `query === row`:
|
|
69
|
+
a revision, a year or a month the query omits matches any value; the type
|
|
70
|
+
(`J`, `AMS`, …) is compared, and a part and an amendment are `subset_strict`
|
|
71
|
+
(`SAE J2337` does not answer with `SAE J2337/2`). Of the matches the highest
|
|
72
|
+
revision wins (`[length, letters]`, so `AA` follows `Z`), then the latest
|
|
73
|
+
year and month, then the file name (the index sort is not stable). A year
|
|
74
|
+
argument goes on a **copy** of a pubid handed in by `Db`.
|
|
75
|
+
|
|
76
|
+
The index key carries the month, so two editions of one year are two rows.
|
|
77
|
+
Two DOIs that spell one code (`ams-4121j`, `ams4121j`) share a key, and
|
|
78
|
+
`DataFetcher#add_to_index` keeps the later one (DOI date, then approval
|
|
79
|
+
date).
|
|
80
|
+
|
|
81
|
+
## What pubid still cannot read
|
|
82
|
+
|
|
83
|
+
About 960 of the ~47,700 deposits: 791 `CP*` collected-paper products (not
|
|
84
|
+
standards) and ~170 one-off shapes (`SUP1` supplements, language tails such
|
|
85
|
+
as `_PTBR`, `_4Q2012`, `_MMYYYY` dates). See pubid's `lib/pubid/sae/CLAUDE.md`.
|
|
86
|
+
Such a record is written, reported in `@errors` (the crawl's GitHub issue),
|
|
87
|
+
and not indexed: one row that fails to deserialize makes `Relaton::Index`
|
|
88
|
+
reject the whole index.
|
|
89
|
+
|
|
90
|
+
A multi-letter tail stays in `number` (`J1939DA`, and also `AMS4121AA`), so
|
|
91
|
+
a two-letter revision is its own bucket: `SAE AMS4121` does not reach it.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
module Relaton
|
|
4
|
+
module Sae
|
|
5
|
+
module Bibliography
|
|
6
|
+
# Transport failures of the document fetch. They become
|
|
7
|
+
# `Relaton::RequestError`, the one class `Relaton::Db#net_retry` retries.
|
|
8
|
+
NET_ERRORS = [
|
|
9
|
+
SocketError, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED,
|
|
10
|
+
Errno::ETIMEDOUT, EOFError, Net::HTTPBadResponse,
|
|
11
|
+
Net::HTTPHeaderSyntaxError, Net::ProtocolError, Net::OpenTimeout,
|
|
12
|
+
Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
#
|
|
17
|
+
# Fetch an SAE standard.
|
|
18
|
+
#
|
|
19
|
+
# @param ref [String, Pubid::Sae::Identifier] `SAE J3016`,
|
|
20
|
+
# `SAE J3016:2021`, or SAE's native edition code `SAE J3016_202104`
|
|
21
|
+
# @param year [String, nil]
|
|
22
|
+
# @param _opts [Hash]
|
|
23
|
+
# @return [Relaton::Sae::ItemData, nil]
|
|
24
|
+
#
|
|
25
|
+
def get(ref, year = nil, _opts = {})
|
|
26
|
+
Util.info "Fetching from Relaton repository ...", key: ref.to_s
|
|
27
|
+
pubid = parse_ref ref, year
|
|
28
|
+
return not_found(ref) unless pubid
|
|
29
|
+
|
|
30
|
+
row = best_match pubid
|
|
31
|
+
item = row && fetch_doc(row[:file])
|
|
32
|
+
return not_found(ref) unless item
|
|
33
|
+
|
|
34
|
+
Util.info "Found: `#{primary_id item}`", key: ref.to_s
|
|
35
|
+
item
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def not_found(ref)
|
|
41
|
+
Util.info "Not found.", key: ref.to_s
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#
|
|
46
|
+
# The query as a `Pubid::Sae::Identifier`.
|
|
47
|
+
#
|
|
48
|
+
# A parsed pubid comes from `Relaton::Db` (relaton#205): it is not
|
|
49
|
+
# mutated, so a year argument goes on a copy. A reference pubid cannot
|
|
50
|
+
# read answers nil, so `get` answers not-found with a log line instead
|
|
51
|
+
# of raising (relaton#235): `Db` passes such a reference as a String.
|
|
52
|
+
#
|
|
53
|
+
# @return [Pubid::Sae::Identifier, nil]
|
|
54
|
+
#
|
|
55
|
+
def parse_ref(ref, year)
|
|
56
|
+
ref = parse_text(ref.to_s.strip) unless ref.is_a?(::Pubid::Sae::Identifier)
|
|
57
|
+
return ref unless ref && year
|
|
58
|
+
|
|
59
|
+
copy = ::Pubid::Sae::Identifier.from_hash ref.to_hash
|
|
60
|
+
copy.date = ::Pubid::Sae::Components::Date.new(year: year.to_i)
|
|
61
|
+
copy
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_text(text)
|
|
65
|
+
::Pubid::Sae::Identifier.parse text
|
|
66
|
+
rescue ::Pubid::Errors::Error, ArgumentError => e
|
|
67
|
+
Util.info "Not an SAE identifier pubid reads: #{e.message}", key: text
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
#
|
|
72
|
+
# The index row for a query: the newest edition among its matches.
|
|
73
|
+
#
|
|
74
|
+
# Passing the pubid (not a String) is what makes `Index::Type#search`
|
|
75
|
+
# narrow to the `number` bucket, and with no block it selects with
|
|
76
|
+
# pubid's subset match `query === row`: a revision, a year or a month
|
|
77
|
+
# the query omits matches any value, and the type (`J`, `AMS`, …), a
|
|
78
|
+
# part and an amendment must agree.
|
|
79
|
+
#
|
|
80
|
+
# Of the matches, the highest revision wins (aerospace standards), then
|
|
81
|
+
# the latest date (J-series editions). `r[:file]` breaks a tie, because
|
|
82
|
+
# the index sort is not stable.
|
|
83
|
+
#
|
|
84
|
+
# @param pubid [Pubid::Sae::Identifier]
|
|
85
|
+
# @return [Hash, nil]
|
|
86
|
+
#
|
|
87
|
+
def best_match(pubid)
|
|
88
|
+
index.search(pubid).max_by do |r|
|
|
89
|
+
id = r[:id]
|
|
90
|
+
[revision_rank(id.revision), id.year.to_i, id.date&.month.to_i,
|
|
91
|
+
r[:file]]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `B` follows `A`, and a two-letter revision (`AA`) follows `Z`.
|
|
96
|
+
def revision_rank(revision)
|
|
97
|
+
[revision.to_s.size, revision.to_s]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param file [String] the row's path in the data repo
|
|
101
|
+
# @return [Relaton::Sae::ItemData, nil] nil when the file is missing
|
|
102
|
+
def fetch_doc(file)
|
|
103
|
+
uri = URI("#{ENDPOINT}#{file}")
|
|
104
|
+
resp = Net::HTTP.get_response uri
|
|
105
|
+
return if resp.code == "404"
|
|
106
|
+
unless resp.code == "200"
|
|
107
|
+
raise Relaton::RequestError,
|
|
108
|
+
"Could not access #{uri}: HTTP #{resp.code}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Item.from_yaml(resp.body).tap { |i| i.fetched = Date.today.to_s }
|
|
112
|
+
rescue *NET_ERRORS => e
|
|
113
|
+
raise Relaton::RequestError, "Could not access #{uri}: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def primary_id(item)
|
|
117
|
+
(item.docidentifier.detect(&:primary) || item.docidentifier.first)&.content
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def index
|
|
121
|
+
Relaton::Index.find_or_create(
|
|
122
|
+
:sae, pages_url: PAGES_URL, pubid_class: ::Pubid::Sae::Identifier
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require_relative "../core"
|
|
3
|
+
require_relative "../json"
|
|
4
|
+
require_relative "../sae"
|
|
5
|
+
require_relative "../doi"
|
|
6
|
+
|
|
7
|
+
module Relaton
|
|
8
|
+
module Sae
|
|
9
|
+
#
|
|
10
|
+
# Crawl every SAE standard from Crossref into a data repo.
|
|
11
|
+
#
|
|
12
|
+
# sae.org answers a script with a CAPTCHA, and Crossref's text search does
|
|
13
|
+
# not find a standard by its identifier, but SAE deposits each edition
|
|
14
|
+
# with a DOI under prefix `10.4271` and type `standard`. This walks that
|
|
15
|
+
# set with Crossref's deep-paging cursor (1000 records a request, about 48
|
|
16
|
+
# requests), maps each record with `Relaton::Doi::Parser`, and writes one
|
|
17
|
+
# document per edition and the pubid `index-v2`.
|
|
18
|
+
#
|
|
19
|
+
class DataFetcher < Core::DataFetcher
|
|
20
|
+
SOURCES = %w[sae-crossref].freeze
|
|
21
|
+
URL = "https://api.crossref.org/prefixes/10.4271/works".freeze
|
|
22
|
+
ROWS = 1000
|
|
23
|
+
# Crossref's public pool allows far more; one request a second is polite
|
|
24
|
+
# and still finishes the crawl in about a minute of waits.
|
|
25
|
+
GAP = 1
|
|
26
|
+
# Attempts for one page before the crawl gives up.
|
|
27
|
+
ATTEMPTS = 5
|
|
28
|
+
RETRY_CODES = %w[429 500 502 503 504].freeze
|
|
29
|
+
|
|
30
|
+
def fetch(source = nil)
|
|
31
|
+
unless SOURCES.include? source
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"Unknown source `#{source}`, expected one of #{SOURCES}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@newest = {}
|
|
37
|
+
each_page { |items| items.each { |work| parse_work work } }
|
|
38
|
+
index.save
|
|
39
|
+
report_errors
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def index
|
|
43
|
+
# `pubid_class:` on the producer too: FileIO#save calls `to_hash` only
|
|
44
|
+
# for instances of it, so without it the crawl writes v1-shaped rows
|
|
45
|
+
# under a v2 name.
|
|
46
|
+
@index ||= Relaton::Index.find_or_create(
|
|
47
|
+
:sae, file: "#{INDEXFILE}.yaml", pubid_class: ::Pubid::Sae::Identifier
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def log_error(msg)
|
|
52
|
+
Util.error msg
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def each_page
|
|
58
|
+
cursor = "*"
|
|
59
|
+
loop do
|
|
60
|
+
message = get_page(cursor)
|
|
61
|
+
items = message["items"]
|
|
62
|
+
break if items.nil? || items.empty?
|
|
63
|
+
|
|
64
|
+
yield items
|
|
65
|
+
cursor = message["next-cursor"]
|
|
66
|
+
break unless cursor
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#
|
|
71
|
+
# One page of the cursor walk. A throttle or a server error is retried
|
|
72
|
+
# with a growing wait; anything else, or a page that keeps failing,
|
|
73
|
+
# stops the crawl, because a partial crawl would publish a partial index.
|
|
74
|
+
#
|
|
75
|
+
# @param cursor [String]
|
|
76
|
+
# @return [Hash] the `message` of the response
|
|
77
|
+
#
|
|
78
|
+
def get_page(cursor) # rubocop:disable Metrics/MethodLength
|
|
79
|
+
uri = page_uri cursor
|
|
80
|
+
ATTEMPTS.times do |n|
|
|
81
|
+
pacer.wait
|
|
82
|
+
resp, failure = attempt uri
|
|
83
|
+
return Relaton::Json.parse_external(resp.body)["message"] if resp&.code == "200"
|
|
84
|
+
if resp && !RETRY_CODES.include?(resp.code)
|
|
85
|
+
raise Relaton::RequestError, "Crossref HTTP #{resp.code} for #{uri}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
break if n == ATTEMPTS - 1
|
|
89
|
+
|
|
90
|
+
Util.warn "Crossref #{failure || "HTTP #{resp.code}"}, " \
|
|
91
|
+
"retry #{n + 1} of #{ATTEMPTS - 1}"
|
|
92
|
+
sleep backoff(resp, n + 1)
|
|
93
|
+
end
|
|
94
|
+
raise Relaton::RequestError,
|
|
95
|
+
"Crossref keeps failing for #{uri} after #{ATTEMPTS} attempts"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A transport error is retried like a 5xx: one timeout in ~48 requests
|
|
99
|
+
# must not fail the crawl.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array(Net::HTTPResponse|nil, String|nil)] the response, or nil
|
|
102
|
+
# and the error message
|
|
103
|
+
def attempt(uri)
|
|
104
|
+
[http_get(uri), nil]
|
|
105
|
+
rescue *Bibliography::NET_ERRORS => e
|
|
106
|
+
[nil, "#{e.class}: #{e.message}"]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def page_uri(cursor)
|
|
110
|
+
uri = URI URL
|
|
111
|
+
uri.query = URI.encode_www_form(filter: "type:standard", rows: ROWS,
|
|
112
|
+
cursor: cursor)
|
|
113
|
+
uri
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def http_get(uri)
|
|
117
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
118
|
+
open_timeout: 30, read_timeout: 120) do |http|
|
|
119
|
+
http.get uri.request_uri, "User-Agent" => Relaton::Doi::Crossref::USER_AGENT
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Crossref sends `X-Rate-Limit-Interval: 1s`; a throttled response can
|
|
124
|
+
# carry no such header, so the wait is never less than a second.
|
|
125
|
+
def backoff(resp, num)
|
|
126
|
+
interval = resp&.[]("x-rate-limit-interval").to_s[/\d+/].to_i
|
|
127
|
+
[interval, 1].max * num * 2
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def pacer
|
|
131
|
+
@pacer ||= Core::Pacer.new(gap: GAP)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
#
|
|
135
|
+
# Write one Crossref record and index it.
|
|
136
|
+
#
|
|
137
|
+
# @param work [Hash] a Crossref work
|
|
138
|
+
#
|
|
139
|
+
def parse_work(work)
|
|
140
|
+
code = DoiCode.parse work["DOI"], approved_year: approved_year(work)
|
|
141
|
+
return Util.warn("Not an SAE DOI: `#{work['DOI']}`") unless code
|
|
142
|
+
|
|
143
|
+
item = to_item work, code
|
|
144
|
+
file = write_unique code.native, serialize(item)
|
|
145
|
+
add_to_index code, work, file
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def approved_year(work)
|
|
149
|
+
(work["approved"] || work["issued"])&.dig("date-parts", 0, 0)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
#
|
|
153
|
+
# The `Relaton::Doi::Parser` item, with SAE's edition code (as SAE
|
|
154
|
+
# prints it) as its primary identifier and the DOI kept beside it.
|
|
155
|
+
#
|
|
156
|
+
# `Doi::Parser` stamps today as `fetched`. A data file must not carry
|
|
157
|
+
# it: the daily crawl would then rewrite every file and commit the
|
|
158
|
+
# whole corpus. `Bibliography#fetch_doc` sets `fetched` on reading.
|
|
159
|
+
#
|
|
160
|
+
# @return [Relaton::Sae::ItemData]
|
|
161
|
+
#
|
|
162
|
+
def to_item(work, code)
|
|
163
|
+
bib = Relaton::Doi::Parser.parse work
|
|
164
|
+
bib.fetched = nil
|
|
165
|
+
bib.id = code.native.delete(" /").gsub(/\W/, "")
|
|
166
|
+
bib.docidentifier = [
|
|
167
|
+
Bib::Docidentifier.new(type: "SAE", content: code.printed,
|
|
168
|
+
primary: true),
|
|
169
|
+
*bib.docidentifier.map do |d|
|
|
170
|
+
Bib::Docidentifier.new(type: d.type, content: d.content)
|
|
171
|
+
end,
|
|
172
|
+
]
|
|
173
|
+
bib.ext = Bib::Ext.new(doctype: Bib::Doctype.new(content: "standard"),
|
|
174
|
+
flavor: "sae")
|
|
175
|
+
Item.from_yaml Bib::Item.to_yaml(bib)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
#
|
|
179
|
+
# Index the edition under its pubid, or record why it cannot be.
|
|
180
|
+
#
|
|
181
|
+
# An id pubid cannot read is recorded in `@errors` (the inherited
|
|
182
|
+
# `report_errors` logs it and opens the crawl's GitHub issue) and the row
|
|
183
|
+
# is skipped: `Relaton::Index` rejects the WHOLE index when one row fails
|
|
184
|
+
# to deserialize. The data file is already written, so the document is
|
|
185
|
+
# unindexed, never lost.
|
|
186
|
+
#
|
|
187
|
+
# The key carries the month, so two editions of one year are two rows.
|
|
188
|
+
# Two DOIs that spell one code (`ams-4121j`, `ams4121j`) share a key;
|
|
189
|
+
# the one with the later DOI date, then approval date, keeps the row.
|
|
190
|
+
#
|
|
191
|
+
def add_to_index(code, work, file)
|
|
192
|
+
pubid = parse_pubid code
|
|
193
|
+
unless pubid
|
|
194
|
+
@errors[code.native] =
|
|
195
|
+
"Unparseable SAE id `#{code.native}` was not indexed (#{file})"
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
key = pubid.to_s
|
|
200
|
+
stamp = [code.date.to_s, approved_stamp(work)]
|
|
201
|
+
return if @newest[key] && (@newest[key] <=> stamp) >= 0
|
|
202
|
+
|
|
203
|
+
@newest[key] = stamp
|
|
204
|
+
index.add_or_update pubid, file
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def parse_pubid(code)
|
|
208
|
+
::Pubid::Sae::Identifier.parse code.reference
|
|
209
|
+
rescue ::Pubid::Errors::Error, ArgumentError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def approved_stamp(work)
|
|
214
|
+
Array(work.dig("approved", "date-parts", 0)).map do |d|
|
|
215
|
+
d.to_i.to_s.rjust(2, "0")
|
|
216
|
+
end.join("-")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def to_yaml(bib) = bib.to_yaml
|
|
220
|
+
def to_xml(bib) = Bibdata.to_xml(bib)
|
|
221
|
+
def to_bibxml(bib) = bib.to_rfcxml
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Relaton
|
|
2
|
+
module Sae
|
|
3
|
+
#
|
|
4
|
+
# The SAE codes a Crossref DOI names.
|
|
5
|
+
#
|
|
6
|
+
# SAE deposits one DOI per edition, and its suffix is SAE's own edition
|
|
7
|
+
# code in lowercase: `10.4271/j3016_202104` is `SAE J3016_202104`, the
|
|
8
|
+
# April 2021 edition of J3016. Aerospace standards are told apart by a
|
|
9
|
+
# revision letter instead and carry no date (`10.4271/ams4121j`).
|
|
10
|
+
#
|
|
11
|
+
# Three strings come out of it:
|
|
12
|
+
#
|
|
13
|
+
# * `#native` — the code in the DOI's spelling (`SAE AMSQQA250/4B`). The
|
|
14
|
+
# output file is named after it, so a file name never depends on pubid.
|
|
15
|
+
# * `#printed` — the code as SAE prints it (`SAE AMS-QQ-A-250/4B`), the
|
|
16
|
+
# document's primary identifier.
|
|
17
|
+
# * `#reference` — the form the index row is parsed from. It is the
|
|
18
|
+
# native code when the DOI has a date; an undated code gets the year of
|
|
19
|
+
# the record's approval date, so a dated reference also reaches an
|
|
20
|
+
# aerospace revision (`SAE AMS4121J:2017`).
|
|
21
|
+
#
|
|
22
|
+
class DoiCode
|
|
23
|
+
PREFIX = "10.4271/".freeze
|
|
24
|
+
# A code, then an optional `_YYYYMM` edition date. The code takes letters,
|
|
25
|
+
# digits, the `/` of a part (`j2337/2`) and any `_` that is not that date
|
|
26
|
+
# (`as5259/7a_a1`, `j1939da_4q2012`: 1,163 of the 2026-11 deposits); what
|
|
27
|
+
# it means is pubid's business, so nothing else is decided here.
|
|
28
|
+
SUFFIX = %r{\A(?<code>[a-z0-9][a-z0-9/._-]*?)(?:_(?<date>\d{6}))?\z}i
|
|
29
|
+
|
|
30
|
+
attr_reader :code, :date, :year
|
|
31
|
+
|
|
32
|
+
#
|
|
33
|
+
# @param doi [String, nil] e.g. `10.4271/j3016_202104`
|
|
34
|
+
# @param approved_year [String, Integer, nil] the record's approval year
|
|
35
|
+
# @return [Relaton::Sae::DoiCode, nil] nil for a DOI that is not SAE's
|
|
36
|
+
#
|
|
37
|
+
def self.parse(doi, approved_year: nil)
|
|
38
|
+
suffix = doi.to_s.strip
|
|
39
|
+
return unless suffix.downcase.start_with? PREFIX
|
|
40
|
+
|
|
41
|
+
match = SUFFIX.match suffix[PREFIX.size..]
|
|
42
|
+
return unless match
|
|
43
|
+
|
|
44
|
+
new(match[:code].upcase, match[:date], approved_year)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(code, date, approved_year)
|
|
48
|
+
@code = code
|
|
49
|
+
@date = date
|
|
50
|
+
@year = date ? date[0, 4] : approved_year&.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String] SAE's edition code, e.g. `SAE J3016_202104`
|
|
54
|
+
def native
|
|
55
|
+
date ? "SAE #{code}_#{date}" : "SAE #{code}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [String] the code SAE prints, or the native code when pubid
|
|
59
|
+
# cannot read it
|
|
60
|
+
def printed
|
|
61
|
+
pubid&.to_s || native
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [String] the reference the index row is parsed from
|
|
65
|
+
def reference
|
|
66
|
+
return native if date || !year
|
|
67
|
+
|
|
68
|
+
"SAE #{code}:#{year}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Pubid::Sae::Identifier, nil] the native code, parsed
|
|
72
|
+
def pubid
|
|
73
|
+
return @pubid if defined? @pubid
|
|
74
|
+
|
|
75
|
+
@pubid = begin
|
|
76
|
+
::Pubid::Sae::Identifier.parse native
|
|
77
|
+
rescue ::Pubid::Errors::Error, ArgumentError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Relaton
|
|
2
|
+
module Sae
|
|
3
|
+
# An SAE bibliographic item. SAE adds no element to the shared model: the
|
|
4
|
+
# Crossref record maps onto `Bib` as it is, so the only flavor-specific
|
|
5
|
+
# value is `ext/flavor`.
|
|
6
|
+
class Item < Bib::Item
|
|
7
|
+
model ItemData
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require_relative "../core/processor"
|
|
2
|
+
|
|
3
|
+
module Relaton
|
|
4
|
+
module Sae
|
|
5
|
+
class Processor < Relaton::Core::Processor
|
|
6
|
+
attr_reader :idtype
|
|
7
|
+
|
|
8
|
+
def initialize # rubocop:disable Lint/MissingSuper
|
|
9
|
+
@short = :relaton_sae
|
|
10
|
+
@prefix = "SAE"
|
|
11
|
+
@defaultprefix = /^SAE\s/
|
|
12
|
+
@idtype = "SAE"
|
|
13
|
+
@datasets = %w[sae-crossref]
|
|
14
|
+
# Db cache key: `Pubid::Sae` reads the reference and SAE's native
|
|
15
|
+
# edition code (`SAE J3016_202104`) alike.
|
|
16
|
+
@pubid_flavor = :Sae
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param code [String, Pubid::Sae::Identifier]
|
|
20
|
+
# @param date [String, nil] year
|
|
21
|
+
# @param opts [Hash]
|
|
22
|
+
# @return [Relaton::Sae::ItemData, nil]
|
|
23
|
+
def get(code, date, opts)
|
|
24
|
+
require_relative "../sae"
|
|
25
|
+
Bibliography.get(code, date, opts)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param source [String] `sae-crossref`
|
|
29
|
+
# @param opts [Hash] `output:`, `format:`
|
|
30
|
+
def fetch_data(source, opts)
|
|
31
|
+
require_relative "data_fetcher"
|
|
32
|
+
DataFetcher.fetch(source, **opts)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param xml [String]
|
|
36
|
+
# @return [Relaton::Sae::ItemData]
|
|
37
|
+
def from_xml(xml)
|
|
38
|
+
require_relative "../sae"
|
|
39
|
+
Item.from_xml xml
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param yaml [String]
|
|
43
|
+
# @return [Relaton::Sae::ItemData]
|
|
44
|
+
def from_yaml(yaml)
|
|
45
|
+
require_relative "../sae"
|
|
46
|
+
Item.from_yaml yaml
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String]
|
|
50
|
+
def grammar_hash
|
|
51
|
+
require_relative "../sae"
|
|
52
|
+
@grammar_hash ||= Sae.grammar_hash
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def remove_index_file
|
|
56
|
+
require_relative "../sae"
|
|
57
|
+
Relaton::Index.find_or_create(:sae, url: true,
|
|
58
|
+
file: "#{INDEXFILE}.yaml").remove_file
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/relaton/sae.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "digest/md5"
|
|
2
|
+
require "pubid"
|
|
3
|
+
require_relative "bib"
|
|
4
|
+
require_relative "index"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
|
|
7
|
+
# Relaton::Sae is the SAE International flavor (relaton/relaton-sae#1).
|
|
8
|
+
#
|
|
9
|
+
# sae.org answers a script with a CAPTCHA, so the flavor reads SAE's Crossref
|
|
10
|
+
# deposits instead: every SAE standard edition has its own DOI
|
|
11
|
+
# (`10.4271/j3016_202104`). `DataFetcher` crawls them into the
|
|
12
|
+
# `relaton-data-sae` data repo with a pubid `index-v2`, and `Bibliography`
|
|
13
|
+
# reads that index. See lib/relaton/sae/CLAUDE.md.
|
|
14
|
+
module Relaton
|
|
15
|
+
module Sae
|
|
16
|
+
INDEXFILE = "index-v2".freeze
|
|
17
|
+
# The machine index on the data repo's Pages site (relaton#206).
|
|
18
|
+
PAGES_URL = "https://relaton.github.io/relaton-data-sae/".freeze
|
|
19
|
+
# Where the documents the index names are read from.
|
|
20
|
+
ENDPOINT = "https://raw.githubusercontent.com/relaton/relaton-data-sae/main/".freeze
|
|
21
|
+
|
|
22
|
+
autoload :Util, "relaton/sae/util"
|
|
23
|
+
autoload :DoiCode, "relaton/sae/doi_code"
|
|
24
|
+
autoload :ItemData, "relaton/sae/item_data"
|
|
25
|
+
autoload :Item, "relaton/sae/item"
|
|
26
|
+
autoload :Bibitem, "relaton/sae/bibitem"
|
|
27
|
+
autoload :Bibdata, "relaton/sae/bibdata"
|
|
28
|
+
autoload :Bibliography, "relaton/sae/bibliography"
|
|
29
|
+
autoload :Processor, "relaton/sae/processor"
|
|
30
|
+
|
|
31
|
+
# Returns hash of XML grammar
|
|
32
|
+
# @return [String]
|
|
33
|
+
def self.grammar_hash
|
|
34
|
+
Digest::MD5.hexdigest Relaton::VERSION
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|