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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/CHANGELOG.md +27 -0
- data/Dockerfile +3 -0
- data/Gemfile +2 -0
- data/README.md +60 -2
- data/Rakefile +4 -1
- data/bin/wp2txt +9 -0
- data/bin/wp2txt-mcp +395 -0
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +110 -0
- data/lib/wp2txt/corpus.rb +1057 -0
- data/lib/wp2txt/corpus_jobs.rb +106 -0
- data/lib/wp2txt/fts_index.rb +445 -0
- data/lib/wp2txt/index_cache.rb +17 -0
- data/lib/wp2txt/index_commands.rb +427 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +738 -0
- data/lib/wp2txt/multistream.rb +38 -5
- data/lib/wp2txt/output_path.rb +27 -0
- data/lib/wp2txt/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/corpus_spec.rb +503 -0
- data/spec/fts_index_spec.rb +245 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/metadata_index_spec.rb +208 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/support/multistream_fixture.rb +66 -0
- data/spec/titles_output_path_spec.rb +338 -0
- metadata +27 -1
|
@@ -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
|