wp2txt 2.2.0 → 2.3.1
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/.dockerignore +2 -1
- data/.gitignore +3 -0
- data/CHANGELOG.md +19 -0
- data/DEVELOPMENT.md +2 -1
- data/DEVELOPMENT_ja.md +2 -1
- data/Dockerfile +10 -1
- data/README.md +36 -56
- data/README_ja.md +33 -1
- data/Rakefile +31 -5
- data/bin/wp2txt +1 -0
- data/bin/wp2txt-mcp +19 -20
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +43 -0
- data/lib/wp2txt/corpus.rb +385 -23
- data/lib/wp2txt/index_commands.rb +83 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +68 -2
- data/lib/wp2txt/multistream.rb +29 -0
- data/lib/wp2txt/output_path.rb +27 -0
- data/lib/wp2txt/regex.rb +7 -0
- data/lib/wp2txt/text_processing.rb +8 -0
- data/lib/wp2txt/utils.rb +17 -1
- data/lib/wp2txt/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/corpus_spec.rb +25 -0
- data/spec/docs_sync_spec.rb +39 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/titles_output_path_spec.rb +338 -0
- data/spec/utils_spec.rb +129 -0
- metadata +14 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require "set"
|
|
5
|
+
require "time"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require_relative "metadata_index"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
|
|
10
|
+
module Wp2txt
|
|
11
|
+
# Imports the official langlinks dump ({lang}wiki-{date}-langlinks.sql.gz,
|
|
12
|
+
# MySQL dump format) into the Tier 1 metadata DB as a `langlinks` table
|
|
13
|
+
# (ll_from = source page_id, ll_lang = target language, ll_title = title in
|
|
14
|
+
# the target edition, normalized like pages.title).
|
|
15
|
+
#
|
|
16
|
+
# Version pinning is the reason this feature exists: the langlinks file's
|
|
17
|
+
# dump name (e.g. jawiki-20260701) MUST equal the metadata DB's dump_name;
|
|
18
|
+
# a mismatch is rejected with no override.
|
|
19
|
+
class LanglinksImporter
|
|
20
|
+
# Rows inserted per transaction (index creation is deferred until after
|
|
21
|
+
# the load, so inserts stay fast)
|
|
22
|
+
BATCH_SIZE = 10_000
|
|
23
|
+
|
|
24
|
+
# Post-import sanity check (design doc §1.6): per target language, join a
|
|
25
|
+
# random sample of ll_title values against the target language's local
|
|
26
|
+
# meta DB (when installed) and report the match rate; a low rate signals
|
|
27
|
+
# a title-normalization mismatch
|
|
28
|
+
SANITY_SAMPLE_SIZE = 1000
|
|
29
|
+
SANITY_WARN_THRESHOLD = 0.9
|
|
30
|
+
|
|
31
|
+
INSERT_PREFIX = /\A\s*INSERT\s+INTO\s+`langlinks`\s+VALUES\s+/i
|
|
32
|
+
|
|
33
|
+
# MySQL backslash escapes inside mysqldump string literals
|
|
34
|
+
UNESCAPES = {
|
|
35
|
+
"0" => "\0", "'" => "'", '"' => '"', "b" => "\b", "n" => "\n",
|
|
36
|
+
"r" => "\r", "t" => "\t", "Z" => "\x1A", "\\" => "\\"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
def initialize(db_path, cache_dir: nil)
|
|
40
|
+
@db_path = db_path
|
|
41
|
+
@cache_dir = cache_dir
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# "jawiki-20260701-langlinks.sql.gz" => "jawiki-20260701" (same extraction
|
|
45
|
+
# rule as the dump_name recorded in the metadata DB)
|
|
46
|
+
def self.dump_name_of(path)
|
|
47
|
+
File.basename(path)[/\A[a-z0-9_\-]+?-\d{8}/]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param source_path [String] langlinks .sql or .sql.gz file
|
|
51
|
+
# @param langs [Array<String>, nil] target languages to import (nil = all)
|
|
52
|
+
# @param force [Boolean] drop and re-import an existing langlinks table
|
|
53
|
+
# @param progress [Proc, nil] called with the running row count per batch
|
|
54
|
+
# @return [Hash] { status: :imported | :already_imported, ... }
|
|
55
|
+
def import!(source_path, langs: nil, force: false, progress: nil)
|
|
56
|
+
raise ArgumentError, "langlinks file not found: #{source_path}" unless File.exist?(source_path)
|
|
57
|
+
|
|
58
|
+
db = open_db
|
|
59
|
+
dump_name = metadata_value(db, "dump_name")
|
|
60
|
+
raise ArgumentError, "metadata index is not built: #{@db_path}" unless dump_name
|
|
61
|
+
|
|
62
|
+
source_dump = self.class.dump_name_of(source_path)
|
|
63
|
+
unless source_dump && source_dump == dump_name
|
|
64
|
+
raise ArgumentError,
|
|
65
|
+
"dump version mismatch: the metadata index is #{dump_name} but the langlinks file is " \
|
|
66
|
+
"#{source_dump || File.basename(source_path)} (versions must match; there is no override)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if !force && (existing = imported_at(db))
|
|
70
|
+
return { status: :already_imported, imported_at: existing,
|
|
71
|
+
row_count: db.get_first_value("SELECT COUNT(*) FROM langlinks").to_i }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
lang_filter = langs && Set.new(langs)
|
|
75
|
+
|
|
76
|
+
db.execute("DROP TABLE IF EXISTS langlinks")
|
|
77
|
+
# Clear stale provenance immediately: if the load below fails midway,
|
|
78
|
+
# the DB must be left as "not imported" (partial table only), so the
|
|
79
|
+
# next non-force run re-imports instead of reporting a stale success
|
|
80
|
+
db.execute("DELETE FROM metadata WHERE key LIKE 'langlinks\\_%' ESCAPE '\\'")
|
|
81
|
+
db.execute(<<~SQL)
|
|
82
|
+
CREATE TABLE langlinks (
|
|
83
|
+
ll_from INTEGER NOT NULL,
|
|
84
|
+
ll_lang TEXT NOT NULL,
|
|
85
|
+
ll_title TEXT NOT NULL
|
|
86
|
+
)
|
|
87
|
+
SQL
|
|
88
|
+
|
|
89
|
+
row_count = 0
|
|
90
|
+
batch = []
|
|
91
|
+
flush = lambda do
|
|
92
|
+
db.transaction do
|
|
93
|
+
stmt = db.prepare("INSERT INTO langlinks (ll_from, ll_lang, ll_title) VALUES (?, ?, ?)")
|
|
94
|
+
batch.each { |row| stmt.execute(row) }
|
|
95
|
+
stmt.close
|
|
96
|
+
end
|
|
97
|
+
row_count += batch.size
|
|
98
|
+
progress&.call(row_count)
|
|
99
|
+
batch.clear
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
skipped_invalid = each_source_row(source_path) do |ll_from, ll_lang, ll_title|
|
|
103
|
+
next if lang_filter && !lang_filter.include?(ll_lang)
|
|
104
|
+
|
|
105
|
+
batch << [ll_from, ll_lang, MetadataIndex.normalize_title(ll_title)]
|
|
106
|
+
flush.call if batch.size >= BATCH_SIZE
|
|
107
|
+
end
|
|
108
|
+
flush.call unless batch.empty?
|
|
109
|
+
|
|
110
|
+
# Indexes are created after the load, not before (insert speed)
|
|
111
|
+
db.execute("CREATE INDEX idx_langlinks_from ON langlinks(ll_from, ll_lang)")
|
|
112
|
+
db.execute("CREATE INDEX idx_langlinks_lang_title ON langlinks(ll_lang, ll_title)")
|
|
113
|
+
|
|
114
|
+
stamp_provenance(db, source_path, langs, row_count, skipped_invalid)
|
|
115
|
+
|
|
116
|
+
{ status: :imported, row_count: row_count, skipped_invalid: skipped_invalid,
|
|
117
|
+
provenance: read_provenance(db),
|
|
118
|
+
sanity: sanity_check(db, dump_name) }
|
|
119
|
+
ensure
|
|
120
|
+
db&.close
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def open_db
|
|
126
|
+
db = SQLite3::Database.new(@db_path)
|
|
127
|
+
db.busy_timeout = 5000
|
|
128
|
+
db
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def metadata_value(db, key)
|
|
132
|
+
db.get_first_value("SELECT value FROM metadata WHERE key = ?", [key])
|
|
133
|
+
rescue SQLite3::Exception
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Non-nil only when a previous completed import is still in place
|
|
138
|
+
def imported_at(db)
|
|
139
|
+
table = db.get_first_value(
|
|
140
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'langlinks'"
|
|
141
|
+
)
|
|
142
|
+
table && metadata_value(db, "langlinks_imported_at")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def stamp_provenance(db, source_path, langs, row_count, skipped_invalid)
|
|
146
|
+
values = {
|
|
147
|
+
langlinks_source: File.basename(source_path),
|
|
148
|
+
langlinks_source_size: File.size(source_path),
|
|
149
|
+
langlinks_imported_at: Time.now.utc.iso8601,
|
|
150
|
+
langlinks_wp2txt_version: Wp2txt::VERSION,
|
|
151
|
+
langlinks_lang_filter: langs.nil? ? "all" : langs.join(","),
|
|
152
|
+
langlinks_row_count: row_count,
|
|
153
|
+
langlinks_skipped_invalid: skipped_invalid
|
|
154
|
+
}
|
|
155
|
+
stmt = db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)")
|
|
156
|
+
values.each { |k, v| stmt.execute([k.to_s, v.to_s]) }
|
|
157
|
+
stmt.close
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def read_provenance(db)
|
|
161
|
+
{
|
|
162
|
+
source: metadata_value(db, "langlinks_source"),
|
|
163
|
+
source_size: metadata_value(db, "langlinks_source_size").to_i,
|
|
164
|
+
imported_at: metadata_value(db, "langlinks_imported_at"),
|
|
165
|
+
imported_with: metadata_value(db, "langlinks_wp2txt_version"),
|
|
166
|
+
lang_filter: metadata_value(db, "langlinks_lang_filter"),
|
|
167
|
+
row_count: metadata_value(db, "langlinks_row_count").to_i,
|
|
168
|
+
skipped_invalid: metadata_value(db, "langlinks_skipped_invalid").to_i
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Post-import sanity check (§1.6): for each target language whose meta DB
|
|
173
|
+
# is installed locally, draw a random sample and report how many ll_title
|
|
174
|
+
# values exist in that DB's pages.title
|
|
175
|
+
def sanity_check(db, dump_name)
|
|
176
|
+
reports = []
|
|
177
|
+
main_date = dump_name[/\d{8}\z/]
|
|
178
|
+
db.execute("SELECT DISTINCT ll_lang FROM langlinks ORDER BY ll_lang").flatten.each do |lang|
|
|
179
|
+
candidates = MetadataIndex.cached_candidates(lang, cache_dir: @cache_dir)
|
|
180
|
+
next if candidates.empty? # target edition not installed locally: skip
|
|
181
|
+
|
|
182
|
+
pick = candidates.find { |c| c[:dump_name].to_s.end_with?(main_date.to_s) } || candidates.first
|
|
183
|
+
sample = db.execute(
|
|
184
|
+
"SELECT ll_title FROM langlinks WHERE ll_lang = ? ORDER BY RANDOM() LIMIT ?",
|
|
185
|
+
[lang, SANITY_SAMPLE_SIZE]
|
|
186
|
+
).flatten
|
|
187
|
+
next if sample.empty?
|
|
188
|
+
|
|
189
|
+
other = SQLite3::Database.new(pick[:db_path], readonly: true)
|
|
190
|
+
begin
|
|
191
|
+
stmt = other.prepare("SELECT 1 FROM pages WHERE title = ? LIMIT 1")
|
|
192
|
+
matched = sample.count { |title| stmt.execute(title).any? }
|
|
193
|
+
stmt.close
|
|
194
|
+
ensure
|
|
195
|
+
other.close
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
rate = matched.to_f / sample.size
|
|
199
|
+
reports << { lang: lang, sampled: sample.size, matched: matched,
|
|
200
|
+
match_rate: rate.round(3), against: pick[:dump_name],
|
|
201
|
+
warning: rate < SANITY_WARN_THRESHOLD }
|
|
202
|
+
end
|
|
203
|
+
reports
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Streaming MySQL dump parser
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
# Yield [ll_from, ll_lang, ll_title] for every VALID tuple of every
|
|
211
|
+
# INSERT INTO `langlinks` statement, streaming (the dump is never
|
|
212
|
+
# loaded into memory whole). Handles .sql.gz and plain .sql.
|
|
213
|
+
# @return [Integer] number of tuples skipped for invalid UTF-8
|
|
214
|
+
#
|
|
215
|
+
# Tuple extraction is regex-based: a hand-rolled line[i] character-index
|
|
216
|
+
# loop is O(n²) on multibyte (UTF-8 code-range) lines, and real extended
|
|
217
|
+
# INSERT lines are MB-scale with multilingual titles — the regex engine
|
|
218
|
+
# scans at C speed and is O(n) regardless of encoding.
|
|
219
|
+
#
|
|
220
|
+
# Lines are read as BINARY: ll_title is VARBINARY in MySQL and real dumps
|
|
221
|
+
# contain historically corrupted bytes, so regex matching on UTF-8-tagged
|
|
222
|
+
# strings can raise "invalid byte sequence". The patterns are ASCII-only,
|
|
223
|
+
# so they run on byte strings without encoding checks; captures are then
|
|
224
|
+
# tagged UTF-8 and validated — a garbled title could never join
|
|
225
|
+
# pages.title anyway, so such rows are skipped (and counted), not scrubbed.
|
|
226
|
+
def each_source_row(source_path)
|
|
227
|
+
io = if source_path.end_with?(".gz")
|
|
228
|
+
# GzipReader ignores set_encoding; the encoding must be given
|
|
229
|
+
# at open time (lines must come out as BINARY — see below)
|
|
230
|
+
Zlib::GzipReader.open(source_path, encoding: Encoding::BINARY.to_s)
|
|
231
|
+
else
|
|
232
|
+
File.open(source_path, "rb")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
skipped = 0
|
|
236
|
+
begin
|
|
237
|
+
io.each_line do |line|
|
|
238
|
+
next unless INSERT_PREFIX.match?(line)
|
|
239
|
+
|
|
240
|
+
line.scan(TUPLE_REGEX) do |ll_from, ll_lang, ll_title|
|
|
241
|
+
lang = unescape_mysql(ll_lang).force_encoding(Encoding::UTF_8)
|
|
242
|
+
title = unescape_mysql(ll_title).force_encoding(Encoding::UTF_8)
|
|
243
|
+
unless lang.valid_encoding? && title.valid_encoding?
|
|
244
|
+
skipped += 1
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
yield ll_from.to_i, lang, title
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
ensure
|
|
252
|
+
io.close
|
|
253
|
+
end
|
|
254
|
+
skipped
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# One extended-INSERT tuple: (123,'lang','Title'). The string classes
|
|
258
|
+
# [^'\\]|\\. match any run of non-quote/non-backslash characters and
|
|
259
|
+
# backslash escape pairs, so escaped quotes (\') and backslashes (\\) —
|
|
260
|
+
# and commas/parens inside titles — do not terminate the capture.
|
|
261
|
+
# Tuples that do not match this shape are simply not extracted
|
|
262
|
+
# (equivalent to the old parser skipping malformed tuples)
|
|
263
|
+
TUPLE_REGEX = /\((\d+),'((?:[^'\\]|\\.)*)','((?:[^'\\]|\\.)*)'\)/
|
|
264
|
+
|
|
265
|
+
UNESCAPE_REGEX = /\\(.)/m
|
|
266
|
+
|
|
267
|
+
# Resolve MySQL backslash escapes in a captured string literal
|
|
268
|
+
# (same mapping as the old hand-rolled parser)
|
|
269
|
+
def unescape_mysql(str)
|
|
270
|
+
str.gsub(UNESCAPE_REGEX) { UNESCAPES[::Regexp.last_match(1)] || ::Regexp.last_match(1) }
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -37,15 +37,47 @@ module Wp2txt
|
|
|
37
37
|
File.join(dir, "#{basename}_#{path_hash}#{CACHE_SUFFIX}")
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# Normalize a
|
|
40
|
+
# Normalize a page title the way MediaWiki treats titles:
|
|
41
41
|
# underscores to spaces, trimmed, first letter capitalized
|
|
42
|
-
def self.
|
|
42
|
+
def self.normalize_title(name)
|
|
43
43
|
n = name.to_s.tr("_", " ").strip.squeeze(" ")
|
|
44
44
|
return n if n.empty?
|
|
45
45
|
|
|
46
46
|
n[0].upcase + n[1..].to_s
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Normalize a category name (same MediaWiki title rules as normalize_title)
|
|
50
|
+
def self.normalize_category(name)
|
|
51
|
+
normalize_title(name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Built metadata DBs for one language found in a cache directory, most
|
|
55
|
+
# recently built first. Used for cross-dump ATTACH resolution (Corpus)
|
|
56
|
+
# and langlinks sanity checks (LanglinksImporter).
|
|
57
|
+
# @return [Array<Hash>] [{db_path:, dump_name:, built_at:, built_with:}]
|
|
58
|
+
def self.cached_candidates(lang, cache_dir: nil)
|
|
59
|
+
dir = cache_dir || File.expand_path("~/.wp2txt/cache")
|
|
60
|
+
Dir.glob(File.join(dir, "#{lang}wiki-*#{CACHE_SUFFIX}")).filter_map do |path|
|
|
61
|
+
meta = read_metadata_file(path)
|
|
62
|
+
next unless meta && meta[:schema_version].to_i == SCHEMA_VERSION && meta[:built_at]
|
|
63
|
+
|
|
64
|
+
{ db_path: path, dump_name: meta[:dump_name], built_at: meta[:built_at],
|
|
65
|
+
built_with: meta[:wp2txt_version] }
|
|
66
|
+
end.sort_by { |c| c[:built_at].to_s }.reverse
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Light, read-only metadata table read for a DB file we do not manage
|
|
70
|
+
def self.read_metadata_file(path)
|
|
71
|
+
db = SQLite3::Database.new(path, readonly: true)
|
|
72
|
+
result = {}
|
|
73
|
+
db.execute("SELECT key, value FROM metadata") { |key, value| result[key.to_sym] = value }
|
|
74
|
+
result
|
|
75
|
+
rescue SQLite3::Exception
|
|
76
|
+
nil
|
|
77
|
+
ensure
|
|
78
|
+
db&.close
|
|
79
|
+
end
|
|
80
|
+
|
|
49
81
|
# Remove wiki markup from a heading ('''bold''', [[link|label]], HTML tags)
|
|
50
82
|
def self.clean_heading(text)
|
|
51
83
|
t = text.gsub(/'{2,}/, "")
|
|
@@ -111,6 +143,24 @@ module Wp2txt
|
|
|
111
143
|
}
|
|
112
144
|
end
|
|
113
145
|
|
|
146
|
+
# Provenance of an imported langlinks table (nil when not imported).
|
|
147
|
+
# The langlinks table is an optional post-build addition (LanglinksImporter),
|
|
148
|
+
# so its absence does not affect built? or schema_version.
|
|
149
|
+
def langlinks_provenance
|
|
150
|
+
return nil unless File.exist?(@db_path)
|
|
151
|
+
|
|
152
|
+
meta = read_metadata
|
|
153
|
+
return nil unless meta && meta[:langlinks_imported_at]
|
|
154
|
+
|
|
155
|
+
{ source: meta[:langlinks_source],
|
|
156
|
+
source_size: meta[:langlinks_source_size].to_i,
|
|
157
|
+
imported_at: meta[:langlinks_imported_at],
|
|
158
|
+
imported_with: meta[:langlinks_wp2txt_version],
|
|
159
|
+
lang_filter: meta[:langlinks_lang_filter],
|
|
160
|
+
row_count: meta[:langlinks_row_count].to_i,
|
|
161
|
+
skipped_invalid: meta[:langlinks_skipped_invalid].to_i }
|
|
162
|
+
end
|
|
163
|
+
|
|
114
164
|
def close
|
|
115
165
|
@db&.close
|
|
116
166
|
@db = nil
|
|
@@ -269,6 +319,22 @@ module Wp2txt
|
|
|
269
319
|
).map(&:first)
|
|
270
320
|
end
|
|
271
321
|
|
|
322
|
+
# Look up titles in pages (existence + redirect target), batched to keep
|
|
323
|
+
# the IN clause small. Used by Corpus#extract_corpus titles: resolution.
|
|
324
|
+
# @return [Hash] { title => redirect_to_or_nil } for the titles that exist
|
|
325
|
+
def redirect_map(titles)
|
|
326
|
+
result = {}
|
|
327
|
+
titles.each_slice(500) do |slice|
|
|
328
|
+
placeholders = slice.map { "?" }.join(",")
|
|
329
|
+
open_db.execute(
|
|
330
|
+
"SELECT title, redirect_to FROM pages WHERE title IN (#{placeholders})", slice
|
|
331
|
+
).each do |title, redirect_to|
|
|
332
|
+
result[title] = redirect_to
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
result
|
|
336
|
+
end
|
|
337
|
+
|
|
272
338
|
# Subcategory tree starting at category, as [{name:, depth:}, ...] (BFS order)
|
|
273
339
|
def category_tree(category, depth: 2)
|
|
274
340
|
cat = self.class.normalize_category(category)
|
data/lib/wp2txt/multistream.rb
CHANGED
|
@@ -826,6 +826,35 @@ module Wp2txt
|
|
|
826
826
|
File.join(@cache_dir, "#{@lang}wiki-#{latest_dump_date}-multistream.xml.bz2")
|
|
827
827
|
end
|
|
828
828
|
|
|
829
|
+
# URL of the langlinks dump for an explicit dump date
|
|
830
|
+
def langlinks_url(date)
|
|
831
|
+
wiki = "#{@lang}wiki"
|
|
832
|
+
"#{DUMP_BASE_URL}/#{wiki}/#{date}/#{wiki}-#{date}-langlinks.sql.gz"
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# Cache path of the langlinks dump for an explicit dump date
|
|
836
|
+
def cached_langlinks_path(date)
|
|
837
|
+
File.join(@cache_dir, "#{@lang}wiki-#{date}-langlinks.sql.gz")
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# Download the langlinks dump for an explicit dump date. The date MUST
|
|
841
|
+
# come from the built metadata index (not latest_dump_date) so the
|
|
842
|
+
# imported links stay pinned to the indexed dump version.
|
|
843
|
+
def download_langlinks(date:, force: false)
|
|
844
|
+
path = cached_langlinks_path(date)
|
|
845
|
+
if File.exist?(path) && !force
|
|
846
|
+
puts "Langlinks already cached: #{File.basename(path)}"
|
|
847
|
+
$stdout.flush
|
|
848
|
+
return path
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
url = langlinks_url(date)
|
|
852
|
+
puts "Downloading langlinks: #{url}"
|
|
853
|
+
$stdout.flush
|
|
854
|
+
download_file(url, path)
|
|
855
|
+
path
|
|
856
|
+
end
|
|
857
|
+
|
|
829
858
|
# Check if cache is fresh (within configured days)
|
|
830
859
|
def cache_fresh?(days = nil)
|
|
831
860
|
days ||= @dump_expiry_days
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wp2txt
|
|
4
|
+
# Server-side output path confinement shared by the file-writing tools
|
|
5
|
+
# (extract_corpus / start_extract_job / query_sql with output_path).
|
|
6
|
+
# Paths must resolve under the server's output directory — an agent mixing
|
|
7
|
+
# up paths must not be able to clobber arbitrary user files — and existing
|
|
8
|
+
# files are not replaced unless overwrite is set.
|
|
9
|
+
module OutputPath
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @return [String] the confined, absolute output path
|
|
13
|
+
# @raise [ArgumentError] when the path escapes base_dir or the file exists
|
|
14
|
+
def confine(output_path, base_dir, overwrite: false)
|
|
15
|
+
path = File.expand_path(output_path, base_dir)
|
|
16
|
+
base = File.expand_path(base_dir)
|
|
17
|
+
unless path == base || path.start_with?(base + File::SEPARATOR)
|
|
18
|
+
raise ArgumentError, "output_path must stay within the server output directory (#{base})"
|
|
19
|
+
end
|
|
20
|
+
if File.exist?(path) && !overwrite
|
|
21
|
+
raise ArgumentError, "output file already exists: #{path} (pass overwrite: true to replace it)"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
path
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/wp2txt/regex.rb
CHANGED
|
@@ -210,6 +210,13 @@ module Wp2txt
|
|
|
210
210
|
REMOVE_HR_REGEX = Regexp.new('^\s*\-{4,}\s*$')
|
|
211
211
|
MAKE_REFERENCE_REGEX_A = Regexp.new('<br ?\/>', Regexp::IGNORECASE)
|
|
212
212
|
MAKE_REFERENCE_REGEX_B = Regexp.new('<ref[^>]*\/>', Regexp::IGNORECASE)
|
|
213
|
+
# A reference carrying no content adds nothing in either mode
|
|
214
|
+
MAKE_REFERENCE_REGEX_EMPTY = Regexp.new('<ref[^>]*>\s*<\/ref>', Regexp::MULTILINE | Regexp::IGNORECASE)
|
|
215
|
+
# One whole reference span, used to normalize newlines inside it.
|
|
216
|
+
# The span must not contain another opening <ref: an unclosed <ref> would
|
|
217
|
+
# otherwise pair with a later </ref> and swallow the paragraphs in between.
|
|
218
|
+
MAKE_REFERENCE_REGEX_SPAN = Regexp.new('<ref[^>]*>(?:(?!<ref[\s>]).)*?<\/ref>',
|
|
219
|
+
Regexp::MULTILINE | Regexp::IGNORECASE)
|
|
213
220
|
MAKE_REFERENCE_REGEX_C = Regexp.new('<ref[^>]*>', Regexp::IGNORECASE)
|
|
214
221
|
MAKE_REFERENCE_REGEX_D = Regexp.new('<\/ref>', Regexp::IGNORECASE)
|
|
215
222
|
FORMAT_REF_REGEX = Regexp.new('\[ref\](.*?)\[\/ref\]', Regexp::MULTILINE)
|
|
@@ -308,6 +308,14 @@ module Wp2txt
|
|
|
308
308
|
result = +str.to_s
|
|
309
309
|
result.gsub!(MAKE_REFERENCE_REGEX_A, "\n")
|
|
310
310
|
result.gsub!(MAKE_REFERENCE_REGEX_B, "")
|
|
311
|
+
# Element splitting (Article#parse) breaks paragraphs at newlines, so a
|
|
312
|
+
# reference written across lines would land in separate elements and its
|
|
313
|
+
# [ref]/[/ref] pair would never be visible to remove_ref at the same time.
|
|
314
|
+
# Drop empty references outright, and flatten the rest onto one line so
|
|
315
|
+
# multi-line references behave exactly like single-line ones (this is also
|
|
316
|
+
# what makes --extract-citations work on multi-line cite templates).
|
|
317
|
+
result.gsub!(MAKE_REFERENCE_REGEX_EMPTY, "")
|
|
318
|
+
result.gsub!(MAKE_REFERENCE_REGEX_SPAN) { |span| span.gsub(/\s*\n\s*/, " ") }
|
|
311
319
|
result.gsub!(MAKE_REFERENCE_REGEX_C, "[ref]")
|
|
312
320
|
result.gsub!(MAKE_REFERENCE_REGEX_D, "[/ref]")
|
|
313
321
|
result
|
data/lib/wp2txt/utils.rb
CHANGED
|
@@ -415,11 +415,24 @@ module Wp2txt
|
|
|
415
415
|
result.strip
|
|
416
416
|
end
|
|
417
417
|
|
|
418
|
+
# Reference markers are already in [ref] form when external links are
|
|
419
|
+
# processed, and the scanner would strip their brackets (making remove_ref
|
|
420
|
+
# fail). Hide them behind placeholders for the duration of the scan.
|
|
421
|
+
# NOTE: do not special-case them inside the block instead — returning
|
|
422
|
+
# "[ref]" from the block makes process_nested_single_pass re-detect the same
|
|
423
|
+
# spot forever, burning MAX_NESTING_ITERATIONS and leaving the whole string
|
|
424
|
+
# unprocessed (measured: 478x slower, no links processed).
|
|
425
|
+
REF_OPEN_PLACEHOLDER = "«REFOPEN»"
|
|
426
|
+
REF_CLOSE_PLACEHOLDER = "«REFCLOSE»"
|
|
427
|
+
|
|
418
428
|
def process_external_links(str)
|
|
419
429
|
# Early exit if no external links present
|
|
420
430
|
return str unless str.include?("[")
|
|
421
431
|
|
|
422
|
-
|
|
432
|
+
protected_str = str.gsub("[ref]", REF_OPEN_PLACEHOLDER)
|
|
433
|
+
.gsub("[/ref]", REF_CLOSE_PLACEHOLDER)
|
|
434
|
+
|
|
435
|
+
processed = process_nested_single_pass(protected_str, "[", "]") do |contents|
|
|
423
436
|
if /\A\s.+\s\z/ =~ contents
|
|
424
437
|
" (#{contents.strip}) "
|
|
425
438
|
else
|
|
@@ -432,6 +445,9 @@ module Wp2txt
|
|
|
432
445
|
end
|
|
433
446
|
end
|
|
434
447
|
end
|
|
448
|
+
|
|
449
|
+
processed.gsub(REF_OPEN_PLACEHOLDER, "[ref]")
|
|
450
|
+
.gsub(REF_CLOSE_PLACEHOLDER, "[/ref]")
|
|
435
451
|
end
|
|
436
452
|
|
|
437
453
|
#################### template processing ####################
|
data/lib/wp2txt/version.rb
CHANGED
data/spec/auto_download_spec.rb
CHANGED
|
@@ -33,6 +33,26 @@ RSpec.describe "Wp2txt Auto Download" do
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
describe "langlinks paths" do
|
|
37
|
+
it "builds URL and cache path for an explicit dump date (no network)" do
|
|
38
|
+
manager = Wp2txt::DumpManager.new(:ja, cache_dir: cache_dir)
|
|
39
|
+
expect(manager.langlinks_url("20260101")).to eq(
|
|
40
|
+
"https://dumps.wikimedia.org/jawiki/20260101/jawiki-20260101-langlinks.sql.gz"
|
|
41
|
+
)
|
|
42
|
+
expect(manager.cached_langlinks_path("20260101")).to eq(
|
|
43
|
+
File.join(cache_dir, "jawiki-20260101-langlinks.sql.gz")
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "reuses a cached langlinks file without downloading" do
|
|
48
|
+
manager = Wp2txt::DumpManager.new(:ja, cache_dir: cache_dir)
|
|
49
|
+
path = manager.cached_langlinks_path("20260101")
|
|
50
|
+
File.write(path, "cached")
|
|
51
|
+
expect(manager.download_langlinks(date: "20260101")).to eq(path)
|
|
52
|
+
expect(File.read(path)).to eq("cached")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
36
56
|
describe "#cache_status" do
|
|
37
57
|
it "returns status hash with expected keys" do
|
|
38
58
|
manager = Wp2txt::DumpManager.new(:ja, cache_dir: cache_dir)
|
|
@@ -285,6 +305,63 @@ RSpec.describe "Wp2txt Auto Download" do
|
|
|
285
305
|
end
|
|
286
306
|
end
|
|
287
307
|
end
|
|
308
|
+
|
|
309
|
+
context "--import-langlinks option" do
|
|
310
|
+
it "accepts --import-langlinks with --lang" do
|
|
311
|
+
opts = Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--cache-dir=#{cache_dir}"])
|
|
312
|
+
expect(opts[:import_langlinks]).to be true
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it "requires --lang" do
|
|
316
|
+
suppress_stderr do
|
|
317
|
+
expect { Wp2txt::CLI.parse_options(["--import-langlinks", "--cache-dir=#{cache_dir}"]) }.to raise_error(SystemExit)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
it "cannot be combined with --build-index" do
|
|
322
|
+
suppress_stderr do
|
|
323
|
+
expect do
|
|
324
|
+
Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--build-index", "--cache-dir=#{cache_dir}"])
|
|
325
|
+
end.to raise_error(SystemExit)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
it "accepts --langlinks-file with an existing file" do
|
|
330
|
+
file = File.join(cache_dir, "jawiki-20260101-langlinks.sql")
|
|
331
|
+
File.write(file, "")
|
|
332
|
+
opts = Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--langlinks-file=#{file}", "--cache-dir=#{cache_dir}"])
|
|
333
|
+
expect(opts[:langlinks_file]).to eq(file)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
it "rejects --langlinks-file without --import-langlinks" do
|
|
337
|
+
suppress_stderr do
|
|
338
|
+
expect do
|
|
339
|
+
Wp2txt::CLI.parse_options(["--lang=ja", "--langlinks-file=x.sql", "--cache-dir=#{cache_dir}"])
|
|
340
|
+
end.to raise_error(SystemExit)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
it "rejects a missing --langlinks-file" do
|
|
345
|
+
suppress_stderr do
|
|
346
|
+
expect do
|
|
347
|
+
Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--langlinks-file=#{cache_dir}/nope.sql", "--cache-dir=#{cache_dir}"])
|
|
348
|
+
end.to raise_error(SystemExit)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
it "accepts --langlinks-langs as a comma-separated list" do
|
|
353
|
+
opts = Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--langlinks-langs=en,de,fr", "--cache-dir=#{cache_dir}"])
|
|
354
|
+
expect(opts[:langlinks_langs]).to eq("en,de,fr")
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
it "rejects invalid language codes in --langlinks-langs" do
|
|
358
|
+
suppress_stderr do
|
|
359
|
+
expect do
|
|
360
|
+
Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--langlinks-langs=en,../x", "--cache-dir=#{cache_dir}"])
|
|
361
|
+
end.to raise_error(SystemExit)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
288
365
|
end
|
|
289
366
|
end
|
|
290
367
|
|
data/spec/corpus_spec.rb
CHANGED
|
@@ -199,6 +199,31 @@ RSpec.describe Wp2txt::Corpus do
|
|
|
199
199
|
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).to be < 5
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
it "gives the query child its own kernel-enforced CPU limit" do
|
|
203
|
+
# A child orphaned by the parent's death (interrupted test run, closed
|
|
204
|
+
# terminal) must still die on its own: sqlite3 holds the GVL inside
|
|
205
|
+
# sqlite3_step, so signals are never processed and only the kernel can
|
|
206
|
+
# stop it. Verified here by reading the limit the child actually gets.
|
|
207
|
+
skip "no CPU rlimits on this platform" unless Process.respond_to?(:setrlimit)
|
|
208
|
+
|
|
209
|
+
reader, writer = IO.pipe
|
|
210
|
+
pid = Process.fork do
|
|
211
|
+
reader.close
|
|
212
|
+
Wp2txt::Corpus.apply_child_cpu_limit(30)
|
|
213
|
+
writer.puts(Process.getrlimit(Process::RLIMIT_CPU).inspect)
|
|
214
|
+
writer.close
|
|
215
|
+
exit!(0)
|
|
216
|
+
end
|
|
217
|
+
writer.close
|
|
218
|
+
limits = reader.read
|
|
219
|
+
reader.close
|
|
220
|
+
Process.waitpid(pid)
|
|
221
|
+
|
|
222
|
+
# 30s query timeout => soft 35s / hard 40s of CPU time. Both exceed the
|
|
223
|
+
# parent's own wall-clock deadline, so no legitimate query is affected.
|
|
224
|
+
expect(limits.strip).to eq("[35, 40]")
|
|
225
|
+
end
|
|
226
|
+
|
|
202
227
|
it "keeps serving queries after a timeout" do
|
|
203
228
|
expect do
|
|
204
229
|
@corpus.query_sql("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT COUNT(*) FROM c", timeout: 1)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# The MCP tool surface and its public documentation drift apart easily
|
|
6
|
+
# (the 2.2.0 README shipped with a tool table missing four tools). This spec
|
|
7
|
+
# pins them together: every tool defined in bin/wp2txt-mcp must appear in the
|
|
8
|
+
# docs/RESEARCH.md tool table, and the table must not list phantom tools.
|
|
9
|
+
RSpec.describe "documentation surface sync" do
|
|
10
|
+
repo_root = File.expand_path("..", __dir__)
|
|
11
|
+
|
|
12
|
+
define_method(:defined_tools) do
|
|
13
|
+
src = File.read(File.join(repo_root, "bin", "wp2txt-mcp"))
|
|
14
|
+
src.scan(/server\.define_tool\(\s*name:\s*"([a-z_]+)"/).flatten
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
define_method(:documented_tools) do
|
|
18
|
+
doc = File.read(File.join(repo_root, "docs", "RESEARCH.md"))
|
|
19
|
+
table = doc[/^### Tools\n(.*?)\n\n/m, 1]
|
|
20
|
+
raise "Tools table not found in docs/RESEARCH.md" unless table
|
|
21
|
+
|
|
22
|
+
# Tool names live in the first column only (the purpose column may
|
|
23
|
+
# backtick argument names like `attach`)
|
|
24
|
+
table.lines.filter_map { |line| line.split("|")[1] }
|
|
25
|
+
.flat_map { |cell| cell.scan(/`([a-z_]+)`/).flatten }
|
|
26
|
+
.uniq
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "defines a non-trivial number of MCP tools" do
|
|
30
|
+
expect(defined_tools.size).to be >= 15
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "documents every MCP tool in docs/RESEARCH.md, with no phantom entries" do
|
|
34
|
+
missing = defined_tools - documented_tools
|
|
35
|
+
phantom = documented_tools - defined_tools
|
|
36
|
+
expect(missing).to be_empty, "tools not documented in docs/RESEARCH.md: #{missing.join(', ')}"
|
|
37
|
+
expect(phantom).to be_empty, "documented tools that do not exist: #{phantom.join(', ')}"
|
|
38
|
+
end
|
|
39
|
+
end
|