bipm-data-importer 0.2.2 → 0.4.0

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +68 -9
  3. data/TODO.refactor/01-remove-debug-code.md +18 -0
  4. data/TODO.refactor/02-fix-gemspec.md +21 -0
  5. data/TODO.refactor/03-update-readme.md +28 -0
  6. data/TODO.refactor/04-replace-require-relative-with-autoload.md +34 -0
  7. data/TODO.refactor/05-fix-outcomes-bugs.md +22 -0
  8. data/TODO.refactor/06-unify-clause-taxonomy.md +31 -0
  9. data/TODO.refactor/07-extract-french-parsing.md +21 -0
  10. data/TODO.refactor/08-extract-data-quirks.md +25 -0
  11. data/TODO.refactor/09-introduce-body-registry.md +23 -0
  12. data/TODO.refactor/10-introduce-strategy-pattern.md +34 -0
  13. data/TODO.refactor/11-extract-cli-class.md +20 -0
  14. data/TODO.refactor/12-specs-common.md +23 -0
  15. data/TODO.refactor/13-specs-cgpm.md +22 -0
  16. data/TODO.refactor/14-specs-asciimath.md +23 -0
  17. data/TODO.refactor/15-specs-outcomes.md +24 -0
  18. data/TODO.refactor/16-quarantine-failing-specs.md +23 -0
  19. data/TODO.refactor/17-typed-domain-models.md +32 -0
  20. data/TODO.refactor/README.md +62 -0
  21. data/bipm-data-importer.gemspec +11 -7
  22. data/exe/bipm-fetch +2 -440
  23. data/lib/bipm/data/importer/bodies.rb +45 -0
  24. data/lib/bipm/data/importer/body.rb +43 -0
  25. data/lib/bipm/data/importer/cgpm.rb +21 -0
  26. data/lib/bipm/data/importer/clauses.rb +239 -0
  27. data/lib/bipm/data/importer/cli.rb +83 -0
  28. data/lib/bipm/data/importer/common.rb +31 -120
  29. data/lib/bipm/data/importer/fetcher.rb +62 -0
  30. data/lib/bipm/data/importer/language.rb +66 -0
  31. data/lib/bipm/data/importer/quirks.rb +73 -0
  32. data/lib/bipm/data/importer/strategies/base.rb +40 -0
  33. data/lib/bipm/data/importer/strategies/spa_meetings.rb +340 -0
  34. data/lib/bipm/data/importer/strategies/static_index.rb +324 -0
  35. data/lib/bipm/data/importer/strategies.rb +13 -0
  36. data/lib/bipm/data/importer/text/french.rb +49 -0
  37. data/lib/bipm/data/importer/text.rb +11 -0
  38. data/lib/bipm/data/importer/version.rb +1 -1
  39. data/lib/bipm/data/importer.rb +31 -4
  40. data/lib/bipm/data/outcomes/action.rb +1 -1
  41. data/lib/bipm/data/outcomes/approval.rb +1 -1
  42. data/lib/bipm/data/outcomes/body.rb +1 -1
  43. data/lib/bipm/data/outcomes/consideration.rb +1 -1
  44. data/lib/bipm/data/outcomes/localized_body.rb +1 -1
  45. data/lib/bipm/data/outcomes/meeting.rb +2 -2
  46. data/lib/bipm/data/outcomes/resolution.rb +1 -1
  47. data/lib/bipm/data/outcomes.rb +9 -13
  48. data/lib/bipm-data-importer.rb +1 -1
  49. metadata +46 -19
  50. data/.hound.yml +0 -5
  51. data/.rspec +0 -3
  52. data/.rubocop.yml +0 -10
  53. data/exe/bipm-fetch-cgpm +0 -3
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bipm
4
+ module Data
5
+ module Importer
6
+ # First-class language value object. English and French are equal
7
+ # citizens — neither is the canonical form, neither is a translation
8
+ # of the other. Both directions of any linguistic operation must be
9
+ # expressible through this type without one being the "default".
10
+ class Language
11
+ attr_reader :code
12
+
13
+ def initialize(code)
14
+ @code = code
15
+ end
16
+
17
+ EN = new(:en)
18
+ FR = new(:fr)
19
+ ALL = [EN, FR].freeze
20
+
21
+ def self.find(name)
22
+ sym = name.to_sym
23
+ ALL.find { |l| l.code == sym } ||
24
+ raise(ArgumentError, "unknown language: #{name.inspect} (supported: #{ALL.map(&:to_s).inspect})")
25
+ end
26
+
27
+ def self.all
28
+ ALL
29
+ end
30
+
31
+ def to_s
32
+ @code.to_s
33
+ end
34
+
35
+ def to_sym
36
+ @code
37
+ end
38
+
39
+ def suffix
40
+ case @code
41
+ when :en then ""
42
+ when :fr then "-fr"
43
+ else raise(ArgumentError, "no suffix defined for #{@code}")
44
+ end
45
+ end
46
+
47
+ def directory_suffix
48
+ case @code
49
+ when :en then "-en"
50
+ when :fr then "-fr"
51
+ else raise(ArgumentError, "no directory suffix for #{@code}")
52
+ end
53
+ end
54
+
55
+ def eql?(other)
56
+ other.is_a?(Language) && @code == other.code
57
+ end
58
+ alias == eql?
59
+
60
+ def hash
61
+ @code.hash
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bipm
4
+ module Data
5
+ module Importer
6
+ # Registry of upstream-data patches. Each quirk documents a specific
7
+ # mistake on bipm.org (or in a cassette) and the transformation that
8
+ # corrects it. Adding a new quirk = adding a method here, not editing
9
+ # the scrape loop.
10
+ #
11
+ # Categories:
12
+ # - HREF_TRANSFORMS: URL path corrections applied unconditionally.
13
+ # - per-meeting predicates/rewrites that depend on (body, lang, id).
14
+ module Quirks
15
+ HREF_TRANSFORMS = [
16
+ # Legacy URL typo: /jen/ should be /en/.
17
+ ->(href) { href.gsub(%r{\A/jen/}, "/en/") },
18
+ # Legacy CGPM document database path.
19
+ ->(href) { href.gsub(%r{\A/en/CGPM/jsp/}, "/en/CGPM/db/") },
20
+ # CIPM fr recommendation listing points to the wrong meeting URL
21
+ # for two specific resolutions on the 106-2017 page.
22
+ ->(href) do
23
+ next href unless href =~ %r{/ci/cipm/106-2017/resolution-[12]\z}
24
+ href.gsub("/106-2017/", "/104-_1-2015/")
25
+ end,
26
+ # CIPM 104-2015 actually lives at 104-_1-2015.
27
+ ->(href) { href.gsub("/104-2015/", "/104-_1-2015/") },
28
+ # Normalise the legacy Liferay /web/guest/ prefix to the locale.
29
+ # (Caller supplies the language via Quirks.localize_guest_path.)
30
+ ].freeze
31
+
32
+ def self.fix_href(href, language: nil)
33
+ href = href.gsub(%r{\Ahttps://www\.bipm\.org}, "")
34
+ if language && href.include?("/web/guest/")
35
+ href = href.gsub("/web/guest/", "/#{language}/")
36
+ end
37
+ HREF_TRANSFORMS.reduce(href) { |h, fn| fn.call(h) }
38
+ end
39
+
40
+ # CIPM 104-_2-2015 is a duplicate of 104-_1-2015; clear the year
41
+ # so the scrape skips the recommendations branch for it.
42
+ def self.skip_meeting_year?(body_id, meeting_id)
43
+ [body_id, meeting_id] == [:cipm, "104-2"]
44
+ end
45
+
46
+ # CIPM fr/94-2005 lists resolution 2 twice on the website.
47
+ def self.deduplicate_resolutions(body_id, language, meeting_id, resolutions)
48
+ return resolutions unless [body_id, language.to_s, meeting_id] == [:cipm, "fr", "94"]
49
+ return resolutions unless resolutions.sort.uniq != resolutions.sort
50
+
51
+ (1..3).map do |i|
52
+ "https://www.bipm.org/fr/committees/ci/cipm/94-2005/resolution-#{i}"
53
+ end
54
+ end
55
+
56
+ # JCRB fr/43-2021 has duplicate identifiers for one action.
57
+ def self.renumber_jcrb_43_fr(resolutions)
58
+ ids = resolutions.map { |i| [i["type"], i["identifier"]] }.sort
59
+ return resolutions unless ids != ids.uniq
60
+
61
+ idx = 1
62
+ resolutions.map do |i|
63
+ next i unless i["identifier"] == "43-1" && i["type"] == "action"
64
+ i = i.dup
65
+ i["identifier"] = "#{i['identifier']}-xxx-#{idx}"
66
+ idx += 1
67
+ i
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Bipm
6
+ module Data
7
+ module Importer
8
+ module Strategies
9
+ # Abstract strategy. Subclasses implement `call` and override
10
+ # `recording_mode`. The Fetcher queries `recording_mode` to decide
11
+ # whether to disable VCR/WebMock before invoking — the CLI never
12
+ # touches VCR directly.
13
+ class Base
14
+ attr_reader :body, :base_dir, :languages
15
+
16
+ def initialize(body:, base_dir:, languages: Language.all)
17
+ @body = body
18
+ @base_dir = base_dir
19
+ @languages = languages.map { |l| l.is_a?(Language) ? l : Language.find(l) }
20
+ end
21
+
22
+ def call(agent:)
23
+ raise NotImplementedError, "#{self.class}#call not implemented"
24
+ end
25
+
26
+ # :live -> HTTP requests bypass VCR/WebMock entirely.
27
+ # :cassette -> VCR replays recorded responses (records new
28
+ # episodes when BIPM_VCR_RECORD is set).
29
+ def self.recording_mode
30
+ :cassette
31
+ end
32
+
33
+ def recording_mode
34
+ self.class.recording_mode
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mechanize"
4
+ require "nokogiri"
5
+ require "yaml"
6
+ require "json"
7
+ require "date"
8
+ require "fileutils"
9
+
10
+ module Bipm
11
+ module Data
12
+ module Importer
13
+ module Strategies
14
+ # Scrapes bodies rendered through BIPM's Liferay SPA. Each body
15
+ # exposes index/meetings/publications/(recommendations)/(outcomes)
16
+ # pages whose DOM uses the `.meetings-list__*`, `.bipm-resolutions`,
17
+ # `.bipm-decisions` selectors. Per-meeting and per-recommendation
18
+ # pages are linked from the meetings listing.
19
+ #
20
+ # All requests are wrapped in VCR cassettes for deterministic replays
21
+ # (set `BIPM_VCR_RECORD` env var to refresh).
22
+ class SpaMeetings < Base
23
+ def call(agent:)
24
+ languages.each { |lang| scrape_language(agent, lang) }
25
+ end
26
+
27
+ private
28
+
29
+ def scrape_language(agent, lang)
30
+ index = { "meetings" => Hash[languages.map { |l| [l.to_s, []] }],
31
+ "decisions" => Hash[languages.map { |l| [l.to_s, []] }] }
32
+
33
+ pages = fetch_pages(agent, lang)
34
+ process_meetings(agent, lang, pages, index)
35
+ write_meetings(lang, index)
36
+ write_publications(agent, lang, pages[:publications])
37
+ puts "* #{body.display_name}/#{lang} parsing done"
38
+ end
39
+
40
+ def fetch_pages(agent, lang)
41
+ base = body.url(lang)
42
+ {
43
+ index: get(agent, lang, "#{base}", "index"),
44
+ meetings: get(agent, lang, "#{base}/meetings", "meetings"),
45
+ publications: get(agent, lang, "#{base}/publications", "publications"),
46
+ recommendations: get_optional(agent, lang, "#{base}/recommendations", "recommendations"),
47
+ outcomes: get_optional(agent, lang, "#{base}/#{outcomes_subpath}", "outcomes"),
48
+ }
49
+ end
50
+
51
+ def outcomes_subpath
52
+ body.id == :cipm ? "outcomes" : "meeting-outcomes"
53
+ end
54
+
55
+ def get(agent, lang, url, kind)
56
+ VCR.use_cassette("#{cassette_scope}/#{body.id}-#{kind}#{lang.suffix}") { agent.get(url) }
57
+ end
58
+
59
+ def get_optional(agent, lang, url, kind)
60
+ VCR.use_cassette("#{cassette_scope}/#{body.id}-#{kind}#{lang.suffix}") { agent.get(url) }
61
+ rescue Mechanize::ResponseCodeError
62
+ nil
63
+ end
64
+
65
+ def cassette_scope
66
+ body.id.to_s
67
+ end
68
+
69
+ def process_meetings(agent, lang, pages, index)
70
+ meetings_page = pages[:meetings]
71
+ publications = pages[:publications]
72
+ recommendations = pages[:recommendations]
73
+ outcomes = pages[:outcomes]
74
+
75
+ meetings_page.css(".meetings-list__item").each do |meeting_div|
76
+ date = Common.extract_date(meeting_div.at_css(".meetings-list__informations-date").text)
77
+ title = meeting_div.at_css(".meetings-list__informations-title").text.strip
78
+ href = meeting_div.at_css(".meetings-list__informations-title").attr("href")
79
+ href = "/#{lang}" + href unless href.start_with?("/#{lang}/")
80
+
81
+ ident = href.split("/#{body.id}/").last.gsub("/", ".")
82
+ yr, wg, meeting_id = parse_meeting_identifiers(href)
83
+
84
+ meeting = VCR.use_cassette("#{cassette_scope}/#{body.id}-meeting-#{ident}#{lang.suffix}") do
85
+ agent.get(href)
86
+ end
87
+
88
+ yr = nil if Quirks.skip_meeting_year?(body.id, meeting_id)
89
+
90
+ if yr
91
+ build_meeting_entry(agent, lang, meeting, ident, yr, meeting_id, title, date, recommendations, index)
92
+ end
93
+
94
+ if meeting_id
95
+ build_decision_entry(lang, meeting, ident, meeting_id, title, date, wg, outcomes, index)
96
+ end
97
+ end
98
+ end
99
+
100
+ def parse_meeting_identifiers(href)
101
+ wg = nil
102
+ yr = href.include?("/wg/") ? nil : href.split("-").last
103
+ meeting_id =
104
+ if href.include?("/wg/")
105
+ wg = href.split("/wg/").last.split("/").first
106
+ href.split("/").last
107
+ else
108
+ parts = href.split("/").last.split("-")
109
+ if parts.length == 2
110
+ parts[0]
111
+ else
112
+ parts[0] + parts[1].sub("_", "-")
113
+ end
114
+ end
115
+ [yr, wg, meeting_id]
116
+ end
117
+
118
+ def build_meeting_entry(agent, lang, meeting, ident, yr, meeting_id, title, date, recommendations, index)
119
+ pdf = Common.extract_pdf(meeting, lang)
120
+
121
+ h = {
122
+ "metadata" => {
123
+ "title" => title,
124
+ "identifier" => meeting_id,
125
+ "date" => date.to_s,
126
+ "source" => "BIPM - Pavillon de Breteuil",
127
+ "url" => meeting.uri.to_s,
128
+ },
129
+ }
130
+ h["pdf"] = pdf if pdf
131
+
132
+ resolutions = meeting.css(".bipm-resolutions .publications__content").map do |res_div|
133
+ res_div.at_css("a").attr("href")
134
+ end
135
+
136
+ resolutions_additional =
137
+ recommendations&.css(".bipm-resolutions .publications__content")&.map do |res_div|
138
+ Quirks.fix_href(res_div.at_css("a").attr("href"), language: lang.to_s)
139
+ end&.select { |href| href.include?("/#{ident}/") } || []
140
+
141
+ resolutions = Quirks.deduplicate_resolutions(body.id, lang, meeting_id, resolutions)
142
+ resolutions = (resolutions + resolutions_additional).uniq
143
+
144
+ h["resolutions"] = resolutions.map do |href|
145
+ href = Quirks.fix_href(href, language: lang.to_s)
146
+ res_id = href.split("-").last.to_i
147
+ res = VCR.use_cassette("#{cassette_scope}/#{body.id}-recommendation-#{yr}-#{res_id}#{lang.suffix}") do
148
+ agent.get(href)
149
+ end
150
+ Common.parse_resolution(res, res_id, date, body.id, lang.to_s, "recommendation?")
151
+ end
152
+
153
+ index["meetings"][lang.to_s] << h
154
+ end
155
+
156
+ def build_decision_entry(lang, meeting, ident, meeting_id, title, date, wg, outcomes, index)
157
+ h = {
158
+ "metadata" => {
159
+ "title" => title,
160
+ "identifier" => meeting_id,
161
+ "date" => date.to_s,
162
+ "source" => "BIPM - Pavillon de Breteuil",
163
+ "url" => meeting.uri.to_s,
164
+ },
165
+ }
166
+ h["metadata"]["workgroup"] = wg if wg
167
+
168
+ decisions = meeting.css(".bipm-decisions .decisions")
169
+
170
+ if outcomes
171
+ decisions_additional = outcomes.css(".bipm-decisions .decisions").select do |i|
172
+ i["data-meeting_key"] == meeting_id ||
173
+ i["data-meeting_key"] == "#{meeting_id}-0" ||
174
+ i["data-meeting"] == meeting_id
175
+ end
176
+ decisions = decisions.to_a + decisions_additional.to_a
177
+ end
178
+
179
+ h["resolutions"] = decisions.map { |tr| build_decision(tr, meeting, lang, date) }
180
+ h["resolutions"] = Quirks.renumber_jcrb_43_fr(h["resolutions"]) if [body.id, lang.to_s, meeting_id] == [:jcrb, "fr", "43"]
181
+ h["resolutions"] = h["resolutions"].sort_by do |i|
182
+ [i["type"], i["identifier"].scan(/([0-9]+|[^0-9]+)/).map(&:first).map { |x| x =~ /[0-9]/ ? x.to_i : x }]
183
+ end
184
+
185
+ h["working_documents"] = meeting.css(".portlet-boundary_CommitteePublications_ .publications__content").map do |d|
186
+ build_working_document(d)
187
+ end
188
+
189
+ index["decisions"][lang.to_s] << h
190
+ end
191
+
192
+ def build_decision(titletr, meeting, lang, date)
193
+ title = titletr.at_css(".title-third").text.strip
194
+ type = classify_decision_title(title)
195
+ categories = JSON.parse(titletr.attr("data-decisioncategories") || "[]").map(&:strip).uniq
196
+
197
+ r = {
198
+ "dates" => [date.to_s],
199
+ "subject" => body.display_name,
200
+ "type" => type,
201
+ "title" => title,
202
+ "identifier" => "#{titletr.attr('data-meeting')}-#{titletr.attr('data-number')}",
203
+ "url" => meeting.uri.to_s,
204
+ "categories" => categories,
205
+ "considerations" => [],
206
+ "actions" => [],
207
+ }
208
+
209
+ contenttr = titletr.attr("data-text").to_s
210
+ if contenttr.empty?
211
+ contenttr = titletr.css("p").map(&:inner_html).join("\n")
212
+ end
213
+ contenttr = Nokogiri::HTML(contenttr)
214
+
215
+ Common.replace_links(contenttr, meeting, lang.to_s)
216
+ part = Common.ng_to_string(contenttr)
217
+ part = part.gsub(%r"<a name=\"haut\">(.*?)</a>"m, '\1')
218
+ parse = Nokogiri::HTML(part).text.strip
219
+
220
+ parse =~ /\A(((the|le|la|Le secrétaire du|Le président du|Les membres du|Le directeur du) +[BC][IG]PM( Director| President| Secretary| members)?|Dr May|W\.E\. May)[, ]+)/i
221
+ subject = $1
222
+ if subject
223
+ parse = parse[subject.length..-1]
224
+ part = part.gsub(/\A(<html><body>\n*<p>)#{Regexp.escape subject}/, '\1')
225
+ end
226
+
227
+ xparse = parse
228
+ (1..1024).each do |pass|
229
+ if try_clause!(r, "considerations", xparse, part, date) then break end
230
+ if try_clause!(r, "actions", xparse, part, date) then break end
231
+ case pass
232
+ when 1, 2, 3
233
+ xparse = xparse.gsub(/\A.*?(CIPM( President| in its meeting)?:?\n*|\((2018|CIPM)\)|de la 103e session) /m, "")
234
+ when 4
235
+ xparse = parse.gsub(/\A.*?, /m, "")
236
+ when 5
237
+ xparse = parse.gsub(/\A.*? (and|et) /m, "")
238
+ else
239
+ r["x-unparsed"] ||= []
240
+ r["x-unparsed"] << parse
241
+ break
242
+ end
243
+ end
244
+
245
+ r
246
+ end
247
+
248
+ def try_clause!(result, key, xparse, part, date)
249
+ map = key == "actions" ? Clauses::ACTIONS : Clauses::CONSIDERATIONS
250
+ map.each do |k, _|
251
+ if xparse =~ /\A#{Clauses::PREFIX}#{k}\b/i
252
+ result[key] << {
253
+ "type" => map[k],
254
+ "date_effective" => date.to_s,
255
+ "message" => Common.format_message(part),
256
+ }
257
+ return true
258
+ end
259
+ end
260
+ false
261
+ end
262
+
263
+ def classify_decision_title(title)
264
+ case title
265
+ when /\AD[eé]cision/ then "decision"
266
+ when /\AR[eé]solution/ then "resolution"
267
+ when /\AAction/ then "action"
268
+ when /\ARecomm[ae]ndation/ then "recommendation"
269
+ else "decision"
270
+ end
271
+ end
272
+
273
+ def build_working_document(d)
274
+ date_str = d.at_css(".publications__date")&.text&.strip
275
+ date_str = Date.parse(date_str).strftime("%d/%m/%Y") if date_str
276
+ {
277
+ "title" => d.at_css(".title-third").text.strip,
278
+ "pdf" => d.at_css(".title-third").attr("href"),
279
+ "description" => d.css(".publications__body").first.text.strip,
280
+ "author" => d.css(".publications__body").last.text.strip,
281
+ "date" => date_str,
282
+ }.compact
283
+ end
284
+
285
+ def write_meetings(lang, index)
286
+ parts = index["meetings"][lang.to_s] + index["decisions"][lang.to_s]
287
+ parts = parts.group_by { |i| [i["metadata"]["workgroup"].to_s, i["metadata"]["identifier"].to_s] }
288
+ parts = parts.sort_by { |(wg, i),| [wg, i.to_i, i] }.to_h
289
+
290
+ parts.each do |(wg, mid), hs|
291
+ h = {
292
+ "metadata" => hs.first["metadata"],
293
+ "pdf" => hs.first["pdf"],
294
+ "resolutions" => hs.map { |i| i["resolutions"] }.sum([]),
295
+ "working_documents" => hs.map { |i| i["working_documents"] || [] }.sum([]),
296
+ }
297
+
298
+ h.delete("pdf") unless h["pdf"]
299
+ h.delete("working_documents") if h["working_documents"].empty?
300
+
301
+ fn = if wg
302
+ "#{base_dir}/#{body.id}/workgroups/#{wg}/meetings#{lang.directory_suffix}/meeting-#{mid}.yml"
303
+ elsif body.id == :cgpm
304
+ "#{base_dir}/#{body.id}/meetings#{lang.directory_suffix}/meeting-#{format('%02d', mid.to_i)}.yml"
305
+ else
306
+ "#{base_dir}/#{body.id}/meetings#{lang.directory_suffix}/meeting-#{mid}.yml"
307
+ end
308
+
309
+ FileUtils.mkdir_p(File.dirname(fn))
310
+ File.write(fn, YAML.dump(h))
311
+ end
312
+ end
313
+
314
+ def write_publications(agent, lang, publications)
315
+ return unless publications
316
+ categories = publications.css(".publications").map do |i|
317
+ title = i.previous_element&.text&.strip || "Misc"
318
+ {
319
+ "title" => title,
320
+ "documents" => i.css(".publications__content").map do |d|
321
+ {
322
+ "title" => d.at_css(".title-third").text.strip.gsub(/\s+/, " "),
323
+ "pdf" => d.at_css(".title-third")&.attr("href")&.split("?")&.first,
324
+ }.compact
325
+ end,
326
+ }
327
+ end
328
+ return if categories.empty?
329
+
330
+ doc = {
331
+ "metadata" => { "url" => "#{body.url(lang)}/publications/" },
332
+ "categories" => categories,
333
+ }
334
+ File.write("#{base_dir}/#{body.id}/publications-#{lang}.yml", YAML.dump(doc))
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end
340
+ end