@d-zero/beholder 3.1.6 → 4.0.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/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [4.0.0](https://github.com/d-zero-dev/tools/compare/@d-zero/beholder@3.1.6...@d-zero/beholder@4.0.0) (2026-07-21)
7
+
8
+ - feat(beholder)!: enrich PageData with mainContents metrics and scrollHeight ([8a0fc72](https://github.com/d-zero-dev/tools/commit/8a0fc72412295a92850b2c2fa2fa8cd8145eb5f6))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ - PageData now requires mainContents and scrollHeight fields.
13
+
14
+ Co-authored-by: Cursor <cursoragent@cursor.com>
15
+
6
16
  ## [3.1.6](https://github.com/d-zero-dev/tools/compare/@d-zero/beholder@3.1.5...@d-zero/beholder@3.1.6) (2026-07-14)
7
17
 
8
18
  **Note:** Version bump only for package @d-zero/beholder
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@d-zero/beholder`
2
2
 
3
- Puppeteer の `Page` を受け取り、単一ページのメタデータ・リンク・画像・ネットワークリソースを収集するインプロセス型スクレイパー。結果は `ScrapeResult` として戻り値で返却される(イベント経由ではない)。ブラウザ管理は呼び出し側の責任。
3
+ Puppeteer の `Page` を受け取り、単一ページのメタデータ・リンク・画像・ネットワークリソースに加え、メインコンテンツの定量データと desktop/mobile の `scrollHeight` を収集するインプロセス型スクレイパー。結果は `ScrapeResult` として戻り値で返却される(イベント経由ではない)。ブラウザ管理は呼び出し側の責任。
4
4
 
5
5
  ## Installation
6
6
 
@@ -26,13 +26,48 @@ const result = await scraper.scrapeStart(page, parseUrl('https://example.com'),
26
26
  isExternal: false,
27
27
  });
28
28
 
29
- if (result.type === 'success') {
30
- console.log(result.pageData?.meta.title);
29
+ if (result.type === 'success' && result.pageData) {
30
+ console.log(result.pageData.meta.title);
31
+ console.log(result.pageData.mainContents?.wordCount);
32
+ console.log(result.pageData.scrollHeight);
31
33
  }
32
34
  ```
33
35
 
34
36
  設計判断(イベントではなく戻り値で返す理由、`page` のライフサイクル責務、リトライ機構など)は `src/scraper.ts` の JSDoc を参照。
35
37
 
38
+ ## 取得フロー
39
+
40
+ ```mermaid
41
+ flowchart TD
42
+ goto[page.goto]
43
+ ct{content-type is text/html?}
44
+ early[both null]
45
+ html[get HTML]
46
+ ext{isExternal?}
47
+ extOut[title-only; both null]
48
+ idle[networkidle]
49
+ mc["page.evaluate: mainContents"]
50
+ anchors[getAnchorList / getMeta]
51
+ imgs{captureImages?}
52
+ fetchImg["#fetchImages + collect scrollHeight"]
53
+ light["measureScrollHeight light path"]
54
+ done[return PageData]
55
+
56
+ goto --> ct
57
+ ct -->|no| early
58
+ ct -->|yes| html --> ext
59
+ ext -->|yes| extOut
60
+ ext -->|no| idle --> mc --> anchors --> imgs
61
+ imgs -->|yes| fetchImg --> done
62
+ imgs -->|no| light --> done
63
+ ```
64
+
65
+ ## 返却データ(`PageData` の追加分)
66
+
67
+ - `mainContents`: メイン領域の定量(`wordCount` / `bodyWordCount`、見出し・画像・表・ボタン・iframe / video / audio / canvas の配列、検出した `main` の識別情報)。メイン未検出時もオブジェクトは返り、配列は空・`wordCount` は 0(非 HTML 等で取得しなかったときだけ `null`)
68
+ - `scrollHeight`: `{ desktop, mobile }`(各 `number | null`)。未計測時はフィールド全体が `null`
69
+ - 配列要素のフィールド詳細は型(`MainContents*` / `ScrollHeightData`)を参照
70
+
36
71
  ## DOM 文字列からメタ抽出(Puppeteer なし)
37
72
 
38
73
  HTML 文字列を jsdom などでパースしてから `Meta` を取り出したい場合、`extractMetaFromDocument` を使う。`Scraper` が内部で呼ぶ `collectHead → detectTags → classify` パイプラインと同じ実装を再利用するため、戻り値の `Meta` 形状は `scrapeStart` と同一。DOM ライブラリ(jsdom 等)はユーザランドの責務。
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Main-content quantitative extraction for scraped HTML pages.
3
+ *
4
+ * Runs entirely against a `Document` (Puppeteer page realm or jsdom) so the
5
+ * same logic is unit-tested without a browser. Puppeteer callers use
6
+ * {@link getMainContents}, which passes this function into `page.evaluate`.
7
+ *
8
+ * WHY no `@medv/finder`: selector strings are diagnostic only; a tag+id+class // cspell:disable-line
9
+ * path is enough and avoids a Node-only dependency inside the page realm.
10
+ * @module
11
+ */
12
+ import type { MainContentsData } from './types.js';
13
+ import type { Page } from 'puppeteer';
14
+ /**
15
+ * Extract main-content metrics from a `Document`.
16
+ *
17
+ * This function must remain free of closure over imports so Puppeteer can
18
+ * serialize it into `page.evaluate`.
19
+ *
20
+ * Argument order matches `page.evaluate(fn, mainContentSelector)`: the selector
21
+ * is the first argument; `doc` defaults to the page-realm global `document`
22
+ * when omitted (jsdom tests pass an explicit `Document` as the second argument).
23
+ * @param mainContentSelector - Optional selector prepended to the default list.
24
+ * @param doc - The document to inspect (defaults to the global `document`).
25
+ * @returns Quantitative main-content data (never `null`; empty when no main region).
26
+ * @example
27
+ * ```ts
28
+ * // In page.evaluate / browser:
29
+ * const data = extractMainContentsFromDocument('#page-body');
30
+ * // In jsdom tests:
31
+ * const data = extractMainContentsFromDocument('#page-body', document);
32
+ * ```
33
+ */
34
+ export declare function extractMainContentsFromDocument(mainContentSelector?: string | null, doc?: Document): MainContentsData;
35
+ /**
36
+ * Extract main-content metrics from a Puppeteer page via a single `page.evaluate`.
37
+ * @param page - Puppeteer page whose DOM has finished loading.
38
+ * @param options - Optional main-content selector override.
39
+ * @param options.mainContentSelector
40
+ * @returns Quantitative main-content data.
41
+ * @example
42
+ * ```ts
43
+ * const mainContents = await getMainContents(page, { mainContentSelector: '#page-body' });
44
+ * ```
45
+ */
46
+ export declare function getMainContents(page: Page, options?: {
47
+ mainContentSelector?: string | null;
48
+ }): Promise<MainContentsData>;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Main-content quantitative extraction for scraped HTML pages.
3
+ *
4
+ * Runs entirely against a `Document` (Puppeteer page realm or jsdom) so the
5
+ * same logic is unit-tested without a browser. Puppeteer callers use
6
+ * {@link getMainContents}, which passes this function into `page.evaluate`.
7
+ *
8
+ * WHY no `@medv/finder`: selector strings are diagnostic only; a tag+id+class // cspell:disable-line
9
+ * path is enough and avoids a Node-only dependency inside the page realm.
10
+ * @module
11
+ */
12
+ /**
13
+ * Extract main-content metrics from a `Document`.
14
+ *
15
+ * This function must remain free of closure over imports so Puppeteer can
16
+ * serialize it into `page.evaluate`.
17
+ *
18
+ * Argument order matches `page.evaluate(fn, mainContentSelector)`: the selector
19
+ * is the first argument; `doc` defaults to the page-realm global `document`
20
+ * when omitted (jsdom tests pass an explicit `Document` as the second argument).
21
+ * @param mainContentSelector - Optional selector prepended to the default list.
22
+ * @param doc - The document to inspect (defaults to the global `document`).
23
+ * @returns Quantitative main-content data (never `null`; empty when no main region).
24
+ * @example
25
+ * ```ts
26
+ * // In page.evaluate / browser:
27
+ * const data = extractMainContentsFromDocument('#page-body');
28
+ * // In jsdom tests:
29
+ * const data = extractMainContentsFromDocument('#page-body', document);
30
+ * ```
31
+ */
32
+ export function extractMainContentsFromDocument(mainContentSelector = null, doc = document) {
33
+ /**
34
+ * @param text
35
+ */
36
+ function removeSpaces(text) {
37
+ return text?.trim().replaceAll(/\s+/g, '') || null;
38
+ }
39
+ /**
40
+ * @param value
41
+ */
42
+ function cssEscape(value) {
43
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
44
+ return CSS.escape(value);
45
+ }
46
+ return value.replaceAll(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
47
+ }
48
+ /**
49
+ * @param el
50
+ */
51
+ function buildSelector(el) {
52
+ const tag = el.nodeName.toLowerCase();
53
+ const id = el.id ? `#${cssEscape(el.id)}` : '';
54
+ const classes = [...el.classList].map((c) => `.${cssEscape(c)}`).join('');
55
+ return `${tag}${id}${classes}`;
56
+ }
57
+ /**
58
+ * @param media
59
+ */
60
+ function resolveMediaSrc(media) {
61
+ if (media.currentSrc) {
62
+ return media.currentSrc;
63
+ }
64
+ const attrSrc = media.getAttribute('src');
65
+ if (attrSrc) {
66
+ try {
67
+ return new URL(attrSrc, doc.baseURI).href;
68
+ }
69
+ catch {
70
+ return attrSrc;
71
+ }
72
+ }
73
+ const source = media.querySelector('source[src]');
74
+ const sourceSrc = source?.getAttribute('src');
75
+ if (sourceSrc) {
76
+ try {
77
+ return new URL(sourceSrc, doc.baseURI).href;
78
+ }
79
+ catch {
80
+ return sourceSrc;
81
+ }
82
+ }
83
+ return '';
84
+ }
85
+ const title = doc.title?.trim() ?? '';
86
+ const bodyWordCount = removeSpaces(doc.body?.textContent ?? null)?.length ?? 0;
87
+ const selectors = [
88
+ 'main',
89
+ '[role="main"]',
90
+ '#main',
91
+ '.main',
92
+ '#content',
93
+ '.content',
94
+ '#contents',
95
+ '.contents',
96
+ '#main-content',
97
+ '.main-content',
98
+ '#main_content',
99
+ '.main_content',
100
+ '#mainContent',
101
+ '.mainContent',
102
+ ];
103
+ if (mainContentSelector) {
104
+ selectors.unshift(mainContentSelector);
105
+ }
106
+ let $main = null;
107
+ try {
108
+ $main = doc.querySelector(selectors.join(','));
109
+ }
110
+ catch {
111
+ // Invalid custom selector: retry without it so built-in selectors still run.
112
+ if (mainContentSelector) {
113
+ selectors.shift();
114
+ $main = doc.querySelector(selectors.join(','));
115
+ }
116
+ }
117
+ if (!$main) {
118
+ const fallbackSelectors = [
119
+ '[id*="main" i]',
120
+ '[class*="main" i]',
121
+ '[id*="content" i]',
122
+ '[class*="content" i]',
123
+ ];
124
+ for (const sel of fallbackSelectors) {
125
+ const candidate = doc.querySelector(sel);
126
+ if (candidate && candidate !== doc.body && candidate !== doc.documentElement) {
127
+ $main = candidate;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ if (!$main) {
133
+ return {
134
+ title,
135
+ main: null,
136
+ wordCount: 0,
137
+ bodyWordCount,
138
+ headings: [],
139
+ images: [],
140
+ tables: [],
141
+ buttons: [],
142
+ iframes: [],
143
+ videos: [],
144
+ audios: [],
145
+ canvases: [],
146
+ };
147
+ }
148
+ const headings = [];
149
+ for (const $heading of $main.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
150
+ headings.push({
151
+ text: removeSpaces($heading.textContent),
152
+ level: Number.parseInt($heading.nodeName.replace(/h/i, ''), 10),
153
+ });
154
+ }
155
+ const images = [];
156
+ for (const $img of $main.querySelectorAll('img, input[type="image"]')) {
157
+ images.push({
158
+ src: $img.src,
159
+ alt: $img.alt,
160
+ });
161
+ }
162
+ const tables = [];
163
+ for (const $table of $main.querySelectorAll('table')) {
164
+ tables.push({
165
+ rows: $table.querySelectorAll('tr').length,
166
+ cols: $table.querySelector('tr')?.querySelectorAll('th, td').length || 0,
167
+ hasHeader: !!$table.querySelector('thead'),
168
+ hasFooter: !!$table.querySelector('tfoot'),
169
+ hasMergedCell: !!$table.querySelector('[colspan], [rowspan]'),
170
+ });
171
+ }
172
+ const win = doc.defaultView;
173
+ const HTMLButton = win?.HTMLButtonElement;
174
+ const HTMLInput = win?.HTMLInputElement;
175
+ const buttons = [];
176
+ for (const $el of $main.querySelectorAll('button, [role="button"], [class*="button"], [class*="btn"]')) {
177
+ const isButton = HTMLButton ? $el instanceof HTMLButton : $el.nodeName === 'BUTTON';
178
+ const isInput = HTMLInput ? $el instanceof HTMLInput : $el.nodeName === 'INPUT';
179
+ const isFormControl = isButton || isInput;
180
+ let text = removeSpaces($el.textContent);
181
+ if (!text && isInput) {
182
+ text = removeSpaces($el.getAttribute('value'));
183
+ }
184
+ const disabledProp = isFormControl && 'disabled' in $el
185
+ ? Boolean($el.disabled)
186
+ : false;
187
+ buttons.push({
188
+ nodeName: $el.nodeName,
189
+ role: $el.getAttribute('role'),
190
+ type: isFormControl ? $el.type : null,
191
+ text,
192
+ disabled: disabledProp || $el.getAttribute('aria-disabled') === 'true',
193
+ });
194
+ }
195
+ const iframes = [];
196
+ for (const $el of $main.querySelectorAll('iframe')) {
197
+ iframes.push({
198
+ src: $el.src,
199
+ title: $el.hasAttribute('title') ? $el.getAttribute('title') : null,
200
+ width: $el.getAttribute('width'),
201
+ height: $el.getAttribute('height'),
202
+ });
203
+ }
204
+ const videos = [];
205
+ for (const $el of $main.querySelectorAll('video')) {
206
+ const posterAttr = $el.getAttribute('poster');
207
+ let poster = null;
208
+ if (posterAttr) {
209
+ try {
210
+ poster = new URL(posterAttr, doc.baseURI).href;
211
+ }
212
+ catch {
213
+ poster = posterAttr;
214
+ }
215
+ }
216
+ videos.push({
217
+ src: resolveMediaSrc($el),
218
+ poster,
219
+ width: $el.width,
220
+ height: $el.height,
221
+ });
222
+ }
223
+ const audios = [];
224
+ for (const $el of $main.querySelectorAll('audio')) {
225
+ audios.push({
226
+ src: resolveMediaSrc($el),
227
+ });
228
+ }
229
+ const canvases = [];
230
+ for (const $el of $main.querySelectorAll('canvas')) {
231
+ canvases.push({
232
+ width: $el.width,
233
+ height: $el.height,
234
+ });
235
+ }
236
+ return {
237
+ title,
238
+ main: {
239
+ nodeName: $main.nodeName,
240
+ id: $main.id || null,
241
+ classList: [...$main.classList],
242
+ role: $main.getAttribute('role'),
243
+ selector: buildSelector($main),
244
+ },
245
+ wordCount: removeSpaces($main.textContent)?.length ?? 0,
246
+ bodyWordCount,
247
+ headings,
248
+ images,
249
+ tables,
250
+ buttons,
251
+ iframes,
252
+ videos,
253
+ audios,
254
+ canvases,
255
+ };
256
+ }
257
+ /**
258
+ * Extract main-content metrics from a Puppeteer page via a single `page.evaluate`.
259
+ * @param page - Puppeteer page whose DOM has finished loading.
260
+ * @param options - Optional main-content selector override.
261
+ * @param options.mainContentSelector
262
+ * @returns Quantitative main-content data.
263
+ * @example
264
+ * ```ts
265
+ * const mainContents = await getMainContents(page, { mainContentSelector: '#page-body' });
266
+ * ```
267
+ */
268
+ export async function getMainContents(page, options) {
269
+ return page.evaluate(extractMainContentsFromDocument, options?.mainContentSelector ?? null);
270
+ }
package/dist/index.d.ts CHANGED
@@ -20,4 +20,4 @@ export { detectCDN } from '@d-zero/shared/detect-cdn';
20
20
  export type { CDNType } from '@d-zero/shared/detect-cdn';
21
21
  export type { ScrapeResult, ResourceEntry, PageData } from './types.js';
22
22
  export type { ScraperOptions, ChangePhaseEvent, ScraperEventTypes } from './types.js';
23
- export type { Resource, AnchorData, Meta, ImageElement, SkippedPageData, NetworkLog, OpenGraphMeta, OgArticleMeta, OgBookMeta, OgProfileMeta, OgMusicMeta, OgVideoNsMeta, TwitterMeta, FbMeta, FediverseMeta, AppleMeta, MsApplicationMeta, VerificationMeta, GoogleMeta, GeoMeta, CitationMeta, RdfaMeta, MicrodataMeta, AmpMeta, LegacyMeta, MobileMeta, MicroformatsMeta, PinterestMeta, SlackMeta, LinkedInMeta, ExperimentalMeta, WikiMeta, LinkMeta, LinkEntry, JsonLdEntry, OthersBucket, ScriptEntry, IframeEntry, TagsMeta, TagDetail, TagEntry, TagSource, ViewportMeta, RobotsMeta, ReferrerMeta, FormatDetectionMeta, HttpEquivMeta, HttpEquivRefresh, RawHeadEntry, } from './types.js';
23
+ export type { Resource, AnchorData, Meta, ImageElement, SkippedPageData, NetworkLog, ScrollHeightData, MainContentsData, MainContentsMainTag, MainContentsHeading, MainContentsImage, MainContentsTable, MainContentsButton, MainContentsIframe, MainContentsVideo, MainContentsAudio, MainContentsCanvas, OpenGraphMeta, OgArticleMeta, OgBookMeta, OgProfileMeta, OgMusicMeta, OgVideoNsMeta, TwitterMeta, FbMeta, FediverseMeta, AppleMeta, MsApplicationMeta, VerificationMeta, GoogleMeta, GeoMeta, CitationMeta, RdfaMeta, MicrodataMeta, AmpMeta, LegacyMeta, MobileMeta, MicroformatsMeta, PinterestMeta, SlackMeta, LinkedInMeta, ExperimentalMeta, WikiMeta, LinkMeta, LinkEntry, JsonLdEntry, OthersBucket, ScriptEntry, IframeEntry, TagsMeta, TagDetail, TagEntry, TagSource, ViewportMeta, RobotsMeta, ReferrerMeta, FormatDetectionMeta, HttpEquivMeta, HttpEquivRefresh, RawHeadEntry, } from './types.js';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Determine whether a Content-Type media type is HTML.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045). Surrounding whitespace after
5
+ * parameter stripping is also tolerated so `text/html `, `Text/HTML`, and
6
+ * `text/html; charset=utf-8` (after `;` split) all classify as HTML.
7
+ * @param contentType - The media type portion of a Content-Type header
8
+ * (parameters already stripped), or `null` when unknown.
9
+ * @returns `true` when the media type is `text/html` in any letter case.
10
+ * @example
11
+ * ```ts
12
+ * isHtmlContentType('text/html'); // true
13
+ * isHtmlContentType('Text/HTML'); // true
14
+ * isHtmlContentType('application/pdf'); // false
15
+ * // Callers should strip parameters first (e.g. split on ';') before calling.
16
+ * ```
17
+ */
18
+ export declare function isHtmlContentType(contentType: string | null): boolean;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Determine whether a Content-Type media type is HTML.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045). Surrounding whitespace after
5
+ * parameter stripping is also tolerated so `text/html `, `Text/HTML`, and
6
+ * `text/html; charset=utf-8` (after `;` split) all classify as HTML.
7
+ * @param contentType - The media type portion of a Content-Type header
8
+ * (parameters already stripped), or `null` when unknown.
9
+ * @returns `true` when the media type is `text/html` in any letter case.
10
+ * @example
11
+ * ```ts
12
+ * isHtmlContentType('text/html'); // true
13
+ * isHtmlContentType('Text/HTML'); // true
14
+ * isHtmlContentType('application/pdf'); // false
15
+ * // Callers should strip parameters first (e.g. split on ';') before calling.
16
+ * ```
17
+ */
18
+ export function isHtmlContentType(contentType) {
19
+ return contentType !== null && contentType.trim().toLowerCase() === 'text/html';
20
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Lightweight scrollHeight measurement without `scrollAllOver`.
3
+ *
4
+ * Used when `captureImages` is false so callers still receive desktop/mobile
5
+ * heights without the cost of full-page scrolling for image extraction.
6
+ * @module
7
+ */
8
+ import type { ScrollHeightData } from './types.js';
9
+ import type { Page } from 'puppeteer';
10
+ /**
11
+ * Measure `document.body.scrollHeight` at desktop-compact and mobile-small viewports.
12
+ *
13
+ * Sets each viewport, reads height once, and does **not** call `scrollAllOver`.
14
+ * A failed preset yields `null` for that side only.
15
+ * @param page - Puppeteer page whose DOM has finished loading.
16
+ * @returns Heights for desktop and mobile presets.
17
+ * @example
18
+ * ```ts
19
+ * const scrollHeight = await measureScrollHeight(page);
20
+ * // { desktop: 2400, mobile: 5200 }
21
+ * ```
22
+ */
23
+ export declare function measureScrollHeight(page: Page): Promise<ScrollHeightData>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Lightweight scrollHeight measurement without `scrollAllOver`.
3
+ *
4
+ * Used when `captureImages` is false so callers still receive desktop/mobile
5
+ * heights without the cost of full-page scrolling for image extraction.
6
+ * @module
7
+ */
8
+ import { devicePresets } from '@d-zero/puppeteer-page-scan';
9
+ /**
10
+ * Measure `document.body.scrollHeight` at desktop-compact and mobile-small viewports.
11
+ *
12
+ * Sets each viewport, reads height once, and does **not** call `scrollAllOver`.
13
+ * A failed preset yields `null` for that side only.
14
+ * @param page - Puppeteer page whose DOM has finished loading.
15
+ * @returns Heights for desktop and mobile presets.
16
+ * @example
17
+ * ```ts
18
+ * const scrollHeight = await measureScrollHeight(page);
19
+ * // { desktop: 2400, mobile: 5200 }
20
+ * ```
21
+ */
22
+ export async function measureScrollHeight(page) {
23
+ const desktop = await measureAtPreset(page, 'desktop-compact');
24
+ const mobile = await measureAtPreset(page, 'mobile-small');
25
+ return { desktop, mobile };
26
+ }
27
+ /**
28
+ * @param page
29
+ * @param key
30
+ */
31
+ async function measureAtPreset(page, key) {
32
+ const preset = devicePresets[key];
33
+ try {
34
+ await page.setViewport({
35
+ width: preset.width,
36
+ height: 800,
37
+ deviceScaleFactor: preset.resolution ?? 1,
38
+ });
39
+ return await page.evaluate(() => document.body.scrollHeight);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }