demobite 1.0.9 → 1.2.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 +125 -79
- package/launcher/index.mjs +52 -9
- package/package.json +7 -3
- package/recorder/scripts/mux.mjs +4 -11
- package/recorder/scripts/post.sh +6 -3
- package/recorder/scripts/tts.mjs +3 -9
- package/scripts/calibrate.mjs +4 -2
- package/scripts/check-aliases.mjs +42 -0
- package/scripts/media-tools.mjs +93 -0
- package/scripts/trim.mjs +28 -13
- package/skill/SKILL.md +84 -3
- package/skill/scripts/briefs.mjs +164 -0
- package/skill/scripts/cleanup.mjs +104 -0
- package/skill/scripts/manifest.mjs +3 -2
- package/skill/scripts/stage-wait.mjs +87 -0
- package/skill/scripts/status.mjs +72 -0
- package/skill/scripts/upload.mjs +75 -79
- package/skill/scripts/vocab.mjs +88 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Where a staged take stands, and the wait for its word.
|
|
2
|
+
//
|
|
3
|
+
// node status.mjs <takeDir> wait for the decision, then for the bite to finish (reads <takeDir>/staged.json)
|
|
4
|
+
// node status.mjs <stagingId> same, by id
|
|
5
|
+
// node status.mjs <takeDir> --no-wait one look, no waiting: pending | approved (+bite status) | rejected
|
|
6
|
+
// node status.mjs --all one look at every take-*/staged.json under the current directory
|
|
7
|
+
//
|
|
8
|
+
// A batch stages every take with `upload.mjs --stage-only --no-open`, then the
|
|
9
|
+
// agent (or the human, later) comes back here per take. The same law as
|
|
10
|
+
// upload.mjs: no studio link before the bite is completed.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { waitForDecision, peekStaged } from "./stage-wait.mjs";
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const noWait = args.includes("--no-wait");
|
|
17
|
+
const all = args.includes("--all");
|
|
18
|
+
const target = args.find((a) => !a.startsWith("--"));
|
|
19
|
+
if (!target && !all) {
|
|
20
|
+
console.error("Usage: node status.mjs <takeDir|stagingId> [--no-wait] | node status.mjs --all");
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
25
|
+
let cfg = {};
|
|
26
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
27
|
+
if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
|
|
28
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
29
|
+
|
|
30
|
+
function readStaged(dir) {
|
|
31
|
+
const p = path.join(dir, "staged.json");
|
|
32
|
+
if (!fs.existsSync(p)) return null;
|
|
33
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function describe(st) {
|
|
37
|
+
if (!st.ok) return `unreachable (${st.httpStatus})`;
|
|
38
|
+
if (st.status === "rejected") return "discarded in the app";
|
|
39
|
+
if (st.status === "approved") return `approved, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
|
|
40
|
+
return "waiting for the word in the app";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (all) {
|
|
44
|
+
const dirs = fs.readdirSync(".").filter((d) => d.startsWith("take-") && fs.existsSync(path.join(d, "staged.json")));
|
|
45
|
+
if (dirs.length === 0) { console.log("No staged takes here."); process.exit(0); }
|
|
46
|
+
for (const d of dirs) {
|
|
47
|
+
const staged = readStaged(d);
|
|
48
|
+
const st = staged?.stagingId ? await peekStaged({ base, apiKey: cfg.api_key, stagingId: staged.stagingId }) : { ok: false, httpStatus: 0 };
|
|
49
|
+
console.log(`${d.padEnd(40)} ${describe(st)}${staged?.previewUrl ? `\n${"".padEnd(40)} ${staged.previewUrl}` : ""}`);
|
|
50
|
+
}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let stagingId = target;
|
|
55
|
+
let pageUrl = null;
|
|
56
|
+
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
57
|
+
const staged = readStaged(target);
|
|
58
|
+
if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
|
|
59
|
+
stagingId = staged.stagingId;
|
|
60
|
+
pageUrl = staged.previewUrl ?? null;
|
|
61
|
+
}
|
|
62
|
+
if (!pageUrl) pageUrl = `${base}/recording-preview/agentic/${encodeURIComponent(stagingId)}`;
|
|
63
|
+
|
|
64
|
+
if (noWait) {
|
|
65
|
+
const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
|
|
66
|
+
console.log(`${stagingId}: ${describe(st)}`);
|
|
67
|
+
if (st.ok && st.status === "approved" && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
|
|
68
|
+
process.exit(st.ok ? 0 : 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
|
|
72
|
+
process.exit(outcome.exitCode);
|
package/skill/scripts/upload.mjs
CHANGED
|
@@ -40,6 +40,28 @@ if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0))
|
|
|
40
40
|
// history show why this version was filmed. Never a secret, never required.
|
|
41
41
|
const noteIdx = process.argv.indexOf("--note");
|
|
42
42
|
const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim().slice(0, 600) : "";
|
|
43
|
+
// BATCH OF BRIEFS (2026-09-13): a take claimed from a brief carries its attempt
|
|
44
|
+
// (briefId, revision, contentHash, attemptRef) so provenance rides into the
|
|
45
|
+
// staged take and the bite. Read from <takeDir>/brief.json (written by
|
|
46
|
+
// briefs.mjs claim) unless --attempt <file> points elsewhere or --no-attempt
|
|
47
|
+
// opts out. `--stage-only` returns right after the two uploads (the batch
|
|
48
|
+
// waits with status.mjs); `--supersede` replaces a stage already pinned to
|
|
49
|
+
// this attempt (the server refuses a second one otherwise).
|
|
50
|
+
const stageOnly = process.argv.includes("--stage-only");
|
|
51
|
+
const supersede = process.argv.includes("--supersede");
|
|
52
|
+
const attemptIdx = process.argv.indexOf("--attempt");
|
|
53
|
+
const attemptPath = process.argv.includes("--no-attempt")
|
|
54
|
+
? null
|
|
55
|
+
: attemptIdx >= 0 ? String(process.argv[attemptIdx + 1] ?? "") : path.join(dir, "brief.json");
|
|
56
|
+
let attempt = null;
|
|
57
|
+
if (attemptPath && fs.existsSync(attemptPath)) {
|
|
58
|
+
try {
|
|
59
|
+
const b = JSON.parse(fs.readFileSync(attemptPath, "utf8"));
|
|
60
|
+
if (b.briefId && b.revision !== undefined && b.contentHash && b.attemptRef) {
|
|
61
|
+
attempt = { briefId: String(b.briefId), revision: b.revision, contentHash: String(b.contentHash), attemptRef: String(b.attemptRef) };
|
|
62
|
+
} else console.error(`${attemptPath} is missing briefId/revision/contentHash/attemptRef; staging without an attempt`);
|
|
63
|
+
} catch (e) { console.error(`${attemptPath} unreadable (${e.message}); staging without an attempt`); }
|
|
64
|
+
} else if (attemptIdx >= 0) { console.error(`--attempt ${attemptPath} not found`); process.exit(2); }
|
|
43
65
|
const cfgPath = path.resolve(".recorder", "config.json");
|
|
44
66
|
let cfg = {};
|
|
45
67
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
@@ -65,6 +87,22 @@ try {
|
|
|
65
87
|
if (retakeNote) recipe.config = { ...(recipe.config ?? {}), retake_note: retakeNote };
|
|
66
88
|
}
|
|
67
89
|
} catch (e) { console.error("recipe skipped:", e.message); }
|
|
90
|
+
// LAW (founder 2026-09-14): a take that created anything returns the workspace
|
|
91
|
+
// to its initial state before it is staged. The storyboard declares cleanup[];
|
|
92
|
+
// cleanup.mjs writes cleanup.json with the checks. No passing cleanup.json,
|
|
93
|
+
// no stage. --allow-uncleaned overrides, and says so out loud.
|
|
94
|
+
try {
|
|
95
|
+
const sbp = path.join(dir, "storyboard.json");
|
|
96
|
+
const sb = fs.existsSync(sbp) ? JSON.parse(fs.readFileSync(sbp, "utf8")) : {};
|
|
97
|
+
if (Array.isArray(sb.cleanup) && sb.cleanup.length > 0) {
|
|
98
|
+
const cp = path.join(dir, "cleanup.json");
|
|
99
|
+
const rep = fs.existsSync(cp) ? JSON.parse(fs.readFileSync(cp, "utf8")) : null;
|
|
100
|
+
if (!rep || rep.ok !== true) {
|
|
101
|
+
if (process.argv.includes("--allow-uncleaned")) console.error("WARNING: staging a take whose cleanup did not run or did not pass (--allow-uncleaned). The workspace may still carry what the take created.");
|
|
102
|
+
else { console.error(`This take declares cleanup[] but ${rep ? "cleanup.json says NOT ok" : "cleanup.json is missing"}. Run: node scripts/cleanup.mjs ${dir} (then stage again)`); process.exit(1); }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (e) { console.error(`cleanup check skipped: ${e.message}`); }
|
|
68
106
|
if (!fs.existsSync(cleanPath)) { console.error(`${cleanPath} not found. Run: node scripts/trim.mjs ${dir}`); process.exit(1); }
|
|
69
107
|
if (!fs.existsSync(wirePath)) { console.error(`${wirePath} not found. Run: node scripts/manifest.mjs ${dir}`); process.exit(1); }
|
|
70
108
|
const manifest = JSON.parse(fs.readFileSync(wirePath, "utf8"));
|
|
@@ -144,13 +182,23 @@ if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId}
|
|
|
144
182
|
|
|
145
183
|
// ── stage ──────────────────────────────────────────────────────────────────
|
|
146
184
|
const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
185
|
+
if (attempt) {
|
|
186
|
+
// The attempt moves to "uploading" before the stage call; a 409 here means
|
|
187
|
+
// the server already sees it at or past that state, which is fine.
|
|
188
|
+
try {
|
|
189
|
+
const ev = await fetch(`${base}/api/recorder/briefs/attempts/${encodeURIComponent(attempt.attemptRef)}`, {
|
|
190
|
+
method: "PUT", headers: authHeaders, body: JSON.stringify({ event: "uploading" }),
|
|
191
|
+
});
|
|
192
|
+
if (!ev.ok && ev.status !== 409) console.error(`attempt event "uploading" → ${ev.status} (continuing)`);
|
|
193
|
+
} catch (e) { console.error(`attempt event "uploading" failed: ${e.message} (continuing)`); }
|
|
194
|
+
}
|
|
147
195
|
const previewSizeBytes = fs.statSync(cleanPath).size;
|
|
148
196
|
let stageRes;
|
|
149
197
|
try {
|
|
150
198
|
stageRes = await fetch(`${base}/api/recorder/stage`, {
|
|
151
199
|
method: "PUT",
|
|
152
200
|
headers: authHeaders,
|
|
153
|
-
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}), ...(retakeOfBiteId ? { retakeOfBiteId } : {}) }),
|
|
201
|
+
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}), ...(retakeOfBiteId ? { retakeOfBiteId } : {}), ...(attempt ? { attempt } : {}), ...(attempt && supersede ? { supersede: true } : {}) }),
|
|
154
202
|
});
|
|
155
203
|
} catch (e) {
|
|
156
204
|
console.error(`Could not reach ${base}: ${e.message}`);
|
|
@@ -168,6 +216,14 @@ if (!stageRes.ok) {
|
|
|
168
216
|
console.error(`DemoBites declined the stage. Check ${base}/bites and try again.`);
|
|
169
217
|
process.exit(1);
|
|
170
218
|
}
|
|
219
|
+
if (errBody?.error === "invalid_attempt") {
|
|
220
|
+
console.error(`DemoBites refused the attempt on this take: ${errBody.message ?? "invalid_attempt"}. Re-claim the brief (node scripts/briefs.mjs claim …) and stage again.`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
if (errBody?.error === "attempt_already_staged") {
|
|
224
|
+
console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}. Review that one, or stage again with --supersede to replace it.`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
171
227
|
console.error(`Stage failed: ${stageRes.status} ${errBody ? JSON.stringify(errBody) : ""}`);
|
|
172
228
|
process.exit(1);
|
|
173
229
|
}
|
|
@@ -191,6 +247,14 @@ async function putS3(url, contentType, filePath, label) {
|
|
|
191
247
|
}
|
|
192
248
|
await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
|
|
193
249
|
await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
|
|
250
|
+
// The staging id used to be printed only; a batch resumes from disk, so it is
|
|
251
|
+
// persisted next to the take (status.mjs reads it).
|
|
252
|
+
try {
|
|
253
|
+
fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
|
|
254
|
+
stagingId, previewUrl: new URL(previewUrl, base).toString(), queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
|
|
255
|
+
pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null, at: new Date().toISOString(),
|
|
256
|
+
}, null, 2) + "\n");
|
|
257
|
+
} catch (e) { console.error(`staged.json not written: ${e.message}`); }
|
|
194
258
|
|
|
195
259
|
// ── open the in-app preview — the review happens THERE ─────────────────────
|
|
196
260
|
// Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
|
|
@@ -211,83 +275,15 @@ if (!noOpen) {
|
|
|
211
275
|
} catch { /* printing the URL above is the fallback */ }
|
|
212
276
|
}
|
|
213
277
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const POLL_MS = 4000;
|
|
218
|
-
const DECISION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
219
|
-
const deadline = Date.now() + DECISION_TIMEOUT_MS;
|
|
220
|
-
let announced = false;
|
|
221
|
-
let completed = false;
|
|
222
|
-
let approvedBiteId = null;
|
|
223
|
-
let finalStudioUrl = null;
|
|
224
|
-
process.stdout.write("Waiting for your word in the browser");
|
|
225
|
-
while (Date.now() < deadline) {
|
|
226
|
-
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
227
|
-
let res;
|
|
228
|
-
try {
|
|
229
|
-
res = await fetch(`${base}/api/recorder/stage?id=${stagingId}`, {
|
|
230
|
-
headers: { Authorization: `Bearer ${cfg.api_key}` },
|
|
231
|
-
});
|
|
232
|
-
} catch { process.stdout.write("."); continue; }
|
|
233
|
-
if (!res.ok) { process.stdout.write("."); continue; }
|
|
234
|
-
const st = await res.json().catch(() => null);
|
|
235
|
-
if (!st) { process.stdout.write("."); continue; }
|
|
236
|
-
if (st.status === "rejected") {
|
|
237
|
-
process.stdout.write("\n");
|
|
238
|
-
console.error("Discarded in the app. Adjust the storyboard and film again.");
|
|
239
|
-
process.exit(1);
|
|
240
|
-
}
|
|
241
|
-
if (st.status === "approved") {
|
|
242
|
-
if (!announced) {
|
|
243
|
-
process.stdout.write("\n");
|
|
244
|
-
console.log(`Approved — bite ${st.biteId} is being created`);
|
|
245
|
-
announced = true;
|
|
246
|
-
approvedBiteId = st.biteId;
|
|
247
|
-
finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
|
|
248
|
-
process.stdout.write("Waiting for the bite to finish");
|
|
249
|
-
}
|
|
250
|
-
if (st.biteStatus === "completed") {
|
|
251
|
-
completed = true;
|
|
252
|
-
process.stdout.write("\n");
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
if (st.biteStatus === "failed") {
|
|
256
|
-
process.stdout.write("\n");
|
|
257
|
-
console.error("The pipeline FAILED for this bite. Do not hand over any link — investigate.");
|
|
258
|
-
process.exit(1);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
process.stdout.write(".");
|
|
262
|
-
}
|
|
263
|
-
if (!announced) {
|
|
264
|
-
process.stdout.write("\n");
|
|
265
|
-
console.error(`No decision yet. The preview stays available at:\n ${pageUrl}`);
|
|
266
|
-
process.exit(1);
|
|
267
|
-
}
|
|
268
|
-
// LAW: the studio link exists ONLY behind a confirmed 'completed'. A deadline
|
|
269
|
-
// expiry after approval is NOT completion (review finding: the fallthrough
|
|
270
|
-
// here once printed the link for an unfinished bite).
|
|
271
|
-
if (!completed) {
|
|
272
|
-
console.error("Approved, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.");
|
|
273
|
-
process.exit(1);
|
|
278
|
+
if (stageOnly) {
|
|
279
|
+
console.log(`Staged only. Wait for the decision later with: node scripts/status.mjs ${dir}`);
|
|
280
|
+
process.exit(0);
|
|
274
281
|
}
|
|
275
282
|
|
|
276
|
-
// ──
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
} catch { /* summary is best-effort; readiness was confirmed above */ }
|
|
284
|
-
if (last && last.status === "completed") {
|
|
285
|
-
console.log(
|
|
286
|
-
`Ready: "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
|
|
287
|
-
`${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
|
|
288
|
-
);
|
|
289
|
-
if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
|
|
290
|
-
else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
|
|
291
|
-
if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
|
|
292
|
-
}
|
|
293
|
-
if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
|
|
283
|
+
// ── poll while the human decides, then until the bite is READY ─────────────
|
|
284
|
+
// Shared with status.mjs (a batch waits there). The law it enforces: never
|
|
285
|
+
// hand a human a studio link before the bite is finished; Approve only
|
|
286
|
+
// STARTS the pipeline.
|
|
287
|
+
const { waitForDecision } = await import("./stage-wait.mjs");
|
|
288
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
|
|
289
|
+
process.exit(outcome.exitCode);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Harvest the product's CURRENT vocabulary from the running app (Phase 3a).
|
|
2
|
+
//
|
|
3
|
+
// node vocab.mjs <takeDir> <url> [<url> ...] [--headless=false]
|
|
4
|
+
//
|
|
5
|
+
// Opens each URL headless on the recorder profile (signed in, no video) and
|
|
6
|
+
// writes <takeDir>/vocab.json: for every screen, the nav labels (visible text,
|
|
7
|
+
// aria-label, title, tooltips), the page headings, the button and link labels,
|
|
8
|
+
// and the titles of any open dialogs. The storyboard's narration and
|
|
9
|
+
// on_screen lines may only use nouns that appear here. The brief's and the
|
|
10
|
+
// PR's words are hints about WHAT changed, never the words the demo speaks
|
|
11
|
+
// (founder, 2026-09-14: a take said "Release Readiness" while the app said
|
|
12
|
+
// "Assignments").
|
|
13
|
+
//
|
|
14
|
+
// Prints a short inventory so the agent can read it in chat before writing.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { chromium } from "playwright";
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const dir = args[0];
|
|
21
|
+
const urls = args.slice(1).filter((a) => /^https?:\/\//.test(a));
|
|
22
|
+
if (!dir || urls.length === 0) {
|
|
23
|
+
console.error("Usage: node vocab.mjs <takeDir> <url> [<url> ...]");
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
const profileDir = path.resolve(".recorder", "profile");
|
|
28
|
+
const headless = !args.includes("--headless=false");
|
|
29
|
+
|
|
30
|
+
const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless, viewport: { width: 1920, height: 1080 } }).catch(async () =>
|
|
31
|
+
chromium.launchPersistentContext(profileDir, { headless, viewport: { width: 1920, height: 1080 } }),
|
|
32
|
+
);
|
|
33
|
+
const page = await ctx.newPage();
|
|
34
|
+
|
|
35
|
+
const harvest = () => {
|
|
36
|
+
const clean = (s) => (s || "").replace(/\s+/g, " ").trim();
|
|
37
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden"; };
|
|
38
|
+
const labelOf = (el) => clean(el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("data-tooltip") || el.innerText);
|
|
39
|
+
const uniq = (xs) => [...new Set(xs.filter(Boolean))];
|
|
40
|
+
const inRail = (el) => el.getBoundingClientRect().x < 90;
|
|
41
|
+
const nav = uniq([...document.querySelectorAll("nav a, nav button, aside a, aside button, [role='navigation'] a, header a, header button")].filter(vis).map((el) => {
|
|
42
|
+
const label = labelOf(el);
|
|
43
|
+
const href = el.getAttribute("href") || "";
|
|
44
|
+
// Tooltips often live on a sibling/parent: look at the closest element that carries one.
|
|
45
|
+
const tip = clean(el.closest("[title],[aria-label],[data-tooltip]")?.getAttribute("title") || el.closest("[data-tooltip]")?.getAttribute("data-tooltip") || "");
|
|
46
|
+
return (label || tip) ? `${label || tip}${href ? ` (${href})` : ""}${inRail(el) ? " [rail]" : ""}` : "";
|
|
47
|
+
}));
|
|
48
|
+
const headings = uniq([...document.querySelectorAll("h1, h2, h3")].filter(vis).map((el) => clean(el.innerText)).filter((t) => t.length < 90));
|
|
49
|
+
// Editors keep their controls in header bars and side panels, so nothing is
|
|
50
|
+
// excluded by landmark; only the left rail (x < 90) is reported separately.
|
|
51
|
+
const buttons = uniq([...document.querySelectorAll("button, [role='button'], a[href], [role='tab'], [role='menuitem']")].filter(vis).filter((el) => !inRail(el)).map(labelOf).filter((t) => t && t.length < 60));
|
|
52
|
+
const fields = uniq([...document.querySelectorAll("input, textarea, [contenteditable='true']")].filter(vis).map((el) => clean(el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.closest("label")?.innerText || "")).filter((t) => t && t.length < 60));
|
|
53
|
+
const dialogs = uniq([...document.querySelectorAll("[role='dialog'], [role='alertdialog']")].map((d) => clean(d.querySelector("h1,h2,h3,[id*='title']")?.innerText || d.getAttribute("aria-label") || "")));
|
|
54
|
+
const tooltips = uniq([...document.querySelectorAll("[role='tooltip']")].map((el) => clean(el.innerText)));
|
|
55
|
+
return { title: document.title, nav, headings, buttons, fields, dialogs, tooltips };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const screens = [];
|
|
59
|
+
for (const url of urls) {
|
|
60
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 }).catch(() => page.goto(url, { waitUntil: "load", timeout: 60000 }).catch((e) => console.error(`${url}: ${e.message}`)));
|
|
61
|
+
// Entitlement-gated nav items paint late; give the app a moment, then hover
|
|
62
|
+
// every rail link (the icons carry their names as tooltips, not as text) so
|
|
63
|
+
// the tooltip portals render into the DOM.
|
|
64
|
+
await page.waitForTimeout(5000);
|
|
65
|
+
const rail = page.locator("a[href^='/']");
|
|
66
|
+
const n = Math.min(await rail.count(), 40);
|
|
67
|
+
const tips = new Set();
|
|
68
|
+
for (let i = 0; i < n; i++) {
|
|
69
|
+
const el = rail.nth(i);
|
|
70
|
+
const box = await el.boundingBox().catch(() => null);
|
|
71
|
+
if (!box || box.x > 90 || box.width > 80) continue;
|
|
72
|
+
await el.hover().catch(() => {});
|
|
73
|
+
await page.waitForTimeout(700);
|
|
74
|
+
const texts = await page.locator("[role='tooltip'], [data-slot='tooltip-content'], [data-slot='tooltip-popup']").allInnerTexts().catch(() => []);
|
|
75
|
+
for (const t of texts) if (t.trim()) tips.add(`${t.trim()} (${(await el.getAttribute("href")) || "?"})`);
|
|
76
|
+
}
|
|
77
|
+
await page.mouse.move(600, 600);
|
|
78
|
+
const h = await page.evaluate(harvest);
|
|
79
|
+
h.railTooltips = [...tips];
|
|
80
|
+
h.url = page.url();
|
|
81
|
+
screens.push(h);
|
|
82
|
+
console.log(`\n${h.url}\n title: ${h.title}\n headings: ${h.headings.join(" · ")}\n rail tooltips: ${h.railTooltips.join(" · ") || "(none rendered)"}\n nav: ${h.nav.slice(0, 20).join(" · ")}\n buttons/links: ${h.buttons.slice(0, 60).join(" · ")}${h.fields.length ? `\n fields: ${h.fields.join(" · ")}` : ""}${h.dialogs.filter(Boolean).length ? `\n dialogs: ${h.dialogs.filter(Boolean).join(" · ")}` : ""}`);
|
|
83
|
+
}
|
|
84
|
+
await ctx.close();
|
|
85
|
+
|
|
86
|
+
const out = { harvestedAt: new Date().toISOString(), screens };
|
|
87
|
+
fs.writeFileSync(path.join(dir, "vocab.json"), JSON.stringify(out, null, 2) + "\n");
|
|
88
|
+
console.log(`\nvocab.json written (${screens.length} screen${screens.length === 1 ? "" : "s"}). Narration may use only these nouns.`);
|