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,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