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,427 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "metadata_index"
5
+ require_relative "fts_index"
6
+ require_relative "langlinks_importer"
7
+ require_relative "corpus"
8
+ require_relative "multistream"
9
+ require_relative "memory_monitor"
10
+
11
+ module Wp2txt
12
+ # CLI command handlers for --build-index and --find-articles.
13
+ # Mixed into WpApp (bin/wp2txt); relies on CliUI helpers for output.
14
+ module IndexCommands
15
+ # Build (or refresh) the local metadata index for a dump
16
+ def run_build_index(opts)
17
+ multistream_path, index_path = resolve_dump_paths(opts, download: true)
18
+ return CliUI::EXIT_ERROR unless multistream_path
19
+
20
+ db_path = MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir])
21
+ meta = MetadataIndex.new(db_path)
22
+
23
+ if meta.valid_for?(multistream_path) && !opts[:update_cache]
24
+ print_success("Metadata index is up to date: #{db_path}")
25
+ print_index_stats(meta.stats)
26
+ meta.close
27
+ return CliUI::EXIT_SUCCESS unless opts[:fulltext]
28
+
29
+ stream_offsets, = load_stream_offsets(index_path, opts)
30
+ num_processes = opts[:num_procs] || MemoryMonitor.optimal_processes
31
+ return build_fulltext_index(opts, multistream_path, stream_offsets, db_path, num_processes)
32
+ end
33
+ meta.close
34
+
35
+ print_mode_banner("Build Metadata Index", {
36
+ "Dump" => File.basename(multistream_path),
37
+ "Output" => db_path
38
+ })
39
+
40
+ time_start = Time.now
41
+ puts pastel.cyan("Loading multistream index...") unless quiet?
42
+ stream_offsets, entry_count = load_stream_offsets(index_path, opts)
43
+ if stream_offsets.empty?
44
+ print_error("Multistream index is empty or unreadable: #{index_path}")
45
+ return CliUI::EXIT_ERROR
46
+ end
47
+
48
+ num_processes = opts[:num_procs] || MemoryMonitor.optimal_processes
49
+ puts pastel.cyan("Scanning #{entry_count} pages in #{stream_offsets.size} streams (#{num_processes} processes)...") unless quiet?
50
+
51
+ ok = run_phase_isolated do
52
+ builder = MetadataIndexBuilder.new(
53
+ multistream_path, stream_offsets,
54
+ db_path: db_path, num_processes: num_processes
55
+ )
56
+
57
+ last_report = Time.now
58
+ built = builder.build do |done, total|
59
+ now = Time.now
60
+ if !quiet? && (now - last_report >= DEFAULT_PROGRESS_INTERVAL || done == total)
61
+ last_report = now
62
+ percent = (done.to_f / total * 100).round(1)
63
+ elapsed = now - time_start
64
+ eta = done.positive? ? (total - done) * (elapsed / done) : 0
65
+ puts pastel.dim(format(" [%d/%d] %.1f%% | ETA: %s", done, total, percent, format_duration(eta)))
66
+ end
67
+ end
68
+
69
+ puts unless quiet?
70
+ print_success("Metadata index built in #{format_duration(Time.now - time_start)}")
71
+ print_index_stats(built.stats)
72
+ built.close
73
+ true
74
+ end
75
+ return CliUI::EXIT_ERROR unless ok
76
+
77
+ return build_fulltext_index(opts, multistream_path, stream_offsets, db_path, num_processes) if opts[:fulltext]
78
+
79
+ CliUI::EXIT_SUCCESS
80
+ end
81
+
82
+ # Run a build phase in a forked child process. Each phase pumps its whole
83
+ # dataset through the parent's heap (Marshal.load of every worker batch),
84
+ # leaving a multi-GB high-water mark; a later phase forking workers from
85
+ # that bloated parent duplicates it per worker via copy-on-write breakage
86
+ # and OOMs the machine (observed on enwiki: 18GB per FTS worker). A child
87
+ # process confines each phase's high-water mark to that phase.
88
+ # @return [Boolean] whether the phase succeeded
89
+ def run_phase_isolated(&block)
90
+ return block.call unless Process.respond_to?(:fork)
91
+
92
+ pid = Process.fork do
93
+ status = 1
94
+ begin
95
+ status = block.call ? 0 : 1
96
+ rescue StandardError => e
97
+ warn "#{e.class}: #{e.message}"
98
+ end
99
+ $stdout.flush
100
+ $stderr.flush
101
+ exit!(status) # skip at_exit handlers; they belong to the parent
102
+ end
103
+ _, status = Process.waitpid2(pid)
104
+ status.success?
105
+ end
106
+
107
+ # Load stream offsets without holding the full title index in memory.
108
+ # The builders fork worker processes; a parent heap holding millions of
109
+ # index entries (~14GB for enwiki) gets duplicated through copy-on-write
110
+ # breakage in every child and OOMs the machine. Offsets are a small
111
+ # integer array, so prefer reading just them from the SQLite cache.
112
+ # @return [Array(Array<Integer>, Integer)] [stream_offsets, entry_count]
113
+ def load_stream_offsets(index_path, opts)
114
+ cache = IndexCache.new(index_path, cache_dir: opts[:cache_dir])
115
+ if cache.valid?
116
+ [cache.stream_offsets, cache.stats[:entry_count]]
117
+ else
118
+ # First run: parse the index (this also writes the SQLite cache),
119
+ # then discard the entry hashes before any fork
120
+ ms_index = MultistreamIndex.new(index_path, cache_dir: opts[:cache_dir], show_progress: !quiet?)
121
+ offsets = ms_index.stream_offsets
122
+ count = ms_index.size
123
+ ms_index = nil
124
+ GC.start
125
+ GC.compact if GC.respond_to?(:compact)
126
+ [offsets, count]
127
+ end
128
+ end
129
+
130
+ # Build the FTS5 full-text index (Tier 2) after the metadata index
131
+ def build_fulltext_index(opts, multistream_path, stream_offsets, meta_db_path, num_processes)
132
+ fts_db_path = FtsIndex.path_for(multistream_path, cache_dir: opts[:cache_dir])
133
+ tokenizer = opts[:fts_tokenizer] || FtsIndex.default_tokenizer(multistream_path)
134
+
135
+ fts = FtsIndex.new(fts_db_path, meta_db_path)
136
+ if fts.valid_for?(multistream_path) && !opts[:update_cache]
137
+ print_success("Full-text index is up to date: #{fts_db_path}")
138
+ fts.close
139
+ return CliUI::EXIT_SUCCESS
140
+ end
141
+ fts.close
142
+
143
+ puts pastel.cyan("Building full-text index (tokenizer: #{tokenizer})...") unless quiet?
144
+ time_start = Time.now
145
+ ok = run_phase_isolated do
146
+ builder = FtsIndexBuilder.new(
147
+ multistream_path, stream_offsets,
148
+ db_path: fts_db_path, meta_db_path: meta_db_path,
149
+ tokenizer: tokenizer, num_processes: num_processes,
150
+ optimize: !opts[:skip_fts_optimize]
151
+ )
152
+
153
+ last_report = Time.now
154
+ built = builder.build do |done, total|
155
+ now = Time.now
156
+ if !quiet? && (now - last_report >= DEFAULT_PROGRESS_INTERVAL || done == total)
157
+ last_report = now
158
+ percent = (done.to_f / total * 100).round(1)
159
+ elapsed = now - time_start
160
+ eta = done.positive? ? (total - done) * (elapsed / done) : 0
161
+ puts pastel.dim(format(" [%d/%d] %.1f%% | ETA: %s", done, total, percent, format_duration(eta)))
162
+ end
163
+ end
164
+
165
+ puts unless quiet?
166
+ print_success("Full-text index built in #{format_duration(Time.now - time_start)}")
167
+ stats = built.stats
168
+ print_info("Tokenizer", stats[:tokenizer].to_s)
169
+ print_info("Sections", stats[:section_count].to_s)
170
+ print_info("Size", format_size(stats[:db_size]))
171
+ unless stats[:optimized]
172
+ print_info_message("Index is unoptimized (built with --skip-fts-optimize). Run 'wp2txt --fts-optimize' later for best query speed.")
173
+ end
174
+ built.close
175
+ true
176
+ end
177
+ ok ? CliUI::EXIT_SUCCESS : CliUI::EXIT_ERROR
178
+ end
179
+
180
+ # Standalone optimize of an existing full-text index (--fts-optimize)
181
+ def run_fts_optimize(opts)
182
+ multistream_path, = resolve_dump_paths(opts, download: false)
183
+ return CliUI::EXIT_ERROR unless multistream_path
184
+
185
+ fts = FtsIndex.new(
186
+ FtsIndex.path_for(multistream_path, cache_dir: opts[:cache_dir]),
187
+ MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir])
188
+ )
189
+ unless fts.built?
190
+ print_error("Full-text index not found for this dump.")
191
+ print_info_message("Build it first with: wp2txt --build-index --fulltext #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}")
192
+ return CliUI::EXIT_ERROR
193
+ end
194
+
195
+ if fts.optimized?
196
+ print_success("Full-text index is already optimized.")
197
+ fts.close
198
+ return CliUI::EXIT_SUCCESS
199
+ end
200
+
201
+ print_info_message("Optimizing full-text index (single-threaded; can take 30-60+ minutes on large indexes)...")
202
+ time_start = Time.now
203
+ fts.optimize!
204
+ print_success("Optimize complete in #{format_duration(Time.now - time_start)}")
205
+ print_info("Size", format_size(File.size(fts.db_path)))
206
+ fts.close
207
+ CliUI::EXIT_SUCCESS
208
+ end
209
+
210
+ # Full-text search from the CLI (--search)
211
+ def run_search(opts)
212
+ multistream_path, = resolve_dump_paths(opts, download: false)
213
+ return CliUI::EXIT_ERROR unless multistream_path
214
+
215
+ corpus = Corpus.new(
216
+ multistream_path: multistream_path,
217
+ index_path: resolve_dump_paths(opts, download: false)[1],
218
+ cache_dir: opts[:cache_dir]
219
+ )
220
+
221
+ unless corpus.fts.built?
222
+ print_error("Full-text index not found for this dump.")
223
+ print_info_message("Build it first with: wp2txt --build-index --fulltext #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}")
224
+ return CliUI::EXIT_ERROR
225
+ end
226
+
227
+ result = corpus.search_text(
228
+ opts[:search],
229
+ sections: opts[:has_section] ? [opts[:has_section]] : nil,
230
+ category: opts[:in_category],
231
+ depth: opts[:in_category] ? opts[:depth] : 0,
232
+ limit: opts[:limit].positive? ? opts[:limit] : 20,
233
+ count: "exact"
234
+ )
235
+
236
+ if opts[:format].to_s.downcase == "json"
237
+ puts JSON.generate(result)
238
+ else
239
+ result[:hits].each do |hit|
240
+ puts "#{hit[:section_path]}: #{hit[:snippet]}"
241
+ end
242
+ $stderr.puts pastel.dim("# #{result[:returned]} of #{result[:total]} matches (dump: #{result[:dump]})")
243
+ end
244
+ corpus.close
245
+ CliUI::EXIT_SUCCESS
246
+ end
247
+
248
+ # Import the official langlinks dump (interlanguage links) into the
249
+ # metadata index. Version pinning: the langlinks file must carry the same
250
+ # dump date as the built index (enforced by LanglinksImporter, no override)
251
+ def run_import_langlinks(opts)
252
+ manager = DumpManager.new(
253
+ opts[:lang],
254
+ cache_dir: opts[:cache_dir],
255
+ dump_expiry_days: CLI.config.dump_expiry_days
256
+ )
257
+ multistream = manager.cached_multistream_path
258
+ unless File.exist?(multistream)
259
+ print_error("No cached dump found for '#{opts[:lang]}'.")
260
+ print_info_message("Download and index it with: wp2txt --build-index -L #{opts[:lang]}")
261
+ return CliUI::EXIT_ERROR
262
+ end
263
+
264
+ db_path = MetadataIndex.path_for(multistream, cache_dir: opts[:cache_dir])
265
+ meta = MetadataIndex.new(db_path)
266
+ unless meta.built?
267
+ meta.close
268
+ print_error("Metadata index not found for this dump.")
269
+ print_info_message("Build it first with: wp2txt --build-index -L #{opts[:lang]}")
270
+ return CliUI::EXIT_ERROR
271
+ end
272
+
273
+ dump_name = meta.stats[:dump_name]
274
+ dump_date = dump_name[/\d{8}\z/]
275
+ meta.close
276
+
277
+ source = opts[:langlinks_file] || begin
278
+ print_header("Downloading langlinks for '#{opts[:lang]}' (#{dump_date})")
279
+ manager.download_langlinks(date: dump_date)
280
+ end
281
+
282
+ langs = opts[:langlinks_langs]&.split(",")&.map(&:strip)&.reject(&:empty?)
283
+ langs = nil if langs&.empty?
284
+
285
+ print_mode_banner("Import Langlinks", {
286
+ "Source" => File.basename(source),
287
+ "Metadata DB" => db_path,
288
+ "Languages" => langs ? langs.join(",") : "all"
289
+ })
290
+
291
+ importer = LanglinksImporter.new(db_path, cache_dir: opts[:cache_dir])
292
+ time_start = Time.now
293
+ last_report = Time.now
294
+ result = importer.import!(source, langs: langs, force: opts[:update_cache]) do |rows|
295
+ now = Time.now
296
+ if !quiet? && now - last_report >= DEFAULT_PROGRESS_INTERVAL
297
+ last_report = now
298
+ puts pastel.dim(format(" [%s] %d rows imported", now.strftime("%H:%M:%S"), rows))
299
+ end
300
+ end
301
+
302
+ if result[:status] == :already_imported
303
+ print_success("Langlinks already imported (at #{result[:imported_at]}, #{result[:row_count]} rows).")
304
+ print_info_message("Use -U/--update-cache to re-import.")
305
+ else
306
+ print_success("Langlinks imported: #{result[:row_count]} rows in #{format_duration(Time.now - time_start)}")
307
+ prov = result[:provenance]
308
+ print_info("Source", prov[:source].to_s)
309
+ print_info("Languages", prov[:lang_filter].to_s)
310
+ if result[:skipped_invalid].to_i.positive?
311
+ print_warning("Skipped #{result[:skipped_invalid]} rows containing invalid UTF-8 bytes (recorded as langlinks_skipped_invalid)")
312
+ end
313
+ (result[:sanity] || []).each do |check|
314
+ msg = format("join check ll_lang=%s: %d/%d titles found in %s (%.1f%%)",
315
+ check[:lang], check[:matched], check[:sampled],
316
+ check[:against], check[:match_rate] * 100)
317
+ if check[:warning]
318
+ print_warning("#{msg} — below 90%; title normalization may mismatch")
319
+ else
320
+ print_info_message(msg)
321
+ end
322
+ end
323
+ end
324
+ CliUI::EXIT_SUCCESS
325
+ rescue ArgumentError => e
326
+ print_error(e.message)
327
+ CliUI::EXIT_ERROR
328
+ end
329
+
330
+ # Query the metadata index and print matching article titles
331
+ def run_find_articles(opts)
332
+ multistream_path, = resolve_dump_paths(opts, download: false)
333
+ return CliUI::EXIT_ERROR unless multistream_path
334
+
335
+ db_path = MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir])
336
+ meta = MetadataIndex.new(db_path)
337
+
338
+ unless meta.built?
339
+ print_error("Metadata index not found for this dump.")
340
+ print_info_message("Build it first with: wp2txt --build-index #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}")
341
+ return CliUI::EXIT_ERROR
342
+ end
343
+
344
+ unless meta.valid_for?(multistream_path)
345
+ print_warning("Metadata index was built from a different version of this dump. Consider re-running --build-index.")
346
+ end
347
+
348
+ filters = {
349
+ category: opts[:in_category],
350
+ depth: opts[:in_category] ? opts[:depth] : 0,
351
+ has_section: opts[:has_section],
352
+ use_aliases: !opts[:no_section_aliases],
353
+ alias_file: opts[:alias_file],
354
+ title_match: opts[:title_match]
355
+ }
356
+
357
+ total = meta.count_articles(**filters)
358
+ titles = meta.find_articles(**filters, limit: opts[:limit])
359
+ dump_name = meta.stats[:dump_name]
360
+ meta.close
361
+
362
+ if opts[:format].to_s.downcase == "json"
363
+ puts JSON.generate({ dump: dump_name, total: total, returned: titles.size, titles: titles })
364
+ else
365
+ titles.each { |t| puts t }
366
+ $stderr.puts pastel.dim("# #{titles.size} of #{total} matching articles (dump: #{dump_name})")
367
+ end
368
+ CliUI::EXIT_SUCCESS
369
+ end
370
+
371
+ private
372
+
373
+ def print_index_stats(stats)
374
+ return unless stats && !quiet?
375
+
376
+ print_info("Dump", stats[:dump_name].to_s)
377
+ print_info("Pages", stats[:page_count].to_s)
378
+ print_info("Articles", stats[:article_count].to_s)
379
+ print_info("Categories", stats[:category_count].to_s)
380
+ print_info("Sections", stats[:section_count].to_s)
381
+ print_info("Size", format_size(stats[:db_size]))
382
+ end
383
+
384
+ # Resolve [multistream_path, index_path] from --lang (cached dump) or --input.
385
+ # Returns [nil, nil] after printing an error when files cannot be located.
386
+ def resolve_dump_paths(opts, download: false)
387
+ if opts[:lang]
388
+ manager = DumpManager.new(
389
+ opts[:lang],
390
+ cache_dir: opts[:cache_dir],
391
+ dump_expiry_days: CLI.config.dump_expiry_days
392
+ )
393
+ multistream = manager.cached_multistream_path
394
+ index = manager.cached_index_path
395
+ unless File.exist?(multistream) && File.exist?(index)
396
+ unless download
397
+ print_error("No cached dump found for '#{opts[:lang]}'.")
398
+ print_info_message("Download and index it with: wp2txt --build-index -L #{opts[:lang]}")
399
+ return [nil, nil]
400
+ end
401
+ print_header("Downloading dump files for '#{opts[:lang]}'")
402
+ manager.download_index
403
+ manager.download_multistream
404
+ end
405
+ [multistream, index]
406
+ else
407
+ multistream = opts[:input]
408
+ index = locate_index_file(multistream)
409
+ unless index
410
+ print_error("Could not find the multistream index file for #{multistream}")
411
+ print_info_message("Expected e.g. #{File.basename(multistream).sub(/multistream\.xml\.bz2\z/, 'multistream-index.txt.bz2')} next to the dump.")
412
+ return [nil, nil]
413
+ end
414
+ [multistream, index]
415
+ end
416
+ end
417
+
418
+ def locate_index_file(multistream_path)
419
+ candidates = [
420
+ multistream_path.sub(/multistream\.xml\.bz2\z/, "multistream-index.txt.bz2"),
421
+ multistream_path.sub(/\.xml\.bz2\z/, "-index.txt.bz2"),
422
+ multistream_path.sub(/\.xml\.bz2\z/, "-index.txt")
423
+ ].uniq
424
+ candidates.find { |c| c != multistream_path && File.exist?(c) }
425
+ end
426
+ end
427
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "set"
5
+ require "time"
6
+ require "zlib"
7
+ require_relative "metadata_index"
8
+ require_relative "version"
9
+
10
+ module Wp2txt
11
+ # Imports the official langlinks dump ({lang}wiki-{date}-langlinks.sql.gz,
12
+ # MySQL dump format) into the Tier 1 metadata DB as a `langlinks` table
13
+ # (ll_from = source page_id, ll_lang = target language, ll_title = title in
14
+ # the target edition, normalized like pages.title).
15
+ #
16
+ # Version pinning is the reason this feature exists: the langlinks file's
17
+ # dump name (e.g. jawiki-20260701) MUST equal the metadata DB's dump_name;
18
+ # a mismatch is rejected with no override.
19
+ class LanglinksImporter
20
+ # Rows inserted per transaction (index creation is deferred until after
21
+ # the load, so inserts stay fast)
22
+ BATCH_SIZE = 10_000
23
+
24
+ # Post-import sanity check (design doc §1.6): per target language, join a
25
+ # random sample of ll_title values against the target language's local
26
+ # meta DB (when installed) and report the match rate; a low rate signals
27
+ # a title-normalization mismatch
28
+ SANITY_SAMPLE_SIZE = 1000
29
+ SANITY_WARN_THRESHOLD = 0.9
30
+
31
+ INSERT_PREFIX = /\A\s*INSERT\s+INTO\s+`langlinks`\s+VALUES\s+/i
32
+
33
+ # MySQL backslash escapes inside mysqldump string literals
34
+ UNESCAPES = {
35
+ "0" => "\0", "'" => "'", '"' => '"', "b" => "\b", "n" => "\n",
36
+ "r" => "\r", "t" => "\t", "Z" => "\x1A", "\\" => "\\"
37
+ }.freeze
38
+
39
+ def initialize(db_path, cache_dir: nil)
40
+ @db_path = db_path
41
+ @cache_dir = cache_dir
42
+ end
43
+
44
+ # "jawiki-20260701-langlinks.sql.gz" => "jawiki-20260701" (same extraction
45
+ # rule as the dump_name recorded in the metadata DB)
46
+ def self.dump_name_of(path)
47
+ File.basename(path)[/\A[a-z0-9_\-]+?-\d{8}/]
48
+ end
49
+
50
+ # @param source_path [String] langlinks .sql or .sql.gz file
51
+ # @param langs [Array<String>, nil] target languages to import (nil = all)
52
+ # @param force [Boolean] drop and re-import an existing langlinks table
53
+ # @param progress [Proc, nil] called with the running row count per batch
54
+ # @return [Hash] { status: :imported | :already_imported, ... }
55
+ def import!(source_path, langs: nil, force: false, progress: nil)
56
+ raise ArgumentError, "langlinks file not found: #{source_path}" unless File.exist?(source_path)
57
+
58
+ db = open_db
59
+ dump_name = metadata_value(db, "dump_name")
60
+ raise ArgumentError, "metadata index is not built: #{@db_path}" unless dump_name
61
+
62
+ source_dump = self.class.dump_name_of(source_path)
63
+ unless source_dump && source_dump == dump_name
64
+ raise ArgumentError,
65
+ "dump version mismatch: the metadata index is #{dump_name} but the langlinks file is " \
66
+ "#{source_dump || File.basename(source_path)} (versions must match; there is no override)"
67
+ end
68
+
69
+ if !force && (existing = imported_at(db))
70
+ return { status: :already_imported, imported_at: existing,
71
+ row_count: db.get_first_value("SELECT COUNT(*) FROM langlinks").to_i }
72
+ end
73
+
74
+ lang_filter = langs && Set.new(langs)
75
+
76
+ db.execute("DROP TABLE IF EXISTS langlinks")
77
+ # Clear stale provenance immediately: if the load below fails midway,
78
+ # the DB must be left as "not imported" (partial table only), so the
79
+ # next non-force run re-imports instead of reporting a stale success
80
+ db.execute("DELETE FROM metadata WHERE key LIKE 'langlinks\\_%' ESCAPE '\\'")
81
+ db.execute(<<~SQL)
82
+ CREATE TABLE langlinks (
83
+ ll_from INTEGER NOT NULL,
84
+ ll_lang TEXT NOT NULL,
85
+ ll_title TEXT NOT NULL
86
+ )
87
+ SQL
88
+
89
+ row_count = 0
90
+ batch = []
91
+ flush = lambda do
92
+ db.transaction do
93
+ stmt = db.prepare("INSERT INTO langlinks (ll_from, ll_lang, ll_title) VALUES (?, ?, ?)")
94
+ batch.each { |row| stmt.execute(row) }
95
+ stmt.close
96
+ end
97
+ row_count += batch.size
98
+ progress&.call(row_count)
99
+ batch.clear
100
+ end
101
+
102
+ skipped_invalid = each_source_row(source_path) do |ll_from, ll_lang, ll_title|
103
+ next if lang_filter && !lang_filter.include?(ll_lang)
104
+
105
+ batch << [ll_from, ll_lang, MetadataIndex.normalize_title(ll_title)]
106
+ flush.call if batch.size >= BATCH_SIZE
107
+ end
108
+ flush.call unless batch.empty?
109
+
110
+ # Indexes are created after the load, not before (insert speed)
111
+ db.execute("CREATE INDEX idx_langlinks_from ON langlinks(ll_from, ll_lang)")
112
+ db.execute("CREATE INDEX idx_langlinks_lang_title ON langlinks(ll_lang, ll_title)")
113
+
114
+ stamp_provenance(db, source_path, langs, row_count, skipped_invalid)
115
+
116
+ { status: :imported, row_count: row_count, skipped_invalid: skipped_invalid,
117
+ provenance: read_provenance(db),
118
+ sanity: sanity_check(db, dump_name) }
119
+ ensure
120
+ db&.close
121
+ end
122
+
123
+ private
124
+
125
+ def open_db
126
+ db = SQLite3::Database.new(@db_path)
127
+ db.busy_timeout = 5000
128
+ db
129
+ end
130
+
131
+ def metadata_value(db, key)
132
+ db.get_first_value("SELECT value FROM metadata WHERE key = ?", [key])
133
+ rescue SQLite3::Exception
134
+ nil
135
+ end
136
+
137
+ # Non-nil only when a previous completed import is still in place
138
+ def imported_at(db)
139
+ table = db.get_first_value(
140
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'langlinks'"
141
+ )
142
+ table && metadata_value(db, "langlinks_imported_at")
143
+ end
144
+
145
+ def stamp_provenance(db, source_path, langs, row_count, skipped_invalid)
146
+ values = {
147
+ langlinks_source: File.basename(source_path),
148
+ langlinks_source_size: File.size(source_path),
149
+ langlinks_imported_at: Time.now.utc.iso8601,
150
+ langlinks_wp2txt_version: Wp2txt::VERSION,
151
+ langlinks_lang_filter: langs.nil? ? "all" : langs.join(","),
152
+ langlinks_row_count: row_count,
153
+ langlinks_skipped_invalid: skipped_invalid
154
+ }
155
+ stmt = db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)")
156
+ values.each { |k, v| stmt.execute([k.to_s, v.to_s]) }
157
+ stmt.close
158
+ end
159
+
160
+ def read_provenance(db)
161
+ {
162
+ source: metadata_value(db, "langlinks_source"),
163
+ source_size: metadata_value(db, "langlinks_source_size").to_i,
164
+ imported_at: metadata_value(db, "langlinks_imported_at"),
165
+ imported_with: metadata_value(db, "langlinks_wp2txt_version"),
166
+ lang_filter: metadata_value(db, "langlinks_lang_filter"),
167
+ row_count: metadata_value(db, "langlinks_row_count").to_i,
168
+ skipped_invalid: metadata_value(db, "langlinks_skipped_invalid").to_i
169
+ }
170
+ end
171
+
172
+ # Post-import sanity check (§1.6): for each target language whose meta DB
173
+ # is installed locally, draw a random sample and report how many ll_title
174
+ # values exist in that DB's pages.title
175
+ def sanity_check(db, dump_name)
176
+ reports = []
177
+ main_date = dump_name[/\d{8}\z/]
178
+ db.execute("SELECT DISTINCT ll_lang FROM langlinks ORDER BY ll_lang").flatten.each do |lang|
179
+ candidates = MetadataIndex.cached_candidates(lang, cache_dir: @cache_dir)
180
+ next if candidates.empty? # target edition not installed locally: skip
181
+
182
+ pick = candidates.find { |c| c[:dump_name].to_s.end_with?(main_date.to_s) } || candidates.first
183
+ sample = db.execute(
184
+ "SELECT ll_title FROM langlinks WHERE ll_lang = ? ORDER BY RANDOM() LIMIT ?",
185
+ [lang, SANITY_SAMPLE_SIZE]
186
+ ).flatten
187
+ next if sample.empty?
188
+
189
+ other = SQLite3::Database.new(pick[:db_path], readonly: true)
190
+ begin
191
+ stmt = other.prepare("SELECT 1 FROM pages WHERE title = ? LIMIT 1")
192
+ matched = sample.count { |title| stmt.execute(title).any? }
193
+ stmt.close
194
+ ensure
195
+ other.close
196
+ end
197
+
198
+ rate = matched.to_f / sample.size
199
+ reports << { lang: lang, sampled: sample.size, matched: matched,
200
+ match_rate: rate.round(3), against: pick[:dump_name],
201
+ warning: rate < SANITY_WARN_THRESHOLD }
202
+ end
203
+ reports
204
+ end
205
+
206
+ # ------------------------------------------------------------------
207
+ # Streaming MySQL dump parser
208
+ # ------------------------------------------------------------------
209
+
210
+ # Yield [ll_from, ll_lang, ll_title] for every VALID tuple of every
211
+ # INSERT INTO `langlinks` statement, streaming (the dump is never
212
+ # loaded into memory whole). Handles .sql.gz and plain .sql.
213
+ # @return [Integer] number of tuples skipped for invalid UTF-8
214
+ #
215
+ # Tuple extraction is regex-based: a hand-rolled line[i] character-index
216
+ # loop is O(n²) on multibyte (UTF-8 code-range) lines, and real extended
217
+ # INSERT lines are MB-scale with multilingual titles — the regex engine
218
+ # scans at C speed and is O(n) regardless of encoding.
219
+ #
220
+ # Lines are read as BINARY: ll_title is VARBINARY in MySQL and real dumps
221
+ # contain historically corrupted bytes, so regex matching on UTF-8-tagged
222
+ # strings can raise "invalid byte sequence". The patterns are ASCII-only,
223
+ # so they run on byte strings without encoding checks; captures are then
224
+ # tagged UTF-8 and validated — a garbled title could never join
225
+ # pages.title anyway, so such rows are skipped (and counted), not scrubbed.
226
+ def each_source_row(source_path)
227
+ io = if source_path.end_with?(".gz")
228
+ # GzipReader ignores set_encoding; the encoding must be given
229
+ # at open time (lines must come out as BINARY — see below)
230
+ Zlib::GzipReader.open(source_path, encoding: Encoding::BINARY.to_s)
231
+ else
232
+ File.open(source_path, "rb")
233
+ end
234
+
235
+ skipped = 0
236
+ begin
237
+ io.each_line do |line|
238
+ next unless INSERT_PREFIX.match?(line)
239
+
240
+ line.scan(TUPLE_REGEX) do |ll_from, ll_lang, ll_title|
241
+ lang = unescape_mysql(ll_lang).force_encoding(Encoding::UTF_8)
242
+ title = unescape_mysql(ll_title).force_encoding(Encoding::UTF_8)
243
+ unless lang.valid_encoding? && title.valid_encoding?
244
+ skipped += 1
245
+ next
246
+ end
247
+
248
+ yield ll_from.to_i, lang, title
249
+ end
250
+ end
251
+ ensure
252
+ io.close
253
+ end
254
+ skipped
255
+ end
256
+
257
+ # One extended-INSERT tuple: (123,'lang','Title'). The string classes
258
+ # [^'\\]|\\. match any run of non-quote/non-backslash characters and
259
+ # backslash escape pairs, so escaped quotes (\') and backslashes (\\) —
260
+ # and commas/parens inside titles — do not terminate the capture.
261
+ # Tuples that do not match this shape are simply not extracted
262
+ # (equivalent to the old parser skipping malformed tuples)
263
+ TUPLE_REGEX = /\((\d+),'((?:[^'\\]|\\.)*)','((?:[^'\\]|\\.)*)'\)/
264
+
265
+ UNESCAPE_REGEX = /\\(.)/m
266
+
267
+ # Resolve MySQL backslash escapes in a captured string literal
268
+ # (same mapping as the old hand-rolled parser)
269
+ def unescape_mysql(str)
270
+ str.gsub(UNESCAPE_REGEX) { UNESCAPES[::Regexp.last_match(1)] || ::Regexp.last_match(1) }
271
+ end
272
+ end
273
+ end