@d-zero/page-cluster 0.2.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.
- package/README.md +68 -0
- package/dist/array-edit-distance.d.ts +20 -0
- package/dist/array-edit-distance.js +52 -0
- package/dist/build-segment.d.ts +18 -0
- package/dist/build-segment.js +27 -0
- package/dist/cap-content-depth.d.ts +69 -0
- package/dist/cap-content-depth.js +161 -0
- package/dist/compute-document-frequency.d.ts +33 -0
- package/dist/compute-document-frequency.js +40 -0
- package/dist/create-frame.d.ts +16 -0
- package/dist/create-frame.js +29 -0
- package/dist/derive-path-group-key.d.ts +44 -0
- package/dist/derive-path-group-key.js +51 -0
- package/dist/derive-stylesheet-group-key.d.ts +36 -0
- package/dist/derive-stylesheet-group-key.js +41 -0
- package/dist/detect-content-depth-cap.d.ts +114 -0
- package/dist/detect-content-depth-cap.js +137 -0
- package/dist/escape-reg-exp.d.ts +11 -0
- package/dist/escape-reg-exp.js +13 -0
- package/dist/excise.d.ts +13 -0
- package/dist/excise.js +24 -0
- package/dist/extract-landmarks.d.ts +82 -0
- package/dist/extract-landmarks.js +104 -0
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
- package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
- package/dist/find-shallowest-elements.d.ts +39 -0
- package/dist/find-shallowest-elements.js +121 -0
- package/dist/foldable-tags.d.ts +8 -0
- package/dist/foldable-tags.js +8 -0
- package/dist/format-bracket.d.ts +11 -0
- package/dist/format-bracket.js +17 -0
- package/dist/hash-content.d.ts +22 -0
- package/dist/hash-content.js +26 -0
- package/dist/html-region-utils.d.ts +74 -0
- package/dist/html-region-utils.js +96 -0
- package/dist/is-fold-candidate.d.ts +13 -0
- package/dist/is-fold-candidate.js +16 -0
- package/dist/is-genuine-close.d.ts +23 -0
- package/dist/is-genuine-close.js +27 -0
- package/dist/is-noise-class.d.ts +6 -0
- package/dist/is-noise-class.js +8 -0
- package/dist/jaccard-similarity.d.ts +23 -0
- package/dist/jaccard-similarity.js +36 -0
- package/dist/merge-landmark-affined-clusters.d.ts +179 -0
- package/dist/merge-landmark-affined-clusters.js +544 -0
- package/dist/merge-spans.d.ts +15 -0
- package/dist/merge-spans.js +22 -0
- package/dist/noise-class-patterns.d.ts +21 -0
- package/dist/noise-class-patterns.js +74 -0
- package/dist/normalize-for-hash.d.ts +10 -0
- package/dist/normalize-for-hash.js +12 -0
- package/dist/opaque-tags.d.ts +17 -0
- package/dist/opaque-tags.js +18 -0
- package/dist/parse-class-list.d.ts +10 -0
- package/dist/parse-class-list.js +23 -0
- package/dist/reassign-orphan-block-keys.d.ts +81 -0
- package/dist/reassign-orphan-block-keys.js +159 -0
- package/dist/remove-content-blocks.d.ts +67 -0
- package/dist/remove-content-blocks.js +150 -0
- package/dist/resolve-blocking-group-keys.d.ts +116 -0
- package/dist/resolve-blocking-group-keys.js +120 -0
- package/dist/resolve-closed-frame.d.ts +26 -0
- package/dist/resolve-closed-frame.js +33 -0
- package/dist/resolve-landmark-variant-keys.d.ts +66 -0
- package/dist/resolve-landmark-variant-keys.js +71 -0
- package/dist/resolve-options.d.ts +6 -0
- package/dist/resolve-options.js +10 -0
- package/dist/resolve-page-cluster-keys.d.ts +222 -0
- package/dist/resolve-page-cluster-keys.js +198 -0
- package/dist/resolve-structural-cluster-keys.d.ts +50 -0
- package/dist/resolve-structural-cluster-keys.js +287 -0
- package/dist/run-tokenizer.d.ts +33 -0
- package/dist/run-tokenizer.js +152 -0
- package/dist/split-tokens-by-frequency.d.ts +46 -0
- package/dist/split-tokens-by-frequency.js +88 -0
- package/dist/tokenize.d.ts +58 -0
- package/dist/tokenize.js +60 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.js +1 -0
- package/package.json +102 -0
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# `@d-zero/page-cluster`
|
|
2
|
+
|
|
3
|
+
大量クローリングした HTML の重複・類似ページ検出のためのパッケージ。`tokenize()` は `<body>` 配下のHTMLを、テキストを除去した構造トークンに変換する。用途・設計判断のWHYは `src/tokenize.ts` の JSDoc を参照。
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
yarn add @d-zero/page-cluster
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { tokenize } from '@d-zero/page-cluster';
|
|
15
|
+
|
|
16
|
+
const { tokens, bodyClassList } = tokenize(
|
|
17
|
+
'<body class="law-page"><div class="card"><ul><li>A</li><li>B</li></ul></div></body>',
|
|
18
|
+
);
|
|
19
|
+
// tokens: ["body>.card>ul>li", "body>.card>ul>li"]
|
|
20
|
+
// bodyClassList: ["law-page"]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### オプション
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
tokenize(html, {
|
|
27
|
+
filterNoiseClasses: true, // 既定値。ハッシュ的自動生成class名を除外する
|
|
28
|
+
includeComments: false, // 既定値。コメントノードをトークン化しない
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### クラスタリング
|
|
33
|
+
|
|
34
|
+
クロールしたページ群から最終的なクラスタキーを得るには `resolvePageClusterKeys()` を使う。ブロッキング(URLパス/スタイルシートによる粗い絞り込み)と構造クラスタリング(ブロック内でのcomplete-linkage階層的クラスタリング)を内部で連結し、ブロックを跨いで一意なキーを返す。既定で各ページの`<header>`/`<footer>`/`<nav>`/`<aside>`(タグ名またはARIAランドマークロール)を比較対象から除外し、共通chromeの影響を受けにくくする。また、スタイルシート参照が記録されていない「孤児」ページを、同一URLセクションに閉じたスタイルシート・ブロックへ再割当する処理(`reassignOrphanBlockKeys()`)や、埋め込みコンテンツが引き込むサードパーティCSS参照をブロッキング判定から除外する処理(`filterFirstPartyStylesheetHrefs()`)も既定で有効。挙動の詳細・トレードオフはそれぞれのJSDocを参照。
|
|
35
|
+
|
|
36
|
+
自由編集ブロックエディタ(CMSが各コンテンツブロックに固有のdata属性を付与するタイプ)を使うサイトでは、`contentBlockAttribute` オプションでその属性名を指定すると、ページごとに異なるブロック構成が構造比較のノイズになるのを防げる(既定は未指定=無効、サイトごとの属性名を推測できないため)。詳細は `removeContentBlocks()` のJSDocを参照。
|
|
37
|
+
|
|
38
|
+
CMSのブロック属性名が分からない・サイトごとに違う場合は `autoCapMainDepth: true` を使う。`<main>`/`role="main"`という標準タグを起点に、構造クラスタ数が急増する直前の深さをブロックごとに実データから自動検出して打ち切るため、サイト固有の設定が一切不要(既定はfalse。実データ検証では`contentBlockAttribute`より良い結果になる場合もあった一方、計算コストが実測で数倍〜1桁台後半になる。倍率はコーパスのブロック構成に依存する)。詳細は `detectContentDepthCap()` のJSDocを参照。
|
|
39
|
+
|
|
40
|
+
header/footer/nav/asideが一致するページ同士をさらに合流させたい場合は `mergeRareLandmarkClusters: true` を使う。ただし単純な一致判定は実データで過剰融合を招くことが分かっているため(header/footer/navは99%以上のページに存在し判別力を持たない)、コーパス全体で希少なランドマークバリアントが一致した場合に限り、より緩いコンテンツ類似度閾値(`landmarkGateSimilarityThreshold`)での合流を許可する(既定はfalse。実データでの検証は未実施で、合成フィクスチャでの単体・回帰テストのみ)。詳細・コスト特性は `mergeLandmarkAffinedClusters()` のJSDocを参照。
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { resolvePageClusterKeys } from '@d-zero/page-cluster/resolve-page-cluster-keys';
|
|
44
|
+
|
|
45
|
+
const keys = resolvePageClusterKeys(
|
|
46
|
+
pages.map((page) => ({
|
|
47
|
+
paths: page.urlPathSegments,
|
|
48
|
+
stylesheetHrefs: page.stylesheetHrefs,
|
|
49
|
+
html: page.html,
|
|
50
|
+
})),
|
|
51
|
+
{ contentBlockAttribute: 'data-bgb' }, // 使っているCMSのブロック属性名に合わせて指定
|
|
52
|
+
);
|
|
53
|
+
// pagesと同じ順序・同じ長さ。同じキーのページが同一テンプレートと判定されたページ群
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### ランドマークバリアント分類
|
|
57
|
+
|
|
58
|
+
「同一テンプレートか」ではなく「このページはどのヘッダー/フッター/ナビ/サイドナビを持っているか」というメタプロパティを個別に知りたい場合は `resolveLandmarkVariantKeys()` を使う。`resolvePageClusterKeys()` とは独立した戻り値で、両者の合成は呼び出し側の責務。
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { resolveLandmarkVariantKeys } from '@d-zero/page-cluster/resolve-landmark-variant-keys';
|
|
62
|
+
|
|
63
|
+
const headerVariantKeys = resolveLandmarkVariantKeys(
|
|
64
|
+
pages.map((page) => page.html),
|
|
65
|
+
'header',
|
|
66
|
+
);
|
|
67
|
+
// pagesと同じ順序・同じ長さ。同じキーのページが同じヘッダーデザインを持つページ群
|
|
68
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element-wise Levenshtein distance between two `tokenize()` outputs, for
|
|
3
|
+
* the small set of comparisons that need order/nesting to matter (refining
|
|
4
|
+
* a merge distance between near-duplicate candidates, spot-checking cluster
|
|
5
|
+
* quality) rather than the set-based similarity used for bulk narrowing.
|
|
6
|
+
* Operates on whole array elements, not characters, so a single differing
|
|
7
|
+
* path costs 1 edit regardless of its string length. This is the same
|
|
8
|
+
* O(n*m) dynamic-programming shape as tree edit distance, but requires no
|
|
9
|
+
* tree: run directly on the flat leaf-path arrays `tokenize()` already
|
|
10
|
+
* produces, which is why it stays viable at the scale this comparison is
|
|
11
|
+
* meant for (small numbers of already-narrowed candidates, not all-pairs).
|
|
12
|
+
* @param a
|
|
13
|
+
* @param b
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* arrayEditDistance(['body>ul>li', 'body>ul>li'], ['body>ul>li']);
|
|
17
|
+
* // 1
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function arrayEditDistance(a: readonly string[], b: readonly string[]): number;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads `row[index]`, throwing instead of returning `undefined`. The DP loop
|
|
3
|
+
* below only ever indexes within bounds it just built, so the thrown branch
|
|
4
|
+
* is unreachable in practice; it exists to satisfy `noUncheckedIndexedAccess`
|
|
5
|
+
* without a non-null assertion. Named `readDpValue` rather than `at` to avoid
|
|
6
|
+
* reading as (and being confused for) `Array.prototype.at`, whose negative-
|
|
7
|
+
* index-from-end semantics this helper does not share.
|
|
8
|
+
* @param row
|
|
9
|
+
* @param index
|
|
10
|
+
*/
|
|
11
|
+
function readDpValue(row, index) {
|
|
12
|
+
const value = row[index];
|
|
13
|
+
if (value === undefined) {
|
|
14
|
+
throw new Error('arrayEditDistance: DP row index out of bounds');
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Element-wise Levenshtein distance between two `tokenize()` outputs, for
|
|
20
|
+
* the small set of comparisons that need order/nesting to matter (refining
|
|
21
|
+
* a merge distance between near-duplicate candidates, spot-checking cluster
|
|
22
|
+
* quality) rather than the set-based similarity used for bulk narrowing.
|
|
23
|
+
* Operates on whole array elements, not characters, so a single differing
|
|
24
|
+
* path costs 1 edit regardless of its string length. This is the same
|
|
25
|
+
* O(n*m) dynamic-programming shape as tree edit distance, but requires no
|
|
26
|
+
* tree: run directly on the flat leaf-path arrays `tokenize()` already
|
|
27
|
+
* produces, which is why it stays viable at the scale this comparison is
|
|
28
|
+
* meant for (small numbers of already-narrowed candidates, not all-pairs).
|
|
29
|
+
* @param a
|
|
30
|
+
* @param b
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* arrayEditDistance(['body>ul>li', 'body>ul>li'], ['body>ul>li']);
|
|
34
|
+
* // 1
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function arrayEditDistance(a, b) {
|
|
38
|
+
const rowCount = a.length;
|
|
39
|
+
const colCount = b.length;
|
|
40
|
+
let previousRow = Array.from({ length: colCount + 1 }, (_, index) => index);
|
|
41
|
+
for (let row = 1; row <= rowCount; row++) {
|
|
42
|
+
const currentRow = [row];
|
|
43
|
+
for (let col = 1; col <= colCount; col++) {
|
|
44
|
+
currentRow.push(a[row - 1] === b[col - 1]
|
|
45
|
+
? readDpValue(previousRow, col - 1)
|
|
46
|
+
: 1 +
|
|
47
|
+
Math.min(readDpValue(previousRow, col), readDpValue(currentRow, col - 1), readDpValue(previousRow, col - 1)));
|
|
48
|
+
}
|
|
49
|
+
previousRow = currentRow;
|
|
50
|
+
}
|
|
51
|
+
return readDpValue(previousRow, colCount);
|
|
52
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the path segment string for one element.
|
|
3
|
+
*
|
|
4
|
+
* `div`/`span` drop their tag name in favor of `.class` when they carry a
|
|
5
|
+
* class list, since the class (not the generic wrapper tag) is what carries
|
|
6
|
+
* meaning for those two tags. Every other tag always keeps its name because
|
|
7
|
+
* the tag itself is semantically meaningful (e.g. `ul`, `table`, `button`).
|
|
8
|
+
*
|
|
9
|
+
* `role`/`type` are appended as a bracket suffix rather than folded into the
|
|
10
|
+
* class list: unlike `class`, they are single-valued attributes with their
|
|
11
|
+
* own semantics (ARIA role, form control kind), so keeping them visually
|
|
12
|
+
* distinct avoids collisions with an actual class named e.g. `button`.
|
|
13
|
+
* @param tagName
|
|
14
|
+
* @param classList
|
|
15
|
+
* @param role
|
|
16
|
+
* @param type
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildSegment(tagName: string, classList: readonly string[], role?: string, type?: string): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { FOLDABLE_TAGS } from './foldable-tags.js';
|
|
2
|
+
import { formatBracket } from './format-bracket.js';
|
|
3
|
+
/**
|
|
4
|
+
* Builds the path segment string for one element.
|
|
5
|
+
*
|
|
6
|
+
* `div`/`span` drop their tag name in favor of `.class` when they carry a
|
|
7
|
+
* class list, since the class (not the generic wrapper tag) is what carries
|
|
8
|
+
* meaning for those two tags. Every other tag always keeps its name because
|
|
9
|
+
* the tag itself is semantically meaningful (e.g. `ul`, `table`, `button`).
|
|
10
|
+
*
|
|
11
|
+
* `role`/`type` are appended as a bracket suffix rather than folded into the
|
|
12
|
+
* class list: unlike `class`, they are single-valued attributes with their
|
|
13
|
+
* own semantics (ARIA role, form control kind), so keeping them visually
|
|
14
|
+
* distinct avoids collisions with an actual class named e.g. `button`.
|
|
15
|
+
* @param tagName
|
|
16
|
+
* @param classList
|
|
17
|
+
* @param role
|
|
18
|
+
* @param type
|
|
19
|
+
*/
|
|
20
|
+
export function buildSegment(tagName, classList, role, type) {
|
|
21
|
+
const base = classList.length > 0
|
|
22
|
+
? FOLDABLE_TAGS.has(tagName)
|
|
23
|
+
? `.${classList.join('.')}`
|
|
24
|
+
: `${tagName}.${classList.join('.')}`
|
|
25
|
+
: tagName;
|
|
26
|
+
return `${base}${formatBracket({ role, type })}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only landmark this function knows how to depth-cap. A closed union
|
|
3
|
+
* (not an open `string`) because, unlike
|
|
4
|
+
* {@link ./remove-content-blocks.js | removeContentBlocks}'s caller-supplied
|
|
5
|
+
* CMS attribute, `<main>`/`role="main"` is an HTML5/ARIA standard — there is
|
|
6
|
+
* exactly one vocabulary to support, not one per site.
|
|
7
|
+
*/
|
|
8
|
+
export type ContentDepthLandmark = 'main';
|
|
9
|
+
/**
|
|
10
|
+
* @see capContentDepth
|
|
11
|
+
*/
|
|
12
|
+
export type CapContentDepthOptions = {
|
|
13
|
+
landmark: ContentDepthLandmark;
|
|
14
|
+
/**
|
|
15
|
+
* How many levels of elements *inside* the landmark to keep, counting the
|
|
16
|
+
* landmark's own direct children as depth 1. Must be a non-negative
|
|
17
|
+
* integer (0 keeps none of the landmark's content, only its own opening/
|
|
18
|
+
* closing tags). See {@link ./cap-content-depth.js | capContentDepth}'s
|
|
19
|
+
* JSDoc for how to choose this.
|
|
20
|
+
*/
|
|
21
|
+
maxDepth: number;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Result of {@link ./cap-content-depth.js | capContentDepth}.
|
|
25
|
+
*/
|
|
26
|
+
export type CapContentDepthResult = {
|
|
27
|
+
remainderHtml: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Excises the deepest content inside `options.landmark` (currently only
|
|
31
|
+
* `'main'`/`role="main"` is supported — see `ContentDepthLandmark`'s JSDoc),
|
|
32
|
+
* keeping up to `options.maxDepth` levels of nesting and returning what's
|
|
33
|
+
* left.
|
|
34
|
+
*
|
|
35
|
+
* Built for the same real-crawl finding {@link ./remove-content-blocks.js |
|
|
36
|
+
* removeContentBlocks} addresses — freeform CMS-block content dominating a
|
|
37
|
+
* page's token set and defeating structural similarity — but without
|
|
38
|
+
* needing the caller to know their CMS's own block-marker attribute.
|
|
39
|
+
* `<main>` is HTML5-standard, so this works without any per-site
|
|
40
|
+
* configuration at all. Confirmed on two unrelated real crawls (302 and
|
|
41
|
+
* ~4,100 pages): once nesting depth inside `<main>` passes a threshold
|
|
42
|
+
* (3, on both), the number of distinct structural clusters explodes (14x
|
|
43
|
+
* and 9x, respectively) — the "skeleton" (which template a page uses) lives
|
|
44
|
+
* in the shallow levels; the free-edited content that varies page-to-page
|
|
45
|
+
* lives deeper. See {@link ./detect-content-depth-cap.js |
|
|
46
|
+
* detectContentDepthCap} to find that threshold automatically instead of
|
|
47
|
+
* hardcoding `maxDepth`.
|
|
48
|
+
*
|
|
49
|
+
* `<main>`'s own opening/closing tags (and their attributes, e.g. a class
|
|
50
|
+
* that itself differs between a list-page and a detail-page `<main>`) are
|
|
51
|
+
* always kept — only what's *between* them past `maxDepth` is excised, for
|
|
52
|
+
* the same reason `extractLandmarks` never adds a placeholder: the tag
|
|
53
|
+
* itself is real structural signal, not something to erase.
|
|
54
|
+
*
|
|
55
|
+
* A page with no `<main>` and no `role="main"` element has nothing to cap;
|
|
56
|
+
* `remainderHtml` is returned unchanged, matching this package's convention
|
|
57
|
+
* for "nothing to do" (see `extractLandmarks`, `removeContentBlocks`).
|
|
58
|
+
* @param html
|
|
59
|
+
* @param options
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* capContentDepth(
|
|
63
|
+
* '<body><main><div><div><div><div>too deep</div></div></div></div></main></body>',
|
|
64
|
+
* { landmark: 'main', maxDepth: 2 },
|
|
65
|
+
* );
|
|
66
|
+
* // { remainderHtml: '<body><main><div><div></div></div></main></body>' }
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function capContentDepth(html: string, options: CapContentDepthOptions): CapContentDepthResult;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Parser } from 'htmlparser2';
|
|
2
|
+
import { excise } from './excise.js';
|
|
3
|
+
import { findShallowestElements } from './find-shallowest-elements.js';
|
|
4
|
+
import { isGenuineClose } from './is-genuine-close.js';
|
|
5
|
+
import { isOpaqueTagName } from './opaque-tags.js';
|
|
6
|
+
const TAG_TO_LANDMARK = { main: 'main' };
|
|
7
|
+
const ROLE_TO_LANDMARK = { main: 'main' };
|
|
8
|
+
/**
|
|
9
|
+
* Finds the single shallowest `<main>`/`role="main"` element in `html` via
|
|
10
|
+
* {@link ./find-shallowest-elements.js | findShallowestElements} (same
|
|
11
|
+
* "shallowest wins" rule as {@link ./extract-landmarks.js | extractLandmarks}
|
|
12
|
+
* uses for its own four landmark types, for the same reason: the site-wide,
|
|
13
|
+
* outermost instance is the real one), and returns the offsets of its
|
|
14
|
+
* content (excluding its own opening/closing tags) — or `undefined` if there
|
|
15
|
+
* is no genuine one.
|
|
16
|
+
* @param html
|
|
17
|
+
* @param landmark
|
|
18
|
+
*/
|
|
19
|
+
function findShallowestLandmarkContent(html, landmark) {
|
|
20
|
+
const [winner] = findShallowestElements(html, (name, role) => TAG_TO_LANDMARK[name] === landmark ||
|
|
21
|
+
(role !== undefined && ROLE_TO_LANDMARK[role] === landmark)
|
|
22
|
+
? [landmark]
|
|
23
|
+
: []);
|
|
24
|
+
return winner
|
|
25
|
+
? { contentStart: winner.contentStart, contentEnd: winner.contentEnd }
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Within `html.slice(contentStart, contentEnd)`, finds every element whose
|
|
30
|
+
* nesting depth (the landmark's own direct children are depth 1) exceeds
|
|
31
|
+
* `maxDepth`, and returns their `[start, end)` spans (absolute offsets into
|
|
32
|
+
* the original `html`) for excision. Once a too-deep element is found, its
|
|
33
|
+
* subtree is not explored further — same reasoning as
|
|
34
|
+
* {@link ./remove-content-blocks.js | removeContentBlocks} not diving into
|
|
35
|
+
* an already-matched block: nothing inside a span already marked for
|
|
36
|
+
* removal needs its own depth checked.
|
|
37
|
+
* @param html
|
|
38
|
+
* @param contentStart
|
|
39
|
+
* @param contentEnd
|
|
40
|
+
* @param maxDepth
|
|
41
|
+
*/
|
|
42
|
+
function collectDeepSpans(html, contentStart, contentEnd, maxDepth) {
|
|
43
|
+
const spans = [];
|
|
44
|
+
const stack = [];
|
|
45
|
+
let opaque = null;
|
|
46
|
+
// Set once a too-deep element opens; cleared when that same element
|
|
47
|
+
// closes. While set, every nested open/close (other than matching
|
|
48
|
+
// closes of the capped tag itself) is ignored, same shape as the
|
|
49
|
+
// `opaque` tracking above.
|
|
50
|
+
let cappedAt = null;
|
|
51
|
+
const parser = new Parser({
|
|
52
|
+
onopentag(name) {
|
|
53
|
+
if (opaque) {
|
|
54
|
+
if (name === opaque.tagName)
|
|
55
|
+
opaque.depth++;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (cappedAt) {
|
|
59
|
+
if (name === cappedAt.tagName)
|
|
60
|
+
cappedAt.depth++;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (isOpaqueTagName(name)) {
|
|
64
|
+
opaque = { tagName: name, depth: 1 };
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const depth = stack.length + 1;
|
|
68
|
+
if (depth > maxDepth) {
|
|
69
|
+
cappedAt = { tagName: name, depth: 1 };
|
|
70
|
+
spans.push({ start: contentStart + parser.startIndex, end: -1 });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
stack.push({ tagName: name, startOffset: parser.startIndex });
|
|
74
|
+
},
|
|
75
|
+
onclosetag(name) {
|
|
76
|
+
if (opaque) {
|
|
77
|
+
if (name === opaque.tagName) {
|
|
78
|
+
opaque.depth--;
|
|
79
|
+
if (opaque.depth === 0)
|
|
80
|
+
opaque = null;
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (cappedAt) {
|
|
85
|
+
if (name === cappedAt.tagName) {
|
|
86
|
+
cappedAt.depth--;
|
|
87
|
+
if (cappedAt.depth === 0) {
|
|
88
|
+
const endOffset = contentStart + parser.endIndex + 1;
|
|
89
|
+
const open = spans.at(-1);
|
|
90
|
+
if (open) {
|
|
91
|
+
open.end = isGenuineClose(html, endOffset, name) ? endOffset : open.start;
|
|
92
|
+
}
|
|
93
|
+
cappedAt = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const frame = stack.pop();
|
|
99
|
+
if (!frame)
|
|
100
|
+
return;
|
|
101
|
+
},
|
|
102
|
+
}, { decodeEntities: false });
|
|
103
|
+
parser.end(html.slice(contentStart, contentEnd));
|
|
104
|
+
// A capped span whose genuine close was never confirmed (malformed
|
|
105
|
+
// markup) collapses to a zero-length span at its own start — excise()
|
|
106
|
+
// treats start === end as a no-op slice, so nothing is corrupted, and
|
|
107
|
+
// that one candidate is simply not removed, the same safety trade-off
|
|
108
|
+
// extractLandmarks/removeContentBlocks make for unclosed tags.
|
|
109
|
+
return spans.filter((span) => span.end > span.start);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Excises the deepest content inside `options.landmark` (currently only
|
|
113
|
+
* `'main'`/`role="main"` is supported — see `ContentDepthLandmark`'s JSDoc),
|
|
114
|
+
* keeping up to `options.maxDepth` levels of nesting and returning what's
|
|
115
|
+
* left.
|
|
116
|
+
*
|
|
117
|
+
* Built for the same real-crawl finding {@link ./remove-content-blocks.js |
|
|
118
|
+
* removeContentBlocks} addresses — freeform CMS-block content dominating a
|
|
119
|
+
* page's token set and defeating structural similarity — but without
|
|
120
|
+
* needing the caller to know their CMS's own block-marker attribute.
|
|
121
|
+
* `<main>` is HTML5-standard, so this works without any per-site
|
|
122
|
+
* configuration at all. Confirmed on two unrelated real crawls (302 and
|
|
123
|
+
* ~4,100 pages): once nesting depth inside `<main>` passes a threshold
|
|
124
|
+
* (3, on both), the number of distinct structural clusters explodes (14x
|
|
125
|
+
* and 9x, respectively) — the "skeleton" (which template a page uses) lives
|
|
126
|
+
* in the shallow levels; the free-edited content that varies page-to-page
|
|
127
|
+
* lives deeper. See {@link ./detect-content-depth-cap.js |
|
|
128
|
+
* detectContentDepthCap} to find that threshold automatically instead of
|
|
129
|
+
* hardcoding `maxDepth`.
|
|
130
|
+
*
|
|
131
|
+
* `<main>`'s own opening/closing tags (and their attributes, e.g. a class
|
|
132
|
+
* that itself differs between a list-page and a detail-page `<main>`) are
|
|
133
|
+
* always kept — only what's *between* them past `maxDepth` is excised, for
|
|
134
|
+
* the same reason `extractLandmarks` never adds a placeholder: the tag
|
|
135
|
+
* itself is real structural signal, not something to erase.
|
|
136
|
+
*
|
|
137
|
+
* A page with no `<main>` and no `role="main"` element has nothing to cap;
|
|
138
|
+
* `remainderHtml` is returned unchanged, matching this package's convention
|
|
139
|
+
* for "nothing to do" (see `extractLandmarks`, `removeContentBlocks`).
|
|
140
|
+
* @param html
|
|
141
|
+
* @param options
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* capContentDepth(
|
|
145
|
+
* '<body><main><div><div><div><div>too deep</div></div></div></div></main></body>',
|
|
146
|
+
* { landmark: 'main', maxDepth: 2 },
|
|
147
|
+
* );
|
|
148
|
+
* // { remainderHtml: '<body><main><div><div></div></div></main></body>' }
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export function capContentDepth(html, options) {
|
|
152
|
+
if (!(Number.isInteger(options.maxDepth) && options.maxDepth >= 0)) {
|
|
153
|
+
throw new RangeError(`capContentDepth: maxDepth must be a non-negative integer, got ${options.maxDepth}`);
|
|
154
|
+
}
|
|
155
|
+
const content = findShallowestLandmarkContent(html, options.landmark);
|
|
156
|
+
if (!content) {
|
|
157
|
+
return { remainderHtml: html };
|
|
158
|
+
}
|
|
159
|
+
const spans = collectDeepSpans(html, content.contentStart, content.contentEnd, options.maxDepth);
|
|
160
|
+
return { remainderHtml: excise(html, spans) };
|
|
161
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DocumentFrequency } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Counts, for each token, how many of the given per-page token sets contain
|
|
4
|
+
* it. This is the first half of separating a page's shared site chrome
|
|
5
|
+
* (header/nav/footer) from its page-specific content: a token that recurs
|
|
6
|
+
* across nearly every page in `tokenSets` is chrome, one that shows up on
|
|
7
|
+
* only a handful of pages is content — see `splitTokensByFrequency`, which
|
|
8
|
+
* consumes this result to make that call per token.
|
|
9
|
+
*
|
|
10
|
+
* `tokenSets` must be a *homogeneous* page collection (typically one site,
|
|
11
|
+
* or one section of a large multi-template site), not an arbitrary pool.
|
|
12
|
+
* Real-data validation against a small single-layout corporate site (a few
|
|
13
|
+
* hundred pages) found a clean bimodal frequency split (site chrome tokens
|
|
14
|
+
* showed up on 95%+ of pages, content tokens on well under 50%, with
|
|
15
|
+
* nothing in between). The same computation against the *whole* crawl of a
|
|
16
|
+
* much larger site that turned out to be a federation of independent
|
|
17
|
+
* sub-sections (the largest covering under half of all pages) found no
|
|
18
|
+
* token crossing even a 50% document-frequency threshold: with no single
|
|
19
|
+
* dominant layout, frequency-based chrome detection needs a mostly-
|
|
20
|
+
* homogeneous input to work at all. Splitting such a site into its
|
|
21
|
+
* sections first (by URL path, or by a coarse structural clustering pass)
|
|
22
|
+
* and calling this function per section recovered the same clean bimodal
|
|
23
|
+
* split. Grouping heterogeneous pages before calling this function is the
|
|
24
|
+
* caller's responsibility; this function has no way to detect that its
|
|
25
|
+
* input mixes multiple layouts.
|
|
26
|
+
* @param tokenSets
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* computeDocumentFrequency([new Set(['body>header>a']), new Set(['body>header>a'])]);
|
|
30
|
+
* // { documentFrequency: Map { 'body>header>a' => 2 }, pageCount: 2 }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function computeDocumentFrequency(tokenSets: readonly ReadonlySet<string>[]): DocumentFrequency;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts, for each token, how many of the given per-page token sets contain
|
|
3
|
+
* it. This is the first half of separating a page's shared site chrome
|
|
4
|
+
* (header/nav/footer) from its page-specific content: a token that recurs
|
|
5
|
+
* across nearly every page in `tokenSets` is chrome, one that shows up on
|
|
6
|
+
* only a handful of pages is content — see `splitTokensByFrequency`, which
|
|
7
|
+
* consumes this result to make that call per token.
|
|
8
|
+
*
|
|
9
|
+
* `tokenSets` must be a *homogeneous* page collection (typically one site,
|
|
10
|
+
* or one section of a large multi-template site), not an arbitrary pool.
|
|
11
|
+
* Real-data validation against a small single-layout corporate site (a few
|
|
12
|
+
* hundred pages) found a clean bimodal frequency split (site chrome tokens
|
|
13
|
+
* showed up on 95%+ of pages, content tokens on well under 50%, with
|
|
14
|
+
* nothing in between). The same computation against the *whole* crawl of a
|
|
15
|
+
* much larger site that turned out to be a federation of independent
|
|
16
|
+
* sub-sections (the largest covering under half of all pages) found no
|
|
17
|
+
* token crossing even a 50% document-frequency threshold: with no single
|
|
18
|
+
* dominant layout, frequency-based chrome detection needs a mostly-
|
|
19
|
+
* homogeneous input to work at all. Splitting such a site into its
|
|
20
|
+
* sections first (by URL path, or by a coarse structural clustering pass)
|
|
21
|
+
* and calling this function per section recovered the same clean bimodal
|
|
22
|
+
* split. Grouping heterogeneous pages before calling this function is the
|
|
23
|
+
* caller's responsibility; this function has no way to detect that its
|
|
24
|
+
* input mixes multiple layouts.
|
|
25
|
+
* @param tokenSets
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* computeDocumentFrequency([new Set(['body>header>a']), new Set(['body>header>a'])]);
|
|
29
|
+
* // { documentFrequency: Map { 'body>header>a' => 2 }, pageCount: 2 }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function computeDocumentFrequency(tokenSets) {
|
|
33
|
+
const documentFrequency = new Map();
|
|
34
|
+
for (const tokens of tokenSets) {
|
|
35
|
+
for (const token of tokens) {
|
|
36
|
+
documentFrequency.set(token, (documentFrequency.get(token) ?? 0) + 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { documentFrequency, pageCount: tokenSets.length };
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Frame, ResolvedOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds the stack frame for a newly-opened element. `id`/`data-*`/every
|
|
4
|
+
* `aria-*` other than `role` are read from `attribs` implicitly by never
|
|
5
|
+
* being looked at: `id`/`data-*` tend to be per-instance/per-page values
|
|
6
|
+
* (breaking similarity detection the same way raw text would), and
|
|
7
|
+
* non-`role` `aria-*` attributes are either free-text (`aria-label`,
|
|
8
|
+
* `aria-describedby` — inconsistent with dropping visible text) or render
|
|
9
|
+
* state (`aria-current`, `aria-expanded`, `aria-selected` — the same
|
|
10
|
+
* per-page-varying-position problem as a `current`/`active` class, see
|
|
11
|
+
* `tokenize.ts`).
|
|
12
|
+
* @param tagName
|
|
13
|
+
* @param attribs
|
|
14
|
+
* @param options
|
|
15
|
+
*/
|
|
16
|
+
export declare function createFrame(tagName: string, attribs: Record<string, string>, options: ResolvedOptions): Frame;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { buildSegment } from './build-segment.js';
|
|
2
|
+
import { isFoldCandidate } from './is-fold-candidate.js';
|
|
3
|
+
import { parseClassList } from './parse-class-list.js';
|
|
4
|
+
/**
|
|
5
|
+
* Builds the stack frame for a newly-opened element. `id`/`data-*`/every
|
|
6
|
+
* `aria-*` other than `role` are read from `attribs` implicitly by never
|
|
7
|
+
* being looked at: `id`/`data-*` tend to be per-instance/per-page values
|
|
8
|
+
* (breaking similarity detection the same way raw text would), and
|
|
9
|
+
* non-`role` `aria-*` attributes are either free-text (`aria-label`,
|
|
10
|
+
* `aria-describedby` — inconsistent with dropping visible text) or render
|
|
11
|
+
* state (`aria-current`, `aria-expanded`, `aria-selected` — the same
|
|
12
|
+
* per-page-varying-position problem as a `current`/`active` class, see
|
|
13
|
+
* `tokenize.ts`).
|
|
14
|
+
* @param tagName
|
|
15
|
+
* @param attribs
|
|
16
|
+
* @param options
|
|
17
|
+
*/
|
|
18
|
+
export function createFrame(tagName, attribs, options) {
|
|
19
|
+
const classList = parseClassList(attribs.class, options.filterNoiseClasses);
|
|
20
|
+
const role = attribs.role || undefined;
|
|
21
|
+
const type = attribs.type || undefined;
|
|
22
|
+
return {
|
|
23
|
+
tagName,
|
|
24
|
+
segment: buildSegment(tagName, classList, role, type),
|
|
25
|
+
isFoldCandidate: isFoldCandidate(tagName, classList, role, type),
|
|
26
|
+
childElementCount: 0,
|
|
27
|
+
pendingPaths: [],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a coarse grouping key from a page's URL path segments (e.g. the
|
|
3
|
+
* `paths` field of `@d-zero/shared/parse-url`'s `ExURL`), keeping only the
|
|
4
|
+
* leading `depth` segments. This is a *blocking key* in the record-linkage
|
|
5
|
+
* sense: a cheap, coarse partition applied before any expensive structural
|
|
6
|
+
* comparison (`jaccardSimilarity`/`arrayEditDistance` on `tokenize()`
|
|
7
|
+
* output), not a similarity score by itself. Real-data validation on a large
|
|
8
|
+
* multi-section site found that a single site-wide document-frequency
|
|
9
|
+
* computation ({@link ./compute-document-frequency.js | computeDocumentFrequency})
|
|
10
|
+
* fails when the site is actually a federation of independently-templated
|
|
11
|
+
* sub-sections; splitting pages by their top-level URL segment first and
|
|
12
|
+
* computing frequency per group recovered a working split. This function
|
|
13
|
+
* produces that split key.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately returns only this one signal rather than merging it with
|
|
16
|
+
* other blocking signals (e.g. a stylesheet-based key) into a single
|
|
17
|
+
* composite key: literature on entity-resolution blocking (e.g. Michelson &
|
|
18
|
+
* Knoblock's DNF blocking scheme) finds that combining independent blocking
|
|
19
|
+
* predicates with AND into one key is inferior to keeping them independent
|
|
20
|
+
* and combining candidate pairs with OR — that combination decision belongs
|
|
21
|
+
* to the caller that actually groups pages, not to this function.
|
|
22
|
+
*
|
|
23
|
+
* Empty segments anywhere in `paths` are dropped before slicing, so the
|
|
24
|
+
* trailing `''` that `ExURL.paths` produces for a directory-style URL (one
|
|
25
|
+
* ending in `/`) doesn't fragment a section's key from the same section's
|
|
26
|
+
* non-trailing-slash URLs.
|
|
27
|
+
*
|
|
28
|
+
* `depth` must be a positive integer. A non-positive or fractional depth has
|
|
29
|
+
* no sensible interpretation as "how many leading segments to keep", so it
|
|
30
|
+
* is rejected eagerly rather than silently coerced or left to produce a
|
|
31
|
+
* confusing result (e.g. slicing with a negative or fractional length).
|
|
32
|
+
* @param paths
|
|
33
|
+
* @param depth
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* derivePathGroupKey(['dept-a', 'news', '123']);
|
|
37
|
+
* // 'dept-a'
|
|
38
|
+
* derivePathGroupKey(['dept-a', 'news', '123'], 2);
|
|
39
|
+
* // 'dept-a/news'
|
|
40
|
+
* derivePathGroupKey([]);
|
|
41
|
+
* // ''
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function derivePathGroupKey(paths: readonly string[], depth?: number): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a coarse grouping key from a page's URL path segments (e.g. the
|
|
3
|
+
* `paths` field of `@d-zero/shared/parse-url`'s `ExURL`), keeping only the
|
|
4
|
+
* leading `depth` segments. This is a *blocking key* in the record-linkage
|
|
5
|
+
* sense: a cheap, coarse partition applied before any expensive structural
|
|
6
|
+
* comparison (`jaccardSimilarity`/`arrayEditDistance` on `tokenize()`
|
|
7
|
+
* output), not a similarity score by itself. Real-data validation on a large
|
|
8
|
+
* multi-section site found that a single site-wide document-frequency
|
|
9
|
+
* computation ({@link ./compute-document-frequency.js | computeDocumentFrequency})
|
|
10
|
+
* fails when the site is actually a federation of independently-templated
|
|
11
|
+
* sub-sections; splitting pages by their top-level URL segment first and
|
|
12
|
+
* computing frequency per group recovered a working split. This function
|
|
13
|
+
* produces that split key.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately returns only this one signal rather than merging it with
|
|
16
|
+
* other blocking signals (e.g. a stylesheet-based key) into a single
|
|
17
|
+
* composite key: literature on entity-resolution blocking (e.g. Michelson &
|
|
18
|
+
* Knoblock's DNF blocking scheme) finds that combining independent blocking
|
|
19
|
+
* predicates with AND into one key is inferior to keeping them independent
|
|
20
|
+
* and combining candidate pairs with OR — that combination decision belongs
|
|
21
|
+
* to the caller that actually groups pages, not to this function.
|
|
22
|
+
*
|
|
23
|
+
* Empty segments anywhere in `paths` are dropped before slicing, so the
|
|
24
|
+
* trailing `''` that `ExURL.paths` produces for a directory-style URL (one
|
|
25
|
+
* ending in `/`) doesn't fragment a section's key from the same section's
|
|
26
|
+
* non-trailing-slash URLs.
|
|
27
|
+
*
|
|
28
|
+
* `depth` must be a positive integer. A non-positive or fractional depth has
|
|
29
|
+
* no sensible interpretation as "how many leading segments to keep", so it
|
|
30
|
+
* is rejected eagerly rather than silently coerced or left to produce a
|
|
31
|
+
* confusing result (e.g. slicing with a negative or fractional length).
|
|
32
|
+
* @param paths
|
|
33
|
+
* @param depth
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* derivePathGroupKey(['dept-a', 'news', '123']);
|
|
37
|
+
* // 'dept-a'
|
|
38
|
+
* derivePathGroupKey(['dept-a', 'news', '123'], 2);
|
|
39
|
+
* // 'dept-a/news'
|
|
40
|
+
* derivePathGroupKey([]);
|
|
41
|
+
* // ''
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function derivePathGroupKey(paths, depth = 1) {
|
|
45
|
+
if (!(Number.isInteger(depth) && depth > 0)) {
|
|
46
|
+
throw new RangeError(`derivePathGroupKey: depth must be a positive integer, got ${depth}`);
|
|
47
|
+
}
|
|
48
|
+
// See the trailing-slash note above.
|
|
49
|
+
const segments = paths.filter((segment) => segment !== '');
|
|
50
|
+
return segments.slice(0, depth).join('/');
|
|
51
|
+
}
|