@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.
@@ -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 { timeout: timedOut } = await raceWithTimeout(work, timeout);
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 plus desktop/mobile scroll heights from the scan path
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<{ imageList: ImageElement[]; scrollHeight: ScrollHeightData }> {
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
  {