@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/session-state.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// event field, an artifact or a screenshot, and it is summarised for logs by `sessionDigest()` —
|
|
11
11
|
// counts and an origin, never a value.
|
|
12
12
|
|
|
13
|
+
import type { SealKeySource } from '@ultimat3/core';
|
|
14
|
+
import { isJsonObject, isSealed, openText, resolveSealKeys, seal } from '@ultimat3/core';
|
|
13
15
|
import type { StorageDriver } from '@ultimat3/storage';
|
|
14
16
|
import { assertSafeKey } from '@ultimat3/storage';
|
|
15
17
|
import type { ScrapeCookie } from './target';
|
|
@@ -168,42 +170,103 @@ export function memorySessionStore(
|
|
|
168
170
|
|
|
169
171
|
export const DEFAULT_SESSION_PREFIX = 'scrape-session';
|
|
170
172
|
|
|
173
|
+
/** What a stored session is sealed FOR. Bound into the tag: nothing else sealed opens as one. */
|
|
174
|
+
export const SESSION_SEAL_PURPOSE = 'scrape-session';
|
|
175
|
+
|
|
176
|
+
export interface StorageSessionStoreOptions {
|
|
177
|
+
readonly prefix?: string | undefined;
|
|
178
|
+
/**
|
|
179
|
+
* Where the app's master key is read from — `seal()`'s own two fields, with its defaults: the
|
|
180
|
+
* process environment, then `.secrets.key` under the working directory.
|
|
181
|
+
*/
|
|
182
|
+
readonly keySource?: SealKeySource | undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The sealed string out of a stored object, or `undefined` for anything that is not one. */
|
|
186
|
+
const sealedIn = (text: string): string | undefined => {
|
|
187
|
+
try {
|
|
188
|
+
const parsed: unknown = JSON.parse(text);
|
|
189
|
+
return isJsonObject(parsed) && isSealed(parsed['sealed']) ? parsed['sealed'] : undefined;
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
171
195
|
/**
|
|
172
196
|
* Sessions on the app's own disk, through `@ultimat3/storage` — the seam that already knows about
|
|
173
197
|
* tenant-scoped keys and refuses one that escapes its prefix. This package owns no upload path.
|
|
174
198
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
199
|
+
* SEALED at rest, under the app's master key — the one `x secrets init` writes — through core's
|
|
200
|
+
* `seal()`, purpose `SESSION_SEAL_PURPOSE`. The stored object is `{ "sealed": "x1.…" }`: no cookie
|
|
201
|
+
* value, no `localStorage` token and no origin is readable from the bucket. With no master key the
|
|
202
|
+
* store refuses (`X_SEAL_KEY_MISSING`) on `load()`, which a run calls BEFORE its browser opens —
|
|
203
|
+
* a login that could not be kept is a login repeated on every attempt.
|
|
204
|
+
*
|
|
205
|
+
* A session is a CACHE, so nothing is migrated: an object that is not a sealed session, does not
|
|
206
|
+
* open under a key this process declares, or opens to a record written for another key is burned
|
|
207
|
+
* and the run authenticates again.
|
|
208
|
+
*
|
|
209
|
+
* `storage` is a THUNK — `storageSessionStore(() => disk('sessions'))` — read on every call and
|
|
210
|
+
* never captured. A `scrape()` is declared when its module is evaluated; the app's disks exist
|
|
211
|
+
* once boot has run `defineStorage()`, which is later. A driver VALUE here could only be a
|
|
212
|
+
* `declare const` in a README.
|
|
179
213
|
*/
|
|
180
214
|
export function storageSessionStore(
|
|
181
|
-
storage: StorageDriver,
|
|
182
|
-
options:
|
|
215
|
+
storage: () => StorageDriver,
|
|
216
|
+
options: StorageSessionStoreOptions = {},
|
|
183
217
|
): ScrapeSessionStore {
|
|
184
218
|
const keyFor = (key: string): string => {
|
|
185
219
|
const path = `${options.prefix ?? DEFAULT_SESSION_PREFIX}/${key}.json`;
|
|
186
220
|
assertSafeKey(path);
|
|
187
221
|
return path;
|
|
188
222
|
};
|
|
223
|
+
/** Best effort: an object that could not be deleted is refused again by the next `load()`. */
|
|
224
|
+
const discard = async (path: string): Promise<undefined> => {
|
|
225
|
+
await storage()
|
|
226
|
+
.delete(path)
|
|
227
|
+
.catch(() => undefined);
|
|
228
|
+
return undefined;
|
|
229
|
+
};
|
|
189
230
|
return {
|
|
190
231
|
async load(key: string): Promise<SessionState | undefined> {
|
|
232
|
+
// The key ring FIRST, and outside every catch below: "no master key" is a deploy that cannot
|
|
233
|
+
// keep a session at all, and answering "no session" for it would log in, fail to save, and
|
|
234
|
+
// log in again on the retry.
|
|
235
|
+
const keys = await resolveSealKeys(options.keySource);
|
|
236
|
+
const path = keyFor(key);
|
|
237
|
+
let stored: string;
|
|
191
238
|
try {
|
|
192
|
-
|
|
193
|
-
return parseSessionState(JSON.parse(new TextDecoder().decode(read.bytes)) as unknown, key);
|
|
239
|
+
stored = new TextDecoder().decode((await storage().get(path)).bytes);
|
|
194
240
|
} catch {
|
|
195
241
|
// A session that cannot be read is a session that does not exist. Refusing the run over a
|
|
196
242
|
// missing cache would make reuse — the fast path — the fragile path.
|
|
197
243
|
return undefined;
|
|
198
244
|
}
|
|
245
|
+
const sealed = sealedIn(stored);
|
|
246
|
+
if (sealed === undefined) return discard(path);
|
|
247
|
+
try {
|
|
248
|
+
const opened: unknown = JSON.parse(
|
|
249
|
+
await openText(sealed, { purpose: SESSION_SEAL_PURPOSE, keys }),
|
|
250
|
+
);
|
|
251
|
+
// The tag covers the record, and the record names the key it was saved under: an object
|
|
252
|
+
// copied onto another tenant's path opens, and is still not that tenant's session.
|
|
253
|
+
if (!isJsonObject(opened) || opened['key'] !== key) return discard(path);
|
|
254
|
+
return parseSessionState(opened, key) ?? discard(path);
|
|
255
|
+
} catch {
|
|
256
|
+
return discard(path);
|
|
257
|
+
}
|
|
199
258
|
},
|
|
200
259
|
async save(state: SessionState): Promise<void> {
|
|
201
|
-
|
|
260
|
+
const sealed = await seal(JSON.stringify(state), {
|
|
261
|
+
purpose: SESSION_SEAL_PURPOSE,
|
|
262
|
+
...options.keySource,
|
|
263
|
+
});
|
|
264
|
+
await storage().put(keyFor(state.key), new TextEncoder().encode(JSON.stringify({ sealed })), {
|
|
202
265
|
contentType: 'application/json',
|
|
203
266
|
});
|
|
204
267
|
},
|
|
205
268
|
async burn(key: string): Promise<void> {
|
|
206
|
-
await storage.delete(keyFor(key));
|
|
269
|
+
await storage().delete(keyFor(key));
|
|
207
270
|
},
|
|
208
271
|
};
|
|
209
272
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// What a URL that carries a credential may say about itself, and what it must hand the run's
|
|
2
|
+
// secret set. A proxy URL holds its password in the userinfo; a provider's CDP connect URL holds
|
|
3
|
+
// its access token in the query or the path. Both are named in errors and both pass through text
|
|
4
|
+
// this package persists, so the split is made once, here.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scheme and host — `wss://connect.provider.test:443` — and nothing a credential can live in: no
|
|
8
|
+
* userinfo, no path, no query. A string that is not a URL answers a fixed phrase rather than
|
|
9
|
+
* itself, because the caller is about to print the answer.
|
|
10
|
+
*/
|
|
11
|
+
export function endpointLabel(url: string): string {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
15
|
+
} catch {
|
|
16
|
+
return 'an endpoint that is not a URL';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A lone `%` survives `URL` parsing and makes `decodeURIComponent` throw; the raw text is the answer then. */
|
|
21
|
+
const decoded = (part: string): string => {
|
|
22
|
+
try {
|
|
23
|
+
return decodeURIComponent(part);
|
|
24
|
+
} catch {
|
|
25
|
+
return part;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** True when the userinfo carries anything — the half `--proxy-server` cannot be handed. */
|
|
30
|
+
export function hasCredentials(url: string): boolean {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(url);
|
|
33
|
+
return parsed.username !== '' || parsed.password !== '';
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `http://user:pass@exit:8080` → `http://exit:8080` plus the pair, decoded as the proxy expects. */
|
|
40
|
+
export function splitCredentials(url: string): {
|
|
41
|
+
readonly bare: string;
|
|
42
|
+
readonly username: string;
|
|
43
|
+
readonly password: string;
|
|
44
|
+
} {
|
|
45
|
+
const parsed = new URL(url);
|
|
46
|
+
const username = decoded(parsed.username);
|
|
47
|
+
const password = decoded(parsed.password);
|
|
48
|
+
parsed.username = '';
|
|
49
|
+
parsed.password = '';
|
|
50
|
+
// `URL` renders a trailing slash for an empty path; a proxy argument never had one.
|
|
51
|
+
return { bare: parsed.toString().replace(/\/$/, ''), username, password };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every value in the URL that is, or may be, a credential — for the run's redaction set.
|
|
56
|
+
*
|
|
57
|
+
* The whole URL first, then the parts that can be quoted on their own: a launcher's error says
|
|
58
|
+
* `401 for /devtools/browser/<id>?token=…` as readily as it repeats the URL. The path counts
|
|
59
|
+
* because a browser id in a CDP path IS the capability. Encoded and decoded forms both, since a
|
|
60
|
+
* message may carry either. The host is left out on purpose: it is what an error is allowed to say.
|
|
61
|
+
*/
|
|
62
|
+
export function urlSecretValues(url: string): readonly string[] {
|
|
63
|
+
const values = new Set<string>([url]);
|
|
64
|
+
try {
|
|
65
|
+
const parsed = new URL(url);
|
|
66
|
+
// The password and not the username: a proxy username is an account and a zone, and redacting
|
|
67
|
+
// `admin` by value would blank the word out of every page artifact.
|
|
68
|
+
values.add(parsed.password);
|
|
69
|
+
values.add(decoded(parsed.password));
|
|
70
|
+
if (parsed.pathname !== '/') values.add(`${parsed.pathname}${parsed.search}`);
|
|
71
|
+
if (parsed.search !== '') values.add(parsed.search.slice(1));
|
|
72
|
+
for (const value of parsed.searchParams.values()) values.add(value);
|
|
73
|
+
} catch {
|
|
74
|
+
// Not a URL: the whole string above is all there is to redact.
|
|
75
|
+
}
|
|
76
|
+
values.delete('');
|
|
77
|
+
return [...values];
|
|
78
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// What one run USED, counted where the work happens and reported with its result. A measurement
|
|
2
|
+
// and nothing else: no quota, no plan, no ledger — what a number here is worth, and what happens
|
|
3
|
+
// when it is too high, is the app's (`docs/idea/19-mechanism-not-convention.md`).
|
|
4
|
+
|
|
5
|
+
import type { MoneyValue } from '@ultimat3/schema';
|
|
6
|
+
import type { ScrapeClock } from './clock';
|
|
7
|
+
|
|
8
|
+
export interface ScrapeUsage {
|
|
9
|
+
/** Milliseconds the browser session was held, from before `driver.open()` to this report. */
|
|
10
|
+
readonly browserMs: number;
|
|
11
|
+
/** `page.goto()` calls that reached the driver — a refused host or a robots refusal is not one. */
|
|
12
|
+
readonly navigations: number;
|
|
13
|
+
/** Requests the HTTP leg put on the wire, one per redirect hop. */
|
|
14
|
+
readonly httpRequests: number;
|
|
15
|
+
/**
|
|
16
|
+
* Response-body bytes the HTTP leg read. The BROWSER leg's traffic is not in it: this package's
|
|
17
|
+
* CDP port subscribes to requests, not to their sizes, so a figure for it would be a guess.
|
|
18
|
+
*/
|
|
19
|
+
readonly bytesIn: number;
|
|
20
|
+
readonly promptsAnswered: number;
|
|
21
|
+
/**
|
|
22
|
+
* What the rented browser cost, as the `CdpResolver` stated it — a `Money` value, never a float.
|
|
23
|
+
* Absent when the driver rented nothing or the provider prices at release.
|
|
24
|
+
*/
|
|
25
|
+
readonly browserCost?: MoneyValue | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The counting half, handed to a driver as `SessionInit.usage`. `pageOverTarget`, `httpOverFetch`
|
|
30
|
+
* and `recordedHttp` each call it where they already report activity, so a third-party driver
|
|
31
|
+
* built on them counts by passing the meter through and one that is not counts by calling it.
|
|
32
|
+
*/
|
|
33
|
+
export interface UsageMeter {
|
|
34
|
+
navigation(): void;
|
|
35
|
+
/** One request on the wire. `bytes` is the body read for it — 0 for a hop that was followed. */
|
|
36
|
+
httpRequest(bytes: number): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RunUsageMeter extends UsageMeter {
|
|
40
|
+
promptAnswered(): void;
|
|
41
|
+
/** The counts so far, with the session's age read from the clock this meter was built on. */
|
|
42
|
+
snapshot(browserCost?: MoneyValue | undefined): ScrapeUsage;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Built BEFORE `driver.open()`, so the time a rented browser took to arrive is time it was held. */
|
|
46
|
+
export function createUsageMeter(clock: ScrapeClock): RunUsageMeter {
|
|
47
|
+
const startedAt = clock.monotonic();
|
|
48
|
+
let navigations = 0;
|
|
49
|
+
let httpRequests = 0;
|
|
50
|
+
let bytesIn = 0;
|
|
51
|
+
let promptsAnswered = 0;
|
|
52
|
+
return {
|
|
53
|
+
navigation(): void {
|
|
54
|
+
navigations += 1;
|
|
55
|
+
},
|
|
56
|
+
httpRequest(bytes: number): void {
|
|
57
|
+
httpRequests += 1;
|
|
58
|
+
bytesIn += bytes;
|
|
59
|
+
},
|
|
60
|
+
promptAnswered(): void {
|
|
61
|
+
promptsAnswered += 1;
|
|
62
|
+
},
|
|
63
|
+
snapshot(browserCost?: MoneyValue | undefined): ScrapeUsage {
|
|
64
|
+
return {
|
|
65
|
+
browserMs: Math.round(clock.monotonic() - startedAt),
|
|
66
|
+
navigations,
|
|
67
|
+
httpRequests,
|
|
68
|
+
bytesIn,
|
|
69
|
+
promptsAnswered,
|
|
70
|
+
...(browserCost === undefined ? {} : { browserCost }),
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Failed attempts remembered at once. A bound, not a ledger: the oldest goes first. */
|
|
77
|
+
const MAX_FAILED_ATTEMPTS = 256;
|
|
78
|
+
|
|
79
|
+
const failedAttempts = new Map<string, ScrapeUsage>();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What a FAILED attempt used, held until its run is settled. A failed run has no report — the
|
|
83
|
+
* job's return value is the only thing the queue hands `onSettled` — and it was billed all the
|
|
84
|
+
* same, so the counts are kept by run id in the worker's own process, which is where the hook runs.
|
|
85
|
+
*/
|
|
86
|
+
export function rememberFailedUsage(runId: string, usage: ScrapeUsage): void {
|
|
87
|
+
failedAttempts.delete(runId);
|
|
88
|
+
failedAttempts.set(runId, usage);
|
|
89
|
+
while (failedAttempts.size > MAX_FAILED_ATTEMPTS) {
|
|
90
|
+
const oldest = failedAttempts.keys().next();
|
|
91
|
+
if (oldest.done === true) return;
|
|
92
|
+
failedAttempts.delete(oldest.value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The last failed attempt's usage for this run, read once: taking it forgets it. */
|
|
97
|
+
export function takeFailedUsage(runId: string): ScrapeUsage | undefined {
|
|
98
|
+
const usage = failedAttempts.get(runId);
|
|
99
|
+
failedAttempts.delete(runId);
|
|
100
|
+
return usage;
|
|
101
|
+
}
|
|
File without changes
|