iev 0.4.6 → 0.4.8

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/scheduled_tasks.lock +1 -0
  3. data/.rubocop_todo.yml +5 -47
  4. data/CLAUDE.md +11 -1
  5. data/Gemfile +4 -2
  6. data/TODO.reconcile/01-design.md +57 -0
  7. data/TODO.reconcile/02-change-models.md +44 -0
  8. data/TODO.reconcile/02-termbase-loader.md +48 -0
  9. data/TODO.reconcile/03-live-loader.md +28 -0
  10. data/TODO.reconcile/03-status-mapper.md +24 -0
  11. data/TODO.reconcile/04-concept-merger.md +45 -0
  12. data/TODO.reconcile/04-termbase-loader.md +28 -0
  13. data/TODO.reconcile/05-content-diff.md +45 -0
  14. data/TODO.reconcile/05-live-loader.md +20 -0
  15. data/TODO.reconcile/06-content-differ.md +32 -0
  16. data/TODO.reconcile/06-v3-serializer.md +42 -0
  17. data/TODO.reconcile/07-cli-and-run.md +27 -0
  18. data/TODO.reconcile/07-concept-merger.md +32 -0
  19. data/TODO.reconcile/08-report.md +53 -0
  20. data/TODO.reconcile/09-pipeline.md +23 -0
  21. data/TODO.reconcile/10-script-and-run.md +15 -0
  22. data/data/locales/glossarist_enums.yml +705 -0
  23. data/iev.gemspec +4 -2
  24. data/lib/iev/bibliography_builder.rb +87 -0
  25. data/lib/iev/cli/command.rb +29 -0
  26. data/lib/iev/cli/command_helper.rb +1 -6
  27. data/lib/iev/config.rb +10 -1
  28. data/lib/iev/exporter.rb +37 -4
  29. data/lib/iev/figure_builder.rb +186 -0
  30. data/lib/iev/multi_doc_yaml.rb +60 -0
  31. data/lib/iev/reconciler/change.rb +29 -0
  32. data/lib/iev/reconciler/change_set.rb +48 -0
  33. data/lib/iev/reconciler/concept_merger.rb +130 -0
  34. data/lib/iev/reconciler/content_differ.rb +157 -0
  35. data/lib/iev/reconciler/live_loader.rb +163 -0
  36. data/lib/iev/reconciler/pipeline.rb +104 -0
  37. data/lib/iev/reconciler/reconciled_concept.rb +14 -0
  38. data/lib/iev/reconciler/report.rb +118 -0
  39. data/lib/iev/reconciler/status_mapper.rb +29 -0
  40. data/lib/iev/reconciler/term_marker_parser.rb +196 -0
  41. data/lib/iev/reconciler/termbase_loader.rb +141 -0
  42. data/lib/iev/reconciler.rb +17 -0
  43. data/lib/iev/scraper/browser.rb +155 -50
  44. data/lib/iev/scraper/page_parser.rb +116 -24
  45. data/lib/iev/source_parser.rb +12 -1
  46. data/lib/iev/subject_area_concepts.rb +4 -2
  47. data/lib/iev/utilities.rb +7 -5
  48. data/lib/iev/version.rb +1 -1
  49. data/lib/iev.rb +4 -0
  50. data/scripts/find_unparseable.rb +22 -0
  51. metadata +54 -6
@@ -4,13 +4,14 @@ module Iev
4
4
  class Scraper
5
5
  # Parses an Electropedia HTML page into a concept data hash.
6
6
  #
7
- # The Electropedia HTML structure is a table with rows for each language:
8
- # - Language row: <div align="center"><font color="#800080">en</font></div>
9
- # - Term cell: <b>term text</b> in the third <td>
10
- # - Definition row: next row's third <td> (if present)
11
- # - Empty/separator rows with <hr> or spacer images
7
+ # All extracted text (terms, definitions, notes, examples) is run
8
+ # through Iev::Utilities' semantic enrichment pipeline, converting
9
+ # HTML to AsciiDoc notation: <i>x</i> → stem:[x], <b>x</b> → *x*,
10
+ # <a href=IEV...> → {{urn:...}}, MathML → AsciiMath. This ensures
11
+ # the parsed output matches the format used in termbase.yaml.
12
12
  class PageParser
13
- # Map Electropedia HTML language codes to ISO 639-2/3 three-char codes.
13
+ include Utilities
14
+
14
15
  LANG_CODE_MAP = {
15
16
  "en" => "eng",
16
17
  "fr" => "fra",
@@ -54,28 +55,48 @@ module Iev
54
55
  private
55
56
 
56
57
  def find_iev_ref
57
- # Find the IEV reference cell to confirm the page is valid
58
58
  @doc.at_css("b:contains('#{@code}')") ||
59
59
  @doc.at_xpath("//td/b[contains(text(), '#{@code}')]")
60
60
  end
61
61
 
62
+ def term_domain
63
+ @code.rpartition("-").first
64
+ end
65
+
62
66
  def localized_concepts
63
67
  result = {}
64
- lang_sections.each do |lang, term_row, def_row|
68
+ lang_sections.each do |lang, area, term_row, def_row|
65
69
  term = extract_term(term_row)
66
70
  next unless term
67
71
 
68
- entry = { "term" => term }
72
+ entry = result[lang] || { "terms" => [] }
73
+ term_entry = { "designation" => term }
74
+ term_entry["geographical_area"] = area if area
75
+ entry["terms"] << term_entry
76
+
69
77
  definition = extract_definition(def_row)
70
- entry["definition"] = definition if definition
78
+ entry["definition"] = definition if definition && !entry.key?("definition")
71
79
 
72
80
  result[lang] = entry
73
81
  end
82
+
83
+ # Convert terms array to single "term" field for backward compat
84
+ # when there's only one term and no area
85
+ result.each_value do |entry|
86
+ terms = entry.delete("terms")
87
+ if terms.size == 1 && !terms.first["geographical_area"]
88
+ entry["term"] = terms.first["designation"]
89
+ else
90
+ entry["term"] = terms.map { |t| t["designation"] }.join("\n")
91
+ entry["term_areas"] = terms.each_with_object({}) do |t, h|
92
+ area = t["geographical_area"]
93
+ h[t["designation"]] = area if area
94
+ end
95
+ end
96
+ end
74
97
  result
75
98
  end
76
99
 
77
- # Finds all language sections in the table.
78
- # Returns array of [lang_code, term_row, definition_row] tuples.
79
100
  def lang_sections
80
101
  sections = []
81
102
  rows = content_rows
@@ -84,17 +105,30 @@ module Iev
84
105
  lang = extract_lang(row)
85
106
  next unless lang
86
107
 
87
- # The definition is in the next non-empty, non-separator row
108
+ area = extract_area(row)
88
109
  def_row = find_definition_row(rows, idx + 1)
89
- sections << [lang, row, def_row]
110
+ sections << [lang, area, row, def_row]
90
111
  end
91
112
 
92
113
  sections
93
114
  end
94
115
 
116
+ def extract_area(row)
117
+ tds = row.css("td")
118
+ return nil if tds.length < 2
119
+
120
+ area_cell = tds[1]
121
+ font = area_cell.at_css("font[color='#800080']")
122
+ return nil unless font
123
+
124
+ text = font.text.strip.downcase
125
+ return nil if text.empty?
126
+ return nil if text.match?(/\A(nl|be|nb|nn|br|pt|fr|de|us|gb|ch|at|ca|au|nz)\z/i) == false
127
+
128
+ text
129
+ end
130
+
95
131
  def content_rows
96
- # Find the main content table (the one with language data)
97
- # It's the largest table with IEV data
98
132
  tables = @doc.css("table")
99
133
  content_table = tables.max_by { |t| t.css("tr").length }
100
134
  content_table ? content_table.css("tr").to_a : []
@@ -109,15 +143,30 @@ module Iev
109
143
  end
110
144
 
111
145
  def extract_term(row)
112
- # Term is in the third <td> — may be in a <b> tag (en, fr) or plain text
113
146
  tds = row.css("td")
114
147
  return nil if tds.length < 3
115
148
 
116
149
  content_td = tds[2]
150
+ return nil if area_marker_cell?(content_td)
151
+
117
152
  bold = content_td.at_css("b")
118
153
 
119
- term = bold ? bold.text.strip : content_td.text.strip
120
- term.empty? ? nil : term
154
+ html = if bold
155
+ bold.inner_html.strip
156
+ else
157
+ content_td.inner_html.strip
158
+ end
159
+ return nil if html.empty?
160
+
161
+ enrich(html)
162
+ end
163
+
164
+ # True when the cell contains only a geographical-area marker font
165
+ # tag (e.g. <font color="#800080">nb</font>), not a real term.
166
+ def area_marker_cell?(cell)
167
+ font = cell.at_css("font[color='#800080']")
168
+ return false unless font
169
+ cell.at_css("b").nil? && cell.text.strip == font.text.strip && font.text.strip.length <= 3
121
170
  end
122
171
 
123
172
  def extract_definition(row)
@@ -127,15 +176,59 @@ module Iev
127
176
  return nil if tds.length < 3
128
177
 
129
178
  content_td = tds[2]
130
- # The definition is the text content, which may include MathML
131
179
  html = content_td.inner_html.strip
132
180
  return nil if html.empty? || html.match?(/\A<img.*ecblank/)
133
181
 
134
- html
182
+ enrich(html)
183
+ end
184
+
185
+ def enrich(html_text)
186
+ text = html_text.to_s
187
+ .gsub(/&lt;i&gt;/, "<i>")
188
+ .gsub(/&lt;\/i&gt;/, "</i>")
189
+ mathml_blocks, text = extract_mathml_blocks(text)
190
+ result = parse_anchor_tag(text, term_domain)
191
+ result = restore_and_convert_mathml(result, mathml_blocks)
192
+ result = replace_newlines(result)
193
+ result = convert_literal_italic(result)
194
+ result.strip
195
+ end
196
+
197
+ MATHML_PLACEHOLDER = "@@MATHML_%d@@"
198
+
199
+ def extract_mathml_blocks(text)
200
+ blocks = []
201
+ result = text.gsub(/<math[^>]*>.*?<\/math>/m) do |match|
202
+ blocks << match
203
+ format(MATHML_PLACEHOLDER, blocks.size - 1)
204
+ end
205
+ [blocks, result]
206
+ end
207
+
208
+ # Restore MathML blocks and convert each to AsciiMath individually.
209
+ # If the converter returns empty (unsupported expression like
210
+ # <mtable>), keep the original MathML so the formula is not lost.
211
+ def restore_and_convert_mathml(text, blocks)
212
+ return text if blocks.empty?
213
+
214
+ blocks.each_with_index do |mathml, i|
215
+ converted = Iev::Converter.mathml_to_asciimath(mathml).strip
216
+ replacement = converted.empty? ? mathml : converted
217
+ text = text.sub(format(MATHML_PLACEHOLDER, i), replacement)
218
+ end
219
+ text
220
+ end
221
+
222
+ # After Nokogiri processing, &lt;i&gt;entity-encoded tags appear as
223
+ # literal <i>...</i> text. Convert them to stem:[...] just like
224
+ # real <i> elements are converted by convert_italic.
225
+ def convert_literal_italic(text)
226
+ text.gsub(/<i>([^<]*)<\/i>/) do
227
+ inner = Regexp.last_match(1)
228
+ convert_italic(inner)
229
+ end
135
230
  end
136
231
 
137
- # Find the definition row following a language row.
138
- # Skip separator rows (empty, <hr>, or spacer images).
139
232
  def find_definition_row(rows, start_idx)
140
233
  return nil if start_idx >= rows.length
141
234
 
@@ -149,7 +242,6 @@ module Iev
149
242
  content = tds[2].inner_html.strip
150
243
  return nil if content.empty?
151
244
 
152
- # Skip rows that are only spacer images (unless they have <b> content)
153
245
  if content.match?(/\A<img.*ecblank/) && !content.include?("<b>")
154
246
  return nil
155
247
  end
@@ -406,7 +406,18 @@ module Iev
406
406
  return nil unless item
407
407
 
408
408
  item.source("src")
409
- rescue Relaton::RequestError, Socket::ResolutionError, SocketError => e
409
+ # Free-text refs like "ITU-R Rec. 431 MOD" are not parseable pubids.
410
+ # relaton 3 (pubid 2) raises Pubid::Errors::ParseError (a
411
+ # Parslet::ParseFailed) eagerly when building the search, whereas
412
+ # relaton 2 swallowed it. Link resolution is best-effort: warn and
413
+ # skip instead of failing the whole source parse.
414
+ # relaton 3.0.0.pre.alpha.6 (#205 strict routing) also raises
415
+ # UnknownReferenceError for spellings no flavor claims (the French
416
+ # "CEI", agency refs like "IAEA 4") where alpha.5's permissive
417
+ # parse answered nil. Same best-effort contract: warn and skip.
418
+ rescue Relaton::RequestError, Relaton::UnknownReferenceError,
419
+ Socket::ResolutionError, SocketError,
420
+ Parslet::ParseFailed => e
410
421
  warn e.message
411
422
  nil
412
423
  end
@@ -128,10 +128,12 @@ module Iev
128
128
 
129
129
  # --- RelatedConcept factory methods ---
130
130
 
131
+ # RelatedConcept#content is a lang-keyed hash since glossarist 2.13
132
+ # (L10N invariant: all relationship free-text is { "eng" => ... }).
131
133
  def broader_relation(target_uri)
132
134
  Glossarist::RelatedConcept.new(
133
135
  type: "broader",
134
- content: target_uri,
136
+ content: { "eng" => target_uri },
135
137
  ref: Glossarist::ConceptRef.new(source: "IEV", id: target_uri),
136
138
  )
137
139
  end
@@ -139,7 +141,7 @@ module Iev
139
141
  def narrower_relation(target_uri)
140
142
  Glossarist::RelatedConcept.new(
141
143
  type: "narrower",
142
- content: target_uri,
144
+ content: { "eng" => target_uri },
143
145
  ref: Glossarist::ConceptRef.new(source: "IEV", id: target_uri),
144
146
  )
145
147
  end
data/lib/iev/utilities.rb CHANGED
@@ -92,7 +92,9 @@ module Iev
92
92
  when "img"
93
93
  src = node["src"] || node.attributes.keys.first.to_s
94
94
  "#{IMAGE_PATH_PREFIX}/#{term_domain}/#{src}[]"
95
- when "p", "div", "span"
95
+ when "p"
96
+ "#{inner}\n"
97
+ when "div", "span"
96
98
  inner
97
99
  when "i"
98
100
  convert_italic(inner)
@@ -101,9 +103,9 @@ module Iev
101
103
  when "sup"
102
104
  inner.empty? ? "" : "^#{inner}^"
103
105
  when "ol"
104
- convert_list(node, ". ")
106
+ convert_list(node, ". ", term_domain)
105
107
  when "ul"
106
- convert_list(node, "* ")
108
+ convert_list(node, "* ", term_domain)
107
109
  when "li"
108
110
  inner
109
111
  when "font"
@@ -124,8 +126,8 @@ module Iev
124
126
  end
125
127
  end
126
128
 
127
- def convert_list(node, prefix)
128
- node.css("li").map { |li| "#{prefix}#{li.text}" }.join
129
+ def convert_list(node, prefix, term_domain)
130
+ node.css("li").map { |li| "#{prefix}#{nodes_to_adoc(li.children, term_domain)}" }.join("\n")
129
131
  end
130
132
 
131
133
  def convert_font(node, inner)
data/lib/iev/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Iev
4
- VERSION = "0.4.6"
4
+ VERSION = "0.4.8"
5
5
  end
data/lib/iev.rb CHANGED
@@ -21,6 +21,7 @@ module Iev
21
21
  # IEV dataset URN — single source of truth for all concept references.
22
22
  IEV_SOURCE = "urn:iec:std:iec:60050"
23
23
 
24
+ autoload :BibliographyBuilder, "iev/bibliography_builder"
24
25
  autoload :Cli, "iev/cli"
25
26
  autoload :Config, "iev/config"
26
27
  autoload :Converter, "iev/converter"
@@ -28,9 +29,12 @@ module Iev
28
29
  autoload :DataSource, "iev/data_source"
29
30
  autoload :DbWriter, "iev/db_writer"
30
31
  autoload :Exporter, "iev/exporter"
32
+ autoload :FigureBuilder, "iev/figure_builder"
31
33
  autoload :IevCode, "iev/iev_code"
32
34
  autoload :Iso639Code, "iev/iso_639_code"
35
+ autoload :MultiDocYaml, "iev/multi_doc_yaml"
33
36
  autoload :Profiler, "iev/profiler"
37
+ autoload :Reconciler, "iev/reconciler"
34
38
  autoload :RelatonDb, "iev/relaton_db"
35
39
  autoload :Scraper, "iev/scraper"
36
40
  autoload :Section, "iev/section"
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+ # Identify cached HTML pages that PageParser cannot turn into a concept.
3
+ # Run: bundle exec ruby -Ilib scripts/find_unparseable.rb
4
+ require "iev"
5
+
6
+ store = Iev::Fetcher::PageStore.new
7
+ ok = 0
8
+ failed = []
9
+ store.each_concept(scope: Iev::Fetcher::Scope.all).each do |code, html|
10
+ doc = Nokogiri::HTML(html)
11
+ if Iev::Scraper::PageParser.new(doc, code).parse
12
+ ok += 1
13
+ else
14
+ failed << code
15
+ end
16
+ end
17
+
18
+ puts "Parsed OK: #{ok}"
19
+ puts "Failed: #{failed.size}"
20
+ puts
21
+ puts "First 20 failed codes:"
22
+ failed.take(20).each { |c| puts " #{c}" }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iev
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.6
4
+ version: 0.4.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: creek
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - ">="
46
46
  - !ruby/object:Gem::Version
47
- version: 2.8.2
47
+ version: 2.8.15
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
- version: 2.8.2
54
+ version: 2.8.15
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: lutaml-model
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -103,7 +103,7 @@ dependencies:
103
103
  version: 2.0.0
104
104
  - - "<"
105
105
  - !ruby/object:Gem::Version
106
- version: '3'
106
+ version: '4'
107
107
  type: :runtime
108
108
  prerelease: false
109
109
  version_requirements: !ruby/object:Gem::Requirement
@@ -113,7 +113,21 @@ dependencies:
113
113
  version: 2.0.0
114
114
  - - "<"
115
115
  - !ruby/object:Gem::Version
116
- version: '3'
116
+ version: '4'
117
+ - !ruby/object:Gem::Dependency
118
+ name: rubyzip
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '3.7'
124
+ type: :runtime
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '3.7'
117
131
  - !ruby/object:Gem::Dependency
118
132
  name: sequel
119
133
  requirement: !ruby/object:Gem::Requirement
@@ -178,6 +192,7 @@ executables:
178
192
  extensions: []
179
193
  extra_rdoc_files: []
180
194
  files:
195
+ - ".claude/scheduled_tasks.lock"
181
196
  - ".github/workflows/rake.yml"
182
197
  - ".github/workflows/release.yml"
183
198
  - ".gitignore"
@@ -189,12 +204,30 @@ files:
189
204
  - LICENSE.txt
190
205
  - README.adoc
191
206
  - Rakefile
207
+ - TODO.reconcile/01-design.md
208
+ - TODO.reconcile/02-change-models.md
209
+ - TODO.reconcile/02-termbase-loader.md
210
+ - TODO.reconcile/03-live-loader.md
211
+ - TODO.reconcile/03-status-mapper.md
212
+ - TODO.reconcile/04-concept-merger.md
213
+ - TODO.reconcile/04-termbase-loader.md
214
+ - TODO.reconcile/05-content-diff.md
215
+ - TODO.reconcile/05-live-loader.md
216
+ - TODO.reconcile/06-content-differ.md
217
+ - TODO.reconcile/06-v3-serializer.md
218
+ - TODO.reconcile/07-cli-and-run.md
219
+ - TODO.reconcile/07-concept-merger.md
220
+ - TODO.reconcile/08-report.md
221
+ - TODO.reconcile/09-pipeline.md
222
+ - TODO.reconcile/10-script-and-run.md
192
223
  - bin/console
193
224
  - bin/setup
225
+ - data/locales/glossarist_enums.yml
194
226
  - data/subject_areas.yaml
195
227
  - exe/iev
196
228
  - iev.gemspec
197
229
  - lib/iev.rb
230
+ - lib/iev/bibliography_builder.rb
198
231
  - lib/iev/cli.rb
199
232
  - lib/iev/cli/command.rb
200
233
  - lib/iev/cli/command_helper.rb
@@ -206,10 +239,24 @@ files:
206
239
  - lib/iev/data_source.rb
207
240
  - lib/iev/db_writer.rb
208
241
  - lib/iev/exporter.rb
242
+ - lib/iev/figure_builder.rb
209
243
  - lib/iev/iev_code.rb
210
244
  - lib/iev/iso_639_2.yaml
211
245
  - lib/iev/iso_639_code.rb
246
+ - lib/iev/multi_doc_yaml.rb
212
247
  - lib/iev/profiler.rb
248
+ - lib/iev/reconciler.rb
249
+ - lib/iev/reconciler/change.rb
250
+ - lib/iev/reconciler/change_set.rb
251
+ - lib/iev/reconciler/concept_merger.rb
252
+ - lib/iev/reconciler/content_differ.rb
253
+ - lib/iev/reconciler/live_loader.rb
254
+ - lib/iev/reconciler/pipeline.rb
255
+ - lib/iev/reconciler/reconciled_concept.rb
256
+ - lib/iev/reconciler/report.rb
257
+ - lib/iev/reconciler/status_mapper.rb
258
+ - lib/iev/reconciler/term_marker_parser.rb
259
+ - lib/iev/reconciler/termbase_loader.rb
213
260
  - lib/iev/relaton_db.rb
214
261
  - lib/iev/scraper.rb
215
262
  - lib/iev/scraper/browser.rb
@@ -224,6 +271,7 @@ files:
224
271
  - lib/iev/term_builder.rb
225
272
  - lib/iev/utilities.rb
226
273
  - lib/iev/version.rb
274
+ - scripts/find_unparseable.rb
227
275
  homepage: https://github.com/glossarist/iev
228
276
  licenses:
229
277
  - BSD-2-Clause