wp2txt 2.1.2 → 2.3.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.
@@ -0,0 +1,738 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "fileutils"
5
+ require "digest"
6
+ require "open3"
7
+ require "parallel"
8
+ require "time"
9
+ require "json"
10
+ require_relative "regex"
11
+ require_relative "section_extractor"
12
+ require_relative "version"
13
+
14
+ module Wp2txt
15
+ # Local metadata index (Tier 1) built from a multistream dump.
16
+ # Stores per-page categories, section headings, redirects, and the category
17
+ # hierarchy in SQLite, enabling offline exhaustive queries such as
18
+ # "all articles in category X that have a Plot section" without any API access.
19
+ class MetadataIndex
20
+ SCHEMA_VERSION = 2
21
+ CACHE_SUFFIX = "_meta.sqlite3"
22
+ NS_ARTICLE = 0
23
+ NS_CATEGORY = 14
24
+
25
+ attr_reader :db_path
26
+
27
+ def initialize(db_path)
28
+ @db_path = db_path
29
+ @db = nil
30
+ end
31
+
32
+ # Default index location for a given multistream file (mirrors IndexCache naming)
33
+ def self.path_for(multistream_path, cache_dir: nil)
34
+ dir = cache_dir || File.expand_path("~/.wp2txt/cache")
35
+ basename = File.basename(multistream_path, ".*").sub(/\.xml\z/, "")
36
+ path_hash = Digest::MD5.hexdigest(multistream_path)[0, 8]
37
+ File.join(dir, "#{basename}_#{path_hash}#{CACHE_SUFFIX}")
38
+ end
39
+
40
+ # Normalize a page title the way MediaWiki treats titles:
41
+ # underscores to spaces, trimmed, first letter capitalized
42
+ def self.normalize_title(name)
43
+ n = name.to_s.tr("_", " ").strip.squeeze(" ")
44
+ return n if n.empty?
45
+
46
+ n[0].upcase + n[1..].to_s
47
+ end
48
+
49
+ # Normalize a category name (same MediaWiki title rules as normalize_title)
50
+ def self.normalize_category(name)
51
+ normalize_title(name)
52
+ end
53
+
54
+ # Built metadata DBs for one language found in a cache directory, most
55
+ # recently built first. Used for cross-dump ATTACH resolution (Corpus)
56
+ # and langlinks sanity checks (LanglinksImporter).
57
+ # @return [Array<Hash>] [{db_path:, dump_name:, built_at:, built_with:}]
58
+ def self.cached_candidates(lang, cache_dir: nil)
59
+ dir = cache_dir || File.expand_path("~/.wp2txt/cache")
60
+ Dir.glob(File.join(dir, "#{lang}wiki-*#{CACHE_SUFFIX}")).filter_map do |path|
61
+ meta = read_metadata_file(path)
62
+ next unless meta && meta[:schema_version].to_i == SCHEMA_VERSION && meta[:built_at]
63
+
64
+ { db_path: path, dump_name: meta[:dump_name], built_at: meta[:built_at],
65
+ built_with: meta[:wp2txt_version] }
66
+ end.sort_by { |c| c[:built_at].to_s }.reverse
67
+ end
68
+
69
+ # Light, read-only metadata table read for a DB file we do not manage
70
+ def self.read_metadata_file(path)
71
+ db = SQLite3::Database.new(path, readonly: true)
72
+ result = {}
73
+ db.execute("SELECT key, value FROM metadata") { |key, value| result[key.to_sym] = value }
74
+ result
75
+ rescue SQLite3::Exception
76
+ nil
77
+ ensure
78
+ db&.close
79
+ end
80
+
81
+ # Remove wiki markup from a heading ('''bold''', [[link|label]], HTML tags)
82
+ def self.clean_heading(text)
83
+ t = text.gsub(/'{2,}/, "")
84
+ t = t.gsub(/\[\[(?:[^\]|]*\|)?([^\]]*)\]\]/) { ::Regexp.last_match(1) }
85
+ t.gsub(/<[^>]+>/, "").strip
86
+ end
87
+
88
+ # Expand a section name to its full alias group (bidirectional):
89
+ # "Plot" => ["Plot", "Synopsis", ...]; "Synopsis" => same group
90
+ def self.expand_section_names(name, alias_file: nil)
91
+ aliases = SectionExtractor::DEFAULT_ALIASES
92
+ if alias_file
93
+ custom = SectionExtractor.load_aliases_from_file(alias_file)
94
+ aliases = aliases.merge(custom) unless custom.empty?
95
+ end
96
+
97
+ down = name.downcase
98
+ aliases.each do |canonical, list|
99
+ group = [canonical, *list]
100
+ return group if group.any? { |g| g.downcase == down }
101
+ end
102
+ [name]
103
+ end
104
+
105
+ # ------------------------------------------------------------------
106
+ # Status
107
+ # ------------------------------------------------------------------
108
+
109
+ # True if the index file exists and has a compatible schema
110
+ def built?
111
+ return false unless File.exist?(@db_path)
112
+
113
+ meta = read_metadata
114
+ !meta.nil? && meta[:schema_version].to_i == SCHEMA_VERSION && !meta[:built_at].nil?
115
+ rescue SQLite3::Exception
116
+ false
117
+ end
118
+
119
+ # True if the index was built from the given (unchanged) multistream file
120
+ def valid_for?(multistream_path)
121
+ return false unless built?
122
+ return false unless File.exist?(multistream_path)
123
+
124
+ meta = read_metadata
125
+ stat = File.stat(multistream_path)
126
+ meta[:source_size].to_i == stat.size && meta[:source_mtime].to_i == stat.mtime.to_i
127
+ end
128
+
129
+ def stats
130
+ return nil unless File.exist?(@db_path)
131
+
132
+ meta = read_metadata || {}
133
+ {
134
+ db_path: @db_path,
135
+ db_size: File.size(@db_path),
136
+ dump_name: meta[:dump_name],
137
+ built_at: meta[:built_at],
138
+ built_with: meta[:wp2txt_version],
139
+ page_count: count_scalar("SELECT COUNT(*) FROM pages"),
140
+ article_count: count_scalar("SELECT COUNT(*) FROM pages WHERE namespace = #{NS_ARTICLE} AND redirect_to IS NULL"),
141
+ category_count: count_scalar("SELECT COUNT(DISTINCT category) FROM page_categories"),
142
+ section_count: count_scalar("SELECT COUNT(*) FROM page_sections")
143
+ }
144
+ end
145
+
146
+ # Provenance of an imported langlinks table (nil when not imported).
147
+ # The langlinks table is an optional post-build addition (LanglinksImporter),
148
+ # so its absence does not affect built? or schema_version.
149
+ def langlinks_provenance
150
+ return nil unless File.exist?(@db_path)
151
+
152
+ meta = read_metadata
153
+ return nil unless meta && meta[:langlinks_imported_at]
154
+
155
+ { source: meta[:langlinks_source],
156
+ source_size: meta[:langlinks_source_size].to_i,
157
+ imported_at: meta[:langlinks_imported_at],
158
+ imported_with: meta[:langlinks_wp2txt_version],
159
+ lang_filter: meta[:langlinks_lang_filter],
160
+ row_count: meta[:langlinks_row_count].to_i,
161
+ skipped_invalid: meta[:langlinks_skipped_invalid].to_i }
162
+ end
163
+
164
+ def close
165
+ @db&.close
166
+ @db = nil
167
+ end
168
+
169
+ # ------------------------------------------------------------------
170
+ # Build API (used by MetadataIndexBuilder)
171
+ # ------------------------------------------------------------------
172
+
173
+ # Build into a sidecar file and atomically rename in finalize_build!, so a
174
+ # failed multi-hour rebuild never destroys a working index
175
+ def prepare_build!
176
+ FileUtils.mkdir_p(File.dirname(@db_path))
177
+ close
178
+ @build_path = "#{@db_path}.building"
179
+ FileUtils.rm_f([@build_path, "#{@build_path}-wal", "#{@build_path}-shm"])
180
+ db = open_db
181
+ db.execute(<<~SQL)
182
+ CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)
183
+ SQL
184
+ db.execute(<<~SQL)
185
+ CREATE TABLE pages (
186
+ page_id INTEGER PRIMARY KEY,
187
+ title TEXT,
188
+ namespace INTEGER,
189
+ redirect_to TEXT,
190
+ text_length INTEGER
191
+ )
192
+ SQL
193
+ db.execute("CREATE TABLE page_categories (page_id INTEGER, category TEXT)")
194
+ # ord: position of the section within the article, where 0 is the lead
195
+ # text before the first heading. Lead rows are NOT stored here (they have
196
+ # no heading), so ord starts at 1 — consistent with fts_map.ord in the
197
+ # full-text DB, where the lead IS stored as ord 0.
198
+ db.execute(<<~SQL)
199
+ CREATE TABLE page_sections (
200
+ page_id INTEGER,
201
+ heading TEXT,
202
+ level INTEGER,
203
+ -- ord: section position in the article; 0 = lead text (not stored in
204
+ -- this table), so headings start at 1. Same semantics as fts_map.ord.
205
+ ord INTEGER
206
+ )
207
+ SQL
208
+ db.execute("CREATE TABLE category_hierarchy (child TEXT, parent TEXT)")
209
+ end
210
+
211
+ # Insert one scanned batch: {pages:, categories:, sections:, hierarchy:}
212
+ def insert_batch(rows)
213
+ db = open_db
214
+ db.transaction do
215
+ stmt = db.prepare("INSERT OR IGNORE INTO pages (page_id, title, namespace, redirect_to, text_length) VALUES (?, ?, ?, ?, ?)")
216
+ rows[:pages].each { |r| stmt.execute(r) }
217
+ stmt.close
218
+
219
+ stmt = db.prepare("INSERT INTO page_categories (page_id, category) VALUES (?, ?)")
220
+ rows[:categories].each { |r| stmt.execute(r) }
221
+ stmt.close
222
+
223
+ stmt = db.prepare("INSERT INTO page_sections (page_id, heading, level, ord) VALUES (?, ?, ?, ?)")
224
+ rows[:sections].each { |r| stmt.execute(r) }
225
+ stmt.close
226
+
227
+ stmt = db.prepare("INSERT INTO category_hierarchy (child, parent) VALUES (?, ?)")
228
+ rows[:hierarchy].each { |r| stmt.execute(r) }
229
+ stmt.close
230
+ end
231
+ end
232
+
233
+ def finalize_build!(source_path)
234
+ db = open_db
235
+ db.execute("CREATE INDEX IF NOT EXISTS idx_pages_title ON pages(title)")
236
+ db.execute("CREATE INDEX IF NOT EXISTS idx_pc_category ON page_categories(category)")
237
+ db.execute("CREATE INDEX IF NOT EXISTS idx_pc_page ON page_categories(page_id)")
238
+ db.execute("CREATE INDEX IF NOT EXISTS idx_ps_heading ON page_sections(heading COLLATE NOCASE)")
239
+ db.execute("CREATE INDEX IF NOT EXISTS idx_ps_page ON page_sections(page_id)")
240
+ db.execute("CREATE INDEX IF NOT EXISTS idx_ch_parent ON category_hierarchy(parent)")
241
+
242
+ stat = File.stat(source_path)
243
+ dump_name = File.basename(source_path)[/\A[a-z0-9_\-]+?-\d{8}/] || File.basename(source_path)
244
+ save_metadata(
245
+ schema_version: SCHEMA_VERSION,
246
+ wp2txt_version: Wp2txt::VERSION,
247
+ source_path: source_path,
248
+ source_size: stat.size,
249
+ source_mtime: stat.mtime.to_i,
250
+ dump_name: dump_name,
251
+ built_at: Time.now.utc.iso8601
252
+ )
253
+ db.execute("ANALYZE")
254
+ close
255
+ if @build_path
256
+ File.rename(@build_path, @db_path)
257
+ FileUtils.rm_f(["#{@db_path}-wal", "#{@db_path}-shm"])
258
+ @build_path = nil
259
+ end
260
+ end
261
+
262
+ # ------------------------------------------------------------------
263
+ # Queries
264
+ # ------------------------------------------------------------------
265
+
266
+ # Find article titles matching the given filters.
267
+ # @param category [String, nil] category name (without namespace prefix)
268
+ # @param depth [Integer] subcategory recursion depth (0 = exact category only)
269
+ # @param has_section [String, nil] single section heading (alias-aware by default)
270
+ # @param sections [Array<String>, nil] multiple headings (OR match, used as-is)
271
+ # @param alias_set [String, nil] saved alias set name used to expand headings
272
+ # @param use_aliases [Boolean] expand has_section via built-in alias groups
273
+ # @param alias_file [String, nil] custom alias YAML (merged with defaults)
274
+ # @param title_match [String, nil] substring match on title
275
+ # @param limit [Integer] max titles to return (0 = no limit)
276
+ # @param offset [Integer] result offset
277
+ # @return [Array<String>] matching titles ordered by page_id
278
+ def find_articles(category: nil, depth: 0, categories: nil, category_match: nil,
279
+ has_section: nil, sections: nil, alias_set: nil,
280
+ use_aliases: true, alias_file: nil, title_match: nil, limit: 0, offset: 0)
281
+ cte, where, params = build_article_query(
282
+ category: category, depth: depth, categories: categories, category_match: category_match,
283
+ has_section: has_section, sections: sections,
284
+ alias_set: alias_set, use_aliases: use_aliases, alias_file: alias_file, title_match: title_match
285
+ )
286
+ sql = +""
287
+ sql << "WITH RECURSIVE #{cte} " if cte
288
+ sql << "SELECT p.title FROM pages p WHERE #{where} ORDER BY p.page_id"
289
+ sql << " LIMIT #{limit.to_i}" if limit.to_i.positive?
290
+ sql << " OFFSET #{offset.to_i}" if offset.to_i.positive?
291
+
292
+ open_db.execute(sql, params).map { |row| row[0] }
293
+ end
294
+
295
+ # Count articles matching the same filters as find_articles
296
+ def count_articles(category: nil, depth: 0, categories: nil, category_match: nil,
297
+ has_section: nil, sections: nil, alias_set: nil,
298
+ use_aliases: true, alias_file: nil, title_match: nil)
299
+ cte, where, params = build_article_query(
300
+ category: category, depth: depth, categories: categories, category_match: category_match,
301
+ has_section: has_section, sections: sections,
302
+ alias_set: alias_set, use_aliases: use_aliases, alias_file: alias_file, title_match: title_match
303
+ )
304
+ sql = +""
305
+ sql << "WITH RECURSIVE #{cte} " if cte
306
+ sql << "SELECT COUNT(*) FROM pages p WHERE #{where}"
307
+
308
+ open_db.get_first_value(sql, params).to_i
309
+ end
310
+
311
+ # Categories of one article (by exact title)
312
+ # @return [Array<String>, nil] category names, nil if the title is unknown
313
+ def categories_of(title)
314
+ row = open_db.execute("SELECT page_id FROM pages WHERE title = ?", [title]).first
315
+ return nil unless row
316
+
317
+ open_db.execute(
318
+ "SELECT category FROM page_categories WHERE page_id = ? ORDER BY category", [row[0]]
319
+ ).map(&:first)
320
+ end
321
+
322
+ # Look up titles in pages (existence + redirect target), batched to keep
323
+ # the IN clause small. Used by Corpus#extract_corpus titles: resolution.
324
+ # @return [Hash] { title => redirect_to_or_nil } for the titles that exist
325
+ def redirect_map(titles)
326
+ result = {}
327
+ titles.each_slice(500) do |slice|
328
+ placeholders = slice.map { "?" }.join(",")
329
+ open_db.execute(
330
+ "SELECT title, redirect_to FROM pages WHERE title IN (#{placeholders})", slice
331
+ ).each do |title, redirect_to|
332
+ result[title] = redirect_to
333
+ end
334
+ end
335
+ result
336
+ end
337
+
338
+ # Subcategory tree starting at category, as [{name:, depth:}, ...] (BFS order)
339
+ def category_tree(category, depth: 2)
340
+ cat = self.class.normalize_category(category)
341
+ sql = <<~SQL
342
+ WITH RECURSIVE cat_tree(name, d) AS (
343
+ SELECT ?, 0
344
+ UNION
345
+ SELECT ch.child, ct.d + 1
346
+ FROM category_hierarchy ch JOIN cat_tree ct ON ch.parent = ct.name
347
+ WHERE ct.d < #{depth.to_i}
348
+ )
349
+ SELECT name, MIN(d) FROM cat_tree GROUP BY name ORDER BY MIN(d), name
350
+ SQL
351
+ open_db.execute(sql, [cat]).map { |name, d| { name: name, depth: d } }
352
+ end
353
+
354
+ # Section heading frequencies across articles, optionally scoped to a category
355
+ def section_stats(category: nil, depth: 0, top_n: 50)
356
+ conds = ["p.namespace = #{NS_ARTICLE}", "p.redirect_to IS NULL"]
357
+ params = []
358
+ cte = nil
359
+ if category
360
+ cte, cond, cat_params = category_condition(category, depth)
361
+ conds << cond
362
+ params.concat(cat_params)
363
+ end
364
+ sql = +""
365
+ sql << "WITH RECURSIVE #{cte} " if cte
366
+ sql << <<~SQL
367
+ SELECT ps.heading, COUNT(*) AS cnt
368
+ FROM page_sections ps JOIN pages p ON p.page_id = ps.page_id
369
+ WHERE #{conds.join(' AND ')}
370
+ GROUP BY ps.heading ORDER BY cnt DESC, ps.heading LIMIT #{top_n.to_i}
371
+ SQL
372
+ open_db.execute(sql, params)
373
+ end
374
+
375
+ # Article counts, average positions, and pairwise co-occurrence for a set of
376
+ # headings. Synonymous headings almost never co-occur in the same article,
377
+ # so a high co-occurrence ratio is evidence AGAINST treating them as aliases.
378
+ # @param headings [Array<String>] headings to compare
379
+ # @param category [String, nil] optional category scope
380
+ # @param depth [Integer] category recursion depth
381
+ # @return [Hash] { headings: [{heading:, articles:, avg_position:}],
382
+ # pairs: [{a:, b:, both:, cooccurrence_ratio:}] }
383
+ def section_cooccurrence(headings, category: nil, depth: 0)
384
+ scope_cte = nil
385
+ scope_cond = "p.namespace = #{NS_ARTICLE} AND p.redirect_to IS NULL"
386
+ scope_params = []
387
+ if category
388
+ scope_cte, cond, scope_params = category_condition(category, depth)
389
+ scope_cond += " AND #{cond}"
390
+ end
391
+
392
+ db = open_db
393
+ with = scope_cte ? "WITH RECURSIVE #{scope_cte} " : ""
394
+ # Placeholders bind positionally: with a recursive CTE the category `?`
395
+ # sits inside the CTE (before any heading `?`); without one it sits
396
+ # inside scope_cond (after the heading `?`)
397
+ cte_params = scope_cte ? scope_params : []
398
+ cond_params = scope_cte ? [] : scope_params
399
+
400
+ heading_stats = headings.map do |h|
401
+ row = db.execute(
402
+ "#{with}SELECT COUNT(DISTINCT ps.page_id), AVG(ps.ord) FROM page_sections ps " \
403
+ "JOIN pages p ON p.page_id = ps.page_id " \
404
+ "WHERE ps.heading COLLATE NOCASE = ? AND #{scope_cond}",
405
+ cte_params + [h] + cond_params
406
+ ).first
407
+ { heading: h, articles: row[0].to_i, avg_position: row[1]&.round(2) }
408
+ end
409
+
410
+ counts = heading_stats.to_h { |s| [s[:heading], s[:articles]] }
411
+ pairs = headings.combination(2).map do |a, b|
412
+ both = db.get_first_value(
413
+ "#{with}SELECT COUNT(*) FROM (" \
414
+ "SELECT ps.page_id FROM page_sections ps JOIN pages p ON p.page_id = ps.page_id " \
415
+ "WHERE ps.heading COLLATE NOCASE = ? AND #{scope_cond} " \
416
+ "INTERSECT " \
417
+ "SELECT ps.page_id FROM page_sections ps JOIN pages p ON p.page_id = ps.page_id " \
418
+ "WHERE ps.heading COLLATE NOCASE = ? AND #{scope_cond})",
419
+ cte_params + [a] + cond_params + [b] + cond_params
420
+ ).to_i
421
+ min = [counts[a], counts[b]].min
422
+ { a: a, b: b, both: both, cooccurrence_ratio: min.positive? ? (both.to_f / min).round(3) : 0.0 }
423
+ end
424
+
425
+ { headings: heading_stats, pairs: pairs }
426
+ end
427
+
428
+ # ------------------------------------------------------------------
429
+ # Alias sets (LLM-generated, persisted per dump for reproducibility)
430
+ # ------------------------------------------------------------------
431
+
432
+ # Save a named alias set. groups is an array of heading groups, e.g.
433
+ # [["あらすじ", "ストーリー", "物語"], ["脚注", "出典"]]
434
+ def save_alias_set(name, groups)
435
+ raise ArgumentError, "groups must be a non-empty array of arrays" unless groups.is_a?(Array) && !groups.empty? && groups.all? { |g| g.is_a?(Array) && !g.empty? }
436
+
437
+ ensure_alias_table
438
+ open_db.execute(
439
+ "INSERT OR REPLACE INTO alias_sets (name, groups, created_at) VALUES (?, ?, ?)",
440
+ [name, JSON.generate(groups), Time.now.utc.iso8601]
441
+ )
442
+ { name: name, groups: groups }
443
+ end
444
+
445
+ # @return [Hash, nil] { name:, groups:, created_at: } or nil if not found
446
+ def get_alias_set(name)
447
+ ensure_alias_table
448
+ row = open_db.execute("SELECT name, groups, created_at FROM alias_sets WHERE name = ?", [name]).first
449
+ return nil unless row
450
+
451
+ { name: row[0], groups: JSON.parse(row[1]), created_at: row[2] }
452
+ end
453
+
454
+ def list_alias_sets
455
+ ensure_alias_table
456
+ open_db.execute("SELECT name, groups, created_at FROM alias_sets ORDER BY name").map do |name, groups, created_at|
457
+ { name: name, group_count: JSON.parse(groups).size, created_at: created_at }
458
+ end
459
+ end
460
+
461
+ def delete_alias_set(name)
462
+ ensure_alias_table
463
+ open_db.execute("DELETE FROM alias_sets WHERE name = ?", [name])
464
+ nil
465
+ end
466
+
467
+ private
468
+
469
+ # Returns [cte_sql_or_nil, where_sql, params]
470
+ def build_article_query(category:, depth:, has_section:, categories: nil, category_match: nil,
471
+ sections: nil, alias_set: nil,
472
+ use_aliases: true, alias_file: nil, title_match: nil)
473
+ conds = ["p.namespace = #{NS_ARTICLE}", "p.redirect_to IS NULL"]
474
+ params = []
475
+ cte = nil
476
+
477
+ if category
478
+ cte, cond, cat_params = category_condition(category, depth)
479
+ conds << cond
480
+ params.concat(cat_params)
481
+ end
482
+
483
+ # AND-intersection of exact category memberships (no recursion)
484
+ Array(categories).each do |cat|
485
+ conds << "p.page_id IN (SELECT pc.page_id FROM page_categories pc WHERE pc.category = ?)"
486
+ params << self.class.normalize_category(cat)
487
+ end
488
+
489
+ # Substring match on category names, e.g. "アメリカ合衆国の%映画" style scoping
490
+ if category_match
491
+ conds << "p.page_id IN (SELECT pc.page_id FROM page_categories pc WHERE pc.category LIKE ? ESCAPE '\\')"
492
+ params << "%#{escape_like(category_match)}%"
493
+ end
494
+
495
+ names = resolve_section_names(has_section: has_section, sections: sections,
496
+ alias_set: alias_set, use_aliases: use_aliases, alias_file: alias_file)
497
+ if names
498
+ placeholders = names.map { "?" }.join(",")
499
+ conds << "p.page_id IN (SELECT ps.page_id FROM page_sections ps WHERE ps.heading COLLATE NOCASE IN (#{placeholders}))"
500
+ params.concat(names)
501
+ end
502
+
503
+ if title_match
504
+ conds << "p.title LIKE ? ESCAPE '\\'"
505
+ params << "%#{escape_like(title_match)}%"
506
+ end
507
+
508
+ [cte, conds.join(" AND "), params]
509
+ end
510
+
511
+ # Merge has_section / sections into one heading list, expanding through a
512
+ # saved alias set (if given) or the built-in alias groups (single name only).
513
+ def resolve_section_names(has_section:, sections:, alias_set:, use_aliases:, alias_file:)
514
+ names = Array(sections).compact
515
+ names += [has_section] if has_section
516
+ return nil if names.empty?
517
+
518
+ if alias_set
519
+ set = get_alias_set(alias_set)
520
+ raise ArgumentError, "alias set not found: #{alias_set}" unless set
521
+
522
+ names = names.flat_map { |n| expand_via_groups(n, set[:groups]) }
523
+ elsif use_aliases && names.size == 1 && sections.nil?
524
+ names = self.class.expand_section_names(names.first, alias_file: alias_file)
525
+ end
526
+ names.uniq
527
+ end
528
+
529
+ # Bidirectional expansion through an array of heading groups
530
+ def expand_via_groups(name, groups)
531
+ down = name.downcase
532
+ groups.each do |group|
533
+ return group if group.any? { |g| g.downcase == down }
534
+ end
535
+ [name]
536
+ end
537
+
538
+ def ensure_alias_table
539
+ open_db.execute(<<~SQL)
540
+ CREATE TABLE IF NOT EXISTS alias_sets (
541
+ name TEXT PRIMARY KEY,
542
+ groups TEXT,
543
+ created_at TEXT
544
+ )
545
+ SQL
546
+ end
547
+
548
+ # Returns [cte_sql_or_nil, condition_sql, params] for a category filter
549
+ def category_condition(category, depth)
550
+ cat = self.class.normalize_category(category)
551
+ if depth.to_i.positive?
552
+ cte = <<~SQL.strip
553
+ cat_tree(name, d) AS (
554
+ SELECT ?, 0
555
+ UNION
556
+ SELECT ch.child, ct.d + 1
557
+ FROM category_hierarchy ch JOIN cat_tree ct ON ch.parent = ct.name
558
+ WHERE ct.d < #{depth.to_i}
559
+ )
560
+ SQL
561
+ cond = "p.page_id IN (SELECT pc.page_id FROM page_categories pc WHERE pc.category IN (SELECT name FROM cat_tree))"
562
+ [cte, cond, [cat]]
563
+ else
564
+ [nil, "p.page_id IN (SELECT pc.page_id FROM page_categories pc WHERE pc.category = ?)", [cat]]
565
+ end
566
+ end
567
+
568
+ def escape_like(str)
569
+ str.gsub(/[\\%_]/) { |c| "\\#{c}" }
570
+ end
571
+
572
+ def open_db
573
+ return @db if @db
574
+
575
+ @db = SQLite3::Database.new(@build_path || @db_path)
576
+ @db.busy_timeout = 5000
577
+ @db.execute("PRAGMA journal_mode = WAL")
578
+ @db.execute("PRAGMA synchronous = NORMAL")
579
+ @db.execute("PRAGMA cache_size = -64000")
580
+ @db
581
+ end
582
+
583
+ def count_scalar(sql)
584
+ open_db.get_first_value(sql).to_i
585
+ rescue SQLite3::Exception
586
+ 0
587
+ end
588
+
589
+ def read_metadata
590
+ db = open_db
591
+ result = {}
592
+ db.execute("SELECT key, value FROM metadata") do |key, value|
593
+ result[key.to_sym] = value
594
+ end
595
+ result
596
+ rescue SQLite3::Exception
597
+ nil
598
+ end
599
+
600
+ def save_metadata(hash)
601
+ db = open_db
602
+ stmt = db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)")
603
+ hash.each { |k, v| stmt.execute([k.to_s, v.to_s]) }
604
+ stmt.close
605
+ end
606
+ end
607
+
608
+ # Builds a MetadataIndex by scanning all streams of a multistream dump in parallel.
609
+ # Workers decompress and regex-scan streams; the parent process owns the sole
610
+ # SQLite connection and inserts each batch from the Parallel `finish` hook.
611
+ class MetadataIndexBuilder
612
+ STREAMS_PER_BATCH = 50
613
+
614
+ PAGE_BLOCK_REGEX = %r{<page>(.*?)</page>}m
615
+ TITLE_REGEX = %r{<title>([^<]*)</title>}
616
+ NS_REGEX = %r{<ns>(-?\d+)</ns>}
617
+ ID_REGEX = %r{<id>(\d+)</id>}
618
+ TEXT_REGEX = %r{<text[^>]*>(.*)</text>}m
619
+ HEADING_REGEX = /\A(={2,6})[ \t]*(.+?)[ \t]*={2,6}[ \t]*\z/
620
+ HTML_COMMENT_REGEX = /<!--.*?-->/m
621
+
622
+ def initialize(multistream_path, stream_offsets, db_path:, num_processes: 4)
623
+ @multistream_path = multistream_path
624
+ @stream_offsets = stream_offsets
625
+ @db_path = db_path
626
+ @num_processes = num_processes
627
+ end
628
+
629
+ # Build the index. Yields (batches_done, batches_total) after each batch.
630
+ # @return [MetadataIndex] the built index
631
+ def build(&progress)
632
+ index = MetadataIndex.new(@db_path)
633
+ index.prepare_build!
634
+ # Close before Parallel forks workers so children do not inherit a
635
+ # writable SQLite connection; the finish hook reopens it lazily in
636
+ # the parent, which is the only process that ever writes.
637
+ index.close
638
+
639
+ pairs = @stream_offsets.zip(@stream_offsets[1..].to_a + [nil])
640
+ batches = pairs.each_slice(STREAMS_PER_BATCH).to_a
641
+ done = 0
642
+
643
+ Parallel.map(
644
+ batches,
645
+ in_processes: @num_processes,
646
+ finish: lambda { |_item, _idx, result|
647
+ index.insert_batch(result)
648
+ done += 1
649
+ progress&.call(done, batches.size)
650
+ }
651
+ ) do |batch|
652
+ self.class.scan_batch(@multistream_path, batch)
653
+ end
654
+
655
+ index.finalize_build!(@multistream_path)
656
+ index
657
+ end
658
+
659
+ # Scan a batch of [offset, next_offset] stream pairs.
660
+ # Runs inside worker processes: must not touch SQLite.
661
+ def self.scan_batch(multistream_path, offset_pairs)
662
+ out = { pages: [], categories: [], sections: [], hierarchy: [] }
663
+ File.open(multistream_path, "rb") do |f|
664
+ offset_pairs.each do |offset, next_offset|
665
+ f.seek(offset)
666
+ data = next_offset ? f.read(next_offset - offset) : f.read
667
+ xml = decompress_bz2(data)
668
+ xml.scan(PAGE_BLOCK_REGEX) { scan_page(::Regexp.last_match(1), out) }
669
+ end
670
+ end
671
+ out
672
+ end
673
+
674
+ def self.decompress_bz2(data)
675
+ stdout, status = Open3.capture2("bzcat", stdin_data: data)
676
+ raise "bzcat failed (exit #{status.exitstatus})" unless status.success?
677
+
678
+ stdout.force_encoding(Encoding::UTF_8)
679
+ end
680
+
681
+ def self.scan_page(block, out)
682
+ title = block[TITLE_REGEX, 1]
683
+ return unless title && !title.empty?
684
+
685
+ page_id = block[ID_REGEX, 1]&.to_i
686
+ return unless page_id
687
+
688
+ title = unescape_xml(title)
689
+ ns = (block[NS_REGEX, 1] || "0").to_i
690
+ text = block[TEXT_REGEX, 1] || ""
691
+ text = unescape_xml(text)
692
+ # Strip HTML comments before scanning, matching what the Article parser
693
+ # does for the FTS index: a trailing comment after a heading's closing
694
+ # `==` must not hide the heading, and commented-out [[Category:]] links
695
+ # must not be indexed as real categories
696
+ text = text.gsub(HTML_COMMENT_REGEX, "")
697
+
698
+ redirect_to = nil
699
+ if (m = REDIRECT_REGEX.match(text))
700
+ redirect_to = m[1].split(/[#|]/).first.to_s.strip
701
+ redirect_to = nil if redirect_to.empty?
702
+ end
703
+
704
+ out[:pages] << [page_id, title, ns, redirect_to, text.length]
705
+
706
+ categories = text.scan(CATEGORY_REGEX)
707
+ .map { |c| MetadataIndex.normalize_category(c[0]) }
708
+ .reject(&:empty?).uniq
709
+ if ns == MetadataIndex::NS_CATEGORY
710
+ child = MetadataIndex.normalize_category(title.sub(/\A[^:]+:/, ""))
711
+ categories.each { |c| out[:hierarchy] << [child, c] } unless child.empty?
712
+ else
713
+ categories.each { |c| out[:categories] << [page_id, c] }
714
+ end
715
+
716
+ # ord 0 is the lead text (not stored here — it has no heading); headings
717
+ # start at 1 so ord aligns with fts_map.ord in the full-text DB
718
+ ord = 0
719
+ text.each_line do |line|
720
+ l = line.chomp
721
+ next unless l.start_with?("==")
722
+
723
+ hm = HEADING_REGEX.match(l)
724
+ next unless hm
725
+
726
+ ord += 1
727
+ heading = MetadataIndex.clean_heading(hm[2])
728
+ next if heading.empty?
729
+
730
+ out[:sections] << [page_id, heading, hm[1].length, ord]
731
+ end
732
+ end
733
+
734
+ def self.unescape_xml(str)
735
+ str.gsub("&lt;", "<").gsub("&gt;", ">").gsub("&quot;", '"').gsub("&amp;", "&")
736
+ end
737
+ end
738
+ end