@ultimat3/scraping 22.15.0 → 23.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 +184 -12
- package/package.json +6 -5
- package/src/artifacts.ts +3 -2
- package/src/auth.ts +66 -8
- package/src/cdp-port.ts +10 -0
- package/src/cdp-resolver.ts +81 -0
- package/src/clock.ts +34 -0
- package/src/driver-cdp.ts +164 -18
- package/src/driver.ts +25 -1
- package/src/error-throws-session.ts +74 -0
- package/src/error-throws.ts +35 -2
- package/src/errors.ts +10 -0
- package/src/event-prompt.ts +133 -0
- package/src/events.ts +6 -0
- package/src/http-recorded.ts +4 -0
- package/src/http.ts +9 -1
- package/src/index.ts +40 -4
- package/src/offline-session.ts +6 -0
- package/src/page-over-target.ts +4 -0
- package/src/scrape-run.ts +82 -11
- package/src/scrape.ts +73 -6
- package/src/secret-scan.ts +17 -0
- package/src/secrets.ts +29 -4
- package/src/session-state.ts +73 -10
- package/src/url-secrets.ts +78 -0
- package/src/usage.ts +101 -0
- /package/src/{driver-fixture.ts → driver-recorded.ts} +0 -0
package/src/http-recorded.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { HttpRecording } from './recording';
|
|
|
15
15
|
import type { NetworkRing } from './rings';
|
|
16
16
|
import type { RobotsGate } from './robots';
|
|
17
17
|
import type { ScrapeSecrets } from './secrets';
|
|
18
|
+
import type { UsageMeter } from './usage';
|
|
18
19
|
|
|
19
20
|
export type HttpRecordingLookup = (
|
|
20
21
|
method: string,
|
|
@@ -36,6 +37,8 @@ export interface RecordedHttpInit {
|
|
|
36
37
|
readonly maxAgeMs?: number | undefined;
|
|
37
38
|
/** The SAME bag the live leg holds, so a recorded 4xx body redacts the way a real one does. */
|
|
38
39
|
readonly secrets?: ScrapeSecrets | undefined;
|
|
40
|
+
/** The SAME meter the live leg counts into, so a fixture run reports the usage a real one would. */
|
|
41
|
+
readonly usage?: UsageMeter | undefined;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/** `GET https://api.example.com/v1/orders?page=2` -> `http-get-api-example-com-v1-orders-page-2`. */
|
|
@@ -70,6 +73,7 @@ export function recordedHttp(init: RecordedHttpInit): ScrapeHttp {
|
|
|
70
73
|
resourceType: 'fetch',
|
|
71
74
|
at: init.clock.now().getTime(),
|
|
72
75
|
});
|
|
76
|
+
init.usage?.httpRequest(new TextEncoder().encode(found.body).length);
|
|
73
77
|
return responseOver(
|
|
74
78
|
url,
|
|
75
79
|
found.status,
|
package/src/http.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { RobotsGate } from './robots';
|
|
|
25
25
|
import type { ScrapeSecrets } from './secrets';
|
|
26
26
|
import { redactSecrets } from './secrets';
|
|
27
27
|
import type { SessionSnapshot } from './session-state';
|
|
28
|
+
import type { UsageMeter } from './usage';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Just the call. `typeof fetch` also carries `preconnect`, which no test double and no app wrapper
|
|
@@ -110,6 +111,8 @@ export interface HttpTransportInit {
|
|
|
110
111
|
* until 2026-08-24, so nothing on this leg COULD redact.
|
|
111
112
|
*/
|
|
112
113
|
readonly secrets?: ScrapeSecrets | undefined;
|
|
114
|
+
/** The run's meter: one `httpRequest` per hop on the wire, with the body bytes read for it. */
|
|
115
|
+
readonly usage?: UsageMeter | undefined;
|
|
113
116
|
readonly fetch?: ScrapeFetch | undefined;
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -213,8 +216,11 @@ const readResponse = async (
|
|
|
213
216
|
response: Response,
|
|
214
217
|
maxBytes: number,
|
|
215
218
|
secrets: ScrapeSecrets | undefined,
|
|
219
|
+
usage: UsageMeter | undefined,
|
|
216
220
|
): Promise<ScrapeResponse> => {
|
|
217
221
|
const capped = await readWithinLimit(response.body, maxBytes);
|
|
222
|
+
// Counted on BOTH branches: the bytes a refused body cost were read before the refusal.
|
|
223
|
+
usage?.httpRequest('over' in capped ? capped.over : capped.bytes.length);
|
|
218
224
|
if ('over' in capped) throw bodyTooLarge(url, capped.over, maxBytes);
|
|
219
225
|
const body = new TextDecoder().decode(capped.bytes);
|
|
220
226
|
return responseOver(
|
|
@@ -345,7 +351,9 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
|
|
|
345
351
|
hop.body,
|
|
346
352
|
);
|
|
347
353
|
if (next === undefined)
|
|
348
|
-
return await readResponse(hop.url, response, maxBytes, init.secrets);
|
|
354
|
+
return await readResponse(hop.url, response, maxBytes, init.secrets, init.usage);
|
|
355
|
+
// A followed hop was a request on the wire too; its body is discarded unread.
|
|
356
|
+
init.usage?.httpRequest(0);
|
|
349
357
|
// Discarded BEFORE the refusal, not after it: a throw over an unread stream holds that
|
|
350
358
|
// hop's socket until the collector reaches it, and the refusal path is exactly the one a
|
|
351
359
|
// hostile chain drives ten times per request.
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
export type {
|
|
13
13
|
AuthContext,
|
|
14
14
|
PromptHandler,
|
|
15
|
+
PromptInit,
|
|
15
16
|
PromptRequest,
|
|
16
17
|
ScrapeAuth,
|
|
17
18
|
} from './auth';
|
|
@@ -29,11 +30,21 @@ export type {
|
|
|
29
30
|
CdpScreenshotOptions,
|
|
30
31
|
CdpSessionLike,
|
|
31
32
|
} from './cdp-port';
|
|
33
|
+
export type { CdpResolution, CdpResolver, CdpResolverRequest } from './cdp-resolver';
|
|
32
34
|
export { snapshotExpression } from './cdp-snapshot';
|
|
33
35
|
export type { CdpTargetInit } from './cdp-target';
|
|
34
36
|
export { CDP_DRIVER, cdpTarget } from './cdp-target';
|
|
35
37
|
export type { Deadline, ScrapeClock, TestScrapeClock } from './clock';
|
|
36
|
-
export {
|
|
38
|
+
export {
|
|
39
|
+
deadline,
|
|
40
|
+
noWaitClock,
|
|
41
|
+
resetScrapeClock,
|
|
42
|
+
scrapeClock,
|
|
43
|
+
setScrapeClock,
|
|
44
|
+
systemScrapeClock,
|
|
45
|
+
testClock,
|
|
46
|
+
throwIfAborted,
|
|
47
|
+
} from './clock';
|
|
37
48
|
export type { ColorScheme } from './color-scheme';
|
|
38
49
|
export { COLOR_SCHEMES, isColorScheme } from './color-scheme';
|
|
39
50
|
export { cookieHeaderFor } from './cookie-scope';
|
|
@@ -43,14 +54,15 @@ export type { BrowserOptions, LocalBrowserOptions, RemoteBrowserOptions } from '
|
|
|
43
54
|
export { localBrowser, remoteBrowser } from './driver-cdp';
|
|
44
55
|
export type { FakeBrowserOptions, FakePageOptions, FakePages } from './driver-fake';
|
|
45
56
|
export { FAKE_DRIVER, FAKE_PAGE_URL, fakeBrowser, fakePage, recordingsOf } from './driver-fake';
|
|
46
|
-
export type { FixtureBrowserOptions } from './driver-
|
|
47
|
-
export { FIXTURE_DRIVER, fixtureBrowser } from './driver-
|
|
57
|
+
export type { FixtureBrowserOptions } from './driver-recorded';
|
|
58
|
+
export { FIXTURE_DRIVER, fixtureBrowser } from './driver-recorded';
|
|
48
59
|
export {
|
|
49
60
|
authFailed,
|
|
50
61
|
blocked,
|
|
51
62
|
bodyTooLarge,
|
|
52
63
|
browserUnreachable,
|
|
53
64
|
cdpAttachFailed,
|
|
65
|
+
cdpResolveFailed,
|
|
54
66
|
downloadTimeout,
|
|
55
67
|
driverUnknown,
|
|
56
68
|
fixtureMissing,
|
|
@@ -75,6 +87,12 @@ export {
|
|
|
75
87
|
wedged,
|
|
76
88
|
yieldCollapsed,
|
|
77
89
|
} from './error-throws';
|
|
90
|
+
export {
|
|
91
|
+
egressInPayload,
|
|
92
|
+
egressUnsupported,
|
|
93
|
+
promptAnswerInvalid,
|
|
94
|
+
promptTimedOut,
|
|
95
|
+
} from './error-throws-session';
|
|
78
96
|
export type { ScrapeErrorCode, ScrapeErrorInit, ScrapeOwnedErrorCode } from './errors';
|
|
79
97
|
export {
|
|
80
98
|
isRetryableScrapeError,
|
|
@@ -86,6 +104,14 @@ export {
|
|
|
86
104
|
SCRAPE_OWNED_ERROR_CODES,
|
|
87
105
|
ScrapeError,
|
|
88
106
|
} from './errors';
|
|
107
|
+
export type { AnswerPromptInput, EventPromptOptions } from './event-prompt';
|
|
108
|
+
export {
|
|
109
|
+
answerPrompt,
|
|
110
|
+
DEFAULT_PROMPT_ANSWER_TTL_MS,
|
|
111
|
+
DEFAULT_PROMPT_POLL_MS,
|
|
112
|
+
eventPrompt,
|
|
113
|
+
promptEventName,
|
|
114
|
+
} from './event-prompt';
|
|
89
115
|
export type { ScrapeEventFields, StepEvent } from './events';
|
|
90
116
|
export type { YieldCheck, YieldExpectation, YieldGuardInput, YieldHistory } from './expect';
|
|
91
117
|
export {
|
|
@@ -164,6 +190,7 @@ export type {
|
|
|
164
190
|
ScrapeDefinition,
|
|
165
191
|
ScrapeReport,
|
|
166
192
|
ScrapeRunArgs,
|
|
193
|
+
ScrapeSettled,
|
|
167
194
|
} from './scrape';
|
|
168
195
|
export { scrape } from './scrape';
|
|
169
196
|
export { DEFAULT_PAGE_TIMEOUT_MS, runScrape } from './scrape-run';
|
|
@@ -177,10 +204,16 @@ export {
|
|
|
177
204
|
safeNetwork,
|
|
178
205
|
safePageErrors,
|
|
179
206
|
} from './secrets';
|
|
180
|
-
export type {
|
|
207
|
+
export type {
|
|
208
|
+
ScrapeSessionStore,
|
|
209
|
+
SessionSnapshot,
|
|
210
|
+
SessionState,
|
|
211
|
+
StorageSessionStoreOptions,
|
|
212
|
+
} from './session-state';
|
|
181
213
|
export {
|
|
182
214
|
memorySessionStore,
|
|
183
215
|
parseSessionState,
|
|
216
|
+
SESSION_SEAL_PURPOSE,
|
|
184
217
|
sessionDigest,
|
|
185
218
|
sessionKeyFor,
|
|
186
219
|
storageSessionStore,
|
|
@@ -196,5 +229,8 @@ export type {
|
|
|
196
229
|
ScrapeDownloadFile,
|
|
197
230
|
ScrapeTarget,
|
|
198
231
|
} from './target';
|
|
232
|
+
export { endpointLabel, urlSecretValues } from './url-secrets';
|
|
233
|
+
export type { RunUsageMeter, ScrapeUsage, UsageMeter } from './usage';
|
|
234
|
+
export { createUsageMeter } from './usage';
|
|
199
235
|
export type { WedgeGuard, WedgeGuardInit } from './watchdog';
|
|
200
236
|
export { DEFAULT_GRACE_MS } from './watchdog';
|
package/src/offline-session.ts
CHANGED
|
@@ -42,6 +42,10 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
|
|
|
42
42
|
if (init.session.restore !== undefined) await target.restore(init.session.restore);
|
|
43
43
|
return {
|
|
44
44
|
driver: init.driver,
|
|
45
|
+
// An offline session dials nothing — except the run's own `/robots.txt` read, which is a real
|
|
46
|
+
// request. Reporting the run's exit is what makes that one request leave through it, exactly
|
|
47
|
+
// as it would on the driver this recording stands in for.
|
|
48
|
+
...(init.session.proxy === undefined ? {} : { proxy: init.session.proxy }),
|
|
45
49
|
page: pageOverTarget(target, {
|
|
46
50
|
clock: init.session.clock,
|
|
47
51
|
allowHosts: init.session.rules.allowHosts,
|
|
@@ -51,6 +55,7 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
|
|
|
51
55
|
signal: init.session.signal,
|
|
52
56
|
onActivity: init.session.onActivity,
|
|
53
57
|
pace: init.session.pace,
|
|
58
|
+
usage: init.session.usage,
|
|
54
59
|
}),
|
|
55
60
|
http: recordedHttp({
|
|
56
61
|
lookup: init.http,
|
|
@@ -63,6 +68,7 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
|
|
|
63
68
|
// The same bag, for the same reason: a redaction only the live leg performs is one no
|
|
64
69
|
// fixture can prove.
|
|
65
70
|
secrets: init.session.secrets,
|
|
71
|
+
usage: init.session.usage,
|
|
66
72
|
maxAgeMs: init.maxAgeMs,
|
|
67
73
|
}),
|
|
68
74
|
close: () => target.close(),
|
package/src/page-over-target.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
ScrapeTarget,
|
|
36
36
|
} from './target';
|
|
37
37
|
import { ROOT_SELECTOR } from './target';
|
|
38
|
+
import type { UsageMeter } from './usage';
|
|
38
39
|
|
|
39
40
|
export interface PageContext {
|
|
40
41
|
readonly clock: ScrapeClock;
|
|
@@ -51,6 +52,8 @@ export interface PageContext {
|
|
|
51
52
|
readonly secrets?: ScrapeSecrets | undefined;
|
|
52
53
|
readonly robots?: RobotsGate | undefined;
|
|
53
54
|
readonly signal?: AbortSignal | undefined;
|
|
55
|
+
/** The run's meter. Counts a navigation where the wedge watchdog is told about one. */
|
|
56
|
+
readonly usage?: UsageMeter | undefined;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Mutable, shared by the page and every frame under it: a taint is a property of the SESSION. */
|
|
@@ -251,6 +254,7 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
|
|
|
251
254
|
await guardNavigation(url, ctx);
|
|
252
255
|
await ctx.pace?.(ctx.signal);
|
|
253
256
|
ctx.onActivity?.();
|
|
257
|
+
ctx.usage?.navigation();
|
|
254
258
|
await target.goto(url, {
|
|
255
259
|
timeoutMs: options?.timeout ?? ctx.defaultTimeoutMs,
|
|
256
260
|
signal: ctx.signal,
|
package/src/scrape-run.ts
CHANGED
|
@@ -22,10 +22,12 @@ import {
|
|
|
22
22
|
persistSession,
|
|
23
23
|
restorableSession,
|
|
24
24
|
} from './auth';
|
|
25
|
-
import {
|
|
25
|
+
import { scrapeClock } from './clock';
|
|
26
26
|
import type { ScrapeSession } from './driver';
|
|
27
27
|
import { scrapeDriver } from './driver';
|
|
28
28
|
import { driverUnknown, outputInvalid } from './error-throws';
|
|
29
|
+
import { egressInPayload } from './error-throws-session';
|
|
30
|
+
import type { ScrapeEventFields } from './events';
|
|
29
31
|
import { scrapeLogger, withStepEvent } from './events';
|
|
30
32
|
import { guardYield } from './expect';
|
|
31
33
|
import { burnsSession, errorCode, neverRetried } from './failures';
|
|
@@ -33,8 +35,12 @@ import { createPacer, DEFAULT_NAVIGATION_RATE } from './rate';
|
|
|
33
35
|
import { runRecovery } from './recover';
|
|
34
36
|
import { createRobotsGate } from './robots';
|
|
35
37
|
import type { ScrapeDefinition, ScrapeReport } from './scrape';
|
|
36
|
-
import {
|
|
38
|
+
import { containsSecret } from './secret-scan';
|
|
39
|
+
import { createSecretBag, MIN_REDACTABLE_LENGTH } from './secrets';
|
|
37
40
|
import { sessionKeyFor } from './session-state';
|
|
41
|
+
import { hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
|
|
42
|
+
import type { ScrapeUsage } from './usage';
|
|
43
|
+
import { createUsageMeter, rememberFailedUsage } from './usage';
|
|
38
44
|
|
|
39
45
|
/** `ctx.actor` is a structural read: this package never imports the auth types (tier 2). */
|
|
40
46
|
const orgOf = (ctx: unknown): string | undefined => {
|
|
@@ -63,11 +69,26 @@ const toMillis = (value: string | number | undefined, fallback: number, option:
|
|
|
63
69
|
|
|
64
70
|
export const DEFAULT_PAGE_TIMEOUT_MS = 30_000;
|
|
65
71
|
|
|
72
|
+
/**
|
|
73
|
+
* True when the exit's password is readable out of the run's own input — which is the queue row.
|
|
74
|
+
* Both spellings, because a password with a quote or a backslash is stored JSON-escaped; and
|
|
75
|
+
* nothing shorter than `MIN_REDACTABLE_LENGTH`, which is a substring of ordinary payload text.
|
|
76
|
+
*/
|
|
77
|
+
function credentialInPayload(egress: string, input: unknown): boolean {
|
|
78
|
+
const { password } = splitCredentials(egress);
|
|
79
|
+
if (password.length < MIN_REDACTABLE_LENGTH) return false;
|
|
80
|
+
const payload = JSON.stringify(input ?? null);
|
|
81
|
+
const escaped = JSON.stringify(password).slice(1, -1);
|
|
82
|
+
// Both scans always run: an `||` would skip the second exactly when the first found the secret.
|
|
83
|
+
const plain = containsSecret(payload, password);
|
|
84
|
+
return containsSecret(payload, escaped) || plain;
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
export async function runScrape<I, Row>(
|
|
67
88
|
definition: ScrapeDefinition<I, Row>,
|
|
68
89
|
args: JobRunArgs<I>,
|
|
69
90
|
): Promise<ScrapeReport<Row>> {
|
|
70
|
-
const clock = definition.clock ??
|
|
91
|
+
const clock = definition.clock ?? scrapeClock();
|
|
71
92
|
const driver = definition.driver ?? scrapeDriver();
|
|
72
93
|
// The scrape's name goes in the SCRAPE slot: there is no driver here to name, which is the
|
|
73
94
|
// whole failure.
|
|
@@ -79,6 +100,21 @@ export async function runScrape<I, Row>(
|
|
|
79
100
|
driver: driver.name,
|
|
80
101
|
});
|
|
81
102
|
const secrets = createSecretBag(definition.secrets ?? []);
|
|
103
|
+
// The run's exit, resolved HERE — in the worker, under the job's tenant — so what the queue row
|
|
104
|
+
// carries is the id of whatever holds it. An empty answer is "none", the same as absent: a
|
|
105
|
+
// driver handed `''` would dial `--proxy-server=` and call that a decision.
|
|
106
|
+
const declared = await definition.egress?.(args.input, args.ctx);
|
|
107
|
+
const egress = declared === undefined || declared === '' ? undefined : declared;
|
|
108
|
+
if (egress !== undefined && hasCredentials(egress)) {
|
|
109
|
+
// Concealed HERE and not only in the driver that dials it: an offline or third-party driver
|
|
110
|
+
// never parses the exit, and the page artifact is redacted from this one bag either way.
|
|
111
|
+
for (const value of urlSecretValues(egress)) secrets.conceal(value);
|
|
112
|
+
// Before the browser opens: the credential is in `x_jobs` already, and running would only add
|
|
113
|
+
// a session to an exposure somebody has to rotate.
|
|
114
|
+
if (credentialInPayload(egress, args.input)) {
|
|
115
|
+
throw egressInPayload({ scrape: definition.name, egress });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
82
118
|
const rules = { allowHosts: definition.allowHosts, block: definition.block };
|
|
83
119
|
// Screened here as well as in `scrape()`, and the two are not one check written twice: that one
|
|
84
120
|
// refuses the DECLARATION and never sees a definition assembled by hand, which `runScrape` is
|
|
@@ -113,13 +149,19 @@ export async function runScrape<I, Row>(
|
|
|
113
149
|
// opening a session first would already have spent an identity on a run that cannot succeed.
|
|
114
150
|
const restored = await restorableSession(plan);
|
|
115
151
|
const pageTimeoutMs = toMillis(definition.pageTimeout, DEFAULT_PAGE_TIMEOUT_MS, 'pageTimeout');
|
|
116
|
-
// The exit the session dials, readable only AFTER `driver.open()` —
|
|
117
|
-
// option and the gate below is an argument to `open()`, so the gate asks
|
|
118
|
-
// instead of being handed a value that cannot exist yet. Every read happens
|
|
119
|
-
// navigation, which is after this is assigned.
|
|
152
|
+
// The exit the session dials, readable only AFTER `driver.open()` — a driver with no run exit
|
|
153
|
+
// falls back to its own option, and the gate below is an argument to `open()`, so the gate asks
|
|
154
|
+
// for it per read instead of being handed a value that cannot exist yet. Every read happens
|
|
155
|
+
// during a navigation, which is after this is assigned.
|
|
120
156
|
let sessionProxy: string | undefined;
|
|
157
|
+
// Before `open()`: the time a rented browser takes to arrive is time it was held.
|
|
158
|
+
const usage = createUsageMeter(clock);
|
|
121
159
|
const session = await driver.open({
|
|
122
160
|
name: definition.name,
|
|
161
|
+
logger,
|
|
162
|
+
runId: args.runId,
|
|
163
|
+
...(egress === undefined ? {} : { proxy: egress }),
|
|
164
|
+
usage,
|
|
123
165
|
rules,
|
|
124
166
|
clock,
|
|
125
167
|
timeoutMs: pageTimeoutMs,
|
|
@@ -151,10 +193,21 @@ export async function runScrape<I, Row>(
|
|
|
151
193
|
ensureAuthenticated({
|
|
152
194
|
...plan,
|
|
153
195
|
input: args.input,
|
|
196
|
+
runId: args.runId,
|
|
154
197
|
page: session.page,
|
|
155
198
|
secrets,
|
|
156
199
|
restored,
|
|
157
|
-
prompt: createPrompt(
|
|
200
|
+
prompt: createPrompt({
|
|
201
|
+
scrape: definition.name,
|
|
202
|
+
handler: definition.prompt,
|
|
203
|
+
input: args.input,
|
|
204
|
+
page: session.page,
|
|
205
|
+
runId: args.runId,
|
|
206
|
+
clock,
|
|
207
|
+
signal: args.ctx.signal,
|
|
208
|
+
secrets,
|
|
209
|
+
onAnswered: () => usage.promptAnswered(),
|
|
210
|
+
}),
|
|
158
211
|
}),
|
|
159
212
|
);
|
|
160
213
|
// Persisted after a LOGIN only, never after a reuse: rewriting the record on every run
|
|
@@ -181,16 +234,23 @@ export async function runScrape<I, Row>(
|
|
|
181
234
|
// alone made a run that blocked 5,000 images print 200 and discarded the one number
|
|
182
235
|
// (`Ring.dropped`) that exists to say "you are not seeing it all".
|
|
183
236
|
const networkDropped = session.page.networkDropped();
|
|
184
|
-
|
|
237
|
+
const used = usage.snapshot(session.browserCost);
|
|
238
|
+
logger.info('scrape.ok', { rows: rows.length, refused, ...usageFields(used) });
|
|
185
239
|
return {
|
|
186
240
|
scrape: definition.name,
|
|
187
241
|
rows,
|
|
188
242
|
artifacts: artifact.saved.map((ref) => ref.key),
|
|
189
243
|
refused,
|
|
190
244
|
networkDropped,
|
|
245
|
+
usage: used,
|
|
191
246
|
};
|
|
192
247
|
} catch (thrown) {
|
|
193
|
-
|
|
248
|
+
// A failed run has no report, and it was billed all the same: the counts ride the one line
|
|
249
|
+
// every failure already writes.
|
|
250
|
+
const used = usage.snapshot(session.browserCost);
|
|
251
|
+
logger.error('scrape.failed', { code: errorCode(thrown), ...usageFields(used) });
|
|
252
|
+
// Kept for `onSettled`, and only when somebody declared one to read it.
|
|
253
|
+
if (definition.onSettled !== undefined) rememberFailedUsage(args.runId, used);
|
|
194
254
|
if (errorCode(thrown) === 'X_SCRAPE_AUTH_FAILED') {
|
|
195
255
|
await recordSessionOutcome('session.refuse', logger, () => markRefused(plan));
|
|
196
256
|
} else if (burnsSession(thrown)) {
|
|
@@ -204,6 +264,15 @@ export async function runScrape<I, Row>(
|
|
|
204
264
|
}
|
|
205
265
|
}
|
|
206
266
|
|
|
267
|
+
/** The counts as log fields. `browserCost` stays off the line: it is on the report, as `Money`. */
|
|
268
|
+
const usageFields = (used: ScrapeUsage): ScrapeEventFields => ({
|
|
269
|
+
browserMs: used.browserMs,
|
|
270
|
+
navigations: used.navigations,
|
|
271
|
+
httpRequests: used.httpRequests,
|
|
272
|
+
bytesIn: used.bytesIn,
|
|
273
|
+
promptsAnswered: used.promptsAnswered,
|
|
274
|
+
});
|
|
275
|
+
|
|
207
276
|
/**
|
|
208
277
|
* The tombstone or the burn, on the way out — best effort, and it may NEVER replace the failure
|
|
209
278
|
* that caused it. `markRefused` reaches `store.save()` reaches `storage.put()`, so an S3 503 or an
|
|
@@ -250,11 +319,13 @@ async function bodyWithRecovery<I, Row>(
|
|
|
250
319
|
secrets,
|
|
251
320
|
artifact,
|
|
252
321
|
attempt: args.attempt,
|
|
322
|
+
finalAttempt: args.finalAttempt,
|
|
323
|
+
progress: args.progress,
|
|
253
324
|
runId: args.runId,
|
|
254
325
|
});
|
|
255
326
|
try {
|
|
256
327
|
return await withStepEvent(
|
|
257
|
-
{ name: 'body', logger, clock: definition.clock ??
|
|
328
|
+
{ name: 'body', logger, clock: definition.clock ?? scrapeClock(), attempt: args.attempt },
|
|
258
329
|
body,
|
|
259
330
|
);
|
|
260
331
|
} catch (thrown) {
|
package/src/scrape.ts
CHANGED
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
import type { Ctx } from '@ultimat3/core';
|
|
13
13
|
import { assert } from '@ultimat3/core';
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
JobCompleted,
|
|
16
|
+
JobConcurrency,
|
|
17
|
+
JobFailed,
|
|
18
|
+
JobHandle,
|
|
19
|
+
JobTenant,
|
|
20
|
+
ProgressFn,
|
|
21
|
+
RetryPolicy,
|
|
22
|
+
StepApi,
|
|
23
|
+
} from '@ultimat3/jobs';
|
|
15
24
|
import { DEFAULT_RETRY, job } from '@ultimat3/jobs';
|
|
16
25
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
17
26
|
import type { StorageDriver } from '@ultimat3/storage';
|
|
@@ -29,6 +38,8 @@ import type { ResourceType } from './rings';
|
|
|
29
38
|
import type { RobotsPolicy } from './robots';
|
|
30
39
|
import { runScrape } from './scrape-run';
|
|
31
40
|
import type { ScrapeSecrets } from './secrets';
|
|
41
|
+
import type { ScrapeUsage } from './usage';
|
|
42
|
+
import { takeFailedUsage } from './usage';
|
|
32
43
|
|
|
33
44
|
export interface ScrapeRunArgs<I> {
|
|
34
45
|
readonly input: I;
|
|
@@ -47,11 +58,16 @@ export interface ScrapeRunArgs<I> {
|
|
|
47
58
|
readonly secrets: ScrapeSecrets;
|
|
48
59
|
readonly artifact: ArtifactWriter;
|
|
49
60
|
readonly attempt: number;
|
|
61
|
+
/** The job's own: true when a failure of this attempt is not retried for want of attempts. */
|
|
62
|
+
readonly finalAttempt: boolean;
|
|
63
|
+
/** The job's own `progress(done, total, note?)` — what `x jobs show` and a dashboard read. */
|
|
64
|
+
readonly progress: ProgressFn;
|
|
50
65
|
readonly runId: string;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
export interface ScrapeArtifacts {
|
|
54
|
-
|
|
69
|
+
/** A thunk, read per write — `() => disk('artifacts')`: the app's disk exists only after boot. */
|
|
70
|
+
readonly storage?: (() => StorageDriver) | undefined;
|
|
55
71
|
/** Save the page's HTML when the run fails. On by default — it is the only forensic left. */
|
|
56
72
|
readonly onFailure?: boolean | undefined;
|
|
57
73
|
readonly prefix?: string | undefined;
|
|
@@ -93,9 +109,26 @@ export interface ScrapeDefinition<I, Row> {
|
|
|
93
109
|
* primary case, so this is declared rather than hand-rolled per app. See `auth.ts`.
|
|
94
110
|
*/
|
|
95
111
|
readonly auth?: ScrapeAuth<I> | undefined;
|
|
96
|
-
/**
|
|
97
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Where an out-of-band code comes from, for a site that asks for one after the password. The
|
|
114
|
+
* request carries the run's `input` and `runId`: what a handler ties the prompt to.
|
|
115
|
+
*/
|
|
116
|
+
readonly prompt?: PromptHandler<I> | undefined;
|
|
98
117
|
readonly driver?: ScrapeDriver | undefined;
|
|
118
|
+
/**
|
|
119
|
+
* The exit THIS run leaves through — a proxy URL, credentials included — RESOLVED IN THE WORKER,
|
|
120
|
+
* under the job's tenant: `async ({ connectionId }) => (await repo.connectionById(connectionId))
|
|
121
|
+
* ?.exit`. The input names the row; the row (a `.sealed()` column) holds the exit. An exit whose
|
|
122
|
+
* credential is ALSO in the input rode the queue payload and sits in `x_jobs` in the clear, and
|
|
123
|
+
* is refused before the browser opens (`X_SCRAPE_EGRESS_IN_PAYLOAD`).
|
|
124
|
+
*
|
|
125
|
+
* It reaches the driver as `SessionInit.proxy` and wins over the driver's own `proxy`; both legs
|
|
126
|
+
* and the robots read dial it. `undefined` leaves the driver's exit in force. A driver that
|
|
127
|
+
* cannot dial it refuses with `X_SCRAPE_EGRESS_UNSUPPORTED` rather than dialling another.
|
|
128
|
+
*/
|
|
129
|
+
readonly egress?:
|
|
130
|
+
| ((input: I, ctx: Ctx) => string | undefined | Promise<string | undefined>)
|
|
131
|
+
| undefined;
|
|
99
132
|
readonly retry?: RetryPolicy | undefined;
|
|
100
133
|
/** Per attempt, whole-run. `'5m'` or ms. */
|
|
101
134
|
readonly timeout?: string | number | undefined;
|
|
@@ -103,12 +136,33 @@ export interface ScrapeDefinition<I, Row> {
|
|
|
103
136
|
readonly pageTimeout?: string | number | undefined;
|
|
104
137
|
/** Kill the browser after this much silence from it. See `watchdog.ts`. */
|
|
105
138
|
readonly watchdog?: { readonly idleMs?: number; readonly graceMs?: number } | undefined;
|
|
106
|
-
|
|
139
|
+
/** The job's own field, unchanged: a number, or `{ key, limit, whenBusy }` for a cap per key. */
|
|
140
|
+
readonly concurrency?: JobConcurrency<I> | undefined;
|
|
107
141
|
readonly queue?: string | undefined;
|
|
142
|
+
/**
|
|
143
|
+
* Pins this definition's clock. Omit it: a run waits on the process's (`scrapeClock()`), which
|
|
144
|
+
* is the system clock and what a test replaces with `setScrapeClock()`.
|
|
145
|
+
*/
|
|
108
146
|
readonly clock?: ScrapeClock | undefined;
|
|
109
147
|
run(args: ScrapeRunArgs<I>): Promise<readonly unknown[]>;
|
|
148
|
+
/**
|
|
149
|
+
* The job's own `onSettled`, with what a scrape adds: a `completed` run hands over its
|
|
150
|
+
* `ScrapeReport` — rows, artifacts and `usage` — and a run that ended any other way carries the
|
|
151
|
+
* `usage` of its last attempt (`undefined` for a `refused` one, whose body never ran). Same
|
|
152
|
+
* guarantees as the job's: after the row is settled, under the job's tenant, AT MOST ONCE
|
|
153
|
+
* across a crash, and a hook that throws changes nothing.
|
|
154
|
+
*/
|
|
155
|
+
onSettled?(settled: ScrapeSettled<I, Row>): Promise<void>;
|
|
110
156
|
}
|
|
111
157
|
|
|
158
|
+
/** How one scrape run ended, as `ScrapeDefinition.onSettled` is told. */
|
|
159
|
+
export type ScrapeSettled<I, Row> =
|
|
160
|
+
| JobCompleted<I, ScrapeReport<Row>>
|
|
161
|
+
| (JobFailed<I> & {
|
|
162
|
+
/** What the last attempt used. A failed run has no report and was billed all the same. */
|
|
163
|
+
readonly usage: ScrapeUsage | undefined;
|
|
164
|
+
});
|
|
165
|
+
|
|
112
166
|
/** What one completed scrape reports — bounded, so `x jobs show` can print it. */
|
|
113
167
|
export interface ScrapeReport<Row> {
|
|
114
168
|
readonly scrape: string;
|
|
@@ -124,6 +178,8 @@ export interface ScrapeReport<Row> {
|
|
|
124
178
|
* "you are not seeing it all": `refused` was counted from what survived the bound.
|
|
125
179
|
*/
|
|
126
180
|
readonly networkDropped: number;
|
|
181
|
+
/** What this run used. A measurement: no quota is read from it and none is enforced. */
|
|
182
|
+
readonly usage: ScrapeUsage;
|
|
127
183
|
}
|
|
128
184
|
|
|
129
185
|
export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<I> {
|
|
@@ -145,7 +201,8 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
|
|
|
145
201
|
if (definition.expect?.maxDrop !== undefined && definition.history === undefined) {
|
|
146
202
|
throw yieldHistoryMissing(definition.name);
|
|
147
203
|
}
|
|
148
|
-
|
|
204
|
+
const onSettled = definition.onSettled?.bind(definition);
|
|
205
|
+
return job<I, ScrapeReport<Row>>({
|
|
149
206
|
name: definition.name,
|
|
150
207
|
input: definition.input,
|
|
151
208
|
idempotencyKey: definition.idempotencyKey,
|
|
@@ -155,5 +212,15 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
|
|
|
155
212
|
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
156
213
|
...(definition.concurrency === undefined ? {} : { concurrency: definition.concurrency }),
|
|
157
214
|
run: (args) => runScrape(definition, args),
|
|
215
|
+
...(onSettled === undefined
|
|
216
|
+
? {}
|
|
217
|
+
: {
|
|
218
|
+
onSettled: (settled) => {
|
|
219
|
+
// Read on EVERY ending, the completed one too: a run that failed an attempt and then
|
|
220
|
+
// completed must not leave that attempt's counts behind in this process.
|
|
221
|
+
const usage = takeFailedUsage(settled.runId);
|
|
222
|
+
return onSettled(settled.outcome === 'completed' ? settled : { ...settled, usage });
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
158
225
|
});
|
|
159
226
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Whether a secret occurs in a piece of text, without a search that stops at the first differing
|
|
2
|
+
// byte. `.includes(secret)` answers sooner the more of a window matches the secret's prefix — the
|
|
3
|
+
// oracle `scripts/secret-compare.ts` refuses — so every window is compared whole, in constant time.
|
|
4
|
+
|
|
5
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** True when `secret` occurs in `text`. Time depends on the two lengths, never on their bytes. */
|
|
8
|
+
export function containsSecret(text: string, secret: string): boolean {
|
|
9
|
+
const width = secret.length;
|
|
10
|
+
if (width === 0) return false;
|
|
11
|
+
let found = false;
|
|
12
|
+
for (let start = 0; start + width <= text.length; start += 1) {
|
|
13
|
+
// `timingSafeEqual` first, so a window after a hit is still compared rather than skipped.
|
|
14
|
+
found = timingSafeEqual(text.slice(start, start + width), secret) || found;
|
|
15
|
+
}
|
|
16
|
+
return found;
|
|
17
|
+
}
|
package/src/secrets.ts
CHANGED
|
@@ -14,6 +14,14 @@ export interface ScrapeSecrets {
|
|
|
14
14
|
readonly names: readonly string[];
|
|
15
15
|
/** The boxed value. Reading it out is `revealSecret()`, one greppable call site. */
|
|
16
16
|
get(name: string): Secret;
|
|
17
|
+
/**
|
|
18
|
+
* Add a value this run learned MID-RUN to the redaction set: a prompt answer, the credentials
|
|
19
|
+
* of the exit it dials, the connect URL of the browser it rented, a bearer the body read off a
|
|
20
|
+
* page. It has no name and cannot be read back — it exists only so `page.html()`, the rings and
|
|
21
|
+
* the failure artifact never carry it. The same floor applies: shorter than
|
|
22
|
+
* `MIN_REDACTABLE_LENGTH` is not redacted.
|
|
23
|
+
*/
|
|
24
|
+
conceal(value: string): void;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
/**
|
|
@@ -30,6 +38,7 @@ export function createSecretBag(
|
|
|
30
38
|
resolve: SecretResolver = fromEnvironment,
|
|
31
39
|
): ScrapeSecrets {
|
|
32
40
|
const boxed = new Map<string, Secret>();
|
|
41
|
+
const learned = new Set<string>();
|
|
33
42
|
for (const name of names) {
|
|
34
43
|
const value = resolve(name);
|
|
35
44
|
if (value === undefined || value === '') {
|
|
@@ -42,8 +51,11 @@ export function createSecretBag(
|
|
|
42
51
|
}
|
|
43
52
|
boxed.set(name, secret(value, name));
|
|
44
53
|
}
|
|
45
|
-
|
|
54
|
+
const bag: ScrapeSecrets = {
|
|
46
55
|
names: [...names],
|
|
56
|
+
conceal(value: string): void {
|
|
57
|
+
if (value !== '') learned.add(value);
|
|
58
|
+
},
|
|
47
59
|
get(name: string): Secret {
|
|
48
60
|
const found = boxed.get(name);
|
|
49
61
|
if (found === undefined) {
|
|
@@ -57,8 +69,17 @@ export function createSecretBag(
|
|
|
57
69
|
return found;
|
|
58
70
|
},
|
|
59
71
|
};
|
|
72
|
+
concealed.set(bag, learned);
|
|
73
|
+
return bag;
|
|
60
74
|
}
|
|
61
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The values `conceal()` was handed, by bag — module-private, so the only way out of a bag is
|
|
78
|
+
* still `revealSecret(secrets.get(name))` for a DECLARED name. A `ScrapeSecrets` built by hand has
|
|
79
|
+
* no entry here and redacts its declared names alone.
|
|
80
|
+
*/
|
|
81
|
+
const concealed = new WeakMap<ScrapeSecrets, ReadonlySet<string>>();
|
|
82
|
+
|
|
62
83
|
export const SECRET_PLACEHOLDER = '[redacted]';
|
|
63
84
|
|
|
64
85
|
/**
|
|
@@ -93,9 +114,13 @@ export const MIN_REDACTABLE_LENGTH = 4;
|
|
|
93
114
|
* Longest first, so a secret that contains another one does not leave its tail behind.
|
|
94
115
|
*/
|
|
95
116
|
export function redactSecrets(text: string, secrets: ScrapeSecrets | undefined): string {
|
|
96
|
-
if (secrets === undefined
|
|
97
|
-
const
|
|
98
|
-
|
|
117
|
+
if (secrets === undefined) return text;
|
|
118
|
+
const learned = concealed.get(secrets);
|
|
119
|
+
if (secrets.names.length === 0 && (learned === undefined || learned.size === 0)) return text;
|
|
120
|
+
const values = [
|
|
121
|
+
...secrets.names.map((name) => revealSecret(secrets.get(name))),
|
|
122
|
+
...(learned ?? []),
|
|
123
|
+
]
|
|
99
124
|
.filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
|
|
100
125
|
.sort((a, b) => b.length - a.length);
|
|
101
126
|
let out = text;
|