wp2txt 2.3.2 → 2.3.4

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,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require "timeout"
6
+ require "open3"
7
+ require "rake"
8
+ require_relative "../lib/wp2txt/stream_processor"
9
+ require_relative "../lib/wp2txt/corpus"
10
+ require_relative "../lib/wp2txt/corpus_jobs"
11
+ require_relative "../lib/wp2txt/output_path"
12
+ require_relative "support/multistream_fixture"
13
+
14
+ RSpec.describe "P1 correctness contracts" do
15
+ include MultistreamFixture
16
+
17
+ around do |example|
18
+ Dir.mktmpdir("wp2txt-p1-") do |dir|
19
+ @dir = dir
20
+ example.run
21
+ end
22
+ end
23
+
24
+ def processor_for(bytes, size)
25
+ processor = Wp2txt::StreamProcessor.new("unused.xml", adaptive_buffer: false)
26
+ processor.instance_variable_set(:@file_pointer, StringIO.new(bytes.b))
27
+ processor.instance_variable_set(:@buffer_size, size)
28
+ processor
29
+ end
30
+
31
+ describe "stream decoding" do
32
+ %w[é あ 😀].each do |character|
33
+ (1...character.bytesize).each do |boundary|
34
+ it "preserves #{character.inspect} split after byte #{boundary}" do
35
+ bytes = "x" * (8 - boundary) + character + "終"
36
+ processor = processor_for(bytes, 8)
37
+ nil while processor.send(:fill_buffer)
38
+ expect(processor.instance_variable_get(:@buffer)).to eq(bytes)
39
+ end
40
+ end
41
+ end
42
+
43
+ it "stops on an invalid byte and reports where it is" do
44
+ processor = processor_for("éあ\xFF😀".b, 1)
45
+ expect { nil while processor.send(:fill_buffer) }
46
+ .to raise_error(Wp2txt::EncodingError, /byte 5\b/)
47
+ end
48
+
49
+ it "stops when the input ends partway through a character" do
50
+ processor = processor_for("éあ\xE3\x81".b, 1)
51
+ expect { nil while processor.send(:fill_buffer) }
52
+ .to raise_error(Wp2txt::EncodingError, /middle of a UTF-8 character/)
53
+ end
54
+
55
+ it "passes valid text through unchanged at every buffer size" do
56
+ text = "é\nあ😀終\n" * 7
57
+ [1, 2, 3, 5, 64].each do |size|
58
+ processor = processor_for(text.b, size)
59
+ nil while processor.send(:fill_buffer)
60
+ expect(processor.instance_variable_get(:@buffer)).to eq(text)
61
+ end
62
+ end
63
+
64
+ it "handles empty input" do
65
+ processor = processor_for("", 1)
66
+ expect(processor.send(:fill_buffer)).to be false
67
+ expect(processor.instance_variable_get(:@buffer)).to eq("")
68
+ end
69
+
70
+ it "preserves multibyte XML content through one-byte reads" do
71
+ path = File.join(@dir, "boundary.xml")
72
+ File.binwrite(path, page_xml(id: 1, ns: 0, title: "作品: é😀", text: "あé😀"))
73
+ processor = Wp2txt::StreamProcessor.new(path, adaptive_buffer: false)
74
+ processor.instance_variable_set(:@buffer_size, 1)
75
+ expect(processor.each_page.to_a).to eq([["作品: é😀", "あé😀"]])
76
+ end
77
+ end
78
+
79
+ describe "namespace parity" do
80
+ [["作品: 東京", 0, true], ["No punctuation", 4, false], ["", 6, false]].each do |title, ns, included|
81
+ it "uses ns=#{ns} for #{title.inspect}" do
82
+ xml = page_xml(id: 1, ns: ns, title: title, text: "日本語")
83
+ processor = Wp2txt::StreamProcessor.new("unused.xml", adaptive_buffer: false)
84
+ expect(!processor.send(:parse_page_xml, xml).nil?).to eq(included)
85
+ rows = { pages: [], categories: [], sections: [], hierarchy: [] }
86
+ Wp2txt::MetadataIndexBuilder.scan_page(xml, rows)
87
+ expect(rows[:pages].first[2]).to eq(ns) unless title.empty?
88
+ end
89
+ end
90
+
91
+ it "uses namespace elements in the turbo extraction and section-stat paths" do
92
+ input = File.join(@dir, "pages.xml")
93
+ output = File.join(@dir, "output.jsonl")
94
+ File.write(input, [
95
+ page_xml(id: 1, ns: 0, title: "作品: 東京", text: "本文\n== Allowed ==\nあ"),
96
+ page_xml(id: 2, ns: 4, title: "No punctuation", text: "本文\n== Hidden ==\nあ"),
97
+ page_xml(id: 3, ns: 0, title: "Empty", text: "")
98
+ ].join)
99
+ script = <<~'CODE'
100
+ path, input, output = ARGV
101
+ source = File.read(path).split("\n# Handle Ctrl+C gracefully").first
102
+ eval(source, TOPLEVEL_BINDING, path)
103
+ require File.expand_path("../lib/wp2txt/corpus", File.dirname(path))
104
+ app = WpApp.new
105
+ count = app.send(:process_xml_file_and_write, input, output,
106
+ Wp2txt::Corpus::RENDER_CONFIG.merge(format: :json), false, :json)
107
+ stats = app.send(:process_xml_file_for_stats, input)
108
+ puts JSON.generate(count: count, stats: stats)
109
+ CODE
110
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-e", script,
111
+ File.expand_path("../bin/wp2txt", __dir__), input, output)
112
+ expect(status.success?).to be(true), stderr
113
+ result = JSON.parse(stdout)
114
+ expect(result["count"]).to eq(1)
115
+ expect(result["stats"].to_json).to include("Allowed")
116
+ expect(result["stats"].to_json).not_to include("Hidden")
117
+ expect(File.read(output)).to include("作品: 東京")
118
+ end
119
+
120
+ it "retains the historical ns=0 default for missing ns in both parsers" do
121
+ xml = page_xml(id: 1, ns: 0, title: "作品: 東京", text: "本文").sub(/<ns>.*?<\/ns>/, "")
122
+ processor = Wp2txt::StreamProcessor.new("unused.xml", adaptive_buffer: false)
123
+ expect(processor.send(:parse_page_xml, xml)).to eq(["作品: 東京", "本文", { page_id: 1, revision_id: 100 }])
124
+ rows = { pages: [], categories: [], sections: [], hierarchy: [] }
125
+ Wp2txt::MetadataIndexBuilder.scan_page(xml, rows)
126
+ expect(rows[:pages].first[2]).to eq(0)
127
+ end
128
+ end
129
+
130
+ describe "SQL rows and cells" do
131
+ around do |example|
132
+ @corpus = Wp2txt::Corpus.allocate
133
+ @db = SQLite3::Database.new(":memory:")
134
+ example.run
135
+ ensure
136
+ @db.close
137
+ end
138
+
139
+ it "reserves original and generated column names without losing values" do
140
+ path = File.join(@dir, "q.jsonl")
141
+ result = @corpus.send(:run_sql_file_on, @db,
142
+ 'SELECT 1 AS x, 2 AS x, 3 AS x_2, 4 AS x, 5 AS "日本", 6 AS "日本", 7 AS ""', path)
143
+ record = JSON.parse(File.read(path))
144
+ expect(record).to eq("x" => 1, "x_3" => 2, "x_2" => 3, "x_4" => 4,
145
+ "日本" => 5, "日本_2" => 6, "" => 7)
146
+ expect(result[:column_mapping][1]).to eq(ordinal: 1, original_name: "x", output_name: "x_3")
147
+ end
148
+
149
+ [0, 1, 2].each do |rows|
150
+ it "reports truncation only when more than the limit exists (#{rows} rows)" do
151
+ result = @corpus.send(:run_sql_on, @db, "SELECT 1 UNION ALL SELECT 2 LIMIT #{rows}", 1)
152
+ expect(result[:truncated]).to eq(rows > 1)
153
+ expect(result[:row_count]).to eq([rows, 1].min)
154
+ end
155
+ end
156
+
157
+ it "does not silently sanitize invalid UTF-8 cells while clipping" do
158
+ @db.execute("CREATE TABLE cells (value BLOB)")
159
+ @db.execute("INSERT INTO cells VALUES (?)", [SQLite3::Blob.new("\xFF".b + "a" * 70_000)])
160
+ expect do
161
+ @corpus.send(:run_sql_file_on, @db, "SELECT value FROM cells", File.join(@dir, "invalid.jsonl"))
162
+ end.to raise_error(JSON::GeneratorError)
163
+ end
164
+
165
+ it "clips by bytes including the ellipsis without splitting UTF-8" do
166
+ @db.execute("CREATE TABLE cells (value TEXT)")
167
+ values = ["あ" * 30_000, "😀" * 20_000, "é" * 40_000, "", "a" * 65_536]
168
+ values.each { |value| @db.execute("INSERT INTO cells VALUES (?)", [value]) }
169
+ path = File.join(@dir, "cells.jsonl")
170
+ result = @corpus.send(:run_sql_file_on, @db, "SELECT value FROM cells", path)
171
+ output = File.readlines(path).map { |line| JSON.parse(line)["value"] }
172
+ expect(result[:cells_clipped]).to eq(3)
173
+ expect(output.all?(&:valid_encoding?)).to be true
174
+ expect(output.map(&:bytesize).max).to be <= 65_536
175
+ expect(output.first(3).all? { |value| value.end_with?("…") }).to be true
176
+ expect(output.last(2)).to eq(values.last(2))
177
+ end
178
+ end
179
+
180
+ describe "exclusive staged output" do
181
+ let(:out) { File.join(@dir, "成果.jsonl") }
182
+
183
+ def publish(path, overwrite: false)
184
+ Wp2txt::OutputPath.write_pair(path, overwrite: overwrite) do |data, meta|
185
+ File.write(data, "本文")
186
+ File.write(meta, "{}")
187
+ end
188
+ end
189
+
190
+ it "rejects symlink directories, output files, dangling links, and sidecars even with overwrite" do
191
+ target = File.join(@dir, "target")
192
+ Dir.mkdir(target)
193
+ link = File.join(@dir, "link")
194
+ File.symlink(target, link)
195
+ expect { publish(File.join(link, "out"), overwrite: true) }.to raise_error(ArgumentError, /symbolic/)
196
+ [out, "#{out}.meta.json"].each do |destination|
197
+ File.symlink(File.join(target, "missing"), destination)
198
+ expect { publish(out, overwrite: true) }.to raise_error(ArgumentError, /symbolic/)
199
+ expect { Wp2txt::OutputPath.confine(out, @dir, overwrite: true) }.to raise_error(ArgumentError, /symbolic/)
200
+ File.unlink(destination)
201
+ end
202
+ expect(Dir.children(target)).to be_empty
203
+ end
204
+
205
+ it "rejects an existing sidecar without creating the output" do
206
+ File.write("#{out}.meta.json", "old")
207
+ expect { publish(out) }.to raise_error(ArgumentError, /already exists/)
208
+ expect(File.exist?(out)).to be false
209
+ expect(File.read("#{out}.meta.json")).to eq("old")
210
+ end
211
+
212
+ it "uses EXCL even if a file arrives after validation" do
213
+ allow(Wp2txt::OutputPath).to receive(:validate_pair!).and_wrap_original do |method, *args, **kwargs|
214
+ method.call(*args, **kwargs)
215
+ File.write(out, "competing writer")
216
+ end
217
+ expect { publish(out) }.to raise_error(ArgumentError, /already exists/)
218
+ expect(File.read(out)).to eq("competing writer")
219
+ expect(File.exist?("#{out}.meta.json")).to be false
220
+ end
221
+
222
+ it "cleans its own reservation when sidecar reservation loses a race" do
223
+ allow(Wp2txt::OutputPath).to receive(:validate_pair!).and_wrap_original do |method, *args, **kwargs|
224
+ method.call(*args, **kwargs)
225
+ File.write("#{out}.meta.json", "competing writer")
226
+ end
227
+ expect { publish(out) }.to raise_error(ArgumentError, /already exists/)
228
+ expect(File.exist?(out)).to be false
229
+ expect(File.read("#{out}.meta.json")).to eq("competing writer")
230
+ end
231
+
232
+ it "publishes both files and leaves no temporary files" do
233
+ publish(out)
234
+ expect(File.read(out)).to eq("本文")
235
+ expect(File.read("#{out}.meta.json")).to eq("{}")
236
+ expect(Dir.children(@dir).sort).to eq([File.basename(out), File.basename(out) + ".meta.json"].sort)
237
+ end
238
+
239
+ it "preserves old files if staging fails during an overwrite" do
240
+ File.write(out, "old data")
241
+ File.write("#{out}.meta.json", "old meta")
242
+ expect do
243
+ Wp2txt::OutputPath.write_pair(out, overwrite: true) do |data, _meta|
244
+ File.write(data, "incomplete")
245
+ raise "failed"
246
+ end
247
+ end.to raise_error("failed")
248
+ expect(File.read(out)).to eq("old data")
249
+ expect(File.read("#{out}.meta.json")).to eq("old meta")
250
+ expect(Dir.glob(File.join(@dir, ".wp2txt-*"))).to be_empty
251
+ publish(out, overwrite: true)
252
+ expect(File.read(out)).to eq("本文")
253
+ end
254
+
255
+ it "allows only one concurrent writer without overwrite" do
256
+ entered = Queue.new
257
+ release = Queue.new
258
+ writer = Thread.new do
259
+ Wp2txt::OutputPath.write_pair(out) do |data, meta|
260
+ entered << true
261
+ release.pop
262
+ File.write(data, "first")
263
+ File.write(meta, "{}")
264
+ end
265
+ end
266
+ Timeout.timeout(3) { entered.pop }
267
+ expect { publish(out) }.to raise_error(ArgumentError, /already exists/)
268
+ release << true
269
+ writer.value
270
+ expect(File.read(out)).to eq("first")
271
+ ensure
272
+ release << true
273
+ writer&.join
274
+ end
275
+ end
276
+
277
+ describe "Parallel finish contract" do
278
+ [0, 2].each do |workers|
279
+ it "delivers each payload to finish but retains none with #{workers} workers" do
280
+ skip "fork unavailable" if workers.positive? && !Process.respond_to?(:fork)
281
+ payloads = ["日本語", "\xFF".b, ""]
282
+ received = []
283
+ returned = Parallel.each(payloads, in_processes: workers,
284
+ finish: ->(_item, _i, value) { received << value }) { |value| "rendered:#{value.bytesize}" }
285
+ expect(received).to match_array(payloads.map { |value| "rendered:#{value.bytesize}" })
286
+ expect(Array(returned).flatten.grep(/\Arendered:/)).to be_empty
287
+ end
288
+ end
289
+
290
+ it "builds both indexes while disabling retained batch results" do
291
+ dump, index_path = create_fixture(@dir)
292
+ index = Wp2txt::MultistreamIndex.new(index_path, use_cache: false, show_progress: false)
293
+ calls = []
294
+ allow(Parallel).to receive(:each).and_wrap_original do |method, source, options, &block|
295
+ calls << options
296
+ method.call(source, options, &block)
297
+ end
298
+ meta_path = Wp2txt::MetadataIndex.path_for(dump, cache_dir: @dir)
299
+ meta = Wp2txt::MetadataIndexBuilder.new(dump, index.stream_offsets, db_path: meta_path, num_processes: 0).build
300
+ expect(meta.stats[:article_count]).to eq(3)
301
+ meta.close
302
+ fts = Wp2txt::FtsIndexBuilder.new(dump, index.stream_offsets,
303
+ db_path: File.join(@dir, "fts.sqlite3"), meta_db_path: meta_path, num_processes: 0).build
304
+ expect(fts.search("Story", count: "exact")[:total]).to eq(2)
305
+ expect(calls.size).to eq(2)
306
+ expect(calls.all? { |options| options[:finish] && !options.key?(:preserve_results) }).to be true
307
+ fts.close
308
+ end
309
+ end
310
+
311
+ describe "job lifecycle" do
312
+ it "records factory failure and permits a subsequent job" do
313
+ manager = Wp2txt::CorpusJobManager.new(-> { raise "factory failure" })
314
+ first = manager.start_extract({})
315
+ Timeout.timeout(3) { Thread.pass until manager.status(first[:job_id])[:status] == "error" }
316
+ expect(manager.status(first[:job_id])[:error]).to include("factory failure")
317
+ second = manager.start_extract({})
318
+ expect(second[:job_id]).not_to eq(first[:job_id])
319
+ Timeout.timeout(3) { Thread.pass until manager.status(second[:job_id])[:status] == "error" }
320
+ end
321
+
322
+ it "atomically admits one job from simultaneous callers" do
323
+ release = Queue.new
324
+ fake = Object.new
325
+ fake.define_singleton_method(:extract_corpus) { |**_params| release.pop; {} }
326
+ fake.define_singleton_method(:close) {}
327
+ manager = Wp2txt::CorpusJobManager.new(-> { fake })
328
+ # Yield during state construction, precisely where the old implementation
329
+ # had released its check lock but had not registered the running job.
330
+ allow(Time).to receive(:now).and_wrap_original do |method|
331
+ sleep 0.005
332
+ method.call
333
+ end
334
+ gate = Queue.new
335
+ callers = Array.new(12) { Thread.new { gate.pop; manager.start_extract({}) } }
336
+ 12.times { gate << true }
337
+ results = callers.map(&:value)
338
+ expect(results.count { |result| result[:job_id] }).to eq(1)
339
+ expect(results.count { |result| result[:error] }).to eq(11)
340
+ release << true
341
+ Timeout.timeout(3) { Thread.pass until manager.list.first[:status] == "completed" }
342
+ ensure
343
+ release << true
344
+ callers&.each(&:join)
345
+ end
346
+ end
347
+
348
+ describe "image verification" do
349
+ around do |example|
350
+ previous = Rake.application
351
+ Rake.application = Rake::Application.new
352
+ Rake::Task.define_task(:build)
353
+ suppress_stderr { load File.expand_path("../Rakefile", __dir__) }
354
+ example.run
355
+ ensure
356
+ Rake.application = previous
357
+ end
358
+
359
+ [[false, "Cannot connect to daemon"], [false, "image missing"], [true, "LEAK:/wp2txt/tmp\n"]].each do |success, output|
360
+ it "fails verification for #{output.strip}" do
361
+ allow(Open3).to receive(:capture2e).and_return([output, double(success?: success)])
362
+ expect { suppress_stderr { Rake::Task[:verify_image].invoke("test-image") } }.to raise_error(SystemExit)
363
+ end
364
+ end
365
+
366
+ it "passes only after a successful clean container inspection" do
367
+ expect(Open3).to receive(:capture2e).with("docker", "run", "--rm", "test-image", "sh", "-c", kind_of(String))
368
+ .and_return(["", double(success?: true)])
369
+ expect { Rake::Task[:verify_image].invoke("test-image") }.to output(/OK:/).to_stdout
370
+ end
371
+ end
372
+ end
@@ -112,12 +112,14 @@ RSpec.describe Wp2txt::StreamProcessor do
112
112
  </page>
113
113
  <page>
114
114
  <title>Wikipedia:Help</title>
115
+ <ns>4</ns>
115
116
  <revision>
116
117
  <text>Help content.</text>
117
118
  </revision>
118
119
  </page>
119
120
  <page>
120
121
  <title>File:Image.jpg</title>
122
+ <ns>6</ns>
121
123
  <revision>
122
124
  <text>File description.</text>
123
125
  </revision>
@@ -131,7 +133,7 @@ RSpec.describe Wp2txt::StreamProcessor do
131
133
  File.write(xml_file, xml_content)
132
134
  end
133
135
 
134
- it "skips pages with colon in title (special pages)" do
136
+ it "skips non-article namespaces" do
135
137
  processor = described_class.new(xml_file)
136
138
  pages = processor.each_page.to_a
137
139
 
@@ -36,6 +36,18 @@ RSpec.describe "titles extraction and SQL file output" do
36
36
  File.readlines(path).map { |l| JSON.parse(l) }
37
37
  end
38
38
 
39
+ describe "extract_corpus across several batches with worker processes" do
40
+ it "writes each record once (workers must not re-flush the output buffer)" do
41
+ stub_const("Wp2txt::Corpus::EXTRACT_BATCH_SIZE", 1)
42
+ out = File.join(@dir, "batched.jsonl")
43
+ titles = ["Film A", "Film B", "Person X"]
44
+ result = @corpus.extract_corpus(output_path: out, content: "full", titles: titles, num_processes: 2)
45
+ records = read_jsonl(out)
46
+ expect(records.map { |r| r["title"] }.tally).to eq(titles.to_h { |t| [t, 1] })
47
+ expect(records.size).to eq(result[:records_written])
48
+ end
49
+ end
50
+
39
51
  describe "extract_corpus titles:" do
40
52
  it "extracts an explicit set with normalization, dedup, and input order" do
41
53
  out = File.join(@dir, "t.jsonl")
@@ -237,6 +249,46 @@ RSpec.describe "titles extraction and SQL file output" do
237
249
  expect(File.exist?("#{out}.partial")).to be false
238
250
  end
239
251
 
252
+ it "records ordinal to original name mappings in the sidecar for colliding names" do
253
+ out = File.join(@dir, "names.jsonl")
254
+ @corpus.query_sql('SELECT 1 AS x, 2 AS x, 3 AS x_2', output_path: out)
255
+ expect(read_jsonl(out)).to eq([{ "x" => 1, "x_3" => 2, "x_2" => 3 }])
256
+ meta = JSON.parse(File.read("#{out}.meta.json"))
257
+ expect(meta["column_mapping"]).to eq([
258
+ { "ordinal" => 0, "original_name" => "x", "output_name" => "x" },
259
+ { "ordinal" => 1, "original_name" => "x", "output_name" => "x_3" },
260
+ { "ordinal" => 2, "original_name" => "x_2", "output_name" => "x_2" }
261
+ ])
262
+ expect(meta["cell_byte_limit"]).to eq(65_536)
263
+ end
264
+
265
+ it "refuses sidecar collisions through both public output APIs" do
266
+ out = File.join(@dir, "sidecar.jsonl")
267
+ File.write("#{out}.meta.json", "old")
268
+ expect { @corpus.query_sql("SELECT 1", output_path: out) }.to raise_error(ArgumentError, /already exists/)
269
+ expect do
270
+ @corpus.extract_corpus(output_path: out, content: "summary", titles: ["Film A"], num_processes: 0)
271
+ end.to raise_error(ArgumentError, /already exists/)
272
+ expect(File.exist?(out)).to be false
273
+ expect(File.read("#{out}.meta.json")).to eq("old")
274
+ end
275
+
276
+ it "supports explicit overwrite for extraction and cleans up cancelled staging" do
277
+ out = File.join(@dir, "extract.jsonl")
278
+ File.write(out, "old")
279
+ File.write("#{out}.meta.json", "old metadata")
280
+ expect do
281
+ @corpus.extract_corpus(output_path: out, content: "summary", titles: ["Film A"],
282
+ num_processes: 0, overwrite: true, cancel_check: -> { true })
283
+ end.to raise_error(Wp2txt::Corpus::Cancelled)
284
+ expect(File.read(out)).to eq("old")
285
+ expect(File.read("#{out}.meta.json")).to eq("old metadata")
286
+ expect(Dir.glob(File.join(@dir, ".wp2txt-*"))).to be_empty
287
+ @corpus.extract_corpus(output_path: out, content: "summary", titles: ["Film A"],
288
+ num_processes: 0, overwrite: true)
289
+ expect(read_jsonl(out).first["title"]).to eq("Film A")
290
+ end
291
+
240
292
  it "ignores limit in file-output mode" do
241
293
  out = File.join(@dir, "q.jsonl")
242
294
  result = @corpus.query_sql("SELECT title FROM pages", output_path: out, limit: 1)
@@ -255,6 +307,14 @@ RSpec.describe "titles extraction and SQL file output" do
255
307
  expect(read_jsonl(out)).to eq([{ "one" => 1 }])
256
308
  end
257
309
 
310
+ it "does not touch another writer's fixed .partial path" do
311
+ out = File.join(@dir, "q.jsonl")
312
+ File.write("#{out}.partial", "another writer")
313
+ @corpus.query_sql("SELECT 1", output_path: out)
314
+ expect(File.read("#{out}.partial")).to eq("another writer")
315
+ expect(Dir.glob(File.join(@dir, ".wp2txt-*"))).to be_empty
316
+ end
317
+
258
318
  it "removes .partial and leaves no output on SQL error" do
259
319
  out = File.join(@dir, "q.jsonl")
260
320
  expect { @corpus.query_sql("SELECT * FROM no_such_table", output_path: out) }
@@ -291,7 +351,7 @@ RSpec.describe "titles extraction and SQL file output" do
291
351
 
292
352
  expect(result[:cells_clipped]).to eq(1)
293
353
  value = read_jsonl(out).first["big"]
294
- expect(value.length).to eq(Wp2txt::Corpus::SQL_FILE_CELL_LIMIT + 1)
354
+ expect(value.bytesize).to eq(Wp2txt::Corpus::SQL_FILE_CELL_LIMIT)
295
355
  end
296
356
 
297
357
  it "writes a .meta.json sidecar with SQL, attach provenance, and counts" do
data/wp2txt.gemspec CHANGED
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
13
13
  s.license = "MIT"
14
14
  s.required_ruby_version = Gem::Requirement.new(">= 3.0")
15
15
  s.files = `git ls-files`.split("\n")
16
- s.files -= ["data/*", "image/*"]
16
+ .reject { |f| f.start_with?("data/", "image/", "scripts/") }
17
17
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
18
  s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
19
19
  s.require_paths = ["lib"]
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.3.2
4
+ version: 2.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoichiro Hasebe
@@ -204,6 +204,7 @@ files:
204
204
  - ".dockerignore"
205
205
  - ".github/FUNDING.yml"
206
206
  - ".github/workflows/ci.yml"
207
+ - ".github/workflows/publish-image.yml"
207
208
  - ".gitignore"
208
209
  - ".solargraph.yml"
209
210
  - CHANGELOG.md
@@ -218,8 +219,6 @@ files:
218
219
  - bin/wp2txt
219
220
  - bin/wp2txt-mcp
220
221
  - docs/INDEXES.md
221
- - image/wp2txt-logo.svg
222
- - image/wp2txt.svg
223
222
  - lib/wp2txt.rb
224
223
  - lib/wp2txt/article.rb
225
224
  - lib/wp2txt/bz2_validator.rb
@@ -259,12 +258,6 @@ files:
259
258
  - lib/wp2txt/text_processing.rb
260
259
  - lib/wp2txt/utils.rb
261
260
  - lib/wp2txt/version.rb
262
- - scripts/benchmark_regex.rb
263
- - scripts/fetch_html_entities.rb
264
- - scripts/fetch_language_metadata.rb
265
- - scripts/fetch_mediawiki_data.rb
266
- - scripts/fetch_template_data.rb
267
- - scripts/profile_memory.rb
268
261
  - spec/article_spec.rb
269
262
  - spec/auto_download_spec.rb
270
263
  - spec/bz2_validator_spec.rb
@@ -291,7 +284,9 @@ files:
291
284
  - spec/metadata_index_spec.rb
292
285
  - spec/multi_dump_attach_spec.rb
293
286
  - spec/multistream_spec.rb
287
+ - spec/output_integrity_spec.rb
294
288
  - spec/output_writer_spec.rb
289
+ - spec/p1_correctness_spec.rb
295
290
  - spec/parser_functions_spec.rb
296
291
  - spec/ractor_worker_spec.rb
297
292
  - spec/regex_spec.rb
@@ -357,7 +352,9 @@ test_files:
357
352
  - spec/metadata_index_spec.rb
358
353
  - spec/multi_dump_attach_spec.rb
359
354
  - spec/multistream_spec.rb
355
+ - spec/output_integrity_spec.rb
360
356
  - spec/output_writer_spec.rb
357
+ - spec/p1_correctness_spec.rb
361
358
  - spec/parser_functions_spec.rb
362
359
  - spec/ractor_worker_spec.rb
363
360
  - spec/regex_spec.rb
@@ -1,16 +0,0 @@
1
- <?xml version="1.0" encoding="utf-8"?>
2
- <svg viewBox="46.275 153.235 274.248 70.635" xmlns="http://www.w3.org/2000/svg">
3
- <g style="" transform="matrix(0.140807, 0, 0, 0.140807, 46.270237, 160.188004)">
4
- <path style="fill:#194F82;" d="M359.854,0H32.743C14.707,0,0.032,14.675,0.032,32.711v241.972 c0,18.036,14.675,32.711,32.711,32.711h91.798l-5.495,21.786H91.83c-6.012,0-10.925,4.848-10.925,10.925v41.503 c0,6.012,4.848,10.925,10.925,10.925h208.873c6.012,0,10.925-4.848,10.925-10.925v-41.503c0-6.012-4.848-10.925-10.925-10.925 h-27.216l-5.495-21.786h91.798c18.036,0,32.711-14.675,32.711-32.711V32.711C392.566,14.675,377.891,0,359.854,0z M289.778,351.03 v19.717H102.82V351.03h24.824c5.042,0,9.374-3.426,10.602-8.275l8.792-35.362h98.586l8.792,35.362 c1.228,4.848,5.56,8.275,10.602,8.275H289.778z M359.854,285.608H32.743c-6.012,0-10.925-4.848-10.925-10.925V32.711 c0-6.012,4.848-10.925,10.925-10.925h327.111c6.012,0,10.925,4.848,10.925,10.925v242.036h0.065 C370.78,280.695,365.931,285.608,359.854,285.608z"/>
5
- <path style="fill:#194F82;" d="M338.004,43.572H54.594c-6.012,0-10.925,4.848-10.925,10.925v198.335 c0,6.012,4.848,10.925,10.925,10.925h283.539c6.012,0,10.925-4.848,10.925-10.925V54.562 C348.994,48.549,344.016,43.572,338.004,43.572z M327.208,241.907h-0.065H65.454v-88.566h13.188 c6.012,0,10.925-4.848,10.925-10.925c0-6.012-4.848-10.925-10.925-10.925H65.454V109.64h34.974c6.012,0,10.925-4.848,10.925-10.925 c0-6.012-4.848-10.925-10.925-10.925H65.454V65.293h261.754V241.907L327.208,241.907z"/>
6
- <path style="fill:#194F82;" d="M109.155,161.099l34.521,34.521c6.723,6.206,13.317,2.133,15.451,0 c4.267-4.267,4.267-11.119,0-15.451l-26.828-26.764l26.828-26.828c4.267-4.267,4.267-11.119,0-15.451 c-4.267-4.267-11.119-4.267-15.451,0l-34.521,34.521C104.889,149.915,104.889,156.768,109.155,161.099z"/>
7
- <path style="fill:#194F82;" d="M233.535,195.62c4.978,5.042,12.283,3.556,15.451,0l34.521-34.521 c4.267-4.267,4.267-11.119,0-15.451l-34.521-34.521c-4.267-4.267-11.119-4.267-15.451,0c-4.267,4.267-4.267,11.119,0,15.451 l26.828,26.828l-26.828,26.828C229.269,184.436,229.269,191.289,233.535,195.62z"/>
8
- <path style="fill:#194F82;" d="M174.254,219.152c6.853,2.069,12.283-2.263,13.834-6.788l37.883-110.222 c1.939-5.689-1.099-11.895-6.788-13.834c-5.689-1.939-11.895,1.099-13.834,6.788l-37.883,110.222 C165.527,210.941,168.501,217.341,174.254,219.152z"/>
9
- </g>
10
- <path d="M 187.019 174.178 L 187.537 174.178 Q 188.054 174.178 188.266 174.484 Q 188.477 174.79 188.477 175.001 Q 188.477 175.213 188.43 175.354 L 180.012 205.593 Q 179.542 207.145 178.061 207.145 Q 176.579 207.145 176.156 205.593 L 170.371 184.901 L 164.587 205.687 Q 164.07 207.145 162.682 207.145 Q 161.295 207.145 160.778 205.687 L 151.89 175.401 Q 151.842 175.213 151.842 174.978 Q 151.842 174.743 152.054 174.461 Q 152.266 174.178 152.783 174.178 L 153.253 174.178 Q 153.818 174.178 154.147 174.837 L 162.706 203.9 L 168.773 182.22 Q 169.149 180.903 170.442 180.903 Q 171.735 180.903 172.017 182.267 Q 173.005 185.841 175.051 193.06 Q 177.096 200.279 178.084 203.853 L 186.126 174.884 Q 186.22 174.649 186.479 174.414 Q 186.737 174.178 187.019 174.178 Z" transform="matrix(1.352871, 0, 0, 1.231972, -93.713486, -46.78933)" style="fill: rgb(25, 79, 130); line-height: 73.9473px; white-space: pre;"/>
11
- <path d="M 193.83 174.178 L 201.12 174.178 Q 204.412 174.178 207.186 175.966 Q 208.644 176.906 209.538 178.693 Q 210.431 180.48 210.431 182.926 Q 210.431 186.641 208.268 189.157 Q 206.105 191.673 200.743 191.673 L 197.64 191.673 Q 197.216 191.673 196.958 191.391 Q 196.699 191.109 196.699 190.732 L 196.699 190.309 Q 196.699 189.369 197.64 189.369 L 200.179 189.369 Q 205.305 189.369 206.81 187.205 Q 208.08 185.418 208.08 182.879 Q 208.08 179.54 205.94 178.035 Q 203.8 176.53 200.884 176.53 L 194.112 176.53 L 194.112 206.157 Q 194.112 206.534 193.83 206.816 Q 193.548 207.098 193.172 207.098 L 192.702 207.098 Q 192.325 207.098 192.043 206.816 Q 191.761 206.534 191.761 206.157 L 191.761 176.248 Q 191.761 175.025 192.184 174.602 Q 192.608 174.178 193.83 174.178 Z" transform="matrix(1.352871, 0, 0, 1.231972, -93.713486, -46.78933)" style="fill: rgb(25, 79, 130); line-height: 73.9473px; white-space: pre;"/>
12
- <path d="M 224.167 174.178 Q 228.258 174.178 230.468 176.318 Q 232.679 178.458 232.679 181.985 Q 232.679 183.96 231.456 186.523 Q 230.233 189.086 229.057 190.732 Q 226.33 194.636 221.721 199.95 L 220.31 201.643 Q 219.135 203.054 218.5 203.9 Q 217.865 204.747 217.724 204.888 Q 217.818 204.841 218.006 204.841 L 232.443 204.841 Q 233.384 204.841 233.384 205.734 L 233.384 206.157 Q 233.384 207.098 232.443 207.098 L 217.959 207.098 Q 217.206 207.098 216.76 207.004 Q 216.313 206.91 215.866 206.369 Q 215.419 205.828 215.419 204.864 Q 215.419 203.9 217.442 201.361 L 219.417 199.103 Q 219.84 198.586 220.357 197.975 Q 221.58 196.564 222.568 195.294 L 223.837 193.789 Q 224.119 193.413 224.99 192.308 Q 225.86 191.203 226.189 190.779 Q 226.518 190.356 227.247 189.369 Q 227.976 188.381 228.281 187.84 Q 228.587 187.299 229.081 186.429 Q 229.575 185.559 229.81 184.948 Q 230.374 183.49 230.374 182.22 Q 230.374 179.258 228.54 177.847 Q 226.706 176.436 224.167 176.436 Q 219.652 176.436 217.912 180.668 Q 217.63 181.233 217.065 181.233 L 216.642 181.233 Q 215.701 181.233 215.701 180.386 Q 215.701 180.151 215.749 180.057 Q 216.689 177.282 218.97 175.73 Q 221.251 174.178 224.167 174.178 Z" transform="matrix(1.352871, 0, 0, 1.231972, -93.713486, -46.78933)" style="fill: rgb(25, 79, 130); line-height: 73.9473px; white-space: pre;"/>
13
- <path d="M 247.486 177.753 L 247.956 177.753 Q 248.897 177.753 248.897 178.646 L 248.897 206.157 Q 248.897 206.534 248.638 206.816 Q 248.38 207.098 247.956 207.098 L 247.486 207.098 Q 247.063 207.098 246.804 206.816 Q 246.545 206.534 246.545 206.157 L 246.545 178.646 Q 246.545 177.753 247.486 177.753 Z M 237.328 174.178 L 258.208 174.178 Q 258.632 174.178 258.89 174.461 Q 259.149 174.743 259.149 175.119 L 259.149 175.636 Q 259.149 176.483 258.208 176.483 L 237.328 176.483 Q 236.387 176.483 236.387 175.636 L 236.387 175.119 Q 236.387 174.743 236.67 174.461 Q 236.952 174.178 237.328 174.178 Z" transform="matrix(1.352871, 0, 0, 1.231972, -93.713486, -46.78933)" style="fill: rgb(25, 79, 130); line-height: 73.9473px; white-space: pre;"/>
14
- <path d="M 279.324 174.178 L 279.841 174.178 Q 280.405 174.178 280.664 174.649 Q 280.923 175.119 280.64 175.589 L 280.64 175.542 L 272.693 190.027 Q 272.646 190.074 272.528 190.262 Q 272.411 190.45 272.317 190.544 L 272.599 190.92 L 281.111 205.734 Q 281.252 205.969 281.252 206.204 Q 281.252 206.44 281.064 206.769 Q 280.876 207.098 280.311 207.098 L 279.747 207.098 Q 279.23 207.098 279.042 206.816 L 270.906 192.707 Q 270.435 193.507 267.167 198.751 Q 263.899 203.994 262.253 206.675 Q 261.923 207.098 261.453 207.098 L 260.889 207.098 Q 260.277 207.098 260.042 206.628 Q 259.807 206.157 260.089 205.64 L 269.307 190.92 L 269.307 190.967 Q 269.495 190.591 269.636 190.45 Q 269.448 190.262 269.401 190.121 L 259.666 175.636 Q 259.525 175.401 259.525 175.166 Q 259.525 174.931 259.713 174.555 Q 259.901 174.178 260.465 174.178 L 260.983 174.178 Q 261.453 174.178 261.782 174.602 L 271 188.287 L 278.571 174.461 Q 278.759 174.178 279.324 174.178 Z" transform="matrix(1.352871, 0, 0, 1.231972, -93.713486, -46.78933)" style="fill: rgb(25, 79, 130); line-height: 73.9473px; white-space: pre;"/>
15
- <path d="M 293.994 177.753 L 294.464 177.753 Q 295.405 177.753 295.405 178.646 L 295.405 206.157 Q 295.405 206.534 295.146 206.816 Q 294.888 207.098 294.464 207.098 L 293.994 207.098 Q 293.571 207.098 293.312 206.816 Q 293.054 206.534 293.054 206.157 L 293.054 178.646 Q 293.054 177.753 293.994 177.753 Z M 283.836 174.178 L 304.716 174.178 Q 305.14 174.178 305.398 174.461 Q 305.657 174.743 305.657 175.119 L 305.657 175.636 Q 305.657 176.483 304.716 176.483 L 283.836 176.483 Q 282.896 176.483 282.896 175.636 L 282.896 175.119 Q 282.896 174.743 283.178 174.461 Q 283.46 174.178 283.836 174.178 Z" transform="matrix(1.352871, 0, 0, 1.231972, -93.713486, -46.78933)" style="fill: rgb(25, 79, 130); line-height: 73.9473px; white-space: pre;"/>
16
- </svg>
data/image/wp2txt.svg DELETED
@@ -1,31 +0,0 @@
1
- <?xml version="1.0" encoding="utf-8"?>
2
- <svg version="1.1" id="Layer_1" x="0px" y="0px" style="enable-background:new 0 0 392.533 392.533;" xmlns="http://www.w3.org/2000/svg" xmlns:bx="https://boxy-svg.com">
3
- <defs>
4
- <bx:guide x="212.88" y="167.188" angle="90"/>
5
- <bx:guide x="327.22" y="208.912" angle="90"/>
6
- <style bx:fonts="Text Me One">@import url(https://fonts.googleapis.com/css2?family=Text+Me+One%3Aital%2Cwght%400%2C400&amp;display=swap);</style>
7
- </defs>
8
- <g style="" transform="matrix(0.140807, 0, 0, 0.140807, 46.270237, 160.188004)">
9
- <path style="fill:#194F82;" d="M359.854,0H32.743C14.707,0,0.032,14.675,0.032,32.711v241.972 c0,18.036,14.675,32.711,32.711,32.711h91.798l-5.495,21.786H91.83c-6.012,0-10.925,4.848-10.925,10.925v41.503 c0,6.012,4.848,10.925,10.925,10.925h208.873c6.012,0,10.925-4.848,10.925-10.925v-41.503c0-6.012-4.848-10.925-10.925-10.925 h-27.216l-5.495-21.786h91.798c18.036,0,32.711-14.675,32.711-32.711V32.711C392.566,14.675,377.891,0,359.854,0z M289.778,351.03 v19.717H102.82V351.03h24.824c5.042,0,9.374-3.426,10.602-8.275l8.792-35.362h98.586l8.792,35.362 c1.228,4.848,5.56,8.275,10.602,8.275H289.778z M359.854,285.608H32.743c-6.012,0-10.925-4.848-10.925-10.925V32.711 c0-6.012,4.848-10.925,10.925-10.925h327.111c6.012,0,10.925,4.848,10.925,10.925v242.036h0.065 C370.78,280.695,365.931,285.608,359.854,285.608z"/>
10
- <path style="fill:#194F82;" d="M338.004,43.572H54.594c-6.012,0-10.925,4.848-10.925,10.925v198.335 c0,6.012,4.848,10.925,10.925,10.925h283.539c6.012,0,10.925-4.848,10.925-10.925V54.562 C348.994,48.549,344.016,43.572,338.004,43.572z M327.208,241.907h-0.065H65.454v-88.566h13.188 c6.012,0,10.925-4.848,10.925-10.925c0-6.012-4.848-10.925-10.925-10.925H65.454V109.64h34.974c6.012,0,10.925-4.848,10.925-10.925 c0-6.012-4.848-10.925-10.925-10.925H65.454V65.293h261.754V241.907L327.208,241.907z"/>
11
- <path style="fill:#194F82;" d="M109.155,161.099l34.521,34.521c6.723,6.206,13.317,2.133,15.451,0 c4.267-4.267,4.267-11.119,0-15.451l-26.828-26.764l26.828-26.828c4.267-4.267,4.267-11.119,0-15.451 c-4.267-4.267-11.119-4.267-15.451,0l-34.521,34.521C104.889,149.915,104.889,156.768,109.155,161.099z"/>
12
- <path style="fill:#194F82;" d="M233.535,195.62c4.978,5.042,12.283,3.556,15.451,0l34.521-34.521 c4.267-4.267,4.267-11.119,0-15.451l-34.521-34.521c-4.267-4.267-11.119-4.267-15.451,0c-4.267,4.267-4.267,11.119,0,15.451 l26.828,26.828l-26.828,26.828C229.269,184.436,229.269,191.289,233.535,195.62z"/>
13
- <path style="fill:#194F82;" d="M174.254,219.152c6.853,2.069,12.283-2.263,13.834-6.788l37.883-110.222 c1.939-5.689-1.099-11.895-6.788-13.834c-5.689-1.939-11.895,1.099-13.834,6.788l-37.883,110.222 C165.527,210.941,168.501,217.341,174.254,219.152z"/>
14
- </g>
15
- <g/>
16
- <g/>
17
- <g/>
18
- <g/>
19
- <g/>
20
- <g/>
21
- <g/>
22
- <g/>
23
- <g/>
24
- <g/>
25
- <g/>
26
- <g/>
27
- <g/>
28
- <g/>
29
- <g/>
30
- <text style="fill: rgb(25, 79, 130); font-family: &quot;Text Me One&quot;; font-size: 47.0279px; line-height: 73.9473px; white-space: pre;" transform="matrix(1.352871, 0, 0, 1.231972, -93.713486, -46.78933)" x="152.689" y="207.098">WP2TXT</text>
31
- </svg>