relaton 3.0.0.pre.alpha.17 → 3.0.0.pre.alpha.19

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/data/equivalence/adoption-chains.yaml +59 -0
  3. data/lib/relaton/3gpp/CLAUDE.md +187 -0
  4. data/lib/relaton/3gpp/README.adoc +149 -0
  5. data/lib/relaton/adobe/CLAUDE.md +104 -0
  6. data/lib/relaton/bib/CLAUDE.md +130 -0
  7. data/lib/relaton/bib/README.adoc +330 -0
  8. data/lib/relaton/bib/model/item_shared.rb +5 -1
  9. data/lib/relaton/bib/model/series.rb +1 -1
  10. data/lib/relaton/bipm/CLAUDE.md +173 -0
  11. data/lib/relaton/bipm/README.adoc +352 -0
  12. data/lib/relaton/bipm/data_fetcher.rb +3 -2
  13. data/lib/relaton/bsi/CLAUDE.md +35 -0
  14. data/lib/relaton/bsi/README.adoc +112 -0
  15. data/lib/relaton/calconnect/CLAUDE.md +177 -0
  16. data/lib/relaton/calconnect/README.adoc +140 -0
  17. data/lib/relaton/ccsds/CLAUDE.md +74 -0
  18. data/lib/relaton/ccsds/README.adoc +154 -0
  19. data/lib/relaton/cen/CLAUDE.md +219 -0
  20. data/lib/relaton/cen/README.adoc +155 -0
  21. data/lib/relaton/cie/CLAUDE.md +90 -0
  22. data/lib/relaton/cie/README.adoc +121 -0
  23. data/lib/relaton/core/CLAUDE.md +140 -0
  24. data/lib/relaton/core/README.adoc +64 -0
  25. data/lib/relaton/core/data_fetcher.rb +142 -3
  26. data/lib/relaton/db/CLAUDE.md +373 -0
  27. data/lib/relaton/db.rb +21 -1
  28. data/lib/relaton/doi/CLAUDE.md +105 -0
  29. data/lib/relaton/doi/README.adoc +89 -0
  30. data/lib/relaton/easc/CLAUDE.md +85 -0
  31. data/lib/relaton/ecma/CLAUDE.md +217 -0
  32. data/lib/relaton/ecma/README.adoc +178 -0
  33. data/lib/relaton/equivalence.rb +117 -0
  34. data/lib/relaton/etsi/CLAUDE.md +219 -0
  35. data/lib/relaton/etsi/README.adoc +137 -0
  36. data/lib/relaton/evs/CLAUDE.md +120 -0
  37. data/lib/relaton/evs/README.adoc +27 -0
  38. data/lib/relaton/gb/CLAUDE.md +47 -0
  39. data/lib/relaton/gb/README.adoc +169 -0
  40. data/lib/relaton/gost/CLAUDE.md +100 -0
  41. data/lib/relaton/iala/CLAUDE.md +97 -0
  42. data/lib/relaton/iana/CLAUDE.md +105 -0
  43. data/lib/relaton/iana/README.adoc +107 -0
  44. data/lib/relaton/iec/CLAUDE.md +13 -0
  45. data/lib/relaton/iec/README.adoc +280 -0
  46. data/lib/relaton/iec/data_fetcher.rb +24 -8
  47. data/lib/relaton/iec/project_fetcher.rb +48 -0
  48. data/lib/relaton/iec/project_parser.rb +111 -0
  49. data/lib/relaton/iec.rb +5 -0
  50. data/lib/relaton/ieee/CLAUDE.md +176 -0
  51. data/lib/relaton/ieee/README.adoc +131 -0
  52. data/lib/relaton/ietf/CLAUDE.md +357 -0
  53. data/lib/relaton/ietf/README.adoc +167 -0
  54. data/lib/relaton/iho/CLAUDE.md +70 -0
  55. data/lib/relaton/iho/README.adoc +119 -0
  56. data/lib/relaton/index/CLAUDE.md +207 -0
  57. data/lib/relaton/index/README.adoc +221 -0
  58. data/lib/relaton/index/dataset_lock.rb +122 -0
  59. data/lib/relaton/index/family.rb +63 -0
  60. data/lib/relaton/index/type.rb +32 -0
  61. data/lib/relaton/index.rb +3 -0
  62. data/lib/relaton/isbn/CLAUDE.md +47 -0
  63. data/lib/relaton/isbn/README.adoc +91 -0
  64. data/lib/relaton/iso/CLAUDE.md +38 -0
  65. data/lib/relaton/iso/README.adoc +370 -0
  66. data/lib/relaton/iso/data_fetcher.rb +16 -1
  67. data/lib/relaton/itu/CLAUDE.md +685 -0
  68. data/lib/relaton/itu/README.adoc +188 -0
  69. data/lib/relaton/jcgm/CLAUDE.md +107 -0
  70. data/lib/relaton/jis/CLAUDE.md +64 -0
  71. data/lib/relaton/jis/README.adoc +167 -0
  72. data/lib/relaton/logger/CLAUDE.md +37 -0
  73. data/lib/relaton/logger/README.adoc +120 -0
  74. data/lib/relaton/nist/CLAUDE.md +180 -0
  75. data/lib/relaton/nist/README.adoc +389 -0
  76. data/lib/relaton/nist/bibliography.rb +17 -1
  77. data/lib/relaton/nist/data_fetcher.rb +60 -4
  78. data/lib/relaton/nist/hit_collection.rb +121 -59
  79. data/lib/relaton/nist/scraper.rb +21 -7
  80. data/lib/relaton/oasis/CLAUDE.md +251 -0
  81. data/lib/relaton/oasis/README.adoc +132 -0
  82. data/lib/relaton/ogc/CLAUDE.md +242 -0
  83. data/lib/relaton/ogc/README.adoc +134 -0
  84. data/lib/relaton/ogc/data_fetcher.rb +9 -3
  85. data/lib/relaton/oiml/CLAUDE.md +47 -0
  86. data/lib/relaton/oiml/README.adoc +70 -0
  87. data/lib/relaton/omg/CLAUDE.md +137 -0
  88. data/lib/relaton/omg/README.adoc +101 -0
  89. data/lib/relaton/plateau/CLAUDE.md +126 -0
  90. data/lib/relaton/plateau/README.adoc +139 -0
  91. data/lib/relaton/sdo/CLAUDE.md +81 -0
  92. data/lib/relaton/un/CLAUDE.md +91 -0
  93. data/lib/relaton/un/README.adoc +125 -0
  94. data/lib/relaton/version.rb +1 -1
  95. data/lib/relaton/w3c/CLAUDE.md +369 -0
  96. data/lib/relaton/w3c/README.adoc +140 -0
  97. data/lib/relaton/w3c/data_fetcher.rb +1 -1
  98. data/lib/relaton/xsf/CLAUDE.md +189 -0
  99. data/lib/relaton/xsf/README.adoc +127 -0
  100. data/lib/relaton.rb +1 -0
  101. metadata +85 -4
@@ -0,0 +1,140 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ relaton-core is the foundation gem every flavor plugin builds on. It provides the abstract **`Relaton::Core::Processor`** base class (the registry plug-in interface), the search primitives (`HitCollection`, `Hit`), a parallel-fetch thread pool (`WorkersPool`), the crawl-politeness pair `Pacer` (shared request pacing) and `Governor` (pool-wide rate-limit back-pressure), the `DataFetcher` base for bulk ingest, and small parsing/utility mixins. It has no knowledge of any specific standards body.
8
+
9
+ ## Development
10
+
11
+ ```bash
12
+ bundle exec rake # default task → rspec
13
+ bundle exec rspec spec/core/processor_spec.rb # single file
14
+ bundle exec rubocop # lint (rubocop-performance/-rake/-rspec)
15
+ ```
16
+
17
+ ## Architecture
18
+
19
+ Namespace: `Relaton::Core`. Key classes in `lib/relaton/core/`:
20
+
21
+ - **`Processor`** (`processor.rb`) — abstract base with the template methods every flavor implements: `get`, `fetch_data`, `from_xml`, `from_yaml`, `grammar_hash`, `threads`. Flavor processors subclass it and set `@short`/`@prefix`/`@defaultprefix`/`@datasets` in `initialize`.
22
+ - **`HitCollection`** (`hit_collection.rb`) — holds search results, delegates to an internal array, and uses `WorkersPool` to fetch items in parallel (4 workers by default). Uses `WeakRef` to avoid a circular reference back to its hits.
23
+ - **`Hit`** (`hit.rb`) — wraps one search result; its `item` is lazy-loaded on first access.
24
+ - **`DataFetcher`** (`data_fetcher.rb`) — abstract base for flavor bulk fetchers; provides `gh_issue` reporting (via `Relaton::Logger::Channels::GhIssue`), `output_file`, `unique_output_file`, `write_unique`, and `serialize` helpers.
25
+
26
+ **Filename collisions.** `output_file` sanitizes `.`, `,`, `/`, `:`, `(`, `)`,
27
+ `-` and whitespace all to `-`, so two **distinct** docids can map to one path
28
+ — the live instance is relaton-data-iana's `rpki/signed-objects` and
29
+ `rpki-signed-objects`, which both give `data/rpki-signed-objects.yaml`. A
30
+ writer that merely warns and writes anyway leaves one file holding the wrong
31
+ document for one of two index ids: a wrong answer, not a missing one.
32
+
33
+ **`unique_output_file(docid)` is the write-path entry point.** It reserves and
34
+ returns `output_file(docid)` when that path is free *or already belongs to the
35
+ same docid*, and only a real clash with a **different** docid gets a variant
36
+ suffixed with a digest of that docid. Two consequences worth keeping:
37
+
38
+ - It is a **no-op for genuine duplicates**, so each flavor's own duplicate
39
+ handling (skip / merge / last-wins) still fires exactly as before. That is
40
+ what makes it safe to drop into an existing writer.
41
+ - The suffix is keyed on the docid, not on encounter order, so adding a record
42
+ never renames an existing file. (Which member of a clashing pair keeps the
43
+ plain path does follow write order, which is stable for a given corpus.)
44
+
45
+ **The genuine-duplicate check must come FIRST in an adopting writer.** Once a
46
+ docid has been disambiguated, `unique_output_file` returns that suffixed path
47
+ for it *forever*, so `file != output_file(docid)` stays true on every repeat.
48
+ Gating the flavor's duplicate handling on that comparison makes it unreachable
49
+ for a repeat, which silently drops a merge (3gpp) or a skip (ecma, jis). Use
50
+ `@files.include?(file)` as the duplicate test — a reserved path only ever
51
+ belongs to one docid — and use the `!= output_file` comparison only to decide
52
+ what to log:
53
+
54
+ ```ruby
55
+ file = unique_output_file docid
56
+ if @files.include? file
57
+ <genuine duplicate: merge / skip / warn>
58
+ else
59
+ Util.warn "... writing #{file} instead" if file != output_file(docid)
60
+ @files << file
61
+ <write + index>
62
+ end
63
+ ```
64
+
65
+ **`write_unique(docid, content)` closes the cross-PROCESS gap.**
66
+ `unique_output_file` reserves in one process's `@file_docids`, which a forked
67
+ worker cannot see. `write_unique` reserves, then creates the file with
68
+ `O_EXCL` (`EXCLUSIVE = File::WRONLY | File::CREAT | File::EXCL`), and returns
69
+ the path it actually wrote. Four outcomes:
70
+
71
+ | situation | what happens |
72
+ |---|---|
73
+ | we already wrote this path for this docid | plain overwrite — a genuine duplicate stays one file |
74
+ | `O_EXCL` succeeds | the normal case |
75
+ | `EEXIST`, single process | `unique_output_file` already proved no peer of ours holds it, so it is a leftover from an earlier crawl: overwrite |
76
+ | `EEXIST`, `@cross_process` | a peer's file and a leftover are indistinguishable: take the digest path instead |
77
+
78
+ `@cross_process` defaults to false, so every single-process flavor takes the
79
+ third row and behaves exactly as it did before adopting this. A fetcher that
80
+ forks sets it **before** the first fork, so children inherit it — see
81
+ `Relaton::Ietf::DataFetcher#fetch_ieft_internet_drafts`.
82
+
83
+ A forked worker only learns that a path was taken, never *which* clashing
84
+ docid deserves it, so the winner would follow the race and the two filenames
85
+ would swap between crawls. The parent settles that afterwards, once it can see
86
+ every docid: `Relaton::Ietf::DataFetcher#reconcile_output_files`.
87
+
88
+ **Deliberately not a wall-clock staleness test.** `File.mtime(file) <
89
+ run_started_at` looks like the obvious way to tell a leftover from a peer, and
90
+ it is a trap: crawls run into a populated `data/`, so `EEXIST` is the *common*
91
+ case on a re-run and the test would decide it for every record. Its
92
+ false-"stale" direction is the silent overwrite this method exists to prevent,
93
+ and coarse filesystem mtime granularity makes that reachable. Gating on
94
+ `@cross_process` removes the failure class instead of tuning it.
95
+
96
+ **Do not drop the unconditional `delete_suffix("-")` in `output_file`.** It is
97
+ the root cause of the trailing-punctuation half of these collisions, and
98
+ removing it would give the clashing ids distinct names — but it renames any id
99
+ ending in a character the gsub maps to `-`. Measured over this repo's index
100
+ fixtures (2026-08-26):
101
+
102
+ | corpus | rows | filenames that would change |
103
+ |---|---|---|
104
+ | `spec/itu/fixtures/index-v2.zip` | 5,361 | **808** — ids ending `:`, e.g. `ITU-R 1-3/8:` |
105
+ | `spec/nist/fixtures/index-v2.zip` | 19,515 | 2 — `NBS TN 467pt1 Add.` |
106
+ | `spec/ietf/fixtures/ietf-index-v2.zip` | 177,362 | 3 |
107
+ | `spec/iso` + `spec/iec` v2 | 112,189 | 0 |
108
+ | every v1 string-id fixture | 97,966 | 7 — IEEE `IEEE 802.3-`, OGC `20-001r2 ` |
109
+
110
+ A 15% rename of the published ITU corpus does not pay for collisions that
111
+ `unique_output_file` and `write_unique` already close.
112
+
113
+ **Audit of every `output_file` caller** (keep this exhaustive — it is the only
114
+ place the decision is recorded):
115
+
116
+ | Adopted | Abstains, and why |
117
+ |---|---|
118
+ | `iana`, `oasis`, `calconnect`, `xsf`, `nist`, `ecma`, `jis`, `3gpp`, `iec`, `etsi`; `ietf` via `write_unique` | `ieee` — `reconcile_staged_outputs` recomputes `output_file(docnumber)` in a later pass with no reservation state, so a disambiguated path breaks the staged rename |
119
+ | | `itu`, `cie` — documented positional `@seen`/`pos` dedup under a parallel crawl |
120
+ | | `iso` — `File.exist?` → `rewrite_with_same_or_newer` version compare |
121
+ | | `ccsds` — `merge_links` merges into the existing file by design |
122
+
123
+ Note `etsi` had **no** collision handling at all before this (silent overwrite,
124
+ not even a warning), and `iec` had the warn-then-overwrite-anyway shape.
125
+ - **`WorkersPool`** (`workers_pool.rb`) — `SizedQueue`-backed thread pool for parallel work. **Runtime search only** — `HitCollection` uses it. No fetcher does: it carries no per-worker resources (a crawler needs one Mechanize agent per thread) and collects into an unsynchronised array. A crawler that needs a pool builds its own; see `Relaton::Itu::DataCrawlerR#in_parallel` and `Relaton::Cie::DataFetcher#process_hits`.
126
+ - **`Pacer`** (`pacer.rb`) — process-wide, thread-safe request pacer: the whole pool between them starts at most one request per `gap` seconds. **Shared, not per-worker** — that is the point, and the contrast with `Relaton::Cie::DataFetcher::Pacing`, whose gap is per worker (N workers there issue N requests per gap, which is right for CIE and wrong when the budget belongs to one host). A reservation never starts before "now", so an idle stretch cannot be repaid as a burst, and the mutex is never held across the sleep. `mode: :fixed` reproduces a plain `sleep delay` loop as a rollback knob. `clock:`/`sleeper:` are injectable, so the arithmetic is specced without wall-clock waits.
127
+ - **`Governor`** (`governor.rb`) — process-wide, thread-safe rate-limit back-pressure: one shared cooldown the whole pool observes, escalating per *round* (60 s → 900 s) rather than per worker, decaying on success, and latching a give-up after five barren rounds so a banned crawl fails fast instead of grinding out a CI job's remaining hours. Two round counters, deliberately: **`#throttle_rounds`** is the LIVE count since the last success (ITU logs it per retry as the current round) and `#succeeded!` resets it, while **`#give_up_rounds`** is the count snapshotted at the instant `@abandoned` latched, and is what an abort message must report — reading the live one after the latch names whatever re-accumulated since, which is how a W3C give-up at round 5 got logged as *"after 2 consecutive backoffs"*. Note also that `#wait` returning 0.0 once latched stops the pool *waiting*, not *asking*: a flavor must guard its own request sites (see `Relaton::W3c::SafeRealize#realize`) or a banned crawl keeps hammering the host until it winds down. Promoted here from `Relaton::W3c`. A flavor subclasses it and supplies two things: `THROTTLE_ERRORS` (or `.throttle?`, when the signal is a status code rather than an exception class, as for ITU) and `ENV_PREFIX`, which namespaces `<PREFIX>_THROTTLE_BASE/_MAX/_GIVEUP` so two flavors crawling in one process cannot share a ladder. Bindings: `Relaton::W3c::Governor`, `Relaton::Itu::Governor`.
128
+ - **Mixins** — `DateParser` (`parse_date` for "February 2012"/"2012-02-03"/etc.), `ArrayWrapper` (`array(x)` → always an Array), `HashKeysSymbolizer` (recursive string→symbol keys).
129
+
130
+ ### How flavors consume it (important)
131
+
132
+ Every flavor's `Relaton::<Flavor>::Processor < Relaton::Core::Processor`. Because the umbrella registry now loads flavors lazily (only their `…/processor` file), any processor method that touches a flavor constant must `require_relative "../<flavor>"` first — see the root `CLAUDE.md` "Registry is lazy" note.
133
+
134
+ ## External dependencies
135
+
136
+ `nokogiri ~> 1.16`, `psych ~> 5.2.0`, `relaton-logger ~> 2.2.0.pre.alpha.1`.
137
+
138
+ ## Testing
139
+
140
+ RSpec with SimpleCov. Specs live under `spec/core/` and mirror the lib classes; `spec/support/` defines dummy processor/types for exercising the base classes.
@@ -0,0 +1,64 @@
1
+ = Library for importing and caching bibliographic references to technical standards library
2
+
3
+ == Usage
4
+
5
+ === Match a partial reference against a document identifier
6
+
7
+ `#===` is a subset match: the reference is the receiver, the candidate is the
8
+ argument. A component the reference leaves out matches any value. The match is
9
+ NOT symmetric.
10
+
11
+ [source,ruby]
12
+ ----
13
+ require "relaton-core"
14
+
15
+ pubid_first = Identifier.parse("ISO 1:1999")
16
+ pubid_second = Identifier.parse("ISO 1")
17
+
18
+ pubid_first == pubid_second
19
+ => false
20
+
21
+ pubid_second === pubid_first
22
+ => true
23
+
24
+ pubid_first === pubid_second
25
+ => false
26
+ ----
27
+
28
+ === Exclude attributes when compare
29
+
30
+ Use `#exclude` to build a copy without a component, for example to render a
31
+ reference without its year. For matching, prefer `#===`.
32
+
33
+ [source,ruby]
34
+ ----
35
+ pubid_first.exclude(:year) == pubid_second
36
+ => true
37
+ ----
38
+
39
+ === Using #to_h to convert identifier to hash
40
+
41
+ [source,ruby]
42
+ ----
43
+ require "pubid-core"
44
+
45
+ pubid = Identifier.parse("ISO 1:1999")
46
+ pubid.to_h
47
+ => { publisher: "ISO", number: 1, year: 1999 }
48
+ ----
49
+
50
+ === Using #new_edition_of? to compare identifiers
51
+
52
+ [source,ruby]
53
+ ----
54
+ require "pubid-core"
55
+
56
+ pubid_first = Identifier.parse("ISO 1:1999")
57
+ pubid_second = Identifier.parse("ISO 1:2000")
58
+
59
+ pubid_first.new_edition_of?(pubid_second)
60
+ => false
61
+ pubid_second.new_edition_of?(pubid_first)
62
+ => true
63
+
64
+ ----
@@ -25,19 +25,104 @@ module Relaton
25
25
  # Set true by a fetcher that writes from forked worker processes,
26
26
  # where @file_docids cannot see a peer's claim. See #write_unique.
27
27
  @cross_process = false
28
+ # True when the caller asked this run to keep the existing output tree
29
+ # and prune it afterwards (see ::fetch's prune:). A flavor that wipes
30
+ # its output at the start of #fetch must skip the wipe in this mode.
31
+ @prune = false
28
32
  # @docs = []
29
33
  @errors = Hash.new(true)
30
34
  end
31
35
 
36
+ # @return [Boolean] whether this run prunes the existing output tree
37
+ attr_accessor :prune
38
+
39
+ # @return [Boolean] whether this run consults and maintains the
40
+ # content-digest ledger (see #unchanged?). Off by default: a flavor
41
+ # turns it on only where its enumeration entry determines the record
42
+ # (the TODO.sota crawl-audit rule).
43
+ attr_accessor :digest_ledger
44
+
45
+ # -- Content-digest ledger -----------------------------------------
46
+ #
47
+ # A per-output sidecar (`.digests.yaml`: key -> digest + file) lets a
48
+ # run skip the fetch+parse+write of a record whose enumeration entry is
49
+ # unchanged, where the entry determines the record. The sidecar is
50
+ # tracked in the data repo so a fresh CI clone still skips. Skipped
51
+ # files are recorded as written, so #prune_unwritten keeps them; the
52
+ # saved ledger keeps only files this run owned, so deleted records
53
+ # drop out with their file.
54
+ #
55
+ # Sound only where the entry bounds the record: a per-item detail
56
+ # fetch whose content the entry does not determine, or relation output
57
+ # discovered across records, must stay outside the skip (TODO.sota,
58
+ # crawl audit 2026-10-23).
59
+
60
+ LEDGER_FILE = ".digests.yaml"
61
+
62
+ # True when the ledger holds `key` with the entry's digest - the
63
+ # record is unchanged and the caller may skip it. Records the
64
+ # ledger's file as written, so prune keeps a skipped record.
65
+ #
66
+ # @param key [String] the record's stable identity (its docid)
67
+ # @param entry [Object] the enumeration entry; digested as JSON
68
+ # @return [Boolean]
69
+ def unchanged?(key, entry)
70
+ return false unless @digest_ledger
71
+
72
+ row = ledger[key]
73
+ return false unless row.is_a?(Hash) && row["digest"] == entry_digest(entry)
74
+
75
+ @written << row["file"] if row["file"]
76
+ warn "Skipping unchanged `#{key}`"
77
+ true
78
+ end
79
+
80
+ # Record the entry's digest for `key`, with the file the record was
81
+ # written to (nil when this run wrote no file for it - the entry
82
+ # drops out of the ledger at save time).
83
+ #
84
+ # @param key [String]
85
+ # @param entry [Object]
86
+ # @param file [String, nil]
87
+ # @return [void]
88
+ def note_digest(key, entry, file: nil)
89
+ return unless @digest_ledger
90
+
91
+ ledger_new[key] = { "digest" => entry_digest(entry), "file" => file }
92
+ end
93
+
94
+ # Persist the ledger: this run's noted entries over the loaded ones,
95
+ # keeping only entries whose file this run recorded as written.
96
+ #
97
+ # @return [void]
98
+ def save_ledger
99
+ kept = ledger.merge(ledger_new).select do |_, row|
100
+ row.is_a?(Hash) && row["file"] && @written.include?(row["file"])
101
+ end
102
+ File.write ledger_path, YAML.dump(kept), encoding: "UTF-8"
103
+ @ledger = kept
104
+ end
105
+
32
106
  # API method for external service
33
107
  #
108
+ # @param prune [Boolean] delete output files this run did not write. The
109
+ # flavor must write its whole corpus on every run; #prune_unwritten's
110
+ # ratio guard stops a partial crawl from wiping the dataset.
34
111
  # @return the value returned by the instance `#fetch`, so callers can act
35
112
  # on the outcome (e.g. relaton-iso returns whether it rebuilt).
36
- def self.fetch(source = nil, output: "data", format: "yaml")
113
+ def self.fetch(source = nil, output: "data", format: "yaml", prune: false,
114
+ digest_ledger: false)
37
115
  t1 = Time.now
38
116
  puts "Started at: #{t1}"
39
117
  FileUtils.mkdir_p output
40
- result = new(output, format).fetch(source)
118
+ fetcher = new(output, format)
119
+ # Only touched in prune mode: the default path stays exactly what it
120
+ # was, and existing ::fetch callers (and their specs) see no new call.
121
+ fetcher.prune = true if prune
122
+ fetcher.digest_ledger = digest_ledger
123
+ result = fetcher.fetch(source)
124
+ fetcher.prune_unwritten if prune
125
+ fetcher.save_ledger if digest_ledger
41
126
  t2 = Time.now
42
127
  puts "Stopped at: #{t2}"
43
128
  puts "Done in: #{(t2 - t1).round} sec."
@@ -187,6 +272,30 @@ module Relaton
187
272
  force_write reserve(digest_output_file(docid), docid), content
188
273
  end
189
274
 
275
+ # Delete output files this run did not write — the replacement for a
276
+ # crawler's `rm -rf data` when the data tree is kept between runs. A
277
+ # partial crawl must never wipe the dataset, so when the run wrote less
278
+ # than +min_ratio+ of the existing corpus nothing is deleted.
279
+ #
280
+ # @param min_ratio [Float]
281
+ # @return [Array<String>] deleted paths
282
+ def prune_unwritten(min_ratio: 0.9)
283
+ existing = Dir.glob(File.join(@output, "**", "*.#{@ext}"))
284
+ return [] if existing.empty?
285
+
286
+ written = existing.count { |file| @written.include?(file) }
287
+ if written < existing.size * min_ratio
288
+ warn "prune skipped: #{written}/#{existing.size} written " \
289
+ "(threshold #{(existing.size * min_ratio).ceil})"
290
+ return []
291
+ end
292
+
293
+ stale = existing - @written.to_a
294
+ stale.each { |file| File.delete(file) }
295
+ warn "pruned #{stale.size} stale file(s)" unless stale.empty?
296
+ stale
297
+ end
298
+
190
299
  #
191
300
  # Serialize bibliographic item
192
301
  #
@@ -212,9 +321,39 @@ module Relaton
212
321
 
213
322
  private
214
323
 
324
+ def ledger_path
325
+ File.join @output, LEDGER_FILE
326
+ end
327
+
328
+ def ledger
329
+ @ledger ||= if File.exist?(ledger_path)
330
+ YAML.safe_load_file(ledger_path) || {}
331
+ else
332
+ {}
333
+ end
334
+ end
335
+
336
+ def ledger_new
337
+ @ledger_new ||= {}
338
+ end
339
+
340
+ def entry_digest(entry)
341
+ require "digest"
342
+ require "json"
343
+ Digest::SHA256.hexdigest JSON.generate(entry)
344
+ end
345
+
215
346
  # Write, clobbering whatever is there. The caller has already established
216
- # that the path is ours to take.
347
+ # that the path is ours to take. Identical content is left untouched:
348
+ # an incremental crawl (one that keeps the data tree) must not churn
349
+ # mtimes or rewrite bytes for a record that did not change. The file is
350
+ # still recorded as written, so #prune_unwritten keeps it.
217
351
  def force_write(file, content)
352
+ if File.exist?(file) && File.read(file, encoding: "UTF-8") == content
353
+ @written << file
354
+ return file
355
+ end
356
+
218
357
  File.write file, content, encoding: "UTF-8"
219
358
  @written << file
220
359
  file