@ultimat3/scraping 2.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/src/target.ts ADDED
@@ -0,0 +1,118 @@
1
+ // The driver-blind currency: what every browser driver hands back, and the only shape the page
2
+ // vocabulary reads. No puppeteer type, no CDP type and no `any` crosses this line — which is what
3
+ // lets `page-over-target.ts` be the ONE implementation of `ScrapePage` for the real browser, the
4
+ // fixture replayer and the fake alike.
5
+
6
+ import type { ConsoleRing, NetworkRing } from './rings';
7
+ import type { SessionSnapshot } from './session-state';
8
+
9
+ /**
10
+ * The document root, as a selector every target must answer. `text()` with no argument is "all
11
+ * the text on this page", and a driver-specific spelling of that would be the first thing to
12
+ * diverge between the fake and the real browser.
13
+ */
14
+ export const ROOT_SELECTOR = 'html';
15
+
16
+ export interface ElementBox {
17
+ readonly x: number;
18
+ readonly y: number;
19
+ readonly width: number;
20
+ readonly height: number;
21
+ }
22
+
23
+ /**
24
+ * One element, as of one observation. Everything the actionability rule needs, and nothing that
25
+ * would let a caller hold a live handle: a snapshot is a VALUE, so it cannot go stale behind the
26
+ * caller's back the way a `frameLocator` handle does — it is simply old, and `page-over-target.ts`
27
+ * takes a fresh one before every act.
28
+ */
29
+ export interface ElementSnapshot {
30
+ readonly tag: string;
31
+ readonly attrs: Readonly<Record<string, string>>;
32
+ readonly text: string;
33
+ /** The form value for a control, `''` for anything else. Never `undefined` — absent is ''. */
34
+ readonly value: string;
35
+ readonly visible: boolean;
36
+ readonly enabled: boolean;
37
+ /**
38
+ * Absent means THIS TARGET HAS NO LAYOUT ENGINE (the fake and the fixture parse HTML; nothing
39
+ * is laid out). Never a fabricated zero box: `driver-parity.test.ts` pins the divergence in one
40
+ * place, and a `{ x: 0, y: 0 }` invented here would make a covered button test green on the
41
+ * only driver that could have caught it.
42
+ */
43
+ readonly box?: ElementBox | undefined;
44
+ /** Whether this element is what a click at its own centre would hit. Absent: no layout. */
45
+ readonly hitTarget?: boolean | undefined;
46
+ }
47
+
48
+ export interface ScrapeCookie {
49
+ readonly name: string;
50
+ readonly value: string;
51
+ readonly domain: string;
52
+ readonly path: string;
53
+ readonly expires?: number | undefined;
54
+ readonly httpOnly: boolean;
55
+ readonly secure: boolean;
56
+ }
57
+
58
+ export interface ScrapeDownloadFile {
59
+ readonly filename: string;
60
+ readonly bytes: Uint8Array;
61
+ }
62
+
63
+ /** A child frame, addressed by whatever the page can see of it. */
64
+ export interface FrameRef {
65
+ readonly name: string;
66
+ readonly url: string;
67
+ /** The `<iframe>` selector in the PARENT document, when the driver can tell. */
68
+ readonly selector?: string | undefined;
69
+ readonly target: ScrapeTarget;
70
+ }
71
+
72
+ export interface GotoOptions {
73
+ readonly timeoutMs: number;
74
+ readonly signal?: AbortSignal | undefined;
75
+ }
76
+
77
+ export interface CaptureOptions {
78
+ readonly fullPage?: boolean | undefined;
79
+ readonly timeoutMs: number;
80
+ }
81
+
82
+ /**
83
+ * The port. Twelve methods, every one of them something a browser genuinely does and nothing a
84
+ * scraper's vocabulary should be re-deriving per driver.
85
+ */
86
+ export interface ScrapeTarget {
87
+ /** `puppeteer` | `fixture` | `fake`. Appears in every error cause raised against it. */
88
+ readonly driver: string;
89
+ readonly console: ConsoleRing;
90
+ readonly network: NetworkRing;
91
+ url(): string;
92
+ goto(url: string, options: GotoOptions): Promise<void>;
93
+ /** Serialised HTML of THIS target — the document for a page, the subtree for a frame. */
94
+ content(): Promise<string>;
95
+ query(selector: string): Promise<readonly ElementSnapshot[]>;
96
+ click(selector: string, index: number): Promise<void>;
97
+ /** Appends, exactly as typing does. Clearing first is `fill`'s job at the page level. */
98
+ type(selector: string, text: string): Promise<void>;
99
+ clear(selector: string): Promise<void>;
100
+ select(selector: string, values: readonly string[]): Promise<void>;
101
+ /** The expression runs in the page. The result is `unknown` and is parsed by the caller. */
102
+ evaluate(expression: string): Promise<unknown>;
103
+ screenshot(options: CaptureOptions): Promise<Uint8Array>;
104
+ pdf(options: CaptureOptions): Promise<Uint8Array>;
105
+ cookies(): Promise<readonly ScrapeCookie[]>;
106
+ /** Whatever the last click/navigation produced as a file, or a `X_SCRAPE_DOWNLOAD_TIMEOUT`. */
107
+ download(options: { readonly timeoutMs: number }): Promise<ScrapeDownloadFile>;
108
+ frames(): Promise<readonly FrameRef[]>;
109
+ /**
110
+ * Everything that makes this client THIS client: cookies, storage, the headers the site now
111
+ * expects, the user agent. It is the browser-to-HTTP handoff, and it is the thing a session
112
+ * store persists — so it is credential material and is summarised, never logged.
113
+ */
114
+ session(): Promise<SessionSnapshot>;
115
+ /** Put a previously captured session back, before the first navigation. */
116
+ restore(session: SessionSnapshot): Promise<void>;
117
+ close(): Promise<void>;
118
+ }
@@ -0,0 +1,100 @@
1
+ // The wedge and zombie discipline, which is the difference between a scrape that fails and a
2
+ // scrape that pins a worker.
3
+ //
4
+ // Two real incidents, both from the same missing mechanism. One run held a queue slot for 3h11m
5
+ // and persisted nothing: the browser's socket was open, so every `await` was legitimately waiting
6
+ // and no timeout was armed on anything. Another left a browser process alive after its run ended
7
+ // and the queue could not be drained until a redeploy.
8
+ //
9
+ // So: an INACTIVITY watchdog that kills the OS process — killing it is what makes the blocked
10
+ // socket die, which is what turns an infinite await into a catchable `X_SCRAPE_WEDGED` — and a
11
+ // graceful-quit CEILING on shutdown, past which the same kill runs.
12
+
13
+ import type { ScrapeClock } from './clock';
14
+ import { wedged } from './error-throws';
15
+
16
+ export const DEFAULT_IDLE_MS = 120_000;
17
+ export const DEFAULT_GRACE_MS = 5_000;
18
+ const WATCH_POLL_MS = 250;
19
+
20
+ export interface WedgeGuardInit {
21
+ readonly clock: ScrapeClock;
22
+ /** Longest gap between two browser operations before the process is killed. */
23
+ readonly idleMs?: number | undefined;
24
+ /** How long a graceful quit may take before the same kill runs. */
25
+ readonly graceMs?: number | undefined;
26
+ /** What is being watched, for the cause line. */
27
+ readonly what: string;
28
+ /** The polite ending: `browser.close()`. May hang, which is the whole reason for the ceiling. */
29
+ quit(): Promise<void>;
30
+ /**
31
+ * SIGKILL, not SIGTERM. A wedged Chrome ignores the polite signal — that is what "wedged"
32
+ * means — and a kill that can be ignored is not a ceiling.
33
+ */
34
+ kill(): void;
35
+ }
36
+
37
+ export interface WedgeGuard {
38
+ /** Aborts with `X_SCRAPE_WEDGED` when the idle budget passes. Handed to every wait. */
39
+ readonly signal: AbortSignal;
40
+ /** Called after every browser operation. The watchdog measures the gap between these. */
41
+ touch(): void;
42
+ /** Graceful quit under the ceiling, then kill. Idempotent, and never throws. */
43
+ shutdown(): Promise<void>;
44
+ /** True once the watchdog fired — a run that ends after this ended because of it. */
45
+ readonly fired: boolean;
46
+ }
47
+
48
+ export function createWedgeGuard(init: WedgeGuardInit): WedgeGuard {
49
+ const idleMs = init.idleMs ?? DEFAULT_IDLE_MS;
50
+ const graceMs = init.graceMs ?? DEFAULT_GRACE_MS;
51
+ const controller = new AbortController();
52
+ let lastTouch = init.clock.monotonic();
53
+ let stopped = false;
54
+ let fired = false;
55
+
56
+ const watch = async (): Promise<void> => {
57
+ while (!stopped) {
58
+ await init.clock.sleep(WATCH_POLL_MS);
59
+ if (stopped) return;
60
+ if (init.clock.monotonic() - lastTouch < idleMs) continue;
61
+ fired = true;
62
+ stopped = true;
63
+ // Kill FIRST, abort second. The abort is what the awaiting code sees; the kill is what
64
+ // makes the socket it is blocked on close. Reversing them leaves the run cancelled and the
65
+ // browser alive, which is the zombie half of the same incident.
66
+ init.kill();
67
+ controller.abort(wedged(init.what, idleMs));
68
+ }
69
+ };
70
+ void watch();
71
+
72
+ return {
73
+ signal: controller.signal,
74
+ get fired(): boolean {
75
+ return fired;
76
+ },
77
+ touch(): void {
78
+ lastTouch = init.clock.monotonic();
79
+ },
80
+ async shutdown(): Promise<void> {
81
+ if (stopped) return;
82
+ stopped = true;
83
+ let quit = false;
84
+ // A ceiling, not a hope: whichever finishes first wins, and if it is the clock the process
85
+ // is killed. `browser.close()` on a wedged renderer never returns.
86
+ await Promise.race([
87
+ init.quit().then(
88
+ () => {
89
+ quit = true;
90
+ },
91
+ () => {
92
+ quit = false;
93
+ },
94
+ ),
95
+ init.clock.sleep(graceMs),
96
+ ]);
97
+ if (!quit) init.kill();
98
+ },
99
+ };
100
+ }