@spark-apps/quickpeek 1.2.2 → 1.2.4
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 +2 -0
- 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 +561 -36
- package/dist/index.mjs +2288 -399
- package/dist/mcp-tools.mjs +6965 -1866
- package/dist/qp.js +6281 -1566
- package/dist/voices-CPpnWn39.d.mts +613 -0
- package/dist/web.d.mts +5 -3
- package/dist/web.mjs +398 -164
- package/mcp.mjs +94 -21
- package/package.json +5 -2
- package/dist/scraping-BgepklP3.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,68 @@ 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_SELECTOR = [
|
|
427
|
+
'[aria-busy="true"]',
|
|
428
|
+
'[role="progressbar"]',
|
|
429
|
+
'[data-loading="true"]',
|
|
430
|
+
'[class*="skeleton" i]',
|
|
431
|
+
'[class*="animate-pulse" i]',
|
|
432
|
+
'[class*="spinner" i]'
|
|
433
|
+
].join(",");
|
|
434
|
+
async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
|
|
435
|
+
const deadline = Date.now() + cap;
|
|
436
|
+
let previous = -1;
|
|
437
|
+
let unchanged = 0;
|
|
438
|
+
while (Date.now() < deadline) {
|
|
439
|
+
const count = await page.evaluate((selector) => {
|
|
440
|
+
let visible = 0;
|
|
441
|
+
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
442
|
+
const rect = el.getBoundingClientRect();
|
|
443
|
+
if (rect.width > 0 && rect.height > 0) visible++;
|
|
444
|
+
}
|
|
445
|
+
return visible;
|
|
446
|
+
}, PLACEHOLDER_SELECTOR).catch(() => 0);
|
|
447
|
+
if (count === 0) return;
|
|
448
|
+
if (count === previous) {
|
|
449
|
+
if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
|
|
450
|
+
} else {
|
|
451
|
+
unchanged = 0;
|
|
452
|
+
previous = count;
|
|
453
|
+
}
|
|
454
|
+
await page.waitForTimeout(PLACEHOLDER_POLL_MS);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function gotoSettled(page, url, timeout = 3e4) {
|
|
458
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
459
|
+
await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {
|
|
460
|
+
});
|
|
461
|
+
await waitForDomStable(page).catch(() => {
|
|
462
|
+
});
|
|
463
|
+
await waitForPlaceholdersGone(page).catch(() => {
|
|
464
|
+
});
|
|
465
|
+
}
|
|
370
466
|
function idSelector(id) {
|
|
371
467
|
return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
|
|
372
468
|
}
|
|
@@ -387,6 +483,40 @@ function getActionForType(type) {
|
|
|
387
483
|
return "type";
|
|
388
484
|
}
|
|
389
485
|
}
|
|
486
|
+
async function sitemapRoutes(pageUrl) {
|
|
487
|
+
let origin;
|
|
488
|
+
try {
|
|
489
|
+
origin = new URL(pageUrl).origin;
|
|
490
|
+
} catch {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const res = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(4e3) });
|
|
495
|
+
if (!res.ok) return [];
|
|
496
|
+
const xml = await res.text();
|
|
497
|
+
const seen = /* @__PURE__ */ new Set();
|
|
498
|
+
const routes = [];
|
|
499
|
+
for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
|
|
500
|
+
const raw = (m[1] ?? "").trim();
|
|
501
|
+
let u;
|
|
502
|
+
try {
|
|
503
|
+
u = new URL(raw);
|
|
504
|
+
} catch {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (u.origin !== origin) continue;
|
|
508
|
+
if (/\.(txt|xml|json|png|jpg|svg|ico|pdf)$/i.test(u.pathname)) continue;
|
|
509
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
510
|
+
if (!path || seen.has(u.href)) continue;
|
|
511
|
+
seen.add(u.href);
|
|
512
|
+
routes.push({ text: path.slice(1), href: u.href });
|
|
513
|
+
if (routes.length >= 20) break;
|
|
514
|
+
}
|
|
515
|
+
return routes;
|
|
516
|
+
} catch {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
}
|
|
390
520
|
var noopTask = () => ({ succeed: () => {
|
|
391
521
|
} });
|
|
392
522
|
var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
@@ -401,155 +531,180 @@ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
|
|
|
401
531
|
}
|
|
402
532
|
const browserTask = task("Launching browser");
|
|
403
533
|
const browser = await chromium.launch({ headless: true });
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
534
|
+
try {
|
|
535
|
+
const context = await browser.newContext({
|
|
536
|
+
...storageStatePath ? { storageState: storageStatePath } : {},
|
|
537
|
+
// Crawl at the size we will record at, or the measurements below describe
|
|
538
|
+
// a page nobody will ever see.
|
|
539
|
+
...viewport ? { viewport } : {}
|
|
540
|
+
});
|
|
541
|
+
const page = await context.newPage();
|
|
542
|
+
browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
|
|
543
|
+
const crawlTask = task("Crawling page");
|
|
544
|
+
await gotoSettled(page, url, 3e4);
|
|
545
|
+
const title = await page.title();
|
|
546
|
+
const scrollable = await page.evaluate(
|
|
547
|
+
"Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
|
|
548
|
+
);
|
|
549
|
+
const rawElements = await page.evaluate(`
|
|
550
|
+
(() => {
|
|
551
|
+
const items = [];
|
|
421
552
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
553
|
+
// Helper to get parent context
|
|
554
|
+
const getCtx = (el) => {
|
|
555
|
+
let p = el.parentElement;
|
|
556
|
+
while (p && p !== document.body) {
|
|
557
|
+
if (p.id) return '#' + p.id;
|
|
558
|
+
const cls = Array.from(p.classList).find(c =>
|
|
559
|
+
/toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
|
|
560
|
+
);
|
|
561
|
+
if (cls) return '.' + cls;
|
|
562
|
+
if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
|
|
563
|
+
return p.tagName.toLowerCase();
|
|
564
|
+
}
|
|
565
|
+
p = p.parentElement;
|
|
433
566
|
}
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
return '';
|
|
437
|
-
};
|
|
567
|
+
return '';
|
|
568
|
+
};
|
|
438
569
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
570
|
+
// Buttons (use textContent to catch hidden elements too)
|
|
571
|
+
document.querySelectorAll('button, [role="button"]').forEach(el => {
|
|
572
|
+
const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
|
573
|
+
const testId = el.getAttribute('data-testid');
|
|
574
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
575
|
+
if (!text && !testId && !ariaLabel) return;
|
|
576
|
+
items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
|
|
577
|
+
});
|
|
447
578
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
579
|
+
// Inputs (including file inputs, range sliders, checkboxes, selects)
|
|
580
|
+
document.querySelectorAll('input, textarea, select').forEach(el => {
|
|
581
|
+
if (el.type === 'hidden') return;
|
|
582
|
+
const testId = el.getAttribute('data-testid');
|
|
583
|
+
const inputType = el.type || 'text';
|
|
453
584
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
585
|
+
// Get label text for any input type
|
|
586
|
+
let labelText = '';
|
|
587
|
+
if (el.labels?.[0]) {
|
|
588
|
+
labelText = el.labels[0].textContent?.trim() || '';
|
|
589
|
+
} else if (el.id) {
|
|
590
|
+
const label = document.querySelector('label[for="' + el.id + '"]');
|
|
591
|
+
if (label) labelText = label.textContent?.trim() || '';
|
|
592
|
+
}
|
|
462
593
|
|
|
463
|
-
|
|
594
|
+
const text = labelText || el.placeholder || el.name || el.id || '';
|
|
464
595
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
596
|
+
// Include file, range, and checkbox inputs even without text
|
|
597
|
+
const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
|
|
598
|
+
if (!text && !testId && !alwaysInclude) return;
|
|
468
599
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
600
|
+
items.push({
|
|
601
|
+
type: inputType,
|
|
602
|
+
text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
|
|
603
|
+
testId,
|
|
604
|
+
id: el.id,
|
|
605
|
+
name: el.name,
|
|
606
|
+
placeholder: el.placeholder,
|
|
607
|
+
isFile: inputType === 'file'
|
|
608
|
+
});
|
|
477
609
|
});
|
|
478
|
-
});
|
|
479
610
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
611
|
+
// Links (use textContent to catch hidden elements too)
|
|
612
|
+
document.querySelectorAll('a[href]').forEach(el => {
|
|
613
|
+
const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
|
614
|
+
if (!text || text.length < 2) return;
|
|
615
|
+
// Keep the destination, not just the label. Without it the planner
|
|
616
|
+
// can name a page in a caption but has no way to go there, so every
|
|
617
|
+
// line gets forced onto whatever is already on screen.
|
|
618
|
+
let href = '';
|
|
619
|
+
try {
|
|
620
|
+
const u = new URL(el.getAttribute('href') || '', location.href);
|
|
621
|
+
if (u.origin === location.origin) href = u.href;
|
|
622
|
+
} catch { /* a javascript: or mailto: href is not somewhere to go */ }
|
|
623
|
+
items.push({ type: 'link', text, href });
|
|
624
|
+
});
|
|
485
625
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
626
|
+
return items.slice(0, 40);
|
|
627
|
+
})()
|
|
628
|
+
`);
|
|
629
|
+
const filteredElements = rawElements.filter((el) => {
|
|
630
|
+
if (!el.text) return true;
|
|
631
|
+
const text = el.text.toLowerCase().trim();
|
|
632
|
+
return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
|
|
633
|
+
});
|
|
634
|
+
const btnTexts = {};
|
|
635
|
+
for (const el of filteredElements) {
|
|
636
|
+
if (el.type === "button" && el.text) {
|
|
637
|
+
btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
|
|
638
|
+
}
|
|
498
639
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
640
|
+
const elements = filteredElements.map((el) => {
|
|
641
|
+
let selector;
|
|
642
|
+
if (el.testId) {
|
|
643
|
+
selector = `[data-testid="${el.testId}"]`;
|
|
644
|
+
} else if (el.isFile || el.type === "file") {
|
|
645
|
+
selector = 'input[type="file"]';
|
|
646
|
+
} else if (el.type === "range") {
|
|
647
|
+
selector = el.id ? idSelector(el.id) : 'input[type="range"]';
|
|
648
|
+
} else if (el.type === "checkbox") {
|
|
649
|
+
selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
|
|
650
|
+
} else if (el.type === "button") {
|
|
651
|
+
if (btnTexts[el.text] === 1) {
|
|
652
|
+
selector = `text="${el.text}"`;
|
|
653
|
+
} else {
|
|
654
|
+
selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
|
|
655
|
+
}
|
|
656
|
+
} else if (el.type === "link") {
|
|
512
657
|
selector = `text="${el.text}"`;
|
|
658
|
+
} else if (el.id) {
|
|
659
|
+
selector = idSelector(el.id);
|
|
660
|
+
} else if (el.name) {
|
|
661
|
+
selector = `[name="${el.name}"]`;
|
|
662
|
+
} else if (el.placeholder) {
|
|
663
|
+
selector = `[placeholder="${el.placeholder}"]`;
|
|
513
664
|
} else {
|
|
514
|
-
selector =
|
|
665
|
+
selector = `text="${el.text}"`;
|
|
515
666
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
667
|
+
let normalizedType;
|
|
668
|
+
if (el.isFile || el.type === "file") {
|
|
669
|
+
normalizedType = "file";
|
|
670
|
+
} else if (el.type === "range") {
|
|
671
|
+
normalizedType = "range";
|
|
672
|
+
} else if (el.type === "checkbox") {
|
|
673
|
+
normalizedType = "checkbox";
|
|
674
|
+
} else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
|
|
675
|
+
normalizedType = "select";
|
|
676
|
+
} else if (el.type === "button" || el.type === "link") {
|
|
677
|
+
normalizedType = el.type;
|
|
678
|
+
} else {
|
|
679
|
+
normalizedType = "input";
|
|
680
|
+
}
|
|
681
|
+
const action = getActionForType(normalizedType);
|
|
682
|
+
const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
|
|
683
|
+
return { type: normalizedType, text: el.text, selector, action, target, href: el.href };
|
|
684
|
+
});
|
|
685
|
+
await browser.close();
|
|
686
|
+
for (const route of await sitemapRoutes(url)) {
|
|
687
|
+
if (elements.some((e) => e.href === route.href)) continue;
|
|
688
|
+
elements.push({
|
|
689
|
+
type: "link",
|
|
690
|
+
text: route.text,
|
|
691
|
+
selector: "",
|
|
692
|
+
action: "click",
|
|
693
|
+
target: route.text,
|
|
694
|
+
href: route.href
|
|
695
|
+
});
|
|
526
696
|
}
|
|
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";
|
|
697
|
+
crawlTask.succeed(`Found ${elements.length} interactive elements`);
|
|
698
|
+
crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
|
|
699
|
+
if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
|
|
700
|
+
const oldest = crawlCache.keys().next().value;
|
|
701
|
+
if (oldest !== void 0) crawlCache.delete(oldest);
|
|
540
702
|
}
|
|
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);
|
|
703
|
+
return { title, elements, scrollable };
|
|
704
|
+
} finally {
|
|
705
|
+
await browser.close().catch(() => {
|
|
706
|
+
});
|
|
551
707
|
}
|
|
552
|
-
return { title, elements, scrollable };
|
|
553
708
|
}
|
|
554
709
|
|
|
555
710
|
// src/lib/plan-validation.ts
|
|
@@ -747,8 +902,9 @@ function collectPreservedValues(steps) {
|
|
|
747
902
|
mediaFile: step.mediaFile,
|
|
748
903
|
mediaType: step.mediaType
|
|
749
904
|
});
|
|
750
|
-
} else if (step.action === "media") {
|
|
905
|
+
} else if (step.action === "media" || step.action === "image") {
|
|
751
906
|
mediaSteps.push({
|
|
907
|
+
value: step.value,
|
|
752
908
|
mediaFile: step.mediaFile,
|
|
753
909
|
mediaType: step.mediaType,
|
|
754
910
|
target: step.target
|
|
@@ -769,14 +925,15 @@ function restorePreservedValues(step, preserved, indices) {
|
|
|
769
925
|
if (original.mediaType !== void 0) step.mediaType = original.mediaType;
|
|
770
926
|
uploadIdx++;
|
|
771
927
|
}
|
|
772
|
-
if (step.action === "media" && mediaIdx < preserved.mediaSteps.length) {
|
|
928
|
+
if ((step.action === "media" || step.action === "image") && mediaIdx < preserved.mediaSteps.length) {
|
|
773
929
|
const original = preserved.mediaSteps[mediaIdx];
|
|
930
|
+
if (original.value !== void 0) step.value = original.value;
|
|
774
931
|
if (original.mediaFile !== void 0) step.mediaFile = original.mediaFile;
|
|
775
932
|
if (original.mediaType !== void 0) step.mediaType = original.mediaType;
|
|
776
933
|
if (!step.target && original.target) step.target = original.target;
|
|
777
934
|
mediaIdx++;
|
|
778
935
|
}
|
|
779
|
-
if (step.action !== "upload" && step.action !== "media" && step.target) {
|
|
936
|
+
if (step.action !== "upload" && step.action !== "media" && step.action !== "image" && step.target) {
|
|
780
937
|
const original = preserved.otherValues.get(`${step.action}:${step.target}`);
|
|
781
938
|
if (original) step.value = original;
|
|
782
939
|
}
|
|
@@ -858,9 +1015,13 @@ function planEditorUrl(baseUrl, plan) {
|
|
|
858
1015
|
}
|
|
859
1016
|
|
|
860
1017
|
// src/lib/prompts.ts
|
|
861
|
-
function buildSystemPrompt(maxSteps) {
|
|
1018
|
+
function buildSystemPrompt(maxSteps, profile) {
|
|
862
1019
|
let scope;
|
|
863
|
-
if (
|
|
1020
|
+
if (profile === "wide") {
|
|
1021
|
+
scope = `SCOPE: Educational tutorial (${maxSteps} steps max) - comprehensive, structured coverage of every major feature area, in a logical teaching order`;
|
|
1022
|
+
} else if (profile === "short") {
|
|
1023
|
+
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`;
|
|
1024
|
+
} else if (maxSteps <= 5) {
|
|
864
1025
|
scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
|
|
865
1026
|
} else if (maxSteps <= 8) {
|
|
866
1027
|
scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
|
|
@@ -872,9 +1033,47 @@ function buildSystemPrompt(maxSteps) {
|
|
|
872
1033
|
${scope}
|
|
873
1034
|
|
|
874
1035
|
CORE FLOW (always follow this structure):
|
|
875
|
-
1. wait -
|
|
1036
|
+
1. wait - the hook, over the app's own card (see OPENING AND CLOSING)
|
|
876
1037
|
2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
|
|
877
|
-
3.
|
|
1038
|
+
3. Stop. The closing card is appended for you - do not write one
|
|
1039
|
+
|
|
1040
|
+
NARRATION STYLE:
|
|
1041
|
+
\u2022 Professional and direct, like a good tutorial - never condescending, never explaining as if to a child
|
|
1042
|
+
\u2022 NEVER mention step numbers or sequencing ("step one", "first step", "in this step") - just say what is happening
|
|
1043
|
+
\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
|
|
1044
|
+
\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
|
|
1045
|
+
\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
|
|
1046
|
+
\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
|
|
1047
|
+
\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
|
|
1048
|
+
\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
|
|
1049
|
+
\u2022 Caption length drives step length - keep captions tight so the video moves
|
|
1050
|
+
|
|
1051
|
+
OPENING AND CLOSING (a fixed template - both cards are built for you):
|
|
1052
|
+
\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
|
|
1053
|
+
\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
|
|
1054
|
+
\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
|
|
1055
|
+
\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
|
|
1056
|
+
\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
|
|
1057
|
+
|
|
1058
|
+
HOW IT SHOULD SOUND (this is read aloud, not printed on a slide):
|
|
1059
|
+
\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
|
|
1060
|
+
\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
|
|
1061
|
+
\u2022 The free tier is "try it free", or just "free". Never "free to start", which reads as a pricing table talking
|
|
1062
|
+
\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
|
|
1063
|
+
\u2022 Say the thing, do not build up to it. No "in this video we will", no "let's take a look at"
|
|
1064
|
+
|
|
1065
|
+
WHAT TO COVER (a tour of tabs is a menu, not a demo):
|
|
1066
|
+
\u2022 Lead with the outcome the app produces, not the screens it has
|
|
1067
|
+
\u2022 Say where that outcome ENDS UP when the app puts it somewhere - search results, AI assistant answers, directories, an app store
|
|
1068
|
+
\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
|
|
1069
|
+
\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
|
|
1070
|
+
\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
|
|
1071
|
+
\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
|
|
1072
|
+
\u2022 Prefer one feature shown working over three named in passing
|
|
1073
|
+
\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
|
|
1074
|
+
\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
|
|
1075
|
+
\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
|
|
1076
|
+
\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
1077
|
|
|
879
1078
|
TARGET SYNTAX (simplified):
|
|
880
1079
|
\u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
|
|
@@ -897,9 +1096,12 @@ FORBIDDEN:
|
|
|
897
1096
|
|
|
898
1097
|
ACTION FORMATS (STRICT - follow exactly):
|
|
899
1098
|
\u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
|
|
1099
|
+
\u2022 hover: { "action": "hover", "target": "pricing card" } - point the cursor at a feature while the caption explains it, without clicking
|
|
900
1100
|
\u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
|
|
901
1101
|
\u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
|
|
1102
|
+
\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
1103
|
\u2022 wait: { "action": "wait" } - for intro/outro captions
|
|
1104
|
+
\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
1105
|
\u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
|
|
904
1106
|
\u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
|
|
905
1107
|
\u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
|
|
@@ -924,6 +1126,17 @@ Purpose: ${description}` : "";
|
|
|
924
1126
|
const selectables = elements.filter((e) => e.action === "select");
|
|
925
1127
|
const checkables = elements.filter((e) => e.action === "check");
|
|
926
1128
|
const elementDict = [];
|
|
1129
|
+
const navigables = elements.filter((e) => e.href);
|
|
1130
|
+
const seenHref = /* @__PURE__ */ new Set();
|
|
1131
|
+
const linkTargets = navigables.filter((e) => {
|
|
1132
|
+
if (!e.href || seenHref.has(e.href)) return false;
|
|
1133
|
+
seenHref.add(e.href);
|
|
1134
|
+
return true;
|
|
1135
|
+
});
|
|
1136
|
+
if (linkTargets.length > 0) {
|
|
1137
|
+
elementDict.push(`NAVIGATE targets (links found on the page; same-origin paths not listed here are also allowed):
|
|
1138
|
+
${linkTargets.map((e) => `\u2022 "${e.text}" -> ${e.href}`).join("\n")}`);
|
|
1139
|
+
}
|
|
927
1140
|
if (clickables.length > 0) {
|
|
928
1141
|
elementDict.push(`CLICK targets (use simplified text):
|
|
929
1142
|
${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
|
|
@@ -994,26 +1207,23 @@ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
|
|
|
994
1207
|
var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
|
|
995
1208
|
var BASE = "https://sparkpay.dev";
|
|
996
1209
|
var TIERS = {
|
|
997
|
-
|
|
998
|
-
|
|
1210
|
+
// Essentials
|
|
1211
|
+
price_1TCREmEf9opkuWidvhuqzqDM: "pro",
|
|
1212
|
+
// live monthly
|
|
1213
|
+
price_1TCREmEf9opkuWidacf1tiC7: "pro",
|
|
1214
|
+
// live yearly
|
|
999
1215
|
price_1TADm7Ef9opkuWid3I9tquIB: "pro",
|
|
1216
|
+
// test monthly
|
|
1000
1217
|
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
|
-
]
|
|
1218
|
+
// test yearly
|
|
1219
|
+
// Elite. These were missing entirely, so an Elite subscriber fell through
|
|
1220
|
+
// the `?? 'pro'` fallback below and got Essentials limits for $29.
|
|
1221
|
+
price_1TrHz8Ef9opkuWid6lh2opD8: "elite",
|
|
1222
|
+
// live monthly
|
|
1223
|
+
price_1TrHz9Ef9opkuWid1qS26iMu: "elite",
|
|
1224
|
+
// live yearly
|
|
1225
|
+
price_1TGPCREf9opkuWidR0iJeJNe: "elite"
|
|
1226
|
+
// test monthly
|
|
1017
1227
|
};
|
|
1018
1228
|
async function _fetch(email, init) {
|
|
1019
1229
|
const r = await fetch(
|
|
@@ -1038,7 +1248,6 @@ function getTier(d) {
|
|
|
1038
1248
|
return TIERS[d.subscription.price_id] ?? "pro";
|
|
1039
1249
|
}
|
|
1040
1250
|
var isPaid = (d) => getTier(d) !== "free";
|
|
1041
|
-
var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
|
|
1042
1251
|
var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
|
|
1043
1252
|
var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
|
|
1044
1253
|
var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
|
|
@@ -1064,22 +1273,43 @@ function pricingUrl(opts) {
|
|
|
1064
1273
|
}
|
|
1065
1274
|
|
|
1066
1275
|
// src/lib/voices.ts
|
|
1067
|
-
import { WIDE_VOICES } from "@spark-apps/video-kit";
|
|
1276
|
+
import { MALE_VOICES, MULTILINGUAL_VOICES, resolveVoice, WIDE_VOICES } from "@spark-apps/video-kit";
|
|
1277
|
+
import { MALE_VOICES as MALE_VOICES2, MULTILINGUAL_VOICES as MULTILINGUAL_VOICES2, WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
|
|
1278
|
+
var INTERNATIONAL_VOICE = "international";
|
|
1279
|
+
function internationalVoiceFor(lang, gender = "female") {
|
|
1280
|
+
if (!hasVoice(lang)) return null;
|
|
1281
|
+
const raw = lang.trim().toLowerCase();
|
|
1282
|
+
const multilingual = MULTILINGUAL_VOICES[raw] ?? MULTILINGUAL_VOICES[raw.slice(0, 2)];
|
|
1283
|
+
if (gender === "male") return voiceFor(lang, gender);
|
|
1284
|
+
return multilingual ?? voiceFor(lang, gender);
|
|
1285
|
+
}
|
|
1286
|
+
function hasVoice(lang) {
|
|
1287
|
+
const raw = lang.trim().toLowerCase();
|
|
1288
|
+
return Boolean(WIDE_VOICES[raw] ?? WIDE_VOICES[raw.slice(0, 2)]);
|
|
1289
|
+
}
|
|
1290
|
+
function voiceFor(lang, gender = "female") {
|
|
1291
|
+
if (!hasVoice(lang)) return null;
|
|
1292
|
+
return resolveVoice(lang, { gender, table: WIDE_VOICES, maleTable: MALE_VOICES });
|
|
1293
|
+
}
|
|
1068
1294
|
export {
|
|
1069
1295
|
ACTIONS_REQUIRING_TARGET,
|
|
1070
1296
|
EXCLUDED_LINK_PATTERNS,
|
|
1071
|
-
FEATURES,
|
|
1072
1297
|
FIX_PLAN_SYSTEM_PROMPT,
|
|
1073
1298
|
INTERACTIVE_SELECTORS,
|
|
1299
|
+
INTERNATIONAL_VOICE,
|
|
1074
1300
|
INVALID_TARGETS,
|
|
1075
|
-
|
|
1301
|
+
MALE_VOICES2 as MALE_VOICES,
|
|
1076
1302
|
MAX_HANDOFF_URL_CHARS,
|
|
1303
|
+
MULTILINGUAL_VOICES2 as MULTILINGUAL_VOICES,
|
|
1304
|
+
PLAN_MAX_TOKENS,
|
|
1077
1305
|
REFINE_PLAN_SYSTEM_PROMPT,
|
|
1306
|
+
WIDE_VOICES2 as WIDE_VOICES,
|
|
1078
1307
|
buildSystemPrompt,
|
|
1079
1308
|
buildUserPrompt,
|
|
1080
1309
|
callAI,
|
|
1081
1310
|
collectPreservedValues,
|
|
1082
1311
|
crawlPage,
|
|
1312
|
+
deStock,
|
|
1083
1313
|
decodePlan,
|
|
1084
1314
|
encodePlan,
|
|
1085
1315
|
filterValidSteps,
|
|
@@ -1089,9 +1319,10 @@ export {
|
|
|
1089
1319
|
getStatusCached,
|
|
1090
1320
|
getTier,
|
|
1091
1321
|
getTierByEmail,
|
|
1092
|
-
hasAccess,
|
|
1093
1322
|
hasTrialRemaining,
|
|
1323
|
+
hasVoice,
|
|
1094
1324
|
hexToAss as hexToASS,
|
|
1325
|
+
internationalVoiceFor,
|
|
1095
1326
|
isCanceling,
|
|
1096
1327
|
isExcludedLink,
|
|
1097
1328
|
isInvalidTarget,
|
|
@@ -1106,10 +1337,13 @@ export {
|
|
|
1106
1337
|
pricingUrl,
|
|
1107
1338
|
restorePreservedValues,
|
|
1108
1339
|
rgbaToHex,
|
|
1340
|
+
runPooled,
|
|
1109
1341
|
serializePlan,
|
|
1110
1342
|
shouldSkipLink,
|
|
1111
1343
|
showPaywall,
|
|
1344
|
+
stripLongDashes,
|
|
1112
1345
|
toTitleCase,
|
|
1113
1346
|
validateStep,
|
|
1347
|
+
voiceFor,
|
|
1114
1348
|
windowsDrivePathToWsl as windowsPathToWSL
|
|
1115
1349
|
};
|