@ultimat3/scraping 22.15.0 → 24.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 +187 -14
- package/package.json +6 -5
- package/src/artifacts.ts +3 -2
- package/src/auth.ts +156 -22
- package/src/cdp-arm.ts +65 -1
- package/src/cdp-fake-target.ts +67 -0
- package/src/cdp-fake.ts +9 -1
- package/src/cdp-port.ts +31 -0
- package/src/cdp-resolver.ts +81 -0
- package/src/cdp-target.ts +70 -48
- package/src/clock.ts +34 -0
- package/src/driver-cdp.ts +175 -20
- package/src/driver.ts +26 -2
- package/src/error-throws-session.ts +111 -0
- package/src/error-throws.ts +40 -17
- package/src/errors.ts +18 -2
- package/src/event-prompt.ts +133 -0
- package/src/events.ts +6 -0
- package/src/expect.ts +21 -4
- package/src/html-target.ts +10 -15
- package/src/http-recorded.ts +7 -1
- package/src/http.ts +56 -11
- package/src/index.ts +43 -5
- package/src/launch-args.ts +51 -0
- package/src/offline-session.ts +6 -0
- package/src/page-over-target.ts +4 -0
- package/src/pinned-host.ts +65 -0
- package/src/recover.ts +3 -2
- package/src/robots-fetch.ts +79 -11
- package/src/scrape-run.ts +109 -21
- package/src/scrape.ts +77 -8
- package/src/secret-scan.ts +17 -0
- package/src/secrets.ts +86 -9
- package/src/session-state.ts +84 -10
- package/src/url-secrets.ts +139 -0
- package/src/usage.ts +101 -0
- /package/src/{driver-fixture.ts → driver-recorded.ts} +0 -0
package/src/secrets.ts
CHANGED
|
@@ -7,13 +7,21 @@
|
|
|
7
7
|
// logger. The third is not, because pixels cannot be redacted after the fact.
|
|
8
8
|
|
|
9
9
|
import type { Secret } from '@ultimat3/core';
|
|
10
|
-
import { revealSecret, secret, UltimateError } from '@ultimat3/core';
|
|
10
|
+
import { escapeHtml, revealSecret, secret, UltimateError } from '@ultimat3/core';
|
|
11
11
|
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
12
12
|
|
|
13
13
|
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
|
/**
|
|
@@ -74,6 +95,56 @@ export const SECRET_PLACEHOLDER = '[redacted]';
|
|
|
74
95
|
*/
|
|
75
96
|
export const MIN_REDACTABLE_LENGTH = 4;
|
|
76
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The SUBSET a serialiser escaped, each character spelt by core's one table — a page escapes fewer
|
|
100
|
+
* characters than `escapeHtml` does (a browser's text serialiser leaves quotes alone), so the
|
|
101
|
+
* spelling is the table applied to that serialiser's set, never a second table.
|
|
102
|
+
*/
|
|
103
|
+
const escapeOnly = (value: string, chars: RegExp): string => value.replace(chars, escapeHtml);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every spelling a page or a URL writes one value in. The raw value alone missed the case
|
|
107
|
+
* `safeNetwork` exists for: a password in a query string arrives percent-encoded, or form-encoded
|
|
108
|
+
* (`+` for a space, `'` and `(` escaped), and a server-rendered page writes it HTML-escaped — as
|
|
109
|
+
* text (`& < >`, what a browser's serialiser emits), as an attribute (`& "`),
|
|
110
|
+
* or through a template that escapes all five (`'` or `'` for the apostrophe).
|
|
111
|
+
* Each spelling is at least as long as the value, so the length floor is decided on the value.
|
|
112
|
+
*/
|
|
113
|
+
function spellingsOf(value: string): readonly string[] {
|
|
114
|
+
const text = escapeOnly(value, /[&<>]/g);
|
|
115
|
+
const attribute = escapeOnly(value, /[&"]/g);
|
|
116
|
+
const full = escapeOnly(value, /[&<>"]/g);
|
|
117
|
+
return [
|
|
118
|
+
value,
|
|
119
|
+
encodeURIComponent(value),
|
|
120
|
+
new URLSearchParams([['', value]]).toString().slice(1),
|
|
121
|
+
text,
|
|
122
|
+
attribute,
|
|
123
|
+
full.replaceAll("'", '''),
|
|
124
|
+
full.replaceAll("'", '''),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const REGEX_SPECIAL = /[.*+?^${}()|[\]\\/]/g;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One spelling as a pattern: literal throughout, except that the hex digits of a `%XX` escape
|
|
132
|
+
* match in either case — `%2F` and `%2f` are the same byte, and clients emit both. The value's own
|
|
133
|
+
* letters stay case-sensitive: `Hunter2` is not `hunter2`.
|
|
134
|
+
*/
|
|
135
|
+
const spellingPattern = (spelling: string): RegExp =>
|
|
136
|
+
new RegExp(
|
|
137
|
+
spelling
|
|
138
|
+
.split(/(%[0-9A-Fa-f]{2})/)
|
|
139
|
+
.map((part, index) =>
|
|
140
|
+
index % 2 === 1
|
|
141
|
+
? part.replace(/[A-Fa-f]/g, (digit) => `[${digit.toUpperCase()}${digit.toLowerCase()}]`)
|
|
142
|
+
: part.replace(REGEX_SPECIAL, '\\$&'),
|
|
143
|
+
)
|
|
144
|
+
.join(''),
|
|
145
|
+
'g',
|
|
146
|
+
);
|
|
147
|
+
|
|
77
148
|
/**
|
|
78
149
|
* Redaction BY VALUE, over the text this package hands back or persists. Name-based redaction only
|
|
79
150
|
* catches a secret travelling under a name somebody remembered to list, and a password pasted into
|
|
@@ -90,16 +161,22 @@ export const MIN_REDACTABLE_LENGTH = 4;
|
|
|
90
161
|
* `MIN_REDACTABLE_LENGTH`, and pixels — which is why a secret TAINTS the page and captures are
|
|
91
162
|
* refused outright.
|
|
92
163
|
*
|
|
93
|
-
*
|
|
164
|
+
* Every spelling of every value (`spellingsOf`), longest first, so a secret that contains another
|
|
165
|
+
* one does not leave its tail behind.
|
|
94
166
|
*/
|
|
95
167
|
export function redactSecrets(text: string, secrets: ScrapeSecrets | undefined): string {
|
|
96
|
-
if (secrets === undefined
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
168
|
+
if (secrets === undefined) return text;
|
|
169
|
+
const learned = concealed.get(secrets);
|
|
170
|
+
if (secrets.names.length === 0 && (learned === undefined || learned.size === 0)) return text;
|
|
171
|
+
const values = [
|
|
172
|
+
...new Set(
|
|
173
|
+
[...secrets.names.map((name) => revealSecret(secrets.get(name))), ...(learned ?? [])]
|
|
174
|
+
.filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
|
|
175
|
+
.flatMap(spellingsOf),
|
|
176
|
+
),
|
|
177
|
+
].sort((a, b) => b.length - a.length);
|
|
101
178
|
let out = text;
|
|
102
|
-
for (const value of values) out = out.
|
|
179
|
+
for (const value of values) out = out.replace(spellingPattern(value), SECRET_PLACEHOLDER);
|
|
103
180
|
return out;
|
|
104
181
|
}
|
|
105
182
|
|
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';
|
|
@@ -36,6 +38,13 @@ export interface SessionState extends SessionSnapshot {
|
|
|
36
38
|
readonly key: string;
|
|
37
39
|
/** ISO 8601. What a `maxAge` on reuse is measured against. */
|
|
38
40
|
readonly savedAt: string;
|
|
41
|
+
/**
|
|
42
|
+
* Unique per save — what a burn or a tombstone compares to decide whether the record is still
|
|
43
|
+
* the one a run saw (`auth.ts`'s `stillSeen`). Never `savedAt`: two saves in one millisecond, or
|
|
44
|
+
* on one frozen clock, share it. Absent on a record written before it existed; `recordVersion`
|
|
45
|
+
* reads `savedAt` for that one.
|
|
46
|
+
*/
|
|
47
|
+
readonly version?: string | undefined;
|
|
39
48
|
/**
|
|
40
49
|
* ISO 8601, set when this site REFUSED these credentials — and the reason the record survives
|
|
41
50
|
* the failure instead of being deleted with it.
|
|
@@ -168,42 +177,103 @@ export function memorySessionStore(
|
|
|
168
177
|
|
|
169
178
|
export const DEFAULT_SESSION_PREFIX = 'scrape-session';
|
|
170
179
|
|
|
180
|
+
/** What a stored session is sealed FOR. Bound into the tag: nothing else sealed opens as one. */
|
|
181
|
+
export const SESSION_SEAL_PURPOSE = 'scrape-session';
|
|
182
|
+
|
|
183
|
+
export interface StorageSessionStoreOptions {
|
|
184
|
+
readonly prefix?: string | undefined;
|
|
185
|
+
/**
|
|
186
|
+
* Where the app's master key is read from — `seal()`'s own two fields, with its defaults: the
|
|
187
|
+
* process environment, then `.secrets.key` under the working directory.
|
|
188
|
+
*/
|
|
189
|
+
readonly keySource?: SealKeySource | undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The sealed string out of a stored object, or `undefined` for anything that is not one. */
|
|
193
|
+
const sealedIn = (text: string): string | undefined => {
|
|
194
|
+
try {
|
|
195
|
+
const parsed: unknown = JSON.parse(text);
|
|
196
|
+
return isJsonObject(parsed) && isSealed(parsed['sealed']) ? parsed['sealed'] : undefined;
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
171
202
|
/**
|
|
172
203
|
* Sessions on the app's own disk, through `@ultimat3/storage` — the seam that already knows about
|
|
173
204
|
* tenant-scoped keys and refuses one that escapes its prefix. This package owns no upload path.
|
|
174
205
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
206
|
+
* SEALED at rest, under the app's master key — the one `x secrets init` writes — through core's
|
|
207
|
+
* `seal()`, purpose `SESSION_SEAL_PURPOSE`. The stored object is `{ "sealed": "x1.…" }`: no cookie
|
|
208
|
+
* value, no `localStorage` token and no origin is readable from the bucket. With no master key the
|
|
209
|
+
* store refuses (`X_SEAL_KEY_MISSING`) on `load()`, which a run calls BEFORE its browser opens —
|
|
210
|
+
* a login that could not be kept is a login repeated on every attempt.
|
|
211
|
+
*
|
|
212
|
+
* A session is a CACHE, so nothing is migrated: an object that is not a sealed session, does not
|
|
213
|
+
* open under a key this process declares, or opens to a record written for another key is burned
|
|
214
|
+
* and the run authenticates again.
|
|
215
|
+
*
|
|
216
|
+
* `storage` is a THUNK — `storageSessionStore(() => disk('sessions'))` — read on every call and
|
|
217
|
+
* never captured. A `scrape()` is declared when its module is evaluated; the app's disks exist
|
|
218
|
+
* once boot has run `defineStorage()`, which is later. A driver VALUE here could only be a
|
|
219
|
+
* `declare const` in a README.
|
|
179
220
|
*/
|
|
180
221
|
export function storageSessionStore(
|
|
181
|
-
storage: StorageDriver,
|
|
182
|
-
options:
|
|
222
|
+
storage: () => StorageDriver,
|
|
223
|
+
options: StorageSessionStoreOptions = {},
|
|
183
224
|
): ScrapeSessionStore {
|
|
184
225
|
const keyFor = (key: string): string => {
|
|
185
226
|
const path = `${options.prefix ?? DEFAULT_SESSION_PREFIX}/${key}.json`;
|
|
186
227
|
assertSafeKey(path);
|
|
187
228
|
return path;
|
|
188
229
|
};
|
|
230
|
+
/** Best effort: an object that could not be deleted is refused again by the next `load()`. */
|
|
231
|
+
const discard = async (path: string): Promise<undefined> => {
|
|
232
|
+
await storage()
|
|
233
|
+
.delete(path)
|
|
234
|
+
.catch(() => undefined);
|
|
235
|
+
return undefined;
|
|
236
|
+
};
|
|
189
237
|
return {
|
|
190
238
|
async load(key: string): Promise<SessionState | undefined> {
|
|
239
|
+
// The key ring FIRST, and outside every catch below: "no master key" is a deploy that cannot
|
|
240
|
+
// keep a session at all, and answering "no session" for it would log in, fail to save, and
|
|
241
|
+
// log in again on the retry.
|
|
242
|
+
const keys = await resolveSealKeys(options.keySource);
|
|
243
|
+
const path = keyFor(key);
|
|
244
|
+
let stored: string;
|
|
191
245
|
try {
|
|
192
|
-
|
|
193
|
-
return parseSessionState(JSON.parse(new TextDecoder().decode(read.bytes)) as unknown, key);
|
|
246
|
+
stored = new TextDecoder().decode((await storage().get(path)).bytes);
|
|
194
247
|
} catch {
|
|
195
248
|
// A session that cannot be read is a session that does not exist. Refusing the run over a
|
|
196
249
|
// missing cache would make reuse — the fast path — the fragile path.
|
|
197
250
|
return undefined;
|
|
198
251
|
}
|
|
252
|
+
const sealed = sealedIn(stored);
|
|
253
|
+
if (sealed === undefined) return discard(path);
|
|
254
|
+
try {
|
|
255
|
+
const opened: unknown = JSON.parse(
|
|
256
|
+
await openText(sealed, { purpose: SESSION_SEAL_PURPOSE, keys }),
|
|
257
|
+
);
|
|
258
|
+
// The tag covers the record, and the record names the key it was saved under: an object
|
|
259
|
+
// copied onto another tenant's path opens, and is still not that tenant's session.
|
|
260
|
+
if (!isJsonObject(opened) || opened['key'] !== key) return discard(path);
|
|
261
|
+
return parseSessionState(opened, key) ?? discard(path);
|
|
262
|
+
} catch {
|
|
263
|
+
return discard(path);
|
|
264
|
+
}
|
|
199
265
|
},
|
|
200
266
|
async save(state: SessionState): Promise<void> {
|
|
201
|
-
|
|
267
|
+
const sealed = await seal(JSON.stringify(state), {
|
|
268
|
+
purpose: SESSION_SEAL_PURPOSE,
|
|
269
|
+
...options.keySource,
|
|
270
|
+
});
|
|
271
|
+
await storage().put(keyFor(state.key), new TextEncoder().encode(JSON.stringify({ sealed })), {
|
|
202
272
|
contentType: 'application/json',
|
|
203
273
|
});
|
|
204
274
|
},
|
|
205
275
|
async burn(key: string): Promise<void> {
|
|
206
|
-
await storage.delete(keyFor(key));
|
|
276
|
+
await storage().delete(keyFor(key));
|
|
207
277
|
},
|
|
208
278
|
};
|
|
209
279
|
}
|
|
@@ -246,6 +316,7 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und
|
|
|
246
316
|
return {
|
|
247
317
|
key,
|
|
248
318
|
savedAt: value.savedAt,
|
|
319
|
+
...(typeof value.version === 'string' ? { version: value.version } : {}),
|
|
249
320
|
...(typeof value.refusedAt === 'string' ? { refusedAt: value.refusedAt } : {}),
|
|
250
321
|
cookies: value.cookies.flatMap((cookie: unknown) => toCookie(cookie) ?? []),
|
|
251
322
|
headers: value.headers ?? {},
|
|
@@ -254,3 +325,6 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und
|
|
|
254
325
|
origin: value.origin ?? '',
|
|
255
326
|
};
|
|
256
327
|
}
|
|
328
|
+
|
|
329
|
+
/** The identity a conditional burn or tombstone compares: the record's version, else its `savedAt`. */
|
|
330
|
+
export const recordVersion = (state: SessionState): string => state.version ?? state.savedAt;
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
* The words that name a credential, matched against a query key's COMPONENTS — split on `_`, `-`,
|
|
56
|
+
* `.` and camelCase — never as substrings: `keyboard` holds `key` and `monkey` holds it too, and
|
|
57
|
+
* concealing their values blanked ordinary words out of every artifact. Read off the key and not
|
|
58
|
+
* the value, because a provider's token can be short and the key is the URL's own statement.
|
|
59
|
+
*/
|
|
60
|
+
const CREDENTIAL_WORDS = new Set([
|
|
61
|
+
'token',
|
|
62
|
+
'key',
|
|
63
|
+
'apikey',
|
|
64
|
+
'secret',
|
|
65
|
+
'auth',
|
|
66
|
+
'authorization',
|
|
67
|
+
'sig',
|
|
68
|
+
'signature',
|
|
69
|
+
'pass',
|
|
70
|
+
'password',
|
|
71
|
+
'passwd',
|
|
72
|
+
'pwd',
|
|
73
|
+
'cred',
|
|
74
|
+
'credential',
|
|
75
|
+
'credentials',
|
|
76
|
+
'session',
|
|
77
|
+
'sessionid',
|
|
78
|
+
'sid',
|
|
79
|
+
'jwt',
|
|
80
|
+
'bearer',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const credentialKey = (key: string): boolean =>
|
|
84
|
+
key
|
|
85
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.split(/[\s_.-]+/)
|
|
88
|
+
.some((part) => CREDENTIAL_WORDS.has(part));
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A value that is a capability whatever it is called: long, one unbroken run, letters and digits
|
|
92
|
+
* mixed — a browser id, an access token. A word (`residential`, `true`, `eu`) is none of those.
|
|
93
|
+
*/
|
|
94
|
+
const credentialShaped = (value: string): boolean =>
|
|
95
|
+
value.length >= 16 && !/\s/.test(value) && /[0-9]/.test(value) && /[a-z]/i.test(value);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every value in the URL that is, or may be, a credential — for the run's redaction set.
|
|
99
|
+
*
|
|
100
|
+
* The whole URL first, then the parts a message quotes on their own: a launcher's error says
|
|
101
|
+
* `401 for /devtools/browser/<id>?token=…` as readily as it repeats the URL. Encoded and decoded
|
|
102
|
+
* forms both, since a message may carry either. The host is left out on purpose: it is what an
|
|
103
|
+
* error is allowed to say.
|
|
104
|
+
*
|
|
105
|
+
* A query VALUE or a path SEGMENT only when it is credential-shaped or sits under a
|
|
106
|
+
* credential-named key — never every value. Concealing `stealth=true&proxy=residential` blanked
|
|
107
|
+
* every `true` and `residential` in the page, the console and the network ring of the run: the
|
|
108
|
+
* unreadable artifact `MIN_REDACTABLE_LENGTH` exists to prevent.
|
|
109
|
+
*/
|
|
110
|
+
export function urlSecretValues(url: string): readonly string[] {
|
|
111
|
+
const values = new Set<string>([url]);
|
|
112
|
+
try {
|
|
113
|
+
const parsed = new URL(url);
|
|
114
|
+
// The password and not the username: a proxy username is an account and a zone, and redacting
|
|
115
|
+
// `admin` by value would blank the word out of every page artifact.
|
|
116
|
+
values.add(parsed.password);
|
|
117
|
+
values.add(decoded(parsed.password));
|
|
118
|
+
if (parsed.search !== '') {
|
|
119
|
+
values.add(`${parsed.pathname}${parsed.search}`);
|
|
120
|
+
values.add(parsed.search.slice(1));
|
|
121
|
+
}
|
|
122
|
+
const segments = parsed.pathname.split('/');
|
|
123
|
+
for (const [index, segment] of segments.entries()) {
|
|
124
|
+
// By POSITION as well as by shape: Chrome's `/devtools/<browser|page>/<id>` id IS the
|
|
125
|
+
// capability, and an id of letters alone is not credential-shaped.
|
|
126
|
+
const capability = index >= 2 && segments[index - 2] === 'devtools';
|
|
127
|
+
if (capability || credentialShaped(decoded(segment))) {
|
|
128
|
+
values.add(decoded(segment)).add(segment);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const [key, value] of parsed.searchParams) {
|
|
132
|
+
if (credentialKey(key) || credentialShaped(value)) values.add(value);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Not a URL: the whole string above is all there is to redact.
|
|
136
|
+
}
|
|
137
|
+
values.delete('');
|
|
138
|
+
return [...values];
|
|
139
|
+
}
|
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
|