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,672 @@
|
|
|
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 "json"
|
|
10
|
+
require_relative "regex"
|
|
11
|
+
require_relative "section_extractor"
|
|
12
|
+
require_relative "version"
|
|
13
|
+
|
|
14
|
+
module Wp2txt
|
|
15
|
+
# Local metadata index (Tier 1) built from a multistream dump.
|
|
16
|
+
# Stores per-page categories, section headings, redirects, and the category
|
|
17
|
+
# hierarchy in SQLite, enabling offline exhaustive queries such as
|
|
18
|
+
# "all articles in category X that have a Plot section" without any API access.
|
|
19
|
+
class MetadataIndex
|
|
20
|
+
SCHEMA_VERSION = 2
|
|
21
|
+
CACHE_SUFFIX = "_meta.sqlite3"
|
|
22
|
+
NS_ARTICLE = 0
|
|
23
|
+
NS_CATEGORY = 14
|
|
24
|
+
|
|
25
|
+
attr_reader :db_path
|
|
26
|
+
|
|
27
|
+
def initialize(db_path)
|
|
28
|
+
@db_path = db_path
|
|
29
|
+
@db = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Default index location for a given multistream file (mirrors IndexCache naming)
|
|
33
|
+
def self.path_for(multistream_path, cache_dir: nil)
|
|
34
|
+
dir = cache_dir || File.expand_path("~/.wp2txt/cache")
|
|
35
|
+
basename = File.basename(multistream_path, ".*").sub(/\.xml\z/, "")
|
|
36
|
+
path_hash = Digest::MD5.hexdigest(multistream_path)[0, 8]
|
|
37
|
+
File.join(dir, "#{basename}_#{path_hash}#{CACHE_SUFFIX}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Normalize a category name the way MediaWiki treats titles:
|
|
41
|
+
# underscores to spaces, trimmed, first letter capitalized
|
|
42
|
+
def self.normalize_category(name)
|
|
43
|
+
n = name.to_s.tr("_", " ").strip.squeeze(" ")
|
|
44
|
+
return n if n.empty?
|
|
45
|
+
|
|
46
|
+
n[0].upcase + n[1..].to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Remove wiki markup from a heading ('''bold''', [[link|label]], HTML tags)
|
|
50
|
+
def self.clean_heading(text)
|
|
51
|
+
t = text.gsub(/'{2,}/, "")
|
|
52
|
+
t = t.gsub(/\[\[(?:[^\]|]*\|)?([^\]]*)\]\]/) { ::Regexp.last_match(1) }
|
|
53
|
+
t.gsub(/<[^>]+>/, "").strip
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Expand a section name to its full alias group (bidirectional):
|
|
57
|
+
# "Plot" => ["Plot", "Synopsis", ...]; "Synopsis" => same group
|
|
58
|
+
def self.expand_section_names(name, alias_file: nil)
|
|
59
|
+
aliases = SectionExtractor::DEFAULT_ALIASES
|
|
60
|
+
if alias_file
|
|
61
|
+
custom = SectionExtractor.load_aliases_from_file(alias_file)
|
|
62
|
+
aliases = aliases.merge(custom) unless custom.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
down = name.downcase
|
|
66
|
+
aliases.each do |canonical, list|
|
|
67
|
+
group = [canonical, *list]
|
|
68
|
+
return group if group.any? { |g| g.downcase == down }
|
|
69
|
+
end
|
|
70
|
+
[name]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Status
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
# True if the index file exists and has a compatible schema
|
|
78
|
+
def built?
|
|
79
|
+
return false unless File.exist?(@db_path)
|
|
80
|
+
|
|
81
|
+
meta = read_metadata
|
|
82
|
+
!meta.nil? && meta[:schema_version].to_i == SCHEMA_VERSION && !meta[:built_at].nil?
|
|
83
|
+
rescue SQLite3::Exception
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# True if the index was built from the given (unchanged) multistream file
|
|
88
|
+
def valid_for?(multistream_path)
|
|
89
|
+
return false unless built?
|
|
90
|
+
return false unless File.exist?(multistream_path)
|
|
91
|
+
|
|
92
|
+
meta = read_metadata
|
|
93
|
+
stat = File.stat(multistream_path)
|
|
94
|
+
meta[:source_size].to_i == stat.size && meta[:source_mtime].to_i == stat.mtime.to_i
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stats
|
|
98
|
+
return nil unless File.exist?(@db_path)
|
|
99
|
+
|
|
100
|
+
meta = read_metadata || {}
|
|
101
|
+
{
|
|
102
|
+
db_path: @db_path,
|
|
103
|
+
db_size: File.size(@db_path),
|
|
104
|
+
dump_name: meta[:dump_name],
|
|
105
|
+
built_at: meta[:built_at],
|
|
106
|
+
built_with: meta[:wp2txt_version],
|
|
107
|
+
page_count: count_scalar("SELECT COUNT(*) FROM pages"),
|
|
108
|
+
article_count: count_scalar("SELECT COUNT(*) FROM pages WHERE namespace = #{NS_ARTICLE} AND redirect_to IS NULL"),
|
|
109
|
+
category_count: count_scalar("SELECT COUNT(DISTINCT category) FROM page_categories"),
|
|
110
|
+
section_count: count_scalar("SELECT COUNT(*) FROM page_sections")
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def close
|
|
115
|
+
@db&.close
|
|
116
|
+
@db = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Build API (used by MetadataIndexBuilder)
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
# Build into a sidecar file and atomically rename in finalize_build!, so a
|
|
124
|
+
# failed multi-hour rebuild never destroys a working index
|
|
125
|
+
def prepare_build!
|
|
126
|
+
FileUtils.mkdir_p(File.dirname(@db_path))
|
|
127
|
+
close
|
|
128
|
+
@build_path = "#{@db_path}.building"
|
|
129
|
+
FileUtils.rm_f([@build_path, "#{@build_path}-wal", "#{@build_path}-shm"])
|
|
130
|
+
db = open_db
|
|
131
|
+
db.execute(<<~SQL)
|
|
132
|
+
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)
|
|
133
|
+
SQL
|
|
134
|
+
db.execute(<<~SQL)
|
|
135
|
+
CREATE TABLE pages (
|
|
136
|
+
page_id INTEGER PRIMARY KEY,
|
|
137
|
+
title TEXT,
|
|
138
|
+
namespace INTEGER,
|
|
139
|
+
redirect_to TEXT,
|
|
140
|
+
text_length INTEGER
|
|
141
|
+
)
|
|
142
|
+
SQL
|
|
143
|
+
db.execute("CREATE TABLE page_categories (page_id INTEGER, category TEXT)")
|
|
144
|
+
# ord: position of the section within the article, where 0 is the lead
|
|
145
|
+
# text before the first heading. Lead rows are NOT stored here (they have
|
|
146
|
+
# no heading), so ord starts at 1 — consistent with fts_map.ord in the
|
|
147
|
+
# full-text DB, where the lead IS stored as ord 0.
|
|
148
|
+
db.execute(<<~SQL)
|
|
149
|
+
CREATE TABLE page_sections (
|
|
150
|
+
page_id INTEGER,
|
|
151
|
+
heading TEXT,
|
|
152
|
+
level INTEGER,
|
|
153
|
+
-- ord: section position in the article; 0 = lead text (not stored in
|
|
154
|
+
-- this table), so headings start at 1. Same semantics as fts_map.ord.
|
|
155
|
+
ord INTEGER
|
|
156
|
+
)
|
|
157
|
+
SQL
|
|
158
|
+
db.execute("CREATE TABLE category_hierarchy (child TEXT, parent TEXT)")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Insert one scanned batch: {pages:, categories:, sections:, hierarchy:}
|
|
162
|
+
def insert_batch(rows)
|
|
163
|
+
db = open_db
|
|
164
|
+
db.transaction do
|
|
165
|
+
stmt = db.prepare("INSERT OR IGNORE INTO pages (page_id, title, namespace, redirect_to, text_length) VALUES (?, ?, ?, ?, ?)")
|
|
166
|
+
rows[:pages].each { |r| stmt.execute(r) }
|
|
167
|
+
stmt.close
|
|
168
|
+
|
|
169
|
+
stmt = db.prepare("INSERT INTO page_categories (page_id, category) VALUES (?, ?)")
|
|
170
|
+
rows[:categories].each { |r| stmt.execute(r) }
|
|
171
|
+
stmt.close
|
|
172
|
+
|
|
173
|
+
stmt = db.prepare("INSERT INTO page_sections (page_id, heading, level, ord) VALUES (?, ?, ?, ?)")
|
|
174
|
+
rows[:sections].each { |r| stmt.execute(r) }
|
|
175
|
+
stmt.close
|
|
176
|
+
|
|
177
|
+
stmt = db.prepare("INSERT INTO category_hierarchy (child, parent) VALUES (?, ?)")
|
|
178
|
+
rows[:hierarchy].each { |r| stmt.execute(r) }
|
|
179
|
+
stmt.close
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def finalize_build!(source_path)
|
|
184
|
+
db = open_db
|
|
185
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_pages_title ON pages(title)")
|
|
186
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_pc_category ON page_categories(category)")
|
|
187
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_pc_page ON page_categories(page_id)")
|
|
188
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_ps_heading ON page_sections(heading COLLATE NOCASE)")
|
|
189
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_ps_page ON page_sections(page_id)")
|
|
190
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_ch_parent ON category_hierarchy(parent)")
|
|
191
|
+
|
|
192
|
+
stat = File.stat(source_path)
|
|
193
|
+
dump_name = File.basename(source_path)[/\A[a-z0-9_\-]+?-\d{8}/] || File.basename(source_path)
|
|
194
|
+
save_metadata(
|
|
195
|
+
schema_version: SCHEMA_VERSION,
|
|
196
|
+
wp2txt_version: Wp2txt::VERSION,
|
|
197
|
+
source_path: source_path,
|
|
198
|
+
source_size: stat.size,
|
|
199
|
+
source_mtime: stat.mtime.to_i,
|
|
200
|
+
dump_name: dump_name,
|
|
201
|
+
built_at: Time.now.utc.iso8601
|
|
202
|
+
)
|
|
203
|
+
db.execute("ANALYZE")
|
|
204
|
+
close
|
|
205
|
+
if @build_path
|
|
206
|
+
File.rename(@build_path, @db_path)
|
|
207
|
+
FileUtils.rm_f(["#{@db_path}-wal", "#{@db_path}-shm"])
|
|
208
|
+
@build_path = nil
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
# Queries
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
# Find article titles matching the given filters.
|
|
217
|
+
# @param category [String, nil] category name (without namespace prefix)
|
|
218
|
+
# @param depth [Integer] subcategory recursion depth (0 = exact category only)
|
|
219
|
+
# @param has_section [String, nil] single section heading (alias-aware by default)
|
|
220
|
+
# @param sections [Array<String>, nil] multiple headings (OR match, used as-is)
|
|
221
|
+
# @param alias_set [String, nil] saved alias set name used to expand headings
|
|
222
|
+
# @param use_aliases [Boolean] expand has_section via built-in alias groups
|
|
223
|
+
# @param alias_file [String, nil] custom alias YAML (merged with defaults)
|
|
224
|
+
# @param title_match [String, nil] substring match on title
|
|
225
|
+
# @param limit [Integer] max titles to return (0 = no limit)
|
|
226
|
+
# @param offset [Integer] result offset
|
|
227
|
+
# @return [Array<String>] matching titles ordered by page_id
|
|
228
|
+
def find_articles(category: nil, depth: 0, categories: nil, category_match: nil,
|
|
229
|
+
has_section: nil, sections: nil, alias_set: nil,
|
|
230
|
+
use_aliases: true, alias_file: nil, title_match: nil, limit: 0, offset: 0)
|
|
231
|
+
cte, where, params = build_article_query(
|
|
232
|
+
category: category, depth: depth, categories: categories, category_match: category_match,
|
|
233
|
+
has_section: has_section, sections: sections,
|
|
234
|
+
alias_set: alias_set, use_aliases: use_aliases, alias_file: alias_file, title_match: title_match
|
|
235
|
+
)
|
|
236
|
+
sql = +""
|
|
237
|
+
sql << "WITH RECURSIVE #{cte} " if cte
|
|
238
|
+
sql << "SELECT p.title FROM pages p WHERE #{where} ORDER BY p.page_id"
|
|
239
|
+
sql << " LIMIT #{limit.to_i}" if limit.to_i.positive?
|
|
240
|
+
sql << " OFFSET #{offset.to_i}" if offset.to_i.positive?
|
|
241
|
+
|
|
242
|
+
open_db.execute(sql, params).map { |row| row[0] }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Count articles matching the same filters as find_articles
|
|
246
|
+
def count_articles(category: nil, depth: 0, categories: nil, category_match: nil,
|
|
247
|
+
has_section: nil, sections: nil, alias_set: nil,
|
|
248
|
+
use_aliases: true, alias_file: nil, title_match: nil)
|
|
249
|
+
cte, where, params = build_article_query(
|
|
250
|
+
category: category, depth: depth, categories: categories, category_match: category_match,
|
|
251
|
+
has_section: has_section, sections: sections,
|
|
252
|
+
alias_set: alias_set, use_aliases: use_aliases, alias_file: alias_file, title_match: title_match
|
|
253
|
+
)
|
|
254
|
+
sql = +""
|
|
255
|
+
sql << "WITH RECURSIVE #{cte} " if cte
|
|
256
|
+
sql << "SELECT COUNT(*) FROM pages p WHERE #{where}"
|
|
257
|
+
|
|
258
|
+
open_db.get_first_value(sql, params).to_i
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Categories of one article (by exact title)
|
|
262
|
+
# @return [Array<String>, nil] category names, nil if the title is unknown
|
|
263
|
+
def categories_of(title)
|
|
264
|
+
row = open_db.execute("SELECT page_id FROM pages WHERE title = ?", [title]).first
|
|
265
|
+
return nil unless row
|
|
266
|
+
|
|
267
|
+
open_db.execute(
|
|
268
|
+
"SELECT category FROM page_categories WHERE page_id = ? ORDER BY category", [row[0]]
|
|
269
|
+
).map(&:first)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Subcategory tree starting at category, as [{name:, depth:}, ...] (BFS order)
|
|
273
|
+
def category_tree(category, depth: 2)
|
|
274
|
+
cat = self.class.normalize_category(category)
|
|
275
|
+
sql = <<~SQL
|
|
276
|
+
WITH RECURSIVE cat_tree(name, d) AS (
|
|
277
|
+
SELECT ?, 0
|
|
278
|
+
UNION
|
|
279
|
+
SELECT ch.child, ct.d + 1
|
|
280
|
+
FROM category_hierarchy ch JOIN cat_tree ct ON ch.parent = ct.name
|
|
281
|
+
WHERE ct.d < #{depth.to_i}
|
|
282
|
+
)
|
|
283
|
+
SELECT name, MIN(d) FROM cat_tree GROUP BY name ORDER BY MIN(d), name
|
|
284
|
+
SQL
|
|
285
|
+
open_db.execute(sql, [cat]).map { |name, d| { name: name, depth: d } }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Section heading frequencies across articles, optionally scoped to a category
|
|
289
|
+
def section_stats(category: nil, depth: 0, top_n: 50)
|
|
290
|
+
conds = ["p.namespace = #{NS_ARTICLE}", "p.redirect_to IS NULL"]
|
|
291
|
+
params = []
|
|
292
|
+
cte = nil
|
|
293
|
+
if category
|
|
294
|
+
cte, cond, cat_params = category_condition(category, depth)
|
|
295
|
+
conds << cond
|
|
296
|
+
params.concat(cat_params)
|
|
297
|
+
end
|
|
298
|
+
sql = +""
|
|
299
|
+
sql << "WITH RECURSIVE #{cte} " if cte
|
|
300
|
+
sql << <<~SQL
|
|
301
|
+
SELECT ps.heading, COUNT(*) AS cnt
|
|
302
|
+
FROM page_sections ps JOIN pages p ON p.page_id = ps.page_id
|
|
303
|
+
WHERE #{conds.join(' AND ')}
|
|
304
|
+
GROUP BY ps.heading ORDER BY cnt DESC, ps.heading LIMIT #{top_n.to_i}
|
|
305
|
+
SQL
|
|
306
|
+
open_db.execute(sql, params)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Article counts, average positions, and pairwise co-occurrence for a set of
|
|
310
|
+
# headings. Synonymous headings almost never co-occur in the same article,
|
|
311
|
+
# so a high co-occurrence ratio is evidence AGAINST treating them as aliases.
|
|
312
|
+
# @param headings [Array<String>] headings to compare
|
|
313
|
+
# @param category [String, nil] optional category scope
|
|
314
|
+
# @param depth [Integer] category recursion depth
|
|
315
|
+
# @return [Hash] { headings: [{heading:, articles:, avg_position:}],
|
|
316
|
+
# pairs: [{a:, b:, both:, cooccurrence_ratio:}] }
|
|
317
|
+
def section_cooccurrence(headings, category: nil, depth: 0)
|
|
318
|
+
scope_cte = nil
|
|
319
|
+
scope_cond = "p.namespace = #{NS_ARTICLE} AND p.redirect_to IS NULL"
|
|
320
|
+
scope_params = []
|
|
321
|
+
if category
|
|
322
|
+
scope_cte, cond, scope_params = category_condition(category, depth)
|
|
323
|
+
scope_cond += " AND #{cond}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
db = open_db
|
|
327
|
+
with = scope_cte ? "WITH RECURSIVE #{scope_cte} " : ""
|
|
328
|
+
# Placeholders bind positionally: with a recursive CTE the category `?`
|
|
329
|
+
# sits inside the CTE (before any heading `?`); without one it sits
|
|
330
|
+
# inside scope_cond (after the heading `?`)
|
|
331
|
+
cte_params = scope_cte ? scope_params : []
|
|
332
|
+
cond_params = scope_cte ? [] : scope_params
|
|
333
|
+
|
|
334
|
+
heading_stats = headings.map do |h|
|
|
335
|
+
row = db.execute(
|
|
336
|
+
"#{with}SELECT COUNT(DISTINCT ps.page_id), AVG(ps.ord) FROM page_sections ps " \
|
|
337
|
+
"JOIN pages p ON p.page_id = ps.page_id " \
|
|
338
|
+
"WHERE ps.heading COLLATE NOCASE = ? AND #{scope_cond}",
|
|
339
|
+
cte_params + [h] + cond_params
|
|
340
|
+
).first
|
|
341
|
+
{ heading: h, articles: row[0].to_i, avg_position: row[1]&.round(2) }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
counts = heading_stats.to_h { |s| [s[:heading], s[:articles]] }
|
|
345
|
+
pairs = headings.combination(2).map do |a, b|
|
|
346
|
+
both = db.get_first_value(
|
|
347
|
+
"#{with}SELECT COUNT(*) FROM (" \
|
|
348
|
+
"SELECT ps.page_id FROM page_sections ps JOIN pages p ON p.page_id = ps.page_id " \
|
|
349
|
+
"WHERE ps.heading COLLATE NOCASE = ? AND #{scope_cond} " \
|
|
350
|
+
"INTERSECT " \
|
|
351
|
+
"SELECT ps.page_id FROM page_sections ps JOIN pages p ON p.page_id = ps.page_id " \
|
|
352
|
+
"WHERE ps.heading COLLATE NOCASE = ? AND #{scope_cond})",
|
|
353
|
+
cte_params + [a] + cond_params + [b] + cond_params
|
|
354
|
+
).to_i
|
|
355
|
+
min = [counts[a], counts[b]].min
|
|
356
|
+
{ a: a, b: b, both: both, cooccurrence_ratio: min.positive? ? (both.to_f / min).round(3) : 0.0 }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
{ headings: heading_stats, pairs: pairs }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# ------------------------------------------------------------------
|
|
363
|
+
# Alias sets (LLM-generated, persisted per dump for reproducibility)
|
|
364
|
+
# ------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
# Save a named alias set. groups is an array of heading groups, e.g.
|
|
367
|
+
# [["あらすじ", "ストーリー", "物語"], ["脚注", "出典"]]
|
|
368
|
+
def save_alias_set(name, groups)
|
|
369
|
+
raise ArgumentError, "groups must be a non-empty array of arrays" unless groups.is_a?(Array) && !groups.empty? && groups.all? { |g| g.is_a?(Array) && !g.empty? }
|
|
370
|
+
|
|
371
|
+
ensure_alias_table
|
|
372
|
+
open_db.execute(
|
|
373
|
+
"INSERT OR REPLACE INTO alias_sets (name, groups, created_at) VALUES (?, ?, ?)",
|
|
374
|
+
[name, JSON.generate(groups), Time.now.utc.iso8601]
|
|
375
|
+
)
|
|
376
|
+
{ name: name, groups: groups }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# @return [Hash, nil] { name:, groups:, created_at: } or nil if not found
|
|
380
|
+
def get_alias_set(name)
|
|
381
|
+
ensure_alias_table
|
|
382
|
+
row = open_db.execute("SELECT name, groups, created_at FROM alias_sets WHERE name = ?", [name]).first
|
|
383
|
+
return nil unless row
|
|
384
|
+
|
|
385
|
+
{ name: row[0], groups: JSON.parse(row[1]), created_at: row[2] }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def list_alias_sets
|
|
389
|
+
ensure_alias_table
|
|
390
|
+
open_db.execute("SELECT name, groups, created_at FROM alias_sets ORDER BY name").map do |name, groups, created_at|
|
|
391
|
+
{ name: name, group_count: JSON.parse(groups).size, created_at: created_at }
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def delete_alias_set(name)
|
|
396
|
+
ensure_alias_table
|
|
397
|
+
open_db.execute("DELETE FROM alias_sets WHERE name = ?", [name])
|
|
398
|
+
nil
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
private
|
|
402
|
+
|
|
403
|
+
# Returns [cte_sql_or_nil, where_sql, params]
|
|
404
|
+
def build_article_query(category:, depth:, has_section:, categories: nil, category_match: nil,
|
|
405
|
+
sections: nil, alias_set: nil,
|
|
406
|
+
use_aliases: true, alias_file: nil, title_match: nil)
|
|
407
|
+
conds = ["p.namespace = #{NS_ARTICLE}", "p.redirect_to IS NULL"]
|
|
408
|
+
params = []
|
|
409
|
+
cte = nil
|
|
410
|
+
|
|
411
|
+
if category
|
|
412
|
+
cte, cond, cat_params = category_condition(category, depth)
|
|
413
|
+
conds << cond
|
|
414
|
+
params.concat(cat_params)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# AND-intersection of exact category memberships (no recursion)
|
|
418
|
+
Array(categories).each do |cat|
|
|
419
|
+
conds << "p.page_id IN (SELECT pc.page_id FROM page_categories pc WHERE pc.category = ?)"
|
|
420
|
+
params << self.class.normalize_category(cat)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Substring match on category names, e.g. "アメリカ合衆国の%映画" style scoping
|
|
424
|
+
if category_match
|
|
425
|
+
conds << "p.page_id IN (SELECT pc.page_id FROM page_categories pc WHERE pc.category LIKE ? ESCAPE '\\')"
|
|
426
|
+
params << "%#{escape_like(category_match)}%"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
names = resolve_section_names(has_section: has_section, sections: sections,
|
|
430
|
+
alias_set: alias_set, use_aliases: use_aliases, alias_file: alias_file)
|
|
431
|
+
if names
|
|
432
|
+
placeholders = names.map { "?" }.join(",")
|
|
433
|
+
conds << "p.page_id IN (SELECT ps.page_id FROM page_sections ps WHERE ps.heading COLLATE NOCASE IN (#{placeholders}))"
|
|
434
|
+
params.concat(names)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
if title_match
|
|
438
|
+
conds << "p.title LIKE ? ESCAPE '\\'"
|
|
439
|
+
params << "%#{escape_like(title_match)}%"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
[cte, conds.join(" AND "), params]
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Merge has_section / sections into one heading list, expanding through a
|
|
446
|
+
# saved alias set (if given) or the built-in alias groups (single name only).
|
|
447
|
+
def resolve_section_names(has_section:, sections:, alias_set:, use_aliases:, alias_file:)
|
|
448
|
+
names = Array(sections).compact
|
|
449
|
+
names += [has_section] if has_section
|
|
450
|
+
return nil if names.empty?
|
|
451
|
+
|
|
452
|
+
if alias_set
|
|
453
|
+
set = get_alias_set(alias_set)
|
|
454
|
+
raise ArgumentError, "alias set not found: #{alias_set}" unless set
|
|
455
|
+
|
|
456
|
+
names = names.flat_map { |n| expand_via_groups(n, set[:groups]) }
|
|
457
|
+
elsif use_aliases && names.size == 1 && sections.nil?
|
|
458
|
+
names = self.class.expand_section_names(names.first, alias_file: alias_file)
|
|
459
|
+
end
|
|
460
|
+
names.uniq
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Bidirectional expansion through an array of heading groups
|
|
464
|
+
def expand_via_groups(name, groups)
|
|
465
|
+
down = name.downcase
|
|
466
|
+
groups.each do |group|
|
|
467
|
+
return group if group.any? { |g| g.downcase == down }
|
|
468
|
+
end
|
|
469
|
+
[name]
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def ensure_alias_table
|
|
473
|
+
open_db.execute(<<~SQL)
|
|
474
|
+
CREATE TABLE IF NOT EXISTS alias_sets (
|
|
475
|
+
name TEXT PRIMARY KEY,
|
|
476
|
+
groups TEXT,
|
|
477
|
+
created_at TEXT
|
|
478
|
+
)
|
|
479
|
+
SQL
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Returns [cte_sql_or_nil, condition_sql, params] for a category filter
|
|
483
|
+
def category_condition(category, depth)
|
|
484
|
+
cat = self.class.normalize_category(category)
|
|
485
|
+
if depth.to_i.positive?
|
|
486
|
+
cte = <<~SQL.strip
|
|
487
|
+
cat_tree(name, d) AS (
|
|
488
|
+
SELECT ?, 0
|
|
489
|
+
UNION
|
|
490
|
+
SELECT ch.child, ct.d + 1
|
|
491
|
+
FROM category_hierarchy ch JOIN cat_tree ct ON ch.parent = ct.name
|
|
492
|
+
WHERE ct.d < #{depth.to_i}
|
|
493
|
+
)
|
|
494
|
+
SQL
|
|
495
|
+
cond = "p.page_id IN (SELECT pc.page_id FROM page_categories pc WHERE pc.category IN (SELECT name FROM cat_tree))"
|
|
496
|
+
[cte, cond, [cat]]
|
|
497
|
+
else
|
|
498
|
+
[nil, "p.page_id IN (SELECT pc.page_id FROM page_categories pc WHERE pc.category = ?)", [cat]]
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def escape_like(str)
|
|
503
|
+
str.gsub(/[\\%_]/) { |c| "\\#{c}" }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def open_db
|
|
507
|
+
return @db if @db
|
|
508
|
+
|
|
509
|
+
@db = SQLite3::Database.new(@build_path || @db_path)
|
|
510
|
+
@db.busy_timeout = 5000
|
|
511
|
+
@db.execute("PRAGMA journal_mode = WAL")
|
|
512
|
+
@db.execute("PRAGMA synchronous = NORMAL")
|
|
513
|
+
@db.execute("PRAGMA cache_size = -64000")
|
|
514
|
+
@db
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def count_scalar(sql)
|
|
518
|
+
open_db.get_first_value(sql).to_i
|
|
519
|
+
rescue SQLite3::Exception
|
|
520
|
+
0
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def read_metadata
|
|
524
|
+
db = open_db
|
|
525
|
+
result = {}
|
|
526
|
+
db.execute("SELECT key, value FROM metadata") do |key, value|
|
|
527
|
+
result[key.to_sym] = value
|
|
528
|
+
end
|
|
529
|
+
result
|
|
530
|
+
rescue SQLite3::Exception
|
|
531
|
+
nil
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def save_metadata(hash)
|
|
535
|
+
db = open_db
|
|
536
|
+
stmt = db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)")
|
|
537
|
+
hash.each { |k, v| stmt.execute([k.to_s, v.to_s]) }
|
|
538
|
+
stmt.close
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Builds a MetadataIndex by scanning all streams of a multistream dump in parallel.
|
|
543
|
+
# Workers decompress and regex-scan streams; the parent process owns the sole
|
|
544
|
+
# SQLite connection and inserts each batch from the Parallel `finish` hook.
|
|
545
|
+
class MetadataIndexBuilder
|
|
546
|
+
STREAMS_PER_BATCH = 50
|
|
547
|
+
|
|
548
|
+
PAGE_BLOCK_REGEX = %r{<page>(.*?)</page>}m
|
|
549
|
+
TITLE_REGEX = %r{<title>([^<]*)</title>}
|
|
550
|
+
NS_REGEX = %r{<ns>(-?\d+)</ns>}
|
|
551
|
+
ID_REGEX = %r{<id>(\d+)</id>}
|
|
552
|
+
TEXT_REGEX = %r{<text[^>]*>(.*)</text>}m
|
|
553
|
+
HEADING_REGEX = /\A(={2,6})[ \t]*(.+?)[ \t]*={2,6}[ \t]*\z/
|
|
554
|
+
HTML_COMMENT_REGEX = /<!--.*?-->/m
|
|
555
|
+
|
|
556
|
+
def initialize(multistream_path, stream_offsets, db_path:, num_processes: 4)
|
|
557
|
+
@multistream_path = multistream_path
|
|
558
|
+
@stream_offsets = stream_offsets
|
|
559
|
+
@db_path = db_path
|
|
560
|
+
@num_processes = num_processes
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Build the index. Yields (batches_done, batches_total) after each batch.
|
|
564
|
+
# @return [MetadataIndex] the built index
|
|
565
|
+
def build(&progress)
|
|
566
|
+
index = MetadataIndex.new(@db_path)
|
|
567
|
+
index.prepare_build!
|
|
568
|
+
# Close before Parallel forks workers so children do not inherit a
|
|
569
|
+
# writable SQLite connection; the finish hook reopens it lazily in
|
|
570
|
+
# the parent, which is the only process that ever writes.
|
|
571
|
+
index.close
|
|
572
|
+
|
|
573
|
+
pairs = @stream_offsets.zip(@stream_offsets[1..].to_a + [nil])
|
|
574
|
+
batches = pairs.each_slice(STREAMS_PER_BATCH).to_a
|
|
575
|
+
done = 0
|
|
576
|
+
|
|
577
|
+
Parallel.map(
|
|
578
|
+
batches,
|
|
579
|
+
in_processes: @num_processes,
|
|
580
|
+
finish: lambda { |_item, _idx, result|
|
|
581
|
+
index.insert_batch(result)
|
|
582
|
+
done += 1
|
|
583
|
+
progress&.call(done, batches.size)
|
|
584
|
+
}
|
|
585
|
+
) do |batch|
|
|
586
|
+
self.class.scan_batch(@multistream_path, batch)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
index.finalize_build!(@multistream_path)
|
|
590
|
+
index
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Scan a batch of [offset, next_offset] stream pairs.
|
|
594
|
+
# Runs inside worker processes: must not touch SQLite.
|
|
595
|
+
def self.scan_batch(multistream_path, offset_pairs)
|
|
596
|
+
out = { pages: [], categories: [], sections: [], hierarchy: [] }
|
|
597
|
+
File.open(multistream_path, "rb") do |f|
|
|
598
|
+
offset_pairs.each do |offset, next_offset|
|
|
599
|
+
f.seek(offset)
|
|
600
|
+
data = next_offset ? f.read(next_offset - offset) : f.read
|
|
601
|
+
xml = decompress_bz2(data)
|
|
602
|
+
xml.scan(PAGE_BLOCK_REGEX) { scan_page(::Regexp.last_match(1), out) }
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
out
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def self.decompress_bz2(data)
|
|
609
|
+
stdout, status = Open3.capture2("bzcat", stdin_data: data)
|
|
610
|
+
raise "bzcat failed (exit #{status.exitstatus})" unless status.success?
|
|
611
|
+
|
|
612
|
+
stdout.force_encoding(Encoding::UTF_8)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def self.scan_page(block, out)
|
|
616
|
+
title = block[TITLE_REGEX, 1]
|
|
617
|
+
return unless title && !title.empty?
|
|
618
|
+
|
|
619
|
+
page_id = block[ID_REGEX, 1]&.to_i
|
|
620
|
+
return unless page_id
|
|
621
|
+
|
|
622
|
+
title = unescape_xml(title)
|
|
623
|
+
ns = (block[NS_REGEX, 1] || "0").to_i
|
|
624
|
+
text = block[TEXT_REGEX, 1] || ""
|
|
625
|
+
text = unescape_xml(text)
|
|
626
|
+
# Strip HTML comments before scanning, matching what the Article parser
|
|
627
|
+
# does for the FTS index: a trailing comment after a heading's closing
|
|
628
|
+
# `==` must not hide the heading, and commented-out [[Category:]] links
|
|
629
|
+
# must not be indexed as real categories
|
|
630
|
+
text = text.gsub(HTML_COMMENT_REGEX, "")
|
|
631
|
+
|
|
632
|
+
redirect_to = nil
|
|
633
|
+
if (m = REDIRECT_REGEX.match(text))
|
|
634
|
+
redirect_to = m[1].split(/[#|]/).first.to_s.strip
|
|
635
|
+
redirect_to = nil if redirect_to.empty?
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
out[:pages] << [page_id, title, ns, redirect_to, text.length]
|
|
639
|
+
|
|
640
|
+
categories = text.scan(CATEGORY_REGEX)
|
|
641
|
+
.map { |c| MetadataIndex.normalize_category(c[0]) }
|
|
642
|
+
.reject(&:empty?).uniq
|
|
643
|
+
if ns == MetadataIndex::NS_CATEGORY
|
|
644
|
+
child = MetadataIndex.normalize_category(title.sub(/\A[^:]+:/, ""))
|
|
645
|
+
categories.each { |c| out[:hierarchy] << [child, c] } unless child.empty?
|
|
646
|
+
else
|
|
647
|
+
categories.each { |c| out[:categories] << [page_id, c] }
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# ord 0 is the lead text (not stored here — it has no heading); headings
|
|
651
|
+
# start at 1 so ord aligns with fts_map.ord in the full-text DB
|
|
652
|
+
ord = 0
|
|
653
|
+
text.each_line do |line|
|
|
654
|
+
l = line.chomp
|
|
655
|
+
next unless l.start_with?("==")
|
|
656
|
+
|
|
657
|
+
hm = HEADING_REGEX.match(l)
|
|
658
|
+
next unless hm
|
|
659
|
+
|
|
660
|
+
ord += 1
|
|
661
|
+
heading = MetadataIndex.clean_heading(hm[2])
|
|
662
|
+
next if heading.empty?
|
|
663
|
+
|
|
664
|
+
out[:sections] << [page_id, heading, hm[1].length, ord]
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def self.unescape_xml(str)
|
|
669
|
+
str.gsub("<", "<").gsub(">", ">").gsub(""", '"').gsub("&", "&")
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
end
|
data/lib/wp2txt/multistream.rb
CHANGED
|
@@ -233,8 +233,9 @@ module Wp2txt
|
|
|
233
233
|
def initialize(multistream_path, index_or_path, use_cache: true, cache_dir: nil)
|
|
234
234
|
@multistream_path = multistream_path
|
|
235
235
|
|
|
236
|
-
# Accept
|
|
237
|
-
|
|
236
|
+
# Accept an existing index (or any object with the same lookup interface,
|
|
237
|
+
# e.g. a lazy SQLite-backed one) or a path to create one
|
|
238
|
+
if index_or_path.respond_to?(:find_by_title)
|
|
238
239
|
@index = index_or_path
|
|
239
240
|
else
|
|
240
241
|
@index = MultistreamIndex.new(index_or_path, use_cache: use_cache, cache_dir: cache_dir)
|
|
@@ -365,10 +366,13 @@ module Wp2txt
|
|
|
365
366
|
end
|
|
366
367
|
|
|
367
368
|
def find_next_offset(current_offset)
|
|
368
|
-
|
|
369
|
-
|
|
369
|
+
offsets = @index.stream_offsets
|
|
370
|
+
idx = offsets.index(current_offset)
|
|
371
|
+
# A missing offset means a corrupt or empty stream index; returning nil
|
|
372
|
+
# here would silently read gigabytes to EOF, so fail fast instead
|
|
373
|
+
raise "Stream offset #{current_offset} not found in index (#{offsets.size} streams known)" unless idx
|
|
370
374
|
|
|
371
|
-
|
|
375
|
+
offsets[idx + 1]
|
|
372
376
|
end
|
|
373
377
|
|
|
374
378
|
def decompress_bz2(data)
|