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,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
|
data/lib/wp2txt/index_cache.rb
CHANGED
|
@@ -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")
|