wp2txt 2.3.0 → 2.3.1
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 +2 -1
- data/CHANGELOG.md +9 -0
- data/DEVELOPMENT.md +2 -1
- data/DEVELOPMENT_ja.md +2 -1
- data/Dockerfile +7 -1
- data/README.md +1 -1
- data/README_ja.md +33 -1
- data/Rakefile +28 -5
- data/bin/wp2txt-mcp +1 -1
- data/lib/wp2txt/corpus.rb +27 -0
- data/lib/wp2txt/regex.rb +7 -0
- data/lib/wp2txt/text_processing.rb +8 -0
- data/lib/wp2txt/utils.rb +17 -1
- data/lib/wp2txt/version.rb +1 -1
- data/spec/corpus_spec.rb +25 -0
- data/spec/docs_sync_spec.rb +39 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/utils_spec.rb +129 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4062c47480d0801e7a07d8e3bec7c65be8efeeda2eae30d398f9872810d321cd
|
|
4
|
+
data.tar.gz: 25bb3aa8a4d5baefc4616a8a4b754ec63e92d3e0b86b22ccc0ead2929a39739e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff61afbb989613e286af8bea784e4494c8f71b0dd52d24d3d038e72c5d77f9196d899039d9ff8c5e4b16461c044670d8cca6bc3feda55aa8cdbc31cbc30e012b
|
|
7
|
+
data.tar.gz: ddffdfb2452a3710d527bc7deeae52818cb9c2b4dedd433b2c7ecb06db74e6e389edf127976567a3269583531acf0aabc2ea9058d7422b5b675de9bae2e227fa
|
data/.dockerignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.3.1] - 2026-08-12
|
|
9
|
+
|
|
10
|
+
- **Container images no longer carry private files**: the image is built from the working tree with `git init && git add -A`, but the build context excluded `.gitignore`, so files ignored locally were tracked inside the container and shipped in the image — 2.3.0's images contained the maintainer's private research notes under `/wp2txt/research-notes`. The context now keeps `.gitignore` (so the container's file list matches a local `gem build`), explicitly excludes `research-notes` and `.bundle`, and the throwaway `.git` (a blob copy of every added file) is deleted after the build. `rake check_image` builds and inspects an image for private paths, and `rake push` refuses to publish without that check. The published gems were never affected; 2.3.0 images have been withdrawn
|
|
11
|
+
- **Docker Hub retired**: images are published to GitHub Container Registry only (`ghcr.io/yohasebe/wp2txt`). The Docker Hub repository is no longer updated
|
|
12
|
+
- **Fix: multi-line `<ref>` references broken by element splitting — also fixes `--extract-citations` on multi-line cite templates**: `Article#parse` splits elements at newlines, so any reference written across lines (very common for multi-line `{{cite …}}` templates) had its `[ref]` and `[/ref]` markers land in separate elements, invisible to `remove_ref`. The markers and raw reference markup leaked into the output, and — more importantly — `--extract-citations` silently never fired for multi-line cite templates, producing inconsistent output versus their single-line equivalents. `make_reference` now drops empty references outright and flattens the rest onto a single line before element splitting, so multi-line references behave exactly like single-line ones. Ordinary paragraph breaks outside references are unaffected
|
|
13
|
+
- **Fix: `[ref]` markers destroyed by external-link processing**: `process_external_links` stripped the brackets of the `[ref]`/`[/ref]` markers produced by `make_reference` (their contents took the single-word branch), so `remove_ref` could no longer find them and tag names plus reference bodies leaked into extracted text — even with `--ref`, the kept markers came out broken. The markers are now hidden behind placeholders for the duration of the bracket scan and restored afterwards. Downstream corpora no longer contain `ref…/ref` residue, which broke tokenization/sentence splitting and mixed bibliographic text into body prose
|
|
14
|
+
- **Runaway-query hardening**: a `query_sql` child process now sets its own kernel-enforced CPU limit (`RLIMIT_CPU`, the query timeout plus a small grace) in addition to the parent's wall-clock kill. Previously a child orphaned by the parent's death — an interrupted test run, a closed terminal, a crashed server — kept executing forever: sqlite3 holds the GVL inside `sqlite3_step`, so Ruby never reaches a signal-safe point and even SIGTERM is ignored. Observed in the wild as two processes spinning at 99% CPU for over five days. The CPU limit is strictly more permissive than the existing wall-clock deadline, so no query that would otherwise succeed is affected
|
|
15
|
+
- **Test-suite hang guard**: each example now runs under a wall-clock timeout (120s default; `WP2TXT_SPEC_TIMEOUT=0` disables it, the `:no_timeout` tag exempts an example). The suite deliberately exercises runaway queries, so a wedged example must fail rather than spin
|
|
16
|
+
|
|
8
17
|
## [2.3.0] - 2026-07-24
|
|
9
18
|
|
|
10
19
|
- **Documentation split**: README now focuses on text extraction; the research layer (indexes, exhaustive queries, full-text search, langlinks, cross-language SQL, MCP server) is documented in the new [Research Infrastructure Guide](docs/RESEARCH.md), including the complete MCP tool table
|
data/DEVELOPMENT.md
CHANGED
|
@@ -395,7 +395,8 @@ info = Wp2txt::Bz2Validator.file_info("/path/to/file.bz2")
|
|
|
395
395
|
Build and push Docker images:
|
|
396
396
|
|
|
397
397
|
```bash
|
|
398
|
-
rake
|
|
398
|
+
rake check_image # Builds the image locally and verifies it carries no private files
|
|
399
|
+
rake push # Verifies, then builds multi-arch and pushes to GHCR
|
|
399
400
|
```
|
|
400
401
|
|
|
401
402
|
## Release Process
|
data/DEVELOPMENT_ja.md
CHANGED
|
@@ -395,7 +395,8 @@ info = Wp2txt::Bz2Validator.file_info("/path/to/file.bz2")
|
|
|
395
395
|
Dockerイメージのビルドとプッシュ:
|
|
396
396
|
|
|
397
397
|
```bash
|
|
398
|
-
rake
|
|
398
|
+
rake check_image # ローカルでイメージをビルドし、私的ファイルの混入がないか検証
|
|
399
|
+
rake push # 検証したうえでマルチアーキテクチャでビルドしGHCRにプッシュ
|
|
399
400
|
```
|
|
400
401
|
|
|
401
402
|
## リリースプロセス
|
data/Dockerfile
CHANGED
|
@@ -7,7 +7,12 @@ WORKDIR /wp2txt
|
|
|
7
7
|
COPY . ./
|
|
8
8
|
RUN rm -f Gemfile.lock
|
|
9
9
|
|
|
10
|
-
# Install dependencies (git is required by gemspec's `git ls-files`)
|
|
10
|
+
# Install dependencies (git is required by gemspec's `git ls-files`).
|
|
11
|
+
# The repository's .gitignore is copied in deliberately: `git add -A` must
|
|
12
|
+
# honour it so the file list here matches a local `gem build` — without it,
|
|
13
|
+
# ignored material (private notes, scratch files) would land in the image.
|
|
14
|
+
# The throwaway .git is removed afterwards: it holds a blob copy of every
|
|
15
|
+
# added file and is dead weight in the published image.
|
|
11
16
|
RUN apk update && \
|
|
12
17
|
apk upgrade && \
|
|
13
18
|
apk add --no-cache \
|
|
@@ -17,6 +22,7 @@ RUN apk update && \
|
|
|
17
22
|
build-base curl-dev wget && \
|
|
18
23
|
git init && git add -A && \
|
|
19
24
|
bundle install -j4 && \
|
|
25
|
+
rm -rf /wp2txt/.git && \
|
|
20
26
|
apk del .build-packages
|
|
21
27
|
|
|
22
28
|
# lbzip2 is not available as an Alpine package; build from source
|
data/README.md
CHANGED
|
@@ -84,7 +84,7 @@ docker run -it -v /path/to/localdata:/data ghcr.io/yohasebe/wp2txt
|
|
|
84
84
|
|
|
85
85
|
The `wp2txt` command is available inside the container. Use `/data` for input/output files.
|
|
86
86
|
|
|
87
|
-
Images are published to GitHub Container Registry (`ghcr.io/yohasebe/wp2txt`)
|
|
87
|
+
Images are published to GitHub Container Registry (`ghcr.io/yohasebe/wp2txt`). The former Docker Hub repository (`yohasebe/wp2txt`) is no longer updated as of 2.3.1.
|
|
88
88
|
|
|
89
89
|
**MCP server (no Ruby required on the host):**
|
|
90
90
|
|
data/README_ja.md
CHANGED
|
@@ -34,6 +34,8 @@ WP2TXTはWikipediaダンプファイルからプレーンテキストとカテ
|
|
|
34
34
|
- **多言語対応** - 350以上のWikipedia言語でカテゴリ・リダイレクトを検出
|
|
35
35
|
- **ストリーミング処理** - 中間ファイルなしで大規模ダンプを処理
|
|
36
36
|
- **JSON出力** - データパイプライン向けの機械可読JSONL形式
|
|
37
|
+
- **オフライン研究索引** - カテゴリ・節見出し・全文に対する悉皆的・版固定クエリ、言語間リンクによる言語版横断SQL
|
|
38
|
+
- **MCPサーバー** - ローカルダンプをLLMエージェント(Claude、ChatGPT、Gemini、ローカルモデル)に公開
|
|
37
39
|
|
|
38
40
|
## ユースケース
|
|
39
41
|
|
|
@@ -43,6 +45,8 @@ wp2txtは以下の用途に適しています:
|
|
|
43
45
|
- トピック領域を横断した比較言語研究
|
|
44
46
|
- NLPタスク向けのメタデータ付きWikipediaテキスト抽出
|
|
45
47
|
- 並行カテゴリ構造を利用した対照言語研究
|
|
48
|
+
- 版固定のRAG知識ベース・LLM評価データセット
|
|
49
|
+
- web検索では原理的に不可能な悉皆的言明(「このカテゴリのどの記事にもXへの言及がない」)
|
|
46
50
|
|
|
47
51
|
## データアクセス
|
|
48
52
|
|
|
@@ -71,11 +75,13 @@ Windows:[Bzip2 for Windows](http://gnuwin32.sourceforge.net/packages/bzip2.htm
|
|
|
71
75
|
### Docker(代替方法)
|
|
72
76
|
|
|
73
77
|
```shell
|
|
74
|
-
docker run -it -v /path/to/localdata:/data yohasebe/wp2txt
|
|
78
|
+
docker run -it -v /path/to/localdata:/data ghcr.io/yohasebe/wp2txt
|
|
75
79
|
```
|
|
76
80
|
|
|
77
81
|
`wp2txt`コマンドはコンテナ内で使用可能です。入出力には`/data`ディレクトリを使用してください。
|
|
78
82
|
|
|
83
|
+
イメージはGitHub Container Registry(`ghcr.io/yohasebe/wp2txt`)で公開されています。旧 Docker Hub リポジトリ(`yohasebe/wp2txt`)は 2.3.1 以降更新されません。
|
|
84
|
+
|
|
79
85
|
## 基本的な使い方
|
|
80
86
|
|
|
81
87
|
### 自動ダウンロードと処理(推奨)
|
|
@@ -327,6 +333,28 @@ defaults:
|
|
|
327
333
|
|
|
328
334
|
コマンドラインオプションは設定ファイルの設定を上書きします。
|
|
329
335
|
|
|
336
|
+
## 研究基盤(索引・悉皆クエリ・MCP)
|
|
337
|
+
|
|
338
|
+
テキスト抽出に加えて、wp2txtはダンプを**ローカルな版固定研究データベース**に変換できます。
|
|
339
|
+
カテゴリ・節見出し・リダイレクト・(オプションで)記事全文のSQLite索引、言語版横断比較のための
|
|
340
|
+
言語間リンク(langlinks)— すべてオフラインで悉皆的にクエリでき、MCPサーバー経由で
|
|
341
|
+
LLMエージェントにも公開できます。
|
|
342
|
+
|
|
343
|
+
```console
|
|
344
|
+
$ wp2txt --build-index --fulltext --lang=ja # 索引の構築
|
|
345
|
+
$ wp2txt --find-articles --in-category "映画作品" -D 3 --has-section "あらすじ" --lang=ja
|
|
346
|
+
$ wp2txt --search "タイムループ" --in-category "映画作品" -D 3 --lang=ja
|
|
347
|
+
$ wp2txt --import-langlinks -L ja --langlinks-langs en,de,fr,zh,ko
|
|
348
|
+
$ wp2txt-mcp --lang=ja # LLMエージェント向けMCPサーバー
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
web/API アクセスと異なり、これらのクエリは全記事を走査します(`0件` は当該ダンプ版に対する
|
|
352
|
+
検証可能な不在の言明になります)。抽出結果には dump 版とクエリを記録した `.meta.json`
|
|
353
|
+
サイドカーが付き、再現可能です。
|
|
354
|
+
|
|
355
|
+
**→ 詳細は [Research Infrastructure Guide](docs/RESEARCH.md)(英語)を参照**:
|
|
356
|
+
索引構築、悉皆クエリ、全文検索、言語間リンク、言語版横断SQL、MCPツール一覧、設計原則。
|
|
357
|
+
|
|
330
358
|
## パフォーマンス
|
|
331
359
|
|
|
332
360
|
MacBook Air M4でのベンチマーク結果(7並列プロセス、ターボモード、ダウンロード時間除く):
|
|
@@ -347,6 +375,10 @@ MacBook Air M4でのベンチマーク結果(7並列プロセス、ターボ
|
|
|
347
375
|
|
|
348
376
|
詳細なリリースノートは[CHANGELOG.md](CHANGELOG.md)を参照してください。
|
|
349
377
|
|
|
378
|
+
**v2.3.0(2026年7月)**: 言語間リンク取り込み、言語版横断SQL(multi-dump ATTACH)、明示タイトル集合の抽出、SQL結果のファイル出力と再現性サイドカー、GHCRイメージ公開。
|
|
379
|
+
|
|
380
|
+
**v2.2.0(2026年7月)**: オフラインメタデータ索引、FTS5全文検索、MCPサーバー、query_sql、抽出ジョブ。
|
|
381
|
+
|
|
350
382
|
**v2.1.0(2026年2月)**: SQLiteキャッシュ、Ractor並列処理(Ruby 4.0+)、テンプレート展開、コンテンツマーカー、Dockerイメージ更新。
|
|
351
383
|
|
|
352
384
|
**v2.0.0(2026年1月)**: 自動ダウンロードモード、カテゴリベース抽出、タイトル指定抽出、JSON出力、ストリーミング処理、Ruby 4.0サポート。
|
data/Rakefile
CHANGED
|
@@ -32,16 +32,39 @@ Rake::Task["build"].enhance([:normalize_permissions])
|
|
|
32
32
|
# Docker
|
|
33
33
|
# =============================================================================
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
# Paths that must never reach a published image. The image is built from the
|
|
36
|
+
# working tree, so anything ignored locally (private notes, scratch files)
|
|
37
|
+
# would otherwise ride along; 2.3.0's images shipped research-notes/ this way.
|
|
38
|
+
IMAGE_FORBIDDEN_PATHS = %w[/wp2txt/research-notes /wp2txt/tmp /wp2txt/.git /wp2txt/CLAUDE.md /wp2txt/.claude].freeze
|
|
39
|
+
|
|
40
|
+
desc "Verify a built image contains no private material (run before pushing)"
|
|
41
|
+
task :verify_image, [:tag] do |_t, args|
|
|
42
|
+
tag = args[:tag] || "wp2txt-verify:local"
|
|
43
|
+
checks = IMAGE_FORBIDDEN_PATHS.map { |p| "test -e #{p} && echo LEAK:#{p}" }.join("; ")
|
|
44
|
+
out = `docker run --rm #{tag} sh -c '#{checks}; true' 2>&1`
|
|
45
|
+
leaks = out.lines.grep(/^LEAK:/).map(&:strip)
|
|
46
|
+
abort "Image #{tag} contains private paths:\n #{leaks.join("\n ")}" unless leaks.empty?
|
|
47
|
+
|
|
48
|
+
puts "OK: #{tag} contains none of #{IMAGE_FORBIDDEN_PATHS.join(', ')}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
desc "Build the image locally and verify it, without pushing"
|
|
52
|
+
task :check_image do
|
|
53
|
+
sh "docker build -t wp2txt-verify:local ."
|
|
54
|
+
Rake::Task[:verify_image].invoke
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc "Build and push Docker images to GHCR (verifies a local build first)"
|
|
58
|
+
task push: :check_image do
|
|
59
|
+
# Docker Hub was retired after 2.3.0; GHCR (ghcr.io/yohasebe/wp2txt) is the
|
|
60
|
+
# only registry. Requires `docker buildx use multiarch` and a ghcr.io login.
|
|
37
61
|
sh <<-SCRIPT.strip_heredoc, { verbose: false }
|
|
38
62
|
/bin/bash -xeu <<'BASH'
|
|
39
|
-
# docker buildx create --name
|
|
40
|
-
# docker buildx use
|
|
63
|
+
# docker buildx create --name multiarch
|
|
64
|
+
# docker buildx use multiarch
|
|
41
65
|
# docker buildx inspect --bootstrap
|
|
42
66
|
docker buildx build --platform linux/amd64,linux/arm64 \
|
|
43
67
|
-t ghcr.io/yohasebe/wp2txt:#{Wp2txt::VERSION} -t ghcr.io/yohasebe/wp2txt:latest \
|
|
44
|
-
-t yohasebe/wp2txt:#{Wp2txt::VERSION} -t yohasebe/wp2txt:latest \
|
|
45
68
|
. --push
|
|
46
69
|
BASH
|
|
47
70
|
SCRIPT
|
data/bin/wp2txt-mcp
CHANGED
|
@@ -254,7 +254,7 @@ end
|
|
|
254
254
|
|
|
255
255
|
server.define_tool(
|
|
256
256
|
name: "save_alias_set",
|
|
257
|
-
description: "Persist a named set of section-heading alias groups for this dump (e.g. [[\"あらすじ\",\"ストーリー\",\"物語\"]]). A server-side guardrail re-checks co-occurrence: a pair that appears in the same article more than 20% of the time (when both headings occur in at least 100 articles) is treated as distinct sections, not synonyms, and blocks the save with saved:false and the offending pairs listed — fix the groups, or pass force:true only if you verified them another way (e.g., by reading section contents with get_sections). The response echoes the saved groups and the exact criteria used. Saved sets are referenced via alias_set in find_articles / get_sections / extract_corpus and recorded in extraction metadata for reproducibility.",
|
|
257
|
+
description: "Persist a named set of section-heading alias groups for this dump (e.g. [[\"あらすじ\",\"ストーリー\",\"物語\"]]). A server-side guardrail re-checks co-occurrence: a pair that appears in the same article more than 20% of the time (when both headings occur in at least 100 articles) is treated as distinct sections, not synonyms, and blocks the save with saved:false and the offending pairs listed — fix the groups, or pass force:true only if you verified them another way (e.g., by reading section contents with get_sections). The response echoes the saved groups and the exact criteria used. Saved sets are referenced via alias_set in find_articles / get_sections / extract_corpus and recorded in extraction metadata for reproducibility. A refused save is signaled by saved:false in the RESULT (not an exception); verify persistence with list_alias_sets.",
|
|
258
258
|
input_schema: {
|
|
259
259
|
properties: {
|
|
260
260
|
name: { type: "string" },
|
data/lib/wp2txt/corpus.rb
CHANGED
|
@@ -482,6 +482,31 @@ module Wp2txt
|
|
|
482
482
|
SQL_ROW_LIMIT = 200
|
|
483
483
|
SQL_CELL_LIMIT = 2000
|
|
484
484
|
SQL_TIMEOUT_SECONDS = 30
|
|
485
|
+
|
|
486
|
+
# Grace added to the child's own CPU limit: the parent's IO.select deadline
|
|
487
|
+
# should normally fire first; this is the fallback for when it cannot.
|
|
488
|
+
SQL_CHILD_CPU_GRACE = 5
|
|
489
|
+
|
|
490
|
+
# Self-imposed deadline for a query child, applied inside the fork.
|
|
491
|
+
#
|
|
492
|
+
# The parent kills the child on timeout, but a child ORPHANED by the
|
|
493
|
+
# parent's death (interrupted test run, closed terminal, crashed server)
|
|
494
|
+
# would otherwise spin forever: sqlite3 holds the GVL inside sqlite3_step,
|
|
495
|
+
# so Ruby's deferred signal handling never reaches a safe point and even
|
|
496
|
+
# SIGTERM is ignored — only SIGKILL or the kernel can stop it. A CPU
|
|
497
|
+
# rlimit is enforced by the kernel regardless of the GVL (SIGXCPU at the
|
|
498
|
+
# soft limit, SIGKILL at the hard one), so the child always dies on its own.
|
|
499
|
+
def self.apply_child_cpu_limit(timeout)
|
|
500
|
+
return unless Process.respond_to?(:setrlimit) && defined?(Process::RLIMIT_CPU)
|
|
501
|
+
|
|
502
|
+
seconds = timeout.to_f.ceil
|
|
503
|
+
Process.setrlimit(Process::RLIMIT_CPU,
|
|
504
|
+
seconds + SQL_CHILD_CPU_GRACE,
|
|
505
|
+
seconds + (SQL_CHILD_CPU_GRACE * 2))
|
|
506
|
+
rescue StandardError
|
|
507
|
+
# A platform without CPU rlimits keeps the previous behaviour (parent-only kill)
|
|
508
|
+
nil
|
|
509
|
+
end
|
|
485
510
|
SQL_FORBIDDEN = /\b(ATTACH|DETACH|PRAGMA|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE|VACUUM|REINDEX)\b/i
|
|
486
511
|
|
|
487
512
|
# File-output mode (query_sql output_path:): hard row cap and per-cell
|
|
@@ -656,6 +681,7 @@ module Wp2txt
|
|
|
656
681
|
fts_path = fts.db_path
|
|
657
682
|
reader_io, writer_io = IO.pipe
|
|
658
683
|
pid = Process.fork do
|
|
684
|
+
self.class.apply_child_cpu_limit(timeout)
|
|
659
685
|
reader_io.close
|
|
660
686
|
outcome = begin
|
|
661
687
|
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
|
|
@@ -797,6 +823,7 @@ module Wp2txt
|
|
|
797
823
|
fts_path = fts.db_path
|
|
798
824
|
reader_io, writer_io = IO.pipe
|
|
799
825
|
pid = Process.fork do
|
|
826
|
+
self.class.apply_child_cpu_limit(timeout)
|
|
800
827
|
reader_io.close
|
|
801
828
|
outcome = begin
|
|
802
829
|
db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
|
data/lib/wp2txt/regex.rb
CHANGED
|
@@ -210,6 +210,13 @@ module Wp2txt
|
|
|
210
210
|
REMOVE_HR_REGEX = Regexp.new('^\s*\-{4,}\s*$')
|
|
211
211
|
MAKE_REFERENCE_REGEX_A = Regexp.new('<br ?\/>', Regexp::IGNORECASE)
|
|
212
212
|
MAKE_REFERENCE_REGEX_B = Regexp.new('<ref[^>]*\/>', Regexp::IGNORECASE)
|
|
213
|
+
# A reference carrying no content adds nothing in either mode
|
|
214
|
+
MAKE_REFERENCE_REGEX_EMPTY = Regexp.new('<ref[^>]*>\s*<\/ref>', Regexp::MULTILINE | Regexp::IGNORECASE)
|
|
215
|
+
# One whole reference span, used to normalize newlines inside it.
|
|
216
|
+
# The span must not contain another opening <ref: an unclosed <ref> would
|
|
217
|
+
# otherwise pair with a later </ref> and swallow the paragraphs in between.
|
|
218
|
+
MAKE_REFERENCE_REGEX_SPAN = Regexp.new('<ref[^>]*>(?:(?!<ref[\s>]).)*?<\/ref>',
|
|
219
|
+
Regexp::MULTILINE | Regexp::IGNORECASE)
|
|
213
220
|
MAKE_REFERENCE_REGEX_C = Regexp.new('<ref[^>]*>', Regexp::IGNORECASE)
|
|
214
221
|
MAKE_REFERENCE_REGEX_D = Regexp.new('<\/ref>', Regexp::IGNORECASE)
|
|
215
222
|
FORMAT_REF_REGEX = Regexp.new('\[ref\](.*?)\[\/ref\]', Regexp::MULTILINE)
|
|
@@ -308,6 +308,14 @@ module Wp2txt
|
|
|
308
308
|
result = +str.to_s
|
|
309
309
|
result.gsub!(MAKE_REFERENCE_REGEX_A, "\n")
|
|
310
310
|
result.gsub!(MAKE_REFERENCE_REGEX_B, "")
|
|
311
|
+
# Element splitting (Article#parse) breaks paragraphs at newlines, so a
|
|
312
|
+
# reference written across lines would land in separate elements and its
|
|
313
|
+
# [ref]/[/ref] pair would never be visible to remove_ref at the same time.
|
|
314
|
+
# Drop empty references outright, and flatten the rest onto one line so
|
|
315
|
+
# multi-line references behave exactly like single-line ones (this is also
|
|
316
|
+
# what makes --extract-citations work on multi-line cite templates).
|
|
317
|
+
result.gsub!(MAKE_REFERENCE_REGEX_EMPTY, "")
|
|
318
|
+
result.gsub!(MAKE_REFERENCE_REGEX_SPAN) { |span| span.gsub(/\s*\n\s*/, " ") }
|
|
311
319
|
result.gsub!(MAKE_REFERENCE_REGEX_C, "[ref]")
|
|
312
320
|
result.gsub!(MAKE_REFERENCE_REGEX_D, "[/ref]")
|
|
313
321
|
result
|
data/lib/wp2txt/utils.rb
CHANGED
|
@@ -415,11 +415,24 @@ module Wp2txt
|
|
|
415
415
|
result.strip
|
|
416
416
|
end
|
|
417
417
|
|
|
418
|
+
# Reference markers are already in [ref] form when external links are
|
|
419
|
+
# processed, and the scanner would strip their brackets (making remove_ref
|
|
420
|
+
# fail). Hide them behind placeholders for the duration of the scan.
|
|
421
|
+
# NOTE: do not special-case them inside the block instead — returning
|
|
422
|
+
# "[ref]" from the block makes process_nested_single_pass re-detect the same
|
|
423
|
+
# spot forever, burning MAX_NESTING_ITERATIONS and leaving the whole string
|
|
424
|
+
# unprocessed (measured: 478x slower, no links processed).
|
|
425
|
+
REF_OPEN_PLACEHOLDER = "«REFOPEN»"
|
|
426
|
+
REF_CLOSE_PLACEHOLDER = "«REFCLOSE»"
|
|
427
|
+
|
|
418
428
|
def process_external_links(str)
|
|
419
429
|
# Early exit if no external links present
|
|
420
430
|
return str unless str.include?("[")
|
|
421
431
|
|
|
422
|
-
|
|
432
|
+
protected_str = str.gsub("[ref]", REF_OPEN_PLACEHOLDER)
|
|
433
|
+
.gsub("[/ref]", REF_CLOSE_PLACEHOLDER)
|
|
434
|
+
|
|
435
|
+
processed = process_nested_single_pass(protected_str, "[", "]") do |contents|
|
|
423
436
|
if /\A\s.+\s\z/ =~ contents
|
|
424
437
|
" (#{contents.strip}) "
|
|
425
438
|
else
|
|
@@ -432,6 +445,9 @@ module Wp2txt
|
|
|
432
445
|
end
|
|
433
446
|
end
|
|
434
447
|
end
|
|
448
|
+
|
|
449
|
+
processed.gsub(REF_OPEN_PLACEHOLDER, "[ref]")
|
|
450
|
+
.gsub(REF_CLOSE_PLACEHOLDER, "[/ref]")
|
|
435
451
|
end
|
|
436
452
|
|
|
437
453
|
#################### template processing ####################
|
data/lib/wp2txt/version.rb
CHANGED
data/spec/corpus_spec.rb
CHANGED
|
@@ -199,6 +199,31 @@ RSpec.describe Wp2txt::Corpus do
|
|
|
199
199
|
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).to be < 5
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
it "gives the query child its own kernel-enforced CPU limit" do
|
|
203
|
+
# A child orphaned by the parent's death (interrupted test run, closed
|
|
204
|
+
# terminal) must still die on its own: sqlite3 holds the GVL inside
|
|
205
|
+
# sqlite3_step, so signals are never processed and only the kernel can
|
|
206
|
+
# stop it. Verified here by reading the limit the child actually gets.
|
|
207
|
+
skip "no CPU rlimits on this platform" unless Process.respond_to?(:setrlimit)
|
|
208
|
+
|
|
209
|
+
reader, writer = IO.pipe
|
|
210
|
+
pid = Process.fork do
|
|
211
|
+
reader.close
|
|
212
|
+
Wp2txt::Corpus.apply_child_cpu_limit(30)
|
|
213
|
+
writer.puts(Process.getrlimit(Process::RLIMIT_CPU).inspect)
|
|
214
|
+
writer.close
|
|
215
|
+
exit!(0)
|
|
216
|
+
end
|
|
217
|
+
writer.close
|
|
218
|
+
limits = reader.read
|
|
219
|
+
reader.close
|
|
220
|
+
Process.waitpid(pid)
|
|
221
|
+
|
|
222
|
+
# 30s query timeout => soft 35s / hard 40s of CPU time. Both exceed the
|
|
223
|
+
# parent's own wall-clock deadline, so no legitimate query is affected.
|
|
224
|
+
expect(limits.strip).to eq("[35, 40]")
|
|
225
|
+
end
|
|
226
|
+
|
|
202
227
|
it "keeps serving queries after a timeout" do
|
|
203
228
|
expect do
|
|
204
229
|
@corpus.query_sql("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT COUNT(*) FROM c", timeout: 1)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# The MCP tool surface and its public documentation drift apart easily
|
|
6
|
+
# (the 2.2.0 README shipped with a tool table missing four tools). This spec
|
|
7
|
+
# pins them together: every tool defined in bin/wp2txt-mcp must appear in the
|
|
8
|
+
# docs/RESEARCH.md tool table, and the table must not list phantom tools.
|
|
9
|
+
RSpec.describe "documentation surface sync" do
|
|
10
|
+
repo_root = File.expand_path("..", __dir__)
|
|
11
|
+
|
|
12
|
+
define_method(:defined_tools) do
|
|
13
|
+
src = File.read(File.join(repo_root, "bin", "wp2txt-mcp"))
|
|
14
|
+
src.scan(/server\.define_tool\(\s*name:\s*"([a-z_]+)"/).flatten
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
define_method(:documented_tools) do
|
|
18
|
+
doc = File.read(File.join(repo_root, "docs", "RESEARCH.md"))
|
|
19
|
+
table = doc[/^### Tools\n(.*?)\n\n/m, 1]
|
|
20
|
+
raise "Tools table not found in docs/RESEARCH.md" unless table
|
|
21
|
+
|
|
22
|
+
# Tool names live in the first column only (the purpose column may
|
|
23
|
+
# backtick argument names like `attach`)
|
|
24
|
+
table.lines.filter_map { |line| line.split("|")[1] }
|
|
25
|
+
.flat_map { |cell| cell.scan(/`([a-z_]+)`/).flatten }
|
|
26
|
+
.uniq
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "defines a non-trivial number of MCP tools" do
|
|
30
|
+
expect(defined_tools.size).to be >= 15
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "documents every MCP tool in docs/RESEARCH.md, with no phantom entries" do
|
|
34
|
+
missing = defined_tools - documented_tools
|
|
35
|
+
phantom = documented_tools - defined_tools
|
|
36
|
+
expect(missing).to be_empty, "tools not documented in docs/RESEARCH.md: #{missing.join(', ')}"
|
|
37
|
+
expect(phantom).to be_empty, "documented tools that do not exist: #{phantom.join(', ')}"
|
|
38
|
+
end
|
|
39
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -59,6 +59,28 @@ RSpec.configure do |config|
|
|
|
59
59
|
mocks.verify_partial_doubles = true
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Per-example wall-clock guard. The suite deliberately exercises runaway
|
|
63
|
+
# queries and forked workers; an interrupted or wedged example must not turn
|
|
64
|
+
# into a process spinning at 100% CPU for days (observed 2026-07-24).
|
|
65
|
+
# Slowest legitimate example is ~14s, so 120s is pure headroom. Override with
|
|
66
|
+
# WP2TXT_SPEC_TIMEOUT=0 to disable, or tag an example :no_timeout.
|
|
67
|
+
spec_timeout = (ENV["WP2TXT_SPEC_TIMEOUT"] || 120).to_i
|
|
68
|
+
if spec_timeout.positive?
|
|
69
|
+
require "timeout"
|
|
70
|
+
config.around(:each) do |example|
|
|
71
|
+
if example.metadata[:no_timeout]
|
|
72
|
+
example.run
|
|
73
|
+
else
|
|
74
|
+
begin
|
|
75
|
+
Timeout.timeout(spec_timeout) { example.run }
|
|
76
|
+
rescue Timeout::Error
|
|
77
|
+
raise "example exceeded the #{spec_timeout}s spec timeout (possible hang; " \
|
|
78
|
+
"set WP2TXT_SPEC_TIMEOUT=0 to disable this guard)"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
62
84
|
config.shared_context_metadata_behavior = :apply_to_host_groups
|
|
63
85
|
config.filter_run_when_matching :focus
|
|
64
86
|
config.example_status_persistence_file_path = "spec/examples.txt"
|
data/spec/utils_spec.rb
CHANGED
|
@@ -59,6 +59,135 @@ RSpec.describe "Wp2txt Utils" do
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Regression: process_external_links used to strip the brackets of the
|
|
63
|
+
# [ref]/[/ref] markers (contents "ref" / "/ref" take the parts.size == 1
|
|
64
|
+
# branch), so remove_ref could no longer find them and the tag names plus
|
|
65
|
+
# reference body leaked into the extracted text. These tests exercise the
|
|
66
|
+
# composed make_reference -> format_wiki path, since testing remove_ref in
|
|
67
|
+
# isolation passes even with the bug.
|
|
68
|
+
describe "reference markers through format_wiki" do
|
|
69
|
+
it "removes <ref>...</ref> including the body" do
|
|
70
|
+
input = "Paris was founded.<ref>Patrick Boucheron, France in the World (2019) pp 81-86.</ref> The city grew."
|
|
71
|
+
result = format_wiki(make_reference(input))
|
|
72
|
+
expect(result).to eq "Paris was founded. The city grew."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "removes named <ref name=\"x\">...</ref> including the body" do
|
|
76
|
+
input = "Fine dining.<ref name=\"lemonde\">Le Monde, 2 February 2015</ref> Paris has."
|
|
77
|
+
result = format_wiki(make_reference(input))
|
|
78
|
+
expect(result).to eq "Fine dining. Paris has."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "removes self-closing <ref name=\"x\"/>" do
|
|
82
|
+
input = "An asteroid,<ref name=\"x\"/> and a building."
|
|
83
|
+
result = format_wiki(make_reference(input))
|
|
84
|
+
expect(result).to eq "An asteroid, and a building."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "removes consecutive <ref>A</ref><ref>B</ref> without joining tag names" do
|
|
88
|
+
input = "Text<ref>A</ref><ref>B</ref> more."
|
|
89
|
+
result = format_wiki(make_reference(input))
|
|
90
|
+
expect(result).to eq "Text more."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "keeps [ref]...[/ref] markers when config[:ref] is true" do
|
|
94
|
+
input = "Paris was founded.<ref>Patrick Boucheron, France in the World (2019) pp 81-86.</ref> The city grew."
|
|
95
|
+
result = format_wiki(make_reference(input), ref: true)
|
|
96
|
+
expect(result).to eq "Paris was founded.[ref]Patrick Boucheron, France in the World (2019) pp 81-86.[/ref] The city grew."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Guards against the rejected fix of returning "[ref]" from the scanner
|
|
100
|
+
# block: that makes process_nested_single_pass re-detect the same spot
|
|
101
|
+
# until MAX_NESTING_ITERATIONS, leaving external links unprocessed.
|
|
102
|
+
it "still processes external links outside [ref] markers" do
|
|
103
|
+
input = "Claim.<ref>See [http://example.com the site] for detail.</ref> Next [http://foo.com Foo] end."
|
|
104
|
+
expect(format_wiki(make_reference(input))).to eq "Claim. Next Foo end."
|
|
105
|
+
expect(format_wiki(make_reference(input), ref: true)).to eq "Claim.[ref]See the site for detail.[/ref] Next Foo end."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Regression: element splitting (Article#parse) breaks paragraphs at
|
|
110
|
+
# newlines, so a reference written across lines landed in separate elements
|
|
111
|
+
# with [ref] and [/ref] never visible to remove_ref at the same time. This
|
|
112
|
+
# also meant --extract-citations never fired for multi-line cite templates.
|
|
113
|
+
# These tests go through Article.new -> format_wiki per element, since
|
|
114
|
+
# passing a string to format_wiki directly skips element splitting and does
|
|
115
|
+
# not reproduce the bug.
|
|
116
|
+
describe "multi-line references through element splitting" do
|
|
117
|
+
def render_elements(wikitext, config = {})
|
|
118
|
+
Wp2txt::Article.new(wikitext).elements.map { |e| format_wiki(e[1], config) }.join
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "removes an empty reference spanning a blank line" do
|
|
122
|
+
result = render_elements("...in the Super League.<ref>\n\n</ref> In 2006, Catalans Dragons became...")
|
|
123
|
+
expect(result).not_to include("[ref]")
|
|
124
|
+
expect(result).not_to include("[/ref]")
|
|
125
|
+
expect(result).to include("Super League. In 2006,")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "removes a named empty reference spanning a blank line" do
|
|
129
|
+
result = render_elements("Claim.<ref name=\"x\">\n\n</ref> Next.")
|
|
130
|
+
expect(result).not_to include("[ref]")
|
|
131
|
+
expect(result).not_to include("[/ref]")
|
|
132
|
+
expect(result).to include("Claim. Next.")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "removes a non-empty reference spanning a blank line" do
|
|
136
|
+
result = render_elements("Claim.<ref>Author\n\nPublisher</ref> Next.")
|
|
137
|
+
expect(result).not_to include("[ref]")
|
|
138
|
+
expect(result).not_to include("[/ref]")
|
|
139
|
+
expect(result).to include("Claim. Next.")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "removes a multi-line cite template reference (with and without leading space)" do
|
|
143
|
+
["Claim.<ref> {{cite book\n |title=Foo\n |year=2019}}</ref> Next.",
|
|
144
|
+
"Claim.<ref>{{cite book\n|title=Foo\n|year=2019}}</ref> Next."].each do |input|
|
|
145
|
+
result = render_elements(input)
|
|
146
|
+
expect(result).not_to include("[ref]")
|
|
147
|
+
expect(result).not_to include("[/ref]")
|
|
148
|
+
expect(result).not_to include("cite book")
|
|
149
|
+
expect(result).to include("Claim. Next.")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The core of this fix: before, only the multi-line form leaked raw
|
|
154
|
+
# markup, so --extract-citations silently did nothing for it.
|
|
155
|
+
it "extracts citations identically from single-line and multi-line cite templates" do
|
|
156
|
+
config = { extract_citations: true, ref: true }
|
|
157
|
+
single = render_elements("Claim.<ref>{{cite book|title=Foo|year=2019}}</ref> Next.", config)
|
|
158
|
+
multi = render_elements("Claim.<ref>{{cite book\n|title=Foo\n|year=2019}}</ref> Next.", config)
|
|
159
|
+
expect(multi).to eq single
|
|
160
|
+
expect(single).to include("[ref]\"Foo\". 2019.[/ref]")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Control: newlines outside references must still split paragraphs.
|
|
164
|
+
it "still splits ordinary paragraph boundaries at blank lines" do
|
|
165
|
+
elements = Wp2txt::Article.new("First para.\n\nSecond para.").elements
|
|
166
|
+
paragraphs = elements.select { |e| e[0] == :mw_paragraph }
|
|
167
|
+
expect(paragraphs.size).to eq 2
|
|
168
|
+
expect(paragraphs[0][1]).to include("First para.")
|
|
169
|
+
expect(paragraphs[1][1]).to include("Second para.")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# An unclosed <ref> must not pair with a later </ref> across paragraphs;
|
|
173
|
+
# the body text stays (a floating [ref] is the pre-existing behavior for
|
|
174
|
+
# this malformed markup).
|
|
175
|
+
it "does not swallow paragraphs when a <ref> is left unclosed" do
|
|
176
|
+
wikitext = "First para with <ref>unclosed reference.\n\nSecond para here.\n\n" \
|
|
177
|
+
"Third para with <ref>closed</ref> end."
|
|
178
|
+
result = render_elements(wikitext)
|
|
179
|
+
expect(result).to include("Second para here.")
|
|
180
|
+
expect(result).to include("First para with")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "handles a multi-line reference with a group attribute" do
|
|
184
|
+
result = render_elements("Claim.<ref group=\"note\">Author\nTitle 2019</ref> Next.")
|
|
185
|
+
expect(result).not_to include("[ref]")
|
|
186
|
+
expect(result).not_to include("[/ref]")
|
|
187
|
+
expect(result).to include("Claim. Next.")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
62
191
|
describe "remove_table" do
|
|
63
192
|
it "removes table formated parts" do
|
|
64
193
|
str_before = "{| ... \n{| ... \n ...|}\n ...|}"
|
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.
|
|
4
|
+
version: 2.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yoichiro Hasebe
|
|
@@ -276,6 +276,7 @@ files:
|
|
|
276
276
|
- spec/config_spec.rb
|
|
277
277
|
- spec/constants_spec.rb
|
|
278
278
|
- spec/corpus_spec.rb
|
|
279
|
+
- spec/docs_sync_spec.rb
|
|
279
280
|
- spec/file_utils_spec.rb
|
|
280
281
|
- spec/fixtures/samples.rb
|
|
281
282
|
- spec/formatter_sections_spec.rb
|
|
@@ -341,6 +342,7 @@ test_files:
|
|
|
341
342
|
- spec/config_spec.rb
|
|
342
343
|
- spec/constants_spec.rb
|
|
343
344
|
- spec/corpus_spec.rb
|
|
345
|
+
- spec/docs_sync_spec.rb
|
|
344
346
|
- spec/file_utils_spec.rb
|
|
345
347
|
- spec/fixtures/samples.rb
|
|
346
348
|
- spec/formatter_sections_spec.rb
|