wp2txt 2.1.2 → 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 +27 -0
- data/Dockerfile +3 -0
- data/Gemfile +2 -0
- data/README.md +60 -2
- data/Rakefile +4 -1
- data/bin/wp2txt +9 -0
- data/bin/wp2txt-mcp +395 -0
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +110 -0
- data/lib/wp2txt/corpus.rb +1057 -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 +427 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +738 -0
- data/lib/wp2txt/multistream.rb +38 -5
- 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/corpus_spec.rb +503 -0
- data/spec/fts_index_spec.rb +245 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/metadata_index_spec.rb +208 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/support/multistream_fixture.rb +66 -0
- data/spec/titles_output_path_spec.rb +338 -0
- metadata +27 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require_relative "support/multistream_fixture"
|
|
8
|
+
require_relative "../lib/wp2txt/fts_index"
|
|
9
|
+
require_relative "../lib/wp2txt/corpus"
|
|
10
|
+
|
|
11
|
+
RSpec.describe "Wp2txt Full-Text Search" do
|
|
12
|
+
include MultistreamFixture
|
|
13
|
+
|
|
14
|
+
def build_indexes(dir, tokenizer: "unicode61", optimize: true)
|
|
15
|
+
multistream_path, index_path = create_fixture(dir)
|
|
16
|
+
ms_index = Wp2txt::MultistreamIndex.new(index_path, use_cache: false, show_progress: false)
|
|
17
|
+
|
|
18
|
+
meta_db = Wp2txt::MetadataIndex.path_for(multistream_path, cache_dir: dir)
|
|
19
|
+
Wp2txt::MetadataIndexBuilder.new(
|
|
20
|
+
multistream_path, ms_index.stream_offsets, db_path: meta_db, num_processes: 0
|
|
21
|
+
).build
|
|
22
|
+
|
|
23
|
+
fts_db = Wp2txt::FtsIndex.path_for(multistream_path, cache_dir: dir)
|
|
24
|
+
Wp2txt::FtsIndexBuilder.new(
|
|
25
|
+
multistream_path, ms_index.stream_offsets,
|
|
26
|
+
db_path: fts_db, meta_db_path: meta_db, tokenizer: tokenizer,
|
|
27
|
+
num_processes: 0, optimize: optimize
|
|
28
|
+
).build
|
|
29
|
+
|
|
30
|
+
[multistream_path, fts_db, meta_db]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe Wp2txt::FtsIndex do
|
|
34
|
+
describe ".default_tokenizer" do
|
|
35
|
+
it "picks trigram for CJK language dumps" do
|
|
36
|
+
expect(described_class.default_tokenizer("/x/jawiki-20260701-multistream.xml.bz2")).to eq("trigram")
|
|
37
|
+
expect(described_class.default_tokenizer("/x/zhwiki-20260701-multistream.xml.bz2")).to eq("trigram")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "picks unicode61 for space-delimited language dumps" do
|
|
41
|
+
expect(described_class.default_tokenizer("/x/enwiki-20260701-multistream.xml.bz2")).to eq("unicode61")
|
|
42
|
+
expect(described_class.default_tokenizer("/x/dewiki-20260701-multistream.xml.bz2")).to eq("unicode61")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
context "with a built unicode61 index" do
|
|
47
|
+
around do |example|
|
|
48
|
+
Dir.mktmpdir do |dir|
|
|
49
|
+
@multistream_path, fts_db, meta_db = build_indexes(dir)
|
|
50
|
+
@fts = described_class.new(fts_db, meta_db)
|
|
51
|
+
example.run
|
|
52
|
+
@fts.close
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "is built, valid, and records the tokenizer" do
|
|
57
|
+
expect(@fts.built?).to be true
|
|
58
|
+
expect(@fts.valid_for?(@multistream_path)).to be true
|
|
59
|
+
expect(@fts.tokenizer).to eq("unicode61")
|
|
60
|
+
expect(@fts.stats[:section_count]).to be > 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "finds matches across articles with exact counts" do
|
|
64
|
+
result = @fts.search("Story", count: "exact")
|
|
65
|
+
expect(result[:total]).to eq(2)
|
|
66
|
+
expect(result[:total_is_capped]).to be false
|
|
67
|
+
expect(result[:hits].map { |h| h[:title] }).to contain_exactly("Film A", "Film B")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "searches lead sections (empty heading)" do
|
|
71
|
+
result = @fts.search("Intro", count: "exact")
|
|
72
|
+
expect(result[:total]).to eq(2)
|
|
73
|
+
expect(result[:hits].map { |h| h[:heading] }.uniq).to eq([""])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "excludes redirect pages from the index" do
|
|
77
|
+
result = @fts.search("Old Film", count: "exact")
|
|
78
|
+
expect(result[:total]).to eq(0)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "returns zero for absent strings (absence claim)" do
|
|
82
|
+
expect(@fts.search("zebra unicorn", count: "exact")[:total]).to eq(0)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "composes with an exact category filter" do
|
|
86
|
+
result = @fts.search("Story", category: "Japanese films", count: "exact")
|
|
87
|
+
expect(result[:hits].map { |h| h[:title] }).to eq(["Film A"])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "composes with a recursive category filter" do
|
|
91
|
+
result = @fts.search("Story", category: "Films", depth: 1, count: "exact")
|
|
92
|
+
expect(result[:total]).to eq(2)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "composes with a section filter" do
|
|
96
|
+
result = @fts.search("Story", sections: ["Plot"], count: "exact")
|
|
97
|
+
expect(result[:hits].map { |h| h[:heading] }).to eq(["Plot"])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "normalizes decorated headings the same way as the metadata index" do
|
|
101
|
+
result = @fts.search("Distinct prose", count: "exact")
|
|
102
|
+
expect(result[:hits].map { |h| h[:heading] }).to eq(["Style"])
|
|
103
|
+
expect(@fts.search("Distinct prose", sections: ["Style"], count: "exact")[:total]).to eq(1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "keeps ord aligned between page_sections and fts_map (shared semantics)" do
|
|
107
|
+
meta_db = SQLite3::Database.new(@fts.meta_db_path, readonly: true)
|
|
108
|
+
meta_ord = meta_db.get_first_value(
|
|
109
|
+
"SELECT ps.ord FROM page_sections ps JOIN pages p ON p.page_id = ps.page_id " \
|
|
110
|
+
"WHERE p.title = 'Person X' AND ps.heading = 'Career'"
|
|
111
|
+
)
|
|
112
|
+
meta_db.close
|
|
113
|
+
fts_db = SQLite3::Database.new(@fts.db_path, readonly: true)
|
|
114
|
+
fts_ord = fts_db.get_first_value(
|
|
115
|
+
"SELECT fm.ord FROM fts_map fm WHERE fm.heading = 'Career'"
|
|
116
|
+
)
|
|
117
|
+
fts_db.close
|
|
118
|
+
expect(meta_ord).to eq(1) # lead = 0 (not stored), first heading = 1
|
|
119
|
+
expect(fts_ord).to eq(meta_ord)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "caps counting when requested" do
|
|
123
|
+
result = @fts.search("Story", count: "capped", count_cap: 1)
|
|
124
|
+
expect(result[:total]).to eq(1)
|
|
125
|
+
expect(result[:total_is_capped]).to be true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "supports raw FTS5 query mode" do
|
|
129
|
+
result = @fts.search("Story OR Acting", mode: "query", count: "exact")
|
|
130
|
+
expect(result[:total]).to eq(3)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "escapes quotes in phrase mode" do
|
|
134
|
+
expect { @fts.search('say "hi" now', count: "exact") }.not_to raise_error
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
context "built with optimize: false" do
|
|
139
|
+
around do |example|
|
|
140
|
+
Dir.mktmpdir do |dir|
|
|
141
|
+
@multistream_path, fts_db, meta_db = build_indexes(dir, optimize: false)
|
|
142
|
+
@fts = described_class.new(fts_db, meta_db)
|
|
143
|
+
example.run
|
|
144
|
+
@fts.close
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "is valid, flagged unoptimized, and fully searchable" do
|
|
149
|
+
expect(@fts.built?).to be true
|
|
150
|
+
expect(@fts.valid_for?(@multistream_path)).to be true
|
|
151
|
+
expect(@fts.optimized?).to be false
|
|
152
|
+
expect(@fts.stats[:optimized]).to be false
|
|
153
|
+
expect(@fts.search("Story", count: "exact")[:total]).to eq(2)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "can be optimized afterwards (idempotent)" do
|
|
157
|
+
expect(@fts.optimize!).to be true
|
|
158
|
+
expect(@fts.optimized?).to be true
|
|
159
|
+
expect(@fts.search("Story", count: "exact")[:total]).to eq(2)
|
|
160
|
+
expect(@fts.optimize!).to be true
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
context "with a trigram index" do
|
|
165
|
+
around do |example|
|
|
166
|
+
Dir.mktmpdir do |dir|
|
|
167
|
+
@multistream_path, fts_db, meta_db = build_indexes(dir, tokenizer: "trigram")
|
|
168
|
+
@fts = described_class.new(fts_db, meta_db)
|
|
169
|
+
example.run
|
|
170
|
+
@fts.close
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "matches substrings of three or more characters" do
|
|
175
|
+
result = @fts.search("tory", count: "exact")
|
|
176
|
+
expect(result[:total]).to eq(2)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
describe "Corpus#search_text" do
|
|
182
|
+
around do |example|
|
|
183
|
+
Dir.mktmpdir do |dir|
|
|
184
|
+
@multistream_path, = build_indexes(dir)
|
|
185
|
+
@corpus = Wp2txt::Corpus.for_input(@multistream_path, cache_dir: dir)
|
|
186
|
+
example.run
|
|
187
|
+
@corpus.close
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "reports the fulltext tier in dump_info" do
|
|
192
|
+
info = @corpus.dump_info
|
|
193
|
+
expect(info[:tiers][:fulltext]).to be true
|
|
194
|
+
expect(info[:fulltext_current]).to be true
|
|
195
|
+
expect(info[:fulltext][:tokenizer]).to eq("unicode61")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "exposes the fts tables to query_sql" do
|
|
199
|
+
result = @corpus.query_sql("SELECT COUNT(*) FROM fts.fts_map")
|
|
200
|
+
expect(result[:rows].first.first).to be > 0
|
|
201
|
+
expect(@corpus.describe_schema[:fts].join).to include("fts_map")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "returns hits with section paths and dump identity" do
|
|
205
|
+
result = @corpus.search_text("Story", count: "exact")
|
|
206
|
+
expect(result[:dump]).to eq("testwiki-20260101")
|
|
207
|
+
expect(result[:total]).to eq(2)
|
|
208
|
+
paths = result[:hits].map { |h| h[:section_path] }
|
|
209
|
+
expect(paths).to contain_exactly("Film A > Plot", "Film B > Synopsis")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it "renders snippets containing the search term" do
|
|
213
|
+
result = @corpus.search_text("Story")
|
|
214
|
+
expect(result[:hits].first[:snippet]).to include("Story")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it "uses the article title as section_path for lead hits" do
|
|
218
|
+
result = @corpus.search_text("Intro", count: "exact")
|
|
219
|
+
expect(result[:hits].map { |h| h[:section_path] }).to contain_exactly("Film A", "Film B")
|
|
220
|
+
expect(result[:hits].first).not_to have_key(:section)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "expands section filters through alias sets" do
|
|
224
|
+
@corpus.save_alias_set("plot", [%w[Plot Synopsis]], min_articles: 1)
|
|
225
|
+
result = @corpus.search_text("Story", sections: ["Plot"], alias_set: "plot", count: "exact")
|
|
226
|
+
expect(result[:total]).to eq(2)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it "raises a helpful error when the index is missing" do
|
|
230
|
+
Dir.mktmpdir do |dir2|
|
|
231
|
+
ms2, = create_fixture(dir2)
|
|
232
|
+
ms_index = Wp2txt::MultistreamIndex.new(
|
|
233
|
+
ms2.sub(/multistream\.xml\.bz2\z/, "multistream-index.txt"), use_cache: false, show_progress: false
|
|
234
|
+
)
|
|
235
|
+
Wp2txt::MetadataIndexBuilder.new(
|
|
236
|
+
ms2, ms_index.stream_offsets,
|
|
237
|
+
db_path: Wp2txt::MetadataIndex.path_for(ms2, cache_dir: dir2), num_processes: 0
|
|
238
|
+
).build
|
|
239
|
+
corpus2 = Wp2txt::Corpus.for_input(ms2, cache_dir: dir2)
|
|
240
|
+
expect { corpus2.search_text("x") }.to raise_error(ArgumentError, /Full-text index not built/)
|
|
241
|
+
corpus2.close
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require_relative "support/multistream_fixture"
|
|
7
|
+
require_relative "support/meta_db_fixture"
|
|
8
|
+
require_relative "../lib/wp2txt/langlinks_importer"
|
|
9
|
+
require_relative "../lib/wp2txt/multistream"
|
|
10
|
+
|
|
11
|
+
RSpec.describe Wp2txt::LanglinksImporter do
|
|
12
|
+
include MultistreamFixture
|
|
13
|
+
include MetaDbFixture
|
|
14
|
+
|
|
15
|
+
around do |example|
|
|
16
|
+
Dir.mktmpdir do |dir|
|
|
17
|
+
@dir = dir
|
|
18
|
+
@multistream_path, @index_path = create_fixture(dir)
|
|
19
|
+
|
|
20
|
+
ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
|
|
21
|
+
@db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
|
|
22
|
+
Wp2txt::MetadataIndexBuilder.new(
|
|
23
|
+
@multistream_path, ms_index.stream_offsets,
|
|
24
|
+
db_path: @db_path, num_processes: 0
|
|
25
|
+
).build
|
|
26
|
+
example.run
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# MySQL dump content covering the tricky cases: escaped quotes/backslashes,
|
|
31
|
+
# commas and parens inside titles, underscores, multiple INSERT statements,
|
|
32
|
+
# and an INSERT for a different table (must be ignored)
|
|
33
|
+
LANGLINKS_SQL = <<~SQL
|
|
34
|
+
-- MySQL dump fixture
|
|
35
|
+
CREATE TABLE `langlinks` (
|
|
36
|
+
`ll_from` int unsigned NOT NULL DEFAULT 0,
|
|
37
|
+
`ll_lang` varbinary(20) NOT NULL DEFAULT '',
|
|
38
|
+
`ll_title` varbinary(255) NOT NULL DEFAULT ''
|
|
39
|
+
) ENGINE=InnoDB DEFAULT CHARSET=binary;
|
|
40
|
+
|
|
41
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A'),(1,'de','Film A (Film)'),(2,'en','It\\'s a Film, Really (1984)');
|
|
42
|
+
INSERT INTO `langlinks` VALUES (3,'en','Back\\\\slash Title'),(3,'fr','Film B'),(4,'en','Underscore_title');
|
|
43
|
+
INSERT INTO `pagelinks` VALUES (1,'Foo',0);
|
|
44
|
+
UNLOCK TABLES;
|
|
45
|
+
SQL
|
|
46
|
+
|
|
47
|
+
def write_langlinks(name, content = LANGLINKS_SQL, gzip: false)
|
|
48
|
+
path = File.join(@dir, name)
|
|
49
|
+
if gzip
|
|
50
|
+
Zlib::GzipWriter.open(path) { |gz| gz.write(content) }
|
|
51
|
+
else
|
|
52
|
+
File.write(path, content)
|
|
53
|
+
end
|
|
54
|
+
path
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def import(path, **opts)
|
|
58
|
+
described_class.new(@db_path, cache_dir: @dir).import!(path, **opts)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def langlinks_rows
|
|
62
|
+
db = SQLite3::Database.new(@db_path, readonly: true)
|
|
63
|
+
rows = db.execute("SELECT ll_from, ll_lang, ll_title FROM langlinks ORDER BY ll_from, ll_lang")
|
|
64
|
+
db.close
|
|
65
|
+
rows
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe "parsing and normalization" do
|
|
69
|
+
it "imports tuples with escapes, commas, parens, and multiple INSERT statements" do
|
|
70
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
71
|
+
result = import(path)
|
|
72
|
+
|
|
73
|
+
expect(result[:status]).to eq(:imported)
|
|
74
|
+
expect(result[:row_count]).to eq(6)
|
|
75
|
+
expect(langlinks_rows).to contain_exactly(
|
|
76
|
+
[1, "en", "Film A"],
|
|
77
|
+
[1, "de", "Film A (Film)"],
|
|
78
|
+
[2, "en", "It's a Film, Really (1984)"],
|
|
79
|
+
[3, "en", 'Back\slash Title'],
|
|
80
|
+
[3, "fr", "Film B"],
|
|
81
|
+
[4, "en", "Underscore title"]
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "reads .sql.gz files" do
|
|
86
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql.gz", gzip: true)
|
|
87
|
+
expect(import(path)[:row_count]).to eq(6)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "creates both indexes after the load" do
|
|
91
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
92
|
+
import(path)
|
|
93
|
+
db = SQLite3::Database.new(@db_path, readonly: true)
|
|
94
|
+
indexes = db.execute("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'langlinks'").flatten
|
|
95
|
+
db.close
|
|
96
|
+
expect(indexes).to contain_exactly("idx_langlinks_from", "idx_langlinks_lang_title")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "keeps all rows across batch boundaries" do
|
|
100
|
+
stub_const("Wp2txt::LanglinksImporter::BATCH_SIZE", 2)
|
|
101
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
102
|
+
expect(import(path)[:row_count]).to eq(6)
|
|
103
|
+
expect(langlinks_rows.size).to eq(6)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "imports multibyte (ja/zh/ko) titles, including escapes and underscores" do
|
|
107
|
+
sql = <<~SQL
|
|
108
|
+
INSERT INTO `langlinks` VALUES (1,'ja','宇宙戦艦ヤマト'),(2,'zh','粵語標題(電影)'),(3,'ko','한국어_제목'),(4,'ja','It\\'s 映画, Really(1984)');
|
|
109
|
+
SQL
|
|
110
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
|
|
111
|
+
result = import(path)
|
|
112
|
+
|
|
113
|
+
expect(result[:row_count]).to eq(4)
|
|
114
|
+
expect(langlinks_rows).to contain_exactly(
|
|
115
|
+
[1, "ja", "宇宙戦艦ヤマト"],
|
|
116
|
+
[2, "zh", "粵語標題(電影)"],
|
|
117
|
+
[3, "ko", "한국어 제목"],
|
|
118
|
+
[4, "ja", "It's 映画, Really(1984)"]
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "parses large multibyte INSERT lines fast enough (performance regression)" do
|
|
123
|
+
# A single ~200KB+ extended INSERT line full of multibyte titles: the
|
|
124
|
+
# old character-index parser was O(n²) here (minutes), regex scan is O(n)
|
|
125
|
+
titles = ["宇宙戦艦ヤマト(映画)", "粵語標題(電影, 1984)", "한국어 제목", "時間の旅, それから"]
|
|
126
|
+
tuples = Array.new(6_000) { |i| "(#{i + 1},'ja','#{titles[i % titles.size]}')" }
|
|
127
|
+
sql = +"INSERT INTO `langlinks` VALUES " << tuples.join(",") << ";\n"
|
|
128
|
+
expect(sql.bytesize).to be > 200_000
|
|
129
|
+
|
|
130
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
|
|
131
|
+
importer = described_class.new(@db_path, cache_dir: @dir)
|
|
132
|
+
count = 0
|
|
133
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
134
|
+
importer.send(:each_source_row, path) { count += 1 }
|
|
135
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
136
|
+
|
|
137
|
+
expect(count).to eq(6_000)
|
|
138
|
+
expect(elapsed).to be < 0.5
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "skips and counts rows whose title contains invalid UTF-8 bytes" do
|
|
142
|
+
# Real dumps contain historically corrupted bytes in ll_title (VARBINARY)
|
|
143
|
+
sql = +"INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Bad\xFFTitle'),(3,'fr','Film B');\n"
|
|
144
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql.b)
|
|
145
|
+
result = import(path)
|
|
146
|
+
|
|
147
|
+
expect(result[:status]).to eq(:imported)
|
|
148
|
+
expect(result[:row_count]).to eq(2)
|
|
149
|
+
expect(result[:skipped_invalid]).to eq(1)
|
|
150
|
+
expect(langlinks_rows).to contain_exactly([1, "en", "Film A"], [3, "fr", "Film B"])
|
|
151
|
+
expect(result[:provenance][:skipped_invalid]).to eq(1)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "skips and counts rows whose language code contains invalid UTF-8 bytes" do
|
|
155
|
+
sql = +"INSERT INTO `langlinks` VALUES (1,'e\xFFn','Film A'),(2,'en','Film B');\n"
|
|
156
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql.b)
|
|
157
|
+
result = import(path)
|
|
158
|
+
|
|
159
|
+
expect(result[:row_count]).to eq(1)
|
|
160
|
+
expect(result[:skipped_invalid]).to eq(1)
|
|
161
|
+
expect(langlinks_rows).to eq([[2, "en", "Film B"]])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it "handles invalid bytes in .sql.gz input as well" do
|
|
165
|
+
sql = +"INSERT INTO `langlinks` VALUES (1,'en','Bad\xFFTitle'),(2,'en','Film B');\n"
|
|
166
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql.gz", sql.b, gzip: true)
|
|
167
|
+
result = import(path)
|
|
168
|
+
|
|
169
|
+
expect(result[:row_count]).to eq(1)
|
|
170
|
+
expect(result[:skipped_invalid]).to eq(1)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "filters target languages with the langs option" do
|
|
174
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
175
|
+
result = import(path, langs: %w[en fr])
|
|
176
|
+
|
|
177
|
+
expect(result[:row_count]).to eq(5)
|
|
178
|
+
expect(langlinks_rows.map { |r| r[1] }.uniq).to contain_exactly("en", "fr")
|
|
179
|
+
expect(result[:provenance][:lang_filter]).to eq("en,fr")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe "version pinning" do
|
|
184
|
+
it "rejects a langlinks file whose dump name differs from the metadata DB" do
|
|
185
|
+
path = write_langlinks("testwiki-20260102-langlinks.sql")
|
|
186
|
+
expect { import(path) }.to raise_error(ArgumentError, /version mismatch/)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "rejects a mismatched file even with force (no override)" do
|
|
190
|
+
path = write_langlinks("testwiki-20260102-langlinks.sql")
|
|
191
|
+
expect { import(path, force: true) }.to raise_error(ArgumentError, /version mismatch/)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "rejects files whose name carries no dump name" do
|
|
195
|
+
path = write_langlinks("langlinks.sql")
|
|
196
|
+
expect { import(path) }.to raise_error(ArgumentError, /version mismatch/)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
describe "re-import" do
|
|
201
|
+
it "is a no-op when already imported (reports imported_at)" do
|
|
202
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
203
|
+
first = import(path)
|
|
204
|
+
second = import(path)
|
|
205
|
+
|
|
206
|
+
expect(second[:status]).to eq(:already_imported)
|
|
207
|
+
expect(second[:imported_at]).to eq(first[:provenance][:imported_at])
|
|
208
|
+
expect(second[:row_count]).to eq(6)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "drops and re-imports with force, refreshing the provenance" do
|
|
212
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
213
|
+
import(path)
|
|
214
|
+
|
|
215
|
+
smaller = <<~SQL
|
|
216
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A');
|
|
217
|
+
SQL
|
|
218
|
+
path2 = write_langlinks("testwiki-20260101-langlinks.sql", smaller)
|
|
219
|
+
result = import(path2, force: true)
|
|
220
|
+
|
|
221
|
+
expect(result[:status]).to eq(:imported)
|
|
222
|
+
expect(result[:row_count]).to eq(1)
|
|
223
|
+
expect(langlinks_rows).to eq([[1, "en", "Film A"]])
|
|
224
|
+
expect(result[:provenance][:row_count]).to eq(1)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it "clears stale provenance when a forced re-import fails midway" do
|
|
228
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
229
|
+
expect(import(path)[:status]).to eq(:imported)
|
|
230
|
+
|
|
231
|
+
# Simulate a failure during the load (corrupt bytes, disk full, ...),
|
|
232
|
+
# after the first batch has already been committed
|
|
233
|
+
stub_const("Wp2txt::LanglinksImporter::BATCH_SIZE", 1)
|
|
234
|
+
failing = described_class.new(@db_path, cache_dir: @dir)
|
|
235
|
+
allow(failing).to receive(:each_source_row) do |_source, &block|
|
|
236
|
+
block.call(1, "en", "Film A")
|
|
237
|
+
raise IOError, "simulated read failure"
|
|
238
|
+
end
|
|
239
|
+
expect { failing.import!(path, force: true) }.to raise_error(IOError)
|
|
240
|
+
|
|
241
|
+
# Partial table, but provenance is gone: judged as "not imported"
|
|
242
|
+
expect(langlinks_rows).to eq([[1, "en", "Film A"]])
|
|
243
|
+
meta = Wp2txt::MetadataIndex.new(@db_path)
|
|
244
|
+
expect(meta.langlinks_provenance).to be_nil
|
|
245
|
+
meta.close
|
|
246
|
+
|
|
247
|
+
# A non-force retry re-imports instead of reporting a stale success
|
|
248
|
+
retry_result = import(path)
|
|
249
|
+
expect(retry_result[:status]).to eq(:imported)
|
|
250
|
+
expect(retry_result[:row_count]).to eq(6)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
describe "provenance" do
|
|
255
|
+
it "stamps source, size, time, tool version, filter, and row count" do
|
|
256
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
257
|
+
prov = import(path)[:provenance]
|
|
258
|
+
|
|
259
|
+
expect(prov[:source]).to eq("testwiki-20260101-langlinks.sql")
|
|
260
|
+
expect(prov[:source_size]).to eq(File.size(path))
|
|
261
|
+
expect(prov[:imported_at]).to match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
|
262
|
+
expect(prov[:imported_with]).to eq(Wp2txt::VERSION)
|
|
263
|
+
expect(prov[:lang_filter]).to eq("all")
|
|
264
|
+
expect(prov[:row_count]).to eq(6)
|
|
265
|
+
|
|
266
|
+
meta = Wp2txt::MetadataIndex.new(@db_path)
|
|
267
|
+
expect(meta.langlinks_provenance[:row_count]).to eq(6)
|
|
268
|
+
meta.close
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
describe "sanity check" do
|
|
273
|
+
it "is skipped when the target language has no local meta DB" do
|
|
274
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql")
|
|
275
|
+
expect(import(path)[:sanity]).to eq([])
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it "reports the join rate against an installed target meta DB" do
|
|
279
|
+
create_meta_db(@dir, lang: "en", date: "20260101",
|
|
280
|
+
pages: [[101, "Film A", 0, nil, 10], [102, "Film B", 0, nil, 10]])
|
|
281
|
+
sql = <<~SQL
|
|
282
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B');
|
|
283
|
+
SQL
|
|
284
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
|
|
285
|
+
result = import(path)
|
|
286
|
+
|
|
287
|
+
expect(result[:sanity].size).to eq(1)
|
|
288
|
+
check = result[:sanity].first
|
|
289
|
+
expect(check[:lang]).to eq("en")
|
|
290
|
+
expect(check[:match_rate]).to eq(1.0)
|
|
291
|
+
expect(check[:warning]).to be false
|
|
292
|
+
expect(check[:against]).to eq("enwiki-20260101")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "warns when the join rate falls below 90%" do
|
|
296
|
+
create_meta_db(@dir, lang: "en", date: "20260101",
|
|
297
|
+
pages: [[101, "Something Else", 0, nil, 10]])
|
|
298
|
+
sql = <<~SQL
|
|
299
|
+
INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B');
|
|
300
|
+
SQL
|
|
301
|
+
path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
|
|
302
|
+
check = import(path)[:sanity].first
|
|
303
|
+
|
|
304
|
+
expect(check[:match_rate]).to eq(0.0)
|
|
305
|
+
expect(check[:warning]).to be true
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|