@nitpicker/core 0.24.0 → 0.25.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.
Files changed (83) hide show
  1. package/README.md +165 -35
  2. package/lib/assert-u32-id.d.ts +15 -0
  3. package/lib/assert-u32-id.js +22 -0
  4. package/lib/check-native-executor-config.d.ts +21 -0
  5. package/lib/check-native-executor-config.js +24 -0
  6. package/lib/compute-alias-keys.d.ts +28 -0
  7. package/lib/compute-alias-keys.js +30 -0
  8. package/lib/compute-body-hash.d.ts +22 -0
  9. package/lib/compute-body-hash.js +25 -0
  10. package/lib/compute-url-sort-ranks.d.ts +28 -0
  11. package/lib/compute-url-sort-ranks.js +49 -0
  12. package/lib/create-isolated-cluster-run.d.ts +35 -0
  13. package/lib/create-isolated-cluster-run.js +52 -0
  14. package/lib/create-template-classification-run.d.ts +49 -0
  15. package/lib/create-template-classification-run.js +386 -0
  16. package/lib/group-alias-candidates.d.ts +40 -0
  17. package/lib/group-alias-candidates.js +104 -0
  18. package/lib/load-native-binding.d.ts +26 -0
  19. package/lib/load-native-binding.js +152 -0
  20. package/lib/match-js-license-comment-signal.d.ts +19 -0
  21. package/lib/match-js-license-comment-signal.js +21 -0
  22. package/lib/match-selector-html.d.ts +41 -0
  23. package/lib/match-selector-html.js +42 -0
  24. package/lib/match-selector-snapshots.d.ts +40 -0
  25. package/lib/match-selector-snapshots.js +57 -0
  26. package/lib/match-structural-technology-signals.d.ts +23 -0
  27. package/lib/match-structural-technology-signals.js +25 -0
  28. package/lib/max-decoded-snapshot-bytes.d.ts +22 -0
  29. package/lib/max-decoded-snapshot-bytes.js +35 -0
  30. package/lib/native-binding.d.ts +9 -0
  31. package/lib/native-binding.js +9 -0
  32. package/lib/pack-stored-snapshots.d.ts +23 -0
  33. package/lib/pack-stored-snapshots.js +53 -0
  34. package/lib/run-with-signal.d.ts +27 -0
  35. package/lib/run-with-signal.js +54 -0
  36. package/lib/search-html-document.d.ts +32 -0
  37. package/lib/search-html-document.js +31 -0
  38. package/lib/search-html-snapshots.d.ts +46 -0
  39. package/lib/search-html-snapshots.js +54 -0
  40. package/lib/template-block-sample-size.d.ts +13 -0
  41. package/lib/template-block-sample-size.js +14 -0
  42. package/lib/template-exact-page-limit.d.ts +18 -0
  43. package/lib/template-exact-page-limit.js +19 -0
  44. package/lib/template-memory-budget.d.ts +16 -0
  45. package/lib/template-memory-budget.js +17 -0
  46. package/lib/to-native-selector-plan.d.ts +16 -0
  47. package/lib/to-native-selector-plan.js +40 -0
  48. package/lib/to-u32-ids.d.ts +14 -0
  49. package/lib/to-u32-ids.js +22 -0
  50. package/lib/types.d.ts +736 -248
  51. package/lib/validate-search-pattern.d.ts +25 -0
  52. package/lib/validate-search-pattern.js +27 -0
  53. package/package.json +83 -16
  54. package/lib/core.d.ts +0 -4
  55. package/lib/core.js +0 -4
  56. package/lib/discover-analyze-plugins.d.ts +0 -14
  57. package/lib/discover-analyze-plugins.js +0 -32
  58. package/lib/get-table-cache-root.d.ts +0 -18
  59. package/lib/get-table-cache-root.js +0 -22
  60. package/lib/hooks/define-plugin.d.ts +0 -68
  61. package/lib/hooks/define-plugin.js +0 -69
  62. package/lib/import-modules.d.ts +0 -24
  63. package/lib/import-modules.js +0 -38
  64. package/lib/load-plugin-settings.d.ts +0 -44
  65. package/lib/load-plugin-settings.js +0 -125
  66. package/lib/nitpicker.d.ts +0 -150
  67. package/lib/nitpicker.js +0 -437
  68. package/lib/page-analysis-worker.d.ts +0 -58
  69. package/lib/page-analysis-worker.js +0 -68
  70. package/lib/read-plugin-labels.d.ts +0 -15
  71. package/lib/read-plugin-labels.js +0 -30
  72. package/lib/table.d.ts +0 -75
  73. package/lib/table.js +0 -132
  74. package/lib/url-event-bus.d.ts +0 -33
  75. package/lib/url-event-bus.js +0 -21
  76. package/lib/worker/runner.d.ts +0 -25
  77. package/lib/worker/runner.js +0 -31
  78. package/lib/worker/types.d.ts +0 -23
  79. package/lib/worker/types.js +0 -1
  80. package/lib/worker/worker-pool.d.ts +0 -82
  81. package/lib/worker/worker-pool.js +0 -278
  82. package/lib/worker/worker.d.ts +0 -34
  83. package/lib/worker/worker.js +0 -108
package/README.md CHANGED
@@ -1,47 +1,177 @@
1
1
  # @nitpicker/core
2
2
 
3
- `.nitpicker` アーカイブ内のページに対して analyze プラグインを実行する内部パッケージです。
4
-
5
- プラグインの読み込み、実行、結果の書き戻しを担当します。通常は [@nitpicker/cli](../cli/README.md) の `analyze` コマンドから利用します。
6
-
7
- ## プラグイン作者向けAPI
8
-
9
- analyze プラグインは `definePlugin()` で定義します。
10
-
11
- ```ts
12
- import { definePlugin } from '@nitpicker/core';
13
-
14
- type Options = {
15
- keywords: string[];
16
- };
17
-
18
- export default definePlugin((options: Options) => {
19
- return {
20
- label: 'キーワード検索',
21
- // 並列度を宣言できる。省略時は os.cpus().length
22
- // 重いプラグインは小さく設定する
23
- concurrency: 4,
24
- headers: {
25
- found: 'Keywords Found',
26
- },
27
- async eachPage({ window }) {
28
- const text = window.document.body.textContent ?? '';
29
- const count = options.keywords.filter((keyword) => text.includes(keyword)).length;
30
- return {
31
- page: { found: { value: count } },
32
- };
33
- },
34
- };
3
+ Nitpicker の計算コアを Rust(Node-API、[napi-rs](https://napi.rs/))で実装したネイティブアドオンです。重い計算をここに集め、[@nitpicker/archive](../archive/README.md)などの TypeScript パッケージから呼び出します。現在は、ページ本文の内容ハッシュ(`page_meta.body_hash`)、HTML の構造シグナルによる技術検出、URL の自然順の順位付け、alias の判定、リンクの連結成分、テンプレート分類(ページのシグナルと HTML から、ページごとのテンプレートキーとクラスタごとの reason まで)、保存済み HTML スナップショットに対する CSS セレクタの照合(`query match-selector`)と文字列・正規表現の検索(`query search-html`)を提供しています。
4
+
5
+ ## 対応プラットフォーム
6
+
7
+ ビルド済みバイナリだけを配布し、JavaScript のフォールバックはありません。対応しているのは次の 2 つです。
8
+
9
+ | プラットフォーム | バイナリを含むパッケージ |
10
+ | ------------------------------------- | ---------------------------------------------------------------- |
11
+ | macOS(Apple silicon) | [@nitpicker/core-darwin-arm64](../core-darwin-arm64/README.md) |
12
+ | Linux x64・glibc 2.28 以上(WSL2 可) | [@nitpicker/core-linux-x64-gnu](../core-linux-x64-gnu/README.md) |
13
+
14
+ glibc 2.28 は Node.js 24 自体の Linux バイナリと同じ下限です。上記以外(Intel Mac、linux-arm64、musl、Windows ネイティブ)では、読み込んだ時点で対応表を含むエラーを出して終了します。プラットフォーム別パッケージは`optionalDependencies`で入るため、`--omit=optional`/`--no-optional`を付けてインストールすると読み込めません。
15
+
16
+ ## 使い方
17
+
18
+ 公開 API は 1 関数 1 サブパスで export しています。
19
+
20
+ ```ts
21
+ import { computeBodyHash } from '@nitpicker/core/compute-body-hash';
22
+
23
+ const a = computeBodyHash(Buffer.from('<body><a href="/p/a1b2c3d4">x</a></body>'));
24
+ const b = computeBodyHash(Buffer.from('<body><a href="/p/z9y8x7w6">x</a></body>'));
25
+ a.equals(b); // true — 動的 ID はマスクしてからハッシュする
26
+ ```
27
+
28
+ フロントエンドフレームワークの構造シグナル(Next.js の`__NEXT_DATA__`、Vue の`data-v-*`など)は、`matchStructuralTechnologySignals`がページの HTML を 1 回走査して検出します。JS リソースの先頭にあるライセンスコメントは`matchJsLicenseCommentSignal`で照合します。フィンガープリントの一覧は`crates/nitpicker_tech_detect`が正です。
29
+
30
+ ```ts
31
+ import { matchStructuralTechnologySignals } from '@nitpicker/core/match-structural-technology-signals';
32
+
33
+ matchStructuralTechnologySignals(Buffer.from('<div data-v-1a2b3c4d></div>'));
34
+ // [{ technology: 'Vue', signalType: 'scoped-attr', evidence: ' data-v-1a2b3c4d', weight: 40, category: 'JavaScript frameworks' }]
35
+ ```
36
+
37
+ URL の自然順(`page2`が`page10`の前、大文字小文字・パーセントエンコードで順序が割れない、ホストが先でパスが後)は、`computeUrlSortRanks`が全件まとめて順位にします。URL は 1 回だけパースし、結果は入力順に依存しない全順序です(同じ URL は入力順)。順序の定義は`crates/nitpicker_url_key`の doc が正です。
38
+
39
+ ```ts
40
+ import { computeUrlSortRanks } from '@nitpicker/core/compute-url-sort-ranks';
41
+
42
+ computeUrlSortRanks([
43
+ 'https://example.com/page10',
44
+ 'https://example.com/page2',
45
+ 'https://example.com/',
46
+ ]); // Uint32Array [2, 1, 0]
47
+ ```
48
+
49
+ 同じページが別の URL で現れている組(`content_items.alias_of_id`)の判定には、URL からスキーム・ホストの大文字小文字・`/index.{ext}`をたたんだ Tier A キーと、さらに末尾スラッシュを落とした Tier B キーを使います。`computeAliasKeys`が 1 回のパースで両方を返し、`groupAliasCandidates`がキー・タイトル・本文ハッシュで連結成分にまとめて代表を選びます(グループ内を指す canonical が最多 → 最短 URL → UTF-16 の昇順)。
50
+
51
+ ```ts
52
+ import { computeAliasKeys } from '@nitpicker/core/compute-alias-keys';
53
+
54
+ computeAliasKeys('http://Example.com/about/index.html');
55
+ // { tierA: 'example.com/about/', tierB: 'example.com/about' }
56
+ ```
57
+
58
+ リンクでつながったページの連結成分は、`createIsolatedClusterRun`で求めます。辺は SQLite から読んだ分ずつ`unionEdges`に渡して捨てるので、メモリはページ数に比例し、辺の数には比例しません。リダイレクト先・alias の先に解決してから結び、終わりのない連鎖(ループ、ループに合流する尾)の辺は捨てます。成分の番号は候補の並び順で振られ、辺の順序や分け方に依存しません。
59
+
60
+ ```ts
61
+ import { createIsolatedClusterRun } from '@nitpicker/core/create-isolated-cluster-run';
62
+
63
+ // 候補 10, 20, 30。ページ 40 は 30 へのリダイレクト元で候補ではない
64
+ const run = createIsolatedClusterRun([10, 20, 30], [40, 30]);
65
+ run.unionEdges([10, 20, 20, 40]);
66
+ run.labels(); // Uint32Array [0, 0, 0]
67
+ ```
68
+
69
+ 保存済み HTML スナップショットのセレクタ照合は、`matchSelectorSnapshots`がバッチ(`page_html_blobs`の行)を受け取り、zstd の展開から判定までをスレッドプールで並列に行って、スナップショットごとの判定を返します。同時に展開済みの文書を持つのは、スレッド数ぶんだけです。zstd のスナップショット 1 件の展開後は`MAX_DECODED_SNAPSHOT_BYTES`(`@nitpicker/core/max-decoded-snapshot-bytes`、約 1.5 GiB)までで、超えると「不一致」にせず、`snapshot <n> could not be decoded`で呼び出し全体を reject します。zstd のフレームヘッダが述べる展開後サイズは省略も偽りもできるので、展開しながら出てきたバイト数を数えて止めます。上限はクローラーが保存できる最大(V8 の文字列の最大長 2^29 − 24 code unit × UTF-8 の最大 3 バイト)なので、クローラーが保存したスナップショットが上限で弾かれることはありません。圧縮せずに保存されたスナップショットには掛けません(`searchHtmlSnapshots`も同じ)。1 文書だけを同期で判定する`matchSelectorHtml`は、テストとベンチの段階比較のために出しています。判定は、必要なリテラルが順に現れるかの事前判定、開始タグだけで決まるセレクタの開始タグ走査、結合子と`:nth-*`のための開タグスタックの順で、最も安い厳密な手段を使います。タグ名と属性名は ASCII の大文字小文字だけを無視します。計画(`SelectorPlan`)の作り方と解釈規則は`@nitpicker/cli`の`docs/query.md`が正です。
70
+
71
+ ```ts
72
+ import { matchSelectorSnapshots } from '@nitpicker/core/match-selector-snapshots';
73
+
74
+ const verdicts = await matchSelectorSnapshots({
75
+ plan,
76
+ snapshots: rows.map((row) => ({ body: row.body, codec: row.codec })),
77
+ });
78
+ verdicts[0]; // { matched: true, prefiltered: false, tokenized: false }
79
+ ```
80
+
81
+ 保存済み HTML スナップショットの文字列・正規表現検索は、`searchHtmlSnapshots`がバッチを受け取り、展開から照合までを同じようにスレッドプールで並列に行って、スナップショットごとの`matchCount`(0 なら不一致)とスニペットを返します。パターンはスナップショットごとではなく 1 回だけコンパイルし、アドオンは直前にコンパイルしたパターンを保持して次のバッチでも使い回します(保持は 1 件だけなので、別のパターンの検索が並行すると入れ替わり、そのぶん再コンパイルします)。1 文書だけを同期で検索する`searchHtmlDocument`はテスト用、アーカイブを開く前にパターンを検証する`validateSearchPattern`は空文字列に一致するかを返し、構文エラーは`InvalidArg`で投げます。パターンは Rust の`regex` crate の構文(後方参照・先読み・後読みなし。照合時間は文書長に比例)で、マッチの単位は Unicode スカラー値、`\d` `\w` `\s` `\b`は Unicode の定義、`.`は`\r`と`\n`に一致せず、`m`の`^` `$`は CRLF を 1 つの行末として扱います。スニペットは、文書全体の空白を畳んでから窓を切ります。バッチの`bodies`は JS のバッファをコピーせず読むので、呼び出し側は promise が決着するまで書き換えないでください。永続化される値ではないので`(?-u)`は使わず、契約は`@nitpicker/cli`の`docs/query.md`(パターンの契約)が正です。
82
+
83
+ ```ts
84
+ import { searchHtmlSnapshots } from '@nitpicker/core/search-html-snapshots';
85
+
86
+ const outcomes = await searchHtmlSnapshots({
87
+ pattern: {
88
+ source: 'fonts.example.org',
89
+ literal: true,
90
+ ignoreCase: false,
91
+ multiLine: false,
92
+ dotAll: false,
93
+ },
94
+ snippetLength: 160,
95
+ snapshots: rows.map((row) => ({ body: row.body, codec: row.codec })),
35
96
  });
97
+ outcomes[0]; // { matchCount: 2, snippet: '…fonts.example.org…' }
36
98
  ```
37
99
 
38
- `concurrency` を省略するとCPUコア数が使われます。ChromeやPuppeteerなど重いプロセスを起動するプラグインでは、2〜4程度に絞ることを推奨します。
100
+ テンプレート分類は、呼び出し側(`@nitpicker/archive`の`resolvePageClusterKeys`)がコーパスを読んで順に渡して実行します。全ページのシグナル(URL パス・スタイルシート・ホスト)を渡してブロック分け(Pass 0)し、続けて全ページの HTML をコーパス順に渡します。計算する呼び出しはアドオン内のスレッドプールで動き、Promise を返します。呼び出しは重ねず、1 つずつ await します。
101
+
102
+ ```ts
103
+ import { createTemplateClassificationRun } from '@nitpicker/core/create-template-classification-run';
104
+
105
+ const run = createTemplateClassificationRun();
106
+ run.pushSignals([
107
+ { paths: ['news', '1'], stylesheetHrefs: [] },
108
+ { paths: ['news', '2'], stylesheetHrefs: [] },
109
+ ]);
110
+ await run.block();
111
+ await run.pushPages([
112
+ { html: '<body><main><p>a</p></main></body>' },
113
+ { html: '<body><main><p>b</p></main></body>' },
114
+ ]);
115
+ await run.finishPages();
116
+ const { keys, keyOfPage, reasons } = await run.takeOutput();
117
+ keys[keyOfPage[0]!]; // '["path:news","cluster:0"]'
118
+ ```
119
+
120
+ ブロックは既定で全ページをクラスタリングします。次の 2 つの場合だけ、そのブロックを`TEMPLATE_BLOCK_SAMPLE_SIZE`(`@nitpicker/core/template-block-sample-size`)ページのサンプルでクラスタリングします。
121
+
122
+ - Stage A の類似度表(`n * (n - 1) / 2 * 8`バイト、`n`はブロックのページ数)が`memoryBudget`を超える。`memoryBudget`は`createTemplateClassificationRun`のオプションで、既定は`TEMPLATE_MEMORY_BUDGET`(`@nitpicker/core/template-memory-budget`、512 MiB)、0 なら全ブロックが対象になる。
123
+ - 全ページで計算するブロックの合計が`TEMPLATE_EXACT_PAGE_LIMIT`(`@nitpicker/core/template-exact-page-limit`、20,000 ページ)を超える。この場合は、標本より大きい(100 ページを超える)ブロックを、ページ数が多い順(同数なら出現順)に、収まるまで対象にする。100 ページ以下のブロックはサンプルにしても保持ページが減らないので対象にならず、それだけで上限を超えるときは超えたまま保持する。上限は固定で、利用者は変えられない。
124
+
125
+ 既定の 512 MiB は、約 11,500 ページのブロックまでの類似度表に当たります。1 ページの解析結果は最大で約 60 KB です。同じ切り取りの深さはトークンを共有し、本文は圧縮して持つので、通常はこれより小さくなります。20,000 ページなら最大で約 1.2 GB を、ブロックの計算が終わるまで抱えます。どのブロックが落ちるかで保存される`templateKey`が決まるので、メモリ上限を変えると結果が変わります。既存のアーカイブは、`viewer-build`で再分類するまで以前のキーのままです。
126
+
127
+ サンプルに落としたブロックの残りのページは、2 回目の読み込み(`needsAssignment()`が true のときの`pushAssignmentPages`)で最も近いクラスタに割り当てます。落としたブロックは、`block()`の間に`block-sampled`の進捗イベントで 1 つずつ通知され、`takeOutput()`の`sampledBlocks`にも、理由(`table-over-budget`か`pages-over-limit`)つきで残ります。どちらの読み込みでも全ページをコーパス順に渡しますが、`pageMask()`が 0 のページは HTML を空文字にして構いません(HTML を読み出す必要もありません)。
128
+
129
+ ### スレッドプール・同時実行数・キャンセル
130
+
131
+ 非同期のネイティブ呼び出し(`matchSelectorSnapshots`・`searchHtmlSnapshots`・テンプレート分類の各呼び出し)は、アドオンが持つ専用のスレッドプールで動きます。次の 2 つの環境変数で調整できます。値は正の整数で、プロセスで最初にプールを使うときに 1 回だけ読みます。正の整数でない値は、既定値に置き換えずにエラーにします。
132
+
133
+ | 環境変数 | 既定 | 内容 |
134
+ | ------------------------------ | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
135
+ | `NITPICKER_NATIVE_THREADS` | rayon の既定(`RAYON_NUM_THREADS`、なければ論理コア数) | プールのスレッド数。同期の`computeUrlSortRanks`もこのプールで動く |
136
+ | `NITPICKER_NATIVE_CONCURRENCY` | 1 | 同時に走らせる非同期呼び出しの数。1 つの呼び出しがすでに全スレッドを使うので、増やしても合計の処理量は増えない。同期の`computeUrlSortRanks`はこの上限に数えず、待たずに専用プールを使う |
137
+
138
+ 上限を超えた呼び出しは、拒否せず、受け付けた順(FIFO)に待ちます。待っている間はスレッドを占有しません。`checkNativeExecutorConfig`は、環境変数の誤りを最初のネイティブ呼び出しではなく作業の開始前に検出します。
139
+
140
+ ```ts
141
+ import { checkNativeExecutorConfig } from '@nitpicker/core/check-native-executor-config';
142
+
143
+ checkNativeExecutorConfig(); // NITPICKER_NATIVE_THREADS="lots" なら InvalidArg で投げる
144
+ ```
145
+
146
+ `matchSelectorSnapshots`と`searchHtmlSnapshots`は`signal`(`AbortSignal`)を受け取ります。abort すると、実行中の呼び出しは次のスナップショットの前か展開中のスナップショットの次の 1 MiB を読む前で止まり、待機中の呼び出しは順番が来た時点で終わります。どちらも`signal.reason`で reject します。すでに abort 済みの signal は、アドオンを呼ばずに reject します。同じバッチに展開に失敗したスナップショットがあっても、abort を見たスナップショットが 1 つでもあれば abort(`signal.reason`)を返します。展開のエラーを返すのは、どのスナップショットも abort を見る前に失敗したときだけです。テンプレート分類は、これまでどおり`run.cancel()`で止めます。
147
+
148
+ ```ts
149
+ const controller = new AbortController();
150
+ const pending = matchSelectorSnapshots({ plan, snapshots, signal: controller.signal });
151
+ controller.abort(new Error('client went away'));
152
+ await pending; // rejects with the Error above
153
+ ```
154
+
155
+ ## 開発
156
+
157
+ Rust ツールチェーンが必要です(バージョンはリポジトリルートの`rust-toolchain.toml`で固定。セットアップは[CONTRIBUTING.md](../../../CONTRIBUTING.md)を参照)。`yarn build`を実行すると、`scripts/build-native.mjs`がホスト向けのアドオンをビルドしてパッケージ直下に`core.<platform>.node`として置きます。ローダーはこのローカルビルドを先に探し、なければプラットフォーム別パッケージを読み込みます。
158
+
159
+ - Rust のコードは`crates/`配下の Cargo workspace にあります。napi に依存するのは`nitpicker_napi`(公開関数だけの薄い層)だけで、計算本体の crate は`cargo test`だけでテストできます
160
+ - `yarn test:rust`で Rust のテスト、`yarn lint`で rustfmt と clippy も実行します
161
+ - body hash は既存アーカイブに保存済みの値と byte 単位で一致させています(例外は、本物の body より前に`<bodyguard>`のような`<body`で始まるカスタム要素があるページで、body の開始位置が変わるため値が変わります。#434)。`crates/nitpicker_html_scan/tests/fixtures/body-hash-golden.json`は`@nitpicker/archive`の`src/body-hash/`にある JavaScript 段階実装(parity テストの oracle)から生成した golden fixture です。Rust 側は正規化とマスクを 1 パスにまとめていて、段階版の Rust 関数と一致することを`tests/fused_body_hash_properties.rs`で確かめています
162
+ - 構造シグナルの技術検出(`crates/nitpicker_tech_detect`)は、既存アーカイブの`technology_signals`を書いた JavaScript 正規表現と同じシグナル・同じ順序・同じ evidence を返します。正規表現は JS の`u`フラグなしの意味論に合わせ、`\s`は JS が含める非 ASCII の空白(U+00A0・U+FEFF など)まで並べています。走査は、定義ごとの必須リテラルを Aho-Corasick で 1 回探し、見つかった定義だけを正規表現で確かめます。`tests/fixtures/technology-signals-golden.json`は`@nitpicker/archive`の`src/meta/technologies/structural-signal-definitions.ts`(parity テストの oracle)から`scripts/generate-technology-signals-golden.mjs`で生成した golden fixture です
163
+ - テンプレート分類(`crates/nitpicker_template_cluster`)も、既存アーカイブの`templateKey`と reason に一致させています。HTML は htmlparser2 12.0.0 の挙動を移植した`crates/nitpicker_html_parser`で読み、スタイルシートのホストは呼び出し側の`new URL(href).host`を使います。一致の確認は`@nitpicker/archive`の golden(`src/template-classification/golden/`)で行います
164
+ - URL の順序(`crates/nitpicker_url_key`)は、文字列から数値を取り出して 2 件ずつ比べる方式を採りません。その方式は推移律が崩れる(`a9`は`a10`より前、`a10`は`a1x`より前、`a1x`は`a9`より前になる)ためで、順序はバイト列キーの全順序として定義します。基準は、`tests/order_spec.rs`(期待順序の表)と`tests/order_properties.rs`(入力順不変・キーが同じ URL は生の URL 文字列の順・数値順などのプロパティテスト)です。順位は viewer の read model に保存されるので、順序の定義を変えるときは read model の schema version を上げます
165
+ - alias キー(`crates/nitpicker_url_key`の`alias_keys`)は、既存アーカイブの`content_items.alias_of_id`を作った JavaScript 実装のキーと一致させています。`tests/fixtures/alias-keys-golden.json`は`@nitpicker/archive`の`src/url-alias/`にある JavaScript 実装(parity テストの oracle)から`scripts/generate-alias-keys-golden.mjs`で生成した golden fixture で、キーは URL を書かれたまま(WHATWG URL の`pathname`と`search`)読むので、並べ替え用のキーとは別の読み方です。グルーピングと代表選定は、JavaScript の参照実装と突き合わせる parity テスト(`@nitpicker/archive`の`group-alias-candidates.parity.spec.ts`)で固めています
166
+ - 連結成分(`crates/nitpicker_union_find`)の成分は、辺の順序とバッチ分割に依存しません(`tests/components_properties.rs`)。成分の番号は候補の並び順だけで決まり、read model の`component_id`になるので、候補は page id の昇順で渡します
167
+ - セレクタ照合(`crates/nitpicker_html_scan`の`html_matches_selector`と`scan_snapshots`)の結果は永続化されないので、`docs/query.md`の解釈規則を正解にして書いています。`tag_tokenizer.rs`がタグを読み、direct 段(`run_direct_match.rs`)と開タグスタック(`run_selector_state_machine.rs`)が同じトークナイザを使います。開始タグの属性は、ブラウザのシリアライザの出力を読み戻せるよう HTML のトークナイザと同じ規則で読みます(`start_tag_attributes.rs`。ソースの引用符が欠けたページは属性名に`"`を含んで直列化されるため、引用符の対応だけでは開始タグの終わりを見失います)。正解は`@nitpicker/query`の oracle spec(直列化前の DOM の`querySelector`)です
168
+ - `crates/nitpicker_html_parser`には生成した固定データが 2 つあります。どちらも、リポジトリが依存していない固定版のパッケージを scratch ディレクトリに入れ、そのディレクトリを引数に渡して生成します(手順と理由は各スクリプトの冒頭)
169
+ - エンティティの表`src/html_decode_tree.rs`: `node scripts/generate-html-decode-tree.mjs <entities@8.0.0 のディレクトリ>`。デコードを意図して変えるときだけ再生成します
170
+ - パーサーの比較基準`tests/htmlparser2-fixture-events.tsv`: `node scripts/record-htmlparser2-events.mjs <htmlparser2@12.0.0 のディレクトリ>`。`@nitpicker/archive`の`page-cluster/__fixtures__/`を変えたときに取り直します
39
171
 
40
172
  ## 関連リンク
41
173
 
42
174
  - [Nitpicker README](../../../README.md)
43
- - [CLI analyze docs](../cli/docs/analyze.md)
44
- - [CONTRIBUTING.md](../../../CONTRIBUTING.md)
45
175
  - [ARCHITECTURE.md](../../../ARCHITECTURE.md)
46
176
 
47
177
  ## ライセンス
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Checks that a row id survives being stored in a `u32` array: a typed array
3
+ * wraps `2**32` to `0` and truncates `1.5` to `1`, which here would join the
4
+ * wrong pages without any error.
5
+ * @param value - The id (a database row id).
6
+ * @param what - What the id is, for the error message.
7
+ * @returns `value`, unchanged.
8
+ * @throws {RangeError} When `value` is not an integer in `0..=4294967294`.
9
+ * @example
10
+ * ```ts
11
+ * assertU32Id(7, 'candidate id'); // 7
12
+ * assertU32Id(2 ** 32, 'candidate id'); // throws RangeError
13
+ * ```
14
+ */
15
+ export declare function assertU32Id(value: number, what: string): number;
@@ -0,0 +1,22 @@
1
+ /** The addon reads `4294967295` as "none", so no id may take that value. */
2
+ const MAX_ID = 4_294_967_294;
3
+ /**
4
+ * Checks that a row id survives being stored in a `u32` array: a typed array
5
+ * wraps `2**32` to `0` and truncates `1.5` to `1`, which here would join the
6
+ * wrong pages without any error.
7
+ * @param value - The id (a database row id).
8
+ * @param what - What the id is, for the error message.
9
+ * @returns `value`, unchanged.
10
+ * @throws {RangeError} When `value` is not an integer in `0..=4294967294`.
11
+ * @example
12
+ * ```ts
13
+ * assertU32Id(7, 'candidate id'); // 7
14
+ * assertU32Id(2 ** 32, 'candidate id'); // throws RangeError
15
+ * ```
16
+ */
17
+ export function assertU32Id(value, what) {
18
+ if (!Number.isInteger(value) || value < 0 || value > MAX_ID) {
19
+ throw new RangeError(`${what}: ${value} is not an integer in 0..=${MAX_ID}`);
20
+ }
21
+ return value;
22
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Builds the addon's thread pool from its environment settings now, so an
3
+ * invalid value fails before any work starts instead of at the first native
4
+ * call that needs the pool.
5
+ *
6
+ * - `NITPICKER_NATIVE_THREADS`: threads of the pool. Unset: rayon's default
7
+ * (`RAYON_NUM_THREADS`, else the number of logical cores).
8
+ * - `NITPICKER_NATIVE_CONCURRENCY`: asynchronous native calls (match-selector,
9
+ * search-html, template classification) that run at once. Unset: 1. A call
10
+ * over the limit waits its turn; it is never refused.
11
+ *
12
+ * Both are read once, when the pool is first built; changing them afterwards
13
+ * has no effect in that process.
14
+ * @throws {Error} With code `InvalidArg`, naming the variable and its value,
15
+ * when a setting is not a positive integer.
16
+ * @example
17
+ * ```ts
18
+ * checkNativeExecutorConfig(); // throws before the crawl starts if the env is wrong
19
+ * ```
20
+ */
21
+ export declare function checkNativeExecutorConfig(): void;
@@ -0,0 +1,24 @@
1
+ import { nativeBinding } from './native-binding.js';
2
+ /**
3
+ * Builds the addon's thread pool from its environment settings now, so an
4
+ * invalid value fails before any work starts instead of at the first native
5
+ * call that needs the pool.
6
+ *
7
+ * - `NITPICKER_NATIVE_THREADS`: threads of the pool. Unset: rayon's default
8
+ * (`RAYON_NUM_THREADS`, else the number of logical cores).
9
+ * - `NITPICKER_NATIVE_CONCURRENCY`: asynchronous native calls (match-selector,
10
+ * search-html, template classification) that run at once. Unset: 1. A call
11
+ * over the limit waits its turn; it is never refused.
12
+ *
13
+ * Both are read once, when the pool is first built; changing them afterwards
14
+ * has no effect in that process.
15
+ * @throws {Error} With code `InvalidArg`, naming the variable and its value,
16
+ * when a setting is not a positive integer.
17
+ * @example
18
+ * ```ts
19
+ * checkNativeExecutorConfig(); // throws before the crawl starts if the env is wrong
20
+ * ```
21
+ */
22
+ export function checkNativeExecutorConfig() {
23
+ nativeBinding.checkNativeExecutorConfig();
24
+ }
@@ -0,0 +1,28 @@
1
+ import type { AliasKeys } from './types.js';
2
+ /**
3
+ * Computes the two keys under which pages are grouped as the same page under
4
+ * another URL, parsing `url` once.
5
+ *
6
+ * Both keys read the URL as written (`pathname` and `search` as the WHATWG
7
+ * URL serializes them) and are `host[:port]path[?query]`:
8
+ *
9
+ * - **Tier A** folds what is safe to treat as one resource from the URL
10
+ * alone: the scheme (a default port is left out, so `http` and `https`
11
+ * share a key), the host's case, and a trailing `/index.{ext}`.
12
+ * - **Tier B** is Tier A with one trailing `/` of the path dropped (the path
13
+ * `/` stays): a weaker signal, so a caller confirms it by comparing the
14
+ * page content.
15
+ *
16
+ * The fragment is never part of a key, and an empty query is no query.
17
+ * @param url - The URL string.
18
+ * @returns Both keys, or `null` for a string that does not parse and for a
19
+ * URL that is not `http` or `https` (such a URL is left out of alias
20
+ * grouping).
21
+ * @example
22
+ * ```ts
23
+ * computeAliasKeys('http://Example.com/about/index.html');
24
+ * // { tierA: 'example.com/about/', tierB: 'example.com/about' }
25
+ * computeAliasKeys('mailto:a@example.com'); // null
26
+ * ```
27
+ */
28
+ export declare function computeAliasKeys(url: string): AliasKeys | null;
@@ -0,0 +1,30 @@
1
+ import { nativeBinding } from './native-binding.js';
2
+ /**
3
+ * Computes the two keys under which pages are grouped as the same page under
4
+ * another URL, parsing `url` once.
5
+ *
6
+ * Both keys read the URL as written (`pathname` and `search` as the WHATWG
7
+ * URL serializes them) and are `host[:port]path[?query]`:
8
+ *
9
+ * - **Tier A** folds what is safe to treat as one resource from the URL
10
+ * alone: the scheme (a default port is left out, so `http` and `https`
11
+ * share a key), the host's case, and a trailing `/index.{ext}`.
12
+ * - **Tier B** is Tier A with one trailing `/` of the path dropped (the path
13
+ * `/` stays): a weaker signal, so a caller confirms it by comparing the
14
+ * page content.
15
+ *
16
+ * The fragment is never part of a key, and an empty query is no query.
17
+ * @param url - The URL string.
18
+ * @returns Both keys, or `null` for a string that does not parse and for a
19
+ * URL that is not `http` or `https` (such a URL is left out of alias
20
+ * grouping).
21
+ * @example
22
+ * ```ts
23
+ * computeAliasKeys('http://Example.com/about/index.html');
24
+ * // { tierA: 'example.com/about/', tierB: 'example.com/about' }
25
+ * computeAliasKeys('mailto:a@example.com'); // null
26
+ * ```
27
+ */
28
+ export function computeAliasKeys(url) {
29
+ return nativeBinding.computeAliasKeys(url);
30
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Computes the content hash stored as `page_meta.body_hash`: SHA-256 of the
3
+ * page's `<body>` content after collapsing `/index.{ext}` URL forms to `/`
4
+ * and masking mixed letter-and-digit tokens of 8+ characters (cache-busting
5
+ * hashes, session ids, CSS-module suffixes).
6
+ *
7
+ * Takes UTF-8 bytes rather than a string so the native side never builds a
8
+ * UTF-16 copy. The result is byte-identical to the hashes already stored in
9
+ * existing archives, which JavaScript computed (golden fixture in
10
+ * `crates/nitpicker_html_scan/tests/fixtures/`), so old and new crawls stay
11
+ * comparable.
12
+ * @param html - The full HTML document (or a fragment) as UTF-8 bytes —
13
+ * `Buffer.from(html, 'utf8')`.
14
+ * @returns The 32-byte hash, ready to insert into a `BLOB` column.
15
+ * @example
16
+ * ```ts
17
+ * const a = computeBodyHash(Buffer.from('<body><a href="/p/a1b2c3d4">x</a></body>'));
18
+ * const b = computeBodyHash(Buffer.from('<body><a href="/p/z9y8x7w6">x</a></body>'));
19
+ * a.equals(b); // true — the differing token is masked before hashing
20
+ * ```
21
+ */
22
+ export declare function computeBodyHash(html: Uint8Array): Buffer;
@@ -0,0 +1,25 @@
1
+ import { nativeBinding } from './native-binding.js';
2
+ /**
3
+ * Computes the content hash stored as `page_meta.body_hash`: SHA-256 of the
4
+ * page's `<body>` content after collapsing `/index.{ext}` URL forms to `/`
5
+ * and masking mixed letter-and-digit tokens of 8+ characters (cache-busting
6
+ * hashes, session ids, CSS-module suffixes).
7
+ *
8
+ * Takes UTF-8 bytes rather than a string so the native side never builds a
9
+ * UTF-16 copy. The result is byte-identical to the hashes already stored in
10
+ * existing archives, which JavaScript computed (golden fixture in
11
+ * `crates/nitpicker_html_scan/tests/fixtures/`), so old and new crawls stay
12
+ * comparable.
13
+ * @param html - The full HTML document (or a fragment) as UTF-8 bytes —
14
+ * `Buffer.from(html, 'utf8')`.
15
+ * @returns The 32-byte hash, ready to insert into a `BLOB` column.
16
+ * @example
17
+ * ```ts
18
+ * const a = computeBodyHash(Buffer.from('<body><a href="/p/a1b2c3d4">x</a></body>'));
19
+ * const b = computeBodyHash(Buffer.from('<body><a href="/p/z9y8x7w6">x</a></body>'));
20
+ * a.equals(b); // true — the differing token is masked before hashing
21
+ * ```
22
+ */
23
+ export function computeBodyHash(html) {
24
+ return nativeBinding.computeBodyHash(html);
25
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Ranks URLs in the natural order the viewer lists them in: numbers inside
3
+ * a path compare by value (`page2` before `page10`), letter case and
4
+ * percent-escapes do not split the order, a directory's own page comes
5
+ * before its index pages and its children, and the host decides before the
6
+ * path. The full definition is the `nitpicker_url_key` crate's docs.
7
+ *
8
+ * The order is a total order: the same URLs in any input order get the same
9
+ * ranking, and identical URLs rank in input order. It is persisted (the
10
+ * viewer read model stores each page's rank), so a change to it needs a read
11
+ * model schema version bump.
12
+ *
13
+ * Parses each URL once, natively, and sorts on the native thread pool; only
14
+ * two buffers cross the boundary however many URLs there are.
15
+ * @param urls - The URLs to rank. Each must be a well-formed string (a lone
16
+ * surrogate is written as U+FFFD).
17
+ * @returns For each input position, the rank (zero-based position in the
18
+ * sorted list) of that URL — a permutation of `0..urls.length`.
19
+ * @example
20
+ * ```ts
21
+ * computeUrlSortRanks([
22
+ * 'https://example.com/page10',
23
+ * 'https://example.com/page2',
24
+ * 'https://example.com/',
25
+ * ]); // Uint32Array [2, 1, 0]
26
+ * ```
27
+ */
28
+ export declare function computeUrlSortRanks(urls: readonly string[]): Uint32Array;
@@ -0,0 +1,49 @@
1
+ import { nativeBinding } from './native-binding.js';
2
+ /** The native call addresses the URL bytes with `u32` offsets. */
3
+ const MAX_URL_BYTES = 4_294_967_295;
4
+ /**
5
+ * Ranks URLs in the natural order the viewer lists them in: numbers inside
6
+ * a path compare by value (`page2` before `page10`), letter case and
7
+ * percent-escapes do not split the order, a directory's own page comes
8
+ * before its index pages and its children, and the host decides before the
9
+ * path. The full definition is the `nitpicker_url_key` crate's docs.
10
+ *
11
+ * The order is a total order: the same URLs in any input order get the same
12
+ * ranking, and identical URLs rank in input order. It is persisted (the
13
+ * viewer read model stores each page's rank), so a change to it needs a read
14
+ * model schema version bump.
15
+ *
16
+ * Parses each URL once, natively, and sorts on the native thread pool; only
17
+ * two buffers cross the boundary however many URLs there are.
18
+ * @param urls - The URLs to rank. Each must be a well-formed string (a lone
19
+ * surrogate is written as U+FFFD).
20
+ * @returns For each input position, the rank (zero-based position in the
21
+ * sorted list) of that URL — a permutation of `0..urls.length`.
22
+ * @example
23
+ * ```ts
24
+ * computeUrlSortRanks([
25
+ * 'https://example.com/page10',
26
+ * 'https://example.com/page2',
27
+ * 'https://example.com/',
28
+ * ]); // Uint32Array [2, 1, 0]
29
+ * ```
30
+ */
31
+ export function computeUrlSortRanks(urls) {
32
+ // One encoding pass into a buffer sized for the worst case (3 UTF-8 bytes
33
+ // per UTF-16 unit), rather than measuring every URL and encoding it again.
34
+ let capacity = 0;
35
+ for (const url of urls) {
36
+ capacity += url.length * 3;
37
+ }
38
+ const bytes = Buffer.allocUnsafe(capacity);
39
+ const offsets = new Uint32Array(urls.length + 1);
40
+ let total = 0;
41
+ for (const [index, url] of urls.entries()) {
42
+ total += bytes.write(url, total, 'utf8');
43
+ if (total > MAX_URL_BYTES) {
44
+ throw new RangeError(`The URLs take more than ${MAX_URL_BYTES} bytes (4 GiB), which the native call cannot address.`);
45
+ }
46
+ offsets[index + 1] = total;
47
+ }
48
+ return nativeBinding.computeUrlSortRanks(bytes.subarray(0, total), offsets);
49
+ }
@@ -0,0 +1,35 @@
1
+ import type { IsolatedClusterRun } from './types.js';
2
+ /**
3
+ * Starts the computation of the connected components of the isolated pages:
4
+ * the inventory pages that are linked to only from other inventory pages.
5
+ *
6
+ * The caller reads the link edges (`anchor_edges`) from SQLite batch by batch
7
+ * and hands each batch to {@link IsolatedClusterRun.unionEdges} as it
8
+ * arrives, so the edges are never held: the memory follows the number of pages,
9
+ * not the number of links. An edge to a redirect source or an alias member
10
+ * counts as an edge to the page it finally lands on; a chain that never ends
11
+ * (it loops, or leads into a loop) drops the edge. The components do not
12
+ * depend on the order of the edges or how they were batched.
13
+ *
14
+ * The components are numbered in the order of the candidates: the first
15
+ * candidate's component is `0`, and each later component takes the next
16
+ * number the first time one of its candidates appears. Hand the candidates
17
+ * over in ascending page id, which is the numbering the viewer read model
18
+ * stores as `component_id`.
19
+ * @param candidateIds - The candidate page ids, in the order the components
20
+ * are numbered in; each an integer in `0..=4294967294`.
21
+ * @param redirects - The redirect and alias hops of the whole archive,
22
+ * `[source, destination, source, destination, ...]` by page id. A source
23
+ * listed twice keeps its last hop.
24
+ * @returns The run.
25
+ * @throws {RangeError} When an id is not an integer in `0..=4294967294`
26
+ * (`Uint32Array` would otherwise wrap it silently).
27
+ * @example
28
+ * ```ts
29
+ * // Candidates 10, 20, 30; page 40 redirects to 30 and is not a candidate.
30
+ * const run = createIsolatedClusterRun([10, 20, 30], [40, 30]);
31
+ * run.unionEdges([10, 20, 20, 40]);
32
+ * run.labels(); // Uint32Array [0, 0, 0]
33
+ * ```
34
+ */
35
+ export declare function createIsolatedClusterRun(candidateIds: ArrayLike<number>, redirects: ArrayLike<number>): IsolatedClusterRun;
@@ -0,0 +1,52 @@
1
+ import { nativeBinding } from './native-binding.js';
2
+ import { toU32Ids } from './to-u32-ids.js';
3
+ /**
4
+ * Starts the computation of the connected components of the isolated pages:
5
+ * the inventory pages that are linked to only from other inventory pages.
6
+ *
7
+ * The caller reads the link edges (`anchor_edges`) from SQLite batch by batch
8
+ * and hands each batch to {@link IsolatedClusterRun.unionEdges} as it
9
+ * arrives, so the edges are never held: the memory follows the number of pages,
10
+ * not the number of links. An edge to a redirect source or an alias member
11
+ * counts as an edge to the page it finally lands on; a chain that never ends
12
+ * (it loops, or leads into a loop) drops the edge. The components do not
13
+ * depend on the order of the edges or how they were batched.
14
+ *
15
+ * The components are numbered in the order of the candidates: the first
16
+ * candidate's component is `0`, and each later component takes the next
17
+ * number the first time one of its candidates appears. Hand the candidates
18
+ * over in ascending page id, which is the numbering the viewer read model
19
+ * stores as `component_id`.
20
+ * @param candidateIds - The candidate page ids, in the order the components
21
+ * are numbered in; each an integer in `0..=4294967294`.
22
+ * @param redirects - The redirect and alias hops of the whole archive,
23
+ * `[source, destination, source, destination, ...]` by page id. A source
24
+ * listed twice keeps its last hop.
25
+ * @returns The run.
26
+ * @throws {RangeError} When an id is not an integer in `0..=4294967294`
27
+ * (`Uint32Array` would otherwise wrap it silently).
28
+ * @example
29
+ * ```ts
30
+ * // Candidates 10, 20, 30; page 40 redirects to 30 and is not a candidate.
31
+ * const run = createIsolatedClusterRun([10, 20, 30], [40, 30]);
32
+ * run.unionEdges([10, 20, 20, 40]);
33
+ * run.labels(); // Uint32Array [0, 0, 0]
34
+ * ```
35
+ */
36
+ export function createIsolatedClusterRun(candidateIds, redirects) {
37
+ if (redirects.length % 2 !== 0) {
38
+ throw new RangeError('redirects must hold an even number of ids');
39
+ }
40
+ const native = new nativeBinding.IsolatedClusterRun(toU32Ids(candidateIds, 'candidate ids'), toU32Ids(redirects, 'redirects'));
41
+ return {
42
+ unionEdges(edges) {
43
+ if (edges.length % 2 !== 0) {
44
+ throw new RangeError('edges must hold an even number of ids');
45
+ }
46
+ native.unionEdges(toU32Ids(edges, 'edges'));
47
+ },
48
+ labels() {
49
+ return native.labels();
50
+ },
51
+ };
52
+ }