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.
@@ -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,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require_relative "support/multistream_fixture"
6
+ require_relative "support/meta_db_fixture"
7
+ require_relative "../lib/wp2txt/corpus"
8
+ require_relative "../lib/wp2txt/langlinks_importer"
9
+
10
+ # query_sql's multi-dump ATTACH (design doc §2) and the langlinks join demo (§3)
11
+ RSpec.describe "multi-dump ATTACH" do
12
+ include MultistreamFixture
13
+ include MetaDbFixture
14
+
15
+ EN_PAGES = [
16
+ [101, "Film A", 0, nil, 100],
17
+ [102, "Film B", 0, nil, 100],
18
+ [103, "Person X (actor)", 0, nil, 100]
19
+ ].freeze
20
+
21
+ EN_SECTIONS = [
22
+ [101, "Plot", 2, 1],
23
+ [101, "Reception", 2, 2],
24
+ [101, "Production", 2, 3],
25
+ [102, "Synopsis", 2, 1]
26
+ ].freeze
27
+
28
+ around do |example|
29
+ Dir.mktmpdir do |dir|
30
+ @dir = dir
31
+ @multistream_path, @index_path = create_fixture(dir)
32
+
33
+ ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
34
+ @db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
35
+ Wp2txt::MetadataIndexBuilder.new(
36
+ @multistream_path, ms_index.stream_offsets,
37
+ db_path: @db_path, num_processes: 0
38
+ ).build
39
+
40
+ # Same-date "en" dump (attachable), different-date "fr" dump (mismatch)
41
+ @en_meta = create_meta_db(dir, lang: "en", date: "20260101",
42
+ pages: EN_PAGES, sections: EN_SECTIONS)
43
+ @fr_meta = create_meta_db(dir, lang: "fr", date: "20260202",
44
+ pages: [[201, "Film A (fr)", 0, nil, 50]])
45
+
46
+ @corpus = Wp2txt::Corpus.for_input(@multistream_path, cache_dir: dir)
47
+ example.run
48
+ @corpus.close
49
+ end
50
+ end
51
+
52
+ describe "attach argument validation" do
53
+ it "rejects malformed language codes (paths, traversal, injection)" do
54
+ ["../x", "en/x", "en;DROP", "EN", "e", "x" * 20, ""].each do |bad|
55
+ expect { @corpus.query_sql("SELECT 1", attach: [bad]) }
56
+ .to raise_error(ArgumentError, /invalid language code/), "expected #{bad.inspect} to be rejected"
57
+ end
58
+ end
59
+
60
+ it "rejects languages with no installed index" do
61
+ expect { @corpus.query_sql("SELECT 1", attach: ["ko"]) }
62
+ .to raise_error(ArgumentError, /no installed index/)
63
+ end
64
+
65
+ it "rejects attaching the main database's own language" do
66
+ expect { @corpus.query_sql("SELECT 1", attach: ["test"]) }
67
+ .to raise_error(ArgumentError, /cannot attach 'test'/)
68
+ end
69
+ end
70
+
71
+ describe "read-only cross-dump queries" do
72
+ it "attaches another language's meta DB as {lang}_meta" do
73
+ result = @corpus.query_sql("SELECT title FROM en_meta.pages ORDER BY page_id", attach: ["en"])
74
+ expect(result[:rows].flatten).to eq(["Film A", "Film B", "Person X (actor)"])
75
+ end
76
+
77
+ it "attaches the FTS DB as {lang}_fts when built" do
78
+ create_fts_db(@en_meta)
79
+ result = @corpus.query_sql("SELECT name FROM en_fts.sqlite_master", attach: ["en"])
80
+ expect(result[:rows].flatten).to include("fts_map")
81
+ expect(result[:attached].first[:fts]).to be true
82
+ end
83
+
84
+ it "reports attached metadata (lang, dump_name, built_with, fts) in the response" do
85
+ result = @corpus.query_sql("SELECT 1", attach: ["en"])
86
+ expect(result[:attached]).to eq([
87
+ { lang: "en", dump_name: "enwiki-20260101", built_with: Wp2txt::VERSION, fts: false }
88
+ ])
89
+ end
90
+
91
+ it "flags dump_mismatch when only a different-date dump is installed" do
92
+ result = @corpus.query_sql("SELECT 1", attach: ["fr"])
93
+ entry = result[:attached].first
94
+ expect(entry[:dump_name]).to eq("frwiki-20260202")
95
+ expect(entry[:dump_mismatch]).to be true
96
+ end
97
+
98
+ it "makes attached databases read-only at the driver level" do
99
+ attachments = @corpus.send(:resolve_attachments, ["en"])
100
+ db = @corpus.send(:build_readonly_connection, attach_fts: false, attachments: attachments)
101
+ expect { db.execute("INSERT INTO en_meta.pages VALUES (999, 'x', 0, NULL, 0)") }
102
+ .to raise_error(SQLite3::Exception, /readonly/i)
103
+ db.close
104
+ end
105
+
106
+ it "maps hyphenated language codes to underscored aliases (zh-yue → zh_yue_meta)" do
107
+ create_meta_db(@dir, lang: "zh-yue", date: "20260101",
108
+ pages: [[301, "Cantonese Page", 0, nil, 10]])
109
+ result = @corpus.query_sql("SELECT title FROM zh_yue_meta.pages", attach: ["zh-yue"])
110
+ expect(result[:rows].flatten).to eq(["Cantonese Page"])
111
+ expect(result[:attached].first[:lang]).to eq("zh-yue")
112
+ end
113
+ end
114
+
115
+ describe "user SQL ATTACH/DETACH stays forbidden (regression)" do
116
+ it "rejects ATTACH in user SQL even when the attach argument is used" do
117
+ expect { @corpus.query_sql("SELECT 1; ATTACH DATABASE 'x' AS y", attach: ["en"]) }
118
+ .to raise_error(ArgumentError, /forbidden/)
119
+ end
120
+
121
+ it "rejects DETACH in user SQL" do
122
+ expect { @corpus.query_sql("SELECT 1; DETACH DATABASE en_meta", attach: ["en"]) }
123
+ .to raise_error(ArgumentError, /forbidden/)
124
+ end
125
+
126
+ it "still allows 'attach' inside string literals" do
127
+ result = @corpus.query_sql("SELECT 'please attach the file' AS note")
128
+ expect(result[:rows].first.first).to eq("please attach the file")
129
+ end
130
+ end
131
+
132
+ describe "langlinks join demo (design doc §3)" do
133
+ before do
134
+ path = File.join(@dir, "testwiki-20260101-langlinks.sql")
135
+ File.write(path, <<~SQL)
136
+ INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B'),(3,'en','Person X (actor)');
137
+ SQL
138
+ Wp2txt::LanglinksImporter.new(@db_path, cache_dir: @dir).import!(path)
139
+ end
140
+
141
+ it "compares section structures of article pairs across languages in one query" do
142
+ result = @corpus.query_sql(<<~SQL, attach: ["en"])
143
+ SELECT p.title AS ja_title, ll.ll_title AS en_title,
144
+ (SELECT COUNT(*) FROM page_sections s WHERE s.page_id = p.page_id) AS ja_sections,
145
+ (SELECT COUNT(*) FROM en_meta.page_sections s2
146
+ JOIN en_meta.pages p2 ON p2.page_id = s2.page_id
147
+ WHERE p2.title = ll.ll_title) AS en_sections
148
+ FROM pages p
149
+ JOIN langlinks ll ON ll.ll_from = p.page_id AND ll.ll_lang = 'en'
150
+ WHERE p.namespace = 0 AND p.redirect_to IS NULL
151
+ LIMIT 20
152
+ SQL
153
+
154
+ expect(result[:columns]).to eq(%w[ja_title en_title ja_sections en_sections])
155
+ expect(result[:rows]).to contain_exactly(
156
+ ["Film A", "Film A", 2, 3],
157
+ ["Film B", "Film B", 1, 1],
158
+ ["Person X", "Person X (actor)", 2, 0]
159
+ )
160
+ end
161
+
162
+ it "exposes langlinks provenance via dump_info" do
163
+ info = @corpus.dump_info
164
+ expect(info[:langlinks][:source]).to eq("testwiki-20260101-langlinks.sql")
165
+ expect(info[:langlinks][:row_count]).to eq(3)
166
+ end
167
+
168
+ it "includes the langlinks table in describe_schema (introspection-based)" do
169
+ schemas = @corpus.describe_schema[:meta].join("\n")
170
+ expect(schemas).to include("CREATE TABLE langlinks")
171
+ expect(schemas).to include("idx_langlinks_from")
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require_relative "../../lib/wp2txt/metadata_index"
5
+ require_relative "../../lib/wp2txt/fts_index"
6
+ require_relative "../../lib/wp2txt/version"
7
+
8
+ # Synthetic Tier 1 / Tier 2 index DBs for cross-dump (ATTACH) and langlinks
9
+ # specs: builds the SQLite files directly, without a real multistream dump
10
+ module MetaDbFixture
11
+ # Create a synthetic built metadata DB for LANG in DIR, mimicking what
12
+ # MetadataIndex.path_for + MetadataIndexBuilder produce.
13
+ # @param pages [Array] rows of [page_id, title, namespace, redirect_to, text_length]
14
+ # @param sections [Array] rows of [page_id, heading, level, ord]
15
+ # @return [String] db path
16
+ def create_meta_db(dir, lang:, date:, pages: [], sections: [],
17
+ built_with: Wp2txt::VERSION, built_at: "2026-01-02T00:00:00Z")
18
+ path = File.join(dir, "#{lang}wiki-#{date}-pages-articles-multistream_deadbeef#{Wp2txt::MetadataIndex::CACHE_SUFFIX}")
19
+ db = SQLite3::Database.new(path)
20
+ db.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)")
21
+ db.execute("CREATE TABLE pages (page_id INTEGER PRIMARY KEY, title TEXT, namespace INTEGER, redirect_to TEXT, text_length INTEGER)")
22
+ db.execute("CREATE TABLE page_categories (page_id INTEGER, category TEXT)")
23
+ db.execute("CREATE TABLE page_sections (page_id INTEGER, heading TEXT, level INTEGER, ord INTEGER)")
24
+ db.execute("CREATE TABLE category_hierarchy (child TEXT, parent TEXT)")
25
+ pages.each { |r| db.execute("INSERT INTO pages VALUES (?, ?, ?, ?, ?)", r) }
26
+ sections.each { |r| db.execute("INSERT INTO page_sections VALUES (?, ?, ?, ?)", r) }
27
+ {
28
+ "schema_version" => Wp2txt::MetadataIndex::SCHEMA_VERSION.to_s,
29
+ "wp2txt_version" => built_with,
30
+ "dump_name" => "#{lang}wiki-#{date}",
31
+ "built_at" => built_at
32
+ }.each { |k, v| db.execute("INSERT INTO metadata VALUES (?, ?)", [k, v]) }
33
+ db.close
34
+ path
35
+ end
36
+
37
+ # Create the synthetic FTS DB companion of a meta DB created above
38
+ # (same basename, _fts suffix)
39
+ # @return [String] db path
40
+ def create_fts_db(meta_db_path, built_with: Wp2txt::VERSION, built_at: "2026-01-02T00:00:00Z")
41
+ path = meta_db_path.sub(/#{Wp2txt::MetadataIndex::CACHE_SUFFIX}\z/, Wp2txt::FtsIndex::CACHE_SUFFIX)
42
+ db = SQLite3::Database.new(path)
43
+ db.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)")
44
+ db.execute("CREATE TABLE fts_map (rowid INTEGER PRIMARY KEY, page_id INTEGER, heading TEXT, ord INTEGER)")
45
+ {
46
+ "schema_version" => Wp2txt::FtsIndex::SCHEMA_VERSION.to_s,
47
+ "wp2txt_version" => built_with,
48
+ "built_at" => built_at
49
+ }.each { |k, v| db.execute("INSERT INTO metadata VALUES (?, ?)", [k, v]) }
50
+ db.close
51
+ path
52
+ end
53
+ 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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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