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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/Gemfile +2 -0
- data/README.md +79 -1
- data/bin/wp2txt +8 -0
- data/bin/wp2txt-mcp +396 -0
- data/lib/wp2txt/cli.rb +67 -0
- data/lib/wp2txt/corpus.rb +722 -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 +344 -0
- data/lib/wp2txt/metadata_index.rb +672 -0
- data/lib/wp2txt/multistream.rb +9 -5
- data/lib/wp2txt/version.rb +1 -1
- data/spec/corpus_spec.rb +503 -0
- data/spec/fts_index_spec.rb +245 -0
- data/spec/metadata_index_spec.rb +208 -0
- data/spec/support/multistream_fixture.rb +66 -0
- metadata +16 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "metadata_index"
|
|
5
|
+
require_relative "fts_index"
|
|
6
|
+
require_relative "corpus"
|
|
7
|
+
require_relative "multistream"
|
|
8
|
+
require_relative "memory_monitor"
|
|
9
|
+
|
|
10
|
+
module Wp2txt
|
|
11
|
+
# CLI command handlers for --build-index and --find-articles.
|
|
12
|
+
# Mixed into WpApp (bin/wp2txt); relies on CliUI helpers for output.
|
|
13
|
+
module IndexCommands
|
|
14
|
+
# Build (or refresh) the local metadata index for a dump
|
|
15
|
+
def run_build_index(opts)
|
|
16
|
+
multistream_path, index_path = resolve_dump_paths(opts, download: true)
|
|
17
|
+
return CliUI::EXIT_ERROR unless multistream_path
|
|
18
|
+
|
|
19
|
+
db_path = MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir])
|
|
20
|
+
meta = MetadataIndex.new(db_path)
|
|
21
|
+
|
|
22
|
+
if meta.valid_for?(multistream_path) && !opts[:update_cache]
|
|
23
|
+
print_success("Metadata index is up to date: #{db_path}")
|
|
24
|
+
print_index_stats(meta.stats)
|
|
25
|
+
meta.close
|
|
26
|
+
return CliUI::EXIT_SUCCESS unless opts[:fulltext]
|
|
27
|
+
|
|
28
|
+
stream_offsets, = load_stream_offsets(index_path, opts)
|
|
29
|
+
num_processes = opts[:num_procs] || MemoryMonitor.optimal_processes
|
|
30
|
+
return build_fulltext_index(opts, multistream_path, stream_offsets, db_path, num_processes)
|
|
31
|
+
end
|
|
32
|
+
meta.close
|
|
33
|
+
|
|
34
|
+
print_mode_banner("Build Metadata Index", {
|
|
35
|
+
"Dump" => File.basename(multistream_path),
|
|
36
|
+
"Output" => db_path
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
time_start = Time.now
|
|
40
|
+
puts pastel.cyan("Loading multistream index...") unless quiet?
|
|
41
|
+
stream_offsets, entry_count = load_stream_offsets(index_path, opts)
|
|
42
|
+
if stream_offsets.empty?
|
|
43
|
+
print_error("Multistream index is empty or unreadable: #{index_path}")
|
|
44
|
+
return CliUI::EXIT_ERROR
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
num_processes = opts[:num_procs] || MemoryMonitor.optimal_processes
|
|
48
|
+
puts pastel.cyan("Scanning #{entry_count} pages in #{stream_offsets.size} streams (#{num_processes} processes)...") unless quiet?
|
|
49
|
+
|
|
50
|
+
ok = run_phase_isolated do
|
|
51
|
+
builder = MetadataIndexBuilder.new(
|
|
52
|
+
multistream_path, stream_offsets,
|
|
53
|
+
db_path: db_path, num_processes: num_processes
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
last_report = Time.now
|
|
57
|
+
built = builder.build do |done, total|
|
|
58
|
+
now = Time.now
|
|
59
|
+
if !quiet? && (now - last_report >= DEFAULT_PROGRESS_INTERVAL || done == total)
|
|
60
|
+
last_report = now
|
|
61
|
+
percent = (done.to_f / total * 100).round(1)
|
|
62
|
+
elapsed = now - time_start
|
|
63
|
+
eta = done.positive? ? (total - done) * (elapsed / done) : 0
|
|
64
|
+
puts pastel.dim(format(" [%d/%d] %.1f%% | ETA: %s", done, total, percent, format_duration(eta)))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts unless quiet?
|
|
69
|
+
print_success("Metadata index built in #{format_duration(Time.now - time_start)}")
|
|
70
|
+
print_index_stats(built.stats)
|
|
71
|
+
built.close
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
return CliUI::EXIT_ERROR unless ok
|
|
75
|
+
|
|
76
|
+
return build_fulltext_index(opts, multistream_path, stream_offsets, db_path, num_processes) if opts[:fulltext]
|
|
77
|
+
|
|
78
|
+
CliUI::EXIT_SUCCESS
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Run a build phase in a forked child process. Each phase pumps its whole
|
|
82
|
+
# dataset through the parent's heap (Marshal.load of every worker batch),
|
|
83
|
+
# leaving a multi-GB high-water mark; a later phase forking workers from
|
|
84
|
+
# that bloated parent duplicates it per worker via copy-on-write breakage
|
|
85
|
+
# and OOMs the machine (observed on enwiki: 18GB per FTS worker). A child
|
|
86
|
+
# process confines each phase's high-water mark to that phase.
|
|
87
|
+
# @return [Boolean] whether the phase succeeded
|
|
88
|
+
def run_phase_isolated(&block)
|
|
89
|
+
return block.call unless Process.respond_to?(:fork)
|
|
90
|
+
|
|
91
|
+
pid = Process.fork do
|
|
92
|
+
status = 1
|
|
93
|
+
begin
|
|
94
|
+
status = block.call ? 0 : 1
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
warn "#{e.class}: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
$stdout.flush
|
|
99
|
+
$stderr.flush
|
|
100
|
+
exit!(status) # skip at_exit handlers; they belong to the parent
|
|
101
|
+
end
|
|
102
|
+
_, status = Process.waitpid2(pid)
|
|
103
|
+
status.success?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Load stream offsets without holding the full title index in memory.
|
|
107
|
+
# The builders fork worker processes; a parent heap holding millions of
|
|
108
|
+
# index entries (~14GB for enwiki) gets duplicated through copy-on-write
|
|
109
|
+
# breakage in every child and OOMs the machine. Offsets are a small
|
|
110
|
+
# integer array, so prefer reading just them from the SQLite cache.
|
|
111
|
+
# @return [Array(Array<Integer>, Integer)] [stream_offsets, entry_count]
|
|
112
|
+
def load_stream_offsets(index_path, opts)
|
|
113
|
+
cache = IndexCache.new(index_path, cache_dir: opts[:cache_dir])
|
|
114
|
+
if cache.valid?
|
|
115
|
+
[cache.stream_offsets, cache.stats[:entry_count]]
|
|
116
|
+
else
|
|
117
|
+
# First run: parse the index (this also writes the SQLite cache),
|
|
118
|
+
# then discard the entry hashes before any fork
|
|
119
|
+
ms_index = MultistreamIndex.new(index_path, cache_dir: opts[:cache_dir], show_progress: !quiet?)
|
|
120
|
+
offsets = ms_index.stream_offsets
|
|
121
|
+
count = ms_index.size
|
|
122
|
+
ms_index = nil
|
|
123
|
+
GC.start
|
|
124
|
+
GC.compact if GC.respond_to?(:compact)
|
|
125
|
+
[offsets, count]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Build the FTS5 full-text index (Tier 2) after the metadata index
|
|
130
|
+
def build_fulltext_index(opts, multistream_path, stream_offsets, meta_db_path, num_processes)
|
|
131
|
+
fts_db_path = FtsIndex.path_for(multistream_path, cache_dir: opts[:cache_dir])
|
|
132
|
+
tokenizer = opts[:fts_tokenizer] || FtsIndex.default_tokenizer(multistream_path)
|
|
133
|
+
|
|
134
|
+
fts = FtsIndex.new(fts_db_path, meta_db_path)
|
|
135
|
+
if fts.valid_for?(multistream_path) && !opts[:update_cache]
|
|
136
|
+
print_success("Full-text index is up to date: #{fts_db_path}")
|
|
137
|
+
fts.close
|
|
138
|
+
return CliUI::EXIT_SUCCESS
|
|
139
|
+
end
|
|
140
|
+
fts.close
|
|
141
|
+
|
|
142
|
+
puts pastel.cyan("Building full-text index (tokenizer: #{tokenizer})...") unless quiet?
|
|
143
|
+
time_start = Time.now
|
|
144
|
+
ok = run_phase_isolated do
|
|
145
|
+
builder = FtsIndexBuilder.new(
|
|
146
|
+
multistream_path, stream_offsets,
|
|
147
|
+
db_path: fts_db_path, meta_db_path: meta_db_path,
|
|
148
|
+
tokenizer: tokenizer, num_processes: num_processes,
|
|
149
|
+
optimize: !opts[:skip_fts_optimize]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
last_report = Time.now
|
|
153
|
+
built = builder.build do |done, total|
|
|
154
|
+
now = Time.now
|
|
155
|
+
if !quiet? && (now - last_report >= DEFAULT_PROGRESS_INTERVAL || done == total)
|
|
156
|
+
last_report = now
|
|
157
|
+
percent = (done.to_f / total * 100).round(1)
|
|
158
|
+
elapsed = now - time_start
|
|
159
|
+
eta = done.positive? ? (total - done) * (elapsed / done) : 0
|
|
160
|
+
puts pastel.dim(format(" [%d/%d] %.1f%% | ETA: %s", done, total, percent, format_duration(eta)))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
puts unless quiet?
|
|
165
|
+
print_success("Full-text index built in #{format_duration(Time.now - time_start)}")
|
|
166
|
+
stats = built.stats
|
|
167
|
+
print_info("Tokenizer", stats[:tokenizer].to_s)
|
|
168
|
+
print_info("Sections", stats[:section_count].to_s)
|
|
169
|
+
print_info("Size", format_size(stats[:db_size]))
|
|
170
|
+
unless stats[:optimized]
|
|
171
|
+
print_info_message("Index is unoptimized (built with --skip-fts-optimize). Run 'wp2txt --fts-optimize' later for best query speed.")
|
|
172
|
+
end
|
|
173
|
+
built.close
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
ok ? CliUI::EXIT_SUCCESS : CliUI::EXIT_ERROR
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Standalone optimize of an existing full-text index (--fts-optimize)
|
|
180
|
+
def run_fts_optimize(opts)
|
|
181
|
+
multistream_path, = resolve_dump_paths(opts, download: false)
|
|
182
|
+
return CliUI::EXIT_ERROR unless multistream_path
|
|
183
|
+
|
|
184
|
+
fts = FtsIndex.new(
|
|
185
|
+
FtsIndex.path_for(multistream_path, cache_dir: opts[:cache_dir]),
|
|
186
|
+
MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir])
|
|
187
|
+
)
|
|
188
|
+
unless fts.built?
|
|
189
|
+
print_error("Full-text index not found for this dump.")
|
|
190
|
+
print_info_message("Build it first with: wp2txt --build-index --fulltext #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}")
|
|
191
|
+
return CliUI::EXIT_ERROR
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if fts.optimized?
|
|
195
|
+
print_success("Full-text index is already optimized.")
|
|
196
|
+
fts.close
|
|
197
|
+
return CliUI::EXIT_SUCCESS
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
print_info_message("Optimizing full-text index (single-threaded; can take 30-60+ minutes on large indexes)...")
|
|
201
|
+
time_start = Time.now
|
|
202
|
+
fts.optimize!
|
|
203
|
+
print_success("Optimize complete in #{format_duration(Time.now - time_start)}")
|
|
204
|
+
print_info("Size", format_size(File.size(fts.db_path)))
|
|
205
|
+
fts.close
|
|
206
|
+
CliUI::EXIT_SUCCESS
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Full-text search from the CLI (--search)
|
|
210
|
+
def run_search(opts)
|
|
211
|
+
multistream_path, = resolve_dump_paths(opts, download: false)
|
|
212
|
+
return CliUI::EXIT_ERROR unless multistream_path
|
|
213
|
+
|
|
214
|
+
corpus = Corpus.new(
|
|
215
|
+
multistream_path: multistream_path,
|
|
216
|
+
index_path: resolve_dump_paths(opts, download: false)[1],
|
|
217
|
+
cache_dir: opts[:cache_dir]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
unless corpus.fts.built?
|
|
221
|
+
print_error("Full-text index not found for this dump.")
|
|
222
|
+
print_info_message("Build it first with: wp2txt --build-index --fulltext #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}")
|
|
223
|
+
return CliUI::EXIT_ERROR
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
result = corpus.search_text(
|
|
227
|
+
opts[:search],
|
|
228
|
+
sections: opts[:has_section] ? [opts[:has_section]] : nil,
|
|
229
|
+
category: opts[:in_category],
|
|
230
|
+
depth: opts[:in_category] ? opts[:depth] : 0,
|
|
231
|
+
limit: opts[:limit].positive? ? opts[:limit] : 20,
|
|
232
|
+
count: "exact"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if opts[:format].to_s.downcase == "json"
|
|
236
|
+
puts JSON.generate(result)
|
|
237
|
+
else
|
|
238
|
+
result[:hits].each do |hit|
|
|
239
|
+
puts "#{hit[:section_path]}: #{hit[:snippet]}"
|
|
240
|
+
end
|
|
241
|
+
$stderr.puts pastel.dim("# #{result[:returned]} of #{result[:total]} matches (dump: #{result[:dump]})")
|
|
242
|
+
end
|
|
243
|
+
corpus.close
|
|
244
|
+
CliUI::EXIT_SUCCESS
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Query the metadata index and print matching article titles
|
|
248
|
+
def run_find_articles(opts)
|
|
249
|
+
multistream_path, = resolve_dump_paths(opts, download: false)
|
|
250
|
+
return CliUI::EXIT_ERROR unless multistream_path
|
|
251
|
+
|
|
252
|
+
db_path = MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir])
|
|
253
|
+
meta = MetadataIndex.new(db_path)
|
|
254
|
+
|
|
255
|
+
unless meta.built?
|
|
256
|
+
print_error("Metadata index not found for this dump.")
|
|
257
|
+
print_info_message("Build it first with: wp2txt --build-index #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}")
|
|
258
|
+
return CliUI::EXIT_ERROR
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
unless meta.valid_for?(multistream_path)
|
|
262
|
+
print_warning("Metadata index was built from a different version of this dump. Consider re-running --build-index.")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
filters = {
|
|
266
|
+
category: opts[:in_category],
|
|
267
|
+
depth: opts[:in_category] ? opts[:depth] : 0,
|
|
268
|
+
has_section: opts[:has_section],
|
|
269
|
+
use_aliases: !opts[:no_section_aliases],
|
|
270
|
+
alias_file: opts[:alias_file],
|
|
271
|
+
title_match: opts[:title_match]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
total = meta.count_articles(**filters)
|
|
275
|
+
titles = meta.find_articles(**filters, limit: opts[:limit])
|
|
276
|
+
dump_name = meta.stats[:dump_name]
|
|
277
|
+
meta.close
|
|
278
|
+
|
|
279
|
+
if opts[:format].to_s.downcase == "json"
|
|
280
|
+
puts JSON.generate({ dump: dump_name, total: total, returned: titles.size, titles: titles })
|
|
281
|
+
else
|
|
282
|
+
titles.each { |t| puts t }
|
|
283
|
+
$stderr.puts pastel.dim("# #{titles.size} of #{total} matching articles (dump: #{dump_name})")
|
|
284
|
+
end
|
|
285
|
+
CliUI::EXIT_SUCCESS
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
def print_index_stats(stats)
|
|
291
|
+
return unless stats && !quiet?
|
|
292
|
+
|
|
293
|
+
print_info("Dump", stats[:dump_name].to_s)
|
|
294
|
+
print_info("Pages", stats[:page_count].to_s)
|
|
295
|
+
print_info("Articles", stats[:article_count].to_s)
|
|
296
|
+
print_info("Categories", stats[:category_count].to_s)
|
|
297
|
+
print_info("Sections", stats[:section_count].to_s)
|
|
298
|
+
print_info("Size", format_size(stats[:db_size]))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Resolve [multistream_path, index_path] from --lang (cached dump) or --input.
|
|
302
|
+
# Returns [nil, nil] after printing an error when files cannot be located.
|
|
303
|
+
def resolve_dump_paths(opts, download: false)
|
|
304
|
+
if opts[:lang]
|
|
305
|
+
manager = DumpManager.new(
|
|
306
|
+
opts[:lang],
|
|
307
|
+
cache_dir: opts[:cache_dir],
|
|
308
|
+
dump_expiry_days: CLI.config.dump_expiry_days
|
|
309
|
+
)
|
|
310
|
+
multistream = manager.cached_multistream_path
|
|
311
|
+
index = manager.cached_index_path
|
|
312
|
+
unless File.exist?(multistream) && File.exist?(index)
|
|
313
|
+
unless download
|
|
314
|
+
print_error("No cached dump found for '#{opts[:lang]}'.")
|
|
315
|
+
print_info_message("Download and index it with: wp2txt --build-index -L #{opts[:lang]}")
|
|
316
|
+
return [nil, nil]
|
|
317
|
+
end
|
|
318
|
+
print_header("Downloading dump files for '#{opts[:lang]}'")
|
|
319
|
+
manager.download_index
|
|
320
|
+
manager.download_multistream
|
|
321
|
+
end
|
|
322
|
+
[multistream, index]
|
|
323
|
+
else
|
|
324
|
+
multistream = opts[:input]
|
|
325
|
+
index = locate_index_file(multistream)
|
|
326
|
+
unless index
|
|
327
|
+
print_error("Could not find the multistream index file for #{multistream}")
|
|
328
|
+
print_info_message("Expected e.g. #{File.basename(multistream).sub(/multistream\.xml\.bz2\z/, 'multistream-index.txt.bz2')} next to the dump.")
|
|
329
|
+
return [nil, nil]
|
|
330
|
+
end
|
|
331
|
+
[multistream, index]
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def locate_index_file(multistream_path)
|
|
336
|
+
candidates = [
|
|
337
|
+
multistream_path.sub(/multistream\.xml\.bz2\z/, "multistream-index.txt.bz2"),
|
|
338
|
+
multistream_path.sub(/\.xml\.bz2\z/, "-index.txt.bz2"),
|
|
339
|
+
multistream_path.sub(/\.xml\.bz2\z/, "-index.txt")
|
|
340
|
+
].uniq
|
|
341
|
+
candidates.find { |c| c != multistream_path && File.exist?(c) }
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|