wp2txt 2.2.0 → 2.3.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/.gitignore +3 -0
- data/CHANGELOG.md +10 -0
- data/Dockerfile +3 -0
- data/README.md +36 -56
- data/Rakefile +4 -1
- data/bin/wp2txt +1 -0
- data/bin/wp2txt-mcp +18 -19
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +43 -0
- data/lib/wp2txt/corpus.rb +358 -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/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/titles_output_path_spec.rb +338 -0
- metadata +12 -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/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
|
|