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.
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
- File.open(output_path, "w") do |f|
398
- titles.each_slice(EXTRACT_BATCH_SIZE) do |batch|
399
- raise Cancelled if cancel_check&.call
400
-
401
- pages = reader.extract_articles_parallel(batch, num_processes: num_processes)
402
- batch.each do |t|
403
- page = pages[t]
404
- next unless page
405
-
406
- records = build_records(page, content, resolved_sections, chunk_size, chunk_overlap)
407
- next if records.empty?
408
-
409
- articles_extracted += 1
410
- records.each do |record|
411
- f.puts(JSON.generate(record))
412
- records_written += 1
413
- sample << record if sample.size < 3
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
- meta_path = "#{output_path}.meta.json"
422
- File.write(meta_path, JSON.pretty_generate(
423
- tool: "wp2txt #{Wp2txt::VERSION}",
424
- dump: dump_name,
425
- generated_at: Time.now.utc.iso8601,
426
- query: (titles_record || filters.compact).merge(
427
- content: content, resolved_sections: resolved_sections,
428
- chunk_size: chunk_size, chunk_overlap: chunk_size ? chunk_overlap : nil
429
- ).compact,
430
- alias_set_contents: alias_contents,
431
- total_matching: total,
432
- articles_extracted: articles_extracted,
433
- records_written: records_written,
434
- truncated: truncated,
435
- not_found: not_found
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
- break if rows.size >= limit
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: rows.size >= limit }
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
- # Write the full query result to output_path as JSONL. Atomicity: the
731
- # child (or inline fallback) writes "#{output_path}.partial"; the parent
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
- if File.exist?(output_path) && !overwrite
738
- raise ArgumentError, "output file already exists: #{output_path} (pass overwrite: true to replace it)"
739
- end
740
-
741
- partial = "#{output_path}.partial"
742
- FileUtils.rm_f(partial)
743
- outcome = begin
744
- if Process.respond_to?(:fork)
745
- run_sql_file_in_subprocess(sql, timeout, attachments, partial)
746
- else
747
- db = build_readonly_connection(attach_fts: fts.built?, attachments: attachments)
748
- begin
749
- run_sql_file_on(db, sql, partial)
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
- columns = unique_columns(result.columns)
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.length > SQL_FILE_CELL_LIMIT
792
- value = "#{value[0, SQL_FILE_CELL_LIMIT]}…"
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
- seen = Hash.new(0)
812
- columns.map do |c|
813
- seen[c] += 1
814
- seen[c] == 1 ? c : "#{c}_#{seen[c]}"
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, written by the parent after the atomic rename
860
- def write_sql_sidecar(output_path, sql, attachments, outcome)
861
- File.write("#{output_path}.meta.json", JSON.pretty_generate(
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
  ))
@@ -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
- running = @mutex.synchronize { @jobs.values.find { |s| s[:status] == "running" } }
26
- if running
27
- return { error: "another job is already running (#{running[:job_id]}); " \
28
- "poll job_status or cancel_job before starting a new one" }
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 = @factory.call
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.close
57
+ corpus&.close
56
58
  end
57
59
  end
58
60
  thread.report_on_exception = false
@@ -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.map(
398
+ Parallel.each(
390
399
  batches,
391
400
  in_processes: @num_processes,
392
- finish: lambda { |_item, _idx, rows|
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] || "0").to_i
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.map(
643
+ Parallel.each(
644
644
  batches,
645
645
  in_processes: @num_processes,
646
- finish: lambda { |_item, _idx, result|
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] || "0").to_i
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
@@ -1,27 +1,87 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tempfile"
4
+ require "tmpdir"
5
+
3
6
  module Wp2txt
4
- # Server-side output path confinement shared by the file-writing tools
5
- # (extract_corpus / start_extract_job / query_sql with output_path).
6
- # Paths must resolve under the server's output directory an agent mixing
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
- # @return [String] the confined, absolute output path
13
- # @raise [ArgumentError] when the path escapes base_dir or the file exists
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 == base || path.start_with?(base + File::SEPARATOR)
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
- if File.exist?(path) && !overwrite
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
- @file_pointer = File.open(xml_file, "r:UTF-8")
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
- return false unless chunk
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
- # Handle encoding for bz2 streams
166
- chunk = chunk.force_encoding("UTF-8")
167
- chunk = chunk.scrub("")
168
- @buffer << chunk
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
- # Skip special pages (containing colon in title like "Wikipedia:", "File:", etc.)
227
- return nil if title.include?(":")
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wp2txt
4
- VERSION = "2.3.2"
4
+ VERSION = "2.3.3"
5
5
  end
@@ -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)