@ultimat3/scraping 7.0.0 → 9.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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @ultimat3/scraping
2
2
 
3
- Browser automation as a **job**. `scrape()` returns a `JobHandle` — the rule's fourth instance
4
- after `llm()` (an action factory) and `backfill()` (a job factory). There is no ninth primitive.
3
+ Browser automation as a **job**. `scrape()` returns a `JobHandle` — one row of
4
+ `PRIMITIVE_FACTORIES` in `@ultimat3/core`, the derived list of every factory that ships. There is
5
+ no ninth primitive, and no ordinal here to go stale when the next factory lands.
5
6
 
6
7
  ```ts
7
8
  import { t } from '@ultimat3/schema';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "7.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,9 +30,9 @@
30
30
  "test": "bun test"
31
31
  },
32
32
  "dependencies": {
33
- "@ultimat3/core": "7.0.0",
34
- "@ultimat3/jobs": "7.0.0",
35
- "@ultimat3/schema": "7.0.0",
36
- "@ultimat3/storage": "7.0.0"
33
+ "@ultimat3/core": "9.0.0",
34
+ "@ultimat3/jobs": "9.0.0",
35
+ "@ultimat3/schema": "9.0.0",
36
+ "@ultimat3/storage": "9.0.0"
37
37
  }
38
38
  }
@@ -106,6 +106,28 @@ export const wedged = (what: string, idleMs: number): ScrapeError =>
106
106
  meta: { what, idleMs },
107
107
  });
108
108
 
109
+ /**
110
+ * The watchdog's SECOND way of ending a run, and its OWN code rather than `X_SCRAPE_WEDGED`.
111
+ *
112
+ * An exact `cause:` cannot rescue a wrong title. A reader who hits this runs
113
+ * `x errors explain X_SCRAPE_WEDGED`, reads "the browser stopped answering and was killed", and
114
+ * goes to investigate a page that is fine — the browser never stopped answering, the guard's own
115
+ * loop died on code the DEFINITION supplied. Two events with two different subsystems and two
116
+ * opposite fixes are two codes, and the classifications differ with them: a wedge is retryable,
117
+ * this is terminal (`errors.ts`).
118
+ *
119
+ * Reachable through the `ScrapeClock` seam and the driver's `kill()`, both of which a third party
120
+ * writes. Leaving the loop dead and quiet is the alternative, and that is incident #1 with no
121
+ * guard armed at all.
122
+ */
123
+ export const watchdogStopped = (what: string, thrown: unknown): ScrapeError =>
124
+ new ScrapeError({
125
+ code: 'X_SCRAPE_WATCHDOG_STOPPED',
126
+ cause: `the wedge watchdog for ${what} stopped measuring browser activity: ${renderThrowable(thrown)}`,
127
+ fix: 'drop the custom clock: from the scrape() definition — systemScrapeClock is the only ScrapeClock whose sleep() cannot reject — then re-run',
128
+ meta: { what },
129
+ });
130
+
109
131
  export const pageCrashed = (url: string): ScrapeError =>
110
132
  new ScrapeError({
111
133
  code: 'X_SCRAPE_PAGE_CRASHED',
package/src/errors.ts CHANGED
@@ -22,6 +22,7 @@ export const SCRAPE_OWNED_ERROR_CODES = [
22
22
  'X_SCRAPE_NOT_ACTIONABLE',
23
23
  'X_SCRAPE_TIMEOUT',
24
24
  'X_SCRAPE_WEDGED',
25
+ 'X_SCRAPE_WATCHDOG_STOPPED',
25
26
  'X_SCRAPE_PAGE_CRASHED',
26
27
  'X_SCRAPE_OUTPUT_INVALID',
27
28
  'X_SCRAPE_YIELD_COLLAPSED',
@@ -68,6 +69,7 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
68
69
  X_SCRAPE_NOT_ACTIONABLE: 'the element is present and cannot be acted on',
69
70
  X_SCRAPE_TIMEOUT: 'the step exceeded its wall-clock budget',
70
71
  X_SCRAPE_WEDGED: 'the browser stopped answering and was killed',
72
+ X_SCRAPE_WATCHDOG_STOPPED: 'the wedge watchdog stopped measuring and the run was ended',
71
73
  X_SCRAPE_PAGE_CRASHED: 'the renderer process died',
72
74
  X_SCRAPE_OUTPUT_INVALID: 'the extracted rows do not match the extract schema',
73
75
  X_SCRAPE_YIELD_COLLAPSED: 'the run succeeded and returned far too little',
@@ -138,6 +140,14 @@ export const SCRAPE_ERROR_RETRY = {
138
140
  // A declaration error, raised by `scrape()` before any attempt exists — there is no run to
139
141
  // retry, and the same definition would refuse identically forever.
140
142
  X_SCRAPE_YIELD_HISTORY_MISSING: 'terminal',
143
+ // TERMINAL where its sibling `X_SCRAPE_WEDGED` is retryable, and the difference IS the reason
144
+ // the two codes are separate. A wedge is the site or the browser being slow — the definition of
145
+ // "run it again and it may go differently". This is the guard's own loop dying on code the
146
+ // DEFINITION supplied: a `ScrapeClock` an app wrote, reached identically on attempt 2. Retrying
147
+ // launches a real browser five times to die at the first poll, and on an authenticated target
148
+ // that is five arrivals at a login for no chance of a different answer — the rule this whole
149
+ // table is written to, stated at the top of it. The fix is an edit, so a human decides.
150
+ X_SCRAPE_WATCHDOG_STOPPED: 'terminal',
141
151
  X_SCRAPE_ROBOTS_DISALLOWED: 'terminal',
142
152
  X_SCRAPE_FIXTURE_MISSING: 'terminal',
143
153
  X_SCRAPE_FIXTURE_STALE: 'terminal',
@@ -192,6 +192,12 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
192
192
  };
193
193
  return {
194
194
  ...frame,
195
+ // The DOCUMENT's URL is asked of the target, never of the frame's cached `lastUrl`. `ScrapeFrame`
196
+ // resolves its target asynchronously and `url()` is synchronous, so a child frame can only ever
197
+ // answer from a cache refreshed on its last wait — but the page HOLDS its target, so it has no
198
+ // such excuse. Spreading `...frame` without this override made `page.url()` answer the seed
199
+ // (`about:blank`) after every `goto`, which is what `x shot` reported as `finalUrl`.
200
+ url: () => target.url(),
195
201
  async goto(url, options): Promise<void> {
196
202
  await guardNavigation(url, ctx);
197
203
  await ctx.pace?.(ctx.signal);
package/src/scrape.ts CHANGED
@@ -1,5 +1,6 @@
1
- // `scrape()` — a browser run, declared as a `job` and NOT as a ninth primitive. The rule's fourth
2
- // instance after `llm()` (an action factory) and `backfill()` (a job factory).
1
+ // `scrape()` — a browser run, declared as a `job` and NOT as a ninth primitive. One row of
2
+ // `PRIMITIVE_FACTORIES` in `@ultimat3/core`, which is the derived list of every factory that
3
+ // ships; an ordinal written here would be wrong the moment the next one lands, and was.
3
4
  //
4
5
  // It is a job by every field of the definition, not by analogy: a scrape has an input schema, a
5
6
  // tenant, a retry policy, a timeout, a concurrency cap, a queue, and — decisively — a REQUIRED
package/src/watchdog.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // graceful-quit CEILING on shutdown, past which the same kill runs.
12
12
 
13
13
  import type { ScrapeClock } from './clock';
14
- import { wedged } from './error-throws';
14
+ import { watchdogStopped, wedged } from './error-throws';
15
15
 
16
16
  export const DEFAULT_IDLE_MS = 120_000;
17
17
  export const DEFAULT_GRACE_MS = 5_000;
@@ -41,7 +41,10 @@ export interface WedgeGuard {
41
41
  touch(): void;
42
42
  /** Graceful quit under the ceiling, then kill. Idempotent, and never throws. */
43
43
  shutdown(): Promise<void>;
44
- /** True once the watchdog fired — a run that ends after this ended because of it. */
44
+ /**
45
+ * True once the watchdog ended the run — the idle budget passed, or the guard's own loop died
46
+ * and stopped measuring. Either way a run that ends after this ended because of the watchdog.
47
+ */
45
48
  readonly fired: boolean;
46
49
  }
47
50
 
@@ -50,7 +53,13 @@ export function createWedgeGuard(init: WedgeGuardInit): WedgeGuard {
50
53
  const graceMs = init.graceMs ?? DEFAULT_GRACE_MS;
51
54
  const controller = new AbortController();
52
55
  let lastTouch = init.clock.monotonic();
56
+ // TWO latches, not one. `stopped` ends the watch loop; `shuttingDown` guards the teardown. They
57
+ // were the same flag, so a fire — which sets `stopped` — made `shutdown()` return on its first
58
+ // line and NEVER call `quit()`. `localBrowser()` hid it, because the fire's `kill()` still
59
+ // reaches a pid; `remoteBrowser()` is `driver-cdp.ts`'s primary path, `browser.process()` is
60
+ // `null` there, and `browser.close()` is then the only thing that ends the paid remote session.
53
61
  let stopped = false;
62
+ let shuttingDown = false;
54
63
  let fired = false;
55
64
 
56
65
  const watch = async (): Promise<void> => {
@@ -67,7 +76,17 @@ export function createWedgeGuard(init: WedgeGuardInit): WedgeGuard {
67
76
  controller.abort(wedged(init.what, idleMs));
68
77
  }
69
78
  };
70
- void watch();
79
+ // Never floating. `ScrapeClock` is a seam an app implements and `init.kill()` comes from a
80
+ // driver, so this loop can reject on somebody else's code — and a bare `void` turned that into
81
+ // an unhandled rejection, a process-level event belonging to no run, with the guard silently
82
+ // dead behind it. A guard that stopped measuring is incident #1 with nothing armed, so the run
83
+ // is ended with an instruction instead of left unwatched.
84
+ void watch().catch((thrown: unknown) => {
85
+ if (stopped) return;
86
+ stopped = true;
87
+ fired = true;
88
+ controller.abort(watchdogStopped(init.what, thrown));
89
+ });
71
90
 
72
91
  return {
73
92
  signal: controller.signal,
@@ -78,7 +97,8 @@ export function createWedgeGuard(init: WedgeGuardInit): WedgeGuard {
78
97
  lastTouch = init.clock.monotonic();
79
98
  },
80
99
  async shutdown(): Promise<void> {
81
- if (stopped) return;
100
+ if (shuttingDown) return;
101
+ shuttingDown = true;
82
102
  stopped = true;
83
103
  let quit = false;
84
104
  // A ceiling, not a hope: whichever finishes first wins, and if it is the clock the process
@@ -92,7 +112,11 @@ export function createWedgeGuard(init: WedgeGuardInit): WedgeGuard {
92
112
  quit = false;
93
113
  },
94
114
  ),
95
- init.clock.sleep(graceMs),
115
+ // The ceiling's own sleep is caught for the same reason the quit is: this runs in
116
+ // `runScrape`'s `finally`, `close()` is documented never to throw, and a clock that
117
+ // rejected here would replace the run's real failure with a teardown one. A ceiling that
118
+ // cannot be timed has already expired, which lands on `kill()` — the safe direction.
119
+ init.clock.sleep(graceMs).catch(() => undefined),
96
120
  ]);
97
121
  if (!quit) init.kill();
98
122
  },