wp2txt 2.2.0 → 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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/CHANGELOG.md +10 -0
- data/Dockerfile +3 -0
- data/README.md +36 -56
- data/Rakefile +4 -1
- data/bin/wp2txt +1 -0
- data/bin/wp2txt-mcp +18 -19
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +43 -0
- data/lib/wp2txt/corpus.rb +358 -23
- data/lib/wp2txt/index_commands.rb +83 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +68 -2
- data/lib/wp2txt/multistream.rb +29 -0
- data/lib/wp2txt/output_path.rb +27 -0
- data/lib/wp2txt/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/titles_output_path_spec.rb +338 -0
- metadata +12 -1
data/lib/wp2txt/corpus.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
3
5
|
require "json"
|
|
4
6
|
require "time"
|
|
7
|
+
require "uri"
|
|
5
8
|
require_relative "../wp2txt"
|
|
6
9
|
require_relative "article"
|
|
7
10
|
require_relative "utils"
|
|
@@ -110,7 +113,8 @@ module Wp2txt
|
|
|
110
113
|
metadata_current: @metadata.built? && @metadata.valid_for?(@multistream_path),
|
|
111
114
|
fulltext_current: fts.built? && fts.valid_for?(@multistream_path),
|
|
112
115
|
stats: stats,
|
|
113
|
-
fulltext: fts.built? ? fts.stats : nil
|
|
116
|
+
fulltext: fts.built? ? fts.stats : nil,
|
|
117
|
+
langlinks: @metadata.built? ? @metadata.langlinks_provenance : nil
|
|
114
118
|
}
|
|
115
119
|
end
|
|
116
120
|
|
|
@@ -300,8 +304,16 @@ module Wp2txt
|
|
|
300
304
|
# Titles fetched/rendered per batch while streaming to disk
|
|
301
305
|
EXTRACT_BATCH_SIZE = 200
|
|
302
306
|
|
|
307
|
+
# Max titles accepted by extract_corpus titles: (larger sets belong in
|
|
308
|
+
# filter-based extraction or a background job)
|
|
309
|
+
TITLES_MAX = 10_000
|
|
310
|
+
|
|
303
311
|
# @param output_path [String] JSONL destination (sidecar .meta.json is added)
|
|
304
312
|
# @param content [String] "sections" | "full" | "summary"
|
|
313
|
+
# @param titles [Array<String>, nil] explicit article titles to extract
|
|
314
|
+
# (e.g. a set determined via query_sql). Normalized, deduplicated, one
|
|
315
|
+
# redirect hop resolved; missing titles are reported as not_found.
|
|
316
|
+
# Mutually exclusive with the filter arguments
|
|
305
317
|
# @param chunk_size [Integer, nil] split text into ~N-char chunks (RAG-ready records)
|
|
306
318
|
# @param chunk_overlap [Integer] overlap between consecutive chunks
|
|
307
319
|
# @param max_articles [Integer, nil] sync cap (nil = unlimited, for jobs)
|
|
@@ -309,7 +321,7 @@ module Wp2txt
|
|
|
309
321
|
# @param cancel_check [Proc, nil] polled between batches; truthy return aborts with Cancelled
|
|
310
322
|
def extract_corpus(output_path:, content: "sections", sections: nil, alias_set: nil,
|
|
311
323
|
category: nil, depth: 0, categories: nil, category_match: nil,
|
|
312
|
-
title_match: nil, limit: 0,
|
|
324
|
+
title_match: nil, limit: 0, titles: nil,
|
|
313
325
|
chunk_size: nil, chunk_overlap: 0,
|
|
314
326
|
max_articles: DEFAULT_MAX_SYNC_ARTICLES, num_processes: 4,
|
|
315
327
|
progress: nil, cancel_check: nil)
|
|
@@ -319,17 +331,56 @@ module Wp2txt
|
|
|
319
331
|
raise ArgumentError, "chunk_overlap must be smaller than chunk_size" if chunk_size && chunk_overlap >= chunk_size
|
|
320
332
|
raise ArgumentError, "chunking is not supported for content: \"wikitext\"" if chunk_size && content == "wikitext"
|
|
321
333
|
|
|
334
|
+
if titles
|
|
335
|
+
raise ArgumentError, "titles must be an array of title strings" unless titles.is_a?(Array)
|
|
336
|
+
if titles.size > TITLES_MAX
|
|
337
|
+
raise ArgumentError, "titles accepts at most #{TITLES_MAX} titles (got #{titles.size}); " \
|
|
338
|
+
"use filter-based extraction or a background job for larger sets"
|
|
339
|
+
end
|
|
340
|
+
conflicts = []
|
|
341
|
+
conflicts << "category" if category
|
|
342
|
+
conflicts << "categories" if categories
|
|
343
|
+
conflicts << "category_match" if category_match
|
|
344
|
+
conflicts << "title_match" if title_match
|
|
345
|
+
unless conflicts.empty?
|
|
346
|
+
raise ArgumentError, "titles cannot be combined with #{conflicts.join(', ')} — the article " \
|
|
347
|
+
"set would be defined twice; perform set operations in query_sql and " \
|
|
348
|
+
"pass the resulting titles via titles:"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
322
352
|
filters = { category: category, depth: depth, categories: categories,
|
|
323
353
|
category_match: category_match, sections: sections,
|
|
324
354
|
alias_set: alias_set, title_match: title_match }
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
355
|
+
not_found = nil
|
|
356
|
+
titles_record = nil
|
|
357
|
+
if titles
|
|
358
|
+
normalized = titles.map { |t| MetadataIndex.normalize_title(t) }.reject(&:empty?).uniq
|
|
359
|
+
titles_record = { titles_count: normalized.size,
|
|
360
|
+
titles_sha256: Digest::SHA256.hexdigest(normalized.sort.join("\n")) }
|
|
361
|
+
titles_record[:titles] = normalized if normalized.size <= 100
|
|
362
|
+
total = normalized.size
|
|
363
|
+
resolved, missing = resolve_explicit_titles(normalized)
|
|
364
|
+
not_found = { count: missing.size, sample: missing.first(20) }
|
|
365
|
+
cap = if limit.positive?
|
|
366
|
+
max_articles ? [limit, max_articles].min : limit
|
|
367
|
+
else
|
|
368
|
+
max_articles || resolved.size
|
|
369
|
+
end
|
|
370
|
+
titles = resolved.first(cap)
|
|
371
|
+
# Truncated means "cut by the cap" only; shortfalls from missing
|
|
372
|
+
# titles are explained by not_found, not by this flag
|
|
373
|
+
truncated = resolved.size > titles.size
|
|
374
|
+
else
|
|
375
|
+
total = @metadata.count_articles(**filters)
|
|
376
|
+
cap = if limit.positive?
|
|
377
|
+
max_articles ? [limit, max_articles].min : limit
|
|
378
|
+
else
|
|
379
|
+
max_articles || total
|
|
380
|
+
end
|
|
381
|
+
titles = @metadata.find_articles(**filters, limit: cap)
|
|
382
|
+
truncated = total > titles.size
|
|
383
|
+
end
|
|
333
384
|
|
|
334
385
|
resolved_sections = content == "summary" ? [SectionExtractor::SUMMARY_KEY] : expand_with_alias_set(sections, alias_set)
|
|
335
386
|
alias_contents = alias_set ? get_alias_set(alias_set)&.dig(:groups) : nil
|
|
@@ -372,19 +423,56 @@ module Wp2txt
|
|
|
372
423
|
tool: "wp2txt #{Wp2txt::VERSION}",
|
|
373
424
|
dump: dump_name,
|
|
374
425
|
generated_at: Time.now.utc.iso8601,
|
|
375
|
-
query: filters.compact.merge(
|
|
376
|
-
|
|
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,
|
|
377
430
|
alias_set_contents: alias_contents,
|
|
378
431
|
total_matching: total,
|
|
379
432
|
articles_extracted: articles_extracted,
|
|
380
433
|
records_written: records_written,
|
|
381
|
-
truncated: truncated
|
|
434
|
+
truncated: truncated,
|
|
435
|
+
not_found: not_found
|
|
382
436
|
))
|
|
383
437
|
|
|
384
438
|
{ output_path: output_path, meta_path: meta_path, dump: dump_name,
|
|
385
439
|
total_matching: total, articles_extracted: articles_extracted,
|
|
386
440
|
records_written: records_written, truncated: truncated,
|
|
387
|
-
bytes: File.size(output_path), sample: sample
|
|
441
|
+
bytes: File.size(output_path), sample: sample,
|
|
442
|
+
not_found: not_found }.compact
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Resolve explicit titles against the pages table: existence plus one
|
|
446
|
+
# redirect hop (same rule as get_article). Input order is preserved.
|
|
447
|
+
# @return [Array(Array<String>, Array<String>)] [found_titles, missing_titles]
|
|
448
|
+
def resolve_explicit_titles(titles)
|
|
449
|
+
map = @metadata.redirect_map(titles)
|
|
450
|
+
targets = titles.filter_map { |t| map[t] }
|
|
451
|
+
.map { |t| MetadataIndex.normalize_title(t) }.uniq
|
|
452
|
+
target_map = targets.empty? ? {} : @metadata.redirect_map(targets)
|
|
453
|
+
|
|
454
|
+
found = []
|
|
455
|
+
missing = []
|
|
456
|
+
titles.each do |t|
|
|
457
|
+
if !map.key?(t)
|
|
458
|
+
missing << t
|
|
459
|
+
elsif (target = map[t])
|
|
460
|
+
# Redirect: extract under the resolved title; a redirect whose
|
|
461
|
+
# target does not exist counts as not found
|
|
462
|
+
target = MetadataIndex.normalize_title(target)
|
|
463
|
+
if target_map.key?(target)
|
|
464
|
+
found << target
|
|
465
|
+
else
|
|
466
|
+
missing << t
|
|
467
|
+
end
|
|
468
|
+
else
|
|
469
|
+
found << t
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
# Resolution can collapse distinct inputs onto one article (two aliases
|
|
473
|
+
# redirecting to the same target, or a direct title plus its alias):
|
|
474
|
+
# dedupe so no article is extracted twice, preserving first-seen order
|
|
475
|
+
[found.uniq, missing]
|
|
388
476
|
end
|
|
389
477
|
|
|
390
478
|
# ------------------------------------------------------------------
|
|
@@ -396,6 +484,11 @@ module Wp2txt
|
|
|
396
484
|
SQL_TIMEOUT_SECONDS = 30
|
|
397
485
|
SQL_FORBIDDEN = /\b(ATTACH|DETACH|PRAGMA|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE|VACUUM|REINDEX)\b/i
|
|
398
486
|
|
|
487
|
+
# File-output mode (query_sql output_path:): hard row cap and per-cell
|
|
488
|
+
# clip (insurance against runaway blobs, not context economy)
|
|
489
|
+
SQL_FILE_ROW_LIMIT = 5_000_000
|
|
490
|
+
SQL_FILE_CELL_LIMIT = 64 * 1024
|
|
491
|
+
|
|
399
492
|
# Run a read-only SELECT against the metadata DB (with the FTS DB attached
|
|
400
493
|
# as `fts` when built). Defense layers: keyword screening (outside string
|
|
401
494
|
# literals), an SQLITE_OPEN_READONLY connection so writes are impossible at
|
|
@@ -404,19 +497,41 @@ module Wp2txt
|
|
|
404
497
|
# can only be stopped by killing the process running it. On timeout, the
|
|
405
498
|
# error message includes an EXPLAIN QUERY PLAN diagnosis when a likely
|
|
406
499
|
# cause (nested full scans, unbounded recursion) is recognizable.
|
|
407
|
-
|
|
500
|
+
#
|
|
501
|
+
# @param attach [Array<String>] language codes of other locally installed
|
|
502
|
+
# dumps to ATTACH read-only as {lang}_meta / {lang}_fts (e.g. ["en"] →
|
|
503
|
+
# en_meta.pages). Codes are validated and resolved server-side; user SQL
|
|
504
|
+
# itself can never contain ATTACH (SQL_FORBIDDEN).
|
|
505
|
+
# @param output_path [String, nil] when given, write ALL rows (up to
|
|
506
|
+
# SQL_FILE_ROW_LIMIT) to a JSONL file plus a .meta.json sidecar, and
|
|
507
|
+
# return only a summary + 3-row sample; `limit` is ignored in this mode
|
|
508
|
+
# @param overwrite [Boolean] replace an existing output file (default: refuse)
|
|
509
|
+
def query_sql(sql, limit: SQL_ROW_LIMIT, timeout: SQL_TIMEOUT_SECONDS, attach: [],
|
|
510
|
+
output_path: nil, overwrite: false)
|
|
408
511
|
raise ArgumentError, "only SELECT/WITH queries are allowed" unless sql =~ /\A\s*(SELECT|WITH)\b/i
|
|
409
512
|
# Screen keywords outside string literals and quoted identifiers only, so
|
|
410
513
|
# legitimate data values (e.g. title LIKE '%Update%') are not rejected
|
|
411
514
|
screened = sql.gsub(/'(?:[^']|'')*'/m, "''").gsub(/"(?:[^"]|"")*"/m, '""')
|
|
412
515
|
raise ArgumentError, "query contains a forbidden keyword" if screened.match?(SQL_FORBIDDEN)
|
|
413
516
|
|
|
517
|
+
attachments = resolve_attachments(attach)
|
|
518
|
+
return query_sql_to_file(sql, timeout, attachments, output_path, overwrite) if output_path
|
|
519
|
+
|
|
414
520
|
limit = [[limit.to_i, 1].max, 1000].min
|
|
415
|
-
if Process.respond_to?(:fork)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
521
|
+
result = if Process.respond_to?(:fork)
|
|
522
|
+
run_sql_in_subprocess(sql, limit, timeout, attachments)
|
|
523
|
+
elsif attachments.empty?
|
|
524
|
+
run_sql_on(readonly_db, sql, limit)
|
|
525
|
+
else
|
|
526
|
+
db = build_readonly_connection(attach_fts: fts.built?, attachments: attachments)
|
|
527
|
+
begin
|
|
528
|
+
run_sql_on(db, sql, limit)
|
|
529
|
+
ensure
|
|
530
|
+
db.close
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
result[:attached] = attachments_summary(attachments) unless attachments.empty?
|
|
534
|
+
result
|
|
420
535
|
rescue SQLite3::Exception => e
|
|
421
536
|
raise ArgumentError, "SQL error: #{e.message}"
|
|
422
537
|
end
|
|
@@ -448,14 +563,75 @@ module Wp2txt
|
|
|
448
563
|
@readonly_db ||= build_readonly_connection(attach_fts: fts.built?)
|
|
449
564
|
end
|
|
450
565
|
|
|
451
|
-
def build_readonly_connection(attach_fts:, fts_path: fts.db_path)
|
|
566
|
+
def build_readonly_connection(attach_fts:, fts_path: fts.db_path, attachments: [])
|
|
452
567
|
db = SQLite3::Database.new(@metadata.db_path, readonly: true)
|
|
453
568
|
db.busy_timeout = 5000
|
|
454
569
|
# Attached databases inherit the main connection's read-only flag
|
|
455
570
|
db.execute("ATTACH DATABASE ? AS fts", [fts_path]) if attach_fts
|
|
571
|
+
# Cross-dump attachments: aliases and paths come only from validated
|
|
572
|
+
# language codes resolved server-side (resolve_attachments), never from
|
|
573
|
+
# user SQL; opened via mode=ro URIs (belt-and-braces with the
|
|
574
|
+
# inherited read-only flag)
|
|
575
|
+
attachments.each do |a|
|
|
576
|
+
db.execute("ATTACH DATABASE ? AS #{a[:alias]}_meta", [readonly_uri(a[:meta_path])])
|
|
577
|
+
db.execute("ATTACH DATABASE ? AS #{a[:alias]}_fts", [readonly_uri(a[:fts_path])]) if a[:fts_path]
|
|
578
|
+
end
|
|
456
579
|
db
|
|
457
580
|
end
|
|
458
581
|
|
|
582
|
+
# Language codes accepted by query_sql's attach argument
|
|
583
|
+
ATTACH_LANG_REGEX = /\A[a-z][a-z0-9-]{1,11}\z/
|
|
584
|
+
|
|
585
|
+
# Validate attach language codes and resolve them to local index DBs.
|
|
586
|
+
# The argument carries language codes only — never file paths; path
|
|
587
|
+
# resolution (glob the cache dir, inspect dump_name/built state) is
|
|
588
|
+
# server-side. Selection rule: prefer the dump with the same date as the
|
|
589
|
+
# main DB; otherwise take the most recently built one and flag the entry
|
|
590
|
+
# with dump_mismatch so the response must note it.
|
|
591
|
+
def resolve_attachments(attach)
|
|
592
|
+
Array(attach).compact.map(&:to_s).uniq.map do |lang|
|
|
593
|
+
unless lang.match?(ATTACH_LANG_REGEX)
|
|
594
|
+
raise ArgumentError, "invalid language code for attach: #{lang.inspect}"
|
|
595
|
+
end
|
|
596
|
+
if lang == own_lang
|
|
597
|
+
raise ArgumentError, "cannot attach '#{lang}': it is the language of the main database"
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
candidates = MetadataIndex.cached_candidates(lang, cache_dir: @cache_dir)
|
|
601
|
+
if candidates.empty?
|
|
602
|
+
raise ArgumentError,
|
|
603
|
+
"no installed index found for '#{lang}' (build it with: wp2txt --build-index -L #{lang})"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
main_date = dump_name[/\d{8}\z/]
|
|
607
|
+
pick = candidates.find { |c| c[:dump_name].to_s.end_with?(main_date.to_s) }
|
|
608
|
+
mismatch = pick.nil?
|
|
609
|
+
pick ||= candidates.first
|
|
610
|
+
|
|
611
|
+
fts_path = pick[:db_path].sub(/#{MetadataIndex::CACHE_SUFFIX}\z/, FtsIndex::CACHE_SUFFIX)
|
|
612
|
+
has_fts = File.exist?(fts_path) && fts_db_built?(fts_path)
|
|
613
|
+
|
|
614
|
+
{ lang: lang, alias: lang.tr("-", "_"),
|
|
615
|
+
meta_path: pick[:db_path], fts_path: has_fts ? fts_path : nil,
|
|
616
|
+
dump_name: pick[:dump_name], built_with: pick[:built_with],
|
|
617
|
+
fts: has_fts, dump_mismatch: mismatch }
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def fts_db_built?(path)
|
|
622
|
+
meta = MetadataIndex.read_metadata_file(path)
|
|
623
|
+
meta && meta[:schema_version].to_i == FtsIndex::SCHEMA_VERSION && !meta[:built_at].nil?
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# "jawiki-20260701" => "ja"
|
|
627
|
+
def own_lang
|
|
628
|
+
dump_name[/\A([a-z0-9_-]+?)wiki/, 1]
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def readonly_uri(path)
|
|
632
|
+
"file:#{URI::DEFAULT_PARSER.escape(File.expand_path(path))}?mode=ro"
|
|
633
|
+
end
|
|
634
|
+
|
|
459
635
|
# Row extraction shared by the inline (Windows fallback) and subprocess paths
|
|
460
636
|
def run_sql_on(db, sql, limit)
|
|
461
637
|
columns = nil
|
|
@@ -475,14 +651,14 @@ module Wp2txt
|
|
|
475
651
|
# its own read-only connection (no inherited handles), runs the query, and
|
|
476
652
|
# ships the result back over a pipe; a query that outlives the deadline is
|
|
477
653
|
# SIGKILLed — the only reliable abort while the gem holds the GVL.
|
|
478
|
-
def run_sql_in_subprocess(sql, limit, timeout)
|
|
654
|
+
def run_sql_in_subprocess(sql, limit, timeout, attachments = [])
|
|
479
655
|
attach_fts = fts.built?
|
|
480
656
|
fts_path = fts.db_path
|
|
481
657
|
reader_io, writer_io = IO.pipe
|
|
482
658
|
pid = Process.fork do
|
|
483
659
|
reader_io.close
|
|
484
660
|
outcome = begin
|
|
485
|
-
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path)
|
|
661
|
+
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
|
|
486
662
|
{ ok: run_sql_on(db, sql, limit) }
|
|
487
663
|
rescue SQLite3::Exception => e
|
|
488
664
|
{ err: "SQL error: #{e.message}" }
|
|
@@ -510,6 +686,165 @@ module Wp2txt
|
|
|
510
686
|
reader_io&.close
|
|
511
687
|
end
|
|
512
688
|
|
|
689
|
+
# ------------------------------------------------------------------
|
|
690
|
+
# query_sql file-output mode (D4 generalized to SQL: the full result
|
|
691
|
+
# goes to disk, the caller receives a summary plus a small sample)
|
|
692
|
+
# ------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
# Provenance summary of resolved attachments, shared by the interactive
|
|
695
|
+
# and file-output responses
|
|
696
|
+
def attachments_summary(attachments)
|
|
697
|
+
attachments.map do |a|
|
|
698
|
+
entry = { lang: a[:lang], dump_name: a[:dump_name], built_with: a[:built_with], fts: a[:fts] }
|
|
699
|
+
entry[:dump_mismatch] = true if a[:dump_mismatch]
|
|
700
|
+
entry
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Write the full query result to output_path as JSONL. Atomicity: the
|
|
705
|
+
# child (or inline fallback) writes "#{output_path}.partial"; the parent
|
|
706
|
+
# renames it into place only on success and removes it on every failure
|
|
707
|
+
# path (child crash, timeout kill, error over the pipe) — a partially
|
|
708
|
+
# written file is never presented as a result. The .meta.json sidecar is
|
|
709
|
+
# written by the parent after the rename succeeds.
|
|
710
|
+
def query_sql_to_file(sql, timeout, attachments, output_path, overwrite)
|
|
711
|
+
if File.exist?(output_path) && !overwrite
|
|
712
|
+
raise ArgumentError, "output file already exists: #{output_path} (pass overwrite: true to replace it)"
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
partial = "#{output_path}.partial"
|
|
716
|
+
FileUtils.rm_f(partial)
|
|
717
|
+
outcome = begin
|
|
718
|
+
if Process.respond_to?(:fork)
|
|
719
|
+
run_sql_file_in_subprocess(sql, timeout, attachments, partial)
|
|
720
|
+
else
|
|
721
|
+
db = build_readonly_connection(attach_fts: fts.built?, attachments: attachments)
|
|
722
|
+
begin
|
|
723
|
+
run_sql_file_on(db, sql, partial)
|
|
724
|
+
ensure
|
|
725
|
+
db.close
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
rescue StandardError
|
|
729
|
+
FileUtils.rm_f(partial)
|
|
730
|
+
raise
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
File.rename(partial, output_path)
|
|
734
|
+
write_sql_sidecar(output_path, sql, attachments, outcome)
|
|
735
|
+
|
|
736
|
+
result = { output_path: output_path, meta_path: "#{output_path}.meta.json",
|
|
737
|
+
columns: outcome[:columns], row_count: outcome[:row_count],
|
|
738
|
+
truncated: outcome[:truncated], cells_clipped: outcome[:cells_clipped],
|
|
739
|
+
sample: outcome[:sample], bytes: File.size(output_path) }
|
|
740
|
+
result[:attached] = attachments_summary(attachments) unless attachments.empty?
|
|
741
|
+
result
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# Stream the query result into partial_path as JSONL, one object per row
|
|
745
|
+
# keyed by (deduplicated) column names. Runs in the forked child for the
|
|
746
|
+
# subprocess path: the 30s SIGKILL deadline covers the writing too.
|
|
747
|
+
def run_sql_file_on(db, sql, partial_path)
|
|
748
|
+
columns = nil
|
|
749
|
+
row_count = 0
|
|
750
|
+
cells_clipped = 0
|
|
751
|
+
truncated = false
|
|
752
|
+
sample = []
|
|
753
|
+
|
|
754
|
+
File.open(partial_path, "w") do |f|
|
|
755
|
+
db.query(sql) do |result|
|
|
756
|
+
columns = unique_columns(result.columns)
|
|
757
|
+
result.each do |row|
|
|
758
|
+
if row_count >= SQL_FILE_ROW_LIMIT
|
|
759
|
+
truncated = true
|
|
760
|
+
break
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
record = {}
|
|
764
|
+
row.each_with_index do |value, i|
|
|
765
|
+
if value.is_a?(String) && value.length > SQL_FILE_CELL_LIMIT
|
|
766
|
+
value = "#{value[0, SQL_FILE_CELL_LIMIT]}…"
|
|
767
|
+
cells_clipped += 1
|
|
768
|
+
end
|
|
769
|
+
record[columns[i]] = value
|
|
770
|
+
end
|
|
771
|
+
f.puts(JSON.generate(record))
|
|
772
|
+
sample << record if sample.size < 3
|
|
773
|
+
row_count += 1
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
{ columns: columns, row_count: row_count, truncated: truncated,
|
|
779
|
+
cells_clipped: cells_clipped, sample: sample }
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Duplicate result column names (SELECT 1 AS x, 2 AS x) are suffixed
|
|
783
|
+
# (_2, _3, ...) so every JSONL record key is unique
|
|
784
|
+
def unique_columns(columns)
|
|
785
|
+
seen = Hash.new(0)
|
|
786
|
+
columns.map do |c|
|
|
787
|
+
seen[c] += 1
|
|
788
|
+
seen[c] == 1 ? c : "#{c}_#{seen[c]}"
|
|
789
|
+
end
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Subprocess driver for file-output mode; same fork/pipe/SIGKILL
|
|
793
|
+
# structure as run_sql_in_subprocess, but the child writes the rows to
|
|
794
|
+
# partial_path and ships back only the summary
|
|
795
|
+
def run_sql_file_in_subprocess(sql, timeout, attachments, partial_path)
|
|
796
|
+
attach_fts = fts.built?
|
|
797
|
+
fts_path = fts.db_path
|
|
798
|
+
reader_io, writer_io = IO.pipe
|
|
799
|
+
pid = Process.fork do
|
|
800
|
+
reader_io.close
|
|
801
|
+
outcome = begin
|
|
802
|
+
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
|
|
803
|
+
{ ok: run_sql_file_on(db, sql, partial_path) }
|
|
804
|
+
rescue SQLite3::Exception => e
|
|
805
|
+
{ err: "SQL error: #{e.message}" }
|
|
806
|
+
rescue StandardError => e
|
|
807
|
+
{ err: "#{e.class}: #{e.message}" }
|
|
808
|
+
end
|
|
809
|
+
Marshal.dump(outcome, writer_io)
|
|
810
|
+
writer_io.close
|
|
811
|
+
exit!(0)
|
|
812
|
+
end
|
|
813
|
+
writer_io.close
|
|
814
|
+
|
|
815
|
+
unless IO.select([reader_io], nil, nil, timeout)
|
|
816
|
+
Process.kill("KILL", pid)
|
|
817
|
+
Process.waitpid(pid)
|
|
818
|
+
raise ArgumentError, "query exceeded the #{timeout}s time limit#{explain_plan_hint(sql)}"
|
|
819
|
+
end
|
|
820
|
+
payload = reader_io.read
|
|
821
|
+
Process.waitpid(pid)
|
|
822
|
+
raise ArgumentError, "query failed: the child process died without a result" if payload.empty?
|
|
823
|
+
|
|
824
|
+
outcome = Marshal.load(payload)
|
|
825
|
+
raise ArgumentError, outcome[:err] if outcome[:err]
|
|
826
|
+
|
|
827
|
+
outcome[:ok]
|
|
828
|
+
ensure
|
|
829
|
+
reader_io&.close
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Reproducibility sidecar, written by the parent after the atomic rename
|
|
833
|
+
def write_sql_sidecar(output_path, sql, attachments, outcome)
|
|
834
|
+
File.write("#{output_path}.meta.json", JSON.pretty_generate(
|
|
835
|
+
tool: "query_sql",
|
|
836
|
+
dump: dump_name,
|
|
837
|
+
built_with: @metadata.stats&.dig(:built_with),
|
|
838
|
+
sql: sql,
|
|
839
|
+
attached: attachments.map { |a| { lang: a[:lang], dump_name: a[:dump_name], built_with: a[:built_with] } },
|
|
840
|
+
row_count: outcome[:row_count],
|
|
841
|
+
truncated: outcome[:truncated],
|
|
842
|
+
cells_clipped: outcome[:cells_clipped],
|
|
843
|
+
generated_at: Time.now.utc.iso8601,
|
|
844
|
+
wp2txt_version: Wp2txt::VERSION
|
|
845
|
+
))
|
|
846
|
+
end
|
|
847
|
+
|
|
513
848
|
# Best-effort post-mortem for a timed-out query: EXPLAIN QUERY PLAN is
|
|
514
849
|
# instant and safe (it never executes the query), and the plan tree makes
|
|
515
850
|
# the two common pathologies recognizable
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "metadata_index"
|
|
5
5
|
require_relative "fts_index"
|
|
6
|
+
require_relative "langlinks_importer"
|
|
6
7
|
require_relative "corpus"
|
|
7
8
|
require_relative "multistream"
|
|
8
9
|
require_relative "memory_monitor"
|
|
@@ -244,6 +245,88 @@ module Wp2txt
|
|
|
244
245
|
CliUI::EXIT_SUCCESS
|
|
245
246
|
end
|
|
246
247
|
|
|
248
|
+
# Import the official langlinks dump (interlanguage links) into the
|
|
249
|
+
# metadata index. Version pinning: the langlinks file must carry the same
|
|
250
|
+
# dump date as the built index (enforced by LanglinksImporter, no override)
|
|
251
|
+
def run_import_langlinks(opts)
|
|
252
|
+
manager = DumpManager.new(
|
|
253
|
+
opts[:lang],
|
|
254
|
+
cache_dir: opts[:cache_dir],
|
|
255
|
+
dump_expiry_days: CLI.config.dump_expiry_days
|
|
256
|
+
)
|
|
257
|
+
multistream = manager.cached_multistream_path
|
|
258
|
+
unless File.exist?(multistream)
|
|
259
|
+
print_error("No cached dump found for '#{opts[:lang]}'.")
|
|
260
|
+
print_info_message("Download and index it with: wp2txt --build-index -L #{opts[:lang]}")
|
|
261
|
+
return CliUI::EXIT_ERROR
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
db_path = MetadataIndex.path_for(multistream, cache_dir: opts[:cache_dir])
|
|
265
|
+
meta = MetadataIndex.new(db_path)
|
|
266
|
+
unless meta.built?
|
|
267
|
+
meta.close
|
|
268
|
+
print_error("Metadata index not found for this dump.")
|
|
269
|
+
print_info_message("Build it first with: wp2txt --build-index -L #{opts[:lang]}")
|
|
270
|
+
return CliUI::EXIT_ERROR
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
dump_name = meta.stats[:dump_name]
|
|
274
|
+
dump_date = dump_name[/\d{8}\z/]
|
|
275
|
+
meta.close
|
|
276
|
+
|
|
277
|
+
source = opts[:langlinks_file] || begin
|
|
278
|
+
print_header("Downloading langlinks for '#{opts[:lang]}' (#{dump_date})")
|
|
279
|
+
manager.download_langlinks(date: dump_date)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
langs = opts[:langlinks_langs]&.split(",")&.map(&:strip)&.reject(&:empty?)
|
|
283
|
+
langs = nil if langs&.empty?
|
|
284
|
+
|
|
285
|
+
print_mode_banner("Import Langlinks", {
|
|
286
|
+
"Source" => File.basename(source),
|
|
287
|
+
"Metadata DB" => db_path,
|
|
288
|
+
"Languages" => langs ? langs.join(",") : "all"
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
importer = LanglinksImporter.new(db_path, cache_dir: opts[:cache_dir])
|
|
292
|
+
time_start = Time.now
|
|
293
|
+
last_report = Time.now
|
|
294
|
+
result = importer.import!(source, langs: langs, force: opts[:update_cache]) do |rows|
|
|
295
|
+
now = Time.now
|
|
296
|
+
if !quiet? && now - last_report >= DEFAULT_PROGRESS_INTERVAL
|
|
297
|
+
last_report = now
|
|
298
|
+
puts pastel.dim(format(" [%s] %d rows imported", now.strftime("%H:%M:%S"), rows))
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
if result[:status] == :already_imported
|
|
303
|
+
print_success("Langlinks already imported (at #{result[:imported_at]}, #{result[:row_count]} rows).")
|
|
304
|
+
print_info_message("Use -U/--update-cache to re-import.")
|
|
305
|
+
else
|
|
306
|
+
print_success("Langlinks imported: #{result[:row_count]} rows in #{format_duration(Time.now - time_start)}")
|
|
307
|
+
prov = result[:provenance]
|
|
308
|
+
print_info("Source", prov[:source].to_s)
|
|
309
|
+
print_info("Languages", prov[:lang_filter].to_s)
|
|
310
|
+
if result[:skipped_invalid].to_i.positive?
|
|
311
|
+
print_warning("Skipped #{result[:skipped_invalid]} rows containing invalid UTF-8 bytes (recorded as langlinks_skipped_invalid)")
|
|
312
|
+
end
|
|
313
|
+
(result[:sanity] || []).each do |check|
|
|
314
|
+
msg = format("join check ll_lang=%s: %d/%d titles found in %s (%.1f%%)",
|
|
315
|
+
check[:lang], check[:matched], check[:sampled],
|
|
316
|
+
check[:against], check[:match_rate] * 100)
|
|
317
|
+
if check[:warning]
|
|
318
|
+
print_warning("#{msg} — below 90%; title normalization may mismatch")
|
|
319
|
+
else
|
|
320
|
+
print_info_message(msg)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
CliUI::EXIT_SUCCESS
|
|
325
|
+
rescue ArgumentError => e
|
|
326
|
+
print_error(e.message)
|
|
327
|
+
CliUI::EXIT_ERROR
|
|
328
|
+
end
|
|
329
|
+
|
|
247
330
|
# Query the metadata index and print matching article titles
|
|
248
331
|
def run_find_articles(opts)
|
|
249
332
|
multistream_path, = resolve_dump_paths(opts, download: false)
|