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