@spark-apps/quickpeek 1.2.3 → 1.2.5
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 +64 -13
- package/dist/assets/music/calm-drifting-piano.mp3 +0 -0
- package/dist/assets/music/lofi-roof-tops.mp3 +0 -0
- package/dist/assets/music/manifest.json +32 -0
- package/dist/assets/music/upbeat-spring-on-the-horizon.mp3 +0 -0
- package/dist/highlight.css +8 -7
- package/dist/index.d.mts +607 -43
- package/dist/index.mjs +2333 -397
- package/dist/mcp-tools.mjs +7439 -1839
- package/dist/qp.js +6929 -1709
- package/dist/voices-ufga0oOn.d.mts +647 -0
- package/dist/web.d.mts +5 -3
- package/dist/web.mjs +405 -164
- package/mcp.mjs +94 -21
- package/package.json +16 -3
- package/dist/scraping-C334VxX5.d.mts +0 -335
package/dist/web.mjs
CHANGED
|
@@ -74,8 +74,27 @@ function parsePartial(partial) {
|
|
|
74
74
|
function getErrorMessage(err) {
|
|
75
75
|
return err instanceof Error ? err.message : String(err);
|
|
76
76
|
}
|
|
77
|
+
var STOCK_PHRASES = [
|
|
78
|
+
[/\bfree to start\b/gi, "free to try"],
|
|
79
|
+
[/\bget started today\b/gi, "start now"],
|
|
80
|
+
[/\bsay goodbye to\b/gi, "no more"],
|
|
81
|
+
[/\bsupercharge\b/gi, "speed up"],
|
|
82
|
+
[/\beffortlessly\b/gi, "easily"],
|
|
83
|
+
[/\bseamlessly\b/gi, ""]
|
|
84
|
+
];
|
|
85
|
+
function deStock(text) {
|
|
86
|
+
let out = text;
|
|
87
|
+
for (const [re, replacement] of STOCK_PHRASES) out = out.replace(re, replacement);
|
|
88
|
+
return out.replace(/\s{2,}/g, " ").replace(/\s+([,.!?])/g, "$1").trim();
|
|
89
|
+
}
|
|
90
|
+
function stripLongDashes(text) {
|
|
91
|
+
return text.replace(/(\d)\s*[\u2013\u2014]\s*(\d)/g, "$1-$2").replace(/\s*[\u2013\u2014]\s*/g, ", ").replace(/,\s*,/g, ",").replace(/\s+([,.!?])/g, "$1").replace(/\s{2,}/g, " ").trim();
|
|
92
|
+
}
|
|
77
93
|
function toTitleCase(text) {
|
|
78
|
-
return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
|
|
94
|
+
return deStock(stripLongDashes(text)).replace(/\w+(?:'\w+)*/g, (word, index) => {
|
|
95
|
+
if (/[A-Z]/.test(word.slice(1))) {
|
|
96
|
+
return word;
|
|
97
|
+
}
|
|
79
98
|
if (index === 0) {
|
|
80
99
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
81
100
|
}
|
|
@@ -85,8 +104,19 @@ function toTitleCase(text) {
|
|
|
85
104
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
86
105
|
});
|
|
87
106
|
}
|
|
107
|
+
async function runPooled(count, size, task) {
|
|
108
|
+
let cursor = 0;
|
|
109
|
+
const worker = async () => {
|
|
110
|
+
while (cursor < count) {
|
|
111
|
+
const index = cursor++;
|
|
112
|
+
await task(index);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(size, count)) }, worker));
|
|
116
|
+
}
|
|
88
117
|
|
|
89
118
|
// src/lib/api-client.ts
|
|
119
|
+
var PLAN_MAX_TOKENS = 1500;
|
|
90
120
|
var SERVER_BACKOFF_MS = [2e3, 4e3];
|
|
91
121
|
var SPARK_AI_BASE = "https://sparkbrain.app";
|
|
92
122
|
var SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
|
|
@@ -139,7 +169,7 @@ async function getOrRefreshDirectToken() {
|
|
|
139
169
|
}
|
|
140
170
|
}
|
|
141
171
|
async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
|
|
142
|
-
const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
|
|
172
|
+
const { onRetry, backoffMs = SERVER_BACKOFF_MS, maxTokens } = opts;
|
|
143
173
|
let lastRetriableError = null;
|
|
144
174
|
const host = hostOf(chatUrl);
|
|
145
175
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
@@ -154,7 +184,11 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
|
|
|
154
184
|
messages: [
|
|
155
185
|
{ role: "system", content: systemPrompt },
|
|
156
186
|
{ role: "user", content: userPrompt }
|
|
157
|
-
]
|
|
187
|
+
],
|
|
188
|
+
// Explicit numeric test, not truthiness: a 0 or negative budget is a
|
|
189
|
+
// caller bug, and silently falling back to the tiny backend default
|
|
190
|
+
// reproduces exactly the failure this option exists to prevent.
|
|
191
|
+
...typeof maxTokens === "number" && maxTokens > 0 ? { max_tokens: Math.floor(maxTokens) } : {}
|
|
158
192
|
})
|
|
159
193
|
});
|
|
160
194
|
const bodyText = await response.text().catch(() => "");
|
|
@@ -367,6 +401,75 @@ function shouldSkipLink(text, href, currentPath) {
|
|
|
367
401
|
}
|
|
368
402
|
|
|
369
403
|
// src/lib/crawl.ts
|
|
404
|
+
var SETTLE_POLL_MS = 400;
|
|
405
|
+
var SETTLE_STABLE_POLLS = 2;
|
|
406
|
+
var SETTLE_CAP_MS = 2e4;
|
|
407
|
+
async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
|
|
408
|
+
const deadline = Date.now() + cap;
|
|
409
|
+
let previous = -1;
|
|
410
|
+
let unchanged = 0;
|
|
411
|
+
while (Date.now() < deadline) {
|
|
412
|
+
const count = await page.evaluate(() => document.getElementsByTagName("*").length).catch(() => -1);
|
|
413
|
+
if (count < 0) return;
|
|
414
|
+
if (count === previous) {
|
|
415
|
+
if (++unchanged >= SETTLE_STABLE_POLLS) return;
|
|
416
|
+
} else {
|
|
417
|
+
unchanged = 0;
|
|
418
|
+
previous = count;
|
|
419
|
+
}
|
|
420
|
+
await page.waitForTimeout(SETTLE_POLL_MS);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
var PLACEHOLDER_POLL_MS = 250;
|
|
424
|
+
var PLACEHOLDER_CAP_MS = 8e3;
|
|
425
|
+
var PLACEHOLDER_STATIC_POLLS = 4;
|
|
426
|
+
var PLACEHOLDER_CLEAR_POLLS = 3;
|
|
427
|
+
var PLACEHOLDER_SELECTOR = [
|
|
428
|
+
'[aria-busy="true"]',
|
|
429
|
+
'[role="progressbar"]',
|
|
430
|
+
'[data-loading="true"]',
|
|
431
|
+
'[class*="skeleton" i]',
|
|
432
|
+
'[class*="animate-pulse" i]',
|
|
433
|
+
'[class*="spinner" i]'
|
|
434
|
+
].join(",");
|
|
435
|
+
async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
|
|
436
|
+
const deadline = Date.now() + cap;
|
|
437
|
+
let previous = -1;
|
|
438
|
+
let unchanged = 0;
|
|
439
|
+
let clear = 0;
|
|
440
|
+
while (Date.now() < deadline) {
|
|
441
|
+
const count = await page.evaluate((selector) => {
|
|
442
|
+
let visible = 0;
|
|
443
|
+
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
444
|
+
const rect = el.getBoundingClientRect();
|
|
445
|
+
if (rect.width > 0 && rect.height > 0) visible++;
|
|
446
|
+
}
|
|
447
|
+
return visible;
|
|
448
|
+
}, PLACEHOLDER_SELECTOR).catch(() => 0);
|
|
449
|
+
if (count === 0) {
|
|
450
|
+
if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
|
|
451
|
+
await page.waitForTimeout(PLACEHOLDER_POLL_MS);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
clear = 0;
|
|
455
|
+
if (count === previous) {
|
|
456
|
+
if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
|
|
457
|
+
} else {
|
|
458
|
+
unchanged = 0;
|
|
459
|
+
previous = count;
|
|
460
|
+
}
|
|
461
|
+
await page.waitForTimeout(PLACEHOLDER_POLL_MS);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function gotoSettled(page, url, timeout = 3e4) {
|
|
465
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
466
|
+
await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {
|
|
467
|
+
});
|
|
468
|
+
await waitForDomStable(page).catch(() => {
|
|
469
|
+
});
|
|
470
|
+
await waitForPlaceholdersGone(page).catch(() => {
|
|
471
|
+
});
|
|
472
|
+
}
|
|
370
473
|
function idSelector(id) {
|
|
371
474
|
return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
|
|
372
475
|
}
|
|
@@ -387,6 +490,40 @@ function getActionForType(type) {
|
|
|
387
490
|
return "type";
|
|
388
491
|
}
|
|
389
492
|
}
|
|
493
|
+
async function sitemapRoutes(pageUrl) {
|
|
494
|
+
let origin;
|
|
495
|
+
try {
|
|
496
|
+
origin = new URL(pageUrl).origin;
|
|
497
|
+
} catch {
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const res = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(4e3) });
|
|
502
|
+
if (!res.ok) return [];
|
|
503
|
+
const xml = await res.text();
|
|
504
|
+
const seen = /* @__PURE__ */ new Set();
|
|
505
|
+
const routes = [];
|
|
506
|
+
for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
|
|
507
|
+
const raw = (m[1] ?? "").trim();
|
|
508
|
+
let u;
|
|
509
|
+
try {
|
|
510
|
+
u = new URL(raw);
|
|
511
|
+
} catch {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (u.origin !== origin) continue;
|
|
515
|
+
if (/\.(txt|xml|json|png|jpg|svg|ico|pdf)$/i.test(u.pathname)) continue;
|
|
516
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
517
|
+
if (!path || seen.has(u.href)) continue;
|
|
518
|
+
seen.add(u.href);
|
|
519
|
+
routes.push({ text: path.slice(1), href: u.href });
|
|
520
|
+
if (routes.length >= 20) break;
|
|
521
|
+
}
|
|
522
|
+
return routes;
|
|
523
|
+
} catch {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
390
527
|
var noopTask = () => ({ succeed: () => {
|
|
391
528
|
} });
|
|
392
529
|
var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
@@ -401,155 +538,180 @@ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
|
|
|
401
538
|
}
|
|
402
539
|
const browserTask = task("Launching browser");
|
|
403
540
|
const browser = await chromium.launch({ headless: true });
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
541
|
+
try {
|
|
542
|
+
const context = await browser.newContext({
|
|
543
|
+
...storageStatePath ? { storageState: storageStatePath } : {},
|
|
544
|
+
// Crawl at the size we will record at, or the measurements below describe
|
|
545
|
+
// a page nobody will ever see.
|
|
546
|
+
...viewport ? { viewport } : {}
|
|
547
|
+
});
|
|
548
|
+
const page = await context.newPage();
|
|
549
|
+
browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
|
|
550
|
+
const crawlTask = task("Crawling page");
|
|
551
|
+
await gotoSettled(page, url, 3e4);
|
|
552
|
+
const title = await page.title();
|
|
553
|
+
const scrollable = await page.evaluate(
|
|
554
|
+
"Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
|
|
555
|
+
);
|
|
556
|
+
const rawElements = await page.evaluate(`
|
|
557
|
+
(() => {
|
|
558
|
+
const items = [];
|
|
421
559
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
560
|
+
// Helper to get parent context
|
|
561
|
+
const getCtx = (el) => {
|
|
562
|
+
let p = el.parentElement;
|
|
563
|
+
while (p && p !== document.body) {
|
|
564
|
+
if (p.id) return '#' + p.id;
|
|
565
|
+
const cls = Array.from(p.classList).find(c =>
|
|
566
|
+
/toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
|
|
567
|
+
);
|
|
568
|
+
if (cls) return '.' + cls;
|
|
569
|
+
if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
|
|
570
|
+
return p.tagName.toLowerCase();
|
|
571
|
+
}
|
|
572
|
+
p = p.parentElement;
|
|
433
573
|
}
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
return '';
|
|
437
|
-
};
|
|
574
|
+
return '';
|
|
575
|
+
};
|
|
438
576
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
577
|
+
// Buttons (use textContent to catch hidden elements too)
|
|
578
|
+
document.querySelectorAll('button, [role="button"]').forEach(el => {
|
|
579
|
+
const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
|
580
|
+
const testId = el.getAttribute('data-testid');
|
|
581
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
582
|
+
if (!text && !testId && !ariaLabel) return;
|
|
583
|
+
items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
|
|
584
|
+
});
|
|
447
585
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
586
|
+
// Inputs (including file inputs, range sliders, checkboxes, selects)
|
|
587
|
+
document.querySelectorAll('input, textarea, select').forEach(el => {
|
|
588
|
+
if (el.type === 'hidden') return;
|
|
589
|
+
const testId = el.getAttribute('data-testid');
|
|
590
|
+
const inputType = el.type || 'text';
|
|
453
591
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
592
|
+
// Get label text for any input type
|
|
593
|
+
let labelText = '';
|
|
594
|
+
if (el.labels?.[0]) {
|
|
595
|
+
labelText = el.labels[0].textContent?.trim() || '';
|
|
596
|
+
} else if (el.id) {
|
|
597
|
+
const label = document.querySelector('label[for="' + el.id + '"]');
|
|
598
|
+
if (label) labelText = label.textContent?.trim() || '';
|
|
599
|
+
}
|
|
462
600
|
|
|
463
|
-
|
|
601
|
+
const text = labelText || el.placeholder || el.name || el.id || '';
|
|
464
602
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
603
|
+
// Include file, range, and checkbox inputs even without text
|
|
604
|
+
const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
|
|
605
|
+
if (!text && !testId && !alwaysInclude) return;
|
|
468
606
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
607
|
+
items.push({
|
|
608
|
+
type: inputType,
|
|
609
|
+
text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
|
|
610
|
+
testId,
|
|
611
|
+
id: el.id,
|
|
612
|
+
name: el.name,
|
|
613
|
+
placeholder: el.placeholder,
|
|
614
|
+
isFile: inputType === 'file'
|
|
615
|
+
});
|
|
477
616
|
});
|
|
478
|
-
});
|
|
479
617
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
618
|
+
// Links (use textContent to catch hidden elements too)
|
|
619
|
+
document.querySelectorAll('a[href]').forEach(el => {
|
|
620
|
+
const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
|
621
|
+
if (!text || text.length < 2) return;
|
|
622
|
+
// Keep the destination, not just the label. Without it the planner
|
|
623
|
+
// can name a page in a caption but has no way to go there, so every
|
|
624
|
+
// line gets forced onto whatever is already on screen.
|
|
625
|
+
let href = '';
|
|
626
|
+
try {
|
|
627
|
+
const u = new URL(el.getAttribute('href') || '', location.href);
|
|
628
|
+
if (u.origin === location.origin) href = u.href;
|
|
629
|
+
} catch { /* a javascript: or mailto: href is not somewhere to go */ }
|
|
630
|
+
items.push({ type: 'link', text, href });
|
|
631
|
+
});
|
|
485
632
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
633
|
+
return items.slice(0, 40);
|
|
634
|
+
})()
|
|
635
|
+
`);
|
|
636
|
+
const filteredElements = rawElements.filter((el) => {
|
|
637
|
+
if (!el.text) return true;
|
|
638
|
+
const text = el.text.toLowerCase().trim();
|
|
639
|
+
return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
|
|
640
|
+
});
|
|
641
|
+
const btnTexts = {};
|
|
642
|
+
for (const el of filteredElements) {
|
|
643
|
+
if (el.type === "button" && el.text) {
|
|
644
|
+
btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
|
|
645
|
+
}
|
|
498
646
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
647
|
+
const elements = filteredElements.map((el) => {
|
|
648
|
+
let selector;
|
|
649
|
+
if (el.testId) {
|
|
650
|
+
selector = `[data-testid="${el.testId}"]`;
|
|
651
|
+
} else if (el.isFile || el.type === "file") {
|
|
652
|
+
selector = 'input[type="file"]';
|
|
653
|
+
} else if (el.type === "range") {
|
|
654
|
+
selector = el.id ? idSelector(el.id) : 'input[type="range"]';
|
|
655
|
+
} else if (el.type === "checkbox") {
|
|
656
|
+
selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
|
|
657
|
+
} else if (el.type === "button") {
|
|
658
|
+
if (btnTexts[el.text] === 1) {
|
|
659
|
+
selector = `text="${el.text}"`;
|
|
660
|
+
} else {
|
|
661
|
+
selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
|
|
662
|
+
}
|
|
663
|
+
} else if (el.type === "link") {
|
|
512
664
|
selector = `text="${el.text}"`;
|
|
665
|
+
} else if (el.id) {
|
|
666
|
+
selector = idSelector(el.id);
|
|
667
|
+
} else if (el.name) {
|
|
668
|
+
selector = `[name="${el.name}"]`;
|
|
669
|
+
} else if (el.placeholder) {
|
|
670
|
+
selector = `[placeholder="${el.placeholder}"]`;
|
|
513
671
|
} else {
|
|
514
|
-
selector =
|
|
672
|
+
selector = `text="${el.text}"`;
|
|
515
673
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
674
|
+
let normalizedType;
|
|
675
|
+
if (el.isFile || el.type === "file") {
|
|
676
|
+
normalizedType = "file";
|
|
677
|
+
} else if (el.type === "range") {
|
|
678
|
+
normalizedType = "range";
|
|
679
|
+
} else if (el.type === "checkbox") {
|
|
680
|
+
normalizedType = "checkbox";
|
|
681
|
+
} else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
|
|
682
|
+
normalizedType = "select";
|
|
683
|
+
} else if (el.type === "button" || el.type === "link") {
|
|
684
|
+
normalizedType = el.type;
|
|
685
|
+
} else {
|
|
686
|
+
normalizedType = "input";
|
|
687
|
+
}
|
|
688
|
+
const action = getActionForType(normalizedType);
|
|
689
|
+
const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
|
|
690
|
+
return { type: normalizedType, text: el.text, selector, action, target, href: el.href };
|
|
691
|
+
});
|
|
692
|
+
await browser.close();
|
|
693
|
+
for (const route of await sitemapRoutes(url)) {
|
|
694
|
+
if (elements.some((e) => e.href === route.href)) continue;
|
|
695
|
+
elements.push({
|
|
696
|
+
type: "link",
|
|
697
|
+
text: route.text,
|
|
698
|
+
selector: "",
|
|
699
|
+
action: "click",
|
|
700
|
+
target: route.text,
|
|
701
|
+
href: route.href
|
|
702
|
+
});
|
|
526
703
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
} else if (el.type === "checkbox") {
|
|
533
|
-
normalizedType = "checkbox";
|
|
534
|
-
} else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
|
|
535
|
-
normalizedType = "select";
|
|
536
|
-
} else if (el.type === "button" || el.type === "link") {
|
|
537
|
-
normalizedType = el.type;
|
|
538
|
-
} else {
|
|
539
|
-
normalizedType = "input";
|
|
704
|
+
crawlTask.succeed(`Found ${elements.length} interactive elements`);
|
|
705
|
+
crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
|
|
706
|
+
if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
|
|
707
|
+
const oldest = crawlCache.keys().next().value;
|
|
708
|
+
if (oldest !== void 0) crawlCache.delete(oldest);
|
|
540
709
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
await browser.close();
|
|
546
|
-
crawlTask.succeed(`Found ${elements.length} interactive elements`);
|
|
547
|
-
crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
|
|
548
|
-
if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
|
|
549
|
-
const oldest = crawlCache.keys().next().value;
|
|
550
|
-
if (oldest !== void 0) crawlCache.delete(oldest);
|
|
710
|
+
return { title, elements, scrollable };
|
|
711
|
+
} finally {
|
|
712
|
+
await browser.close().catch(() => {
|
|
713
|
+
});
|
|
551
714
|
}
|
|
552
|
-
return { title, elements, scrollable };
|
|
553
715
|
}
|
|
554
716
|
|
|
555
717
|
// src/lib/plan-validation.ts
|
|
@@ -747,8 +909,9 @@ function collectPreservedValues(steps) {
|
|
|
747
909
|
mediaFile: step.mediaFile,
|
|
748
910
|
mediaType: step.mediaType
|
|
749
911
|
});
|
|
750
|
-
} else if (step.action === "media") {
|
|
912
|
+
} else if (step.action === "media" || step.action === "image") {
|
|
751
913
|
mediaSteps.push({
|
|
914
|
+
value: step.value,
|
|
752
915
|
mediaFile: step.mediaFile,
|
|
753
916
|
mediaType: step.mediaType,
|
|
754
917
|
target: step.target
|
|
@@ -769,14 +932,15 @@ function restorePreservedValues(step, preserved, indices) {
|
|
|
769
932
|
if (original.mediaType !== void 0) step.mediaType = original.mediaType;
|
|
770
933
|
uploadIdx++;
|
|
771
934
|
}
|
|
772
|
-
if (step.action === "media" && mediaIdx < preserved.mediaSteps.length) {
|
|
935
|
+
if ((step.action === "media" || step.action === "image") && mediaIdx < preserved.mediaSteps.length) {
|
|
773
936
|
const original = preserved.mediaSteps[mediaIdx];
|
|
937
|
+
if (original.value !== void 0) step.value = original.value;
|
|
774
938
|
if (original.mediaFile !== void 0) step.mediaFile = original.mediaFile;
|
|
775
939
|
if (original.mediaType !== void 0) step.mediaType = original.mediaType;
|
|
776
940
|
if (!step.target && original.target) step.target = original.target;
|
|
777
941
|
mediaIdx++;
|
|
778
942
|
}
|
|
779
|
-
if (step.action !== "upload" && step.action !== "media" && step.target) {
|
|
943
|
+
if (step.action !== "upload" && step.action !== "media" && step.action !== "image" && step.target) {
|
|
780
944
|
const original = preserved.otherValues.get(`${step.action}:${step.target}`);
|
|
781
945
|
if (original) step.value = original;
|
|
782
946
|
}
|
|
@@ -858,9 +1022,13 @@ function planEditorUrl(baseUrl, plan) {
|
|
|
858
1022
|
}
|
|
859
1023
|
|
|
860
1024
|
// src/lib/prompts.ts
|
|
861
|
-
function buildSystemPrompt(maxSteps) {
|
|
1025
|
+
function buildSystemPrompt(maxSteps, profile) {
|
|
862
1026
|
let scope;
|
|
863
|
-
if (
|
|
1027
|
+
if (profile === "wide") {
|
|
1028
|
+
scope = `SCOPE: Educational tutorial (${maxSteps} steps max) - comprehensive, structured coverage of every major feature area, in a logical teaching order`;
|
|
1029
|
+
} else if (profile === "short") {
|
|
1030
|
+
scope = `SCOPE: Quick-paced short (${maxSteps} steps max) - cover every interesting detail with fast cuts and terse captions; running long is fine, the video is compressed afterwards`;
|
|
1031
|
+
} else if (maxSteps <= 5) {
|
|
864
1032
|
scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
|
|
865
1033
|
} else if (maxSteps <= 8) {
|
|
866
1034
|
scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
|
|
@@ -872,9 +1040,47 @@ function buildSystemPrompt(maxSteps) {
|
|
|
872
1040
|
${scope}
|
|
873
1041
|
|
|
874
1042
|
CORE FLOW (always follow this structure):
|
|
875
|
-
1. wait -
|
|
1043
|
+
1. wait - the hook, over the app's own card (see OPENING AND CLOSING)
|
|
876
1044
|
2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
|
|
877
|
-
3.
|
|
1045
|
+
3. Stop. The closing card is appended for you - do not write one
|
|
1046
|
+
|
|
1047
|
+
NARRATION STYLE:
|
|
1048
|
+
\u2022 Professional and direct, like a good tutorial - never condescending, never explaining as if to a child
|
|
1049
|
+
\u2022 NEVER mention step numbers or sequencing ("step one", "first step", "in this step") - just say what is happening
|
|
1050
|
+
\u2022 NEVER build the demo out of marketing surfaces. The landing page and the PRICING page are not the product: a viewer wants to see the thing working, not read plans and prices. If the app is behind a sign-in, demo the signed-in app; if that is impossible, demo its functional public pages (tools, docs, editors), not its sales pages
|
|
1051
|
+
\u2022 A marketing/landing page earns ONE brief opening caption of about a SECOND - a title card, nothing more. Never scroll it, never hover or highlight anything on it, never describe its sections. Optionally return to it for the closing call to action. The demo lives in the app itself, behind the login when a session exists. Never return to the landing page mid-demo to describe a feature the app can show you doing
|
|
1052
|
+
\u2022 NARRATE ONLY WHAT IS ON SCREEN: every caption must describe what the viewer can see at that moment. To talk about a specific component, make it the step's target (hover it, click it, or scroll with that target) so the recorder brings it into frame first
|
|
1053
|
+
\u2022 A blind scroll (no target) lands wherever it lands - use it only for "there is more below", never to reveal something the caption names
|
|
1054
|
+
\u2022 Never combine scrolling with pointing: a step either moves the page or holds still on a component, so the highlight is never dragged across the screen mid-scroll
|
|
1055
|
+
\u2022 A step that starts something asynchronous (processing, generating, analyzing) must WAIT for the result on screen before moving on: give the wait step a "duration" in seconds long enough to show the outcome
|
|
1056
|
+
\u2022 Caption length drives step length - keep captions tight so the video moves
|
|
1057
|
+
|
|
1058
|
+
OPENING AND CLOSING (a fixed template - both cards are built for you):
|
|
1059
|
+
\u2022 Step 1 is ALWAYS a bare { "action": "wait" } with no target. Its caption is the HOOK, and the recorder puts the app's own OG card behind it
|
|
1060
|
+
\u2022 The hook names the VIEWER'S problem in the viewer's words - the cost of not having this app, in one line someone would recognise as their own situation. It is the reason to keep watching
|
|
1061
|
+
\u2022 NEVER open the hook with the product: no "This is X", no "Meet X", no "X helps you..." and no feature. The problem comes first
|
|
1062
|
+
\u2022 Then, in that SAME caption, say the app's address once - "<problem>. <domain> fixes that." A viewer who leaves halfway has to have heard where to go, and the closing card is too late for them. The opening card is both spoken AND captioned, so write the hook to read as well as it sounds: short words, no spelled-out punctuation, and the domain written the way it is typed
|
|
1063
|
+
\u2022 The closing card is appended for you: the app's name, tagline, domain and any live offer, narrated. Never write a sign-off step and never end back on the landing page. Apart from the hook, keep the domain and any discount code out of captions - the card carries both, and repeating them mid-demo is the most common way these scripts read as padding
|
|
1064
|
+
|
|
1065
|
+
HOW IT SHOULD SOUND (this is read aloud, not printed on a slide):
|
|
1066
|
+
\u2022 Write the way a person talks about something they actually like. Warm, quick, a bit of a grin. Never the voice of a landing page
|
|
1067
|
+
\u2022 BANNED, because they are what marketing copy sounds like when nobody meant it: "free to start", "get started today", "unlock", "empower", "seamlessly", "effortlessly", "revolutionise", "supercharge", "take your X to the next level", "say goodbye to". If a line could sit on any product's homepage unchanged, it is not about this product
|
|
1068
|
+
\u2022 The free tier is "try it free", or just "free". Never "free to start", which reads as a pricing table talking
|
|
1069
|
+
\u2022 Short sentences beat long ones out loud. A comma is a breath; a full stop is a beat. Read it back before shipping it
|
|
1070
|
+
\u2022 Say the thing, do not build up to it. No "in this video we will", no "let's take a look at"
|
|
1071
|
+
|
|
1072
|
+
WHAT TO COVER (a tour of tabs is a menu, not a demo):
|
|
1073
|
+
\u2022 Lead with the outcome the app produces, not the screens it has
|
|
1074
|
+
\u2022 Say where that outcome ENDS UP when the app puts it somewhere - search results, AI assistant answers, directories, an app store
|
|
1075
|
+
\u2022 Name any integration that removes work rather than adding a screen - an MCP server, a browser extension that fills the forms, an API, a CLI. An app that can be driven from an assistant is a headline feature, not a footnote
|
|
1076
|
+
\u2022 Do NOT write a pricing step. When the app has a free tier the closing card says so for you, narrated, at the end where it belongs. Never navigate to the pricing page for it
|
|
1077
|
+
\u2022 Cover what surrounds the product as well as the product: a community, a network its users list each other on, a support channel, anything published on the user's behalf. These are reasons to sign up and they are never on screen by themselves
|
|
1078
|
+
\u2022 Say what a feature DOES, never what it is implemented as. Filenames, formats and protocols are not benefits: "so AI assistants can read your product" lands, "it writes llms.txt and robots.txt" is a changelog line read aloud. Same for sitemap.xml, JSON-LD, schema markup, API endpoints and anything ending in .txt, .xml or .json. If a viewer would have to already know what it is, it is not a selling point
|
|
1079
|
+
\u2022 Prefer one feature shown working over three named in passing
|
|
1080
|
+
\u2022 When a step types into a URL or domain field, type a REAL domain, never example.com or example.org. A demo that audits "example.com" on camera shows the tool working on nothing. Use a real site the viewer might recognise, or the demo subject's own domain if nothing better is on the page
|
|
1081
|
+
\u2022 A plan that is mostly bare wait steps is a failed plan. One real run came back 22 waits out of 26: a slideshow of one static screen, narrating pages it never visited. If a caption names a page, NAVIGATE to it; if it names a control, CLICK it. Use wait only for the opening and closing cards
|
|
1082
|
+
\u2022 NEVER narrate a screen you are not on. If a line describes a page the demo has not reached, navigate to it first. A caption saying "start with a free roast" over the dashboard is worse than cutting the line: it makes the whole video look faked
|
|
1083
|
+
\u2022 Cover everything worth covering and let it run long. Length is fixed afterwards by playing the demo faster, never by dropping a feature - a plan trimmed to hit a target is the one mistake the compressor cannot undo
|
|
878
1084
|
|
|
879
1085
|
TARGET SYNTAX (simplified):
|
|
880
1086
|
\u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
|
|
@@ -897,9 +1103,12 @@ FORBIDDEN:
|
|
|
897
1103
|
|
|
898
1104
|
ACTION FORMATS (STRICT - follow exactly):
|
|
899
1105
|
\u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
|
|
1106
|
+
\u2022 hover: { "action": "hover", "target": "pricing card" } - point the cursor at a feature while the caption explains it, without clicking
|
|
900
1107
|
\u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
|
|
901
1108
|
\u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
|
|
1109
|
+
\u2022 navigate: { "action": "navigate", "target": "https://example.com/roast" } - GO TO ANOTHER PAGE. Use it whenever the caption describes something that is not on the current screen. Any path on the SAME ORIGIN as the demo URL is allowed, not only the NAVIGATE targets listed below: an app's own routes are frequently reached by client-side buttons rather than links, so the list is a hint, not the limit. Never navigate off-origin.
|
|
902
1110
|
\u2022 wait: { "action": "wait" } - for intro/outro captions
|
|
1111
|
+
\u2022 scroll to a component: { "action": "scroll", "target": "seo files", "value": "down" } - brings that element into frame; PREFER this over a blind scroll whenever the caption names something
|
|
903
1112
|
\u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
|
|
904
1113
|
\u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
|
|
905
1114
|
\u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
|
|
@@ -924,6 +1133,17 @@ Purpose: ${description}` : "";
|
|
|
924
1133
|
const selectables = elements.filter((e) => e.action === "select");
|
|
925
1134
|
const checkables = elements.filter((e) => e.action === "check");
|
|
926
1135
|
const elementDict = [];
|
|
1136
|
+
const navigables = elements.filter((e) => e.href);
|
|
1137
|
+
const seenHref = /* @__PURE__ */ new Set();
|
|
1138
|
+
const linkTargets = navigables.filter((e) => {
|
|
1139
|
+
if (!e.href || seenHref.has(e.href)) return false;
|
|
1140
|
+
seenHref.add(e.href);
|
|
1141
|
+
return true;
|
|
1142
|
+
});
|
|
1143
|
+
if (linkTargets.length > 0) {
|
|
1144
|
+
elementDict.push(`NAVIGATE targets (links found on the page; same-origin paths not listed here are also allowed):
|
|
1145
|
+
${linkTargets.map((e) => `\u2022 "${e.text}" -> ${e.href}`).join("\n")}`);
|
|
1146
|
+
}
|
|
927
1147
|
if (clickables.length > 0) {
|
|
928
1148
|
elementDict.push(`CLICK targets (use simplified text):
|
|
929
1149
|
${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
|
|
@@ -994,26 +1214,23 @@ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
|
|
|
994
1214
|
var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
|
|
995
1215
|
var BASE = "https://sparkpay.dev";
|
|
996
1216
|
var TIERS = {
|
|
997
|
-
|
|
998
|
-
|
|
1217
|
+
// Essentials
|
|
1218
|
+
price_1TCREmEf9opkuWidvhuqzqDM: "pro",
|
|
1219
|
+
// live monthly
|
|
1220
|
+
price_1TCREmEf9opkuWidacf1tiC7: "pro",
|
|
1221
|
+
// live yearly
|
|
999
1222
|
price_1TADm7Ef9opkuWid3I9tquIB: "pro",
|
|
1223
|
+
// test monthly
|
|
1000
1224
|
price_1TADm8Ef9opkuWid1RSA2wCa: "pro",
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
pro: [
|
|
1011
|
-
"Everything in Free",
|
|
1012
|
-
"No watermark",
|
|
1013
|
-
"Full video composition (voiceover + captions + music + transitions)",
|
|
1014
|
-
"Unlimited videos",
|
|
1015
|
-
"Priority support"
|
|
1016
|
-
]
|
|
1225
|
+
// test yearly
|
|
1226
|
+
// Elite. These were missing entirely, so an Elite subscriber fell through
|
|
1227
|
+
// the `?? 'pro'` fallback below and got Essentials limits for $29.
|
|
1228
|
+
price_1TrHz8Ef9opkuWid6lh2opD8: "elite",
|
|
1229
|
+
// live monthly
|
|
1230
|
+
price_1TrHz9Ef9opkuWid1qS26iMu: "elite",
|
|
1231
|
+
// live yearly
|
|
1232
|
+
price_1TGPCREf9opkuWidR0iJeJNe: "elite"
|
|
1233
|
+
// test monthly
|
|
1017
1234
|
};
|
|
1018
1235
|
async function _fetch(email, init) {
|
|
1019
1236
|
const r = await fetch(
|
|
@@ -1038,7 +1255,6 @@ function getTier(d) {
|
|
|
1038
1255
|
return TIERS[d.subscription.price_id] ?? "pro";
|
|
1039
1256
|
}
|
|
1040
1257
|
var isPaid = (d) => getTier(d) !== "free";
|
|
1041
|
-
var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
|
|
1042
1258
|
var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
|
|
1043
1259
|
var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
|
|
1044
1260
|
var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
|
|
@@ -1064,22 +1280,43 @@ function pricingUrl(opts) {
|
|
|
1064
1280
|
}
|
|
1065
1281
|
|
|
1066
1282
|
// src/lib/voices.ts
|
|
1067
|
-
import { WIDE_VOICES } from "@spark-apps/video-kit";
|
|
1283
|
+
import { MALE_VOICES, MULTILINGUAL_VOICES, resolveVoice, WIDE_VOICES } from "@spark-apps/video-kit";
|
|
1284
|
+
import { MALE_VOICES as MALE_VOICES2, MULTILINGUAL_VOICES as MULTILINGUAL_VOICES2, WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
|
|
1285
|
+
var INTERNATIONAL_VOICE = "international";
|
|
1286
|
+
function internationalVoiceFor(lang, gender = "female") {
|
|
1287
|
+
if (!hasVoice(lang)) return null;
|
|
1288
|
+
const raw = lang.trim().toLowerCase();
|
|
1289
|
+
const multilingual = MULTILINGUAL_VOICES[raw] ?? MULTILINGUAL_VOICES[raw.slice(0, 2)];
|
|
1290
|
+
if (gender === "male") return voiceFor(lang, gender);
|
|
1291
|
+
return multilingual ?? voiceFor(lang, gender);
|
|
1292
|
+
}
|
|
1293
|
+
function hasVoice(lang) {
|
|
1294
|
+
const raw = lang.trim().toLowerCase();
|
|
1295
|
+
return Boolean(WIDE_VOICES[raw] ?? WIDE_VOICES[raw.slice(0, 2)]);
|
|
1296
|
+
}
|
|
1297
|
+
function voiceFor(lang, gender = "female") {
|
|
1298
|
+
if (!hasVoice(lang)) return null;
|
|
1299
|
+
return resolveVoice(lang, { gender, table: WIDE_VOICES, maleTable: MALE_VOICES });
|
|
1300
|
+
}
|
|
1068
1301
|
export {
|
|
1069
1302
|
ACTIONS_REQUIRING_TARGET,
|
|
1070
1303
|
EXCLUDED_LINK_PATTERNS,
|
|
1071
|
-
FEATURES,
|
|
1072
1304
|
FIX_PLAN_SYSTEM_PROMPT,
|
|
1073
1305
|
INTERACTIVE_SELECTORS,
|
|
1306
|
+
INTERNATIONAL_VOICE,
|
|
1074
1307
|
INVALID_TARGETS,
|
|
1075
|
-
|
|
1308
|
+
MALE_VOICES2 as MALE_VOICES,
|
|
1076
1309
|
MAX_HANDOFF_URL_CHARS,
|
|
1310
|
+
MULTILINGUAL_VOICES2 as MULTILINGUAL_VOICES,
|
|
1311
|
+
PLAN_MAX_TOKENS,
|
|
1077
1312
|
REFINE_PLAN_SYSTEM_PROMPT,
|
|
1313
|
+
WIDE_VOICES2 as WIDE_VOICES,
|
|
1078
1314
|
buildSystemPrompt,
|
|
1079
1315
|
buildUserPrompt,
|
|
1080
1316
|
callAI,
|
|
1081
1317
|
collectPreservedValues,
|
|
1082
1318
|
crawlPage,
|
|
1319
|
+
deStock,
|
|
1083
1320
|
decodePlan,
|
|
1084
1321
|
encodePlan,
|
|
1085
1322
|
filterValidSteps,
|
|
@@ -1089,9 +1326,10 @@ export {
|
|
|
1089
1326
|
getStatusCached,
|
|
1090
1327
|
getTier,
|
|
1091
1328
|
getTierByEmail,
|
|
1092
|
-
hasAccess,
|
|
1093
1329
|
hasTrialRemaining,
|
|
1330
|
+
hasVoice,
|
|
1094
1331
|
hexToAss as hexToASS,
|
|
1332
|
+
internationalVoiceFor,
|
|
1095
1333
|
isCanceling,
|
|
1096
1334
|
isExcludedLink,
|
|
1097
1335
|
isInvalidTarget,
|
|
@@ -1106,10 +1344,13 @@ export {
|
|
|
1106
1344
|
pricingUrl,
|
|
1107
1345
|
restorePreservedValues,
|
|
1108
1346
|
rgbaToHex,
|
|
1347
|
+
runPooled,
|
|
1109
1348
|
serializePlan,
|
|
1110
1349
|
shouldSkipLink,
|
|
1111
1350
|
showPaywall,
|
|
1351
|
+
stripLongDashes,
|
|
1112
1352
|
toTitleCase,
|
|
1113
1353
|
validateStep,
|
|
1354
|
+
voiceFor,
|
|
1114
1355
|
windowsDrivePathToWsl as windowsPathToWSL
|
|
1115
1356
|
};
|