wp2txt 2.3.2 → 2.3.3
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/.dockerignore +3 -0
- data/.github/workflows/publish-image.yml +140 -0
- data/CHANGELOG.md +14 -0
- data/DEVELOPMENT.md +12 -4
- data/DEVELOPMENT_ja.md +2 -3
- data/README.md +1 -1
- data/README_ja.md +1 -1
- data/Rakefile +22 -16
- data/bin/wp2txt +4 -2
- data/bin/wp2txt-mcp +4 -3
- data/docs/INDEXES.md +4 -0
- data/lib/wp2txt/constants.rb +5 -0
- data/lib/wp2txt/corpus.rb +96 -79
- data/lib/wp2txt/corpus_jobs.rb +15 -13
- data/lib/wp2txt/fts_index.rb +12 -3
- data/lib/wp2txt/metadata_index.rb +3 -3
- data/lib/wp2txt/output_path.rb +72 -12
- data/lib/wp2txt/stream_processor.rb +19 -9
- data/lib/wp2txt/version.rb +1 -1
- data/spec/fts_index_spec.rb +40 -0
- data/spec/p1_correctness_spec.rb +357 -0
- data/spec/stream_processor_spec.rb +3 -1
- data/spec/titles_output_path_spec.rb +49 -1
- data/wp2txt.gemspec +1 -1
- metadata +4 -9
- data/image/wp2txt-logo.svg +0 -16
- data/image/wp2txt.svg +0 -31
- data/scripts/benchmark_regex.rb +0 -161
- data/scripts/fetch_html_entities.rb +0 -94
- data/scripts/fetch_language_metadata.rb +0 -180
- data/scripts/fetch_mediawiki_data.rb +0 -334
- data/scripts/fetch_template_data.rb +0 -186
- data/scripts/profile_memory.rb +0 -139
data/lib/wp2txt/corpus.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "metadata_index"
|
|
|
14
14
|
require_relative "fts_index"
|
|
15
15
|
require_relative "section_extractor"
|
|
16
16
|
require_relative "version"
|
|
17
|
+
require_relative "output_path"
|
|
17
18
|
|
|
18
19
|
module Wp2txt
|
|
19
20
|
# Facade over a local dump: single-article access (Tier 0, multistream),
|
|
@@ -324,7 +325,7 @@ module Wp2txt
|
|
|
324
325
|
title_match: nil, limit: 0, titles: nil,
|
|
325
326
|
chunk_size: nil, chunk_overlap: 0,
|
|
326
327
|
max_articles: DEFAULT_MAX_SYNC_ARTICLES, num_processes: 4,
|
|
327
|
-
progress: nil, cancel_check: nil)
|
|
328
|
+
progress: nil, cancel_check: nil, overwrite: false)
|
|
328
329
|
if content == "sections" && Array(sections).empty? && alias_set.nil?
|
|
329
330
|
raise ArgumentError, "content: \"sections\" requires sections or alias_set"
|
|
330
331
|
end
|
|
@@ -394,46 +395,48 @@ module Wp2txt
|
|
|
394
395
|
titles_done = 0
|
|
395
396
|
sample = []
|
|
396
397
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
398
|
+
meta_path = "#{output_path}.meta.json"
|
|
399
|
+
OutputPath.write_pair(output_path, overwrite: overwrite) do |staged_output, staged_meta|
|
|
400
|
+
File.open(staged_output, "w") do |f|
|
|
401
|
+
titles.each_slice(EXTRACT_BATCH_SIZE) do |batch|
|
|
402
|
+
raise Cancelled if cancel_check&.call
|
|
403
|
+
|
|
404
|
+
pages = reader.extract_articles_parallel(batch, num_processes: num_processes)
|
|
405
|
+
batch.each do |t|
|
|
406
|
+
page = pages[t]
|
|
407
|
+
next unless page
|
|
408
|
+
|
|
409
|
+
records = build_records(page, content, resolved_sections, chunk_size, chunk_overlap)
|
|
410
|
+
next if records.empty?
|
|
411
|
+
|
|
412
|
+
articles_extracted += 1
|
|
413
|
+
records.each do |record|
|
|
414
|
+
f.puts(JSON.generate(record))
|
|
415
|
+
records_written += 1
|
|
416
|
+
sample << record if sample.size < 3
|
|
417
|
+
end
|
|
414
418
|
end
|
|
419
|
+
titles_done += batch.size
|
|
420
|
+
progress&.call(titles_done, titles.size)
|
|
415
421
|
end
|
|
416
|
-
titles_done += batch.size
|
|
417
|
-
progress&.call(titles_done, titles.size)
|
|
418
422
|
end
|
|
419
|
-
end
|
|
420
423
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
424
|
+
File.write(staged_meta, JSON.pretty_generate(
|
|
425
|
+
tool: "wp2txt #{Wp2txt::VERSION}",
|
|
426
|
+
dump: dump_name,
|
|
427
|
+
generated_at: Time.now.utc.iso8601,
|
|
428
|
+
query: (titles_record || filters.compact).merge(
|
|
429
|
+
content: content, resolved_sections: resolved_sections,
|
|
430
|
+
chunk_size: chunk_size, chunk_overlap: chunk_size ? chunk_overlap : nil
|
|
431
|
+
).compact,
|
|
432
|
+
alias_set_contents: alias_contents,
|
|
433
|
+
total_matching: total,
|
|
434
|
+
articles_extracted: articles_extracted,
|
|
435
|
+
records_written: records_written,
|
|
436
|
+
truncated: truncated,
|
|
437
|
+
not_found: not_found
|
|
438
|
+
))
|
|
439
|
+
end
|
|
437
440
|
|
|
438
441
|
{ output_path: output_path, meta_path: meta_path, dump: dump_name,
|
|
439
442
|
total_matching: total, articles_extracted: articles_extracted,
|
|
@@ -661,15 +664,19 @@ module Wp2txt
|
|
|
661
664
|
def run_sql_on(db, sql, limit)
|
|
662
665
|
columns = nil
|
|
663
666
|
rows = []
|
|
667
|
+
truncated = false
|
|
664
668
|
db.query(sql) do |result|
|
|
665
669
|
columns = result.columns
|
|
666
670
|
result.each do |row|
|
|
667
|
-
|
|
671
|
+
if rows.size >= limit
|
|
672
|
+
truncated = true
|
|
673
|
+
break
|
|
674
|
+
end
|
|
668
675
|
|
|
669
676
|
rows << row.map { |v| v.is_a?(String) && v.length > SQL_CELL_LIMIT ? "#{v[0, SQL_CELL_LIMIT]}…" : v }
|
|
670
677
|
end
|
|
671
678
|
end
|
|
672
|
-
{ columns: columns, rows: rows, row_count: rows.size, truncated:
|
|
679
|
+
{ columns: columns, rows: rows, row_count: rows.size, truncated: truncated }
|
|
673
680
|
end
|
|
674
681
|
|
|
675
682
|
# Execute the query in a forked child with a hard deadline: the child opens
|
|
@@ -727,38 +734,24 @@ module Wp2txt
|
|
|
727
734
|
end
|
|
728
735
|
end
|
|
729
736
|
|
|
730
|
-
#
|
|
731
|
-
#
|
|
732
|
-
# renames it into place only on success and removes it on every failure
|
|
733
|
-
# path (child crash, timeout kill, error over the pipe) — a partially
|
|
734
|
-
# written file is never presented as a result. The .meta.json sidecar is
|
|
735
|
-
# written by the parent after the rename succeeds.
|
|
737
|
+
# Stage JSONL and provenance in unique files, then publish on success.
|
|
738
|
+
# OutputPath owns exclusive destination reservations and failure cleanup.
|
|
736
739
|
def query_sql_to_file(sql, timeout, attachments, output_path, overwrite)
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
ensure
|
|
751
|
-
db.close
|
|
752
|
-
end
|
|
753
|
-
end
|
|
754
|
-
rescue StandardError
|
|
755
|
-
FileUtils.rm_f(partial)
|
|
756
|
-
raise
|
|
740
|
+
outcome = OutputPath.write_pair(output_path, overwrite: overwrite) do |partial, staged_meta|
|
|
741
|
+
result = if Process.respond_to?(:fork)
|
|
742
|
+
run_sql_file_in_subprocess(sql, timeout, attachments, partial)
|
|
743
|
+
else
|
|
744
|
+
db = build_readonly_connection(attach_fts: fts.built?, attachments: attachments)
|
|
745
|
+
begin
|
|
746
|
+
run_sql_file_on(db, sql, partial)
|
|
747
|
+
ensure
|
|
748
|
+
db.close
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
write_sql_sidecar(staged_meta, sql, attachments, result)
|
|
752
|
+
result
|
|
757
753
|
end
|
|
758
754
|
|
|
759
|
-
File.rename(partial, output_path)
|
|
760
|
-
write_sql_sidecar(output_path, sql, attachments, outcome)
|
|
761
|
-
|
|
762
755
|
result = { output_path: output_path, meta_path: "#{output_path}.meta.json",
|
|
763
756
|
columns: outcome[:columns], row_count: outcome[:row_count],
|
|
764
757
|
truncated: outcome[:truncated], cells_clipped: outcome[:cells_clipped],
|
|
@@ -772,6 +765,7 @@ module Wp2txt
|
|
|
772
765
|
# subprocess path: the 30s SIGKILL deadline covers the writing too.
|
|
773
766
|
def run_sql_file_on(db, sql, partial_path)
|
|
774
767
|
columns = nil
|
|
768
|
+
column_mapping = nil
|
|
775
769
|
row_count = 0
|
|
776
770
|
cells_clipped = 0
|
|
777
771
|
truncated = false
|
|
@@ -779,7 +773,11 @@ module Wp2txt
|
|
|
779
773
|
|
|
780
774
|
File.open(partial_path, "w") do |f|
|
|
781
775
|
db.query(sql) do |result|
|
|
782
|
-
|
|
776
|
+
original_columns = result.columns
|
|
777
|
+
columns = unique_columns(original_columns)
|
|
778
|
+
column_mapping = original_columns.each_with_index.map do |name, ordinal|
|
|
779
|
+
{ ordinal: ordinal, original_name: name, output_name: columns[ordinal] }
|
|
780
|
+
end
|
|
783
781
|
result.each do |row|
|
|
784
782
|
if row_count >= SQL_FILE_ROW_LIMIT
|
|
785
783
|
truncated = true
|
|
@@ -788,8 +786,8 @@ module Wp2txt
|
|
|
788
786
|
|
|
789
787
|
record = {}
|
|
790
788
|
row.each_with_index do |value, i|
|
|
791
|
-
if value.is_a?(String) && value.
|
|
792
|
-
value =
|
|
789
|
+
if value.is_a?(String) && value.bytesize > SQL_FILE_CELL_LIMIT
|
|
790
|
+
value = clip_file_cell(value)
|
|
793
791
|
cells_clipped += 1
|
|
794
792
|
end
|
|
795
793
|
record[columns[i]] = value
|
|
@@ -801,20 +799,37 @@ module Wp2txt
|
|
|
801
799
|
end
|
|
802
800
|
end
|
|
803
801
|
|
|
804
|
-
{ columns: columns, row_count: row_count, truncated: truncated,
|
|
802
|
+
{ columns: columns, column_mapping: column_mapping, row_count: row_count, truncated: truncated,
|
|
805
803
|
cells_clipped: cells_clipped, sample: sample }
|
|
806
804
|
end
|
|
807
805
|
|
|
808
806
|
# Duplicate result column names (SELECT 1 AS x, 2 AS x) are suffixed
|
|
809
807
|
# (_2, _3, ...) so every JSONL record key is unique
|
|
810
808
|
def unique_columns(columns)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
809
|
+
reserved = columns.to_h { |name| [name, true] }
|
|
810
|
+
used = {}
|
|
811
|
+
columns.map do |name|
|
|
812
|
+
candidate = name
|
|
813
|
+
suffix = 2
|
|
814
|
+
while used[candidate] || (candidate != name && reserved[candidate])
|
|
815
|
+
candidate = "#{name}_#{suffix}"
|
|
816
|
+
suffix += 1
|
|
817
|
+
end
|
|
818
|
+
used[candidate] = true
|
|
819
|
+
candidate
|
|
815
820
|
end
|
|
816
821
|
end
|
|
817
822
|
|
|
823
|
+
# SQL_FILE_CELL_LIMIT is a byte ceiling INCLUDING the UTF-8 ellipsis.
|
|
824
|
+
# Remove only the incomplete UTF-8 suffix after a byte-based cut.
|
|
825
|
+
def clip_file_cell(value)
|
|
826
|
+
utf8 = value.dup.force_encoding(Encoding::UTF_8)
|
|
827
|
+
raise JSON::GeneratorError, "SQL cell contains invalid UTF-8" unless utf8.valid_encoding?
|
|
828
|
+
|
|
829
|
+
prefix = utf8.byteslice(0, SQL_FILE_CELL_LIMIT - "…".bytesize).force_encoding(Encoding::UTF_8)
|
|
830
|
+
"#{prefix.scrub("")}…"
|
|
831
|
+
end
|
|
832
|
+
|
|
818
833
|
# Subprocess driver for file-output mode; same fork/pipe/SIGKILL
|
|
819
834
|
# structure as run_sql_in_subprocess, but the child writes the rows to
|
|
820
835
|
# partial_path and ships back only the summary
|
|
@@ -856,9 +871,9 @@ module Wp2txt
|
|
|
856
871
|
reader_io&.close
|
|
857
872
|
end
|
|
858
873
|
|
|
859
|
-
# Reproducibility sidecar,
|
|
860
|
-
def write_sql_sidecar(
|
|
861
|
-
File.write(
|
|
874
|
+
# Reproducibility sidecar, staged by the parent before publication.
|
|
875
|
+
def write_sql_sidecar(meta_path, sql, attachments, outcome)
|
|
876
|
+
File.write(meta_path, JSON.pretty_generate(
|
|
862
877
|
tool: "query_sql",
|
|
863
878
|
dump: dump_name,
|
|
864
879
|
built_with: @metadata.stats&.dig(:built_with),
|
|
@@ -867,6 +882,8 @@ module Wp2txt
|
|
|
867
882
|
row_count: outcome[:row_count],
|
|
868
883
|
truncated: outcome[:truncated],
|
|
869
884
|
cells_clipped: outcome[:cells_clipped],
|
|
885
|
+
column_mapping: outcome[:column_mapping],
|
|
886
|
+
cell_byte_limit: SQL_FILE_CELL_LIMIT,
|
|
870
887
|
generated_at: Time.now.utc.iso8601,
|
|
871
888
|
wp2txt_version: Wp2txt::VERSION
|
|
872
889
|
))
|
data/lib/wp2txt/corpus_jobs.rb
CHANGED
|
@@ -22,22 +22,24 @@ module Wp2txt
|
|
|
22
22
|
# unbounded concurrent jobs would multiply workers against the same dump.
|
|
23
23
|
# @return [Hash] { job_id:, status: "running" } or { error: ... }
|
|
24
24
|
def start_extract(params)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
job_id = @mutex.synchronize do
|
|
26
|
+
running = @jobs.values.find { |state| state[:status] == "running" }
|
|
27
|
+
if running
|
|
28
|
+
return { error: "another job is already running (#{running[:job_id]}); " \
|
|
29
|
+
"poll job_status or cancel_job before starting a new one" }
|
|
30
|
+
end
|
|
31
|
+
id = format("job-%04d", @seq += 1)
|
|
32
|
+
@jobs[id] = {
|
|
33
|
+
job_id: id, status: "running", started_at: Time.now.utc.iso8601,
|
|
34
|
+
params: params, titles_done: 0, titles_total: nil, cancel: false
|
|
35
|
+
}
|
|
36
|
+
id
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
job_id = @mutex.synchronize { format("job-%04d", @seq += 1) }
|
|
32
|
-
state = {
|
|
33
|
-
job_id: job_id, status: "running", started_at: Time.now.utc.iso8601,
|
|
34
|
-
params: params, titles_done: 0, titles_total: nil, cancel: false
|
|
35
|
-
}
|
|
36
|
-
@mutex.synchronize { @jobs[job_id] = state }
|
|
37
|
-
|
|
38
39
|
thread = Thread.new do
|
|
39
|
-
corpus =
|
|
40
|
+
corpus = nil
|
|
40
41
|
begin
|
|
42
|
+
corpus = @factory.call
|
|
41
43
|
result = corpus.extract_corpus(
|
|
42
44
|
**params,
|
|
43
45
|
max_articles: nil,
|
|
@@ -52,7 +54,7 @@ module Wp2txt
|
|
|
52
54
|
rescue StandardError => e
|
|
53
55
|
update(job_id) { |s| s[:status] = "error"; s[:error] = "#{e.class}: #{e.message}"; s[:finished_at] = Time.now.utc.iso8601 }
|
|
54
56
|
ensure
|
|
55
|
-
corpus
|
|
57
|
+
corpus&.close
|
|
56
58
|
end
|
|
57
59
|
end
|
|
58
60
|
thread.report_on_exception = false
|
data/lib/wp2txt/fts_index.rb
CHANGED
|
@@ -19,6 +19,12 @@ module Wp2txt
|
|
|
19
19
|
# Queries ATTACH the Tier 1 metadata DB so category/section/redirect
|
|
20
20
|
# filters compose with MATCH in plain SQL.
|
|
21
21
|
class FtsIndex
|
|
22
|
+
class ShortQueryError < ArgumentError
|
|
23
|
+
def code
|
|
24
|
+
"query_too_short"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
SCHEMA_VERSION = 2
|
|
23
29
|
CACHE_SUFFIX = "_fts.sqlite3"
|
|
24
30
|
|
|
@@ -206,6 +212,9 @@ module Wp2txt
|
|
|
206
212
|
# @return [Hash] { total:, total_is_capped:, hits: [{page_id:, title:, heading:, ord:}] }
|
|
207
213
|
def search(query, mode: "phrase", sections: nil, category: nil, depth: 0,
|
|
208
214
|
limit: 20, offset: 0, count: "capped", count_cap: 1000)
|
|
215
|
+
if mode == "phrase" && tokenizer == "trigram" && query.length < 3
|
|
216
|
+
raise ShortQueryError, "trigram phrase searches require at least 3 Unicode characters"
|
|
217
|
+
end
|
|
209
218
|
match_expr = mode == "query" ? query : phrase_query(query)
|
|
210
219
|
|
|
211
220
|
conds = ["fts_sections MATCH ?", "p.namespace = 0", "p.redirect_to IS NULL"]
|
|
@@ -386,10 +395,10 @@ module Wp2txt
|
|
|
386
395
|
batches = pairs.each_slice(STREAMS_PER_BATCH).to_a
|
|
387
396
|
done = 0
|
|
388
397
|
|
|
389
|
-
Parallel.
|
|
398
|
+
Parallel.each(
|
|
390
399
|
batches,
|
|
391
400
|
in_processes: @num_processes,
|
|
392
|
-
|
|
401
|
+
finish: lambda { |_item, _idx, rows|
|
|
393
402
|
index.insert_batch(rows)
|
|
394
403
|
done += 1
|
|
395
404
|
progress&.call(done, batches.size)
|
|
@@ -423,7 +432,7 @@ module Wp2txt
|
|
|
423
432
|
title = block[MetadataIndexBuilder::TITLE_REGEX, 1]
|
|
424
433
|
return unless title && !title.empty?
|
|
425
434
|
|
|
426
|
-
ns = (block[MetadataIndexBuilder::NS_REGEX, 1]
|
|
435
|
+
ns = Wp2txt.namespace_id(block[MetadataIndexBuilder::NS_REGEX, 1])
|
|
427
436
|
return unless ns.zero?
|
|
428
437
|
|
|
429
438
|
page_id = block[MetadataIndexBuilder::ID_REGEX, 1]&.to_i
|
|
@@ -640,10 +640,10 @@ module Wp2txt
|
|
|
640
640
|
batches = pairs.each_slice(STREAMS_PER_BATCH).to_a
|
|
641
641
|
done = 0
|
|
642
642
|
|
|
643
|
-
Parallel.
|
|
643
|
+
Parallel.each(
|
|
644
644
|
batches,
|
|
645
645
|
in_processes: @num_processes,
|
|
646
|
-
|
|
646
|
+
finish: lambda { |_item, _idx, result|
|
|
647
647
|
index.insert_batch(result)
|
|
648
648
|
done += 1
|
|
649
649
|
progress&.call(done, batches.size)
|
|
@@ -686,7 +686,7 @@ module Wp2txt
|
|
|
686
686
|
return unless page_id
|
|
687
687
|
|
|
688
688
|
title = unescape_xml(title)
|
|
689
|
-
ns = (block[NS_REGEX, 1]
|
|
689
|
+
ns = Wp2txt.namespace_id(block[NS_REGEX, 1])
|
|
690
690
|
text = block[TEXT_REGEX, 1] || ""
|
|
691
691
|
text = unescape_xml(text)
|
|
692
692
|
# Strip HTML comments before scanning, matching what the Article parser
|
data/lib/wp2txt/output_path.rb
CHANGED
|
@@ -1,27 +1,87 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
3
6
|
module Wp2txt
|
|
4
|
-
#
|
|
5
|
-
# (
|
|
6
|
-
#
|
|
7
|
-
# up paths must not be able to clobber arbitrary user files — and existing
|
|
8
|
-
# files are not replaced unless overwrite is set.
|
|
7
|
+
# Output directories are dedicated, trusted directories. Reject links below
|
|
8
|
+
# the trusted platform temp root (whose ancestors may be OS aliases on macOS).
|
|
9
|
+
# This does not defend against an attacker replacing parent directories.
|
|
9
10
|
module OutputPath
|
|
10
11
|
module_function
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
def reject_symlinks!(path)
|
|
14
|
+
current = File.expand_path(path)
|
|
15
|
+
temp_roots = [File.expand_path(Dir.tmpdir), File.realpath(Dir.tmpdir)]
|
|
16
|
+
loop do
|
|
17
|
+
raise ArgumentError, "symbolic links are not allowed in output paths: #{current}" if File.symlink?(current)
|
|
18
|
+
parent = File.dirname(current)
|
|
19
|
+
break if parent == current || temp_roots.include?(current)
|
|
20
|
+
|
|
21
|
+
current = parent
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_pair!(path, overwrite: false)
|
|
26
|
+
[path, "#{path}.meta.json"].each do |destination|
|
|
27
|
+
reject_symlinks!(destination)
|
|
28
|
+
if File.exist?(destination) && !overwrite
|
|
29
|
+
raise ArgumentError, "output file already exists: #{destination} (pass overwrite: true to replace it)"
|
|
30
|
+
end
|
|
31
|
+
if File.exist?(destination) && !File.file?(destination)
|
|
32
|
+
raise ArgumentError, "output destination must be a file: #{destination}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Return the confined absolute path; the writer repeats validation and
|
|
38
|
+
# reserves both destinations with EXCL to close the check/create race.
|
|
14
39
|
def confine(output_path, base_dir, overwrite: false)
|
|
15
40
|
path = File.expand_path(output_path, base_dir)
|
|
16
41
|
base = File.expand_path(base_dir)
|
|
17
|
-
unless path
|
|
42
|
+
unless path.start_with?(base + File::SEPARATOR)
|
|
18
43
|
raise ArgumentError, "output_path must stay within the server output directory (#{base})"
|
|
19
44
|
end
|
|
20
|
-
|
|
21
|
-
raise ArgumentError, "output file already exists: #{path} (pass overwrite: true to replace it)"
|
|
22
|
-
end
|
|
23
|
-
|
|
45
|
+
validate_pair!(path, overwrite: overwrite)
|
|
24
46
|
path
|
|
25
47
|
end
|
|
48
|
+
|
|
49
|
+
# Reserve destinations, stage both files, then publish with rename. EXCL
|
|
50
|
+
# reservations are empty until publication; the pair is not a transaction.
|
|
51
|
+
# Failure removes only reservations owned by this call, never prior output.
|
|
52
|
+
def write_pair(path, overwrite: false)
|
|
53
|
+
validate_pair!(path, overwrite: overwrite)
|
|
54
|
+
destinations = [path, "#{path}.meta.json"]
|
|
55
|
+
reservations = {}
|
|
56
|
+
temporary = []
|
|
57
|
+
begin
|
|
58
|
+
unless overwrite
|
|
59
|
+
destinations.each do |destination|
|
|
60
|
+
File.open(destination, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
61
|
+
reservations[destination] = file.stat
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
destinations.each do |destination|
|
|
66
|
+
temporary << Tempfile.create([".wp2txt-", ".partial"], File.dirname(destination))
|
|
67
|
+
temporary.last.close
|
|
68
|
+
end
|
|
69
|
+
result = yield(*temporary.map(&:path))
|
|
70
|
+
destinations.each_with_index do |destination, index|
|
|
71
|
+
reject_symlinks!(destination)
|
|
72
|
+
File.rename(temporary[index].path, destination)
|
|
73
|
+
reservations.delete(destination)
|
|
74
|
+
end
|
|
75
|
+
result
|
|
76
|
+
rescue Errno::EEXIST => e
|
|
77
|
+
raise ArgumentError, "output file already exists: #{e.message} (pass overwrite: true to replace it)"
|
|
78
|
+
ensure
|
|
79
|
+
temporary.each { |file| File.unlink(file.path) if File.exist?(file.path) }
|
|
80
|
+
reservations.each do |destination, stat|
|
|
81
|
+
current = File.lstat(destination) rescue nil
|
|
82
|
+
File.unlink(destination) if current && current.dev == stat.dev && current.ino == stat.ino
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
26
86
|
end
|
|
27
87
|
end
|
|
@@ -23,6 +23,7 @@ module Wp2txt
|
|
|
23
23
|
@input_path = input_path
|
|
24
24
|
@bz2_gem = bz2_gem
|
|
25
25
|
@buffer = +""
|
|
26
|
+
@pending_bytes = +"".b
|
|
26
27
|
@file_pointer = nil
|
|
27
28
|
@adaptive_buffer = adaptive_buffer
|
|
28
29
|
@buffer_size = adaptive_buffer ? calculate_optimal_buffer_size : DEFAULT_BUFFER_SIZE
|
|
@@ -99,7 +100,8 @@ module Wp2txt
|
|
|
99
100
|
# Process a single XML file
|
|
100
101
|
def process_xml_file(xml_file)
|
|
101
102
|
@buffer = +""
|
|
102
|
-
@
|
|
103
|
+
@pending_bytes = +"".b
|
|
104
|
+
@file_pointer = File.open(xml_file, "rb")
|
|
103
105
|
|
|
104
106
|
while (page = extract_next_page)
|
|
105
107
|
result = parse_page_xml(page)
|
|
@@ -120,6 +122,7 @@ module Wp2txt
|
|
|
120
122
|
end
|
|
121
123
|
|
|
122
124
|
@buffer = +""
|
|
125
|
+
@pending_bytes = +"".b
|
|
123
126
|
@file_pointer = open_bz2_stream
|
|
124
127
|
|
|
125
128
|
while (page = extract_next_page)
|
|
@@ -158,14 +161,21 @@ module Wp2txt
|
|
|
158
161
|
# Fill buffer from file pointer
|
|
159
162
|
def fill_buffer
|
|
160
163
|
chunk = @file_pointer.read(@buffer_size)
|
|
161
|
-
|
|
164
|
+
unless chunk
|
|
165
|
+
@buffer << @pending_bytes.to_s.dup.force_encoding(Encoding::UTF_8).scrub("")
|
|
166
|
+
@pending_bytes = +"".b
|
|
167
|
+
return false
|
|
168
|
+
end
|
|
162
169
|
|
|
163
170
|
@bytes_read += chunk.bytesize
|
|
164
|
-
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
bytes = @pending_bytes.to_s.b + chunk.b
|
|
172
|
+
# Retain a trailing UTF-8 sequence until the next read. Only complete
|
|
173
|
+
# chunks are scrubbed, so valid characters split by read are preserved.
|
|
174
|
+
tail = bytes[/[\xC2-\xF4][\x80-\xBF]{0,2}\z/n]
|
|
175
|
+
width = tail && (tail.getbyte(0) < 0xE0 ? 2 : tail.getbyte(0) < 0xF0 ? 3 : 4)
|
|
176
|
+
@pending_bytes = tail && tail.bytesize < width ? tail : +"".b
|
|
177
|
+
bytes = bytes.byteslice(0, bytes.bytesize - @pending_bytes.bytesize)
|
|
178
|
+
@buffer << bytes.force_encoding(Encoding::UTF_8).scrub("")
|
|
169
179
|
|
|
170
180
|
# Adaptive buffer adjustment: if memory is low, reduce buffer size
|
|
171
181
|
if @adaptive_buffer && MemoryMonitor.memory_low?
|
|
@@ -223,8 +233,8 @@ module Wp2txt
|
|
|
223
233
|
return nil unless title_node
|
|
224
234
|
|
|
225
235
|
title = title_node.content
|
|
226
|
-
|
|
227
|
-
return nil
|
|
236
|
+
namespace = title_node.parent.at_css("ns")&.text
|
|
237
|
+
return nil unless Wp2txt.namespace_id(namespace).zero?
|
|
228
238
|
|
|
229
239
|
text = text_node.content
|
|
230
240
|
|
data/lib/wp2txt/version.rb
CHANGED
data/spec/fts_index_spec.rb
CHANGED
|
@@ -171,6 +171,46 @@ RSpec.describe "Wp2txt Full-Text Search" do
|
|
|
171
171
|
end
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
+
["", "東", "東京", "é", "😀"].each do |query|
|
|
175
|
+
it "rejects the short phrase #{query.inspect} with a stable code" do
|
|
176
|
+
expect { @fts.search(query, count: "exact") }.to raise_error(Wp2txt::FtsIndex::ShortQueryError) { |error|
|
|
177
|
+
expect(error.code).to eq("query_too_short")
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "leaves raw FTS syntax to SQLite even for short expressions" do
|
|
183
|
+
expect(@fts.search('"東"', mode: "query", count: "exact")[:total]).to eq(0)
|
|
184
|
+
expect(@fts.search("\xFF".b, mode: "query", count: "exact")[:total]).to eq(0)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it "accepts three Unicode characters" do
|
|
188
|
+
expect(@fts.search("東京都", count: "exact")[:total]).to eq(0)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "returns a coded MCP tool error instead of a zero-result success" do
|
|
192
|
+
@fts.close
|
|
193
|
+
requests = [
|
|
194
|
+
{ jsonrpc: "2.0", id: 1, method: "initialize", params: {
|
|
195
|
+
protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "regression", version: "1" }
|
|
196
|
+
} },
|
|
197
|
+
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
|
198
|
+
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: {
|
|
199
|
+
name: "search_text", arguments: { query: "東京", count: "exact" }
|
|
200
|
+
} }
|
|
201
|
+
]
|
|
202
|
+
stdout, stderr, status = Open3.capture3(RbConfig.ruby,
|
|
203
|
+
File.expand_path("../bin/wp2txt-mcp", __dir__), "--input", @multistream_path,
|
|
204
|
+
"--cache-dir", File.dirname(@multistream_path),
|
|
205
|
+
stdin_data: requests.map { |request| JSON.generate(request) }.join("\n") + "\n")
|
|
206
|
+
expect(status.success?).to be(true), stderr
|
|
207
|
+
response = stdout.lines.map { |line| JSON.parse(line) }.find { |message| message["id"] == 2 }
|
|
208
|
+
expect(response.dig("result", "isError")).to be true
|
|
209
|
+
payload = JSON.parse(response.dig("result", "content", 0, "text"))
|
|
210
|
+
expect(payload["code"]).to eq("query_too_short")
|
|
211
|
+
expect(payload).not_to have_key("total")
|
|
212
|
+
end
|
|
213
|
+
|
|
174
214
|
it "matches substrings of three or more characters" do
|
|
175
215
|
result = @fts.search("tory", count: "exact")
|
|
176
216
|
expect(result[:total]).to eq(2)
|