awwwards-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/motion.js ADDED
@@ -0,0 +1,360 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { CAPTURE_INSTALL_HINT } from "./capture.js";
7
+ export const MOTION_FFMPEG_HINT = "Motion recording needs ffmpeg-static, which is an optional dependency.\n" +
8
+ "Install it with: npm install -D ffmpeg-static\n" +
9
+ "Then retry record_site_motion.";
10
+ // Recording-pass timings, tuned against the 30s MCP tool ceiling. The
11
+ // validated script (scripts/record-scrollthrough.mjs) spends well past 45s
12
+ // (7s dwell, 600ms scroll steps, 750ms hover dwells, 16 targets, 4 clicks),
13
+ // so the tool constrains every dial:
14
+ // goto ....................... (network; not budgeted — waitStrategy decides)
15
+ // preloader dwell ............ 4.0s (script: 7s; also covers the fixed
16
+ // "load" settle capture.ts applies —
17
+ // one top-of-page wait serves both)
18
+ // lazy-render pre-scroll ...... ≤ ~1s (bounded 40ms-step pass, back to top:
19
+ // the ceiling is min(page height, 450 ×
20
+ // 24 = 10.8k px, matching the tour cap,
21
+ // so the walk can never exceed ~1s — the
22
+ // capped tour below still renders lazy
23
+ // content on taller pages)
24
+ // stepped scroll tour ........ ~8.4s (450px steps / 350ms settle, capped at
25
+ // MAX_SCROLL_STEPS=24 ≈ 10.8k px of page
26
+ // height so tall pages cannot blow the
27
+ // budget)
28
+ // interaction pass ........... ~14s (≤12 targets × [250ms into-view settle
29
+ // + cursor move + 500ms hover dwell]
30
+ // ≈ 10.2s, ≤3 safe clicks × 650ms ≈ 2s,
31
+ // 1.5s return-to-top)
32
+ // Recording worst case ≈ 27s, then context.close() flush + the ffmpeg strip
33
+ // pass ≈ 1–2s — inside the 30s ceiling with ~3s genuine headroom for goto
34
+ // variance (slow networks, heavy preloads).
35
+ const DWELL_MS = 4000;
36
+ const SCROLL_STEP = 450;
37
+ const SCROLL_SETTLE_MS = 350;
38
+ const MAX_SCROLL_STEPS = 24;
39
+ const SCROLL_INTO_VIEW_SETTLE_MS = 250;
40
+ const HOVER_DWELL_MS = 500;
41
+ const CLICK_SETTLE_MS = 650;
42
+ const MAX_HOVER_TARGETS = 12;
43
+ const MAX_CLICKS = 3;
44
+ const TOP_RETURN_MS = 1500;
45
+ // Default ffmpeg invocation: tile the recorded video into one filmstrip JPEG
46
+ // (a frame every 4s, 720px wide, cols x rows grid, single output frame).
47
+ // Exported for direct testing; callers inject ffmpegFn to replace it.
48
+ const FFMPEG_TIMEOUT_MS = 60_000;
49
+ export async function runFfmpeg(bin, video, strip, frames) {
50
+ const cols = Math.ceil(Math.sqrt(frames));
51
+ const rows = Math.ceil(frames / cols);
52
+ await new Promise((resolve, reject) => {
53
+ const child = spawn(bin, ["-y", "-i", video, "-vf", `fps=1/4,scale=720:-1,tile=${cols}x${rows}`, "-frames:v", "1", strip], { stdio: "ignore", windowsHide: true });
54
+ // Deadline: a hung ffmpeg (wedged pipe, dead filesystem) must not hang the
55
+ // tool call forever — kill the child and reject so the caller gets the
56
+ // install-hint error path.
57
+ const timer = setTimeout(() => {
58
+ child.kill();
59
+ reject(new Error(`ffmpeg timed out after ${FFMPEG_TIMEOUT_MS / 1000}s`));
60
+ }, FFMPEG_TIMEOUT_MS);
61
+ child.on("error", (err) => {
62
+ clearTimeout(timer);
63
+ reject(err); // spawn failure (missing binary, ...)
64
+ });
65
+ child.on("close", (code) => {
66
+ clearTimeout(timer);
67
+ code === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${code}`));
68
+ });
69
+ });
70
+ }
71
+ // Lazy-render pre-scroll for the motion pass. Same loop shape as structure.ts's
72
+ // preScroll, but bounded: the walk ceiling is min(page height, 450 × 24 = 10.8k
73
+ // px, matching MAX_SCROLL_STEPS) so a 50k-px page costs ≤ ~1s instead of ~4.5s
74
+ // — the capped scroll tour below still renders lazy content on taller pages.
75
+ async function boundedPreScroll(page) {
76
+ await page.evaluate(async () => {
77
+ const g = globalThis;
78
+ const ceiling = Math.min(g.document.documentElement.scrollHeight, 450 * 24);
79
+ const step = 450;
80
+ for (let y = 0; y < ceiling; y += step) {
81
+ g.window.scrollTo(0, y);
82
+ await new Promise((r) => setTimeout(r, 40));
83
+ }
84
+ g.window.scrollTo(0, 0);
85
+ await new Promise((r) => setTimeout(r, 150));
86
+ });
87
+ }
88
+ // Record a motion-through video of a live site so motion is visible to agents
89
+ // — static captures hide it. Ported from scripts/record-scrollthrough.mjs and
90
+ // structured like capture.ts (injectable loader, in-band install hints,
91
+ // runtime errors propagate to the handler). Covers three animation classes:
92
+ // 1. preloader / entrance animations (initial dwell)
93
+ // 2. scroll-triggered animations (slow stepped scroll tour)
94
+ // 3. hover + click animations (a virtual cursor — Playwright's video does
95
+ // not render the real one — visits interactive elements and dwells so
96
+ // :hover transitions play on camera; safe same-page targets are clicked)
97
+ // Returns the renamed .webm path plus the filmstrip JPEG inline as base64.
98
+ export async function recordSiteMotion(url, opts) {
99
+ // Resolve ffmpeg first: it is the cheap check and a missing binary must not
100
+ // pay for a browser launch. An explicit ffmpegPath: null short-circuits too.
101
+ let ffmpegBin = null;
102
+ if (opts.ffmpegPath !== undefined) {
103
+ ffmpegBin = opts.ffmpegPath;
104
+ }
105
+ else {
106
+ try {
107
+ // "as string" keeps ffmpeg-static an unresolved optional dependency at
108
+ // compile time; Node resolves it (and may throw) at runtime.
109
+ const mod = await import("ffmpeg-static");
110
+ ffmpegBin = mod?.default ?? mod;
111
+ }
112
+ catch {
113
+ return { error: MOTION_FFMPEG_HINT };
114
+ }
115
+ }
116
+ if (!ffmpegBin)
117
+ return { error: MOTION_FFMPEG_HINT };
118
+ let chromium;
119
+ try {
120
+ ({ chromium } = await (opts.loader ?? (() => import("playwright")))());
121
+ }
122
+ catch {
123
+ return { error: CAPTURE_INSTALL_HINT };
124
+ }
125
+ let browser;
126
+ try {
127
+ browser = await chromium.launch();
128
+ }
129
+ catch {
130
+ return { error: CAPTURE_INSTALL_HINT };
131
+ }
132
+ const frames = opts.frames ?? 16;
133
+ const hash = createHash("sha1").update(url).digest("hex").slice(0, 10);
134
+ const videoPath = join(opts.cacheImagesDir, `motion-${hash}.webm`);
135
+ const stripPath = join(opts.cacheImagesDir, `motion-${hash}-strip.jpg`);
136
+ // Per-call tmp isolation: each run records into its own mkdtemp dir, so a
137
+ // stale partial video from a crashed run (e.g. goto timeout → context.close()
138
+ // still flushes a partial .webm) or a concurrent run's video can never be
139
+ // globbed and filmed under this URL's hash.
140
+ mkdirSync(opts.cacheImagesDir, { recursive: true });
141
+ // Stale-tmp sweep: a SIGKILLed run never reaches the finally below, so its
142
+ // per-call tmp dir (and any partial .webm in it) leaks. Best-effort hygiene:
143
+ // unlink leftover .video-tmp-* dirs, but only ones older than 10 minutes —
144
+ // a dir younger than that belongs to a concurrent recording (per-call
145
+ // isolation above documents concurrency as supported), and deleting it
146
+ // would make that run's glob come up empty.
147
+ const SWEEP_AGE_MS = 10 * 60_000;
148
+ try {
149
+ const now = Date.now();
150
+ for (const entry of readdirSync(opts.cacheImagesDir)) {
151
+ if (!entry.startsWith(".video-tmp-"))
152
+ continue;
153
+ const full = join(opts.cacheImagesDir, entry);
154
+ try {
155
+ if (statSync(full).mtimeMs < now - SWEEP_AGE_MS) {
156
+ rmSync(full, { recursive: true, force: true });
157
+ }
158
+ }
159
+ catch {
160
+ /* vanished between readdir and stat — nothing left to clean */
161
+ }
162
+ }
163
+ }
164
+ catch {
165
+ /* sweep is best-effort; mkdtemp below still proceeds */
166
+ }
167
+ const videoTmp = mkdtempSync(join(opts.cacheImagesDir, ".video-tmp-"));
168
+ const waitStrategy = opts.waitStrategy ?? "load";
169
+ try {
170
+ const context = await browser.newContext({
171
+ viewport: { width: 1440, height: 900 },
172
+ recordVideo: { dir: videoTmp, size: { width: 1440, height: 900 } },
173
+ });
174
+ try {
175
+ const page = await context.newPage();
176
+ await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
177
+ // Preloader + entrance animations on camera. This top-of-page wait also
178
+ // stands in for capture.ts's fixed 3s "load" settle (see budget above).
179
+ await page.waitForTimeout(DWELL_MS);
180
+ // Pre-render lazy sections so the tour films settled layout (bounded —
181
+ // see boundedPreScroll; a huge page must not burn the budget here).
182
+ await boundedPreScroll(page);
183
+ // Virtual cursor: an SVG arrow injected into the page, moved alongside
184
+ // page.mouse (page.evaluate serializes plain data only, so snippets are
185
+ // self-contained and reach browser globals through globalThis — the
186
+ // Node tsconfig has no DOM lib).
187
+ await page.evaluate(() => {
188
+ const doc = globalThis.document;
189
+ const cur = doc.createElement("div");
190
+ cur.id = "__recorder_cursor";
191
+ cur.style.cssText =
192
+ "position:fixed;left:0;top:0;width:22px;height:22px;z-index:2147483647;" +
193
+ "pointer-events:none;transform:translate(-2px,-2px);transition:transform .12s ease;" +
194
+ "filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));";
195
+ cur.innerHTML =
196
+ '<svg width="22" height="22" viewBox="0 0 22 22"><path d="M4 2l14 8-6 1.2L15 18l-2.6 1.2L9.6 12 5 16z" ' +
197
+ 'fill="#fff" stroke="#111" stroke-width="1.4"/></svg>';
198
+ doc.body.appendChild(cur);
199
+ });
200
+ const moveCursor = async (x, y) => {
201
+ await page.mouse.move(x, y, { steps: 12 });
202
+ await page.evaluate(([mx, my]) => {
203
+ const cur = globalThis.document.getElementById("__recorder_cursor");
204
+ cur?.style.setProperty("transform", `translate(${mx - 2}px,${my - 2}px)`);
205
+ }, [x, y]);
206
+ };
207
+ // Slow scroll down so every scroll-triggered animation fires on camera.
208
+ const totalHeight = await page.evaluate(() => Math.round(globalThis.document.body.scrollHeight));
209
+ const steps = Math.min(Math.ceil(totalHeight / SCROLL_STEP), MAX_SCROLL_STEPS);
210
+ for (let i = 1; i <= steps; i++) {
211
+ await page.evaluate((top) => globalThis.window.scrollTo({ top, behavior: "instant" }), i * SCROLL_STEP);
212
+ await page.waitForTimeout(SCROLL_SETTLE_MS);
213
+ }
214
+ // Interaction-pass target discovery (runs IN THE PAGE): classic
215
+ // interactive selectors PLUS anything whose computed cursor is "pointer"
216
+ // — the browser's own "I'm interactive" signal. Pointer cursor inherits
217
+ // to descendants, so only elements whose PARENT is not pointer count
218
+ // (the top of each pointer region); otherwise one `body { cursor:
219
+ // pointer }` would nominate the whole page. The tour then spreads the
220
+ // kept targets evenly down the page (top to bottom), not DOM order.
221
+ const targets = await page.evaluate(
222
+ // MAX_HOVER_TARGETS is passed as an evaluate argument: page.evaluate
223
+ // serializes the callback into the browser context, where Node-side
224
+ // module constants are not in scope (a bare reference is a
225
+ // ReferenceError at record time).
226
+ (maxTargets) => {
227
+ const g = globalThis;
228
+ const doc = g.document;
229
+ const isVisibleBox = (r) => r.width >= 24 && r.height >= 16;
230
+ const abs = (r) => Math.round(r.top + g.scrollY);
231
+ const out = [];
232
+ const seen = new Set();
233
+ const push = (el) => {
234
+ const r = el.getBoundingClientRect();
235
+ if (!isVisibleBox(r))
236
+ return;
237
+ const top = abs(r);
238
+ if (top < 0 || top > doc.body.scrollHeight)
239
+ return;
240
+ const cx = Math.round(r.left + r.width / 2);
241
+ const cy = Math.round(top + r.height / 2);
242
+ const key = `${cx},${cy}`;
243
+ if (seen.has(key))
244
+ return;
245
+ seen.add(key);
246
+ const href = el.getAttribute ? el.getAttribute("href") : null;
247
+ out.push({
248
+ x: cx,
249
+ y: cy,
250
+ safeClick: (el.tagName === "A" && (!href || String(href).startsWith("#"))) ||
251
+ (el.tagName === "BUTTON" && el.type !== "submit"),
252
+ });
253
+ };
254
+ // Pass 1: classic interactive selectors (always trusted).
255
+ for (const el of doc.querySelectorAll('a, button, [role="button"], input, .oval-btn, .btn')) {
256
+ push(el);
257
+ }
258
+ // Pass 2: cursor:pointer discovery — custom interactive surfaces
259
+ // with unknown markup. Skip the pointer region's top (compare
260
+ // against the parent). Bounded by ITERATIONS only (the 3k-element
261
+ // walk below); no pool cap — dedupe plus the 12-target spread at
262
+ // the end bound the output anyway, and an out.length cap here would
263
+ // silently disable pointer discovery on link-dense pages.
264
+ let visited = 0;
265
+ for (const el of doc.querySelectorAll("body *")) {
266
+ if (++visited > 3000)
267
+ break;
268
+ if (g.getComputedStyle(el).cursor !== "pointer")
269
+ continue;
270
+ const parent = el.parentElement;
271
+ if (parent && g.getComputedStyle(parent).cursor === "pointer")
272
+ continue; // inherited
273
+ push(el);
274
+ }
275
+ out.sort((a, b) => a.y - b.y);
276
+ const keep = Math.min(maxTargets, out.length);
277
+ const spread = [];
278
+ for (let i = 0; i < keep; i++) {
279
+ spread.push(out[Math.round((i * (out.length - 1)) / Math.max(1, keep - 1))]);
280
+ }
281
+ return spread;
282
+ }, MAX_HOVER_TARGETS);
283
+ let clicked = 0;
284
+ for (const t of targets) {
285
+ try {
286
+ // Bring the element into view, then hover: move the real mouse so
287
+ // :hover transitions fire, with the virtual cursor following it.
288
+ await page.evaluate((y) => globalThis.window.scrollTo({
289
+ top: Math.max(0, y - 380),
290
+ behavior: "instant",
291
+ }), t.y);
292
+ await page.waitForTimeout(SCROLL_INTO_VIEW_SETTLE_MS);
293
+ const box = await page.evaluate(([x, absY]) => {
294
+ const g = globalThis;
295
+ const el = g.document.elementFromPoint(x, absY - g.scrollY);
296
+ if (!el)
297
+ return null;
298
+ const r = el.getBoundingClientRect();
299
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
300
+ }, [t.x, t.y]);
301
+ if (!box)
302
+ continue;
303
+ await moveCursor(box.x, box.y);
304
+ await page.waitForTimeout(HOVER_DWELL_MS); // let the hover transition play
305
+ if (t.safeClick && clicked < MAX_CLICKS) {
306
+ await page.mouse.down();
307
+ await page.mouse.up(); // :active + click effects
308
+ clicked++;
309
+ await page.waitForTimeout(CLICK_SETTLE_MS); // let the click animation play
310
+ }
311
+ }
312
+ catch {
313
+ // detached/overlaid element — skip and continue the tour
314
+ }
315
+ }
316
+ // Return to top so the video ends where it started.
317
+ await page.evaluate(() => globalThis.window.scrollTo({ top: 0, behavior: "instant" }));
318
+ await page.waitForTimeout(TOP_RETURN_MS);
319
+ }
320
+ finally {
321
+ // Closing the context is what flushes the .webm to disk.
322
+ try {
323
+ await context.close();
324
+ }
325
+ catch {
326
+ /* flush is best-effort */
327
+ }
328
+ }
329
+ // Video handoff: the glob only sees THIS call's flushed .webm (fresh
330
+ // per-call tmp dir). Rename it to its stable, URL-keyed name in
331
+ // cacheImagesDir while the tmp dir still exists.
332
+ const webms = readdirSync(videoTmp).filter((f) => f.endsWith(".webm"));
333
+ if (webms.length === 0) {
334
+ // Not an install problem — plain failure, no install advice attached.
335
+ return { error: "recording failed: no video file was produced" };
336
+ }
337
+ renameSync(join(videoTmp, webms[0]), videoPath);
338
+ }
339
+ finally {
340
+ try {
341
+ await browser.close();
342
+ }
343
+ catch {
344
+ /* keep the primary error */
345
+ }
346
+ // Drop this run's tmp dir last: any partial video flushed by a mid-run
347
+ // failure dies here instead of leaking into a later run's glob.
348
+ rmSync(videoTmp, { recursive: true, force: true });
349
+ }
350
+ const ffmpegFn = opts.ffmpegFn ?? runFfmpeg;
351
+ try {
352
+ await ffmpegFn(ffmpegBin, videoPath, stripPath, frames);
353
+ }
354
+ catch {
355
+ return { error: MOTION_FFMPEG_HINT };
356
+ }
357
+ if (!existsSync(stripPath))
358
+ return { error: MOTION_FFMPEG_HINT };
359
+ return { file: videoPath, base64: (await readFile(stripPath)).toString("base64"), frames };
360
+ }
@@ -0,0 +1,161 @@
1
+ const ENTITIES = {
2
+ "&quot;": '"',
3
+ "&amp;": "&",
4
+ "&#039;": "'",
5
+ "&#39;": "'",
6
+ "&lt;": "<",
7
+ "&gt;": ">",
8
+ "&nbsp;": " ",
9
+ };
10
+ export function decodeEntities(s) {
11
+ return s.replace(/&(?:quot|amp|#0?39|lt|gt|nbsp);/g, (e) => ENTITIES[e] ?? e);
12
+ }
13
+ const AWARD_LABELS = {
14
+ sotd: "Site of the Day",
15
+ dev: "Developer Award",
16
+ hm: "Honorable Mention",
17
+ sotm: "Site of the Month",
18
+ mobile: "Mobile Excellence",
19
+ ecom: "E-Commerce Award",
20
+ };
21
+ // Card JSON blob and its markup: the blob sits in data-collectable-model-value
22
+ // immediately before the card markup. The blob is HTML-entity-escaped JSON, so
23
+ // the closing quote of the attribute is the first raw `">` after the split point.
24
+ export function parseListing(html) {
25
+ const sites = [];
26
+ const parts = html.split('data-collectable-model-value="');
27
+ for (const part of parts.slice(1)) {
28
+ const end = part.indexOf('">');
29
+ if (end < 0)
30
+ continue;
31
+ let meta;
32
+ try {
33
+ meta = JSON.parse(decodeEntities(part.slice(0, end)));
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (!meta?.slug || !meta?.title)
39
+ continue;
40
+ if (meta.type && meta.type !== "submission")
41
+ continue;
42
+ const card = part.slice(end, end + 8000); // one card block is ~3KB; 8KB is safe
43
+ const detailMatch = card.match(/href="(\/sites\/[^"#?]+)"/);
44
+ if (!detailMatch)
45
+ continue; // collections/other modules are not site cards
46
+ const liveMatch = card.match(/class="figure-rollover__bt"[^>]*href="(https?:\/\/[^"]+)"/);
47
+ const awards = [...card.matchAll(/budget-tag--([a-z-]+)/g)].map((m) => AWARD_LABELS[m[1]] ?? m[1]);
48
+ sites.push({
49
+ id: meta.id ?? 0,
50
+ slug: meta.slug,
51
+ title: decodeEntities(meta.title),
52
+ createdAt: meta.createdAt ?? 0,
53
+ tags: Array.isArray(meta.tags) ? meta.tags.map(decodeEntities) : [],
54
+ thumbnailPath: meta.images?.thumbnail ?? "",
55
+ liveUrl: liveMatch ? decodeEntities(liveMatch[1]) : null,
56
+ detailPath: detailMatch[1],
57
+ awards: [...new Set(awards)],
58
+ });
59
+ }
60
+ return sites;
61
+ }
62
+ function stripTags(s) {
63
+ return decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
64
+ }
65
+ export function parseDetail(html, slug) {
66
+ const palette = [
67
+ ...new Set([...html.matchAll(/<strong>HEX<\/strong>\s*#([0-9A-Fa-f]{6})/g)].map((m) => `#${m[1].toUpperCase()}`)),
68
+ ];
69
+ const techIdx = html.indexOf("Technologies & Tools</h2>");
70
+ const techSection = techIdx >= 0 ? html.slice(techIdx, techIdx + 6000) : "";
71
+ const technologies = [
72
+ ...new Set([...techSection.matchAll(/class="button button--tag"[^>]*>([^<]+)</g)].map((m) => stripTags(m[1]))),
73
+ ].filter(Boolean);
74
+ const elStart = html.indexOf(">Elements</h2>");
75
+ const elEnd = elStart >= 0 ? html.indexOf(">Color Palette</h2>", elStart) : -1;
76
+ const elSection = elStart >= 0 && elEnd > elStart ? html.slice(elStart, elEnd) : "";
77
+ const elements = [...elSection.matchAll(/collectableTitle&quot;:&quot;(.+?)&quot;/g)].map((m) => decodeEntities(m[1]));
78
+ const awards = [...html.matchAll(/(Site of the Day|Developer Award|Honorable Mention|Site of the Month|Mobile Excellence|E-Commerce Award)\s*[-–]\s*([A-Z][a-z]+ \d{1,2}, \d{4})/g)].map((m) => ({ title: m[1], date: m[2] }));
79
+ const descIdx = html.indexOf(">Description</h2>");
80
+ const descMatch = descIdx >= 0
81
+ ? html.slice(descIdx, descIdx + 3000).match(/<h3 class="heading-6">([\s\S]{0,2000}?)<\/h3>/)
82
+ : null;
83
+ const ogMatch = html.match(/property="og:image" content="([^"]+)"/);
84
+ // Live site: the h1 anchor points at the awarded site itself
85
+ // (verified: <h1 class="heading-1 text-uppercase"> <a href="https://..." target="_blank" rel="noopener">TITLE</a>).
86
+ // Fallback: first blank-target noopener anchor to a non-awwwards host.
87
+ const h1Match = html.match(/<h1 class="heading-1[^"]*">\s*<a href="(https?:\/\/(?!www\.awwwards\.com|assets\.awwwards\.com)[^"]+)"[^>]*>([\s\S]*?)<\/a>/);
88
+ const liveFallback = h1Match
89
+ ? null
90
+ : html.match(/href="(https?:\/\/(?!www\.awwwards\.com|assets\.awwwards\.com)[^"]+)"[^>]*target="_blank" rel="noopener"/);
91
+ const liveUrl = h1Match?.[1] ?? liveFallback?.[1] ?? null;
92
+ const titleMatch = html.match(/property="og:title" content="([^"]+)"/);
93
+ return {
94
+ slug,
95
+ title: titleMatch ? decodeEntities(titleMatch[1]) : (h1Match ? stripTags(h1Match[2]) : null),
96
+ description: descMatch ? stripTags(descMatch[1]) || null : null,
97
+ palette,
98
+ technologies,
99
+ elements,
100
+ awards,
101
+ ogImage: ogMatch ? decodeEntities(ogMatch[1]) : null,
102
+ liveUrl: liveUrl ? decodeEntities(liveUrl) : null,
103
+ score: parseScore(html),
104
+ };
105
+ }
106
+ // Displayed overall jury score, e.g. c-heading-score__note">→ 7.37<sup>/ 10</sup>.
107
+ // Non-award pages have no such heading → null.
108
+ export function parseScore(html) {
109
+ const m = /c-heading-score__note[^>]*>[^<]*?([\d]+(?:\.\d{1,2})?)/.exec(html);
110
+ return m ? parseFloat(m[1]) : null;
111
+ }
112
+ const NON_FILTERS = new Set(["sites_of_the_day"]);
113
+ // Filter taxonomy from listing-page sidebars: color filters link to
114
+ // /websites/%23<HEX>/ and tag/technology filters to /websites/<slug>/. Award
115
+ // collections (sites_of_the_day) are not tags — they are exposed via the
116
+ // `award` argument on the search tool instead.
117
+ export function parseCategories(html) {
118
+ const colors = [
119
+ ...new Set([...html.matchAll(/href="\/websites\/%23([0-9A-Fa-f]{6})\/"/g)].map((m) => `#${m[1].toUpperCase()}`)),
120
+ ].sort();
121
+ const filters = [
122
+ ...new Set([...html.matchAll(/href="\/websites\/([a-z0-9-]{2,60})\/"/g)].map((m) => m[1])),
123
+ ]
124
+ .filter((s) => !NON_FILTERS.has(s))
125
+ .sort();
126
+ return { colors, filters };
127
+ }
128
+ // Elements section highlights: null means the page has no Elements section
129
+ // (a legitimate empty); an empty array means the section exists but no blobs
130
+ // parsed — the markup changed and the parser needs updating.
131
+ export function parseElements(html) {
132
+ const start = html.indexOf(">Elements</h2>");
133
+ if (start < 0)
134
+ return null;
135
+ const end = html.indexOf(">Color Palette</h2>", start);
136
+ const section = end > start ? html.slice(start, end) : html.slice(start);
137
+ const elements = [];
138
+ const parts = section.split('data-collectable-model-value="');
139
+ for (const part of parts.slice(1)) {
140
+ const stop = part.indexOf('">');
141
+ if (stop < 0)
142
+ continue;
143
+ let blob;
144
+ try {
145
+ blob = JSON.parse(decodeEntities(part.slice(0, stop)));
146
+ }
147
+ catch {
148
+ continue;
149
+ }
150
+ const mediaPath = blob?.collectableImage;
151
+ // Only element media (videos/posters live under element/); other blobs
152
+ // (site card, collections) must not leak in if the end bound is missing.
153
+ if (typeof mediaPath === "string" && mediaPath.startsWith("element/")) {
154
+ elements.push({
155
+ title: decodeEntities(String(blob.collectableTitle ?? "")),
156
+ mediaPath,
157
+ });
158
+ }
159
+ }
160
+ return elements;
161
+ }