@broberg/lens-daemon 0.0.0-stage → 0.1.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 +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// lens F001.5 — the Lens tables and their row types, owned by the Lens package.
|
|
2
|
+
//
|
|
3
|
+
// Moved verbatim from cardmem apps/agent/src/state.ts (ed10516f): the lens_* DDL,
|
|
4
|
+
// the additive lens migrations, the F276.1 upload queue and the F094.3 baseline
|
|
5
|
+
// backfill. cardmem's getDb() calls ensureLensSchema(db) once the daemon runs on
|
|
6
|
+
// this package; until that cutover cardmem keeps its own copy, and the cutover
|
|
7
|
+
// deletes it (no second copy survives F001.5).
|
|
8
|
+
|
|
9
|
+
import type { Database } from 'bun:sqlite';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { ensureUploadQueue } from './artifact-uploads.ts';
|
|
12
|
+
|
|
13
|
+
/** Create/upgrade every Lens table. Idempotent; call inside the daemon's schema transaction. */
|
|
14
|
+
export function ensureLensSchema(db: Database): void {
|
|
15
|
+
db.exec(`
|
|
16
|
+
-- F074.3 — Lens runs + baselines. Metadata lives here (daemon-local
|
|
17
|
+
-- SQLite); screenshot/diff BYTES live in the storage adapter (local dir
|
|
18
|
+
-- or Tigris). Hard-scoped to project_slug so a project only ever sees
|
|
19
|
+
-- its own runs. The SPA dashboard (F074.8) reads these via the daemon's
|
|
20
|
+
-- HTTP, the same pattern as fleet-status / audit.
|
|
21
|
+
CREATE TABLE IF NOT EXISTS lens_runs (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
project_slug TEXT NOT NULL,
|
|
24
|
+
surface_key TEXT,
|
|
25
|
+
url TEXT NOT NULL,
|
|
26
|
+
mode TEXT NOT NULL,
|
|
27
|
+
selector TEXT,
|
|
28
|
+
status TEXT NOT NULL,
|
|
29
|
+
reason TEXT,
|
|
30
|
+
diff_ratio REAL,
|
|
31
|
+
artifact_key TEXT,
|
|
32
|
+
diff_artifact_key TEXT,
|
|
33
|
+
dom_hash TEXT,
|
|
34
|
+
assert_pass INTEGER,
|
|
35
|
+
assert_detail_json TEXT,
|
|
36
|
+
console_json TEXT,
|
|
37
|
+
network_json TEXT,
|
|
38
|
+
page_errors_json TEXT,
|
|
39
|
+
width INTEGER,
|
|
40
|
+
height INTEGER,
|
|
41
|
+
duration_ms INTEGER,
|
|
42
|
+
critic_json TEXT,
|
|
43
|
+
created_at INTEGER NOT NULL
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_lens_runs_project ON lens_runs(project_slug, created_at DESC);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS lens_baselines (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
project_slug TEXT NOT NULL,
|
|
50
|
+
baseline_key TEXT NOT NULL,
|
|
51
|
+
artifact_key TEXT NOT NULL,
|
|
52
|
+
width INTEGER,
|
|
53
|
+
height INTEGER,
|
|
54
|
+
approved_from_run_id TEXT,
|
|
55
|
+
created_at INTEGER NOT NULL,
|
|
56
|
+
updated_at INTEGER NOT NULL,
|
|
57
|
+
UNIQUE(project_slug, baseline_key)
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
-- F094.3 — immutable baseline version history. Every approve APPENDS a row
|
|
61
|
+
-- here (never overwrites); lens_baselines.current_version_id points at the
|
|
62
|
+
-- active one, so a revert is a re-point with no data loss. The artifact_key
|
|
63
|
+
-- is content-addressed per version (baselines/<key>/v<n>.png).
|
|
64
|
+
CREATE TABLE IF NOT EXISTS lens_baseline_versions (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
project_slug TEXT NOT NULL,
|
|
67
|
+
baseline_key TEXT NOT NULL,
|
|
68
|
+
version_no INTEGER NOT NULL,
|
|
69
|
+
artifact_key TEXT NOT NULL,
|
|
70
|
+
width INTEGER,
|
|
71
|
+
height INTEGER,
|
|
72
|
+
approved_from_run_id TEXT,
|
|
73
|
+
commit_sha TEXT,
|
|
74
|
+
note TEXT,
|
|
75
|
+
approved_at INTEGER NOT NULL,
|
|
76
|
+
UNIQUE(project_slug, baseline_key, version_no)
|
|
77
|
+
);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_lens_baseline_versions
|
|
79
|
+
ON lens_baseline_versions(project_slug, baseline_key, version_no DESC);
|
|
80
|
+
|
|
81
|
+
-- F082 — E2E flow runs (manuscript executions). One row per flow run; the
|
|
82
|
+
-- per-step report (status + screenshot artifact key per step) lives in
|
|
83
|
+
-- steps_json. Same project-scoping + artifact-storage pattern as lens_runs.
|
|
84
|
+
CREATE TABLE IF NOT EXISTS lens_flow_runs (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
project_slug TEXT NOT NULL,
|
|
87
|
+
flow_name TEXT NOT NULL,
|
|
88
|
+
status TEXT NOT NULL,
|
|
89
|
+
failed_at INTEGER,
|
|
90
|
+
reason TEXT,
|
|
91
|
+
steps_json TEXT NOT NULL,
|
|
92
|
+
step_count INTEGER NOT NULL,
|
|
93
|
+
duration_ms INTEGER,
|
|
94
|
+
created_at INTEGER NOT NULL
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_lens_flow_runs_project ON lens_flow_runs(project_slug, created_at DESC);
|
|
97
|
+
`);
|
|
98
|
+
// F094.3 — additive: pointer from a baseline to its active version row.
|
|
99
|
+
try {
|
|
100
|
+
db.exec('ALTER TABLE lens_baselines ADD COLUMN current_version_id TEXT');
|
|
101
|
+
} catch {
|
|
102
|
+
// column already exists
|
|
103
|
+
}
|
|
104
|
+
// F126.4 — additive: composition-critic findings (JSON) per run, so
|
|
105
|
+
// lens_list_runs / the report can show them historically.
|
|
106
|
+
try {
|
|
107
|
+
db.exec('ALTER TABLE lens_runs ADD COLUMN critic_json TEXT');
|
|
108
|
+
} catch {
|
|
109
|
+
// column already exists
|
|
110
|
+
}
|
|
111
|
+
// F255.5 — additive: artifact key of a flow run's .webm recording (null unless
|
|
112
|
+
// the run asked for record:true).
|
|
113
|
+
try {
|
|
114
|
+
db.exec('ALTER TABLE lens_flow_runs ADD COLUMN video_key TEXT');
|
|
115
|
+
} catch {
|
|
116
|
+
// column already exists
|
|
117
|
+
}
|
|
118
|
+
// F074.30 — additive: the build sha the run MEASURED, stamped at capture time.
|
|
119
|
+
// NULL means "not measured" (the target exposes no marker) and must never be
|
|
120
|
+
// read as "same build as the other run" — a comparison spanning a null has to
|
|
121
|
+
// refuse rather than reassure. Without this, a run-vs-run diff can compare a
|
|
122
|
+
// build with itself and answer "no regression".
|
|
123
|
+
for (const table of ['lens_runs', 'lens_flow_runs']) {
|
|
124
|
+
try {
|
|
125
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN build TEXT`);
|
|
126
|
+
} catch {
|
|
127
|
+
// column already exists
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// F074.36 — additive: WHERE the run ended, and WHICH credential it used (or why
|
|
131
|
+
// it had none). Without these, a past verdict cannot be re-checked for the one
|
|
132
|
+
// question that invalidates it: "was that run even logged in, and was it
|
|
133
|
+
// looking at the page I think it was?"
|
|
134
|
+
// F074.99 — additive: the run-level FINDINGS a reader can act on (a password
|
|
135
|
+
// that came back in the URL, a page that was never listening). Both were
|
|
136
|
+
// produced in flow.ts and written nowhere, which is this file's own recurring
|
|
137
|
+
// lesson: a field produced in one place and not persisted in the other reaches
|
|
138
|
+
// nobody. fd-sundhed measured exactly that from a consumer's seat.
|
|
139
|
+
for (const col of ['final_url TEXT', 'auth_json TEXT', 'reduced_motion TEXT', 'findings_json TEXT']) {
|
|
140
|
+
try {
|
|
141
|
+
db.exec(`ALTER TABLE lens_flow_runs ADD COLUMN ${col}`);
|
|
142
|
+
} catch {
|
|
143
|
+
// column already exists
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// F074.59 — reduced_motion is deliberately NULLABLE with no default. A run
|
|
147
|
+
// recorded before the column existed genuinely does not know which motion state
|
|
148
|
+
// it used, and back-filling it with 'reduce' would turn "not measured" into a
|
|
149
|
+
// measurement — the exact collapse this card exists to prevent one layer up.
|
|
150
|
+
|
|
151
|
+
// F272.2 — additive: WHICH STORAGE BACKEND held this run's screenshots, stamped
|
|
152
|
+
// at capture time and never re-derived from current config.
|
|
153
|
+
//
|
|
154
|
+
// The re-derived version is the trap. Read the daemon's CURRENT backend and
|
|
155
|
+
// apply it to every row, and the day LENS_TIGRIS_BUCKET is finally set, every
|
|
156
|
+
// historic run whose PNGs are still on this Mac starts reporting `tigris`. The
|
|
157
|
+
// bytes have not moved; the config changed; the citation goes green. That is
|
|
158
|
+
// the false-green direction, so the value is written once, at the moment the
|
|
159
|
+
// bytes are actually put somewhere.
|
|
160
|
+
//
|
|
161
|
+
// NULL is the same third fact as reduced_motion above: a run older than this
|
|
162
|
+
// column does not know where its bytes went, and `unknown` must never be
|
|
163
|
+
// rounded up to `durable`. An evidence citation over a null is exactly as
|
|
164
|
+
// unproven as one over 'local' — it just fails for a different reason.
|
|
165
|
+
try {
|
|
166
|
+
db.exec('ALTER TABLE lens_flow_runs ADD COLUMN artifact_backend TEXT');
|
|
167
|
+
} catch {
|
|
168
|
+
// column already exists
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// F276.1 — the upload queue that makes every artifact reachable from a phone.
|
|
172
|
+
//
|
|
173
|
+
// A QUEUE RATHER THAN AN INLINE UPLOAD, for three reasons that all bite:
|
|
174
|
+
// · a capture must not get slower, or nobody will run one;
|
|
175
|
+
// · a failed upload must not fail the RUN — the local copy is still valid
|
|
176
|
+
// evidence, and losing a run because R2 hiccuped is a worse outcome than
|
|
177
|
+
// a late upload;
|
|
178
|
+
// · the backfill of 6588 historic files (F276.4, Christian: "ALT") is the
|
|
179
|
+
// same operation at a different scale, so it gets the same machinery
|
|
180
|
+
// instead of a second one that drifts.
|
|
181
|
+
//
|
|
182
|
+
// `artifact_key` is the primary key, so enqueueing the same key twice is a
|
|
183
|
+
// no-op and the backfill can be re-run without duplicating uploads. That is
|
|
184
|
+
// an ACCEPTANCE CRITERION on F276.4, expressed in the schema rather than in a
|
|
185
|
+
// guard someone can forget to call.
|
|
186
|
+
ensureUploadQueue(db);
|
|
187
|
+
|
|
188
|
+
// F094.3 — backfill: every pre-existing baseline becomes version 1, pointing at
|
|
189
|
+
// its current (stable-key) artifact, so the visual history starts from what's
|
|
190
|
+
// live. Runs once (gated on current_version_id IS NULL).
|
|
191
|
+
const legacy = db
|
|
192
|
+
.query('SELECT * FROM lens_baselines WHERE current_version_id IS NULL')
|
|
193
|
+
.all() as LensBaselineRow[];
|
|
194
|
+
for (const b of legacy) {
|
|
195
|
+
const vid = randomUUID();
|
|
196
|
+
db.prepare(
|
|
197
|
+
`INSERT INTO lens_baseline_versions
|
|
198
|
+
(id, project_slug, baseline_key, version_no, artifact_key, width, height,
|
|
199
|
+
approved_from_run_id, commit_sha, note, approved_at)
|
|
200
|
+
VALUES (?,?,?,1,?,?,?,?,NULL,'backfilled v1',?)`,
|
|
201
|
+
).run(vid, b.project_slug, b.baseline_key, b.artifact_key, b.width, b.height, b.approved_from_run_id, b.updated_at);
|
|
202
|
+
db.prepare('UPDATE lens_baselines SET current_version_id = ? WHERE id = ?').run(vid, b.id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface LensRunRow {
|
|
207
|
+
id: string;
|
|
208
|
+
project_slug: string;
|
|
209
|
+
surface_key: string | null;
|
|
210
|
+
url: string;
|
|
211
|
+
mode: string;
|
|
212
|
+
selector: string | null;
|
|
213
|
+
status: string;
|
|
214
|
+
reason: string | null;
|
|
215
|
+
diff_ratio: number | null;
|
|
216
|
+
artifact_key: string | null;
|
|
217
|
+
diff_artifact_key: string | null;
|
|
218
|
+
dom_hash: string | null;
|
|
219
|
+
assert_pass: number | null;
|
|
220
|
+
assert_detail_json: string | null;
|
|
221
|
+
console_json: string | null;
|
|
222
|
+
network_json: string | null;
|
|
223
|
+
page_errors_json: string | null;
|
|
224
|
+
width: number | null;
|
|
225
|
+
height: number | null;
|
|
226
|
+
duration_ms: number | null;
|
|
227
|
+
critic_json: string | null;
|
|
228
|
+
created_at: number;
|
|
229
|
+
/** F074.30 — the build sha this run measured, stamped at capture time. NULL =
|
|
230
|
+
* not measured (no marker on the target); never read it as "same build". */
|
|
231
|
+
build?: string | null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface LensBaselineRow {
|
|
235
|
+
id: string;
|
|
236
|
+
project_slug: string;
|
|
237
|
+
baseline_key: string;
|
|
238
|
+
artifact_key: string;
|
|
239
|
+
width: number | null;
|
|
240
|
+
height: number | null;
|
|
241
|
+
approved_from_run_id: string | null;
|
|
242
|
+
created_at: number;
|
|
243
|
+
updated_at: number;
|
|
244
|
+
current_version_id: string | null; // F094.3 — active version pointer
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// F094.3 — one row per approved baseline version (append-only history).
|
|
248
|
+
export interface LensBaselineVersionRow {
|
|
249
|
+
id: string;
|
|
250
|
+
project_slug: string;
|
|
251
|
+
baseline_key: string;
|
|
252
|
+
version_no: number;
|
|
253
|
+
artifact_key: string;
|
|
254
|
+
width: number | null;
|
|
255
|
+
height: number | null;
|
|
256
|
+
approved_from_run_id: string | null;
|
|
257
|
+
commit_sha: string | null;
|
|
258
|
+
note: string | null;
|
|
259
|
+
approved_at: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface LensFlowRunRow {
|
|
263
|
+
id: string;
|
|
264
|
+
project_slug: string;
|
|
265
|
+
flow_name: string;
|
|
266
|
+
status: string;
|
|
267
|
+
failed_at: number | null;
|
|
268
|
+
reason: string | null;
|
|
269
|
+
steps_json: string;
|
|
270
|
+
step_count: number;
|
|
271
|
+
duration_ms: number | null;
|
|
272
|
+
created_at: number;
|
|
273
|
+
/** F255.5 — artifact key of the run's .webm recording (null unless record:true). */
|
|
274
|
+
video_key?: string | null;
|
|
275
|
+
/** F074.30 — the build sha this run measured, stamped at capture time. NULL =
|
|
276
|
+
* not measured (no marker on the target); never read it as "same build". */
|
|
277
|
+
build?: string | null;
|
|
278
|
+
/** F074.36 — the URL the run ended on. Answers "which page was that verdict
|
|
279
|
+
* about?" for a run nobody is watching any more. */
|
|
280
|
+
final_url?: string | null;
|
|
281
|
+
/** F074.36 — JSON {source, adapter, note}: the credential the run used, or the
|
|
282
|
+
* reason it had none. So a past GREEN run can be asked whether it was even
|
|
283
|
+
* logged in. */
|
|
284
|
+
auth_json?: string | null;
|
|
285
|
+
/** F074.59 — 'reduce' | 'no-preference': the motion state the run executed under.
|
|
286
|
+
* NULL means NOT RECORDED (a run older than the column), which is a third fact,
|
|
287
|
+
* not a synonym for 'reduce'. A reader comparing two runs must refuse on a null
|
|
288
|
+
* rather than assume they saw the same browser. */
|
|
289
|
+
reduced_motion?: string | null;
|
|
290
|
+
/** F272.2 — 'local' | 'tigris': where this run's step screenshots were PUT, stamped
|
|
291
|
+
* at capture time. NULL means NOT RECORDED (a run older than the column) and is a
|
|
292
|
+
* third fact: `unknown`, never rounded up to durable. Read from the row, never
|
|
293
|
+
* re-derived from the daemon's current config — a config flip must not retroactively
|
|
294
|
+
* bless bytes that never moved. */
|
|
295
|
+
artifact_backend?: string | null;
|
|
296
|
+
/** F074.99 — JSON {password_in_url?, not_listening?}: the run-level findings.
|
|
297
|
+
* `'{}'` means the run LOOKED and found nothing; NULL means NOT RECORDED (a run
|
|
298
|
+
* older than the column). A reader must not read the second as the first. */
|
|
299
|
+
findings_json?: string | null;
|
|
300
|
+
}
|
package/src/scrape.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// F125.2 — Lens scrape adapter.
|
|
2
|
+
//
|
|
3
|
+
// Extract an arbitrary article URL → { title, markdown }. This is the Research
|
|
4
|
+
// Adapter worker's "get the body" step. It runs INSIDE Lens (the daemon owns
|
|
5
|
+
// the warm Playwright browser) — never a raw one-off Playwright script (F112).
|
|
6
|
+
//
|
|
7
|
+
// Strategy = exactly the trail web-clipper recipe, server-side: Playwright
|
|
8
|
+
// renders the page (handles JS + auth), Readability runs IN the page on a DOM
|
|
9
|
+
// clone (injected as a global, no jsdom needed under Bun), and Turndown turns
|
|
10
|
+
// the article HTML into markdown Node-side. The Medium adapter authes via a
|
|
11
|
+
// stored storageState (Christian's Medium session) reusing the F074 auth layer.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import TurndownService from 'turndown';
|
|
16
|
+
import { withPage } from './browser.ts';
|
|
17
|
+
import { authPrepare } from './auth/index.ts';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
// Readability.js is a standalone class file (top-level `function Readability`),
|
|
21
|
+
// so injecting it as a <script> defines window.Readability in the page. Read it
|
|
22
|
+
// once at module load — node_modules is present (the daemon runs from source).
|
|
23
|
+
const READABILITY_SRC = readFileSync(require.resolve('@mozilla/readability/Readability.js'), 'utf8');
|
|
24
|
+
|
|
25
|
+
export interface ScrapeResult {
|
|
26
|
+
url: string;
|
|
27
|
+
title: string;
|
|
28
|
+
markdown: string;
|
|
29
|
+
byline: string | null;
|
|
30
|
+
length: number;
|
|
31
|
+
excerpt: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScrapeOptions {
|
|
35
|
+
/** F074.12 storageState path on the daemon host — the Medium session for
|
|
36
|
+
* paywalled posts. The path is not a secret; the gitignored file it points
|
|
37
|
+
* at is. Generic (non-authed) scrapes omit it. */
|
|
38
|
+
storageStatePath?: string;
|
|
39
|
+
/** Override the User-Agent — must match the cf_clearance cookie's UA on
|
|
40
|
+
* Cloudflare-fronted sites (Medium). */
|
|
41
|
+
userAgent?: string;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function scrapeArticle(url: string, opts: ScrapeOptions = {}): Promise<ScrapeResult> {
|
|
46
|
+
let parsed: URL;
|
|
47
|
+
try {
|
|
48
|
+
parsed = new URL(url);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`invalid url: ${url}`);
|
|
51
|
+
}
|
|
52
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
53
|
+
throw new Error(`unsupported protocol: ${parsed.protocol} (only http/https)`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const timeout = opts.timeoutMs ?? 30_000;
|
|
57
|
+
const article = await withPage(
|
|
58
|
+
{
|
|
59
|
+
// Tall viewport so lazy-loaded article bodies render before extraction.
|
|
60
|
+
viewport: { width: 1280, height: 2200 },
|
|
61
|
+
// CSP-strict sites (Medium) block addScriptTag; bypass so Readability injects.
|
|
62
|
+
bypassCSP: true,
|
|
63
|
+
// Look like a real browser past anti-bot walls (Cloudflare on Medium).
|
|
64
|
+
stealth: true,
|
|
65
|
+
...(opts.userAgent ? { userAgent: opts.userAgent } : {}),
|
|
66
|
+
prepareContext: opts.storageStatePath
|
|
67
|
+
? authPrepare({ adapter: 'storageState', statePath: opts.storageStatePath })
|
|
68
|
+
: undefined,
|
|
69
|
+
},
|
|
70
|
+
async (page) => {
|
|
71
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
|
|
72
|
+
// Let late content settle, but don't fail if the page never goes idle
|
|
73
|
+
// (Medium/analytics keep long-lived connections open).
|
|
74
|
+
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
|
|
75
|
+
await page.addScriptTag({ content: READABILITY_SRC });
|
|
76
|
+
return page.evaluate(() => {
|
|
77
|
+
// Readability was injected as a page global by addScriptTag.
|
|
78
|
+
const R = (globalThis as unknown as { Readability?: new (doc: Document, opts?: unknown) => { parse(): { title: string; content: string; byline?: string; length?: number; excerpt?: string } | null } }).Readability;
|
|
79
|
+
if (!R) return null;
|
|
80
|
+
const clone = document.cloneNode(true) as Document;
|
|
81
|
+
const result = new R(clone, { keepClasses: false, charThreshold: 0 }).parse();
|
|
82
|
+
if (!result) return null;
|
|
83
|
+
return {
|
|
84
|
+
title: result.title,
|
|
85
|
+
content: result.content,
|
|
86
|
+
byline: result.byline ?? null,
|
|
87
|
+
length: result.length ?? 0,
|
|
88
|
+
excerpt: result.excerpt ?? null,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (!article || !article.content) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
'extraction failed — Readability found no article content (page may require auth, be JS-gated, or not be an article)',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' });
|
|
101
|
+
turndown.addRule('strip-noise', {
|
|
102
|
+
filter: ['script', 'style', 'noscript', 'iframe', 'nav', 'footer', 'header'],
|
|
103
|
+
replacement: () => '',
|
|
104
|
+
});
|
|
105
|
+
const markdown = turndown.turndown(article.content).trim();
|
|
106
|
+
if (!markdown) throw new Error('extraction produced empty markdown');
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
url,
|
|
110
|
+
title: article.title || parsed.hostname,
|
|
111
|
+
markdown,
|
|
112
|
+
byline: article.byline,
|
|
113
|
+
length: article.length,
|
|
114
|
+
excerpt: article.excerpt,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// F074.97 — seed localStorage / sessionStorage BEFORE the first navigation.
|
|
2
|
+
//
|
|
3
|
+
// A whole class of bug is otherwise unphotographable by our own instrument:
|
|
4
|
+
// anything whose input is state the browser held BEFORE the page loaded. Lens
|
|
5
|
+
// could drive, fill, click and measure — but every run started from a clean
|
|
6
|
+
// profile, so a restored tab strip, a remembered filter, an unsent draft, a
|
|
7
|
+
// dismissed banner, or F178.4's seven-day prune (whose entire input is a
|
|
8
|
+
// persisted timestamp) could only be verified by a unit fixture, while the
|
|
9
|
+
// report read as if a browser had confirmed it.
|
|
10
|
+
//
|
|
11
|
+
// THE ORDERING IS THE WHOLE FEATURE. Writing the value AFTER navigation is the
|
|
12
|
+
// trap this card exists to avoid: the app has already booted on an empty store,
|
|
13
|
+
// so the run photographs the DEFAULT state while reporting that it seeded the
|
|
14
|
+
// stale one — green, wrong, and indistinguishable from success. addInitScript
|
|
15
|
+
// runs at document-start in every document the context loads, which is before
|
|
16
|
+
// any app script.
|
|
17
|
+
//
|
|
18
|
+
// ORIGIN-GUARDED, AND THE CALLER DOES NOT NAME THE ORIGIN. An init script runs
|
|
19
|
+
// in EVERY document, including a cross-origin iframe and a redirect to
|
|
20
|
+
// checkout.stripe.com. The origin is derived from the run's own target URL, so
|
|
21
|
+
// a seed cannot be aimed at somebody else's site even by mistake.
|
|
22
|
+
|
|
23
|
+
import type { BrowserContext } from 'playwright';
|
|
24
|
+
|
|
25
|
+
export interface StorageSeed {
|
|
26
|
+
/** localStorage keys to write for the run's own origin. */
|
|
27
|
+
local?: Record<string, unknown>;
|
|
28
|
+
/** sessionStorage keys to write for the run's own origin. */
|
|
29
|
+
session?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The shape addInitScript receives — everything already stringified, so the
|
|
33
|
+
* page-side function is trivial and has nothing left to decide. */
|
|
34
|
+
export interface StoragePayload {
|
|
35
|
+
origin: string;
|
|
36
|
+
local: Array<[string, string]>;
|
|
37
|
+
session: Array<[string, string]>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** localStorage stores STRINGS. A non-string value is JSON-stringified rather
|
|
41
|
+
* than left to `String()`, because the realistic payload (a tab list, a filter,
|
|
42
|
+
* a saved draft) IS json and `String({})` yields "[object Object]" — a value the
|
|
43
|
+
* app would fail to parse, from a seed that reported success. */
|
|
44
|
+
function toEntries(rec: Record<string, unknown> | undefined): Array<[string, string]> {
|
|
45
|
+
if (!rec) return [];
|
|
46
|
+
return Object.entries(rec).map(([k, v]) => [k, typeof v === 'string' ? v : JSON.stringify(v)]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Tolerate an MCP harness that stringified the object param (the same coercion
|
|
50
|
+
* coerceViewport exists for). Anything unparseable is refused with a reason —
|
|
51
|
+
* a seed silently dropped would put us back in the false-green this card is
|
|
52
|
+
* about, one layer further in. */
|
|
53
|
+
export function coerceStorageSeed(raw: unknown): StorageSeed | undefined {
|
|
54
|
+
if (raw == null) return undefined;
|
|
55
|
+
let value: unknown = raw;
|
|
56
|
+
if (typeof value === 'string') {
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (!trimmed) return undefined;
|
|
59
|
+
try {
|
|
60
|
+
value = JSON.parse(trimmed) as unknown;
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'storage must be an object like {local:{key:"value"}} (or its JSON text) — ' +
|
|
64
|
+
`could not parse: ${trimmed.slice(0, 80)}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
69
|
+
throw new Error('storage must be an object like {local:{…}, session:{…}}');
|
|
70
|
+
}
|
|
71
|
+
const o = value as Record<string, unknown>;
|
|
72
|
+
const bad = Object.keys(o).filter((k) => k !== 'local' && k !== 'session');
|
|
73
|
+
if (bad.length) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`storage accepts only "local" and "session" — unknown key(s): ${bad.join(', ')}. ` +
|
|
76
|
+
'Silently ignoring one would report a seeded run that never seeded anything.',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const seed: StorageSeed = {};
|
|
80
|
+
if (o.local != null) {
|
|
81
|
+
if (typeof o.local !== 'object' || Array.isArray(o.local)) throw new Error('storage.local must be an object of key → value');
|
|
82
|
+
seed.local = o.local as Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
if (o.session != null) {
|
|
85
|
+
if (typeof o.session !== 'object' || Array.isArray(o.session)) throw new Error('storage.session must be an object of key → value');
|
|
86
|
+
seed.session = o.session as Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
return seed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Build the payload for a seed against a run's target URL, or null when there
|
|
92
|
+
* is nothing to write. Null is what keeps the NEGATIVE CONTROL honest: no
|
|
93
|
+
* payload → no init script → a context byte-identical to every run before this
|
|
94
|
+
* option existed, so no baseline in the fleet can move. */
|
|
95
|
+
export function storagePayload(seed: StorageSeed | undefined, targetUrl: string): StoragePayload | null {
|
|
96
|
+
if (!seed) return null;
|
|
97
|
+
const local = toEntries(seed.local);
|
|
98
|
+
const session = toEntries(seed.session);
|
|
99
|
+
if (!local.length && !session.length) return null;
|
|
100
|
+
let origin: string;
|
|
101
|
+
try {
|
|
102
|
+
origin = new URL(targetUrl).origin;
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`storage seeding needs an absolute target url to derive its origin from — got "${targetUrl}". ` +
|
|
106
|
+
'The origin is derived, never passed, so a seed cannot land on a third-party site.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (origin === 'null') {
|
|
110
|
+
throw new Error(`storage seeding cannot target an opaque origin (${targetUrl}) — it has no storage to write.`);
|
|
111
|
+
}
|
|
112
|
+
return { origin, local, session };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The page-side function, exported so the ordering can be asserted without a
|
|
116
|
+
* browser. It runs at document-start in every document; the origin guard is
|
|
117
|
+
* what stops it writing into a cross-origin iframe or a payment redirect.
|
|
118
|
+
* Storage access THROWS in a sandboxed/partitioned document, so each write is
|
|
119
|
+
* guarded — a seed that cannot land must not take the whole run with it. */
|
|
120
|
+
export function seedStorageInPage(payload: StoragePayload): void {
|
|
121
|
+
try {
|
|
122
|
+
if (location.origin !== payload.origin) return;
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (const [k, v] of payload.local) {
|
|
127
|
+
try {
|
|
128
|
+
localStorage.setItem(k, v);
|
|
129
|
+
} catch {
|
|
130
|
+
/* storage unavailable in this document — nothing to seed */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [k, v] of payload.session) {
|
|
134
|
+
try {
|
|
135
|
+
sessionStorage.setItem(k, v);
|
|
136
|
+
} catch {
|
|
137
|
+
/* ditto */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Register the seed on a context. Called by withPage AFTER prepareContext, so a
|
|
143
|
+
* per-run key outranks the same key inside an auth storageState blob: init
|
|
144
|
+
* scripts run in registration order, and a per-call seed is the more specific
|
|
145
|
+
* instruction. */
|
|
146
|
+
export async function applyStorageSeed(context: BrowserContext, payload: StoragePayload | null): Promise<void> {
|
|
147
|
+
if (!payload) return;
|
|
148
|
+
await context.addInitScript(seedStorageInPage, payload);
|
|
149
|
+
}
|