@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 +3 -2
- package/package.json +5 -5
- package/src/error-throws.ts +22 -0
- package/src/errors.ts +10 -0
- package/src/page-over-target.ts +6 -0
- package/src/scrape.ts +3 -2
- package/src/watchdog.ts +29 -5
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @ultimat3/scraping
|
|
2
2
|
|
|
3
|
-
Browser automation as a **job**. `scrape()` returns a `JobHandle` —
|
|
4
|
-
|
|
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": "
|
|
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": "
|
|
34
|
-
"@ultimat3/jobs": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
36
|
-
"@ultimat3/storage": "
|
|
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
|
}
|
package/src/error-throws.ts
CHANGED
|
@@ -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',
|
package/src/page-over-target.ts
CHANGED
|
@@ -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.
|
|
2
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
},
|