@d-zero/beholder 4.2.2 → 5.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 +35 -0
- package/dist/classify-image-scan-error.d.ts +20 -0
- package/dist/classify-image-scan-error.js +41 -0
- package/dist/dom-evaluation.js +17 -1
- package/dist/image-scan-code.d.ts +33 -0
- package/dist/image-scan-code.js +31 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/scraper.js +29 -3
- package/dist/to-console-log-entry.js +4 -0
- package/dist/types.d.ts +27 -0
- package/package.json +9 -5
- package/src/classify-image-scan-error.spec.ts +34 -0
- package/src/classify-image-scan-error.ts +46 -0
- package/src/dom-evaluation.spec.ts +41 -0
- package/src/dom-evaluation.ts +19 -1
- package/src/image-scan-code.ts +34 -0
- package/src/index.ts +3 -0
- package/src/scraper.ts +36 -4
- package/src/to-console-log-entry.ts +4 -0
- package/src/types.ts +29 -0
- package/tsconfig.json +12 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/dom-evaluation.ts
CHANGED
|
@@ -371,6 +371,17 @@ export async function getAnchorList(
|
|
|
371
371
|
return [];
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
// `$anchors` の各 ElementHandle は CDP 側のリモートオブジェクト参照を保持する
|
|
375
|
+
// ため、解放しないとページごとにハンドルがリークする。AsyncDisposableStack に
|
|
376
|
+
// まとめて登録して解放するが、`await using` でこの関数のスコープ脱出時に
|
|
377
|
+
// 即座に解放してはいけない — タイムアウトで打ち切られた後もバックグラウンドで
|
|
378
|
+
// resolveAnchor が動き続けている間にハンドルを解放すると、実行中の CDP 呼び出しが
|
|
379
|
+
// 失敗する。解放は work() の完了(成功・失敗いずれも)を待って初めて行う。
|
|
380
|
+
const anchorHandles = new AsyncDisposableStack();
|
|
381
|
+
for (const $anchor of $anchors) {
|
|
382
|
+
anchorHandles.use($anchor);
|
|
383
|
+
}
|
|
384
|
+
|
|
374
385
|
const collected: AnchorData[] = [];
|
|
375
386
|
let axHits = 0;
|
|
376
387
|
let textFallbacks = 0;
|
|
@@ -410,7 +421,14 @@ export async function getAnchorList(
|
|
|
410
421
|
);
|
|
411
422
|
};
|
|
412
423
|
|
|
413
|
-
const
|
|
424
|
+
const workPromise = work();
|
|
425
|
+
// work() が真に完了した時点(タイムアウトで打ち切られた場合は、その後
|
|
426
|
+
// バックグラウンドで走り続ける resolveAnchor がすべて解決した時点)で
|
|
427
|
+
// ハンドルを解放する。disposeAsync() 自体の失敗も無視してよい
|
|
428
|
+
// (ベストエフォートの後始末であり、呼び出し元への影響はない)。
|
|
429
|
+
void workPromise.finally(() => anchorHandles.disposeAsync()).catch(() => {});
|
|
430
|
+
|
|
431
|
+
const { timeout: timedOut } = await raceWithTimeout(() => workPromise, timeout);
|
|
414
432
|
cancelled = true;
|
|
415
433
|
if (timedOut) {
|
|
416
434
|
log(
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric outcome codes for a single device-preset image scan performed by
|
|
3
|
+
* `Scraper#fetchImages`. Small integers (fit a `Uint8`) so callers that
|
|
4
|
+
* persist per-page scan results to a database column can use an integer
|
|
5
|
+
* rather than a string enum. A `null` value (not part of this const) means
|
|
6
|
+
* the device preset was never attempted — non-HTML/external/non-HTTP pages,
|
|
7
|
+
* or `captureImages: false`.
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { IMAGE_SCAN_CODE } from '@d-zero/beholder';
|
|
11
|
+
*
|
|
12
|
+
* db.insert({ imageScanMobile: pageData.imageScan.mobile ?? null });
|
|
13
|
+
* if (pageData.imageScan.mobile === IMAGE_SCAN_CODE.SCROLL_HEIGHT_EXCEEDED) {
|
|
14
|
+
* // mobile images were skipped because scrollHeight exceeded the limit
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export const IMAGE_SCAN_CODE = {
|
|
19
|
+
/** `beforePageScan` reached `settled: 'idle'`; images extracted normally. */
|
|
20
|
+
OK: 0,
|
|
21
|
+
/** `beforePageScan` reached `settled: 'degraded'`; images extracted, but navigation never went network-idle so the result carries lower confidence. */
|
|
22
|
+
DEGRADED: 1,
|
|
23
|
+
/** Navigation never settled and the post-timeout frame/URL/readyState check also failed (`NavigationUnsettledError`). */
|
|
24
|
+
NAV_UNSETTLED: 2,
|
|
25
|
+
/** The main frame or session was lost mid-scan (detached Frame, session closed, execution context destroyed, or "Not attached to an active page"). */
|
|
26
|
+
FRAME_LOST: 3,
|
|
27
|
+
/** `document.body.scrollHeight` exceeded the scan's `maxScrollHeight` guard; scroll and extraction were skipped. */
|
|
28
|
+
SCROLL_HEIGHT_EXCEEDED: 4,
|
|
29
|
+
/** An error occurred that does not match any of the categories above. */
|
|
30
|
+
UNKNOWN: 255,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/** Numeric outcome code for one device preset's image scan. See {@link IMAGE_SCAN_CODE}. */
|
|
34
|
+
export type ImageScanCode = (typeof IMAGE_SCAN_CODE)[keyof typeof IMAGE_SCAN_CODE];
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ export { detectCompress } from '@d-zero/shared/detect-compress';
|
|
|
22
22
|
export type { CompressType } from '@d-zero/shared/detect-compress';
|
|
23
23
|
export { detectCDN } from '@d-zero/shared/detect-cdn';
|
|
24
24
|
export type { CDNType } from '@d-zero/shared/detect-cdn';
|
|
25
|
+
export { IMAGE_SCAN_CODE } from './image-scan-code.js';
|
|
26
|
+
export type { ImageScanCode } from './image-scan-code.js';
|
|
25
27
|
export type { ScrapeResult, ResourceEntry, ConsoleLogEntry, PageData } from './types.js';
|
|
26
28
|
export type { ScraperOptions, ChangePhaseEvent, ScraperEventTypes } from './types.js';
|
|
27
29
|
export type {
|
|
@@ -32,6 +34,7 @@ export type {
|
|
|
32
34
|
SkippedPageData,
|
|
33
35
|
NetworkLog,
|
|
34
36
|
ScrollHeightData,
|
|
37
|
+
ImageScanData,
|
|
35
38
|
MainContentsData,
|
|
36
39
|
MainContentsMainTag,
|
|
37
40
|
MainContentsHeading,
|
package/src/scraper.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
ScrapeResult,
|
|
8
8
|
ExURL,
|
|
9
9
|
ImageElement,
|
|
10
|
+
ImageScanData,
|
|
10
11
|
NetworkLog,
|
|
11
12
|
PageData,
|
|
12
13
|
ParseURLOptions,
|
|
@@ -23,6 +24,7 @@ import { detectCompress } from '@d-zero/shared/detect-compress';
|
|
|
23
24
|
import { retry as retryable } from '@d-zero/shared/retry';
|
|
24
25
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
25
26
|
|
|
27
|
+
import { classifyImageScanError } from './classify-image-scan-error.js';
|
|
26
28
|
import { resourceLog, scraperLog } from './debug.js';
|
|
27
29
|
import {
|
|
28
30
|
DEFAULT_DOM_EVALUATION_TIMEOUT,
|
|
@@ -31,6 +33,7 @@ import {
|
|
|
31
33
|
getMeta,
|
|
32
34
|
} from './dom-evaluation.js';
|
|
33
35
|
import { getMainContents } from './get-main-contents.js';
|
|
36
|
+
import { IMAGE_SCAN_CODE } from './image-scan-code.js';
|
|
34
37
|
import { isError } from './is-error.js';
|
|
35
38
|
import { isHtmlContentType } from './is-html-content-type.js';
|
|
36
39
|
import { keywordCheck } from './keyword-check.js';
|
|
@@ -57,6 +60,9 @@ const rLog = resourceLog.extend(pid);
|
|
|
57
60
|
*/
|
|
58
61
|
const MAX_SCROLL_HEIGHT = 1_000_000;
|
|
59
62
|
|
|
63
|
+
/** Shared "not attempted" value for `PageData.imageScan`, reused everywhere a page is returned without an image scan (non-HTML/external/non-HTTP, or the `@retryable` fallback). */
|
|
64
|
+
const EMPTY_IMAGE_SCAN: ImageScanData = { desktop: null, mobile: null };
|
|
65
|
+
|
|
60
66
|
/**
|
|
61
67
|
* Page-level scraper that extracts data from a single browser page.
|
|
62
68
|
*
|
|
@@ -162,6 +168,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
162
168
|
html: '',
|
|
163
169
|
mainContents: null,
|
|
164
170
|
scrollHeight: null,
|
|
171
|
+
imageScan: EMPTY_IMAGE_SCAN,
|
|
165
172
|
isSkipped: false,
|
|
166
173
|
};
|
|
167
174
|
|
|
@@ -633,6 +640,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
633
640
|
html: '',
|
|
634
641
|
mainContents: null,
|
|
635
642
|
scrollHeight: null,
|
|
643
|
+
imageScan: EMPTY_IMAGE_SCAN,
|
|
636
644
|
isSkipped: false,
|
|
637
645
|
};
|
|
638
646
|
}
|
|
@@ -684,6 +692,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
684
692
|
html,
|
|
685
693
|
mainContents: null,
|
|
686
694
|
scrollHeight: null,
|
|
695
|
+
imageScan: EMPTY_IMAGE_SCAN,
|
|
687
696
|
isSkipped: false,
|
|
688
697
|
};
|
|
689
698
|
}
|
|
@@ -744,6 +753,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
744
753
|
|
|
745
754
|
let imageList: ImageElement[] = [];
|
|
746
755
|
let scrollHeight: ScrollHeightData | null = null;
|
|
756
|
+
let imageScan: ImageScanData = EMPTY_IMAGE_SCAN;
|
|
747
757
|
|
|
748
758
|
if (captureImages) {
|
|
749
759
|
void this.emit('changePhase', {
|
|
@@ -762,6 +772,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
762
772
|
);
|
|
763
773
|
imageList = fetched.imageList;
|
|
764
774
|
scrollHeight = fetched.scrollHeight;
|
|
775
|
+
imageScan = fetched.imageScan;
|
|
765
776
|
} else {
|
|
766
777
|
scrollHeight = await measureScrollHeight(page);
|
|
767
778
|
}
|
|
@@ -782,6 +793,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
782
793
|
html,
|
|
783
794
|
mainContents,
|
|
784
795
|
scrollHeight,
|
|
796
|
+
imageScan,
|
|
785
797
|
isSkipped: false,
|
|
786
798
|
};
|
|
787
799
|
} finally {
|
|
@@ -798,7 +810,12 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
798
810
|
* WHY per-device try-catch: Some pages (e.g. those using fullpage.js or
|
|
799
811
|
* scroll-jacking libraries) destroy the execution context when the viewport
|
|
800
812
|
* changes and triggers a reload. Isolating each device preset allows partial
|
|
801
|
-
* results — if one viewport fails, the other can still succeed.
|
|
813
|
+
* results — if one viewport fails, the other can still succeed. The outcome
|
|
814
|
+
* of each device preset (success, degraded, or one of the failure kinds) is
|
|
815
|
+
* recorded per-preset in the returned `imageScan`, classified by
|
|
816
|
+
* {@link classifyImageScanError} — a page that never goes network-idle
|
|
817
|
+
* (analytics beacons, chat widgets, open WebSocket connections) still
|
|
818
|
+
* yields images, just flagged `IMAGE_SCAN_CODE.DEGRADED` instead of `OK`.
|
|
802
819
|
*
|
|
803
820
|
* WHY retryable with 20-min timeout and empty fallback: Image extraction is
|
|
804
821
|
* best-effort. If all retries fail, empty images and null scroll heights are
|
|
@@ -825,13 +842,14 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
825
842
|
* @param isExternal - Whether the page is external
|
|
826
843
|
* @param imageLoadTimeout - Timeout (ms) for waiting images to complete loading
|
|
827
844
|
* @param domEvaluationTimeout - Timeout (ms) for the in-page image extraction `page.evaluate`
|
|
828
|
-
* @returns Image elements
|
|
845
|
+
* @returns Image elements, desktop/mobile scroll heights, and the per-device `imageScan` outcome codes
|
|
829
846
|
*/
|
|
830
847
|
@retryable({
|
|
831
848
|
timeout: 20 * 60 * 1000,
|
|
832
849
|
fallback: {
|
|
833
850
|
imageList: [],
|
|
834
851
|
scrollHeight: { desktop: null, mobile: null },
|
|
852
|
+
imageScan: EMPTY_IMAGE_SCAN,
|
|
835
853
|
},
|
|
836
854
|
onWait(this: Scraper, determinedInterval, retryCount, methodName, error) {
|
|
837
855
|
void this.emit('changePhase', {
|
|
@@ -858,7 +876,11 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
858
876
|
isExternal: boolean,
|
|
859
877
|
imageLoadTimeout: number,
|
|
860
878
|
domEvaluationTimeout: number,
|
|
861
|
-
): Promise<{
|
|
879
|
+
): Promise<{
|
|
880
|
+
imageList: ImageElement[];
|
|
881
|
+
scrollHeight: ScrollHeightData;
|
|
882
|
+
imageScan: ImageScanData;
|
|
883
|
+
}> {
|
|
862
884
|
const listener = this.#createPageScanListener(isExternal);
|
|
863
885
|
const devices: {
|
|
864
886
|
key: 'desktop-compact' | 'mobile-small';
|
|
@@ -869,6 +891,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
869
891
|
];
|
|
870
892
|
const imageList: ImageElement[] = [];
|
|
871
893
|
const scrollHeight: ScrollHeightData = { desktop: null, mobile: null };
|
|
894
|
+
const imageScan: ImageScanData = { desktop: null, mobile: null };
|
|
872
895
|
|
|
873
896
|
for (const { key, preset } of devices) {
|
|
874
897
|
const scrollKey = key === 'desktop-compact' ? 'desktop' : 'mobile';
|
|
@@ -888,11 +911,17 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
888
911
|
listener,
|
|
889
912
|
timeout: 5000,
|
|
890
913
|
maxScrollHeight: MAX_SCROLL_HEIGHT,
|
|
914
|
+
// This per-device try/catch already isolates and classifies
|
|
915
|
+
// failures (see `classifyImageScanError`), so a page whose
|
|
916
|
+
// network never settles should degrade gracefully here
|
|
917
|
+
// rather than losing the whole device preset's images.
|
|
918
|
+
continueOnDegradedNetwork: true,
|
|
891
919
|
});
|
|
892
920
|
|
|
893
921
|
scrollHeight[scrollKey] = scanResult.scrollHeight;
|
|
894
922
|
|
|
895
923
|
if (!scanResult.scrolled) {
|
|
924
|
+
imageScan[scrollKey] = IMAGE_SCAN_CODE.SCROLL_HEIGHT_EXCEEDED;
|
|
896
925
|
void this.emit('changePhase', {
|
|
897
926
|
pid: process.pid,
|
|
898
927
|
name: 'retryExhausted',
|
|
@@ -926,9 +955,12 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
926
955
|
});
|
|
927
956
|
const images = await getImageList(page, preset.width, domEvaluationTimeout);
|
|
928
957
|
imageList.push(...images);
|
|
958
|
+
imageScan[scrollKey] =
|
|
959
|
+
scanResult.settled === 'idle' ? IMAGE_SCAN_CODE.OK : IMAGE_SCAN_CODE.DEGRADED;
|
|
929
960
|
} catch (error) {
|
|
930
961
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
931
962
|
log('Error(FETCH_IMAGES/%s): %s', key, errorMessage);
|
|
963
|
+
imageScan[scrollKey] = classifyImageScanError(error);
|
|
932
964
|
void this.emit('changePhase', {
|
|
933
965
|
pid: process.pid,
|
|
934
966
|
name: 'retryExhausted',
|
|
@@ -939,6 +971,6 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
939
971
|
}
|
|
940
972
|
}
|
|
941
973
|
|
|
942
|
-
return { imageList, scrollHeight };
|
|
974
|
+
return { imageList, scrollHeight, imageScan };
|
|
943
975
|
}
|
|
944
976
|
}
|
|
@@ -23,6 +23,10 @@ export async function toConsoleLogEntry(
|
|
|
23
23
|
} catch {
|
|
24
24
|
return;
|
|
25
25
|
} finally {
|
|
26
|
+
// Why not `await using`: dispose() が reject すると SuppressedError に
|
|
27
|
+
// 包まれ、本来無視したいだけの dispose 失敗が呼び出し元へ伝播してしまう。
|
|
28
|
+
// ここでは jsonValue() の結果を最優先し、dispose の失敗は握りつぶす
|
|
29
|
+
// 現状の挙動を維持する。
|
|
26
30
|
await arg.dispose().catch(() => {});
|
|
27
31
|
}
|
|
28
32
|
}),
|
package/src/types.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
export type { ImageScanCode } from './image-scan-code.js';
|
|
8
9
|
export type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
9
10
|
export type { CompressType } from '@d-zero/shared/detect-compress';
|
|
10
11
|
export type { CDNType } from '@d-zero/shared/detect-cdn';
|
|
@@ -55,6 +56,7 @@ export type {
|
|
|
55
56
|
RawHeadEntry,
|
|
56
57
|
} from './meta/types.js';
|
|
57
58
|
|
|
59
|
+
import type { ImageScanCode } from './image-scan-code.js';
|
|
58
60
|
import type { Meta } from './meta/types.js';
|
|
59
61
|
import type { CDNType } from '@d-zero/shared/detect-cdn';
|
|
60
62
|
import type { CompressType } from '@d-zero/shared/detect-compress';
|
|
@@ -117,6 +119,22 @@ export type PageData = {
|
|
|
117
119
|
*/
|
|
118
120
|
scrollHeight: ScrollHeightData | null;
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Per-device-preset outcome of the `<img>` element scan performed by
|
|
124
|
+
* `Scraper#fetchImages`, keyed the same way as {@link ScrollHeightData}.
|
|
125
|
+
* A field is `null` when that device preset's image scan was never
|
|
126
|
+
* attempted (non-HTML, external, non-HTTP page, or `captureImages: false`);
|
|
127
|
+
* otherwise it is an {@link ImageScanCode} recording why the scan
|
|
128
|
+
* succeeded, degraded, or was abandoned. See `IMAGE_SCAN_CODE`.
|
|
129
|
+
* @example
|
|
130
|
+
* ```ts
|
|
131
|
+
* if (pageData.imageScan.mobile === IMAGE_SCAN_CODE.SCROLL_HEIGHT_EXCEEDED) {
|
|
132
|
+
* // mobile images were skipped due to an oversized scrollHeight
|
|
133
|
+
* }
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
imageScan: ImageScanData;
|
|
137
|
+
|
|
120
138
|
/** Always `false` for successfully scraped pages. See {@link SkippedPageData} for skipped pages. */
|
|
121
139
|
isSkipped: false;
|
|
122
140
|
};
|
|
@@ -131,6 +149,17 @@ export type ScrollHeightData = {
|
|
|
131
149
|
mobile: number | null;
|
|
132
150
|
};
|
|
133
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Per-device-preset {@link ImageScanCode} outcome of `Scraper#fetchImages`,
|
|
154
|
+
* mirroring {@link ScrollHeightData}'s desktop/mobile shape.
|
|
155
|
+
*/
|
|
156
|
+
export type ImageScanData = {
|
|
157
|
+
/** Outcome for `desktop-compact` (width 1280), or `null` if not attempted. */
|
|
158
|
+
desktop: ImageScanCode | null;
|
|
159
|
+
/** Outcome for `mobile-small` (width 320 @ 2x), or `null` if not attempted. */
|
|
160
|
+
mobile: ImageScanCode | null;
|
|
161
|
+
};
|
|
162
|
+
|
|
134
163
|
/**
|
|
135
164
|
* Quantitative metrics for the detected main content region of a page.
|
|
136
165
|
* @example
|
package/tsconfig.json
CHANGED
|
@@ -3,7 +3,18 @@
|
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"composite": true,
|
|
5
5
|
"outDir": "./dist",
|
|
6
|
-
"rootDir": "./src"
|
|
6
|
+
"rootDir": "./src",
|
|
7
|
+
// Why not the repo-wide `target: "ESNext"`: `scraper.ts`'s `@retryable`
|
|
8
|
+
// decorator wraps a private method (`#fetchData`), and esbuild's test
|
|
9
|
+
// transform (vitest) emits invalid output for that combination at
|
|
10
|
+
// target ESNext ("Private field '#fetchData' must be declared in an
|
|
11
|
+
// enclosing class"). `tsc` itself has no issue with it — only vitest's
|
|
12
|
+
// esbuild-based transform does — so this package alone stays on the
|
|
13
|
+
// `@d-zero/tsconfig` base target until that esbuild limitation is fixed.
|
|
14
|
+
// TODO: esbuild がデコレータ + private メソッドを target=esnext で正しく
|
|
15
|
+
// 変換できるようになったら、この override を撤去して root の ESNext
|
|
16
|
+
// (ネイティブ using / await using emit)に揃える。
|
|
17
|
+
"target": "es2024"
|
|
7
18
|
},
|
|
8
19
|
"references": [
|
|
9
20
|
{
|