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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "corpus"
5
+
6
+ module Wp2txt
7
+ # In-process job manager for long-running corpus extractions (MCP server).
8
+ # Each job runs in its own thread with its own Corpus instance (separate
9
+ # SQLite connections and reader), so it never contends with the main
10
+ # request thread. Jobs are lost when the server exits (documented v1 limit).
11
+ class CorpusJobManager
12
+ # @param corpus_factory [#call] returns a fresh Corpus for each job
13
+ def initialize(corpus_factory)
14
+ @factory = corpus_factory
15
+ @jobs = {}
16
+ @mutex = Mutex.new
17
+ @seq = 0
18
+ end
19
+
20
+ # Start an extraction job. params are Corpus#extract_corpus keywords.
21
+ # Only one job runs at a time: each job forks worker processes, and
22
+ # unbounded concurrent jobs would multiply workers against the same dump.
23
+ # @return [Hash] { job_id:, status: "running" } or { error: ... }
24
+ def start_extract(params)
25
+ running = @mutex.synchronize { @jobs.values.find { |s| s[:status] == "running" } }
26
+ if running
27
+ return { error: "another job is already running (#{running[:job_id]}); " \
28
+ "poll job_status or cancel_job before starting a new one" }
29
+ end
30
+
31
+ job_id = @mutex.synchronize { format("job-%04d", @seq += 1) }
32
+ state = {
33
+ job_id: job_id, status: "running", started_at: Time.now.utc.iso8601,
34
+ params: params, titles_done: 0, titles_total: nil, cancel: false
35
+ }
36
+ @mutex.synchronize { @jobs[job_id] = state }
37
+
38
+ thread = Thread.new do
39
+ corpus = @factory.call
40
+ begin
41
+ result = corpus.extract_corpus(
42
+ **params,
43
+ max_articles: nil,
44
+ progress: lambda { |done, total|
45
+ update(job_id) { |s| s[:titles_done] = done; s[:titles_total] = total }
46
+ },
47
+ cancel_check: -> { read(job_id)[:cancel] }
48
+ )
49
+ update(job_id) { |s| s[:status] = "completed"; s[:result] = result; s[:finished_at] = Time.now.utc.iso8601 }
50
+ rescue Corpus::Cancelled
51
+ update(job_id) { |s| s[:status] = "cancelled"; s[:finished_at] = Time.now.utc.iso8601 }
52
+ rescue StandardError => e
53
+ update(job_id) { |s| s[:status] = "error"; s[:error] = "#{e.class}: #{e.message}"; s[:finished_at] = Time.now.utc.iso8601 }
54
+ ensure
55
+ corpus.close
56
+ end
57
+ end
58
+ thread.report_on_exception = false
59
+ update(job_id) { |s| s[:thread] = thread }
60
+
61
+ { job_id: job_id, status: "running" }
62
+ end
63
+
64
+ # @return [Hash, nil] public job state (no thread object), nil if unknown
65
+ def status(job_id)
66
+ state = read(job_id)
67
+ return nil unless state
68
+
69
+ public_view(state)
70
+ end
71
+
72
+ # Request cancellation; takes effect at the next batch boundary
73
+ def cancel(job_id)
74
+ state = read(job_id)
75
+ return nil unless state
76
+
77
+ update(job_id) { |s| s[:cancel] = true }
78
+ { job_id: job_id, status: state[:status], cancel_requested: true }
79
+ end
80
+
81
+ def list
82
+ @mutex.synchronize { @jobs.values.map { |s| public_view(s) } }
83
+ end
84
+
85
+ private
86
+
87
+ def read(job_id)
88
+ @mutex.synchronize { @jobs[job_id] }
89
+ end
90
+
91
+ def update(job_id)
92
+ @mutex.synchronize do
93
+ state = @jobs[job_id]
94
+ yield state if state
95
+ end
96
+ end
97
+
98
+ def public_view(state)
99
+ view = state.reject { |k, _v| %i[thread cancel].include?(k) }
100
+ if state[:titles_total] && state[:titles_total].positive?
101
+ view[:progress] = (state[:titles_done].to_f / state[:titles_total]).round(3)
102
+ end
103
+ view
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,445 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "fileutils"
5
+ require "digest"
6
+ require "open3"
7
+ require "parallel"
8
+ require "time"
9
+ require_relative "utils"
10
+ require_relative "article"
11
+ require_relative "metadata_index"
12
+ require_relative "version"
13
+
14
+ module Wp2txt
15
+ # Tier 2: contentless FTS5 full-text index over cleaned section text.
16
+ # Stores only the inverted index plus a rowid -> (page_id, heading, ord)
17
+ # mapping; the dump itself remains the single source of truth for text
18
+ # (snippets are re-rendered on demand via multistream random access).
19
+ # Queries ATTACH the Tier 1 metadata DB so category/section/redirect
20
+ # filters compose with MATCH in plain SQL.
21
+ class FtsIndex
22
+ SCHEMA_VERSION = 2
23
+ CACHE_SUFFIX = "_fts.sqlite3"
24
+
25
+ # Languages without word delimiters: use character-trigram tokenization
26
+ TRIGRAM_LANGS = %w[ja zh ko yue wuu th km lo my bo].freeze
27
+ TOKENIZERS = %w[unicode61 trigram porter].freeze
28
+
29
+ attr_reader :db_path, :meta_db_path
30
+
31
+ def initialize(db_path, meta_db_path)
32
+ @db_path = db_path
33
+ @meta_db_path = meta_db_path
34
+ @db = nil
35
+ end
36
+
37
+ def self.path_for(multistream_path, cache_dir: nil)
38
+ dir = cache_dir || File.expand_path("~/.wp2txt/cache")
39
+ basename = File.basename(multistream_path, ".*").sub(/\.xml\z/, "")
40
+ path_hash = Digest::MD5.hexdigest(multistream_path)[0, 8]
41
+ File.join(dir, "#{basename}_#{path_hash}#{CACHE_SUFFIX}")
42
+ end
43
+
44
+ # Pick a tokenizer from the dump's language (e.g. "jawiki-..." -> trigram)
45
+ def self.default_tokenizer(multistream_path)
46
+ lang = File.basename(multistream_path)[/\A([a-z_-]+?)wiki/, 1]
47
+ TRIGRAM_LANGS.include?(lang) ? "trigram" : "unicode61"
48
+ end
49
+
50
+ # ------------------------------------------------------------------
51
+ # Status
52
+ # ------------------------------------------------------------------
53
+
54
+ def built?
55
+ return false unless File.exist?(@db_path)
56
+
57
+ meta = read_metadata
58
+ !meta.nil? && meta[:schema_version].to_i == SCHEMA_VERSION && !meta[:built_at].nil?
59
+ rescue SQLite3::Exception
60
+ false
61
+ end
62
+
63
+ def valid_for?(multistream_path)
64
+ return false unless built?
65
+ return false unless File.exist?(multistream_path)
66
+
67
+ meta = read_metadata
68
+ stat = File.stat(multistream_path)
69
+ meta[:source_size].to_i == stat.size && meta[:source_mtime].to_i == stat.mtime.to_i
70
+ end
71
+
72
+ def tokenizer
73
+ read_metadata&.dig(:tokenizer)
74
+ end
75
+
76
+ def stats
77
+ return nil unless File.exist?(@db_path)
78
+
79
+ meta = read_metadata || {}
80
+ { db_path: @db_path, db_size: File.size(@db_path),
81
+ tokenizer: meta[:tokenizer], built_at: meta[:built_at],
82
+ built_with: meta[:wp2txt_version],
83
+ render_current: meta[:render_digest] == self.class.current_render_digest,
84
+ optimized: meta[:optimized] == "true",
85
+ section_count: (open_db.get_first_value("SELECT COUNT(*) FROM fts_map") rescue 0) }
86
+ end
87
+
88
+ def close
89
+ @db&.close
90
+ @db = nil
91
+ end
92
+
93
+ # ------------------------------------------------------------------
94
+ # Build API (parent process only)
95
+ # ------------------------------------------------------------------
96
+
97
+ # Build into a sidecar file and atomically rename in finalize_build!, so a
98
+ # failed multi-hour rebuild never destroys a working index
99
+ def prepare_build!(tokenizer:)
100
+ raise ArgumentError, "unknown tokenizer: #{tokenizer}" unless TOKENIZERS.include?(tokenizer)
101
+
102
+ FileUtils.mkdir_p(File.dirname(@db_path))
103
+ close
104
+ @build_path = "#{@db_path}.building"
105
+ FileUtils.rm_f([@build_path, "#{@build_path}-wal", "#{@build_path}-shm"])
106
+ db = open_db
107
+ db.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)")
108
+ db.execute("CREATE VIRTUAL TABLE fts_sections USING fts5(text, content='', tokenize='#{tokenizer}')")
109
+ db.execute(<<~SQL)
110
+ CREATE TABLE fts_map (
111
+ rowid INTEGER PRIMARY KEY,
112
+ page_id INTEGER,
113
+ -- heading: normalized like page_sections.heading; '' for the lead text
114
+ heading TEXT,
115
+ -- ord: section position in the article; 0 = lead text (stored here,
116
+ -- unlike page_sections which starts at 1). Same numbering otherwise.
117
+ ord INTEGER
118
+ )
119
+ SQL
120
+ @pending_tokenizer = tokenizer
121
+ @next_rowid = 0
122
+ end
123
+
124
+ # rows: [[page_id, heading, ord, text], ...]
125
+ def insert_batch(rows)
126
+ db = open_db
127
+ db.transaction do
128
+ fts_stmt = db.prepare("INSERT INTO fts_sections (rowid, text) VALUES (?, ?)")
129
+ map_stmt = db.prepare("INSERT INTO fts_map (rowid, page_id, heading, ord) VALUES (?, ?, ?, ?)")
130
+ rows.each do |page_id, heading, ord, text|
131
+ @next_rowid += 1
132
+ fts_stmt.execute([@next_rowid, text])
133
+ map_stmt.execute([@next_rowid, page_id, heading, ord])
134
+ end
135
+ fts_stmt.close
136
+ map_stmt.close
137
+ end
138
+ end
139
+
140
+ # @param optimize [Boolean] merge FTS segments into one (single-threaded and
141
+ # slow on large indexes; skip and run #optimize! later if build time matters)
142
+ # Digest of everything that determines how section text is rendered.
143
+ # Snippets re-render sections at query time and must reproduce what was
144
+ # indexed; a digest mismatch means the index was built by code whose
145
+ # rendering differs from the current code.
146
+ def self.current_render_digest
147
+ Digest::MD5.hexdigest(SectionRenderer::RENDER_CONFIG.inspect)[0, 12]
148
+ end
149
+
150
+ def finalize_build!(source_path, optimize: true)
151
+ db = open_db
152
+ db.execute("CREATE INDEX IF NOT EXISTS idx_fts_map_page ON fts_map(page_id)")
153
+ db.execute("INSERT INTO fts_sections(fts_sections) VALUES('optimize')") if optimize
154
+ stat = File.stat(source_path)
155
+ save_metadata(
156
+ schema_version: SCHEMA_VERSION,
157
+ wp2txt_version: Wp2txt::VERSION,
158
+ render_digest: self.class.current_render_digest,
159
+ tokenizer: @pending_tokenizer,
160
+ source_path: source_path,
161
+ source_size: stat.size,
162
+ source_mtime: stat.mtime.to_i,
163
+ optimized: optimize,
164
+ built_at: Time.now.utc.iso8601
165
+ )
166
+ close
167
+ if @build_path
168
+ File.rename(@build_path, @db_path)
169
+ FileUtils.rm_f(["#{@db_path}-wal", "#{@db_path}-shm"])
170
+ @build_path = nil
171
+ end
172
+ end
173
+
174
+ # True when the current code's rendering configuration matches what this
175
+ # index was built with (snippet fidelity guarantee)
176
+ def render_current?
177
+ read_metadata&.dig(:render_digest) == self.class.current_render_digest
178
+ end
179
+
180
+ # Merge all FTS segments of an existing index (idempotent). Queries work
181
+ # without this, just somewhat slower; run once, any time after a
182
+ # --no-fts-optimize build.
183
+ def optimize!
184
+ raise ArgumentError, "index not built" unless built?
185
+
186
+ db = open_db
187
+ db.execute("INSERT INTO fts_sections(fts_sections) VALUES('optimize')")
188
+ db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
189
+ save_metadata(optimized: true)
190
+ true
191
+ end
192
+
193
+ def optimized?
194
+ read_metadata&.dig(:optimized) == "true"
195
+ end
196
+
197
+ # ------------------------------------------------------------------
198
+ # Search
199
+ # ------------------------------------------------------------------
200
+
201
+ # @param query [String] search string
202
+ # @param mode [String] "phrase" (literal, default) or "query" (raw FTS5 syntax)
203
+ # @param sections [Array<String>, nil] restrict to these headings
204
+ # @param category [String, nil] category scope (recursion via depth)
205
+ # @param count [String] "capped" (default; stops at count_cap) or "exact"
206
+ # @return [Hash] { total:, total_is_capped:, hits: [{page_id:, title:, heading:, ord:}] }
207
+ def search(query, mode: "phrase", sections: nil, category: nil, depth: 0,
208
+ limit: 20, offset: 0, count: "capped", count_cap: 1000)
209
+ match_expr = mode == "query" ? query : phrase_query(query)
210
+
211
+ conds = ["fts_sections MATCH ?", "p.namespace = 0", "p.redirect_to IS NULL"]
212
+ params = [match_expr]
213
+ cte = nil
214
+
215
+ if category
216
+ cte, cond, cat_params = category_condition(category, depth)
217
+ conds << cond
218
+ params = cat_params + params if cte # CTE placeholders precede MATCH in SQL order
219
+ params += cat_params unless cte
220
+ end
221
+
222
+ if sections && !sections.empty?
223
+ placeholders = sections.map { "?" }.join(",")
224
+ conds << "fm.heading COLLATE NOCASE IN (#{placeholders})"
225
+ params += sections
226
+ end
227
+
228
+ base = "FROM fts_sections f JOIN fts_map fm ON fm.rowid = f.rowid " \
229
+ "JOIN meta.pages p ON p.page_id = fm.page_id WHERE #{conds.join(' AND ')}"
230
+ with = cte ? "WITH RECURSIVE #{cte} " : ""
231
+
232
+ db = attached_db
233
+ hits = db.execute(
234
+ "#{with}SELECT fm.page_id, p.title, fm.heading, fm.ord #{base} ORDER BY rank LIMIT ? OFFSET ?",
235
+ params + [limit, offset]
236
+ ).map { |page_id, title, heading, ord| { page_id: page_id, title: title, heading: heading, ord: ord } }
237
+
238
+ if count == "exact"
239
+ total = db.get_first_value("#{with}SELECT COUNT(*) #{base}", params).to_i
240
+ { total: total, total_is_capped: false, hits: hits }
241
+ else
242
+ capped = db.get_first_value(
243
+ "#{with}SELECT COUNT(*) FROM (SELECT 1 #{base} LIMIT #{count_cap.to_i + 1})", params
244
+ ).to_i
245
+ { total: [capped, count_cap].min, total_is_capped: capped > count_cap, hits: hits }
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ # Escape a literal string as an FTS5 phrase query
252
+ def phrase_query(str)
253
+ %("#{str.gsub('"', '""')}")
254
+ end
255
+
256
+ # Category filter against the ATTACHed metadata DB (meta.*); mirrors
257
+ # MetadataIndex#category_condition
258
+ def category_condition(category, depth)
259
+ cat = MetadataIndex.normalize_category(category)
260
+ if depth.to_i.positive?
261
+ cte = <<~SQL.strip
262
+ cat_tree(name, d) AS (
263
+ SELECT ?, 0
264
+ UNION
265
+ SELECT ch.child, ct.d + 1
266
+ FROM meta.category_hierarchy ch JOIN cat_tree ct ON ch.parent = ct.name
267
+ WHERE ct.d < #{depth.to_i}
268
+ )
269
+ SQL
270
+ cond = "p.page_id IN (SELECT pc.page_id FROM meta.page_categories pc WHERE pc.category IN (SELECT name FROM cat_tree))"
271
+ [cte, cond, [cat]]
272
+ else
273
+ [nil, "p.page_id IN (SELECT pc.page_id FROM meta.page_categories pc WHERE pc.category = ?)", [cat]]
274
+ end
275
+ end
276
+
277
+ def attached_db
278
+ db = open_db
279
+ unless @attached
280
+ db.execute("ATTACH DATABASE ? AS meta", [@meta_db_path])
281
+ @attached = true
282
+ end
283
+ db
284
+ end
285
+
286
+ def open_db
287
+ return @db if @db
288
+
289
+ @db = SQLite3::Database.new(@build_path || @db_path)
290
+ @db.busy_timeout = 5000
291
+ @db.execute("PRAGMA journal_mode = WAL")
292
+ @db.execute("PRAGMA synchronous = NORMAL")
293
+ @db.execute("PRAGMA cache_size = -64000")
294
+ @attached = false
295
+ @db
296
+ end
297
+
298
+ def read_metadata
299
+ result = {}
300
+ open_db.execute("SELECT key, value FROM metadata") do |key, value|
301
+ result[key.to_sym] = value
302
+ end
303
+ result
304
+ rescue SQLite3::Exception
305
+ nil
306
+ end
307
+
308
+ def save_metadata(hash)
309
+ db = open_db
310
+ stmt = db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)")
311
+ hash.each { |k, v| stmt.execute([k.to_s, v.to_s]) }
312
+ stmt.close
313
+ end
314
+ end
315
+
316
+ # Renders an article's wikitext into cleaned per-section texts.
317
+ # Shared by the FTS builder (indexing) and Corpus snippets (re-rendering).
318
+ class SectionRenderer
319
+ include Wp2txt
320
+
321
+ RENDER_CONFIG = {
322
+ format: :text, title: true, heading: true, list: false, table: false,
323
+ pre: false, ref: false, redirect: false, multiline: false, category: false,
324
+ marker: true, markers: true, extract_citations: false, expand_templates: true
325
+ }.freeze
326
+
327
+ # @return [Array<Array(String, Integer, String)>] [heading ("" = lead), ord, text]
328
+ def render_sections(title, wikitext)
329
+ article = Article.new(wikitext, title, false)
330
+ config = RENDER_CONFIG.merge(title: title)
331
+
332
+ sections = []
333
+ heading = ""
334
+ ord = 0
335
+ buffer = +""
336
+
337
+ flush = lambda do
338
+ text = cleanup(format_wiki(buffer, config)).strip
339
+ sections << [heading, ord, text] unless text.empty?
340
+ end
341
+
342
+ article.elements.each do |element|
343
+ if element[0] == :mw_heading
344
+ flush.call
345
+ # Same normalization as the metadata index (MetadataIndex.clean_heading)
346
+ # so fts_map.heading and page_sections.heading always agree — section
347
+ # filters discovered via section_stats must match here too
348
+ raw = element[1].to_s.gsub(/\A[\s\n]*=+\s*/, "").gsub(/\s*=+[\s\n]*\z/, "")
349
+ heading = MetadataIndex.clean_heading(raw)
350
+ ord += 1
351
+ buffer = +""
352
+ else
353
+ buffer << element[1].to_s
354
+ end
355
+ end
356
+ flush.call
357
+ sections
358
+ end
359
+ end
360
+
361
+ # Builds an FtsIndex by scanning all streams in parallel: workers decompress,
362
+ # parse, and render cleaned section text; the parent owns the sole SQLite
363
+ # connection and inserts from the Parallel finish hook.
364
+ class FtsIndexBuilder
365
+ # Smaller batches than the metadata builder: rendering dominates, so this
366
+ # keeps progress granular and per-batch IPC payloads moderate
367
+ STREAMS_PER_BATCH = 10
368
+
369
+ def initialize(multistream_path, stream_offsets, db_path:, meta_db_path:,
370
+ tokenizer: nil, num_processes: 4, optimize: true)
371
+ @multistream_path = multistream_path
372
+ @stream_offsets = stream_offsets
373
+ @db_path = db_path
374
+ @meta_db_path = meta_db_path
375
+ @tokenizer = tokenizer || FtsIndex.default_tokenizer(multistream_path)
376
+ @num_processes = num_processes
377
+ @optimize = optimize
378
+ end
379
+
380
+ def build(&progress)
381
+ index = FtsIndex.new(@db_path, @meta_db_path)
382
+ index.prepare_build!(tokenizer: @tokenizer)
383
+ index.close
384
+
385
+ pairs = @stream_offsets.zip(@stream_offsets[1..].to_a + [nil])
386
+ batches = pairs.each_slice(STREAMS_PER_BATCH).to_a
387
+ done = 0
388
+
389
+ Parallel.map(
390
+ batches,
391
+ in_processes: @num_processes,
392
+ finish: lambda { |_item, _idx, rows|
393
+ index.insert_batch(rows)
394
+ done += 1
395
+ progress&.call(done, batches.size)
396
+ }
397
+ ) do |batch|
398
+ self.class.scan_batch(@multistream_path, batch)
399
+ end
400
+
401
+ index.finalize_build!(@multistream_path, optimize: @optimize)
402
+ index
403
+ end
404
+
405
+ # Runs in worker processes: must not touch SQLite
406
+ def self.scan_batch(multistream_path, offset_pairs)
407
+ renderer = SectionRenderer.new
408
+ rows = []
409
+ File.open(multistream_path, "rb") do |f|
410
+ offset_pairs.each do |offset, next_offset|
411
+ f.seek(offset)
412
+ data = next_offset ? f.read(next_offset - offset) : f.read
413
+ xml = MetadataIndexBuilder.decompress_bz2(data)
414
+ xml.scan(MetadataIndexBuilder::PAGE_BLOCK_REGEX) do
415
+ scan_page(::Regexp.last_match(1), renderer, rows)
416
+ end
417
+ end
418
+ end
419
+ rows
420
+ end
421
+
422
+ def self.scan_page(block, renderer, rows)
423
+ title = block[MetadataIndexBuilder::TITLE_REGEX, 1]
424
+ return unless title && !title.empty?
425
+
426
+ ns = (block[MetadataIndexBuilder::NS_REGEX, 1] || "0").to_i
427
+ return unless ns.zero?
428
+
429
+ page_id = block[MetadataIndexBuilder::ID_REGEX, 1]&.to_i
430
+ return unless page_id
431
+
432
+ title = MetadataIndexBuilder.unescape_xml(title)
433
+ text = block[MetadataIndexBuilder::TEXT_REGEX, 1] || ""
434
+ text = MetadataIndexBuilder.unescape_xml(text)
435
+ return if REDIRECT_REGEX.match?(text)
436
+
437
+ renderer.render_sections(title, text).each do |heading, ord, section_text|
438
+ rows << [page_id, heading, ord, section_text]
439
+ end
440
+ rescue StandardError
441
+ # A single malformed page must not abort the whole batch
442
+ nil
443
+ end
444
+ end
445
+ end
@@ -155,6 +155,20 @@ module Wp2txt
155
155
  end
156
156
  end
157
157
 
158
+ # All stream offsets in ascending order (for lazy title lookup without
159
+ # loading the full index into memory)
160
+ # @return [Array<Integer>] sorted byte offsets
161
+ def stream_offsets
162
+ return [] unless valid?
163
+
164
+ open_db
165
+ begin
166
+ @db.execute("SELECT byte_offset FROM stream_offsets ORDER BY byte_offset").map { |row| row[0] }
167
+ ensure
168
+ close_db
169
+ end
170
+ end
171
+
158
172
  # Get cache statistics
159
173
  def stats
160
174
  return nil unless File.exist?(@cache_path)
@@ -199,6 +213,9 @@ module Wp2txt
199
213
 
200
214
  def open_db
201
215
  @db ||= SQLite3::Database.new(@cache_path)
216
+ # Wait out transient SQLITE_BUSY from concurrent readers (e.g., parallel
217
+ # extraction workers opening the cache simultaneously) instead of failing
218
+ @db.busy_timeout = 5000
202
219
  # Performance optimizations
203
220
  @db.execute("PRAGMA journal_mode = WAL")
204
221
  @db.execute("PRAGMA synchronous = NORMAL")