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,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,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "open3"
|
|
7
|
+
require_relative "support/multistream_fixture"
|
|
8
|
+
require_relative "../lib/wp2txt/metadata_index"
|
|
9
|
+
require_relative "../lib/wp2txt/multistream"
|
|
10
|
+
require_relative "../lib/wp2txt/cli"
|
|
11
|
+
|
|
12
|
+
RSpec.describe "Wp2txt Metadata Index" do
|
|
13
|
+
include MultistreamFixture
|
|
14
|
+
|
|
15
|
+
describe Wp2txt::MetadataIndex do
|
|
16
|
+
describe ".normalize_category" do
|
|
17
|
+
it "replaces underscores, trims, and capitalizes the first letter" do
|
|
18
|
+
expect(described_class.normalize_category(" japanese_films ")).to eq("Japanese films")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "leaves non-ASCII names unchanged" do
|
|
22
|
+
expect(described_class.normalize_category("日本の映画作品")).to eq("日本の映画作品")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe ".clean_heading" do
|
|
27
|
+
it "strips bold markup and resolves links" do
|
|
28
|
+
expect(described_class.clean_heading("'''Bold''' [[link|Label]]")).to eq("Bold Label")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "strips HTML tags" do
|
|
32
|
+
expect(described_class.clean_heading("<small>Notes</small>")).to eq("Notes")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe ".expand_section_names" do
|
|
37
|
+
it "expands a canonical name to its alias group" do
|
|
38
|
+
expect(described_class.expand_section_names("Plot")).to include("Plot", "Synopsis")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "expands an alias back to the full group (bidirectional)" do
|
|
42
|
+
expect(described_class.expand_section_names("Synopsis")).to include("Plot", "Synopsis")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "returns the name itself when no alias group matches" do
|
|
46
|
+
expect(described_class.expand_section_names("Nonexistent Section")).to eq(["Nonexistent Section"])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe ".path_for" do
|
|
51
|
+
it "builds a cache path keyed to the dump file" do
|
|
52
|
+
path = described_class.path_for("/dumps/jawiki-20260101-pages-articles-multistream.xml.bz2", cache_dir: "/cache")
|
|
53
|
+
expect(path).to start_with("/cache/jawiki-20260101-pages-articles-multistream")
|
|
54
|
+
expect(path).to end_with("_meta.sqlite3")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe "build and query" do
|
|
60
|
+
around do |example|
|
|
61
|
+
Dir.mktmpdir do |dir|
|
|
62
|
+
@dir = dir
|
|
63
|
+
@multistream_path, @index_path = create_fixture(dir)
|
|
64
|
+
@db_path = File.join(dir, "meta.sqlite3")
|
|
65
|
+
ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
|
|
66
|
+
builder = Wp2txt::MetadataIndexBuilder.new(
|
|
67
|
+
@multistream_path, ms_index.stream_offsets,
|
|
68
|
+
db_path: @db_path, num_processes: 0
|
|
69
|
+
)
|
|
70
|
+
@index = builder.build
|
|
71
|
+
example.run
|
|
72
|
+
@index.close
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "records all pages and counts articles excluding redirects and category pages" do
|
|
77
|
+
stats = @index.stats
|
|
78
|
+
expect(stats[:page_count]).to eq(8)
|
|
79
|
+
expect(stats[:article_count]).to eq(3)
|
|
80
|
+
expect(stats[:dump_name]).to eq("testwiki-20260101")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "finds articles by exact category" do
|
|
84
|
+
expect(@index.find_articles(category: "Japanese films")).to eq(["Film A"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "finds articles through subcategories with depth" do
|
|
88
|
+
expect(@index.find_articles(category: "Films", depth: 1)).to contain_exactly("Film A", "Film B")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "does not include subcategory members at depth 0" do
|
|
92
|
+
expect(@index.find_articles(category: "Films", depth: 0)).to be_empty
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "matches sections through aliases" do
|
|
96
|
+
titles = @index.find_articles(category: "Films", depth: 1, has_section: "Plot")
|
|
97
|
+
expect(titles).to contain_exactly("Film A", "Film B")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "matches sections exactly when aliases are disabled" do
|
|
101
|
+
titles = @index.find_articles(category: "Films", depth: 1, has_section: "Plot", use_aliases: false)
|
|
102
|
+
expect(titles).to eq(["Film A"])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "excludes redirects from results" do
|
|
106
|
+
expect(@index.find_articles(title_match: "Old Film")).to be_empty
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "filters by title substring" do
|
|
110
|
+
expect(@index.find_articles(title_match: "Film")).to contain_exactly("Film A", "Film B")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "applies limit and reports full count separately" do
|
|
114
|
+
titles = @index.find_articles(title_match: "Film", limit: 1)
|
|
115
|
+
expect(titles.size).to eq(1)
|
|
116
|
+
expect(@index.count_articles(title_match: "Film")).to eq(2)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "returns the category tree with depths" do
|
|
120
|
+
tree = @index.category_tree("Films", depth: 1)
|
|
121
|
+
expect(tree).to include({ name: "Films", depth: 0 },
|
|
122
|
+
{ name: "Japanese films", depth: 1 },
|
|
123
|
+
{ name: "French films", depth: 1 })
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "collects section statistics" do
|
|
127
|
+
stats = @index.section_stats
|
|
128
|
+
expect(stats).to include(["Plot", 1], ["Synopsis", 1], ["Career", 1])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "scopes section statistics to a category" do
|
|
132
|
+
stats = @index.section_stats(category: "Films", depth: 1)
|
|
133
|
+
headings = stats.map(&:first)
|
|
134
|
+
expect(headings).to include("Plot", "Synopsis")
|
|
135
|
+
expect(headings).not_to include("Career")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "records redirect targets" do
|
|
139
|
+
db = SQLite3::Database.new(@db_path)
|
|
140
|
+
target = db.get_first_value("SELECT redirect_to FROM pages WHERE title = 'Old Film'")
|
|
141
|
+
db.close
|
|
142
|
+
expect(target).to eq("Film A")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "validates against the source dump file" do
|
|
146
|
+
expect(@index.built?).to be true
|
|
147
|
+
expect(@index.valid_for?(@multistream_path)).to be true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "records the wp2txt version it was built with" do
|
|
151
|
+
expect(@index.stats[:built_with]).to eq(Wp2txt::VERSION)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "sees headings even when a trailing HTML comment follows the closing markers" do
|
|
155
|
+
expect(@index.find_articles(has_section: "Career", use_aliases: false)).to eq(["Person X"])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "ignores commented-out category links" do
|
|
159
|
+
expect(@index.categories_of("Person X")).to eq(["Japanese actors"])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "builds atomically: the existing index survives an unfinished rebuild" do
|
|
163
|
+
expect(@index.built?).to be true
|
|
164
|
+
rebuilding = Wp2txt::MetadataIndex.new(@db_path)
|
|
165
|
+
rebuilding.prepare_build!
|
|
166
|
+
rebuilding.insert_batch(pages: [[99, "Partial", 0, nil, 10]], categories: [], sections: [], hierarchy: [])
|
|
167
|
+
rebuilding.close # abandon before finalize
|
|
168
|
+
|
|
169
|
+
survivor = Wp2txt::MetadataIndex.new(@db_path)
|
|
170
|
+
expect(survivor.built?).to be true
|
|
171
|
+
expect(survivor.find_articles(title_match: "Film A")).to eq(["Film A"])
|
|
172
|
+
expect(survivor.find_articles(title_match: "Partial")).to be_empty
|
|
173
|
+
survivor.close
|
|
174
|
+
FileUtils.rm_f(Dir.glob("#{@db_path}.building*"))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "detects a changed source dump file" do
|
|
178
|
+
File.binwrite(@multistream_path, File.binread(@multistream_path) + "x")
|
|
179
|
+
expect(@index.valid_for?(@multistream_path)).to be false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "reports not built for a missing index file" do
|
|
183
|
+
missing = Wp2txt::MetadataIndex.new(File.join(@dir, "nope.sqlite3"))
|
|
184
|
+
expect(missing.built?).to be false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
describe "CLI option validation" do
|
|
189
|
+
it "rejects --in-category without --find-articles" do
|
|
190
|
+
expect do
|
|
191
|
+
Wp2txt::CLI.parse_options(["-L", "ja", "--in-category", "Films"])
|
|
192
|
+
end.to raise_error(SystemExit)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "rejects combining --build-index with --find-articles" do
|
|
196
|
+
expect do
|
|
197
|
+
Wp2txt::CLI.parse_options(["-L", "ja", "--build-index", "--find-articles"])
|
|
198
|
+
end.to raise_error(SystemExit)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "accepts --find-articles with filters" do
|
|
202
|
+
opts = Wp2txt::CLI.parse_options(["-L", "ja", "--find-articles", "--in-category", "Films", "--has-section", "Plot"])
|
|
203
|
+
expect(opts[:find_articles]).to be true
|
|
204
|
+
expect(opts[:in_category]).to eq("Films")
|
|
205
|
+
expect(opts[:has_section]).to eq("Plot")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
# Synthetic two-stream multistream dump used by metadata index / corpus specs.
|
|
6
|
+
# Stream 1 holds articles (ns 0), stream 2 holds category pages (ns 14).
|
|
7
|
+
module MultistreamFixture
|
|
8
|
+
def page_xml(id:, ns:, title:, text:)
|
|
9
|
+
escaped = text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
10
|
+
<<~XML
|
|
11
|
+
<page>
|
|
12
|
+
<title>#{title}</title>
|
|
13
|
+
<ns>#{ns}</ns>
|
|
14
|
+
<id>#{id}</id>
|
|
15
|
+
<revision>
|
|
16
|
+
<id>#{id * 100}</id>
|
|
17
|
+
<text bytes="#{text.bytesize}">#{escaped}</text>
|
|
18
|
+
</revision>
|
|
19
|
+
</page>
|
|
20
|
+
XML
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def bzip2(data)
|
|
24
|
+
out, status = Open3.capture2("bzip2", "-c", stdin_data: data)
|
|
25
|
+
raise "bzip2 failed" unless status.success?
|
|
26
|
+
|
|
27
|
+
out
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array(String, String)] [multistream_path, index_path]
|
|
31
|
+
def create_fixture(dir)
|
|
32
|
+
stream1_pages = [
|
|
33
|
+
page_xml(id: 1, ns: 0, title: "Film A",
|
|
34
|
+
text: "Intro.\n== Plot ==\nStory here.\n== Reception ==\nGood.\n[[Category:Japanese films]]\n"),
|
|
35
|
+
page_xml(id: 2, ns: 0, title: "Film B",
|
|
36
|
+
text: "Intro.\n== Synopsis ==\nStory here.\n[[Category:French films|B]]\n"),
|
|
37
|
+
page_xml(id: 3, ns: 0, title: "Person X",
|
|
38
|
+
text: "Bio.\n== Career == <!-- legacy anchor -->\nActing.\n== '''Style''' ==\nDistinct prose.\n<!-- [[Category:Hidden]] -->\n[[Category:Japanese actors]]\n"),
|
|
39
|
+
page_xml(id: 4, ns: 0, title: "Old Film",
|
|
40
|
+
text: "#REDIRECT [[Film A]]\n[[Category:Japanese films]]\n")
|
|
41
|
+
]
|
|
42
|
+
stream2_pages = [
|
|
43
|
+
page_xml(id: 5, ns: 14, title: "Category:Japanese films", text: "[[Category:Films]]\n"),
|
|
44
|
+
page_xml(id: 6, ns: 14, title: "Category:French films", text: "[[Category:Films]]\n"),
|
|
45
|
+
page_xml(id: 7, ns: 14, title: "Category:Films", text: "Top category.\n"),
|
|
46
|
+
page_xml(id: 8, ns: 14, title: "Category:Japanese actors", text: "[[Category:People]]\n")
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
stream1 = bzip2(stream1_pages.join)
|
|
50
|
+
stream2 = bzip2(stream2_pages.join)
|
|
51
|
+
|
|
52
|
+
multistream_path = File.join(dir, "testwiki-20260101-pages-articles-multistream.xml.bz2")
|
|
53
|
+
File.binwrite(multistream_path, stream1 + stream2)
|
|
54
|
+
|
|
55
|
+
offset2 = stream1.bytesize
|
|
56
|
+
index_lines = [
|
|
57
|
+
"0:1:Film A", "0:2:Film B", "0:3:Person X", "0:4:Old Film",
|
|
58
|
+
"#{offset2}:5:Category:Japanese films", "#{offset2}:6:Category:French films",
|
|
59
|
+
"#{offset2}:7:Category:Films", "#{offset2}:8:Category:Japanese actors"
|
|
60
|
+
]
|
|
61
|
+
index_path = File.join(dir, "testwiki-20260101-pages-articles-multistream-index.txt")
|
|
62
|
+
File.write(index_path, index_lines.join("\n") + "\n")
|
|
63
|
+
|
|
64
|
+
[multistream_path, index_path]
|
|
65
|
+
end
|
|
66
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wp2txt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yoichiro Hasebe
|
|
@@ -197,6 +197,7 @@ email:
|
|
|
197
197
|
- yohasebe@gmail.com
|
|
198
198
|
executables:
|
|
199
199
|
- wp2txt
|
|
200
|
+
- wp2txt-mcp
|
|
200
201
|
extensions: []
|
|
201
202
|
extra_rdoc_files: []
|
|
202
203
|
files:
|
|
@@ -215,6 +216,7 @@ files:
|
|
|
215
216
|
- README_ja.md
|
|
216
217
|
- Rakefile
|
|
217
218
|
- bin/wp2txt
|
|
219
|
+
- bin/wp2txt-mcp
|
|
218
220
|
- image/wp2txt-logo.svg
|
|
219
221
|
- image/wp2txt.svg
|
|
220
222
|
- lib/wp2txt.rb
|
|
@@ -225,6 +227,8 @@ files:
|
|
|
225
227
|
- lib/wp2txt/cli_ui.rb
|
|
226
228
|
- lib/wp2txt/config.rb
|
|
227
229
|
- lib/wp2txt/constants.rb
|
|
230
|
+
- lib/wp2txt/corpus.rb
|
|
231
|
+
- lib/wp2txt/corpus_jobs.rb
|
|
228
232
|
- lib/wp2txt/data/html_entities.json
|
|
229
233
|
- lib/wp2txt/data/language_metadata.json
|
|
230
234
|
- lib/wp2txt/data/language_tiers.json
|
|
@@ -234,10 +238,13 @@ files:
|
|
|
234
238
|
- lib/wp2txt/extractor.rb
|
|
235
239
|
- lib/wp2txt/file_utils.rb
|
|
236
240
|
- lib/wp2txt/formatter.rb
|
|
241
|
+
- lib/wp2txt/fts_index.rb
|
|
237
242
|
- lib/wp2txt/global_data_cache.rb
|
|
238
243
|
- lib/wp2txt/index_cache.rb
|
|
244
|
+
- lib/wp2txt/index_commands.rb
|
|
239
245
|
- lib/wp2txt/magic_words.rb
|
|
240
246
|
- lib/wp2txt/memory_monitor.rb
|
|
247
|
+
- lib/wp2txt/metadata_index.rb
|
|
241
248
|
- lib/wp2txt/multistream.rb
|
|
242
249
|
- lib/wp2txt/output_writer.rb
|
|
243
250
|
- lib/wp2txt/parser_functions.rb
|
|
@@ -265,15 +272,18 @@ files:
|
|
|
265
272
|
- spec/cli_spec.rb
|
|
266
273
|
- spec/config_spec.rb
|
|
267
274
|
- spec/constants_spec.rb
|
|
275
|
+
- spec/corpus_spec.rb
|
|
268
276
|
- spec/file_utils_spec.rb
|
|
269
277
|
- spec/fixtures/samples.rb
|
|
270
278
|
- spec/formatter_sections_spec.rb
|
|
279
|
+
- spec/fts_index_spec.rb
|
|
271
280
|
- spec/global_data_cache_spec.rb
|
|
272
281
|
- spec/index_cache_spec.rb
|
|
273
282
|
- spec/integration_spec.rb
|
|
274
283
|
- spec/magic_words_spec.rb
|
|
275
284
|
- spec/markers_spec.rb
|
|
276
285
|
- spec/memory_monitor_spec.rb
|
|
286
|
+
- spec/metadata_index_spec.rb
|
|
277
287
|
- spec/multistream_spec.rb
|
|
278
288
|
- spec/output_writer_spec.rb
|
|
279
289
|
- spec/parser_functions_spec.rb
|
|
@@ -282,6 +292,7 @@ files:
|
|
|
282
292
|
- spec/section_extractor_spec.rb
|
|
283
293
|
- spec/spec_helper.rb
|
|
284
294
|
- spec/stream_processor_spec.rb
|
|
295
|
+
- spec/support/multistream_fixture.rb
|
|
285
296
|
- spec/template_data_spec.rb
|
|
286
297
|
- spec/template_expander_spec.rb
|
|
287
298
|
- spec/template_processing_spec.rb
|
|
@@ -322,15 +333,18 @@ test_files:
|
|
|
322
333
|
- spec/cli_spec.rb
|
|
323
334
|
- spec/config_spec.rb
|
|
324
335
|
- spec/constants_spec.rb
|
|
336
|
+
- spec/corpus_spec.rb
|
|
325
337
|
- spec/file_utils_spec.rb
|
|
326
338
|
- spec/fixtures/samples.rb
|
|
327
339
|
- spec/formatter_sections_spec.rb
|
|
340
|
+
- spec/fts_index_spec.rb
|
|
328
341
|
- spec/global_data_cache_spec.rb
|
|
329
342
|
- spec/index_cache_spec.rb
|
|
330
343
|
- spec/integration_spec.rb
|
|
331
344
|
- spec/magic_words_spec.rb
|
|
332
345
|
- spec/markers_spec.rb
|
|
333
346
|
- spec/memory_monitor_spec.rb
|
|
347
|
+
- spec/metadata_index_spec.rb
|
|
334
348
|
- spec/multistream_spec.rb
|
|
335
349
|
- spec/output_writer_spec.rb
|
|
336
350
|
- spec/parser_functions_spec.rb
|
|
@@ -339,6 +353,7 @@ test_files:
|
|
|
339
353
|
- spec/section_extractor_spec.rb
|
|
340
354
|
- spec/spec_helper.rb
|
|
341
355
|
- spec/stream_processor_spec.rb
|
|
356
|
+
- spec/support/multistream_fixture.rb
|
|
342
357
|
- spec/template_data_spec.rb
|
|
343
358
|
- spec/template_expander_spec.rb
|
|
344
359
|
- spec/template_processing_spec.rb
|