aura-components-mcp 1.7.1 → 1.7.3
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/CHANGELOG.md +235 -0
- package/README.md +152 -22
- package/SETUP.md +15 -4
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/dist/server.js +2652 -23
- package/package.json +4 -2
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// aura-components-mcp v1.7.
|
|
2
|
+
// aura-components-mcp v1.7.3 — bundled (zero deps). Built by swastiksingh-dev.
|
|
3
3
|
|
|
4
4
|
// ---- config.mjs ----
|
|
5
5
|
// Module: config — one small interface (loadConfig) over all env parsing.
|
|
@@ -30,7 +30,7 @@ function loadConfig(env = process.env) {
|
|
|
30
30
|
codeChars: num(env.AURA_CODE_CHARS, 12_000, { min: 1_000, max: 60_000 }),
|
|
31
31
|
contentChars: num(env.AURA_CONTENT_CHARS, 12_000, { min: 1_000, max: 60_000 }),
|
|
32
32
|
freeOnlyDefault: bool(env.AURA_FREE_ONLY_DEFAULT, true),
|
|
33
|
-
userAgent: env.AURA_USER_AGENT ?? "aura-components-mcp/1.7.
|
|
33
|
+
userAgent: env.AURA_USER_AGENT ?? "aura-components-mcp/1.7.3",
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -150,15 +150,31 @@ const DETAIL_COLS = {
|
|
|
150
150
|
};
|
|
151
151
|
|
|
152
152
|
const esc = (s) => String(s).replaceAll('"', '""');
|
|
153
|
-
|
|
153
|
+
// pct: percent-encode chars that break PostgREST query parsing when raw —
|
|
154
|
+
// "#" would start a URL fragment (PGRST100), "," splits value lists,
|
|
155
|
+
// "()" groups logic, quotes break string parsing, raw "%" corrupts decoding.
|
|
156
|
+
// encodeURIComponent deliberately leaves !'()* raw, so escEq finishes the job.
|
|
157
|
+
const pct = (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase();
|
|
158
|
+
const escEq = (s) => encodeURIComponent(String(s)).replace(/[!'()*]/g, pct);
|
|
159
|
+
// ilike patterns keep our own "*" wildcards; everything user-typed that is
|
|
160
|
+
// PostgREST-significant gets encoded so goals like "page #0a0a0b (dark)"
|
|
161
|
+
// match literally instead of crashing the query.
|
|
162
|
+
const ilike = (v) => "*" + String(v).replaceAll("*", "").trim().replace(/[%#()'",]/g, pct) + "*";
|
|
154
163
|
|
|
155
164
|
// Split a goal sentence into significant tokens for OR fallback (stop-word filtered).
|
|
156
165
|
// Theme words (dark/light) are NOT search tokens — they become rank/filter signals
|
|
157
166
|
// (see detectThemeHint + scoreByTheme), so "dark cinematic" can't match light rows.
|
|
167
|
+
// Hex codes (#0a0a0b, bare 6-hex with a digit) are NOT search tokens either —
|
|
168
|
+
// catalogue titles never contain them, so they only poison overlap counts while
|
|
169
|
+
// still riding along for palette synthesis elsewhere. 3-hex words without #
|
|
170
|
+
// ("red", "tan") are kept: too often real words to drop.
|
|
158
171
|
const STOP = new Set("a,an,the,for,with,and,or,of,to,in,on,my,new,free,also,that,this,from,into,plus,vs,top,best,up".split(","));
|
|
159
172
|
const THEME_WORDS = new Set(["dark", "light", "midnight", "noir", "black", "white", "bright", "airy", "minimal"]);
|
|
160
173
|
function queryTokens(q, max = 4) {
|
|
161
|
-
|
|
174
|
+
const raw = String(q ?? "").toLowerCase();
|
|
175
|
+
const hashed = new Set([...raw.matchAll(/#([0-9a-z]+)/g)].map((m) => m[1]));
|
|
176
|
+
const isHexNoise = (t) => hashed.has(t) || (/^(?=.*\d)[0-9a-f]{6}$/.test(t));
|
|
177
|
+
return raw.replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 2 && !STOP.has(t) && !THEME_WORDS.has(t) && !isHexNoise(t)).slice(0, max);
|
|
162
178
|
}
|
|
163
179
|
// Theme hint from the raw query: explicit signal, separate from match tokens.
|
|
164
180
|
function detectThemeHint(q) {
|
|
@@ -169,6 +185,19 @@ function detectThemeHint(q) {
|
|
|
169
185
|
if (light && !dark) return "light";
|
|
170
186
|
return null;
|
|
171
187
|
}
|
|
188
|
+
// Light suffix stemming so inflected query words still hit literal titles
|
|
189
|
+
// (glassy→glass, magazines→magazine). Same rule family as the award ranker;
|
|
190
|
+
// applied symmetrically to query tokens AND haystacks, so counts only grow
|
|
191
|
+
// when both sides agree on the stem — honest-empty behavior preserved.
|
|
192
|
+
function stemTok(t) {
|
|
193
|
+
let s = String(t ?? "").toLowerCase();
|
|
194
|
+
if (s.length > 5 && s.endsWith("ing")) s = s.slice(0, -3);
|
|
195
|
+
else if (s.length > 4 && s.endsWith("ies")) s = s.slice(0, -3) + "y";
|
|
196
|
+
else if (s.length > 4 && s.endsWith("es") && /(ses|xes|zes|ches|shes)$/.test(s)) s = s.slice(0, -2);
|
|
197
|
+
else if (s.length > 4 && s.endsWith("s") && !s.endsWith("ss")) s = s.slice(0, -1);
|
|
198
|
+
else if (s.length > 4 && s.endsWith("y")) s = s.slice(0, -1);
|
|
199
|
+
return s;
|
|
200
|
+
}
|
|
172
201
|
// Rank rows: +2 per matched token in title, +1 in description/tags, theme match first.
|
|
173
202
|
// Returns { rows, scored } where scored explains the top pick for fallback_score.
|
|
174
203
|
function scoreRows(kind, rows, toks, themeHint) {
|
|
@@ -177,7 +206,11 @@ function scoreRows(kind, rows, toks, themeHint) {
|
|
|
177
206
|
const desc = String(r.description || "").toLowerCase();
|
|
178
207
|
const tags = ((r.tags || []).map((x) => String(x).toLowerCase()).join(" "));
|
|
179
208
|
let s = 0;
|
|
180
|
-
for (const
|
|
209
|
+
for (const raw of toks) {
|
|
210
|
+
const t = stemTok(raw);
|
|
211
|
+
if (title.includes(raw) || (t !== raw && title.includes(t))) s += 2;
|
|
212
|
+
else if (desc.includes(raw) || tags.includes(raw) || (t !== raw && (desc.includes(t) || tags.includes(t)))) s += 1;
|
|
213
|
+
}
|
|
181
214
|
const th = r.facets && r.facets.theme;
|
|
182
215
|
const themeOk = !themeHint || th === themeHint || th === "mixed" || th === "unknown";
|
|
183
216
|
if (themeHint && th === themeHint) s += 3;
|
|
@@ -198,7 +231,8 @@ function matchOverlap(kind, toks, item) {
|
|
|
198
231
|
let n = 0;
|
|
199
232
|
for (const t of list) {
|
|
200
233
|
const tok = String(t ?? "").toLowerCase();
|
|
201
|
-
|
|
234
|
+
const stem = stemTok(tok);
|
|
235
|
+
if (tok && (title.includes(tok) || (stem !== tok && title.includes(stem)))) n++;
|
|
202
236
|
}
|
|
203
237
|
return n;
|
|
204
238
|
}
|
|
@@ -211,7 +245,8 @@ function matchOverlap(kind, toks, item) {
|
|
|
211
245
|
let n = 0;
|
|
212
246
|
for (const t of list) {
|
|
213
247
|
const tok = String(t ?? "").toLowerCase();
|
|
214
|
-
|
|
248
|
+
const stem = stemTok(tok);
|
|
249
|
+
if (tok && (hay.includes(tok) || (stem !== tok && hay.includes(stem)))) n++;
|
|
215
250
|
}
|
|
216
251
|
return n;
|
|
217
252
|
}
|
|
@@ -517,7 +552,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
|
|
|
517
552
|
if ((kind === "design_systems" || kind === "skills") && rows.length) {
|
|
518
553
|
const topIds = rows.slice(0, 5).map((r) => r.id);
|
|
519
554
|
try {
|
|
520
|
-
const idList = topIds.map((id) =>
|
|
555
|
+
const idList = topIds.map((id) => escEq(id)).join(",");
|
|
521
556
|
const sel = kind === "design_systems" ? "id,content,preview_html" : "id,content,source_url";
|
|
522
557
|
const { rows: full } = await fetcher.getJson(base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&id=in.(" + idList + ")", headers);
|
|
523
558
|
const byId = new Map(full.map((r) => [String(r.id), r]));
|
|
@@ -592,14 +627,24 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
|
|
|
592
627
|
if (fallback === "popular-safety-net") out.suggested_queries = suggestList();
|
|
593
628
|
}
|
|
594
629
|
else if ((!rows || !rows.length) && toks.length) { out.isFallback = false; out.suggested_queries = suggestList(); }
|
|
595
|
-
// Zero-overlap refusal: fallback engaged but
|
|
596
|
-
// honest empty instead of irrelevant popular rows. Direct hits never
|
|
597
|
-
//
|
|
598
|
-
//
|
|
630
|
+
// Zero-overlap refusal: fallback engaged but NO row matches ≥1 token —
|
|
631
|
+
// honest empty instead of irrelevant popular rows. Direct hits never
|
|
632
|
+
// refused. The gate judges the BEST row, not just the first: a single
|
|
633
|
+
// irrelevant views-leader must not veto relevant rows below it.
|
|
634
|
+
// Matching rows are re-ranked first (theme-ok, then overlap, views
|
|
635
|
+
// order kept within ties) and fallback_score is refreshed to the new
|
|
636
|
+
// top row so the disclosed score always describes returned rows.
|
|
637
|
+
// Kind-aware: components/assets stay title-only strict; skills/systems
|
|
638
|
+
// use the wider net (title+description+tags+snippets) via matchOverlap.
|
|
599
639
|
if (fallback != null && toks.length > 0) {
|
|
600
|
-
const
|
|
601
|
-
const
|
|
602
|
-
|
|
640
|
+
const themeOf = (it) => (it && it.facets && it.facets.theme) || "unknown";
|
|
641
|
+
const themeOkOf = (it) => !effTheme || themeOf(it) === effTheme || themeOf(it) === "mixed" || themeOf(it) === "unknown";
|
|
642
|
+
const scored = out.items.map((it) => ({ it, hits: matchOverlap(kind, toks, it), themeOk: themeOkOf(it) }));
|
|
643
|
+
scored.sort((a, b) => (((b.themeOk ? 1 : 0) - (a.themeOk ? 1 : 0)) || (b.hits - a.hits)));
|
|
644
|
+
// Refusal judges the GLOBAL best: theme-first ordering must never
|
|
645
|
+
// let a 0-hit row veto a matching row below it.
|
|
646
|
+
const best = scored.length ? Math.max(...scored.map((s) => s.hits)) : 0;
|
|
647
|
+
if (best === 0) {
|
|
603
648
|
const refused = fallback;
|
|
604
649
|
out.items = [];
|
|
605
650
|
out.total = 0;
|
|
@@ -610,6 +655,9 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
|
|
|
610
655
|
out.refused_fallback = refused;
|
|
611
656
|
out.suggested_queries = suggestList();
|
|
612
657
|
out.note = "Top fallback result matched 0/" + toks.length + " title tokens — empty instead of irrelevant popular rows. Try a suggested query.";
|
|
658
|
+
} else {
|
|
659
|
+
out.items = scored.map((s) => s.it);
|
|
660
|
+
out.fallback_score = "token-overlap:" + best + "/" + toks.length + "-terms" + (effTheme ? ";theme:" + effTheme : "");
|
|
613
661
|
}
|
|
614
662
|
}
|
|
615
663
|
if (slim) out.items = slimRows(kind, out.items);
|
|
@@ -631,10 +679,10 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
|
|
|
631
679
|
const sel = DETAIL_COLS[kind];
|
|
632
680
|
// design_systems + skills: uuid id vs slug; components: numeric id vs slug
|
|
633
681
|
const filt = (kind === "design_systems" || kind === "skills")
|
|
634
|
-
? (isUuid ? "id=eq." +
|
|
682
|
+
? (isUuid ? "id=eq." + escEq(idOrSlug) : "slug=eq." + escEq(idOrSlug))
|
|
635
683
|
: (kind === "components" && !isNum
|
|
636
|
-
? "slug=eq." +
|
|
637
|
-
: "id=eq." +
|
|
684
|
+
? "slug=eq." + escEq(idOrSlug)
|
|
685
|
+
: "id=eq." + escEq(idOrSlug));
|
|
638
686
|
const url = base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&" + filt + "&limit=1";
|
|
639
687
|
const { rows } = await fetcher.getJson(url, headers);
|
|
640
688
|
if (!rows.length) { const e = new Error("not found: " + kind + " " + idOrSlug); e.code = "NOT_FOUND"; throw e; }
|
|
@@ -778,7 +826,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
|
|
|
778
826
|
const lim = Math.max(1, Math.min(limit || 3, 8));
|
|
779
827
|
const sel = LIST_COLS[kind];
|
|
780
828
|
const idCol = (kind === "components") ? "id" : "id";
|
|
781
|
-
const selfId =
|
|
829
|
+
const selfId = escEq(item.id);
|
|
782
830
|
const premF = (freeOnly && (kind === "components" || kind === "assets")) ? "&premium=eq.false" : "";
|
|
783
831
|
const url = base + "/rest/v1/" + kind + "?select=" + encodeURIComponent(sel) + "&private=eq.false" + premF + tagF + "&" + idCol + "=neq." + selfId + (orParts.length ? "&or=(" + orParts.join(",") + ")" : "") + "&order=views.desc&limit=" + lim;
|
|
784
832
|
try {
|
|
@@ -793,7 +841,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
|
|
|
793
841
|
}
|
|
794
842
|
|
|
795
843
|
// ---- protocol.mjs ----
|
|
796
|
-
const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.
|
|
844
|
+
const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.3" };
|
|
797
845
|
// Module: protocol — stdio JSON-RPC framing + MCP handshake + error codes.
|
|
798
846
|
// One interface: createSession(send) -> { dispatch(msg) }. No business logic.
|
|
799
847
|
|
|
@@ -1630,6 +1678,2084 @@ function createVersion({ fetcher, running, now = () => Date.now() }) {
|
|
|
1630
1678
|
return { getVersion };
|
|
1631
1679
|
}
|
|
1632
1680
|
|
|
1681
|
+
// ---- awards.mjs ----
|
|
1682
|
+
// Module: awards — award-website discovery, retrieval, parsing, ranking.
|
|
1683
|
+
// Reimplements the useful Awwwards-mcp behaviors (site discovery, detail
|
|
1684
|
+
// design-DNA, elements, taxonomy, winners) in Aura's native zero-dep style:
|
|
1685
|
+
// pure parsers + polite fetch client + deterministic local ranking.
|
|
1686
|
+
// Upstream inspiration: INSANE0777/Awwwards-mcp (MIT, (c) 2026 Afjal) —
|
|
1687
|
+
// behavior reimplemented here, not copied; see THIRD_PARTY_NOTICES.md.
|
|
1688
|
+
// No invented metadata: every record carries source/evidence fields.
|
|
1689
|
+
|
|
1690
|
+
const AWARD_SCHEMA_VERSION = 1;
|
|
1691
|
+
const AWARD_BASE_URL = "https://www.awwwards.com";
|
|
1692
|
+
const AWARD_ASSETS_URL = "https://assets.awwwards.com";
|
|
1693
|
+
const AWARD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 aura-components-mcp/1.7.3";
|
|
1694
|
+
const AWARD_ALLOWED_HOSTS = ["www.awwwards.com", "awwwards.com", "assets.awwwards.com"];
|
|
1695
|
+
const AWARD_TIMEOUT_MS = 10000;
|
|
1696
|
+
const AWARD_MAX_BYTES = 2 * 1024 * 1024;
|
|
1697
|
+
const AWARD_RATE_MS = 1000;
|
|
1698
|
+
const AWARD_FILTERS = { sotd: "websites/sites_of_the_day", developer: "websites/developer", honorable: "websites/honorable" };
|
|
1699
|
+
const AWARD_FILTER_NAMES = ["sotd", "developer", "honorable"];
|
|
1700
|
+
const AWARD_SECTIONS = ["hero", "proof", "features", "showcase", "pricing", "testimonials", "faq", "cta", "footer"];
|
|
1701
|
+
|
|
1702
|
+
function awardErr(code, message) {
|
|
1703
|
+
const e = new Error(message);
|
|
1704
|
+
e.code = code;
|
|
1705
|
+
return e;
|
|
1706
|
+
}
|
|
1707
|
+
function awardBad(message) { return awardErr("BAD_ARGS", message); }
|
|
1708
|
+
function awardNotFound(message) { return awardErr("NOT_FOUND", message); }
|
|
1709
|
+
function awardUpstream(message) { return awardErr("UPSTREAM_ERROR", message); }
|
|
1710
|
+
|
|
1711
|
+
const AWARD_ENTITIES = { """: '"', "&": "&", "'": "'", "'": "'", "<": "<", ">": ">", " ": " " };
|
|
1712
|
+
function awardDecodeEntities(s) {
|
|
1713
|
+
return String(s ?? "").replace(/&(?:quot|amp|#0?39|lt|gt|nbsp);/g, (e) => AWARD_ENTITIES[e] ?? e);
|
|
1714
|
+
}
|
|
1715
|
+
function awardStripTags(s) {
|
|
1716
|
+
return awardDecodeEntities(String(s ?? "").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
1717
|
+
}
|
|
1718
|
+
function awardNormalizeSlug(s) {
|
|
1719
|
+
const v = String(s ?? "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1720
|
+
if (!v || v.length > 120) throw awardBad("slug must be 1-120 chars of [a-z0-9-], got: " + String(s).slice(0, 80));
|
|
1721
|
+
return v;
|
|
1722
|
+
}
|
|
1723
|
+
// Watches of kind "url" target a site slug. Accept either a bare slug or a
|
|
1724
|
+
// canonical Awwwards site URL and reduce both to the same slug, so a stored
|
|
1725
|
+
// watch always compares equal to the indexed slug (legacy raw-URL records
|
|
1726
|
+
// are normalized at match time too).
|
|
1727
|
+
function awardWatchTargetSlug(raw) {
|
|
1728
|
+
const s = String(raw ?? "").trim();
|
|
1729
|
+
if (!s) throw awardBad("url watch needs a site slug or an awwwards /sites/<slug> url");
|
|
1730
|
+
let slug = s;
|
|
1731
|
+
if (/^https?:\/\//i.test(s)) {
|
|
1732
|
+
let u;
|
|
1733
|
+
try { u = new URL(s); } catch { throw awardBad("url watch is not a parseable url: " + s.slice(0, 120)); }
|
|
1734
|
+
const host = u.hostname.toLowerCase();
|
|
1735
|
+
if (!AWARD_ALLOWED_HOSTS.includes(host)) throw awardBad("url watch must point at awwwards.com, got: " + host);
|
|
1736
|
+
const m = /^\/sites\/([^/]+)\/?$/.exec(u.pathname);
|
|
1737
|
+
if (!m) throw awardBad("url watch must be an awwwards.com/sites/<slug> url, got path: " + u.pathname.slice(0, 120));
|
|
1738
|
+
slug = m[1];
|
|
1739
|
+
}
|
|
1740
|
+
return awardNormalizeSlug(slug);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// ---- URL safety (SSRF guard) ----
|
|
1744
|
+
// Parsed-address classification, not regex matching: WHATWG URL already
|
|
1745
|
+
// canonicalizes alternate IPv4 spellings (0x7f000001 → 127.0.0.1, 2130706433
|
|
1746
|
+
// → 127.0.0.1) and IPv4-mapped IPv6 (::ffff:127.0.0.1 → ::ffff:7f00:1), so
|
|
1747
|
+
// policy decisions run on normalized forms. allowLocal relaxes LOOPBACK ONLY
|
|
1748
|
+
// (127.0.0.0/8, ::1, localhost names) for intended local dev builds; private
|
|
1749
|
+
// ranges, link-local, CGNAT, ULA, multicast/reserved and cloud metadata stay
|
|
1750
|
+
// blocked regardless of allowLocal. See SECURITY.md.
|
|
1751
|
+
const AWARD_METADATA_HOSTNAMES = new Set(["metadata.google.internal", "metadata.goog", "instance-data", "instance-data.ec2.internal"]);
|
|
1752
|
+
const AWARD_LOCAL_HOST_SUFFIXES = [".local", ".internal", ".home.arpa"];
|
|
1753
|
+
function awardIpv4Number(a, b, c, d) { return (((a << 24) >>> 0) + (b << 16) + (c << 8) + d) >>> 0; }
|
|
1754
|
+
function awardInIpv4Range(n, base, bits) {
|
|
1755
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
1756
|
+
return ((n & mask) >>> 0) === ((base & mask) >>> 0);
|
|
1757
|
+
}
|
|
1758
|
+
function awardParseIpv4(s) {
|
|
1759
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(s || ""));
|
|
1760
|
+
if (!m) return null;
|
|
1761
|
+
const parts = m.slice(1).map(Number);
|
|
1762
|
+
if (parts.some((p) => p > 255)) return null;
|
|
1763
|
+
return awardIpv4Number(parts[0], parts[1], parts[2], parts[3]);
|
|
1764
|
+
}
|
|
1765
|
+
function awardClassifyIpv4(n) {
|
|
1766
|
+
if (awardInIpv4Range(n, 0x7f000000, 8)) return "loopback";
|
|
1767
|
+
if (awardInIpv4Range(n, 0x0a000000, 8)) return "private";
|
|
1768
|
+
if (awardInIpv4Range(n, 0xac100000, 12)) return "private";
|
|
1769
|
+
if (awardInIpv4Range(n, 0xc0a80000, 16)) return "private";
|
|
1770
|
+
if (awardInIpv4Range(n, 0x64400000, 10)) return "private"; // 100.64.0.0/10 CGNAT (incl. Alibaba metadata)
|
|
1771
|
+
if (awardInIpv4Range(n, 0xa9fe0000, 16)) return "link-local"; // 169.254/16 (incl. AWS/GCP/Azure IMDS)
|
|
1772
|
+
if (n === 0) return "unspecified";
|
|
1773
|
+
if (awardInIpv4Range(n, 0xe0000000, 4)) return "multicast";
|
|
1774
|
+
if (awardInIpv4Range(n, 0xf0000000, 4)) return "reserved"; // 240/4 incl. broadcast
|
|
1775
|
+
if (awardInIpv4Range(n, 0xc0000000, 24)) return "reserved"; // 192.0.0.0/24 (incl. Oracle metadata .192)
|
|
1776
|
+
if (awardInIpv4Range(n, 0xc0000200, 24)) return "reserved"; // 192.0.2.0/24 TEST-NET-1
|
|
1777
|
+
if (awardInIpv4Range(n, 0xc6120000, 15)) return "reserved"; // 198.18.0.0/15 benchmark
|
|
1778
|
+
if (awardInIpv4Range(n, 0xc6336400, 24)) return "reserved"; // 198.51.100.0/24 TEST-NET-2
|
|
1779
|
+
if (awardInIpv4Range(n, 0xcb007100, 24)) return "reserved"; // 203.0.113.0/24 TEST-NET-3
|
|
1780
|
+
return "public";
|
|
1781
|
+
}
|
|
1782
|
+
function awardParseIpv6(s) {
|
|
1783
|
+
let t = String(s || "").toLowerCase();
|
|
1784
|
+
if (t.startsWith("[") && t.endsWith("]")) t = t.slice(1, -1);
|
|
1785
|
+
if (!t.includes(":")) return null;
|
|
1786
|
+
let head = t, tail = null;
|
|
1787
|
+
const dc = t.indexOf("::");
|
|
1788
|
+
if (dc >= 0) { head = t.slice(0, dc); tail = t.slice(dc + 2); }
|
|
1789
|
+
const groups = (part) => (part ? part.split(":") : []);
|
|
1790
|
+
const headG = groups(head), tailG = groups(tail);
|
|
1791
|
+
const bytes = [];
|
|
1792
|
+
const pushGroup = (g) => {
|
|
1793
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(g)) {
|
|
1794
|
+
const n = awardParseIpv4(g);
|
|
1795
|
+
if (n === null) return false;
|
|
1796
|
+
bytes.push((n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
|
|
1797
|
+
return true;
|
|
1798
|
+
}
|
|
1799
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return false;
|
|
1800
|
+
const v = parseInt(g, 16);
|
|
1801
|
+
bytes.push((v >> 8) & 255, v & 255);
|
|
1802
|
+
return true;
|
|
1803
|
+
};
|
|
1804
|
+
for (const g of headG) if (!pushGroup(g)) return null;
|
|
1805
|
+
if (tail !== null) {
|
|
1806
|
+
const left = 16 - bytes.length;
|
|
1807
|
+
if (left < 0) return null;
|
|
1808
|
+
const tailBytes = [];
|
|
1809
|
+
for (const g of tailG) {
|
|
1810
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(g)) {
|
|
1811
|
+
const n = awardParseIpv4(g);
|
|
1812
|
+
if (n === null) return null;
|
|
1813
|
+
tailBytes.push((n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
|
|
1814
|
+
} else {
|
|
1815
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
1816
|
+
const v = parseInt(g, 16);
|
|
1817
|
+
tailBytes.push((v >> 8) & 255, v & 255);
|
|
1818
|
+
}
|
|
1819
|
+
if (bytes.length + tailBytes.length > 16) return null;
|
|
1820
|
+
}
|
|
1821
|
+
const voidCount = 16 - bytes.length - tailBytes.length;
|
|
1822
|
+
if (voidCount < 0) return null;
|
|
1823
|
+
for (let i = 0; i < voidCount; i++) bytes.push(0);
|
|
1824
|
+
bytes.push(...tailBytes);
|
|
1825
|
+
}
|
|
1826
|
+
if (bytes.length !== 16) return null;
|
|
1827
|
+
return bytes;
|
|
1828
|
+
}
|
|
1829
|
+
function awardClassifyIpv6(bytes) {
|
|
1830
|
+
const allZero = bytes.every((b) => b === 0);
|
|
1831
|
+
if (allZero) return "unspecified";
|
|
1832
|
+
if (bytes.slice(0, 15).every((b) => b === 0) && bytes[15] === 1) return "loopback";
|
|
1833
|
+
const first10 = bytes.slice(0, 10).every((b) => b === 0);
|
|
1834
|
+
const mapped = first10 && bytes[10] === 0xff && bytes[11] === 0xff; // ::ffff:0:0/96
|
|
1835
|
+
const compatible = first10 && bytes[10] === 0 && bytes[11] === 0 && bytes.slice(12, 15).some((b) => b !== 0); // deprecated ::a.b.c.d
|
|
1836
|
+
if (mapped || compatible) {
|
|
1837
|
+
const v4 = awardClassifyIpv4(awardIpv4Number(bytes[12], bytes[13], bytes[14], bytes[15]));
|
|
1838
|
+
return mapped ? v4 : (v4 === "public" ? "reserved" : v4);
|
|
1839
|
+
}
|
|
1840
|
+
// NAT64 well-known prefix 64:ff9b::/96 embeds an IPv4 destination.
|
|
1841
|
+
if (bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b && bytes.slice(4, 12).every((b) => b === 0)) {
|
|
1842
|
+
return awardClassifyIpv4(awardIpv4Number(bytes[12], bytes[13], bytes[14], bytes[15]));
|
|
1843
|
+
}
|
|
1844
|
+
if ((bytes[0] & 0xfe) === 0xfc) return "private"; // fc00::/7 unique-local
|
|
1845
|
+
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return "link-local"; // fe80::/10
|
|
1846
|
+
if (bytes[0] === 0xff) return "multicast";
|
|
1847
|
+
if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8) return "reserved"; // 2001:db8::/32 documentation
|
|
1848
|
+
return "public";
|
|
1849
|
+
}
|
|
1850
|
+
function awardClassifyHost(hostname) {
|
|
1851
|
+
let h = String(hostname ?? "").toLowerCase().replace(/\.+$/, "");
|
|
1852
|
+
if (!h) return { kind: "invalid", host: String(hostname ?? "") };
|
|
1853
|
+
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
|
1854
|
+
if (h.includes(":")) {
|
|
1855
|
+
const bytes = awardParseIpv6(h);
|
|
1856
|
+
if (!bytes) return { kind: "invalid", host: h };
|
|
1857
|
+
return { kind: awardClassifyIpv6(bytes), version: 6, host: h };
|
|
1858
|
+
}
|
|
1859
|
+
const v4 = awardParseIpv4(h);
|
|
1860
|
+
if (v4 !== null) return { kind: awardClassifyIpv4(v4), version: 4, host: h };
|
|
1861
|
+
if (/^[0-9.]+$/.test(h)) return { kind: "invalid", host: h }; // numeric-looking but unparseable
|
|
1862
|
+
if (h === "localhost" || h.endsWith(".localhost")) return { kind: "loopback", version: null, host: h };
|
|
1863
|
+
if (AWARD_METADATA_HOSTNAMES.has(h)) return { kind: "metadata", version: null, host: h };
|
|
1864
|
+
if (AWARD_LOCAL_HOST_SUFFIXES.some((suf) => h.endsWith(suf))) return { kind: "local-name", version: null, host: h };
|
|
1865
|
+
return { kind: "public", version: null, host: h };
|
|
1866
|
+
}
|
|
1867
|
+
function awardIsBlockedHost(hostname) {
|
|
1868
|
+
return awardClassifyHost(hostname).kind !== "public";
|
|
1869
|
+
}
|
|
1870
|
+
function awardValidateHttpUrl(raw, { allowLocal = false } = {}) {
|
|
1871
|
+
if (typeof raw !== "string" || !raw.trim()) throw awardBad("url must be a non-empty http(s) string");
|
|
1872
|
+
if (raw.length > 2000) throw awardBad("url too long (max 2000 chars)");
|
|
1873
|
+
let u;
|
|
1874
|
+
try { u = new URL(raw.trim()); } catch { throw awardBad("url is not parseable: " + raw.slice(0, 120)); }
|
|
1875
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw awardBad("only http/https urls are allowed, got: " + u.protocol);
|
|
1876
|
+
if (u.username || u.password) throw awardBad("urls with credentials are not allowed");
|
|
1877
|
+
const cls = awardClassifyHost(u.hostname);
|
|
1878
|
+
if (cls.kind === "public") return u.toString();
|
|
1879
|
+
if (allowLocal && cls.kind === "loopback") return u.toString();
|
|
1880
|
+
const e = new Error(awardBlockedReason(cls, u.hostname));
|
|
1881
|
+
e.code = "UNSUPPORTED_OPERATION";
|
|
1882
|
+
e.hostKind = cls.kind;
|
|
1883
|
+
throw e;
|
|
1884
|
+
}
|
|
1885
|
+
function awardBlockedReason(cls, hostname) {
|
|
1886
|
+
const h = String(hostname);
|
|
1887
|
+
if (cls.kind === "loopback") return "loopback target blocked (pass allowLocal:true for intended local dev builds): " + h;
|
|
1888
|
+
if (cls.kind === "metadata") return "cloud metadata endpoints are never fetched: " + h;
|
|
1889
|
+
if (cls.kind === "private") return "private-network targets are never fetched, even with allowLocal:true (allowLocal permits loopback only): " + h;
|
|
1890
|
+
if (cls.kind === "link-local") return "link-local targets are never fetched (" + h + ")";
|
|
1891
|
+
if (cls.kind === "unspecified" || cls.kind === "multicast" || cls.kind === "reserved") return "non-routable target blocked (" + cls.kind + "): " + h;
|
|
1892
|
+
if (cls.kind === "local-name") return "local-network hostname blocked (" + h + "); only loopback dev targets are permitted with allowLocal:true";
|
|
1893
|
+
return "unrecognized host is not fetched: " + h;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// ---- DNS resolution guard ----
|
|
1897
|
+
// Best-effort and positive-blocking only: a hostname that RESOLVES to a
|
|
1898
|
+
// prohibited address is refused; a lookup that fails (offline DNS) is not an
|
|
1899
|
+
// error here — the real connection decides, and every redirect hop is
|
|
1900
|
+
// re-validated anyway. Bounded: lookups that take too long return unchecked
|
|
1901
|
+
// instead of stalling a tool call.
|
|
1902
|
+
async function awardCheckHostResolution(rawUrl, { lookup = null, allowLocal = false, timeoutMs = 1500 } = {}) {
|
|
1903
|
+
const out = { url: String(rawUrl), host: null, checked: false, blocked: false, addresses: [], blockedAddresses: [], reason: null };
|
|
1904
|
+
let u;
|
|
1905
|
+
try { u = new URL(String(rawUrl)); } catch { out.reason = "unparseable url"; return out; }
|
|
1906
|
+
const cls = awardClassifyHost(u.hostname);
|
|
1907
|
+
out.host = cls.host;
|
|
1908
|
+
if (cls.version !== null) { out.checked = true; return out; } // IP literals were classified at validation time
|
|
1909
|
+
const fn = lookup || awardDefaultLookup();
|
|
1910
|
+
if (typeof fn !== "function") { out.reason = "no dns lookup available"; return out; }
|
|
1911
|
+
let addrs;
|
|
1912
|
+
let timer = null;
|
|
1913
|
+
try {
|
|
1914
|
+
addrs = await Promise.race([
|
|
1915
|
+
Promise.resolve(fn(cls.host, { all: true })),
|
|
1916
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); }),
|
|
1917
|
+
]);
|
|
1918
|
+
} catch (e) { out.reason = "dns lookup failed: " + String((e && e.message) || e).slice(0, 120); return out; }
|
|
1919
|
+
finally { if (timer) clearTimeout(timer); }
|
|
1920
|
+
if (addrs === null || addrs === undefined) { out.reason = "dns lookup timed out"; return out; }
|
|
1921
|
+
const list = Array.isArray(addrs) ? addrs : [addrs];
|
|
1922
|
+
out.checked = true;
|
|
1923
|
+
for (const a of list) {
|
|
1924
|
+
const address = typeof a === "string" ? a : (a && a.address) || "";
|
|
1925
|
+
if (!address) continue;
|
|
1926
|
+
out.addresses.push(address);
|
|
1927
|
+
const k = awardClassifyHost(address).kind;
|
|
1928
|
+
const loopbackOk = allowLocal && k === "loopback";
|
|
1929
|
+
if (k !== "public" && !loopbackOk) out.blockedAddresses.push(address);
|
|
1930
|
+
}
|
|
1931
|
+
out.blocked = out.blockedAddresses.length > 0;
|
|
1932
|
+
return out;
|
|
1933
|
+
}
|
|
1934
|
+
function awardDefaultLookup() {
|
|
1935
|
+
const g = globalThis.process && globalThis.process.getBuiltinModule ? globalThis.process.getBuiltinModule : null;
|
|
1936
|
+
if (typeof g !== "function") return null;
|
|
1937
|
+
try { return g.call(globalThis.process, "node:dns").promises.lookup; } catch { return null; }
|
|
1938
|
+
}
|
|
1939
|
+
function awardValidateAwardPath(pathOrUrl) {
|
|
1940
|
+
const s = String(pathOrUrl ?? "");
|
|
1941
|
+
if (/^https?:\/\//i.test(s)) {
|
|
1942
|
+
const u = new URL(s);
|
|
1943
|
+
if (!AWARD_ALLOWED_HOSTS.includes(u.hostname.toLowerCase())) throw awardBad("award source host not allowed: " + u.hostname);
|
|
1944
|
+
return u.pathname + (u.search || "");
|
|
1945
|
+
}
|
|
1946
|
+
if (!s.startsWith("/")) throw awardBad("award path must start with / (e.g. /sites/slug)");
|
|
1947
|
+
if (s.includes("..") || s.includes("\\") || /[\s<>"]/.test(s)) throw awardBad("unsafe award path");
|
|
1948
|
+
return s;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// ---- Filter URL builder (one filter per URL; rest applied client-side) ----
|
|
1952
|
+
function awardFilterUrl(filters = {}) {
|
|
1953
|
+
if (filters.color) {
|
|
1954
|
+
const c = String(filters.color).replace("#", "").toUpperCase();
|
|
1955
|
+
if (!/^[0-9A-F]{6}$/.test(c)) throw awardBad("color must be a hex like #FF5733");
|
|
1956
|
+
return AWARD_BASE_URL + "/websites/%23" + c + "/";
|
|
1957
|
+
}
|
|
1958
|
+
if (filters.award) {
|
|
1959
|
+
if (!AWARD_FILTERS[filters.award]) throw awardBad("award must be one of " + AWARD_FILTER_NAMES.join("|"));
|
|
1960
|
+
return AWARD_BASE_URL + "/" + AWARD_FILTERS[filters.award] + "/";
|
|
1961
|
+
}
|
|
1962
|
+
const tag = filters.technology ?? (Array.isArray(filters.tags) ? filters.tags[0] : null);
|
|
1963
|
+
if (tag) {
|
|
1964
|
+
const t = String(tag).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
|
|
1965
|
+
if (!t || t.length > 60) throw awardBad("tag/technology must be 2-60 chars");
|
|
1966
|
+
return AWARD_BASE_URL + "/websites/" + encodeURIComponent(t) + "/";
|
|
1967
|
+
}
|
|
1968
|
+
return AWARD_BASE_URL + "/websites/";
|
|
1969
|
+
}
|
|
1970
|
+
function awardSiteUrl(slug) { return AWARD_BASE_URL + "/sites/" + awardNormalizeSlug(slug); }
|
|
1971
|
+
function awardThumbnailUrl(thumbPath, size = 880) {
|
|
1972
|
+
const dim = size === 440 ? "440_330" : "880_660";
|
|
1973
|
+
return AWARD_ASSETS_URL + "/awards/media/cache/thumb_" + dim + "/" + String(thumbPath).replace(/^\/+/, "");
|
|
1974
|
+
}
|
|
1975
|
+
function awardElementUrl(mediaPath) { return AWARD_ASSETS_URL + "/awards/" + String(mediaPath).replace(/^\/+/, ""); }
|
|
1976
|
+
|
|
1977
|
+
// ---- Parsers (scoped, fixture-driven, resilient to missing fields) ----
|
|
1978
|
+
const AWARD_LABELS = { sotd: "Site of the Day", dev: "Developer Award", hm: "Honorable Mention", sotm: "Site of the Month", mobile: "Mobile Excellence", ecom: "E-Commerce Award" };
|
|
1979
|
+
// Bump when the listing parser's expectations about upstream markup change;
|
|
1980
|
+
// diagnostics carry it so callers can tell "page is empty" from "parser can
|
|
1981
|
+
// no longer read the page" without guessing.
|
|
1982
|
+
const AWARD_PARSER_VERSION = 1;
|
|
1983
|
+
const AWARD_FIELD_BOUNDS = { slug: 120, title: 200, tag: 40, tags: 20, studio: 120, liveUrl: 500, thumb: 500 };
|
|
1984
|
+
function awardBoundStr(v, max) { return String(v ?? "").slice(0, max); }
|
|
1985
|
+
function awardParseListingEx(html) {
|
|
1986
|
+
const src = String(html || "");
|
|
1987
|
+
const diagnostics = { parserVersion: AWARD_PARSER_VERSION, bytes: src.length, blobs: 0, parsed: 0, filtered: 0, malformed: 0, anchors: 0, verdict: "empty-input" };
|
|
1988
|
+
if (!src) return { sites: [], diagnostics };
|
|
1989
|
+
const sites = [];
|
|
1990
|
+
const seen = new Set();
|
|
1991
|
+
const parts = src.split('data-collectable-model-value="');
|
|
1992
|
+
const blobParts = parts.slice(1);
|
|
1993
|
+
diagnostics.blobs = blobParts.length;
|
|
1994
|
+
for (const part of blobParts) {
|
|
1995
|
+
const end = part.indexOf('">');
|
|
1996
|
+
if (end < 0 || end > 20000) { diagnostics.malformed++; continue; }
|
|
1997
|
+
let meta;
|
|
1998
|
+
try { meta = JSON.parse(awardDecodeEntities(part.slice(0, end))); } catch { diagnostics.malformed++; continue; }
|
|
1999
|
+
if (!meta || !meta.slug || !meta.title) { diagnostics.filtered++; continue; }
|
|
2000
|
+
if (meta.type && meta.type !== "submission") { diagnostics.filtered++; continue; }
|
|
2001
|
+
const slug = awardBoundStr(String(meta.slug).toLowerCase(), AWARD_FIELD_BOUNDS.slug);
|
|
2002
|
+
if (seen.has(slug)) { diagnostics.filtered++; continue; }
|
|
2003
|
+
const card = part.slice(end, end + 8000);
|
|
2004
|
+
const detailMatch = card.match(/href="(\/sites\/[^"#?]+)"/);
|
|
2005
|
+
if (!detailMatch) { diagnostics.filtered++; continue; }
|
|
2006
|
+
const liveMatch = card.match(/class="figure-rollover__bt"[^>]*href="(https?:\/\/[^"]+)"/);
|
|
2007
|
+
const awards = [...new Set([...card.matchAll(/budget-tag--([a-z-]+)/g)].map((m) => AWARD_LABELS[m[1]] ?? m[1]))];
|
|
2008
|
+
const studioMatch = card.match(/avatar-name__title">([^<]+)</);
|
|
2009
|
+
seen.add(slug);
|
|
2010
|
+
sites.push({
|
|
2011
|
+
slug,
|
|
2012
|
+
title: awardBoundStr(awardDecodeEntities(meta.title).trim(), AWARD_FIELD_BOUNDS.title),
|
|
2013
|
+
createdAt: Number(meta.createdAt) || 0,
|
|
2014
|
+
tags: Array.isArray(meta.tags) ? meta.tags.map((t) => awardBoundStr(awardDecodeEntities(t), AWARD_FIELD_BOUNDS.tag)).filter(Boolean).slice(0, AWARD_FIELD_BOUNDS.tags) : [],
|
|
2015
|
+
thumbnailPath: (meta.images && meta.images.thumbnail) ? awardBoundStr(meta.images.thumbnail, AWARD_FIELD_BOUNDS.thumb) : "",
|
|
2016
|
+
liveUrl: liveMatch ? awardBoundStr(awardDecodeEntities(liveMatch[1]), AWARD_FIELD_BOUNDS.liveUrl) : null,
|
|
2017
|
+
detailPath: detailMatch[1],
|
|
2018
|
+
awards,
|
|
2019
|
+
studio: studioMatch ? awardBoundStr(awardDecodeEntities(studioMatch[1]).trim(), AWARD_FIELD_BOUNDS.studio) : null,
|
|
2020
|
+
source: "awwwards",
|
|
2021
|
+
sourceUrl: AWARD_BASE_URL + detailMatch[1],
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
// Fallback: plain /sites/ anchors only when the blob scheme is entirely
|
|
2025
|
+
// absent (true drift). Filtered blobs (collections, untitled) mean the
|
|
2026
|
+
// parser saw the page correctly and found no sites — return empty.
|
|
2027
|
+
if (!sites.length && !blobParts.length) {
|
|
2028
|
+
const anchors = [...src.matchAll(/href="\/sites\/([a-z0-9-]{1,120})\/?"[^>]*>([^<]{1,120})</gi)];
|
|
2029
|
+
for (const m of anchors) {
|
|
2030
|
+
const slug = m[1].toLowerCase();
|
|
2031
|
+
if (seen.has(slug)) continue;
|
|
2032
|
+
seen.add(slug);
|
|
2033
|
+
sites.push({ slug, title: awardStripTags(m[2]) || slug, createdAt: 0, tags: [], thumbnailPath: "", liveUrl: null, detailPath: "/sites/" + slug, awards: [], studio: null, source: "awwwards", sourceUrl: AWARD_BASE_URL + "/sites/" + slug });
|
|
2034
|
+
if (sites.length >= 50) break;
|
|
2035
|
+
}
|
|
2036
|
+
diagnostics.anchors = sites.length;
|
|
2037
|
+
}
|
|
2038
|
+
diagnostics.parsed = sites.length;
|
|
2039
|
+
// Verdict: "ok" whenever cards were read. With zero cards, distinguish a
|
|
2040
|
+
// page the parser understood (filtered/empty listing) from markup it can
|
|
2041
|
+
// no longer read (drift) — callers can keep trust on the former and warn
|
|
2042
|
+
// or abort on the latter instead of treating 0 as an error everywhere.
|
|
2043
|
+
if (sites.length) diagnostics.verdict = "ok";
|
|
2044
|
+
else if (diagnostics.malformed > 0) diagnostics.verdict = "drift-empty";
|
|
2045
|
+
else if (blobParts.length > 0) diagnostics.verdict = "filtered-empty";
|
|
2046
|
+
else if (/awwwards|data-collectable|\/sites\//i.test(src)) diagnostics.verdict = "empty-listing";
|
|
2047
|
+
else diagnostics.verdict = "drift-empty";
|
|
2048
|
+
return { sites, diagnostics };
|
|
2049
|
+
}
|
|
2050
|
+
function awardParseListing(html) {
|
|
2051
|
+
return awardParseListingEx(html).sites;
|
|
2052
|
+
}
|
|
2053
|
+
function awardIsNomineePage(html) { return /<h2 class="text-default">\s*Nominee\b/.test(String(html || "")); }
|
|
2054
|
+
function awardParseScore(html) {
|
|
2055
|
+
const m = /c-heading-score__note[^>]*>[^<]*?([\d]+(?:\.\d{1,2})?)/.exec(String(html || ""));
|
|
2056
|
+
return m ? parseFloat(m[1]) : null;
|
|
2057
|
+
}
|
|
2058
|
+
function awardParseDetail(html, slug) {
|
|
2059
|
+
const src = String(html || "");
|
|
2060
|
+
const norm = awardNormalizeSlug(slug);
|
|
2061
|
+
const palette = [...new Set([...src.matchAll(/<strong>HEX<\/strong>\s*#([0-9A-Fa-f]{6})/g)].map((m) => "#" + m[1].toUpperCase()))];
|
|
2062
|
+
const techTitle = src.search(/Technologies\s*(&|&)\s*Tools<\/h2>/);
|
|
2063
|
+
const techIdx = techTitle >= 0 ? techTitle : -1;
|
|
2064
|
+
const techSection = techIdx >= 0 ? src.slice(techIdx, techIdx + 6000) : "";
|
|
2065
|
+
const technologies = [...new Set([...techSection.matchAll(/class="button button--tag"[^>]*>([^<]+)</g)].map((m) => awardStripTags(m[1])))].filter(Boolean);
|
|
2066
|
+
const elStart = src.indexOf(">Elements</h2>");
|
|
2067
|
+
const elEnd = elStart >= 0 ? src.indexOf(">Color Palette</h2>", elStart) : -1;
|
|
2068
|
+
const elSection = elStart >= 0 && elEnd > elStart ? src.slice(elStart, elEnd) : "";
|
|
2069
|
+
const elements = [...elSection.matchAll(/collectableTitle":"(.+?)"/g)].map((m) => awardDecodeEntities(m[1])).filter(Boolean);
|
|
2070
|
+
const awards = [...src.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] }));
|
|
2071
|
+
const descIdx = src.indexOf(">Description</h2>");
|
|
2072
|
+
const descMatch = descIdx >= 0 ? src.slice(descIdx, descIdx + 3000).match(/<h3 class="heading-6">([\s\S]{0,2000}?)<\/h3>/) : null;
|
|
2073
|
+
const curated = descMatch ? awardStripTags(descMatch[1]) || null : null;
|
|
2074
|
+
const ogDesc = (src.match(/property="og:description" content="([^"]*)"/) || [])[1];
|
|
2075
|
+
const ogImage = (src.match(/property="og:image" content="([^"]+)"/) || [])[1];
|
|
2076
|
+
const h1 = src.match(/<h1 class="heading-1[^"]*">\s*<a href="(https?:\/\/(?!www\.awwwards\.com|assets\.awwwards\.com)[^"]+)"[^>]*>([\s\S]*?)<\/a>/);
|
|
2077
|
+
const liveFallback = h1 ? null : src.match(/href="(https?:\/\/(?!www\.awwwards\.com|assets\.awwwards\.com)[^"]+)"[^>]*target="_blank" rel="noopener"/);
|
|
2078
|
+
const titleMatch = src.match(/property="og:title" content="([^"]+)"/);
|
|
2079
|
+
return {
|
|
2080
|
+
slug: norm,
|
|
2081
|
+
title: titleMatch ? awardDecodeEntities(titleMatch[1]) : (h1 ? awardStripTags(h1[2]) : null),
|
|
2082
|
+
description: curated ?? (ogDesc ? awardDecodeEntities(ogDesc).trim() || null : null),
|
|
2083
|
+
palette, technologies, elements, awards,
|
|
2084
|
+
nominee: awardIsNomineePage(src),
|
|
2085
|
+
score: awardParseScore(src),
|
|
2086
|
+
ogImage: ogImage ? awardDecodeEntities(ogImage) : null,
|
|
2087
|
+
liveUrl: (h1 && h1[1]) || (liveFallback && liveFallback[1]) ? awardDecodeEntities((h1 && h1[1]) || liveFallback[1]) : null,
|
|
2088
|
+
source: "awwwards",
|
|
2089
|
+
sourceUrl: awardSiteUrl(norm),
|
|
2090
|
+
retrievedAt: new Date().toISOString(),
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
function awardParseCategories(html) {
|
|
2094
|
+
const src = String(html || "");
|
|
2095
|
+
const colors = [...new Set([...src.matchAll(/href="\/websites\/%23([0-9A-Fa-f]{6})\/"/g)].map((m) => "#" + m[1].toUpperCase()))].sort();
|
|
2096
|
+
const filters = [...new Set([...src.matchAll(/href="\/websites\/([a-z0-9-]{2,60})\/"/g)].map((m) => m[1]))].filter((s) => s !== "sites_of_the_day").sort();
|
|
2097
|
+
return { colors, filters };
|
|
2098
|
+
}
|
|
2099
|
+
function awardParseElementsSection(html) {
|
|
2100
|
+
const src = String(html || "");
|
|
2101
|
+
const start = src.indexOf(">Elements</h2>");
|
|
2102
|
+
if (start < 0) return null;
|
|
2103
|
+
const end = src.indexOf(">Color Palette</h2>", start);
|
|
2104
|
+
const section = end > start ? src.slice(start, end) : src.slice(start);
|
|
2105
|
+
const out = [];
|
|
2106
|
+
for (const part of section.split('data-collectable-model-value="').slice(1)) {
|
|
2107
|
+
const stop = part.indexOf('">');
|
|
2108
|
+
if (stop < 0) continue;
|
|
2109
|
+
let blob;
|
|
2110
|
+
try { blob = JSON.parse(awardDecodeEntities(part.slice(0, stop))); } catch { continue; }
|
|
2111
|
+
const mp = blob && blob.collectableImage;
|
|
2112
|
+
if (typeof mp === "string" && /^(element|external)\//.test(mp)) out.push({ title: awardDecodeEntities(String(blob.collectableTitle ?? "")), mediaPath: mp });
|
|
2113
|
+
}
|
|
2114
|
+
return out;
|
|
2115
|
+
}
|
|
2116
|
+
function awardParseElementGallery(html) {
|
|
2117
|
+
const src = String(html || "");
|
|
2118
|
+
const slugs = [...new Set([...src.matchAll(/href="\/inspiration\/([\w-]{1,120})\/?"/g)].map((m) => m[1]))];
|
|
2119
|
+
return slugs;
|
|
2120
|
+
}
|
|
2121
|
+
function awardParseElementPage(html) {
|
|
2122
|
+
const src = String(html || "");
|
|
2123
|
+
const slug = (src.match(/rel="canonical" href="[^"]*?\/inspiration\/([\w-]{1,120})\/?"/) || [])[1] ?? awardParseElementGallery(src)[0] ?? null;
|
|
2124
|
+
if (!slug) return null;
|
|
2125
|
+
const titleRaw = (src.match(/<h1[^>]*>\s*([\s\S]{2,200}?)\s*(?:<small>from<\/small>|<\/h1>)/) || [])[1];
|
|
2126
|
+
const authorRaw = (src.match(/from<\/small>\s*<a(?:[^>"']|"[^"]*"|'[^']*')*>\s*([\s\S]{2,120}?)<\/a>/) || [])[1];
|
|
2127
|
+
const siteSlug = (src.match(/from<\/small>\s*<a[^>]*?href="\/sites\/([\w-]{1,120})"/) || [])[1] ?? null;
|
|
2128
|
+
const builtI = src.indexOf("was built with...</p>");
|
|
2129
|
+
const builtWith = [];
|
|
2130
|
+
if (builtI >= 0) for (const t of src.slice(builtI, builtI + 2000).matchAll(/button--tag[^>]*>([^<]{2,30})</g)) {
|
|
2131
|
+
const v = t[1].trim();
|
|
2132
|
+
if (v && !builtWith.includes(v)) builtWith.push(v);
|
|
2133
|
+
}
|
|
2134
|
+
let mediaPath = null;
|
|
2135
|
+
for (const part of src.split('data-collectable-model-value="').slice(1)) {
|
|
2136
|
+
const stop = part.indexOf('"');
|
|
2137
|
+
if (stop < 0) continue;
|
|
2138
|
+
try {
|
|
2139
|
+
const blob = JSON.parse(awardDecodeEntities(part.slice(0, stop)));
|
|
2140
|
+
if (blob && blob.collectableImage) { mediaPath = blob.collectableImage; break; }
|
|
2141
|
+
} catch { /* ignore */ }
|
|
2142
|
+
}
|
|
2143
|
+
const category = (src.match(/href="\/elements\/([\w-]{1,60})\/"/) || [])[1] ?? null;
|
|
2144
|
+
return {
|
|
2145
|
+
slug,
|
|
2146
|
+
title: titleRaw ? awardStripTags(titleRaw) : slug,
|
|
2147
|
+
author: authorRaw ? awardStripTags(authorRaw) : null,
|
|
2148
|
+
siteSlug, builtWith,
|
|
2149
|
+
related: awardParseElementGallery(src).filter((s) => s !== slug).slice(0, 12),
|
|
2150
|
+
mediaPath, mediaType: mediaPath ? (mediaPath.endsWith(".mp4") ? "video" : "image") : null,
|
|
2151
|
+
mediaUrl: mediaPath ? awardElementUrl(mediaPath) : null,
|
|
2152
|
+
category,
|
|
2153
|
+
source: "awwwards",
|
|
2154
|
+
sourceUrl: AWARD_BASE_URL + "/inspiration/" + slug,
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// ---- Deterministic local ranking (token + prefix + BM25-style idf) ----
|
|
2159
|
+
const AWARD_STOP = new Set("a,an,the,for,with,and,or,of,to,in,on,my,new,also,that,this,from,into,plus,vs,top,best,up,site,website,web,design".split(","));
|
|
2160
|
+
function awardTokens(q, max = 6) {
|
|
2161
|
+
return String(q ?? "").toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/[\s-]+/).map((t) => t.trim()).filter((t) => t.length > 1 && !AWARD_STOP.has(t)).slice(0, max);
|
|
2162
|
+
}
|
|
2163
|
+
// Theme words are rank signals, not just match tokens: a "dark …" query
|
|
2164
|
+
// boosts dark-tagged sites even when no title literally says "dark".
|
|
2165
|
+
function awardThemeHint(q) {
|
|
2166
|
+
const t = String(q ?? "").toLowerCase();
|
|
2167
|
+
const dark = /\bdark\b|\bmidnight\b|\bnoir\b|\bblack\b|\bmoody\b|\bcinematic\b/.test(t);
|
|
2168
|
+
const light = /\blight\b|\bbright\b|\bairy\b|\bwhite\b|\bpastel\b/.test(t);
|
|
2169
|
+
if (dark && !light) return "dark";
|
|
2170
|
+
if (light && !dark) return "light";
|
|
2171
|
+
return null;
|
|
2172
|
+
}
|
|
2173
|
+
// Design-vocabulary aliases: "glassy" should find glassmorphism-tagged sites,
|
|
2174
|
+
// "3d" should find webgl/three.js ones. Aliases only ADD candidates — the
|
|
2175
|
+
// reason string always names the original query word, never the alias.
|
|
2176
|
+
const AWARD_ALIASES = {
|
|
2177
|
+
glassy: ["glass", "glassmorphism"], glass: ["glassmorphism"], neon: ["glow", "fluorescent"],
|
|
2178
|
+
"3d": ["webgl", "three", "threejs", "3-d"], minimal: ["minimalist", "minimalism"],
|
|
2179
|
+
bold: ["brutalist", "brutalism"], playful: ["fun", "quirky"], luxury: ["luxe", "premium", "elegant"],
|
|
2180
|
+
dark: ["black", "noir", "midnight", "moody"], light: ["white", "airy", "bright", "pastel"],
|
|
2181
|
+
};
|
|
2182
|
+
function awardExpandToken(tok) {
|
|
2183
|
+
const t = String(tok).toLowerCase();
|
|
2184
|
+
return [t, ...(AWARD_ALIASES[t] || [])];
|
|
2185
|
+
}
|
|
2186
|
+
function awardStemLite(tok) {
|
|
2187
|
+
let t = String(tok).toLowerCase();
|
|
2188
|
+
if (t.length > 5 && t.endsWith("ing")) t = t.slice(0, -3);
|
|
2189
|
+
else if (t.length > 4 && t.endsWith("ies")) t = t.slice(0, -3) + "y";
|
|
2190
|
+
else if (t.length > 4 && t.endsWith("es") && /(ses|xes|zes|ches|shes)$/.test(t)) t = t.slice(0, -2);
|
|
2191
|
+
else if (t.length > 3 && t.endsWith("s")) t = t.slice(0, -1);
|
|
2192
|
+
return t;
|
|
2193
|
+
}
|
|
2194
|
+
function awardFieldHay(site) {
|
|
2195
|
+
return {
|
|
2196
|
+
title: String(site.title || "").toLowerCase(),
|
|
2197
|
+
tags: (Array.isArray(site.tags) ? site.tags : []).map((t) => String(t).toLowerCase()),
|
|
2198
|
+
awards: (Array.isArray(site.awards) ? site.awards : []).map((t) => String(t).toLowerCase()),
|
|
2199
|
+
studio: String(site.studio || "").toLowerCase(),
|
|
2200
|
+
};
|
|
2201
|
+
}
|
|
2202
|
+
function awardScoreSites(sites, query, { technology, color } = {}) {
|
|
2203
|
+
const toks = awardTokens(query).map(awardStemLite);
|
|
2204
|
+
const n = Math.max(1, sites.length);
|
|
2205
|
+
const df = new Map();
|
|
2206
|
+
for (const t of toks) {
|
|
2207
|
+
let c = 0;
|
|
2208
|
+
for (const s of sites) {
|
|
2209
|
+
const h = awardFieldHay(s);
|
|
2210
|
+
const hay = (h.title + " " + h.tags.join(" ") + " " + h.studio).toLowerCase();
|
|
2211
|
+
if (hay.includes(t)) c++;
|
|
2212
|
+
}
|
|
2213
|
+
df.set(t, c);
|
|
2214
|
+
}
|
|
2215
|
+
const scored = (sites || []).map((s) => {
|
|
2216
|
+
const h = awardFieldHay(s);
|
|
2217
|
+
let score = 0;
|
|
2218
|
+
const reasons = [];
|
|
2219
|
+
const hint = awardThemeHint(query);
|
|
2220
|
+
for (const raw of awardTokens(query)) {
|
|
2221
|
+
const variants = awardExpandToken(awardStemLite(raw));
|
|
2222
|
+
const idf = Math.log(1 + n / (1 + (df.get(awardStemLite(raw)) || 0)));
|
|
2223
|
+
const titleHit = variants.some((v) => h.title.toLowerCase().includes(v));
|
|
2224
|
+
const tagHit = variants.some((v) => h.tags.some((g) => g === v || g.startsWith(v) || awardStemLite(g) === v));
|
|
2225
|
+
if (titleHit) { score += 3 * idf; reasons.push("title:" + raw); }
|
|
2226
|
+
else if (tagHit) { score += 2 * idf; reasons.push("tag:" + raw); }
|
|
2227
|
+
else if (variants.some((v) => (h.title + " " + h.tags.join(" ")).toLowerCase().includes(v))) { score += 1 * idf; reasons.push("text:" + raw); }
|
|
2228
|
+
else if (h.studio.includes(raw)) { score += 1.2 * idf; reasons.push("studio:" + raw); }
|
|
2229
|
+
}
|
|
2230
|
+
// Theme-hint boost (dark/light): tag-level signal, labeled as such.
|
|
2231
|
+
if (hint && (h.tags.includes(hint) || h.tags.some((g) => (AWARD_ALIASES[hint] || []).includes(g)))) {
|
|
2232
|
+
if (reasons.length > 0 || !toks.length) { score += 1.2; reasons.push("theme:" + hint); }
|
|
2233
|
+
}
|
|
2234
|
+
// Award/filter bonuses are tie-breakers for matched rows only: they must
|
|
2235
|
+
// never rescue a zero-token row into a "match" (honest-empty stays honest).
|
|
2236
|
+
const matched = reasons.length > 0 || !toks.length;
|
|
2237
|
+
if (technology && h.tags.includes(String(technology).toLowerCase()) && matched) { score += 1.5; reasons.push("tech-filter"); }
|
|
2238
|
+
if (color && JSON.stringify(s).toLowerCase().includes(String(color).toLowerCase()) && matched) { score += 0.5; reasons.push("color-hint"); }
|
|
2239
|
+
if (Array.isArray(s.awards) && s.awards.length && matched) score += 0.3;
|
|
2240
|
+
return { site: s, score: Math.round(score * 100) / 100, reasons: [...new Set(reasons)].slice(0, 6) };
|
|
2241
|
+
});
|
|
2242
|
+
scored.sort((a, b) => (b.score - a.score) || String(a.site.title).localeCompare(String(b.site.title)) || String(a.site.slug).localeCompare(String(b.site.slug)));
|
|
2243
|
+
return { tokens: awardTokens(query), scored };
|
|
2244
|
+
}
|
|
2245
|
+
function awardSuggestQueries(query, taxonomy) {
|
|
2246
|
+
const toks = awardTokens(query);
|
|
2247
|
+
const out = [];
|
|
2248
|
+
if (toks.length > 1) out.push(toks.slice(0, Math.min(3, toks.length)).join(" "));
|
|
2249
|
+
for (const t of toks.slice(0, 3)) out.push(t);
|
|
2250
|
+
const filters = (taxonomy && taxonomy.filters) || [];
|
|
2251
|
+
for (const t of toks) {
|
|
2252
|
+
const hit = filters.find((f) => f.startsWith(t.slice(0, 4)));
|
|
2253
|
+
if (hit && !out.includes(hit)) out.push(hit);
|
|
2254
|
+
}
|
|
2255
|
+
for (const fb of ["portfolio", "hero", "minimal"]) if (!out.includes(fb) && out.length < 5) out.push(fb);
|
|
2256
|
+
return [...new Set(out)].slice(0, 5);
|
|
2257
|
+
}
|
|
2258
|
+
function awardCompactSite(s, scoreInfo) {
|
|
2259
|
+
const tags = (s.tags || []).slice(0, 8);
|
|
2260
|
+
// try_for: agent-actionable cue derived ONLY from observed tags
|
|
2261
|
+
// ("dark hero sections" from tags [dark, hero]) — absent when no tags.
|
|
2262
|
+
const tryFor = tags.length ? "use for: " + tags.slice(0, 3).join(" ") + " sections" : null;
|
|
2263
|
+
return {
|
|
2264
|
+
slug: s.slug, title: s.title,
|
|
2265
|
+
sourceUrl: s.sourceUrl || awardSiteUrl(s.slug),
|
|
2266
|
+
liveUrl: s.liveUrl || null,
|
|
2267
|
+
tags,
|
|
2268
|
+
awards: s.awards || [],
|
|
2269
|
+
studio: s.studio || null,
|
|
2270
|
+
previewUrl: s.thumbnailPath ? awardThumbnailUrl(s.thumbnailPath, 880) : (s.ogImage || null),
|
|
2271
|
+
...(tryFor ? { try_for: tryFor } : {}),
|
|
2272
|
+
...(scoreInfo ? { relevance: scoreInfo.score, matchReasons: scoreInfo.reasons } : {}),
|
|
2273
|
+
evidence: { source: "awwwards", detailPath: s.detailPath || ("/sites/" + s.slug) },
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
function awardFullSite(s, detail, scoreInfo) {
|
|
2277
|
+
const base = awardCompactSite(s, scoreInfo);
|
|
2278
|
+
return { ...base, description: (detail && detail.description) || null, palette: (detail && detail.palette) || [], technologies: (detail && detail.technologies) || [], elements: (detail && detail.elements) || [], awardHistory: (detail && detail.awards) || [], score: (detail && detail.score) ?? null, nominee: !!(detail && detail.nominee), retrievedAt: (detail && detail.retrievedAt) || null };
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// awardSiteIdeas: agent-usable design ideas derived ONLY from observed
|
|
2282
|
+
// detail fields. Every idea carries status:"suggested" + basis naming the
|
|
2283
|
+
// evidence — never presented as observed fact. Count contract: 0–6 ideas
|
|
2284
|
+
// depending on how much evidence the detail holds (1 idea on thin evidence
|
|
2285
|
+
// is valid, not a failure); null detail yields [].
|
|
2286
|
+
function awardSiteIdeas(detail) {
|
|
2287
|
+
if (!detail) return [];
|
|
2288
|
+
const ideas = [];
|
|
2289
|
+
const bands = new Set((detail.palette || []).map((c) => {
|
|
2290
|
+
const l = awardLuminance(String(c).toUpperCase());
|
|
2291
|
+
return l >= 0.7 ? "light" : l <= 0.3 ? "dark" : "mid";
|
|
2292
|
+
}));
|
|
2293
|
+
if (bands.has("dark") && !bands.has("light")) ideas.push({ idea: "near-black grounds with one saturated accent; keep body text light and verify 4.5:1", status: "suggested", basis: "observed palette skews dark" });
|
|
2294
|
+
if (bands.has("light") && !bands.has("dark")) ideas.push({ idea: "light grounds with dark ink; reserve saturated hues for CTAs and focus rings", status: "suggested", basis: "observed palette skews light" });
|
|
2295
|
+
const els = (detail.elements || []).join(" ").toLowerCase();
|
|
2296
|
+
if (/hero/.test(els)) ideas.push({ idea: "full-viewport hero with a single primary CTA above the fold", status: "suggested", basis: "observed element: hero" });
|
|
2297
|
+
if (/pricing|faq/.test(els)) ideas.push({ idea: "comparison table for pricing, accordion for faq — one pattern per section", status: "suggested", basis: "observed elements" });
|
|
2298
|
+
if (/testimonial|review/.test(els)) ideas.push({ idea: "social proof as a logo strip plus 2–3 short quotes, not a carousel", status: "suggested", basis: "observed elements" });
|
|
2299
|
+
const techs = (detail.technologies || []).join(" ").toLowerCase();
|
|
2300
|
+
if (/gsap|scrolltrigger/.test(techs)) ideas.push({ idea: "scroll reveals under 500ms with scrub only on pinned storytelling", status: "suggested", basis: "observed technology: gsap" });
|
|
2301
|
+
if (/webgl|three/.test(techs)) ideas.push({ idea: "keep WebGL to one hero canvas with a static-image fallback and reduced-motion off-switch", status: "suggested", basis: "observed technology: webgl" });
|
|
2302
|
+
return ideas.slice(0, 6);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// ---- Bounded body reader ----
|
|
2306
|
+
// Reads at most maxBytes from a fetch Response without trusting
|
|
2307
|
+
// content-length: a streaming read stops as soon as the threshold is
|
|
2308
|
+
// crossed, so a missing or lying header cannot balloon memory. Falls back
|
|
2309
|
+
// to res.text() (with a size post-check) when the response exposes no byte
|
|
2310
|
+
// stream, which keeps plain test doubles working.
|
|
2311
|
+
async function awardReadBoundedBody(res, maxBytes, label = "response") {
|
|
2312
|
+
if (!res) throw awardUpstream(label + " has no response body");
|
|
2313
|
+
const len = res.headers && typeof res.headers.get === "function" ? Number(res.headers.get("content-length")) : NaN;
|
|
2314
|
+
if (Number.isFinite(len) && len > maxBytes) throw awardUpstream(label + " exceeds " + maxBytes + " bytes");
|
|
2315
|
+
const body = res.body;
|
|
2316
|
+
if (body && typeof body.getReader === "function") {
|
|
2317
|
+
const reader = body.getReader();
|
|
2318
|
+
const chunks = [];
|
|
2319
|
+
let total = 0;
|
|
2320
|
+
let oversize = false;
|
|
2321
|
+
try {
|
|
2322
|
+
for (;;) {
|
|
2323
|
+
const step = await reader.read();
|
|
2324
|
+
if (!step || step.done) break;
|
|
2325
|
+
const value = step.value;
|
|
2326
|
+
if (value === undefined || value === null) continue;
|
|
2327
|
+
total += typeof value === "string" ? value.length : (value.byteLength || 0);
|
|
2328
|
+
if (total > maxBytes) { oversize = true; throw awardUpstream(label + " exceeds " + maxBytes + " bytes"); }
|
|
2329
|
+
chunks.push(value);
|
|
2330
|
+
}
|
|
2331
|
+
} finally {
|
|
2332
|
+
if (oversize) { try { await reader.cancel(); } catch { /* stream already errored */ } }
|
|
2333
|
+
}
|
|
2334
|
+
const decoder = new TextDecoder("utf-8");
|
|
2335
|
+
let text = "";
|
|
2336
|
+
for (const c of chunks) text += typeof c === "string" ? c : decoder.decode(c, { stream: true });
|
|
2337
|
+
return text + decoder.decode();
|
|
2338
|
+
}
|
|
2339
|
+
const text = await res.text();
|
|
2340
|
+
if (typeof text === "string" && text.length > maxBytes) throw awardUpstream(label + " exceeds " + maxBytes + " bytes");
|
|
2341
|
+
return text || "";
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// ---- Polite fetch client (1 req/s, bounded, distinct errors) ----
|
|
2345
|
+
function createAwardClient({ fetchImpl, timeoutMs = AWARD_TIMEOUT_MS, rateMs = AWARD_RATE_MS, userAgent = AWARD_USER_AGENT } = {}) {
|
|
2346
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
2347
|
+
if (typeof fetchFn !== "function") throw new Error("fetchImpl must be a function");
|
|
2348
|
+
let last = 0;
|
|
2349
|
+
let chain = Promise.resolve();
|
|
2350
|
+
const acquire = () => {
|
|
2351
|
+
const next = chain.then(async () => {
|
|
2352
|
+
const wait = last + rateMs - Date.now();
|
|
2353
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
2354
|
+
last = Date.now();
|
|
2355
|
+
});
|
|
2356
|
+
chain = next.catch(() => {});
|
|
2357
|
+
return next;
|
|
2358
|
+
};
|
|
2359
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2360
|
+
const isRedirect = (status) => status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
2361
|
+
async function getAwardHtml(pathOrUrl) {
|
|
2362
|
+
const path = awardValidateAwardPath(pathOrUrl);
|
|
2363
|
+
const startUrl = AWARD_BASE_URL + path;
|
|
2364
|
+
let url = startUrl;
|
|
2365
|
+
let retried = false; // single retry shared by 5xx and network failures
|
|
2366
|
+
let redirects = 0; // redirects do not consume the retry budget
|
|
2367
|
+
for (;;) {
|
|
2368
|
+
await acquire();
|
|
2369
|
+
const ctrl = new AbortController();
|
|
2370
|
+
let timedOut = false;
|
|
2371
|
+
// One deadline covers connect AND body read: a stalled stream aborts too.
|
|
2372
|
+
const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
|
|
2373
|
+
let res = null;
|
|
2374
|
+
let outcome = null;
|
|
2375
|
+
try {
|
|
2376
|
+
try {
|
|
2377
|
+
res = await fetchFn(url, { headers: { "user-agent": userAgent, accept: "text/html,*/*" }, redirect: "manual", signal: ctrl.signal });
|
|
2378
|
+
} catch (e) {
|
|
2379
|
+
outcome = { kind: timedOut ? "timeout" : "network", error: e };
|
|
2380
|
+
}
|
|
2381
|
+
if (!outcome) {
|
|
2382
|
+
const status = res.status;
|
|
2383
|
+
if (isRedirect(status)) {
|
|
2384
|
+
const loc = res.headers && typeof res.headers.get === "function" ? res.headers.get("location") : null;
|
|
2385
|
+
redirects += 1;
|
|
2386
|
+
if (!loc) outcome = { kind: "throw", error: awardUpstream("redirect without location for " + url) };
|
|
2387
|
+
else if (redirects > 5) outcome = { kind: "throw", error: awardUpstream("too many redirects fetching " + startUrl) };
|
|
2388
|
+
else {
|
|
2389
|
+
// Per-hop re-validation: fresh SSRF classification + host allow-list.
|
|
2390
|
+
try {
|
|
2391
|
+
const next = awardValidateHttpUrl(new URL(loc, url).toString());
|
|
2392
|
+
const host = new URL(next).hostname.toLowerCase();
|
|
2393
|
+
if (!AWARD_ALLOWED_HOSTS.includes(host)) throw awardUpstream("redirect leaves award hosts: " + host);
|
|
2394
|
+
url = next;
|
|
2395
|
+
outcome = { kind: "redirect" };
|
|
2396
|
+
} catch (e) {
|
|
2397
|
+
outcome = { kind: "throw", error: e && e.code ? e : awardUpstream("bad redirect for " + url) };
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
} else if (status === 404) {
|
|
2401
|
+
outcome = { kind: "throw", error: awardNotFound("awwwards page not found: " + url) };
|
|
2402
|
+
} else if (status === 403 || status === 429) {
|
|
2403
|
+
const e = awardUpstream("awwwards is blocking requests (HTTP " + status + " on " + url + "). Try again later; blocks are never retried.");
|
|
2404
|
+
e.blocked = true;
|
|
2405
|
+
e.status = status;
|
|
2406
|
+
outcome = { kind: "throw", error: e };
|
|
2407
|
+
} else if (status >= 500 && status < 600) {
|
|
2408
|
+
outcome = retried
|
|
2409
|
+
? { kind: "throw", error: awardUpstream("transient awwwards status " + status + " on " + url) }
|
|
2410
|
+
: { kind: "retry" };
|
|
2411
|
+
} else if (!res.ok) {
|
|
2412
|
+
outcome = { kind: "throw", error: awardUpstream("awwwards HTTP " + status + " on " + url) };
|
|
2413
|
+
} else {
|
|
2414
|
+
try {
|
|
2415
|
+
const html = await awardReadBoundedBody(res, AWARD_MAX_BYTES, "awwwards page");
|
|
2416
|
+
outcome = { kind: "html", html };
|
|
2417
|
+
res = null; // body fully consumed — nothing left to drain
|
|
2418
|
+
} catch (e) {
|
|
2419
|
+
outcome = { kind: "throw", error: e && e.code ? e : awardUpstream((timedOut ? "timeout fetching " : "awwwards body read failed for ") + url) };
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
} finally {
|
|
2424
|
+
clearTimeout(timer);
|
|
2425
|
+
if (res && !res.bodyUsed) { try { if (res.body && typeof res.body.cancel === "function") await res.body.cancel(); } catch { /* socket already released */ } }
|
|
2426
|
+
}
|
|
2427
|
+
if (outcome.kind === "html") return { html: outcome.html, finalUrl: url };
|
|
2428
|
+
if (outcome.kind === "redirect") continue;
|
|
2429
|
+
if (timedOut) throw awardUpstream("timeout fetching " + url);
|
|
2430
|
+
if (outcome.kind === "retry") {
|
|
2431
|
+
retried = true;
|
|
2432
|
+
await sleep(400);
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
if (outcome.kind === "network") {
|
|
2436
|
+
if (!retried) { retried = true; await sleep(300); continue; }
|
|
2437
|
+
throw awardUpstream("network error fetching " + url + ": " + String((outcome.error && outcome.error.message) || outcome.error).slice(0, 200));
|
|
2438
|
+
}
|
|
2439
|
+
throw outcome.error;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
return { getAwardHtml };
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// awardStaticSummary: one-line human rendering of a static-motion record.
|
|
2446
|
+
// Lives with the data shape (locality); the blueprint handler just maps it.
|
|
2447
|
+
function awardStaticSummary(slug, st) {
|
|
2448
|
+
if (!st || st.status !== "static-markup") return null;
|
|
2449
|
+
return slug + ": " + [st.libs.length ? st.libs.join("+") : "vanilla", st.keyframes ? st.keyframes + " keyframes" : null, st.transitions ? st.transitions + " transitions" : null, st.canvases ? st.canvases + " canvas" : null, st.scrollCues.length ? "cues(" + st.scrollCues.slice(0, 4).join(",") + ")" : null].filter(Boolean).join(", ");
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// ---- Static motion cues: real markup evidence without a browser ----
|
|
2453
|
+
// Fetches a public reference URL (bounded, SSRF-guarded) and scans for
|
|
2454
|
+
// animation library scripts, @keyframes, transitions, scroll cues, and
|
|
2455
|
+
// media elements. Status is ALWAYS "static-markup" — this is observed page
|
|
2456
|
+
// source, never runtime behavior: ScrollTrigger counts, scrub ratios, and
|
|
2457
|
+
// easing vocabularies still require a recorded browser study. Failures
|
|
2458
|
+
// return {status:"unavailable", reason} instead of throwing (except empty
|
|
2459
|
+
// input, which is a contract violation → BAD_ARGS).
|
|
2460
|
+
const AWARD_STATIC_LIBS = [
|
|
2461
|
+
{ lib: "gsap", res: [/gsap(\.min)?\.js/i, /scrolltrigger/i, /gsap\./i] },
|
|
2462
|
+
{ lib: "lenis", res: [/lenis/i] },
|
|
2463
|
+
{ lib: "locomotive", res: [/locomotive/i] },
|
|
2464
|
+
{ lib: "swiper", res: [/swiper/i] },
|
|
2465
|
+
{ lib: "framer", res: [/framer/i] },
|
|
2466
|
+
{ lib: "lottie", res: [/lottie/i, /dotlottie/i] },
|
|
2467
|
+
{ lib: "three", res: [/three(\.min)?\.js/i, /three\./i] },
|
|
2468
|
+
{ lib: "pixi", res: [/pixi/i] },
|
|
2469
|
+
{ lib: "aos", res: [/aos(\.min)?\.js/i, /data-aos/i] },
|
|
2470
|
+
{ lib: "barba", res: [/barba/i] },
|
|
2471
|
+
];
|
|
2472
|
+
const AWARD_STATIC_CUES = ["data-aos", "data-scroll", "scrolltrigger", "sticky", "parallax", "marquee", "reveal", "loader", "preloader"];
|
|
2473
|
+
// ---- Guarded HTML fetch (shared by static motion + audit static fallback) ----
|
|
2474
|
+
// Manual redirects (≤3 hops) with per-hop URL classification and a
|
|
2475
|
+
// best-effort DNS guard, one deadline per hop covering the body read, and a
|
|
2476
|
+
// streaming bounded read. Returns {ok:true, url, finalUrl, html} or
|
|
2477
|
+
// {ok:false, reason, status?}; only a contract violation (empty input)
|
|
2478
|
+
// throws.
|
|
2479
|
+
async function awardFetchGuardedHtml(rawUrl, { fetchImpl, lookup = null, timeoutMs = AWARD_TIMEOUT_MS, maxBytes = 1500000, allowLocal = false, label = "page" } = {}) {
|
|
2480
|
+
if (typeof rawUrl !== "string" || !rawUrl.trim()) throw awardErr("BAD_ARGS", "url must be a non-empty string");
|
|
2481
|
+
let target;
|
|
2482
|
+
try { target = awardValidateHttpUrl(rawUrl, { allowLocal }); }
|
|
2483
|
+
catch (e) { return { ok: false, reason: (e && e.message) || String(e) }; }
|
|
2484
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
2485
|
+
if (typeof fetchFn !== "function") return { ok: false, reason: "no fetch available" };
|
|
2486
|
+
const guard = async (u) => {
|
|
2487
|
+
try {
|
|
2488
|
+
const dns = await awardCheckHostResolution(u, { lookup, allowLocal });
|
|
2489
|
+
return dns.blocked ? "host resolves to a blocked address (" + dns.blockedAddresses.join(", ") + ")" : null;
|
|
2490
|
+
} catch { return null; }
|
|
2491
|
+
};
|
|
2492
|
+
const firstBlock = await guard(target);
|
|
2493
|
+
if (firstBlock) return { ok: false, reason: firstBlock };
|
|
2494
|
+
let url = target;
|
|
2495
|
+
for (let hop = 0; hop <= 3; hop++) {
|
|
2496
|
+
const ctrl = new AbortController();
|
|
2497
|
+
let timedOut = false;
|
|
2498
|
+
const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
|
|
2499
|
+
let res = null;
|
|
2500
|
+
try {
|
|
2501
|
+
try {
|
|
2502
|
+
res = await fetchFn(url, { headers: { "user-agent": AWARD_USER_AGENT, accept: "text/html,*/*" }, redirect: "manual", signal: ctrl.signal });
|
|
2503
|
+
} catch (e) {
|
|
2504
|
+
return { ok: false, reason: timedOut ? "timeout fetching " + url : "network error: " + String((e && e.message) || e).slice(0, 120) };
|
|
2505
|
+
}
|
|
2506
|
+
if (res.status >= 300 && res.status < 400) {
|
|
2507
|
+
const loc = res.headers && typeof res.headers.get === "function" ? res.headers.get("location") : null;
|
|
2508
|
+
if (!loc) return { ok: false, reason: "redirect without location for " + url, status: res.status };
|
|
2509
|
+
if (hop === 3) return { ok: false, reason: "too many redirects for " + target };
|
|
2510
|
+
let next;
|
|
2511
|
+
try { next = awardValidateHttpUrl(new URL(loc, url).toString(), { allowLocal }); }
|
|
2512
|
+
catch (e) { return { ok: false, reason: "redirect blocked: " + ((e && e.message) || e), status: res.status }; }
|
|
2513
|
+
const hopBlock = await guard(next);
|
|
2514
|
+
if (hopBlock) return { ok: false, reason: hopBlock, status: res.status };
|
|
2515
|
+
url = next;
|
|
2516
|
+
continue;
|
|
2517
|
+
}
|
|
2518
|
+
if (res.status === 404) return { ok: false, reason: "HTTP 404 for " + url, status: 404 };
|
|
2519
|
+
if (!res.ok) return { ok: false, reason: "HTTP " + res.status + " for " + url, status: res.status };
|
|
2520
|
+
let html;
|
|
2521
|
+
try {
|
|
2522
|
+
html = await awardReadBoundedBody(res, maxBytes, label);
|
|
2523
|
+
res = null;
|
|
2524
|
+
} catch (e) {
|
|
2525
|
+
return { ok: false, reason: timedOut ? "timeout fetching " + url : String((e && e.message) || e).slice(0, 160) };
|
|
2526
|
+
}
|
|
2527
|
+
if (!html || !html.length) return { ok: false, reason: "empty body for " + url, status: 200 };
|
|
2528
|
+
return { ok: true, url: target, finalUrl: url, html };
|
|
2529
|
+
} finally {
|
|
2530
|
+
clearTimeout(timer);
|
|
2531
|
+
if (res && !res.bodyUsed) { try { if (res.body && typeof res.body.cancel === "function") await res.body.cancel(); } catch { /* socket already released */ } }
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
return { ok: false, reason: "redirect loop for " + target };
|
|
2535
|
+
}
|
|
2536
|
+
async function awardStaticMotion(rawUrl, { fetchImpl, lookup = null, timeoutMs = AWARD_TIMEOUT_MS, maxBytes = 1500000, allowLocal = false } = {}) {
|
|
2537
|
+
if (typeof rawUrl !== "string" || !rawUrl.trim()) throw awardErr("BAD_ARGS", "url must be a non-empty string");
|
|
2538
|
+
const fail = (reason) => ({ status: "unavailable", url: String(rawUrl).slice(0, 200), reason: String(reason).slice(0, 200) });
|
|
2539
|
+
const got = await awardFetchGuardedHtml(rawUrl, { fetchImpl, lookup, timeoutMs, maxBytes, allowLocal, label: "static page" });
|
|
2540
|
+
if (!got.ok) return fail(got.reason);
|
|
2541
|
+
const html = got.html;
|
|
2542
|
+
const src = html.slice(0, maxBytes);
|
|
2543
|
+
const count = (re) => Math.min(999, (src.match(re) || []).length);
|
|
2544
|
+
const libs = AWARD_STATIC_LIBS.filter(({ res }) => res.some((re) => re.test(src))).map(({ lib }) => lib);
|
|
2545
|
+
return {
|
|
2546
|
+
status: "static-markup", url: got.url,
|
|
2547
|
+
libs, keyframes: count(/@keyframes\s/gi), animations: count(/animation\s*:/gi), transitions: count(/transition\s*:/gi),
|
|
2548
|
+
canvases: count(/<canvas/gi), videos: count(/<video/gi),
|
|
2549
|
+
scrollCues: AWARD_STATIC_CUES.filter((c) => src.toLowerCase().includes(c)),
|
|
2550
|
+
reducedMotion: /prefers-reduced-motion/i.test(src),
|
|
2551
|
+
bytes: src.length,
|
|
2552
|
+
evidence: "static page markup (not runtime observation): record a browser study for trigger counts and easing vocab",
|
|
2553
|
+
fetchedAt: new Date().toISOString(),
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// ---- Design-brief builder (pure): observed facts vs patterns vs proposals ----
|
|
2558
|
+
const AWARD_BLUEPRINT_VERSION = 1;
|
|
2559
|
+
function awardFreq(rows) {
|
|
2560
|
+
const by = new Map();
|
|
2561
|
+
for (const r of rows) for (const v of new Set((r.values || []).map((x) => String(x).toLowerCase()).filter(Boolean))) {
|
|
2562
|
+
if (!by.has(v)) by.set(v, new Set());
|
|
2563
|
+
by.get(v).add(r.slug);
|
|
2564
|
+
}
|
|
2565
|
+
return [...by].map(([value, slugs]) => ({ value, frequency: slugs.size, sourceSlugs: [...slugs].sort() }))
|
|
2566
|
+
.sort((a, b) => (b.frequency - a.frequency) || a.value.localeCompare(b.value));
|
|
2567
|
+
}
|
|
2568
|
+
function awardLuminance(hex) {
|
|
2569
|
+
try {
|
|
2570
|
+
const ch = hex.slice(1).match(/.{2}/g).map((p) => parseInt(p, 16) / 255).map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
|
2571
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
2572
|
+
} catch { return 0.5; }
|
|
2573
|
+
}
|
|
2574
|
+
// Technology families for compare: "WebGL" and "Three.js" are the same stack
|
|
2575
|
+
// choice spelled differently. Canonicalization applies ONLY to the family
|
|
2576
|
+
// overlap signal — per-site raw values stay verbatim.
|
|
2577
|
+
const AWARD_TECH_FAMILIES = [
|
|
2578
|
+
{ family: "webgl", members: ["webgl", "three", "three.js", "threejs", "babylon", "pixi", "glsl", "shader"] },
|
|
2579
|
+
{ family: "gsap", members: ["gsap", "greensock", "scrolltrigger"] },
|
|
2580
|
+
{ family: "lenis", members: ["lenis", "studio-freight"] },
|
|
2581
|
+
{ family: "react", members: ["react", "next", "next.js", "gatsby"] },
|
|
2582
|
+
{ family: "tailwind", members: ["tailwind", "tailwindcss"] },
|
|
2583
|
+
];
|
|
2584
|
+
function awardTechFamily(tech) {
|
|
2585
|
+
const t = String(tech || "").toLowerCase().trim();
|
|
2586
|
+
for (const f of AWARD_TECH_FAMILIES) if (f.family === t || f.members.includes(t)) return f.family;
|
|
2587
|
+
return t;
|
|
2588
|
+
}
|
|
2589
|
+
function awardCompareSites(entries) {
|
|
2590
|
+
// entries: [{ slug, detail|null, motion|null }]
|
|
2591
|
+
const usable = entries.filter((e) => e.detail);
|
|
2592
|
+
const tech = awardFreq(usable.map((e) => ({ slug: e.slug, values: e.detail.technologies || [] })));
|
|
2593
|
+
const techFamilies = awardFreq(usable.map((e) => ({ slug: e.slug, values: (e.detail.technologies || []).map(awardTechFamily) })));
|
|
2594
|
+
const elements = awardFreq(usable.map((e) => ({ slug: e.slug, values: e.detail.elements || [] })));
|
|
2595
|
+
const palette = awardFreq(usable.map((e) => ({ slug: e.slug, values: e.detail.palette || [] })));
|
|
2596
|
+
// Fuzzy element overlap: "Hero Section" and "hero banner" share the keyword
|
|
2597
|
+
// hero even when full strings differ — reported as keyword overlap, and the
|
|
2598
|
+
// exact strings stay visible per site so nothing is overstated.
|
|
2599
|
+
const elemKeywords = awardFreq(usable.map((e) => {
|
|
2600
|
+
const words = new Set();
|
|
2601
|
+
for (const el of (e.detail.elements || [])) for (const w of String(el).toLowerCase().split(/[^a-z0-9]+/)) if (w.length > 3) words.add(w);
|
|
2602
|
+
return { slug: e.slug, values: [...words] };
|
|
2603
|
+
}));
|
|
2604
|
+
const bandOf = (hex) => (awardLuminance(String(hex).toUpperCase()) >= 0.7 ? "light" : awardLuminance(String(hex).toUpperCase()) <= 0.3 ? "dark" : "mid");
|
|
2605
|
+
const bands = usable.map((e) => ({ slug: e.slug, bands: [...new Set((e.detail.palette || []).map(bandOf))] }));
|
|
2606
|
+
const bandVotes = {};
|
|
2607
|
+
for (const b of bands) for (const x of b.bands) bandVotes[x] = (bandVotes[x] || 0) + 1;
|
|
2608
|
+
const direction = [];
|
|
2609
|
+
const topBand = Object.entries(bandVotes).sort((a, b) => b[1] - a[1])[0];
|
|
2610
|
+
if (topBand && topBand[1] >= 2) direction.push("all refs skew " + topBand[0] + " — keep that value range for backgrounds");
|
|
2611
|
+
const sharedTech = tech.filter((t) => t.frequency >= 2);
|
|
2612
|
+
const sharedTechFamilies = techFamilies.filter((t) => t.frequency >= 2);
|
|
2613
|
+
if (sharedTech.length) direction.push("shared stack (" + sharedTech.slice(0, 3).map((t) => t.value).join(", ") + ") — safe default for the build");
|
|
2614
|
+
else if (sharedTechFamilies.length) direction.push("shared stack family (" + sharedTechFamilies.slice(0, 3).map((t) => t.value).join(", ") + ") — same capability, different spelling — safe default for the build");
|
|
2615
|
+
const per = entries.map((e) => ({
|
|
2616
|
+
slug: e.slug,
|
|
2617
|
+
title: (e.detail && e.detail.title) || e.slug,
|
|
2618
|
+
sourceUrl: (e.detail && e.detail.sourceUrl) || awardSiteUrl(e.slug),
|
|
2619
|
+
palette: (e.detail && e.detail.palette) || [],
|
|
2620
|
+
luminanceBands: (e.detail && [...new Set((e.detail.palette || []).map(bandOf))]) || [],
|
|
2621
|
+
technologies: (e.detail && e.detail.technologies) || [],
|
|
2622
|
+
technologiesInferred: !!(e.detail && e.detail.technologiesInferred),
|
|
2623
|
+
elements: (e.detail && e.detail.elements) || [],
|
|
2624
|
+
awards: (e.detail && e.detail.awards) || [],
|
|
2625
|
+
score: (e.detail && e.detail.score) ?? null,
|
|
2626
|
+
motion: e.motion ? { libs: e.motion.stack.libs, scrollModel: e.motion.stack.scrollModel } : null,
|
|
2627
|
+
gaps: [...(e.detail ? [] : ["detail unavailable"]), ...(e.motion ? [] : ["motion not recorded"])],
|
|
2628
|
+
}));
|
|
2629
|
+
return { per, sharedTech, sharedTechFamilies, sharedElements: elements.filter((t) => t.frequency >= 2), sharedElementKeywords: elemKeywords.filter((t) => t.frequency >= 2), sharedPalette: palette.filter((t) => t.frequency >= 2), sharedBands: bands, directionHints: direction };
|
|
2630
|
+
}
|
|
2631
|
+
function awardBuildBlueprint({ goal, refs, details, auraMatches, structureNotes, motionNotes, motionRecorded = false, constraints = [], technology = null, visualDirection = null, responsive = null, requestedPalette = [], gaps: extraGaps = [] }) {
|
|
2632
|
+
if (!goal || !String(goal).trim()) throw awardBad("goal is required");
|
|
2633
|
+
const usable = (details || []).filter((d) => d && d.detail);
|
|
2634
|
+
const threshold = usable.length === 2 ? 2 : usable.length >= 3 ? Math.ceil(usable.length / 2) : Infinity;
|
|
2635
|
+
const tech = awardFreq(usable.map((d) => ({ slug: d.slug, values: d.detail.technologies || [] })));
|
|
2636
|
+
const elements = awardFreq(usable.map((d) => ({ slug: d.slug, values: d.detail.elements || [] })));
|
|
2637
|
+
const paletteRaw = awardFreq(usable.map((d) => ({ slug: d.slug, values: (d.detail.palette || []).map((c) => String(c).toUpperCase()) })));
|
|
2638
|
+
const palette = paletteRaw.map((p) => ({ ...p, luminanceBand: awardLuminance(p.value) >= 0.7 ? "light" : awardLuminance(p.value) <= 0.3 ? "dark" : "mid" })); const tagged = [...tech.map((s) => ({ ...s, kind: "technology" })), ...elements.map((s) => ({ ...s, kind: "element" })), ...palette.map((s) => ({ ...s, kind: "palette" }))];
|
|
2639
|
+
const shared = tagged.filter((s) => s.frequency >= threshold).sort((a, b) => (b.frequency - a.frequency) || a.value.localeCompare(b.value));
|
|
2640
|
+
const outliers = usable.length >= 2 ? tagged.filter((s) => s.frequency === 1).sort((a, b) => a.value.localeCompare(b.value)).slice(0, 12) : [];
|
|
2641
|
+
const gaps = [];
|
|
2642
|
+
for (const r of (refs || [])) {
|
|
2643
|
+
const hit = usable.find((d) => d.slug === r);
|
|
2644
|
+
if (!hit) gaps.push(r + ": reference detail unavailable (upstream fetch failed or slug unknown)");
|
|
2645
|
+
}
|
|
2646
|
+
for (const g of (extraGaps || [])) if (!gaps.includes(g)) gaps.push(g);
|
|
2647
|
+
if (!usable.length) gaps.push("no reference details available; brief is goal-only and low confidence");
|
|
2648
|
+
const sections = AWARD_SECTIONS;
|
|
2649
|
+
const lines = [];
|
|
2650
|
+
lines.push("# Design brief — " + String(goal).slice(0, 120));
|
|
2651
|
+
lines.push("");
|
|
2652
|
+
lines.push("_Goal:_ " + goal);
|
|
2653
|
+
if (technology) lines.push("_Stack:_ " + technology);
|
|
2654
|
+
if (visualDirection) lines.push("_Direction:_ " + visualDirection);
|
|
2655
|
+
if (responsive) lines.push("_Responsive:_ " + responsive);
|
|
2656
|
+
lines.push("");
|
|
2657
|
+
lines.push("## References (evidence)");
|
|
2658
|
+
for (const d of (details || [])) {
|
|
2659
|
+
const t = d.detail;
|
|
2660
|
+
lines.push("- `" + d.slug + "` — " + ((t && t.title) || d.slug) + " — " + ((t && t.sourceUrl) || awardSiteUrl(d.slug)) + (t ? "" : " (detail unavailable)"));
|
|
2661
|
+
}
|
|
2662
|
+
lines.push("");
|
|
2663
|
+
lines.push("## Direction");
|
|
2664
|
+
lines.push(shared.length ? "Build on what " + shared.length + " shared signals agree on; treat the outliers below as one-site flavor, not rules." : "No cross-reference consensus; follow the goal and the strongest single reference, and mark choices as proposed.");
|
|
2665
|
+
if (shared.length) { lines.push(""); lines.push("### Shared patterns (observed)"); for (const s of shared.slice(0, 12)) lines.push("- " + s.kind + " `" + s.value + "` — " + s.frequency + "/" + usable.length + " refs (" + s.sourceSlugs.join(", ") + ")"); }
|
|
2666
|
+
if (outliers.length) { lines.push(""); lines.push("### One-site details (do not generalize)"); for (const s of outliers.slice(0, 8)) lines.push("- " + s.kind + " `" + s.value + "` — only in " + s.sourceSlugs.join(", ")); }
|
|
2667
|
+
if (palette.length) { lines.push(""); lines.push("### Palette evidence"); for (const p of palette.slice(0, 8)) lines.push("- `" + p.value + "` (" + p.luminanceBand + ") — " + p.frequency + "/" + usable.length + " refs"); }
|
|
2668
|
+
let tokenGround = null, tokenInk = null, tokenAccents = [];
|
|
2669
|
+
if (requestedPalette.length) {
|
|
2670
|
+
lines.push("");
|
|
2671
|
+
lines.push("### Requested palette (proposed, not observed)");
|
|
2672
|
+
lines.push("Named in your goal/direction — apply as CSS tokens; verify contrast before shipping:");
|
|
2673
|
+
for (const c of requestedPalette) lines.push("- `" + c + "` (" + (awardLuminance(c) >= 0.7 ? "light" : awardLuminance(c) <= 0.3 ? "dark" : "mid") + ", proposed)");
|
|
2674
|
+
const observedBands = [...new Set(palette.map((p) => p.luminanceBand))];
|
|
2675
|
+
if (observedBands.length) lines.push("- Refs skew " + observedBands.join("/") + "; keep their layout rhythm, recolor to the proposed tokens above.");
|
|
2676
|
+
// Paste-ready starter: darkest requested hex = ground, lightest = text,
|
|
2677
|
+
// the rest = accents — all labeled proposed, contrast to be verified.
|
|
2678
|
+
const lum = requestedPalette.map((c) => ({ c, l: awardLuminance(c) })).sort((a, b) => a.l - b.l);
|
|
2679
|
+
let ground = lum[0].c, ink = lum[lum.length - 1].c, inkDefaulted = false;
|
|
2680
|
+
if (lum.length === 1) { ink = lum[0].l <= 0.3 ? "#F5F5F5" : "#111318"; inkDefaulted = true; }
|
|
2681
|
+
tokenGround = ground; tokenInk = ink; tokenAccents = lum.slice(1, -1).map((x) => x.c);
|
|
2682
|
+
const accents = lum.slice(1, -1).map((x) => x.c);
|
|
2683
|
+
lines.push("");
|
|
2684
|
+
lines.push("```css");
|
|
2685
|
+
lines.push("/* proposed tokens — paste into styles/tokens.css first */");
|
|
2686
|
+
lines.push(":root {");
|
|
2687
|
+
lines.push(" --aw-ground: " + ground + "; --aw-ink: " + ink + ";" + (inkDefaulted ? " /* ink defaulted for contrast — verify */" : ""));
|
|
2688
|
+
for (let i = 0; i < tokenAccents.length; i++) lines.push(" --aw-accent-" + (i + 1) + ": " + tokenAccents[i] + ";");
|
|
2689
|
+
lines.push(" --aw-glass: color-mix(in srgb, var(--aw-ink) 8%, transparent);");
|
|
2690
|
+
lines.push("}");
|
|
2691
|
+
lines.push("```");
|
|
2692
|
+
}
|
|
2693
|
+
lines.push("");
|
|
2694
|
+
lines.push("## Aura matches (free-only, real catalogue rows)");
|
|
2695
|
+
const matches = (auraMatches && auraMatches.length ? auraMatches : []);
|
|
2696
|
+
if (!matches.length) lines.push("- Catalogue lookup unavailable or matched nothing — proceed with tokens only; do not invent component IDs.");
|
|
2697
|
+
for (const m of matches.slice(0, 10)) lines.push("- [" + m.section + "] `" + m.componentTitle + "` (" + m.componentId + ") — " + m.pageUrl + " — " + m.why + (m.matchEvidence && m.matchEvidence.fallback ? " (via fallback " + m.matchEvidence.fallback + " — verify the fit before pasting)" : "") + (m.editorial ? " [editorial pick]" : ""));
|
|
2698
|
+
lines.push("");
|
|
2699
|
+
lines.push("## Sections (proposed order)");
|
|
2700
|
+
sections.forEach((s, i) => lines.push((i + 1) + ". " + s));
|
|
2701
|
+
lines.push("");
|
|
2702
|
+
lines.push("## Motion guidance (evidence-backed only)");
|
|
2703
|
+
lines.push(motionNotes || "No recorded motion for these refs; keep entrances <500ms, honor prefers-reduced-motion, add scroll reveals only if a study supports them.");
|
|
2704
|
+
if (structureNotes) lines.push("");
|
|
2705
|
+
if (structureNotes) lines.push("## Structure evidence");
|
|
2706
|
+
if (structureNotes) lines.push(structureNotes);
|
|
2707
|
+
lines.push("");
|
|
2708
|
+
lines.push("## Accessibility & performance");
|
|
2709
|
+
lines.push("- Keyboard: all interactive sections reachable; visible focus; no hover-only content.");
|
|
2710
|
+
lines.push("- Contrast: verify palette pairs at 4.5:1 before shipping.");
|
|
2711
|
+
lines.push("- Reduced motion: disable/parallax-off under prefers-reduced-motion; content stays readable without JS.");
|
|
2712
|
+
lines.push("- Performance: prefer system/Aura assets; lazy-load below-fold media; keep animation <500ms.");
|
|
2713
|
+
if (constraints.length) { lines.push(""); lines.push("## Constraints"); for (const c of constraints) lines.push("- Honor constraint: " + c); }
|
|
2714
|
+
lines.push("");
|
|
2715
|
+
lines.push("## Checklist");
|
|
2716
|
+
lines.push("- [ ] Tokens first (palette/type/spacing as CSS vars)");
|
|
2717
|
+
lines.push("- [ ] Sections in the order above, responsive at 390/768/1024+");
|
|
2718
|
+
lines.push("- [ ] Aura components pasted per match list, CSS scoped");
|
|
2719
|
+
lines.push("- [ ] Motion only from evidence; reduced-motion path tested");
|
|
2720
|
+
lines.push("- [ ] Contrast + keyboard pass; no copied third-party source");
|
|
2721
|
+
lines.push("");
|
|
2722
|
+
lines.push("## Evidence gaps");
|
|
2723
|
+
if (gaps.length) { for (const g of gaps) lines.push("- " + g); }
|
|
2724
|
+
else lines.push("- None material: refs, motion/structure evidence as cited above, catalogue matches listed.");
|
|
2725
|
+
// Evidence-scored confidence: base from usable ref count, +1 per
|
|
2726
|
+
// independent axis actually present (matched sections, RECORDED motion,
|
|
2727
|
+
// measured structure, fully-parsed tech). Static markup cues describe the
|
|
2728
|
+
// page source, not observed runtime behavior, so they never add a point.
|
|
2729
|
+
// Thin evidence stays low; rich evidence may reach high — same honesty,
|
|
2730
|
+
// both directions.
|
|
2731
|
+
const why = [];
|
|
2732
|
+
let points = Math.min(usable.length, 3);
|
|
2733
|
+
why.push(usable.length + " usable ref" + (usable.length === 1 ? "" : "s"));
|
|
2734
|
+
const sectionHits = matches.filter((m) => m.section !== "tokens" && !m.editorial).length;
|
|
2735
|
+
if (sectionHits >= 4) { points++; why.push(sectionHits + " catalogue sections matched"); }
|
|
2736
|
+
if (motionRecorded) { points++; why.push("recorded motion evidence"); }
|
|
2737
|
+
if (structureNotes && structureNotes.includes("(live-dom)")) { points++; why.push("measured structure"); }
|
|
2738
|
+
if (usable.length >= 2 && usable.every((d) => (d.detail.technologies || []).length && !d.detail.technologiesInferred)) { points++; why.push("fully-parsed technologies (not inferred)"); }
|
|
2739
|
+
const confidence = points <= 1 ? "low" : points <= 3 ? "low-medium" : points === 4 ? "medium" : "high";
|
|
2740
|
+
const spec = {
|
|
2741
|
+
version: AWARD_BLUEPRINT_VERSION, goal,
|
|
2742
|
+
refs: (refs || []).map((slug) => {
|
|
2743
|
+
const hit = (details || []).find((d) => d.slug === slug);
|
|
2744
|
+
const st = hit && hit.motion_static ? hit.motion_static.status : "none";
|
|
2745
|
+
return { slug, sourceUrl: awardSiteUrl(slug), motionStatic: st };
|
|
2746
|
+
}),
|
|
2747
|
+
sharedPatterns: shared, outliers: outliers.slice(0, 12), palette,
|
|
2748
|
+
requestedPalette: requestedPalette.map((c) => ({ value: c, status: "proposed" })),
|
|
2749
|
+
tokens_css: requestedPalette.length ? (":root{--aw-ground:" + tokenGround + ";--aw-ink:" + tokenInk + ";" + tokenAccents.map((c, i) => "--aw-accent-" + (i + 1) + ":" + c).join(";") + ";}") : "",
|
|
2750
|
+
auraMatches: matches.slice(0, 10),
|
|
2751
|
+
sections, constraints,
|
|
2752
|
+
confidence, confidenceWhy: why,
|
|
2753
|
+
gaps,
|
|
2754
|
+
};
|
|
2755
|
+
return { markdown: lines.join("\n"), spec };
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// ---- awards-index.mjs ----
|
|
2759
|
+
// Module: awards-index — persistent local award-reference store.
|
|
2760
|
+
// File-based JSON (no sqlite dep): atomic writes, versioned schema,
|
|
2761
|
+
// corrupt recovery, stale-lock single-writer, resume checkpoints, bounded
|
|
2762
|
+
// growth, winner baselines, watches, motion records. No auto-crawl on boot.
|
|
2763
|
+
|
|
2764
|
+
const AWARD_INDEX_VERSION = 1;
|
|
2765
|
+
const AWARD_INDEX_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2766
|
+
const AWARD_LOCK_STALE_MS = 30 * 60 * 1000;
|
|
2767
|
+
const AWARD_MAX_SITES = 5000;
|
|
2768
|
+
const AWARD_MAX_ELEMENTS = 5000;
|
|
2769
|
+
const AWARD_MAX_MOTION = 200;
|
|
2770
|
+
const AWARD_FILES = ["sites.json", "elements.json", "taxonomy.json", "motion.json", "meta.json"];
|
|
2771
|
+
|
|
2772
|
+
function awardIdxBuiltins() {
|
|
2773
|
+
const g = globalThis.process && globalThis.process.getBuiltinModule ? globalThis.process.getBuiltinModule : null;
|
|
2774
|
+
if (typeof g !== "function") throw awardErr("UNSUPPORTED_OPERATION", "file persistence needs Node 22+ (process.getBuiltinModule)");
|
|
2775
|
+
const fs = g.call(globalThis.process, "node:fs");
|
|
2776
|
+
const path = g.call(globalThis.process, "node:path");
|
|
2777
|
+
const os = g.call(globalThis.process, "node:os");
|
|
2778
|
+
return { fs, path, os };
|
|
2779
|
+
}
|
|
2780
|
+
function awardResolveDir(override) {
|
|
2781
|
+
const { path, os } = awardIdxBuiltins();
|
|
2782
|
+
if (override && String(override).trim()) {
|
|
2783
|
+
const p = String(override);
|
|
2784
|
+
if (p.includes("..")) throw awardErr("BAD_ARGS", "awards dir must not contain ..");
|
|
2785
|
+
return p;
|
|
2786
|
+
}
|
|
2787
|
+
const env = globalThis.process && globalThis.process.env ? globalThis.process.env.AURA_AWARDS_DIR : null;
|
|
2788
|
+
if (env && String(env).trim()) {
|
|
2789
|
+
if (String(env).includes("..")) throw awardErr("BAD_ARGS", "AURA_AWARDS_DIR must not contain ..");
|
|
2790
|
+
return String(env);
|
|
2791
|
+
}
|
|
2792
|
+
return path.join(os.homedir(), ".aura-mcp", "awards");
|
|
2793
|
+
}
|
|
2794
|
+
function awardIdxBad(m) { return awardErr("BAD_ARGS", m); }
|
|
2795
|
+
// url watches may be stored canonically (slug) or as legacy raw records
|
|
2796
|
+
// (the old code stored the lowercased URL); both reduce to the same target
|
|
2797
|
+
// at match time so legacy entries keep working.
|
|
2798
|
+
function awardUrlWatchTarget(pattern) {
|
|
2799
|
+
try { return awardWatchTargetSlug(pattern); } catch { return String(pattern || "").trim().toLowerCase(); }
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
2803
|
+
const { fs, path } = awardIdxBuiltins();
|
|
2804
|
+
const root = awardResolveDir(dir);
|
|
2805
|
+
fs.mkdirSync(root, { recursive: true });
|
|
2806
|
+
fs.mkdirSync(path.join(root, "captures"), { recursive: true });
|
|
2807
|
+
|
|
2808
|
+
const fp = (name) => {
|
|
2809
|
+
if (!AWARD_FILES.includes(name) && name !== "lock.json") throw awardIdxBad("unknown index file: " + name);
|
|
2810
|
+
const full = path.join(root, name);
|
|
2811
|
+
if (path.relative(root, full).startsWith("..")) throw awardIdxBad("path traversal blocked");
|
|
2812
|
+
return full;
|
|
2813
|
+
};
|
|
2814
|
+
function readJson(name, fallback) {
|
|
2815
|
+
try {
|
|
2816
|
+
if (!fs.existsSync(fp(name))) return fallback;
|
|
2817
|
+
const raw = fs.readFileSync(fp(name), "utf8");
|
|
2818
|
+
const parsed = JSON.parse(raw);
|
|
2819
|
+
if (parsed && typeof parsed === "object" && parsed.version !== undefined && parsed.version !== AWARD_INDEX_VERSION && name !== "lock.json") {
|
|
2820
|
+
return { ...fallback, version: AWARD_INDEX_VERSION, migratedFrom: parsed.version, updatedAt: new Date(now()).toISOString() };
|
|
2821
|
+
}
|
|
2822
|
+
return parsed;
|
|
2823
|
+
} catch (e) {
|
|
2824
|
+
// Corrupt file: quarantine + safe empty (never throw away the session).
|
|
2825
|
+
try {
|
|
2826
|
+
const bad = fp(name) + ".corrupt-" + now();
|
|
2827
|
+
fs.renameSync(fp(name), bad);
|
|
2828
|
+
} catch { /* ignore */ }
|
|
2829
|
+
const err = awardErr("INDEX_CORRUPT", name + " was corrupt and was quarantined; index rebuilt empty (" + String((e && e.message) || e).slice(0, 120) + ")");
|
|
2830
|
+
return { ...fallback, corrupt: err.message, recoveredAt: new Date(now()).toISOString() };
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
function writeAtomic(name, obj) {
|
|
2834
|
+
const full = fp(name);
|
|
2835
|
+
const tmp = full + ".tmp-" + now() + "-" + Math.floor(Math.random() * 1e6);
|
|
2836
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 1), "utf8");
|
|
2837
|
+
fs.renameSync(tmp, full);
|
|
2838
|
+
}
|
|
2839
|
+
const emptySites = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
|
|
2840
|
+
const emptyElements = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
|
|
2841
|
+
const emptyTax = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), colors: [], filters: [] });
|
|
2842
|
+
const emptyMotion = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
|
|
2843
|
+
const emptyMeta = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), indexStatus: null, progress: [], baselines: {}, watches: [], crawl: null });
|
|
2844
|
+
|
|
2845
|
+
function siteMap() {
|
|
2846
|
+
const doc = readJson("sites.json", emptySites());
|
|
2847
|
+
const map = new Map();
|
|
2848
|
+
for (const s of (doc.items || [])) if (s && s.slug) map.set(String(s.slug).toLowerCase(), s);
|
|
2849
|
+
return { doc, map };
|
|
2850
|
+
}
|
|
2851
|
+
function upsertSitesImpl(sites) {
|
|
2852
|
+
const { map } = siteMap();
|
|
2853
|
+
let added = 0, updated = 0;
|
|
2854
|
+
for (const s of (sites || [])) {
|
|
2855
|
+
if (!s || !s.slug) continue;
|
|
2856
|
+
const k = String(s.slug).toLowerCase();
|
|
2857
|
+
if (map.has(k)) { map.set(k, { ...map.get(k), ...s }); updated++; }
|
|
2858
|
+
else { map.set(k, s); added++; }
|
|
2859
|
+
}
|
|
2860
|
+
let items = [...map.values()];
|
|
2861
|
+
if (items.length > AWARD_MAX_SITES) items = items.slice(items.length - AWARD_MAX_SITES);
|
|
2862
|
+
writeAtomic("sites.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items });
|
|
2863
|
+
return { added, updated, total: items.length };
|
|
2864
|
+
}
|
|
2865
|
+
function acquireLock() {
|
|
2866
|
+
const lockFile = path.join(root, "lock.json");
|
|
2867
|
+
try {
|
|
2868
|
+
if (fs.existsSync(lockFile)) {
|
|
2869
|
+
const prev = JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
2870
|
+
if (prev && (now() - (prev.startedAt || 0)) < AWARD_LOCK_STALE_MS) {
|
|
2871
|
+
const e = awardErr("INDEX_BUSY", "another index run is in progress (lock held since " + new Date(prev.startedAt).toISOString() + ")");
|
|
2872
|
+
throw e;
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
} catch (e) { if (e && e.code === "INDEX_BUSY") throw e; /* corrupt lock treated as stale */ }
|
|
2876
|
+
fs.writeFileSync(lockFile, JSON.stringify({ startedAt: now(), pid: (globalThis.process && globalThis.process.pid) || 0 }), "utf8");
|
|
2877
|
+
}
|
|
2878
|
+
function releaseLock() { try { fs.rmSync(path.join(root, "lock.json"), { force: true }); } catch { /* never mask */ } }
|
|
2879
|
+
// Lock must wrap the whole cycle: acquire first, release in finally; an
|
|
2880
|
+
// INDEX_BUSY throw from acquire never releases someone else's lock.
|
|
2881
|
+
const withLock = async (fn) => { acquireLock(); try { return await fn(); } finally { releaseLock(); } };
|
|
2882
|
+
|
|
2883
|
+
// One crawl cycle. Caller holds the lock. Returns a report naming the plan,
|
|
2884
|
+
// what was fetched this call, and what remains.
|
|
2885
|
+
const runCycle = async ({ client, action = "rebuild", tags = null, maxPages = 5, log = () => {}, fresh = false } = {}) => {
|
|
2886
|
+
if (!client || typeof client.getAwardHtml !== "function") throw awardIdxBad(action + " needs an award client");
|
|
2887
|
+
const startedAt = now();
|
|
2888
|
+
const iso = (t = now()) => new Date(t).toISOString();
|
|
2889
|
+
const budget = Math.max(1, Math.min(Number(maxPages) || 5, 20));
|
|
2890
|
+
const crawlView = (plan) => ({ planId: plan.planId || null, cycle: plan.cycle || 1, pagesTotal: plan.tags.length, pagesDone: plan.done.length, complete: !!plan.finishedAt, startedAt: plan.startedAt || null, finishedAt: plan.finishedAt || null });
|
|
2891
|
+
try {
|
|
2892
|
+
let meta = readJson("meta.json", emptyMeta());
|
|
2893
|
+
const explicit = Array.isArray(tags) && tags.length ? tags.map(String).slice(0, 200) : null;
|
|
2894
|
+
const stored = meta.crawl && Array.isArray(meta.crawl.tags) && meta.crawl.tags.length
|
|
2895
|
+
? { ...meta.crawl, tags: meta.crawl.tags.map(String).slice(0, 200), done: (meta.crawl.done || []).map(String) }
|
|
2896
|
+
: null;
|
|
2897
|
+
if (action === "resume" && stored && stored.finishedAt) {
|
|
2898
|
+
// Completed cycle: resume is an explicit no-op, not a silent recrawl.
|
|
2899
|
+
return { pagesDone: 0, pagesTotal: stored.tags.length, sitesIndexed: 0, skipped: stored.done.length, completedPages: stored.done.length, complete: true, continued: false, crawl: crawlView(stored), note: "crawl cycle already complete (" + stored.done.length + "/" + stored.tags.length + " pages); use action:refresh to start a new cycle" };
|
|
2900
|
+
}
|
|
2901
|
+
const explicitMatches = !!(explicit && stored && [...stored.tags].sort().join("|") === [...explicit].sort().join("|"));
|
|
2902
|
+
const tagsIgnored = !!(explicit && action === "resume" && stored && !explicitMatches);
|
|
2903
|
+
const continueStored = !!(stored && !stored.finishedAt && !fresh && (action === "resume" || !explicit || explicitMatches));
|
|
2904
|
+
let plan = null;
|
|
2905
|
+
const continued = continueStored;
|
|
2906
|
+
if (continueStored) plan = stored;
|
|
2907
|
+
if (!plan) {
|
|
2908
|
+
// A new plan needs the tag list: explicit tags, or the taxonomy
|
|
2909
|
+
// (bootstrapped with one fetch when the index has none yet).
|
|
2910
|
+
let wanted = explicit;
|
|
2911
|
+
if (!wanted) {
|
|
2912
|
+
let tax = readJson("taxonomy.json", emptyTax());
|
|
2913
|
+
if (!(tax.filters || []).length) {
|
|
2914
|
+
const { html } = await client.getAwardHtml("/websites/");
|
|
2915
|
+
tax = { version: AWARD_INDEX_VERSION, updatedAt: iso(), ...awardParseCategories(html) };
|
|
2916
|
+
if (!(tax.filters || []).length) throw awardErr("UPSTREAM_ERROR", "awwwards layout may have changed: parsed 0 categories");
|
|
2917
|
+
writeAtomic("taxonomy.json", tax);
|
|
2918
|
+
}
|
|
2919
|
+
wanted = (tax.filters || []).map(String).slice(0, 200);
|
|
2920
|
+
}
|
|
2921
|
+
if (!wanted.length) throw awardErr("UPSTREAM_ERROR", "no taxonomy to crawl; index has no filters");
|
|
2922
|
+
// Legacy checkpoint (a cycle from before plans existed): keep the
|
|
2923
|
+
// completed pages that are still part of this plan.
|
|
2924
|
+
const seedDone = [];
|
|
2925
|
+
if (!fresh && !stored && Array.isArray(meta.progress)) {
|
|
2926
|
+
for (const t of meta.progress.map(String)) if (wanted.includes(t) && !seedDone.includes(t)) seedDone.push(t);
|
|
2927
|
+
}
|
|
2928
|
+
plan = { planId: "cycle-" + startedAt, cycle: ((stored && stored.cycle) || 0) + 1, tags: wanted, done: seedDone, sitesIndexed: 0, startedAt: iso(), finishedAt: null };
|
|
2929
|
+
// Persist the plan immediately so a crash before page 1 still leaves
|
|
2930
|
+
// resumable state instead of an orphaned taxonomy fetch.
|
|
2931
|
+
meta.crawl = plan;
|
|
2932
|
+
meta.progress = [...plan.done];
|
|
2933
|
+
meta.updatedAt = iso();
|
|
2934
|
+
writeAtomic("meta.json", meta);
|
|
2935
|
+
}
|
|
2936
|
+
let pagesFetched = 0, sitesIndexed = 0;
|
|
2937
|
+
const skippedAtStart = plan.done.length;
|
|
2938
|
+
for (const tag of plan.tags) {
|
|
2939
|
+
if (plan.done.includes(tag)) continue;
|
|
2940
|
+
if (pagesFetched >= budget) break;
|
|
2941
|
+
const { html } = await client.getAwardHtml("/websites/" + encodeURIComponent(tag) + "/");
|
|
2942
|
+
const scan = awardParseListingEx(html);
|
|
2943
|
+
// Drift (collectable markup we can no longer read) aborts the cycle
|
|
2944
|
+
// with a named reason; a legitimately empty/filtered page is just 0
|
|
2945
|
+
// sites and the plan moves on.
|
|
2946
|
+
if (scan.diagnostics.verdict === "drift-empty") {
|
|
2947
|
+
throw awardErr("UPSTREAM_ERROR", "awwwards collector drift on /websites/" + tag + "/: no readable cards (parser v" + AWARD_PARSER_VERSION + ", blobs=" + scan.diagnostics.blobs + ", malformed=" + scan.diagnostics.malformed + ") — page not indexed, checkpoint kept");
|
|
2948
|
+
}
|
|
2949
|
+
const sites = scan.sites;
|
|
2950
|
+
if (sites.length) upsertSitesImpl(sites.map((s) => ({ ...s, tags: [...new Set([...(s.tags || []), tag])] })));
|
|
2951
|
+
sitesIndexed += sites.length;
|
|
2952
|
+
pagesFetched++;
|
|
2953
|
+
plan.done.push(tag);
|
|
2954
|
+
plan.sitesIndexed = (plan.sitesIndexed || 0) + sites.length;
|
|
2955
|
+
meta = readJson("meta.json", emptyMeta());
|
|
2956
|
+
meta.crawl = plan;
|
|
2957
|
+
meta.progress = [...plan.done];
|
|
2958
|
+
meta.updatedAt = iso();
|
|
2959
|
+
writeAtomic("meta.json", meta);
|
|
2960
|
+
log("[" + plan.done.length + "/" + plan.tags.length + "] " + tag + ": " + sites.length + " sites" + (sites.length ? "" : " (legit empty page)"));
|
|
2961
|
+
}
|
|
2962
|
+
const complete = plan.done.length >= plan.tags.length;
|
|
2963
|
+
if (complete) plan.finishedAt = iso();
|
|
2964
|
+
meta = readJson("meta.json", emptyMeta());
|
|
2965
|
+
meta.crawl = plan;
|
|
2966
|
+
meta.progress = complete ? [] : [...plan.done];
|
|
2967
|
+
if (complete) meta.indexStatus = { startedAt: plan.startedAt, finishedAt: plan.finishedAt, pagesDone: plan.done.length, pagesTotal: plan.tags.length, sitesIndexed: plan.sitesIndexed || 0 };
|
|
2968
|
+
meta.updatedAt = iso();
|
|
2969
|
+
writeAtomic("meta.json", meta);
|
|
2970
|
+
const out = { pagesDone: pagesFetched, pagesTotal: plan.tags.length, sitesIndexed, skipped: skippedAtStart, completedPages: plan.done.length, complete, continued, crawl: crawlView(plan) };
|
|
2971
|
+
if (complete) out.note = "crawl cycle complete (" + plan.done.length + "/" + plan.tags.length + " pages); refresh starts a new cycle";
|
|
2972
|
+
else if (pagesFetched >= budget) out.note = "page budget reached (" + pagesFetched + " this call, " + plan.done.length + "/" + plan.tags.length + " total); action:resume continues the same plan";
|
|
2973
|
+
else out.note = "no pending pages fetched"; // completes mid-call only when the plan finished exactly at the budget boundary
|
|
2974
|
+
if (tagsIgnored) out.note = "tags argument ignored: resume continues the stored plan | " + out.note;
|
|
2975
|
+
return out;
|
|
2976
|
+
} catch (e) {
|
|
2977
|
+
try {
|
|
2978
|
+
const meta = readJson("meta.json", emptyMeta());
|
|
2979
|
+
meta.indexStatus = { ...(meta.indexStatus || {}), lastError: String((e && e.message) || e).slice(0, 300) };
|
|
2980
|
+
writeAtomic("meta.json", meta);
|
|
2981
|
+
} catch { /* never mask */ }
|
|
2982
|
+
throw e;
|
|
2983
|
+
}
|
|
2984
|
+
};
|
|
2985
|
+
|
|
2986
|
+
return {
|
|
2987
|
+
root,
|
|
2988
|
+
files: AWARD_FILES,
|
|
2989
|
+
getStatus() {
|
|
2990
|
+
const meta = readJson("meta.json", emptyMeta());
|
|
2991
|
+
const sites = readJson("sites.json", emptySites());
|
|
2992
|
+
const elements = readJson("elements.json", emptyElements());
|
|
2993
|
+
const motion = readJson("motion.json", emptyMotion());
|
|
2994
|
+
const tax = readJson("taxonomy.json", emptyTax());
|
|
2995
|
+
let lock = null;
|
|
2996
|
+
try {
|
|
2997
|
+
const lockFile = path.join(root, "lock.json");
|
|
2998
|
+
if (fs.existsSync(lockFile)) lock = JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
2999
|
+
} catch { lock = { unreadable: true }; }
|
|
3000
|
+
const finishedAt = meta.indexStatus && meta.indexStatus.finishedAt;
|
|
3001
|
+
const freshness = finishedAt ? new Date(finishedAt).toISOString() : null;
|
|
3002
|
+
const crawl = meta.crawl ? {
|
|
3003
|
+
planId: meta.crawl.planId || null,
|
|
3004
|
+
cycle: meta.crawl.cycle || 1,
|
|
3005
|
+
pagesTotal: (meta.crawl.tags || []).length,
|
|
3006
|
+
pagesDone: (meta.crawl.done || []).length,
|
|
3007
|
+
complete: !!meta.crawl.finishedAt,
|
|
3008
|
+
startedAt: meta.crawl.startedAt || null,
|
|
3009
|
+
finishedAt: meta.crawl.finishedAt || null,
|
|
3010
|
+
} : null;
|
|
3011
|
+
const recovered = [];
|
|
3012
|
+
if (sites.corrupt) recovered.push("sites.json: " + sites.corrupt);
|
|
3013
|
+
if (elements.corrupt) recovered.push("elements.json: " + elements.corrupt);
|
|
3014
|
+
if (motion.corrupt) recovered.push("motion.json: " + motion.corrupt);
|
|
3015
|
+
if (tax.corrupt) recovered.push("taxonomy.json: " + tax.corrupt);
|
|
3016
|
+
if (meta.corrupt) recovered.push("meta.json: " + meta.corrupt);
|
|
3017
|
+
return {
|
|
3018
|
+
dir: root, version: AWARD_INDEX_VERSION,
|
|
3019
|
+
counts: { sites: (sites.items || []).length, elements: (elements.items || []).length, motion: (motion.items || []).length, taxonomyFilters: (tax.filters || []).length, taxonomyColors: (tax.colors || []).length, watches: (meta.watches || []).length },
|
|
3020
|
+
freshness, stale: !finishedAt || (now() - finishedAt >= AWARD_INDEX_STALE_MS),
|
|
3021
|
+
progress: meta.indexStatus || null,
|
|
3022
|
+
pendingPages: meta.progress || [],
|
|
3023
|
+
completedPages: crawl ? crawl.pagesDone : (meta.progress || []).length,
|
|
3024
|
+
crawl,
|
|
3025
|
+
lock: lock ? { held: true, since: lock.startedAt ? new Date(lock.startedAt).toISOString() : null } : { held: false },
|
|
3026
|
+
...(sites.corrupt ? { sitesCorrupt: sites.corrupt } : {}),
|
|
3027
|
+
...(meta.corrupt ? { metaCorrupt: meta.corrupt } : {}),
|
|
3028
|
+
...(recovered.length ? { recovered } : {}),
|
|
3029
|
+
};
|
|
3030
|
+
},
|
|
3031
|
+
listSites(limit = 50) {
|
|
3032
|
+
const { map } = siteMap();
|
|
3033
|
+
return [...map.values()].slice(0, Math.max(1, Math.min(limit, 200)));
|
|
3034
|
+
},
|
|
3035
|
+
getSite(slug) {
|
|
3036
|
+
const n = awardNormalizeSlug(slug);
|
|
3037
|
+
const { map } = siteMap();
|
|
3038
|
+
return map.get(n) || null;
|
|
3039
|
+
},
|
|
3040
|
+
upsertSites(sites) { return upsertSitesImpl(sites); },
|
|
3041
|
+
getTaxonomy() { return readJson("taxonomy.json", emptyTax()); },
|
|
3042
|
+
setTaxonomy(tax) {
|
|
3043
|
+
const colors = Array.isArray(tax.colors) ? [...new Set(tax.colors.map(String))].slice(0, 100) : [];
|
|
3044
|
+
const filters = Array.isArray(tax.filters) ? [...new Set(tax.filters.map(String))].slice(0, 400) : [];
|
|
3045
|
+
writeAtomic("taxonomy.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), colors, filters });
|
|
3046
|
+
return { colors: colors.length, filters: filters.length };
|
|
3047
|
+
},
|
|
3048
|
+
upsertElements(items) {
|
|
3049
|
+
const doc = readJson("elements.json", emptyElements());
|
|
3050
|
+
const map = new Map((doc.items || []).map((e) => [String(e.slug), e]));
|
|
3051
|
+
let added = 0;
|
|
3052
|
+
for (const e of (items || [])) {
|
|
3053
|
+
if (!e || !e.slug) continue;
|
|
3054
|
+
if (!map.has(String(e.slug))) added++;
|
|
3055
|
+
map.set(String(e.slug), e);
|
|
3056
|
+
}
|
|
3057
|
+
let list = [...map.values()];
|
|
3058
|
+
if (list.length > AWARD_MAX_ELEMENTS) list = list.slice(list.length - AWARD_MAX_ELEMENTS);
|
|
3059
|
+
writeAtomic("elements.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: list });
|
|
3060
|
+
return { added, total: list.length };
|
|
3061
|
+
},
|
|
3062
|
+
listElements(limit = 50) {
|
|
3063
|
+
const doc = readJson("elements.json", emptyElements());
|
|
3064
|
+
return (doc.items || []).slice(0, Math.max(1, Math.min(limit, 200)));
|
|
3065
|
+
},
|
|
3066
|
+
getElement(slug) {
|
|
3067
|
+
const doc = readJson("elements.json", emptyElements());
|
|
3068
|
+
return (doc.items || []).find((e) => String(e.slug) === String(slug)) || null;
|
|
3069
|
+
},
|
|
3070
|
+
recordMotion(study) {
|
|
3071
|
+
if (!study || !study.url) throw awardIdxBad("motion study needs a url");
|
|
3072
|
+
const doc = readJson("motion.json", emptyMotion());
|
|
3073
|
+
const items = (doc.items || []).filter((m) => m.url !== study.url);
|
|
3074
|
+
items.unshift({ ...study, storedAt: new Date(now()).toISOString() });
|
|
3075
|
+
writeAtomic("motion.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: items.slice(0, AWARD_MAX_MOTION) });
|
|
3076
|
+
return study;
|
|
3077
|
+
},
|
|
3078
|
+
searchMotion({ library, easing, scrollModel, limit = 10 } = {}) {
|
|
3079
|
+
const doc = readJson("motion.json", emptyMotion());
|
|
3080
|
+
let items = (doc.items || []);
|
|
3081
|
+
if (library) items = items.filter((m) => JSON.stringify(m).toLowerCase().includes(String(library).toLowerCase()));
|
|
3082
|
+
if (easing) items = items.filter((m) => JSON.stringify(m.easingVocab || m.easings || []).toLowerCase().includes(String(easing).toLowerCase()));
|
|
3083
|
+
if (scrollModel) items = items.filter((m) => String((m.stack && m.stack.scrollModel) || m.scrollModel || "").toLowerCase() === String(scrollModel).toLowerCase());
|
|
3084
|
+
return { total: items.length, items: items.slice(0, Math.max(1, Math.min(limit || 10, 25))), empty: (doc.items || []).length === 0 };
|
|
3085
|
+
},
|
|
3086
|
+
// Index lifecycle: crawl tag pages via client, resumable, bounded.
|
|
3087
|
+
// A "cycle" is one plan (full tag list) plus a done-checkpoint set.
|
|
3088
|
+
// rebuild continues an unfinished cycle, or starts a new one when the
|
|
3089
|
+
// previous cycle completed; resume only continues pending work and
|
|
3090
|
+
// reports an explicit no-op when the cycle is complete; refresh resets
|
|
3091
|
+
// and starts a new cycle after the lock is held (no pre-lock mutation).
|
|
3092
|
+
// maxPages caps fetches per call, so a plan advances across calls
|
|
3093
|
+
// instead of restarting the same prefix every resume.
|
|
3094
|
+
async rebuild(args = {}) { return withLock(() => runCycle({ ...args, action: "rebuild" })); },
|
|
3095
|
+
async resume(args = {}) { return withLock(() => runCycle({ ...args, action: "resume" })); },
|
|
3096
|
+
async refresh(args = {}) { return withLock(() => runCycle({ ...args, action: "refresh", fresh: true })); },
|
|
3097
|
+
// Winners: baseline seed then delta. Drift or an empty listing never
|
|
3098
|
+
// wipes the baseline — otherwise every known winner would be re-reported
|
|
3099
|
+
// as new on the next healthy fetch.
|
|
3100
|
+
async newWinners({ client, award = "sotd" } = {}) {
|
|
3101
|
+
if (!client || typeof client.getAwardHtml !== "function") throw awardIdxBad("newWinners needs an award client");
|
|
3102
|
+
if (!AWARD_FILTERS[award]) throw awardIdxBad("award must be sotd|developer|honorable");
|
|
3103
|
+
const { html } = await client.getAwardHtml("/" + AWARD_FILTERS[award] + "/");
|
|
3104
|
+
const scan = awardParseListingEx(html);
|
|
3105
|
+
const parsed = scan.sites;
|
|
3106
|
+
const diag = scan.diagnostics;
|
|
3107
|
+
const parserStamp = { parserVersion: AWARD_PARSER_VERSION, verdict: diag.verdict, blobs: diag.blobs, malformed: diag.malformed };
|
|
3108
|
+
const meta = readJson("meta.json", emptyMeta());
|
|
3109
|
+
const key = "baseline_" + award;
|
|
3110
|
+
const prev = meta.baselines && meta.baselines[key];
|
|
3111
|
+
const day = new Date(now()).toISOString().slice(0, 10);
|
|
3112
|
+
if (diag.verdict === "drift-empty") {
|
|
3113
|
+
return { day, seeded: false, newWinners: [], totalInListing: 0, degraded: true, parser: parserStamp, note: "listing returned no readable cards (collector drift?); baseline preserved — retry later" };
|
|
3114
|
+
}
|
|
3115
|
+
if (!parsed.length) {
|
|
3116
|
+
const prevSlugs = (prev && prev.slugs) || [];
|
|
3117
|
+
if (prevSlugs.length) return { day, seeded: false, newWinners: [], totalInListing: 0, degraded: true, parser: parserStamp, note: "listing had 0 readable cards; kept the previous baseline of " + prevSlugs.length + " slugs so nothing is re-reported as new" };
|
|
3118
|
+
if (!prev) {
|
|
3119
|
+
meta.baselines = { ...(meta.baselines || {}), [key]: { slugs: [], at: new Date(now()).toISOString() } };
|
|
3120
|
+
writeAtomic("meta.json", meta);
|
|
3121
|
+
return { day, seeded: true, newWinners: [], totalInListing: 0, parser: parserStamp, note: "baseline seeded empty; this listing had no readable cards (not drift)" };
|
|
3122
|
+
}
|
|
3123
|
+
return { day, seeded: false, newWinners: [], totalInListing: 0, parser: parserStamp };
|
|
3124
|
+
}
|
|
3125
|
+
const slugs = parsed.map((s) => s.slug);
|
|
3126
|
+
if (!prev) {
|
|
3127
|
+
meta.baselines = { ...(meta.baselines || {}), [key]: { slugs, at: new Date(now()).toISOString() } };
|
|
3128
|
+
writeAtomic("meta.json", meta);
|
|
3129
|
+
this.upsertSites(parsed);
|
|
3130
|
+
return { day, seeded: true, newWinners: [], totalInListing: parsed.length, parser: parserStamp, note: "baseline seeded; no historical delta available yet" };
|
|
3131
|
+
}
|
|
3132
|
+
const seen = new Set(prev.slugs || []);
|
|
3133
|
+
const fresh = parsed.filter((s) => !seen.has(s.slug));
|
|
3134
|
+
meta.baselines[key] = { slugs, at: new Date(now()).toISOString() };
|
|
3135
|
+
writeAtomic("meta.json", meta);
|
|
3136
|
+
this.upsertSites(parsed);
|
|
3137
|
+
return { day, seeded: false, newWinners: fresh, totalInListing: parsed.length, parser: parserStamp };
|
|
3138
|
+
},
|
|
3139
|
+
watchAdd({ kind, pattern, award } = {}) {
|
|
3140
|
+
if (!["studio", "tag", "url"].includes(kind)) throw awardIdxBad("watch kind must be studio|tag|url");
|
|
3141
|
+
if (typeof pattern !== "string" || !pattern.trim() || pattern.length > 120) throw awardIdxBad("watch pattern must be 1-120 chars");
|
|
3142
|
+
const clean = pattern.trim().toLowerCase();
|
|
3143
|
+
// url watches normalize to the site slug (a URL and its slug are the
|
|
3144
|
+
// same target; storing the raw URL never matched an indexed slug).
|
|
3145
|
+
const stored = kind === "url" ? awardWatchTargetSlug(pattern) : clean;
|
|
3146
|
+
if (kind === "tag" && !/^[a-z0-9-]{2,60}$/.test(clean)) throw awardIdxBad("tag watch must be a slug like 'webgl'");
|
|
3147
|
+
if (award !== undefined && !["sotd", "developer", "honorable"].includes(award)) throw awardIdxBad("watch award must be sotd|developer|honorable");
|
|
3148
|
+
const meta = readJson("meta.json", emptyMeta());
|
|
3149
|
+
const watches = meta.watches || [];
|
|
3150
|
+
const duplicate = watches.some((w) => w.kind === kind && (w.pattern === stored || (kind === "url" && awardUrlWatchTarget(w.pattern) === stored)));
|
|
3151
|
+
if (duplicate) throw awardIdxBad("duplicate watch: " + kind + ":" + stored);
|
|
3152
|
+
if (watches.length >= 50) throw awardIdxBad("too many watches (max 50)");
|
|
3153
|
+
const rec = { kind, pattern: stored, ...(stored !== clean ? { input: pattern.trim() } : {}), award: award || null, createdAt: new Date(now()).toISOString(), lastCheckedAt: null, lastSlugs: null };
|
|
3154
|
+
meta.watches = [...watches, rec];
|
|
3155
|
+
writeAtomic("meta.json", meta);
|
|
3156
|
+
return rec;
|
|
3157
|
+
},
|
|
3158
|
+
watchList() {
|
|
3159
|
+
const meta = readJson("meta.json", emptyMeta());
|
|
3160
|
+
return meta.watches || [];
|
|
3161
|
+
},
|
|
3162
|
+
watchRemove({ kind, pattern } = {}) {
|
|
3163
|
+
const meta = readJson("meta.json", emptyMeta());
|
|
3164
|
+
const clean = String(pattern || "").trim().toLowerCase();
|
|
3165
|
+
const target = kind === "url" ? awardUrlWatchTarget(clean) : clean;
|
|
3166
|
+
const before = (meta.watches || []).length;
|
|
3167
|
+
meta.watches = (meta.watches || []).filter((w) => !(w.kind === kind && (w.pattern === clean || (kind === "url" && awardUrlWatchTarget(w.pattern) === target))));
|
|
3168
|
+
writeAtomic("meta.json", meta);
|
|
3169
|
+
return { removed: (meta.watches.length < before) };
|
|
3170
|
+
},
|
|
3171
|
+
// First evaluation of a watch seeds its baseline silently (deltas stay
|
|
3172
|
+
// empty). Detailed form discloses the seeds so callers can show what a
|
|
3173
|
+
// watch is now tracking; checkWatches keeps the pinned deltas-only shape.
|
|
3174
|
+
checkWatchesDetailed() {
|
|
3175
|
+
const meta = readJson("meta.json", emptyMeta());
|
|
3176
|
+
const { map } = siteMap();
|
|
3177
|
+
const rows = [...map.values()];
|
|
3178
|
+
const deltas = [];
|
|
3179
|
+
const seeded = [];
|
|
3180
|
+
const watches = (meta.watches || []).map((w) => ({ ...w }));
|
|
3181
|
+
for (const w of watches) {
|
|
3182
|
+
const hits = rows.filter((s) => {
|
|
3183
|
+
if (w.kind === "url") return String(s.slug) === awardUrlWatchTarget(w.pattern);
|
|
3184
|
+
if (w.kind === "studio") return String(s.studio || "").toLowerCase().includes(w.pattern);
|
|
3185
|
+
return (s.tags || []).some((t) => String(t).toLowerCase().replace(/\W+/g, "-") === w.pattern);
|
|
3186
|
+
});
|
|
3187
|
+
const slugs = hits.map((s) => s.slug).sort();
|
|
3188
|
+
if (w.lastSlugs === null || w.lastSlugs === undefined) {
|
|
3189
|
+
w.lastCheckedAt = new Date(now()).toISOString();
|
|
3190
|
+
w.lastSlugs = slugs;
|
|
3191
|
+
if (hits.length) seeded.push({ watch: { kind: w.kind, pattern: w.pattern }, baselineMatches: hits.map((s) => ({ slug: s.slug, title: s.title, sourceUrl: s.sourceUrl })) });
|
|
3192
|
+
continue;
|
|
3193
|
+
}
|
|
3194
|
+
const prev = new Set(w.lastSlugs || []);
|
|
3195
|
+
const fresh = hits.filter((s) => !prev.has(s.slug));
|
|
3196
|
+
w.lastCheckedAt = new Date(now()).toISOString();
|
|
3197
|
+
w.lastSlugs = slugs;
|
|
3198
|
+
if (fresh.length) deltas.push({ watch: { kind: w.kind, pattern: w.pattern }, matches: fresh.map((s) => ({ slug: s.slug, title: s.title, sourceUrl: s.sourceUrl })) });
|
|
3199
|
+
}
|
|
3200
|
+
meta.watches = watches;
|
|
3201
|
+
writeAtomic("meta.json", meta);
|
|
3202
|
+
return { deltas, seeded };
|
|
3203
|
+
},
|
|
3204
|
+
checkWatches() { return this.checkWatchesDetailed().deltas; },
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// ---- browser-cdp.mjs ----
|
|
3209
|
+
// Module: browser-cdp — optional native Chrome DevTools Protocol adapter.
|
|
3210
|
+
// Zero npm deps: finds system Chrome/Chromium, launches an isolated profile,
|
|
3211
|
+
// drives it over global WebSocket CDP (navigate, viewport, evaluate,
|
|
3212
|
+
// screenshot), cleans up. Missing browser => BROWSER_UNAVAILABLE, never fake.
|
|
3213
|
+
|
|
3214
|
+
const CDP_VIEWPORTS = { desktop: { width: 1440, height: 900 }, mobile: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true } };
|
|
3215
|
+
const CDP_CAPTURE_TIMEOUT_MS = 45000;
|
|
3216
|
+
const CDP_NAV_TIMEOUT_MS = 30000;
|
|
3217
|
+
const CDP_MAX_SETTLE_MS = 8000;
|
|
3218
|
+
const CDP_MAX_FRAMES = 16;
|
|
3219
|
+
const CDP_MAX_RECORD_MS = 30000;
|
|
3220
|
+
|
|
3221
|
+
function cdpBuiltins() {
|
|
3222
|
+
const g = globalThis.process && globalThis.process.getBuiltinModule ? globalThis.process.getBuiltinModule : null;
|
|
3223
|
+
if (typeof g !== "function") throw awardErr("UNSUPPORTED_OPERATION", "browser capture needs Node 22+");
|
|
3224
|
+
return {
|
|
3225
|
+
fs: g.call(globalThis.process, "node:fs"),
|
|
3226
|
+
path: g.call(globalThis.process, "node:path"),
|
|
3227
|
+
os: g.call(globalThis.process, "node:os"),
|
|
3228
|
+
cp: g.call(globalThis.process, "node:child_process"),
|
|
3229
|
+
crypto: g.call(globalThis.process, "node:crypto"),
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
function cdpBad(m) { return awardErr("BAD_ARGS", m); }
|
|
3233
|
+
function cdpUnavailable(detail) {
|
|
3234
|
+
const e = awardErr("BROWSER_UNAVAILABLE", "no Chrome/Chromium found. Install Chrome or set AURA_CHROME_PATH to its executable. " + (detail || ""));
|
|
3235
|
+
return e;
|
|
3236
|
+
}
|
|
3237
|
+
// Normalize ANY CDP setup failure (socket refused, attach hang, first-frame
|
|
3238
|
+
// timeout) to BROWSER_UNAVAILABLE with operation-specific fallback guidance.
|
|
3239
|
+
// A hung Target.* handshake must never surface as a raw upstream timeout.
|
|
3240
|
+
function cdpSetupFailed(op, err) {
|
|
3241
|
+
const why = String((err && err.message) || err).slice(0, 180);
|
|
3242
|
+
const fallback = op === "structure"
|
|
3243
|
+
? " Fallback: aura_visual_audit {mode:structure} retries automatically over static HTML (source:static-html, no measured offsets)."
|
|
3244
|
+
: op === "motion"
|
|
3245
|
+
? " Fallback motion plan (generic, NOT observed — verify against the live site when a browser is available): entrances <500ms ease-out, one idea per viewport; scroll reveals via IntersectionObserver or GSAP ScrollTrigger (scrub only for pinned storytelling); hover lifts 1.03 + shadow; keep easing vocabulary to ease-out/cubic-bezier(.22,1,.36,1); honor prefers-reduced-motion (disable parallax/scrub, keep content readable); record a study later with aura_motion_study {mode:record}."
|
|
3246
|
+
: " Fallback: aura_visual_audit {mode:structure} can still estimate sections from static HTML; screenshots need a working browser.";
|
|
3247
|
+
const e = awardErr("BROWSER_UNAVAILABLE", "browser control failed during " + op + " (" + why + "). Check that Chrome is installed and no firewall blocks 127.0.0.1 remote debugging." + fallback);
|
|
3248
|
+
return e;
|
|
3249
|
+
}
|
|
3250
|
+
function cdpResolveViewport(name) {
|
|
3251
|
+
if (name === undefined || name === null) return CDP_VIEWPORTS.desktop;
|
|
3252
|
+
if (!CDP_VIEWPORTS[name]) throw cdpBad("viewport must be desktop|mobile");
|
|
3253
|
+
return CDP_VIEWPORTS[name];
|
|
3254
|
+
}
|
|
3255
|
+
// Self-contained page snippets (no closures; run via Runtime.evaluate).
|
|
3256
|
+
const CDP_SCAN_JS = `(() => { const doc = document; const out = { title: doc.title, totalHeight: Math.round(doc.documentElement.scrollHeight), candidates: [] }; const bodyW = (doc.body.getBoundingClientRect().width || 1); const push = (tag, label, bg, top, height, textStart) => out.candidates.push({ tag, label, bg, top: Math.round(top), height: Math.round(height), textStart }); push("body", "body", getComputedStyle(doc.body).backgroundColor, 0, out.totalHeight, ""); const seen = new Set(); const visit = (el, depth) => { if (depth > 6 || out.candidates.length >= 300 || seen.has(el)) return; seen.add(el); try { const r = el.getBoundingClientRect(); if (el !== doc.body && r.width >= 0.6 * bodyW && r.height >= 120) { const bg = getComputedStyle(el).backgroundColor || ""; const m = /rgba?\\(([^)]+)\\)/.exec(bg); let a = 0; if (m) { const parts = m[1].replace(/\\//g, " ").trim().split(/[\\s,]+/).filter(Boolean); const raw = parts.length >= 4 ? parts[3] : "1"; a = raw.endsWith("%") ? parseFloat(raw) / 100 : parseFloat(raw); if (Number.isNaN(a)) a = 0; } else if (/^(oklch|oklab|lab|lch|hwb|color)\\(/.test(bg.trim())) a = 1; if (a > 0) push(el.tagName.toLowerCase(), (el.id ? "#" + el.id : "") + (el.classList.length ? "." + el.classList[0] : ""), bg, r.top + window.scrollY, r.height, (el.textContent || "").trim().slice(0, 60)); } } catch {} for (const ch of el.children) visit(ch, depth + 1); }; visit(doc.body, 0); return out; })()`;
|
|
3257
|
+
const CDP_MOTION_JS = `(() => { const g = window; const libs = []; const add = (s) => { if (!libs.includes(s)) libs.push(s); }; try { if (g.gsap || g.TweenMax) add("gsap"); if (g.ScrollTrigger && g.ScrollTrigger.getAll) add("scrolltrigger"); if (g.Lenis || (document.documentElement.classList && document.documentElement.classList.contains("lenis"))) add("lenis"); if (document.querySelector("[data-scroll-container]")) add("locomotive"); if (document.querySelector("[data-aos]")) add("aos"); if (g.Swiper || document.querySelector(".swiper")) add("swiper"); } catch {} const render = []; try { const cs = [...document.querySelectorAll("canvas")]; if (g.THREE) render.push("three"); if (g.PIXI) render.push("pixi"); if (cs.length && !render.length) render.push("webgl"); } catch {} let st = { count: 0, scrub: 0, pin: 0, sample: [] }; try { if (g.ScrollTrigger && g.ScrollTrigger.getAll) { const all = g.ScrollTrigger.getAll(); st.count = all.length; for (const s of all.slice(0, 20)) { const v = (s.vars || {}); if (v.scrub) st.scrub++; if (v.pin) st.pin++; st.sample.push({ start: v.start != null ? String(v.start) : null, end: v.end != null ? String(v.end) : null, scrub: !!v.scrub, pin: !!v.pin, duration: null, ease: null }); } } } catch {} const tweens = { durations: [], eases: [] }; try { const kids = (g.gsap && g.gsap.globalTimeline && g.gsap.globalTimeline.getChildren(true, true, true)) || []; for (const k of kids.slice(0, 100)) { try { const d = typeof k.duration === "function" ? k.duration() : k.vars && k.vars.duration; if (typeof d === "number") tweens.durations.push(d); if (k.vars && k.vars.ease) tweens.eases.push(String(k.vars.ease)); } catch {} } } catch {} let css = []; try { const anims = (document.getAnimations && document.getAnimations()) || []; for (const a of anims.slice(0, 200)) { try { const t = a.effect && a.effect.getComputedTiming && a.effect.getComputedTiming(); if (t && t.easing && String(t.easing).includes("bezier")) css.push(String(t.easing)); if (t && typeof t.duration === "number") tweens.durations.push(t.duration / 1000); } catch {} } } catch {} return { libs, render, scrollTrigger: st, tweens, cssBeziers: css }; })()`;
|
|
3258
|
+
|
|
3259
|
+
// Pure band reducer (mirrors upstream sweep; tested with fixture DOM).
|
|
3260
|
+
function cdpCollapseBands(cands, totalHeight, maxBands = 40) {
|
|
3261
|
+
const usable = (cands || []).map((c) => ({ ...c, top: Math.round(c.top || 0), height: Math.round(c.height || 0) })).filter((c) => c.height > 0);
|
|
3262
|
+
const STEP = 8;
|
|
3263
|
+
const bgAt = (y) => {
|
|
3264
|
+
let best = null;
|
|
3265
|
+
for (const c of usable) if (c.top <= y && y < c.top + c.height && (!best || c.height < best.height)) best = c;
|
|
3266
|
+
return best;
|
|
3267
|
+
};
|
|
3268
|
+
const runs = [];
|
|
3269
|
+
let y = 0, cur = null;
|
|
3270
|
+
while (y < totalHeight) {
|
|
3271
|
+
const c = bgAt(y);
|
|
3272
|
+
if (cur && c && c.bg === cur.cand.bg) { /* continue */ }
|
|
3273
|
+
else {
|
|
3274
|
+
if (cur) runs.push({ cand: cur.cand, top: cur.top, height: y - cur.top });
|
|
3275
|
+
cur = c ? { cand: c, top: y } : null;
|
|
3276
|
+
}
|
|
3277
|
+
y += STEP;
|
|
3278
|
+
}
|
|
3279
|
+
if (cur) runs.push({ cand: cur.cand, top: cur.top, height: totalHeight - cur.top });
|
|
3280
|
+
const merged = [];
|
|
3281
|
+
for (const r of runs) {
|
|
3282
|
+
const prev = merged[merged.length - 1];
|
|
3283
|
+
if (prev && !prev.sealed && prev.cand.bg === r.cand.bg) prev.height = r.top + r.height - prev.top;
|
|
3284
|
+
else if (r.height < 40 && prev) { prev.height = r.top + r.height - prev.top; prev.sealed = true; }
|
|
3285
|
+
else merged.push({ ...r });
|
|
3286
|
+
}
|
|
3287
|
+
while (merged.length > maxBands && merged.length > 1) {
|
|
3288
|
+
let idx = 1;
|
|
3289
|
+
for (let i = 1; i < merged.length; i++) if (merged[i].height < merged[idx].height) idx = i;
|
|
3290
|
+
merged[idx - 1].height += merged[idx].height;
|
|
3291
|
+
merged.splice(idx, 1);
|
|
3292
|
+
}
|
|
3293
|
+
return merged.map((r, i) => ({ index: i, tag: r.cand.tag, label: r.cand.label, background: r.cand.bg, offsetTop: Math.round(r.top), height: Math.round(r.height), textStart: r.cand.textStart || "" }));
|
|
3294
|
+
}
|
|
3295
|
+
// Static-HTML fallback bands (no layout): honest estimated source label.
|
|
3296
|
+
function cdpBandsFromHtml(html, url) {
|
|
3297
|
+
const src = String(html || "");
|
|
3298
|
+
const bands = [];
|
|
3299
|
+
const re = /<(section|header|footer|nav|main|div|article)[^>]*>/gi;
|
|
3300
|
+
let m, i = 0;
|
|
3301
|
+
while ((m = re.exec(src)) && bands.length < 40) {
|
|
3302
|
+
const tag = m[1].toLowerCase();
|
|
3303
|
+
const chunk = src.slice(m.index, m.index + 2000);
|
|
3304
|
+
const h = (chunk.match(/<h[1-3][^>]*>([^<]{1,80})/i) || [])[1];
|
|
3305
|
+
const bg = (m[0].match(/background(?:-color)?:\s*([^;"']{1,30})/i) || [])[1] || null;
|
|
3306
|
+
const hasImg = /<img|<video/i.test(chunk);
|
|
3307
|
+
bands.push({ index: i++, tag, label: tag, background: bg, offsetTop: null, height: null, textStart: h ? String(h).trim().slice(0, 60) : "", media: hasImg });
|
|
3308
|
+
}
|
|
3309
|
+
return { url, title: (src.match(/<title>([^<]{1,120})<\/title>/i) || [])[1] || url, totalHeight: null, bands, source: "static-html", note: "offsets/heights unavailable without layout; use capture/structure via browser for measured bands" };
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
// Bounded artifact retention: only ever prunes tool-created files/dirs in
|
|
3313
|
+
// tool-owned locations (tmp default dirs), never caller-passed saveDirs.
|
|
3314
|
+
function cdpPruneOldFiles(dir, { prefix, keepNewest = 20 } = {}) {
|
|
3315
|
+
try {
|
|
3316
|
+
const { fs, path } = cdpBuiltins();
|
|
3317
|
+
const entries = fs.readdirSync(dir).filter((n) => n.startsWith(prefix)).map((n) => {
|
|
3318
|
+
const full = path.join(dir, n);
|
|
3319
|
+
let t = 0;
|
|
3320
|
+
try { t = fs.statSync(full).mtimeMs || 0; } catch { /* skipped below by mtime 0 */ }
|
|
3321
|
+
return { full, t };
|
|
3322
|
+
}).filter((e) => e.t > 0).sort((a, b) => b.t - a.t);
|
|
3323
|
+
let removed = 0;
|
|
3324
|
+
for (const e of entries.slice(Math.max(1, keepNewest))) { try { fs.rmSync(e.full, { force: true }); removed++; } catch { /* ignore */ } }
|
|
3325
|
+
return removed;
|
|
3326
|
+
} catch { return 0; }
|
|
3327
|
+
}
|
|
3328
|
+
function cdpPruneOldDirs(parentDir, { prefix, maxAgeMs = 24 * 60 * 60 * 1000, skip = null } = {}) {
|
|
3329
|
+
try {
|
|
3330
|
+
const { fs, path } = cdpBuiltins();
|
|
3331
|
+
const nowMs = Date.now();
|
|
3332
|
+
let removed = 0;
|
|
3333
|
+
const skipResolved = skip ? path.resolve(skip) : null;
|
|
3334
|
+
for (const n of fs.readdirSync(parentDir)) {
|
|
3335
|
+
if (!n.startsWith(prefix)) continue;
|
|
3336
|
+
const full = path.join(parentDir, n);
|
|
3337
|
+
if (skipResolved && path.resolve(full) === skipResolved) continue;
|
|
3338
|
+
let st;
|
|
3339
|
+
try { st = fs.statSync(full); } catch { continue; }
|
|
3340
|
+
if (!st.isDirectory()) continue;
|
|
3341
|
+
if (nowMs - (st.mtimeMs || 0) < maxAgeMs) continue;
|
|
3342
|
+
try { fs.rmSync(full, { recursive: true, force: true }); removed++; } catch { /* ignore */ }
|
|
3343
|
+
}
|
|
3344
|
+
return removed;
|
|
3345
|
+
} catch { return 0; }
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
// CDP connection: id-keyed commands over a WebSocket-like transport.
|
|
3349
|
+
// Messages without an id are domain events (Page.loadEventFired, ...)
|
|
3350
|
+
// dispatched to onEvent subscribers.
|
|
3351
|
+
function createCdpConn(socket) {
|
|
3352
|
+
let nextId = 1;
|
|
3353
|
+
const pending = new Map();
|
|
3354
|
+
const listeners = new Map();
|
|
3355
|
+
const route = (raw) => {
|
|
3356
|
+
let msg;
|
|
3357
|
+
try { msg = JSON.parse(typeof raw === "string" ? raw : raw.data); } catch { return; }
|
|
3358
|
+
if (msg && msg.id !== undefined && pending.has(msg.id)) {
|
|
3359
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
3360
|
+
pending.delete(msg.id);
|
|
3361
|
+
if (msg.error) reject(awardErr("UPSTREAM_ERROR", "CDP " + (msg.error.message || "error")));
|
|
3362
|
+
else resolve(msg.result);
|
|
3363
|
+
} else if (msg && typeof msg.method === "string" && listeners.has(msg.method)) {
|
|
3364
|
+
for (const cb of listeners.get(msg.method)) { try { cb(msg.params || {}); } catch { /* listener never breaks routing */ } }
|
|
3365
|
+
}
|
|
3366
|
+
};
|
|
3367
|
+
// Always install (never guard on a read: setter-only transports report
|
|
3368
|
+
// onmessage as undefined on read, which previously left responses unrouted
|
|
3369
|
+
// so EVERY command timed out even against a healthy browser).
|
|
3370
|
+
try { socket.onmessage = (evt) => route(typeof evt === "string" ? evt : evt.data); } catch { /* send-only test doubles */ }
|
|
3371
|
+
return {
|
|
3372
|
+
pendingCount: () => pending.size,
|
|
3373
|
+
onEvent: (method, cb) => {
|
|
3374
|
+
if (!listeners.has(method)) listeners.set(method, new Set());
|
|
3375
|
+
listeners.get(method).add(cb);
|
|
3376
|
+
return () => { try { listeners.get(method).delete(cb); } catch { /* ignore */ } };
|
|
3377
|
+
},
|
|
3378
|
+
command(method, params = {}, timeoutMs = 10000) {
|
|
3379
|
+
const id = nextId++;
|
|
3380
|
+
return new Promise((resolve, reject) => {
|
|
3381
|
+
const timer = setTimeout(() => { pending.delete(id); reject(awardErr("CAPTURE_TIMEOUT", "CDP " + method + " timed out")); }, timeoutMs);
|
|
3382
|
+
pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); } });
|
|
3383
|
+
try { socket.send(JSON.stringify({ id, method, params })); }
|
|
3384
|
+
catch (e) { clearTimeout(timer); pending.delete(id); reject(e); }
|
|
3385
|
+
});
|
|
3386
|
+
},
|
|
3387
|
+
// Test seam: deliver an inbound message (out-of-order safe).
|
|
3388
|
+
deliver(msg) {
|
|
3389
|
+
if (msg && msg.id !== undefined && pending.has(msg.id)) {
|
|
3390
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
3391
|
+
pending.delete(msg.id);
|
|
3392
|
+
if (msg.error) reject(awardErr("UPSTREAM_ERROR", "CDP error"));
|
|
3393
|
+
else resolve(msg.result);
|
|
3394
|
+
} else if (msg && typeof msg.method === "string" && listeners.has(msg.method)) {
|
|
3395
|
+
for (const cb of listeners.get(msg.method)) { try { cb(msg.params || {}); } catch { /* ignore */ } }
|
|
3396
|
+
}
|
|
3397
|
+
},
|
|
3398
|
+
};
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
function findChromeExecutable({ envPath, exists = null, platform = null } = {}) {
|
|
3402
|
+
const { fs } = cdpBuiltins();
|
|
3403
|
+
const has = exists || ((p) => { try { return fs.existsSync(p); } catch { return false; } });
|
|
3404
|
+
const env = globalThis.process && globalThis.process.env ? globalThis.process.env : {};
|
|
3405
|
+
const plat = platform || (globalThis.process ? globalThis.process.platform : "linux");
|
|
3406
|
+
const candidates = [];
|
|
3407
|
+
const configured = envPath || env.AURA_CHROME_PATH;
|
|
3408
|
+
if (configured) candidates.push(configured);
|
|
3409
|
+
if (plat === "win32") {
|
|
3410
|
+
candidates.push("C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files\\Chromium\\Application\\chrome.exe");
|
|
3411
|
+
const pf = env["PROGRAMFILES"] || "C:\\Program Files";
|
|
3412
|
+
candidates.push(pf + "\\Google\\Chrome\\Application\\chrome.exe");
|
|
3413
|
+
} else if (plat === "darwin") {
|
|
3414
|
+
candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium");
|
|
3415
|
+
} else {
|
|
3416
|
+
candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium");
|
|
3417
|
+
}
|
|
3418
|
+
for (const c of candidates) if (c && has(c)) return c;
|
|
3419
|
+
// PATH lookup (which/where) as last resort.
|
|
3420
|
+
try {
|
|
3421
|
+
const { cp } = cdpBuiltins();
|
|
3422
|
+
const cmd = plat === "win32" ? "where chrome" : "which google-chrome chromium chromium-browser chrome";
|
|
3423
|
+
const out = cp.spawnSync(plat === "win32" ? "where" : "which", plat === "win32" ? ["chrome"] : ["google-chrome", "chromium", "chromium-browser", "chrome"], { encoding: "utf8", timeout: 5000 });
|
|
3424
|
+
const first = String((out && out.stdout) || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
|
|
3425
|
+
if (first && has(first)) return first;
|
|
3426
|
+
} catch { /* ignore */ }
|
|
3427
|
+
return null;
|
|
3428
|
+
}
|
|
3429
|
+
function findFfmpeg({ envPath } = {}) {
|
|
3430
|
+
const env = globalThis.process && globalThis.process.env ? globalThis.process.env : {};
|
|
3431
|
+
const configured = envPath || env.AURA_FFMPEG_PATH;
|
|
3432
|
+
if (configured) return configured;
|
|
3433
|
+
try {
|
|
3434
|
+
const { cp } = cdpBuiltins();
|
|
3435
|
+
const out = cp.spawnSync(globalThis.process && globalThis.process.platform === "win32" ? "where" : "which", ["ffmpeg"], { encoding: "utf8", timeout: 5000 });
|
|
3436
|
+
const first = String((out && out.stdout) || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
|
|
3437
|
+
return first || null;
|
|
3438
|
+
} catch { return null; }
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl = null, spawnImpl = null, now = () => Date.now() } = {}) {
|
|
3442
|
+
const { fs, path, os, cp } = cdpBuiltins();
|
|
3443
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
3444
|
+
const WS = socketImpl || globalThis.WebSocket;
|
|
3445
|
+
const spawn = (spawnImpl && spawnImpl.spawn) || cp.spawn;
|
|
3446
|
+
|
|
3447
|
+
function executable() {
|
|
3448
|
+
const { fs: fss } = cdpBuiltins();
|
|
3449
|
+
if (chromePath) {
|
|
3450
|
+
try { if (fss.existsSync(chromePath)) return chromePath; } catch { /* fall through */ }
|
|
3451
|
+
throw cdpUnavailable("configured chrome not found: " + chromePath);
|
|
3452
|
+
}
|
|
3453
|
+
return findChromeExecutable({});
|
|
3454
|
+
}
|
|
3455
|
+
async function launchIsolated(port) {
|
|
3456
|
+
const exe = executable();
|
|
3457
|
+
if (!exe) throw cdpUnavailable("");
|
|
3458
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "aura-cdp-"));
|
|
3459
|
+
const args = ["--headless=new", "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--disable-dev-shm-usage", "--no-sandbox", "--disable-setuid-sandbox", "--user-data-dir=" + profile, "--remote-debugging-port=" + port, "--remote-allow-origins=*", "about:blank"];
|
|
3460
|
+
const child = (() => {
|
|
3461
|
+
try { return spawn(exe, args, { stdio: "ignore", windowsHide: true, detached: false }); }
|
|
3462
|
+
catch (e) { throw cdpUnavailable("launch failed: " + String((e && e.message) || e).slice(0, 160)); }
|
|
3463
|
+
})();
|
|
3464
|
+
// A bad executable surfaces as an async ENOENT 'error' event, not a
|
|
3465
|
+
// throw: record it so the port poll below fails fast with an honest code.
|
|
3466
|
+
let spawnErr = null;
|
|
3467
|
+
try { child.once("error", (e) => { spawnErr = e; }); } catch { /* ignore */ }
|
|
3468
|
+
const cleanup = () => { try { child.kill(); } catch { /* ignore */ } try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* ignore */ } };
|
|
3469
|
+
// Wait for /json/version (up to 15s).
|
|
3470
|
+
const deadline = Date.now() + 15000;
|
|
3471
|
+
let err = null;
|
|
3472
|
+
while (Date.now() < deadline) {
|
|
3473
|
+
if (spawnErr) { cleanup(); throw cdpUnavailable("launch failed: " + String((spawnErr && spawnErr.message) || spawnErr).slice(0, 160)); }
|
|
3474
|
+
try {
|
|
3475
|
+
const res = await fetchFn("http://127.0.0.1:" + port + "/json/version", { signal: AbortSignal.timeout(2000) });
|
|
3476
|
+
if (res && res.ok) {
|
|
3477
|
+
const j = await res.json();
|
|
3478
|
+
return { child, profile, cleanup, wsUrl: j.webSocketDebuggerUrl };
|
|
3479
|
+
}
|
|
3480
|
+
} catch (e) { err = e; }
|
|
3481
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
3482
|
+
if (child.exitCode !== null && child.exitCode !== undefined) break;
|
|
3483
|
+
}
|
|
3484
|
+
cleanup();
|
|
3485
|
+
throw awardErr("BROWSER_UNAVAILABLE", "Chrome did not open a debug port (" + String((err && err.message) || "launch failed").slice(0, 160) + ")");
|
|
3486
|
+
}
|
|
3487
|
+
// Attach to a real page target via /json/list (stable across Chrome
|
|
3488
|
+
// builds). Target.createTarget over the browser endpoint hangs on several
|
|
3489
|
+
// headless=new builds, so it is only a last-resort fallback here.
|
|
3490
|
+
async function pageWsUrl(port) {
|
|
3491
|
+
const listUrl = "http://127.0.0.1:" + port + "/json/list";
|
|
3492
|
+
const read = async () => {
|
|
3493
|
+
const res = await fetchFn(listUrl, { signal: AbortSignal.timeout(3000) });
|
|
3494
|
+
if (!res || !res.ok) throw new Error("target list HTTP " + (res && res.status));
|
|
3495
|
+
return res.json();
|
|
3496
|
+
};
|
|
3497
|
+
let targets = await read();
|
|
3498
|
+
let page = (targets || []).find((t) => t && t.type === "page" && t.webSocketDebuggerUrl);
|
|
3499
|
+
if (!page) {
|
|
3500
|
+
try { await fetchFn("http://127.0.0.1:" + port + "/json/new?about:blank", { method: "PUT", signal: AbortSignal.timeout(5000) }); } catch { /* fall through to error below */ }
|
|
3501
|
+
targets = await read();
|
|
3502
|
+
page = (targets || []).find((t) => t && t.type === "page" && t.webSocketDebuggerUrl);
|
|
3503
|
+
}
|
|
3504
|
+
if (!page) throw new Error("no page target in " + listUrl);
|
|
3505
|
+
return page.webSocketDebuggerUrl;
|
|
3506
|
+
}
|
|
3507
|
+
// Bounded load wait: resolves on Page.loadEventFired, on network-idle
|
|
3508
|
+
// (no Page.lifecycleEvent for 2s after load when asked), or on timeout —
|
|
3509
|
+
// never hangs the tool call.
|
|
3510
|
+
function waitForSettled(conn, send, waitStrategy) {
|
|
3511
|
+
return new Promise((resolve) => {
|
|
3512
|
+
const cap = setTimeout(resolve, waitStrategy === "networkidle" ? 12000 : 15000);
|
|
3513
|
+
let lastActivity = Date.now();
|
|
3514
|
+
const idleTimer = waitStrategy === "networkidle" ? setInterval(() => {
|
|
3515
|
+
if (Date.now() - lastActivity > 2000) { clearInterval(idleTimer); clearTimeout(cap); resolve(); }
|
|
3516
|
+
}, 500) : null;
|
|
3517
|
+
const off = conn.onEvent("Page.loadEventFired", () => {
|
|
3518
|
+
lastActivity = Date.now();
|
|
3519
|
+
if (waitStrategy !== "networkidle") { if (idleTimer) clearInterval(idleTimer); clearTimeout(cap); off(); resolve(); }
|
|
3520
|
+
});
|
|
3521
|
+
conn.onEvent("Page.lifecycleEvent", () => { lastActivity = Date.now(); });
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
async function withPage({ viewport, fn, timeoutMs = CDP_CAPTURE_TIMEOUT_MS, op = "capture" }) {
|
|
3525
|
+
const vp = cdpResolveViewport(viewport);
|
|
3526
|
+
const port = 19222 + Math.floor(Math.random() * 2000);
|
|
3527
|
+
const { child, cleanup } = await launchIsolated(port);
|
|
3528
|
+
const stopTimer = setTimeout(() => { try { child.kill(); } catch { /* ignore */ } }, timeoutMs + 15000);
|
|
3529
|
+
try {
|
|
3530
|
+
if (!WS) throw cdpUnavailable("WebSocket unavailable in this Node build.");
|
|
3531
|
+
let wsUrl;
|
|
3532
|
+
try { wsUrl = await pageWsUrl(port); }
|
|
3533
|
+
catch (e) { throw cdpSetupFailed(op, e); }
|
|
3534
|
+
const ws = new WS(wsUrl, []);
|
|
3535
|
+
try {
|
|
3536
|
+
await new Promise((resolve, reject) => {
|
|
3537
|
+
const t = setTimeout(() => reject(new Error("CDP socket connect timed out")), 10000);
|
|
3538
|
+
ws.onopen = () => { clearTimeout(t); resolve(); };
|
|
3539
|
+
ws.onerror = () => { clearTimeout(t); reject(new Error("CDP socket error")); };
|
|
3540
|
+
});
|
|
3541
|
+
} catch (e) { try { ws.close(); } catch { /* ignore */ } throw cdpSetupFailed(op, e); }
|
|
3542
|
+
const conn = createCdpConn({ send: (s) => ws.send(s), set onmessage(v) { ws.onmessage = (e) => v(e.data); } });
|
|
3543
|
+
const send = (method, params, t) => conn.command(method, params, t);
|
|
3544
|
+
try {
|
|
3545
|
+
await send("Page.enable", {}, 8000);
|
|
3546
|
+
await send("Emulation.setDeviceMetricsOverride", { width: vp.width, height: vp.height, deviceScaleFactor: vp.deviceScaleFactor || 1, mobile: !!vp.isMobile }, 8000);
|
|
3547
|
+
} catch (e) { try { ws.close(); } catch { /* ignore */ } throw cdpSetupFailed(op, e); }
|
|
3548
|
+
const out = await fn({ send, conn });
|
|
3549
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
3550
|
+
return out;
|
|
3551
|
+
} finally { clearTimeout(stopTimer); cleanup(); }
|
|
3552
|
+
}
|
|
3553
|
+
// Post-navigation URL guard: the pre-flight only validates the REQUESTED
|
|
3554
|
+
// url, but Chrome follows server-side redirects on its own — a public URL
|
|
3555
|
+
// that lands on a blocked host (private IP, metadata endpoint, localhost)
|
|
3556
|
+
// must not be read any further. Reads the committed URL via CDP; a
|
|
3557
|
+
// non-string value (test doubles, detached contexts) is skipped rather
|
|
3558
|
+
// than failed, so honest timeouts stay honest.
|
|
3559
|
+
async function cdpNavGuard(send, allowLocal) {
|
|
3560
|
+
let href = null;
|
|
3561
|
+
try {
|
|
3562
|
+
const res = await send("Runtime.evaluate", { expression: "location.href", returnByValue: true }, 5000);
|
|
3563
|
+
href = res && res.result && typeof res.result.value === "string" ? res.result.value : null;
|
|
3564
|
+
} catch { return; }
|
|
3565
|
+
if (!href || !/^https?:\/\//i.test(href)) return;
|
|
3566
|
+
try { awardValidateHttpUrl(href, { allowLocal }); }
|
|
3567
|
+
catch (e) {
|
|
3568
|
+
const err = awardErr("UNSUPPORTED_OPERATION", "navigation ended on a blocked URL: " + href + " (" + String((e && e.message) || e).slice(0, 160) + ")");
|
|
3569
|
+
err.blockedUrl = href;
|
|
3570
|
+
throw err;
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
return {
|
|
3574
|
+
status() {
|
|
3575
|
+
let exe = null;
|
|
3576
|
+
try { exe = executable(); } catch { exe = null; }
|
|
3577
|
+
return { available: !!exe, executable: exe, ffmpeg: findFfmpeg({}), viewports: Object.keys(CDP_VIEWPORTS) };
|
|
3578
|
+
},
|
|
3579
|
+
async capture(url, { viewport = "desktop", waitStrategy = "load", settleMs = 1500, allowLocal = false, saveDir = null } = {}) {
|
|
3580
|
+
const target = awardValidateHttpUrl(url, { allowLocal });
|
|
3581
|
+
if (!["load", "networkidle"].includes(waitStrategy)) throw cdpBad("waitStrategy must be load|networkidle");
|
|
3582
|
+
const settle = Math.max(0, Math.min(settleMs ?? 1500, CDP_MAX_SETTLE_MS));
|
|
3583
|
+
const exe = executable();
|
|
3584
|
+
if (!exe) throw cdpUnavailable("");
|
|
3585
|
+
const startedAt = new Date(now()).toISOString();
|
|
3586
|
+
const shot = await withPage({
|
|
3587
|
+
viewport, op: "capture",
|
|
3588
|
+
async fn({ send, conn }) {
|
|
3589
|
+
const settled = waitForSettled(conn, send, waitStrategy);
|
|
3590
|
+
await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
|
|
3591
|
+
await settled;
|
|
3592
|
+
await cdpNavGuard(send, allowLocal);
|
|
3593
|
+
await new Promise((r) => setTimeout(r, settle));
|
|
3594
|
+
// Pre-scroll for lazy sections (bounded).
|
|
3595
|
+
try { await send("Runtime.evaluate", { expression: "(async()=>{for(let y=0;y<Math.min(document.documentElement.scrollHeight,10800);y+=450){scrollTo(0,y);await new Promise(r=>setTimeout(r,40));}scrollTo(0,0);await new Promise(r=>setTimeout(r,150));})()", awaitPromise: true }, 8000); } catch { /* best-effort */ }
|
|
3596
|
+
const meta = await send("Runtime.evaluate", { expression: "({title:document.title,w:innerWidth,h:innerHeight,total:Math.round(document.documentElement.scrollHeight)})", returnByValue: true }, 8000).catch(() => ({ result: { value: {} } }));
|
|
3597
|
+
const snap = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true }, 15000);
|
|
3598
|
+
return { meta: (meta && meta.result && meta.result.value) || {}, png: snap.data };
|
|
3599
|
+
},
|
|
3600
|
+
});
|
|
3601
|
+
const dir = saveDir || path.join(os.tmpdir(), "aura-captures");
|
|
3602
|
+
const png = Buffer.from(shot.png, "base64");
|
|
3603
|
+
let artifactPath = null;
|
|
3604
|
+
let artifactNote = null;
|
|
3605
|
+
try {
|
|
3606
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3607
|
+
const file = path.join(dir, "capture-" + String(now()) + ".png");
|
|
3608
|
+
fs.writeFileSync(file, png);
|
|
3609
|
+
artifactPath = file;
|
|
3610
|
+
} catch (e) {
|
|
3611
|
+
artifactNote = "screenshot kept in memory only — could not write to " + dir + " (" + String((e && e.message) || e).slice(0, 120) + ")";
|
|
3612
|
+
}
|
|
3613
|
+
// Retention: only for tool-owned tmp dirs (never a caller saveDir) —
|
|
3614
|
+
// keep the newest 20 captures so long agent sessions cannot fill tmp.
|
|
3615
|
+
let retentionNote = null;
|
|
3616
|
+
if (!saveDir) {
|
|
3617
|
+
try {
|
|
3618
|
+
const removed = cdpPruneOldFiles(dir, { prefix: "capture-", keepNewest: 20 });
|
|
3619
|
+
if (removed > 0) retentionNote = "pruned " + removed + " older capture file(s) (newest 20 kept in " + dir + "; pass saveDir to manage your own directory)";
|
|
3620
|
+
} catch { /* best-effort */ }
|
|
3621
|
+
}
|
|
3622
|
+
return { url: target, viewport, viewportPx: cdpResolveViewport(viewport), capturedAt: startedAt, ...(artifactPath ? { artifactPath } : {}), ...(artifactNote ? { artifactNote } : {}), ...(retentionNote ? { retentionNote } : {}), bytes: png.length, page: shot.meta, limitations: ["lazy-loaded sections below the fold may still be placeholders", "blocked third-party resources are not retried"], source: "live-browser" };
|
|
3623
|
+
},
|
|
3624
|
+
async structure(url, { viewport = "desktop", allowLocal = false } = {}) {
|
|
3625
|
+
const target = awardValidateHttpUrl(url, { allowLocal });
|
|
3626
|
+
const exe = executable();
|
|
3627
|
+
if (!exe) throw cdpUnavailable("");
|
|
3628
|
+
const bands = await withPage({
|
|
3629
|
+
viewport, op: "structure",
|
|
3630
|
+
async fn({ send, conn }) {
|
|
3631
|
+
const settled = waitForSettled(conn, send, "load");
|
|
3632
|
+
await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
|
|
3633
|
+
await settled;
|
|
3634
|
+
await cdpNavGuard(send, allowLocal);
|
|
3635
|
+
const res = await send("Runtime.evaluate", { expression: CDP_SCAN_JS, returnByValue: true }, 10000);
|
|
3636
|
+
return (res && res.result && res.result.value) || { title: target, totalHeight: 0, candidates: [] };
|
|
3637
|
+
},
|
|
3638
|
+
});
|
|
3639
|
+
const collapsed = cdpCollapseBands(bands.candidates || [], bands.totalHeight || 0);
|
|
3640
|
+
return { url: target, title: bands.title || target, totalHeight: bands.totalHeight || 0, bands: collapsed, viewport, viewportPx: cdpResolveViewport(viewport), source: "live-dom" };
|
|
3641
|
+
},
|
|
3642
|
+
async motionInspect(url, { viewport = "desktop", allowLocal = false } = {}) {
|
|
3643
|
+
const target = awardValidateHttpUrl(url, { allowLocal });
|
|
3644
|
+
const exe = executable();
|
|
3645
|
+
if (!exe) throw cdpUnavailable("");
|
|
3646
|
+
const scan = await withPage({
|
|
3647
|
+
viewport, op: "motion",
|
|
3648
|
+
async fn({ send, conn }) {
|
|
3649
|
+
const settled = waitForSettled(conn, send, "load");
|
|
3650
|
+
await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
|
|
3651
|
+
await settled;
|
|
3652
|
+
await cdpNavGuard(send, allowLocal);
|
|
3653
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
3654
|
+
const res = await send("Runtime.evaluate", { expression: CDP_MOTION_JS, returnByValue: true }, 10000);
|
|
3655
|
+
return (res && res.result && res.result.value) || { libs: [], render: [], scrollTrigger: { count: 0 }, tweens: { durations: [], eases: [] }, cssBeziers: [] };
|
|
3656
|
+
},
|
|
3657
|
+
});
|
|
3658
|
+
return reduceCdpScan(target, scan, now(), viewport);
|
|
3659
|
+
},
|
|
3660
|
+
// Bounded motion record: one session, N screenshots across a scroll tour.
|
|
3661
|
+
// Returns frame files + inspect evidence; webm only when ffmpeg exists.
|
|
3662
|
+
async recordFrames(url, { frames = 8, viewport = "desktop", allowLocal = false, saveDir = null, durationMs = 12000 } = {}) {
|
|
3663
|
+
const target = awardValidateHttpUrl(url, { allowLocal });
|
|
3664
|
+
const n = Math.max(2, Math.min(frames || 8, CDP_MAX_FRAMES));
|
|
3665
|
+
const budget = Math.max(4000, Math.min(durationMs || 12000, CDP_MAX_RECORD_MS));
|
|
3666
|
+
const exe = executable();
|
|
3667
|
+
if (!exe) throw cdpUnavailable("");
|
|
3668
|
+
const dir = saveDir || fs.mkdtempSync(path.join(os.tmpdir(), "aura-motion-"));
|
|
3669
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
|
3670
|
+
const startedAt = new Date(now()).toISOString();
|
|
3671
|
+
const stepMs = Math.floor(budget / (n + 2));
|
|
3672
|
+
const out = await withPage({
|
|
3673
|
+
viewport, timeoutMs: budget + 30000, op: "motion",
|
|
3674
|
+
async fn({ send, conn }) {
|
|
3675
|
+
const settled = waitForSettled(conn, send, "load");
|
|
3676
|
+
await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
|
|
3677
|
+
await settled;
|
|
3678
|
+
await cdpNavGuard(send, allowLocal);
|
|
3679
|
+
await new Promise((r) => setTimeout(r, Math.min(4000, stepMs)));
|
|
3680
|
+
const files = [];
|
|
3681
|
+
for (let i = 0; i < n; i++) {
|
|
3682
|
+
try {
|
|
3683
|
+
await send("Runtime.evaluate", { expression: "scrollTo(0, Math.round(document.documentElement.scrollHeight * " + (i / Math.max(1, n - 1)) + "))", returnByValue: true }, 5000);
|
|
3684
|
+
} catch { /* ignore */ }
|
|
3685
|
+
await new Promise((r) => setTimeout(r, Math.min(stepMs, 3000)));
|
|
3686
|
+
try {
|
|
3687
|
+
const snap = await send("Page.captureScreenshot", { format: "jpeg", quality: 70 }, 10000);
|
|
3688
|
+
const file = path.join(dir, "frame-" + String(i).padStart(2, "0") + ".jpg");
|
|
3689
|
+
fs.writeFileSync(file, Buffer.from(snap.data, "base64"));
|
|
3690
|
+
files.push(file);
|
|
3691
|
+
} catch { /* partial capture: keep what we have */ }
|
|
3692
|
+
}
|
|
3693
|
+
let inspect = null;
|
|
3694
|
+
try {
|
|
3695
|
+
const res = await send("Runtime.evaluate", { expression: CDP_MOTION_JS, returnByValue: true }, 10000);
|
|
3696
|
+
inspect = (res && res.result && res.result.value) || null;
|
|
3697
|
+
} catch { inspect = null; }
|
|
3698
|
+
return { files, inspect };
|
|
3699
|
+
},
|
|
3700
|
+
});
|
|
3701
|
+
const study = out.inspect ? reduceCdpScan(target, out.inspect, now(), viewport) : null;
|
|
3702
|
+
// Retention: only prunes tool-created sibling dirs in tmp (never a
|
|
3703
|
+
// caller saveDir), and never the current run's directory.
|
|
3704
|
+
let retentionNote = null;
|
|
3705
|
+
if (!saveDir) {
|
|
3706
|
+
try {
|
|
3707
|
+
const removed = cdpPruneOldDirs(os.tmpdir(), { prefix: "aura-motion-", maxAgeMs: 24 * 60 * 60 * 1000, skip: dir });
|
|
3708
|
+
if (removed > 0) retentionNote = "pruned " + removed + " prior motion-study dir(s) older than 24h (this run's frames kept at " + dir + ")";
|
|
3709
|
+
} catch { /* best-effort */ }
|
|
3710
|
+
}
|
|
3711
|
+
return { url: target, viewport, startedAt, frames: out.files, frameCount: out.files.length, study, dir, ...(retentionNote ? { retentionNote } : {}) };
|
|
3712
|
+
},
|
|
3713
|
+
async encodeWebm(frameFiles, { ffmpegPath = null } = {}) {
|
|
3714
|
+
if (!Array.isArray(frameFiles) || !frameFiles.length) throw cdpBad("no frames to encode");
|
|
3715
|
+
const bin = ffmpegPath || findFfmpeg({});
|
|
3716
|
+
if (!bin) return { video: null, available: false, hint: "ffmpeg not found; install ffmpeg or set AURA_FFMPEG_PATH for .webm output" };
|
|
3717
|
+
// Only encode frames that actually exist on disk, bounded to the
|
|
3718
|
+
// recording cap; verify they share one directory before shelling out.
|
|
3719
|
+
const requested = frameFiles.slice(0, CDP_MAX_FRAMES).filter((f) => typeof f === "string" && f);
|
|
3720
|
+
const existing = requested.filter((f) => { try { return fs.existsSync(f); } catch { return false; } });
|
|
3721
|
+
if (!existing.length) return { video: null, available: false, hint: "frame files are gone (nothing on disk to encode); re-run mode:record to capture frames" };
|
|
3722
|
+
const dir = path.dirname(existing[0]);
|
|
3723
|
+
if (!existing.every((f) => path.dirname(f) === dir)) return { video: null, available: false, hint: "frame files span multiple directories; cannot encode as one sequence" };
|
|
3724
|
+
const first = /-(\d+)\.jpg$/i.exec(path.basename(existing[0]));
|
|
3725
|
+
const startNumber = first ? String(parseInt(first[1], 10)) : "0";
|
|
3726
|
+
const video = path.join(dir, "motion.webm");
|
|
3727
|
+
const args = ["-y", "-framerate", "2", "-start_number", startNumber, "-i", path.join(dir, "frame-%02d.jpg"), "-c:v", "libvpx", "-pix_fmt", "yuv420p", video];
|
|
3728
|
+
const res = cp.spawnSync(bin, args, { timeout: 60000, windowsHide: true });
|
|
3729
|
+
if (res && res.status === 0 && fs.existsSync(video)) return { video, available: true, bytes: fs.statSync(video).size, framesUsed: existing.length };
|
|
3730
|
+
return { video: null, available: false, hint: "ffmpeg encode failed (" + String(res && res.status) + "); frame JPEGs retained" };
|
|
3731
|
+
},
|
|
3732
|
+
};
|
|
3733
|
+
}
|
|
3734
|
+
function reduceCdpScan(url, scan, t, viewport) {
|
|
3735
|
+
const libs = Array.isArray(scan.libs) ? scan.libs : [];
|
|
3736
|
+
const render = Array.isArray(scan.render) ? scan.render : [];
|
|
3737
|
+
const st = scan.scrollTrigger || { count: 0, scrub: 0, pin: 0, sample: [] };
|
|
3738
|
+
const durations = ((scan.tweens && scan.tweens.durations) || []).filter((d) => typeof d === "number");
|
|
3739
|
+
const eases = ((scan.tweens && scan.tweens.eases) || []).concat(scan.cssBeziers || []).filter(Boolean);
|
|
3740
|
+
const agg = new Map();
|
|
3741
|
+
for (const raw of eases) {
|
|
3742
|
+
if (raw === "none" || raw === "linear") continue;
|
|
3743
|
+
const e = agg.get(raw) || { token: raw, uses: 0 };
|
|
3744
|
+
e.uses++;
|
|
3745
|
+
agg.set(raw, e);
|
|
3746
|
+
}
|
|
3747
|
+
const ms = durations.map((d) => d * 1000).sort((a, b) => a - b);
|
|
3748
|
+
const q = (p) => { if (!ms.length) return null; const i = p * (ms.length - 1); const lo = Math.floor(i), hi = Math.ceil(i); return Math.round(ms[lo] + (ms[hi] - ms[lo]) * (i - lo)); };
|
|
3749
|
+
return {
|
|
3750
|
+
url, viewport: viewport || "desktop",
|
|
3751
|
+
stack: { libs, render, scrollModel: libs.includes("lenis") ? "lenis" : libs.includes("locomotive") ? "locomotive" : libs.length ? "native" : "unknown" },
|
|
3752
|
+
scroll: { triggerCount: st.count || 0, scrubCount: st.scrub || 0, pinCount: st.pin || 0, scrubRatio: st.count ? (st.scrub || 0) / st.count : 0, sample: (st.sample || []).slice(0, 20) },
|
|
3753
|
+
easingVocab: [...agg.values()].sort((a, b) => b.uses - a.uses).slice(0, 15),
|
|
3754
|
+
durationVocab: ms.length ? { p25: q(0.25), median: q(0.5), p75: q(0.75) } : null,
|
|
3755
|
+
capturedAt: t, toolVersion: "aura/1.7.3", source: "live-browser",
|
|
3756
|
+
};
|
|
3757
|
+
}
|
|
3758
|
+
|
|
1633
3759
|
// ---- tools.mjs ----
|
|
1634
3760
|
// Module: tools — thin adapters. Each tool validates args at the seam,
|
|
1635
3761
|
// calls one catalog/guide method, wraps textResult. No retries/caching here
|
|
@@ -1700,8 +3826,14 @@ const TOOL_DEFS = [
|
|
|
1700
3826
|
{ name: 'aura_docs_check', description: 'Check pasted code against fresh docs (pure). library + code_excerpt (under 20000 chars, excerpts not full pages) + optional docs_note -> verdict current|needs-update, look-preserving fixes, version-pin needs_check. Fresh API wins, look stays identical.', inputSchema: { type: 'object', properties: { library: { type: 'string' }, code_excerpt: { type: 'string' }, docs_note: { type: 'string' } }, required: ['library', 'code_excerpt'], additionalProperties: false } },
|
|
1701
3827
|
{ name: 'aura_icon', description: 'Fetch one free icon as ready-to-paste SVG (live). name in kebab-case (bolt, arrow-down). source auto|fontawesome|pixel, style auto|solid|regular|brands. Auto-fallback across styles then libraries; every hit carries license + credit (CC BY 4.0, credit always) plus a font/CSS alternative. 24h icon cache.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, source: { type: 'string', enum: ['auto', 'fontawesome', 'pixel'] }, style: { type: 'string', enum: ['solid', 'regular', 'brands', 'auto'] } }, required: ['name'], additionalProperties: false } },
|
|
1702
3828
|
{ name: 'aura_version', description: 'Running vs npm-published version (live, hourly cache). Returns update_available + notice telling the agent to prefer latest (npm i -g aura-components-mcp@latest) when behind. Never fails — offline registries return latest:null. aura_status already carries this verdict on every call.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
3829
|
+
{ name: 'aura_award_search', description: 'Search award-winning website references (Awwwards). Modes: sites (ranked refs with preview links), categories (real taxonomy from the index/source), elements (hero/nav/pricing/footer examples), motion (previously recorded motion studies). Local index first, one polite live refresh when empty.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['sites', 'categories', 'elements', 'motion'] }, query: { type: 'string' }, slug: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, category: { type: 'string' }, color: { type: 'string' }, technology: { type: 'string' }, award: { type: 'string', enum: ['sotd', 'developer', 'honorable'] }, studio: { type: 'string' }, limit: { type: 'number' }, offset: { type: 'number' }, full: { type: 'boolean' }, library: { type: 'string' }, easing: { type: 'string' }, scrollModel: { type: 'string' } }, required: ['mode'], additionalProperties: false } },
|
|
3830
|
+
{ name: 'aura_award_inspect', description: 'Inspect award refs: site_details (palette/tech/awards/evidence), site_elements (section visuals with media URLs), element (one gallery record), compare_sites (2-3 refs: shared vs gaps, missing marked unknown).', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['site_details', 'site_elements', 'element', 'compare_sites'] }, slug: { type: 'string' }, elementSlug: { type: 'string' }, slugs: { type: 'array', items: { type: 'string' } } }, required: ['mode'], additionalProperties: false } },
|
|
3831
|
+
{ name: 'aura_design_blueprint', description: 'Evidence-backed build plan from 2-5 award refs + goal: Markdown brief + versioned JSON, mapped to real free Aura components/systems (free-only enforced, failures marked unavailable). Never copies third-party source.', inputSchema: { type: 'object', properties: { goal: { type: 'string' }, references: { type: 'array', items: { type: 'string' } }, technology: { type: 'string' }, visualDirection: { type: 'string' }, responsive: { type: 'string' }, constraints: { type: 'array', items: { type: 'string' } }, includeStructure: { type: 'boolean' } }, required: ['goal', 'references'], additionalProperties: false } },
|
|
3832
|
+
{ name: 'aura_visual_audit', description: 'Capture/analyze a reference or your build. Modes: capture (real browser screenshot), structure (section band map: live-dom or static-html), compare (reference vs build mismatches). Needs Chrome for live capture; honest BROWSER_UNAVAILABLE otherwise. Loopback only with allowLocal:true.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['capture', 'structure', 'compare'] }, url: { type: 'string' }, referenceUrl: { type: 'string' }, buildUrl: { type: 'string' }, viewport: { type: 'string', enum: ['desktop', 'mobile'] }, waitStrategy: { type: 'string', enum: ['load', 'networkidle'] }, settleMs: { type: 'number' }, allowLocal: { type: 'boolean' } }, required: ['mode'], additionalProperties: false } },
|
|
3833
|
+
{ name: 'aura_motion_study', description: 'Evidence-backed motion guidance. Modes: inspect (live runtime scan: libs/easing/scroll), record (bounded frame study; .webm only when ffmpeg present), search (stored studies by library/easing/scroll). Never claims unobserved library state.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['inspect', 'record', 'search'] }, url: { type: 'string' }, library: { type: 'string' }, easing: { type: 'string' }, scrollModel: { type: 'string' }, frames: { type: 'number' }, durationMs: { type: 'number' }, viewport: { type: 'string', enum: ['desktop', 'mobile'] }, allowLocal: { type: 'boolean' } }, required: ['mode'], additionalProperties: false } },
|
|
3834
|
+
{ name: 'aura_award_index', description: 'Local award corpus: status (counts/freshness/lock), rebuild/resume/refresh (bounded polite crawl), new_winners (baseline then delta), watch_add/list/remove (studio/tag/url watches). Never auto-crawls on startup.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['status', 'rebuild', 'resume', 'refresh', 'new_winners', 'watch_add', 'watch_list', 'watch_remove'] }, award: { type: 'string', enum: ['sotd', 'developer', 'honorable'] }, maxPages: { type: 'number' }, kind: { type: 'string', enum: ['studio', 'tag', 'url'] }, pattern: { type: 'string' } }, required: ['action'], additionalProperties: false } },
|
|
1703
3835
|
];
|
|
1704
|
-
function createHandlers(catalog, icons, version) {
|
|
3836
|
+
function createHandlers(catalog, icons, version, awardDeps) {
|
|
1705
3837
|
// Automatic update notice: every aura_status carries the cached version
|
|
1706
3838
|
// verdict (hourly npm re-check), so agents learn about updates unprompted.
|
|
1707
3839
|
const versionNote = async () => {
|
|
@@ -1710,6 +3842,81 @@ function createHandlers(catalog, icons, version) {
|
|
|
1710
3842
|
} catch { /* status must never fail on version check */ }
|
|
1711
3843
|
return null;
|
|
1712
3844
|
};
|
|
3845
|
+
// Award subsystem deps (optional for old callers): client (live fetch),
|
|
3846
|
+
// index (persistent JSON store), browser (native CDP). Missing pieces stay
|
|
3847
|
+
// honest: local-empty results + unavailable notes, never invented data.
|
|
3848
|
+
const awardClient = (awardDeps && awardDeps.awardClient) || null;
|
|
3849
|
+
const awardBrowser = (awardDeps && awardDeps.browser) || null;
|
|
3850
|
+
const awardIndex = (awardDeps && awardDeps.awardIndex) || awardFallbackIndex();
|
|
3851
|
+
function awardFallbackIndex() {
|
|
3852
|
+
const sites = new Map(); const elements = new Map(); const motions = [];
|
|
3853
|
+
let tax = { colors: [], filters: [] }; let watches = [];
|
|
3854
|
+
return {
|
|
3855
|
+
root: "(memory)", files: [],
|
|
3856
|
+
getStatus: () => ({ dir: "(memory)", version: 1, counts: { sites: sites.size, elements: elements.size, motion: motions.length, taxonomyFilters: tax.filters.length, taxonomyColors: tax.colors.length, watches: watches.length }, freshness: null, stale: true, progress: null, pendingPages: [], lock: { held: false } }),
|
|
3857
|
+
listSites: (n) => [...sites.values()].slice(0, n || 50),
|
|
3858
|
+
getSite: (slug) => { try { return sites.get(String(slug).toLowerCase()) || null; } catch { return null; } },
|
|
3859
|
+
upsertSites: (rows) => { let added = 0; for (const s of (rows || [])) { if (s && s.slug && !sites.has(String(s.slug).toLowerCase())) added++; if (s && s.slug) sites.set(String(s.slug).toLowerCase(), s); } return { added, updated: 0, total: sites.size }; },
|
|
3860
|
+
getTaxonomy: () => ({ version: 1, colors: tax.colors, filters: tax.filters }),
|
|
3861
|
+
setTaxonomy: (t) => { tax = { colors: (t.colors || []).slice(0, 100), filters: (t.filters || []).slice(0, 400) }; return { colors: tax.colors.length, filters: tax.filters.length }; },
|
|
3862
|
+
upsertElements: (rows) => { let added = 0; for (const e of (rows || [])) { if (e && e.slug && !elements.has(String(e.slug))) added++; if (e && e.slug) elements.set(String(e.slug), e); } return { added, total: elements.size }; },
|
|
3863
|
+
listElements: (n) => [...elements.values()].slice(0, n || 50),
|
|
3864
|
+
getElement: (slug) => elements.get(String(slug)) || null,
|
|
3865
|
+
recordMotion: (s) => { const i = motions.findIndex((m) => m.url === s.url); if (i >= 0) motions.splice(i, 1); motions.unshift(s); return s; },
|
|
3866
|
+
searchMotion: (f) => {
|
|
3867
|
+
let items = [...motions];
|
|
3868
|
+
if (f.library) items = items.filter((m) => JSON.stringify(m).toLowerCase().includes(String(f.library).toLowerCase()));
|
|
3869
|
+
if (f.easing) items = items.filter((m) => JSON.stringify(m.easingVocab || []).toLowerCase().includes(String(f.easing).toLowerCase()));
|
|
3870
|
+
if (f.scrollModel) items = items.filter((m) => String((m.stack && m.stack.scrollModel) || "").toLowerCase() === String(f.scrollModel).toLowerCase());
|
|
3871
|
+
return { total: items.length, items: items.slice(0, 10), empty: motions.length === 0 };
|
|
3872
|
+
},
|
|
3873
|
+
newWinners: async () => { throw awardErr("UNSUPPORTED_OPERATION", "new_winners needs a persistent index + award client; wire the server default"); },
|
|
3874
|
+
rebuild: async () => { throw awardErr("UNSUPPORTED_OPERATION", "rebuild needs a persistent index + award client"); },
|
|
3875
|
+
resume: async () => { throw awardErr("UNSUPPORTED_OPERATION", "resume needs a persistent index + award client"); },
|
|
3876
|
+
refresh: async () => { throw awardErr("UNSUPPORTED_OPERATION", "refresh needs a persistent index + award client"); },
|
|
3877
|
+
watchAdd: (w) => { if (watches.some((x) => x.kind === w.kind && x.pattern === w.pattern)) throw awardErr("BAD_ARGS", "duplicate watch"); if (watches.length >= 50) throw awardErr("BAD_ARGS", "too many watches"); const r = { ...w, createdAt: new Date().toISOString(), lastCheckedAt: null, lastSlugs: null }; watches.push(r); return r; },
|
|
3878
|
+
watchList: () => watches,
|
|
3879
|
+
watchRemove: ({ kind, pattern }) => { const b = watches.length; watches = watches.filter((w) => !(w.kind === kind && w.pattern === String(pattern).toLowerCase())); return { removed: watches.length < b }; },
|
|
3880
|
+
checkWatches: () => [],
|
|
3881
|
+
};
|
|
3882
|
+
}
|
|
3883
|
+
function awardMode(v, allowed, name) { if (v === undefined) toolFn_bad(name + ' is required'); if (!allowed.includes(v)) toolFn_bad(name + ' must be one of ' + allowed.join('|')); return v; }
|
|
3884
|
+
function awardLimit(v, def, max) { if (v === undefined) return def; if (typeof v !== 'number' || !Number.isFinite(v)) toolFn_bad('limit must be a number'); return Math.max(1, Math.min(Math.floor(v), max)); }
|
|
3885
|
+
function awardStrArr(v, name, max) { if (v === undefined) return undefined; if (!Array.isArray(v)) toolFn_bad(name + ' must be an array of strings'); if (v.length > max) toolFn_bad(name + ' holds max ' + max + ' entries'); for (const e of v) if (typeof e !== 'string' || !e.trim()) toolFn_bad(name + ' must hold non-empty strings'); return v.map((e) => e.trim()); }
|
|
3886
|
+
async function awardDetailFor(slug, { withStatic = false } = {}) {
|
|
3887
|
+
const norm = awardNormalizeSlug(slug);
|
|
3888
|
+
const cached = awardIndex.getSite(norm);
|
|
3889
|
+
if (!awardClient) return { slug: norm, summary: cached, detail: null, detailStatus: cached ? 'cached' : 'unavailable', liveError: 'award client not wired' };
|
|
3890
|
+
try {
|
|
3891
|
+
const { html } = await awardClient.getAwardHtml('/sites/' + norm);
|
|
3892
|
+
const detail = awardParseDetail(html, norm);
|
|
3893
|
+
const cachedTags = (cached && cached.tags) || [];
|
|
3894
|
+
// Tags vs Technologies consistency (#5): listing tags (WebGL, 3D, …)
|
|
3895
|
+
// exist even when the page has no Technologies section. Backfill from
|
|
3896
|
+
// tags and LABEL it inferred — never present it as parsed section data.
|
|
3897
|
+
if ((!detail.technologies || !detail.technologies.length) && cachedTags.length) {
|
|
3898
|
+
detail.technologies = [...new Set(cachedTags.map((t) => String(t)))].slice(0, 12);
|
|
3899
|
+
detail.technologiesInferred = true;
|
|
3900
|
+
detail.technologyEvidence = "inferred from listing tags; page has no Technologies section";
|
|
3901
|
+
}
|
|
3902
|
+
const summary = { slug: norm, title: detail.title || (cached && cached.title) || norm, createdAt: (cached && cached.createdAt) || 0, tags: (cached && cached.tags) || [], thumbnailPath: (cached && cached.thumbnailPath) || "", liveUrl: detail.liveUrl || (cached && cached.liveUrl) || null, detailPath: "/sites/" + norm, awards: (detail.awards || []).map((a) => a.title), studio: (cached && cached.studio) || null, source: "awwwards", sourceUrl: detail.sourceUrl };
|
|
3903
|
+
try { awardIndex.upsertSites([summary]); } catch { /* memory fallback never fails */ }
|
|
3904
|
+
try {
|
|
3905
|
+
const media = awardParseElementsSection(html);
|
|
3906
|
+
if (media && media.length) awardIndex.upsertElements(media.map((m) => ({ slug: 'site-' + norm + '-' + String(m.title || 'element').toLowerCase().replace(/\W+/g, '-').slice(0, 60), title: m.title || 'Element', author: summary.studio || summary.title, builtWith: [], related: [], mediaPath: m.mediaPath, mediaType: String(m.mediaPath).endsWith('.mp4') ? 'video' : 'image', mediaUrl: awardElementUrl(m.mediaPath), category: 'uncategorized', siteSlug: norm, source: 'site', sourceUrl: summary.sourceUrl })));
|
|
3907
|
+
} catch { /* backfill best-effort */ }
|
|
3908
|
+
// Static motion cues (opt-in per caller: one extra bounded fetch).
|
|
3909
|
+
// Recorded studies stay authoritative; static markup is supplementary.
|
|
3910
|
+
let motionStatic = null;
|
|
3911
|
+
if (withStatic && summary.liveUrl) {
|
|
3912
|
+
try { motionStatic = await awardStaticMotion(summary.liveUrl); } catch { motionStatic = { status: "unavailable", url: summary.liveUrl, reason: "static scan failed" }; }
|
|
3913
|
+
}
|
|
3914
|
+
return { slug: norm, summary, detail, detailStatus: 'fetched', liveError: null, ...(motionStatic ? { motion_static: motionStatic } : {}) };
|
|
3915
|
+
} catch (e) {
|
|
3916
|
+
if (e && e.code === 'NOT_FOUND') throw e;
|
|
3917
|
+
return { slug: norm, summary: cached, detail: null, detailStatus: cached ? 'cached' : 'unavailable', liveError: String((e && e.message) || e).slice(0, 220) };
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
1713
3920
|
return {
|
|
1714
3921
|
aura_status: async () => {
|
|
1715
3922
|
const [status, update] = await Promise.all([catalog.getStatus(), versionNote()]);
|
|
@@ -1880,7 +4087,419 @@ function createHandlers(catalog, icons, version) {
|
|
|
1880
4087
|
const sysItem = sys && sys.item ? sys.item : null;
|
|
1881
4088
|
const toks = sysItem ? tokenHints(sysItem.content || '') : { tokens: {}, css: '' };
|
|
1882
4089
|
const { guides, combined_deps: deps, files } = combineScaffold(sysItem, freeComps);
|
|
1883
|
-
return textResult({ goal: a.goal, order: ['1 tokens.css', '2 system preview_html', '3 component markup in listed order'], tokens_css: toks.css || '', system: sysItem, components: freeComps || [], guides, combined_deps: deps, files, docs_stamp: docsStampFor(deps), ...(bundle.dropped ? { dropped_refs: bundle.dropped, dropped_note: 'Some requested refs resolved to missing or Pro (paid) components and were omitted — see per-id errors via aura_bundle.' } : {}), ...(proFiltered ? { pro_filtered_out: proFiltered, pro_note: 'Pro (paid) components were refused — free-only scaffold ships free rows.' } : {}), license_note: 'Markup and any pulled assets are all-rights-reserved per Aura Terms §4 — fine to prototype, check each page_url before commercial ship.' }); },
|
|
4090
|
+
return textResult({ goal: a.goal, order: ['1 tokens.css', '2 system preview_html', '3 component markup in listed order'], tokens_css: toks.css || '', system: sysItem, ...(sysItem ? {} : { system_note: 'no design system matched this goal — tokens.css is empty; fetch one via aura_search_design_systems or proceed with component defaults' }), components: freeComps || [], guides, combined_deps: deps, files, docs_stamp: docsStampFor(deps), ...(bundle.dropped ? { dropped_refs: bundle.dropped, dropped_note: 'Some requested refs resolved to missing or Pro (paid) components and were omitted — see per-id errors via aura_bundle.' } : {}), ...(proFiltered ? { pro_filtered_out: proFiltered, pro_note: 'Pro (paid) components were refused — free-only scaffold ships free rows.' } : {}), license_note: 'Markup and any pulled assets are all-rights-reserved per Aura Terms §4 — fine to prototype, check each page_url before commercial ship.' }); },
|
|
4091
|
+
aura_award_search: async (a) => { a = a || {}; const mode = awardMode(a.mode, ['sites', 'categories', 'elements', 'motion'], 'mode');
|
|
4092
|
+
const limit = awardLimit(a.limit, 5, 25); const offset = Math.max(0, a.offset ?? 0); const full = a.full === undefined ? false : toolFn_bool(a.full, 'full');
|
|
4093
|
+
if (mode === 'categories') {
|
|
4094
|
+
let tax = awardIndex.getTaxonomy();
|
|
4095
|
+
let source = 'local-index';
|
|
4096
|
+
if ((!tax.filters.length && !tax.colors.length) && awardClient) {
|
|
4097
|
+
try { const { html } = await awardClient.getAwardHtml('/websites/'); const parsed = awardParseCategories(html); awardIndex.setTaxonomy(parsed); tax = awardIndex.getTaxonomy(); source = 'live'; }
|
|
4098
|
+
catch (e) { return textResult({ mode, colors: [], filters: [], source: 'unavailable', liveError: String((e && e.message) || e).slice(0, 220), note: 'taxonomy unavailable; rebuild the index or retry later' }); }
|
|
4099
|
+
}
|
|
4100
|
+
// Paginated: colors are few (return all); filters page with limit/offset.
|
|
4101
|
+
const allFilters = tax.filters || [];
|
|
4102
|
+
return textResult({ mode, colors: tax.colors || [], filters: allFilters.slice(offset, offset + limit), filtersTotal: allFilters.length, limit, offset, source, updatedAt: tax.updatedAt || null, note: allFilters.length > limit ? 'filters paged — raise offset for more' : undefined });
|
|
4103
|
+
}
|
|
4104
|
+
if (mode === 'motion') {
|
|
4105
|
+
const r = awardIndex.searchMotion({ library: toolFn_str(a.library, 'library'), easing: toolFn_str(a.easing, 'easing'), scrollModel: a.scrollModel === undefined ? undefined : String(a.scrollModel), limit });
|
|
4106
|
+
if (r.empty) return textResult({ mode, total: 0, items: [], emptyCorpus: true, note: 'no motion studies recorded yet — run aura_motion_study {mode:inspect|record} first' });
|
|
4107
|
+
return textResult({ mode, total: r.total, items: r.items, emptyCorpus: false });
|
|
4108
|
+
}
|
|
4109
|
+
if (mode === 'elements') {
|
|
4110
|
+
const q = toolFn_str(a.query, 'query') || ''; const cat = toolFn_str(a.category, 'category');
|
|
4111
|
+
let pool = awardIndex.listElements(500);
|
|
4112
|
+
if (!pool.length && awardClient && q) {
|
|
4113
|
+
// Honest empty: element gallery needs per-item fetches; point at winners backfill instead of scraping blindly.
|
|
4114
|
+
return textResult({ mode, total: 0, items: [], note: 'element corpus is empty; fetch site elements via aura_award_inspect {mode:site_elements} or record new_winners to backfill' });
|
|
4115
|
+
}
|
|
4116
|
+
let items = pool;
|
|
4117
|
+
if (cat) items = items.filter((e) => String(e.category || '').toLowerCase() === cat.toLowerCase() || String(e.title || '').toLowerCase().includes(cat.toLowerCase()));
|
|
4118
|
+
if (q) {
|
|
4119
|
+
const toks = awardTokens(q);
|
|
4120
|
+
items = items.map((e) => { const hay = (String(e.title || '') + ' ' + String(e.author || '') + ' ' + String(e.category || '')).toLowerCase(); let s = 0; for (const t of toks) if (hay.includes(t)) s++; return { e, s }; }).filter((x) => x.s > 0).sort((x, y) => y.s - x.s || String(x.e.title).localeCompare(String(y.e.title))).map((x) => x.e);
|
|
4121
|
+
} else items = [...items].sort((x, y) => String(x.title).localeCompare(String(y.title)));
|
|
4122
|
+
const page = items.slice(offset, offset + limit).map((e) => ({ slug: e.slug, title: e.title, author: e.author || null, category: e.category || null, mediaUrl: e.mediaUrl || (e.mediaPath ? awardElementUrl(e.mediaPath) : null), mediaType: e.mediaType || null, siteSlug: e.siteSlug || null, sourceUrl: e.sourceUrl || null, evidence: { source: 'awwwards' } }));
|
|
4123
|
+
return textResult({ mode, query: q || null, total: items.length, limit, offset, items: page, ...(page.length ? {} : { suggestions: awardSuggestQueries(q || 'hero', awardIndex.getTaxonomy()) }) });
|
|
4124
|
+
}
|
|
4125
|
+
// sites
|
|
4126
|
+
const query = toolFn_str(a.query, 'query') || ''; const slugQ = a.slug === undefined ? undefined : awardNormalizeSlug(a.slug);
|
|
4127
|
+
const tech = toolFn_str(a.technology, 'technology'); const color = toolFn_str(a.color, 'color'); const awardF = a.award; const studio = toolFn_str(a.studio, 'studio');
|
|
4128
|
+
if (awardF !== undefined && !['sotd', 'developer', 'honorable'].includes(awardF)) toolFn_bad('award must be sotd|developer|honorable');
|
|
4129
|
+
if (color !== undefined && !/^#?[0-9a-fA-F]{6}$/.test(color.trim())) toolFn_bad('color must be a hex like #FF5733');
|
|
4130
|
+
let pool = awardIndex.listSites(2000);
|
|
4131
|
+
let liveError = null, liveRefreshed = false;
|
|
4132
|
+
if (!pool.length && awardClient) {
|
|
4133
|
+
try {
|
|
4134
|
+
const { html } = await awardClient.getAwardHtml(awardFilterUrl({ color: color ? '#' + String(color).replace('#', '') : undefined, tags: a.tags, technology: tech, award: awardF }));
|
|
4135
|
+
const parsed = awardParseListing(html);
|
|
4136
|
+
if (parsed.length) { awardIndex.upsertSites(parsed); pool = awardIndex.listSites(2000); liveRefreshed = true; }
|
|
4137
|
+
} catch (e) { liveError = String((e && e.message) || e).slice(0, 220); }
|
|
4138
|
+
}
|
|
4139
|
+
let filtered = pool;
|
|
4140
|
+
if (slugQ) filtered = filtered.filter((s) => s.slug === slugQ);
|
|
4141
|
+
if (tech) filtered = filtered.filter((s) => (s.tags || []).map((t) => String(t).toLowerCase()).includes(tech.toLowerCase()) || JSON.stringify(s).toLowerCase().includes(tech.toLowerCase()));
|
|
4142
|
+
if (studio) filtered = filtered.filter((s) => String(s.studio || '').toLowerCase().includes(studio.toLowerCase()));
|
|
4143
|
+
if (Array.isArray(a.tags) && a.tags.length) {
|
|
4144
|
+
const want = a.tags.map((t) => String(t).toLowerCase());
|
|
4145
|
+
filtered = filtered.filter((s) => want.some((w) => (s.tags || []).map((t) => String(t).toLowerCase()).includes(w)));
|
|
4146
|
+
}
|
|
4147
|
+
const { tokens, scored } = awardScoreSites(filtered, query || slugQ || '', { technology: tech, color });
|
|
4148
|
+
const ranked = query ? scored.filter((x) => x.score > 0).map((x) => ({ s: x.site, score: x.score, reasons: x.reasons })) : scored.map((x) => ({ s: x.site, score: x.score, reasons: x.reasons }));
|
|
4149
|
+
const total = ranked.length;
|
|
4150
|
+
const page = ranked.slice(offset, offset + limit);
|
|
4151
|
+
// Full-detail enrichment runs concurrently (the award client spaces
|
|
4152
|
+
// requests 1/s) but is capped: enriching 25 rows would take ~25s.
|
|
4153
|
+
const FULL_CAP = 5;
|
|
4154
|
+
const enrich = full ? page.slice(0, FULL_CAP) : [];
|
|
4155
|
+
const enriched = await Promise.all(enrich.map(async (r) => {
|
|
4156
|
+
try { const d = await awardDetailFor(r.s.slug); return awardFullSite(d.summary || r.s, d.detail, { score: r.score, reasons: r.reasons }); }
|
|
4157
|
+
catch { return awardCompactSite(r.s, { score: r.score, reasons: r.reasons }); }
|
|
4158
|
+
}));
|
|
4159
|
+
const items = page.map((r, i) => (full && i < enriched.length ? enriched[i] : awardCompactSite(r.s, { score: r.score, reasons: r.reasons })));
|
|
4160
|
+
const out = { mode, query: query || null, tokens, total, limit, offset, items, source: liveRefreshed ? 'live' : 'local-index', ...(liveError ? { liveError } : {}),
|
|
4161
|
+
...(full && page.length > FULL_CAP ? { full_enrichment_capped: FULL_CAP, full_note: 'only the first ' + FULL_CAP + ' hits carry full detail (polite-fetch bound); the rest are compact' } : {}),
|
|
4162
|
+
corpusNote: liveRefreshed ? 'local index was empty; seeded from one polite live listing fetch (rebuild the index for depth)' : 'ranked from the local index; rebuild/refresh the index when stale' };
|
|
4163
|
+
if (!items.length && !liveError) { out.suggestions = awardSuggestQueries(query || 'portfolio', awardIndex.getTaxonomy()); out.note = 'zero-match (not a failure): try a suggestion'; }
|
|
4164
|
+
if (!items.length && liveError) out.note = 'upstream fetch failed; local index also empty — retry later, do not treat as zero-match';
|
|
4165
|
+
return textResult(out); },
|
|
4166
|
+
aura_award_inspect: async (a) => { a = a || {}; const mode = awardMode(a.mode, ['site_details', 'site_elements', 'element', 'compare_sites'], 'mode');
|
|
4167
|
+
if (mode === 'element') {
|
|
4168
|
+
const slug = awardNormalizeSlug(a.elementSlug || toolFn_bad('elementSlug (string) is required'));
|
|
4169
|
+
let rec = awardIndex.getElement(slug);
|
|
4170
|
+
if (!rec && awardClient) {
|
|
4171
|
+
try { const { html } = await awardClient.getAwardHtml('/inspiration/' + slug); const parsed = awardParseElementPage(html); if (parsed) { awardIndex.upsertElements([{ ...parsed }]); rec = awardIndex.getElement(slug) || parsed; } } catch (e) { if (e && e.code === 'NOT_FOUND') throw e; }
|
|
4172
|
+
}
|
|
4173
|
+
if (!rec) { const e = new Error('element not found: ' + slug); e.code = 'NOT_FOUND'; throw e; }
|
|
4174
|
+
return textResult({ mode, element: rec });
|
|
4175
|
+
}
|
|
4176
|
+
if (mode === 'compare_sites') {
|
|
4177
|
+
const slugs = awardStrArr(a.slugs, 'slugs', 3) || toolFn_bad('slugs (array of 2-3) is required');
|
|
4178
|
+
if (slugs.length < 2) toolFn_bad('compare_sites needs 2-3 slugs');
|
|
4179
|
+
const normed = slugs.map((s) => awardNormalizeSlug(s));
|
|
4180
|
+
// Concurrent detail fetch (polite 1/s via the client); NOT_FOUND on
|
|
4181
|
+
// any ref still throws, other failures stay per-ref unavailable.
|
|
4182
|
+
const settled = await Promise.all(normed.map(async (s) => {
|
|
4183
|
+
try { return { ok: true, d: await awardDetailFor(s) }; }
|
|
4184
|
+
catch (e) { return { ok: false, slug: s, err: e }; }
|
|
4185
|
+
}));
|
|
4186
|
+
for (const r of settled) if (!r.ok && r.err && r.err.code === 'NOT_FOUND') throw r.err;
|
|
4187
|
+
const entries = settled.map((r) => {
|
|
4188
|
+
const s = r.ok ? r.d.slug : r.slug;
|
|
4189
|
+
let motion = null;
|
|
4190
|
+
try { const mr = awardIndex.searchMotion({ limit: 25 }); motion = (mr.items || []).find((m) => String(m.url || '').toLowerCase().includes(s)) || null; } catch { motion = null; }
|
|
4191
|
+
return { slug: s, detail: r.ok ? r.d.detail : null, motion };
|
|
4192
|
+
});
|
|
4193
|
+
const cmp = awardCompareSites(entries);
|
|
4194
|
+
return textResult({ mode, compared: cmp.per, shared: { technology: cmp.sharedTech, technologyFamilies: cmp.sharedTechFamilies, elements: cmp.sharedElements, elementKeywords: cmp.sharedElementKeywords, palette: cmp.sharedPalette, luminanceBands: cmp.sharedBands }, directionHints: cmp.directionHints, gaps: cmp.per.flatMap((p) => p.gaps.map((g) => p.slug + ': ' + g)), note: 'missing fields are unknown evidence, not negative proof' });
|
|
4195
|
+
}
|
|
4196
|
+
const slug = awardNormalizeSlug(a.slug || toolFn_bad('slug (string) is required'));
|
|
4197
|
+
const d = await awardDetailFor(slug, { withStatic: true });
|
|
4198
|
+
if (!d.summary && !d.detail) { const e = new Error('site not found: ' + slug); e.code = 'NOT_FOUND'; throw e; }
|
|
4199
|
+
if (mode === 'site_details') {
|
|
4200
|
+
let motion = null;
|
|
4201
|
+
try { const mr = awardIndex.searchMotion({ limit: 25 }); motion = (mr.items || []).find((m) => String(m.url || '').toLowerCase().includes(slug)) || null; } catch { motion = null; }
|
|
4202
|
+
return textResult({ mode, slug, summary: d.summary ? awardCompactSite(d.summary, null) : null, detail: d.detail, ideas: awardSiteIdeas(d.detail), ideas_contract: '0-6 ideas by held evidence (1 on thin evidence is valid); every idea is status:suggested with an observed basis, never stated fact', motion: motion || null, motion_static: d.motion_static || null, evidence: { detail: d.detail ? d.detailStatus : 'unavailable', motion: motion ? 'cached' : 'unavailable', motion_static: d.motion_static ? d.motion_static.status : 'unavailable', ...(d.liveError ? { liveError: d.liveError } : {}) } });
|
|
4203
|
+
}
|
|
4204
|
+
// site_elements
|
|
4205
|
+
let elements = [];
|
|
4206
|
+
if (d.detail && d.detail.elements) {
|
|
4207
|
+
const media = awardParseElementsSection((await (async () => { try { const r = await awardClient.getAwardHtml('/sites/' + slug); return r.html; } catch { return ''; } })())) || [];
|
|
4208
|
+
elements = (media.length ? media : d.detail.elements.map((t) => ({ title: t, mediaPath: null }))).map((m) => ({ title: m.title, mediaUrl: m.mediaPath ? awardElementUrl(m.mediaPath) : null, mediaType: m.mediaPath ? (String(m.mediaPath).endsWith('.mp4') ? 'video' : 'image') : null, siteSlug: slug, sourceUrl: awardSiteUrl(slug), evidence: { source: 'awwwards' } }));
|
|
4209
|
+
} else {
|
|
4210
|
+
const pool = awardIndex.listElements(500).filter((e) => e.siteSlug === slug);
|
|
4211
|
+
elements = pool.map((e) => ({ title: e.title, mediaUrl: e.mediaUrl || (e.mediaPath ? awardElementUrl(e.mediaPath) : null), mediaType: e.mediaType, siteSlug: slug, sourceUrl: e.sourceUrl || awardSiteUrl(slug), evidence: { source: 'awwwards' } }));
|
|
4212
|
+
}
|
|
4213
|
+
return textResult({ mode, slug, sourceUrl: awardSiteUrl(slug), elements, ...(elements.length ? {} : { note: 'no Elements section on this page (legitimate empty) or detail unavailable' }) }); },
|
|
4214
|
+
aura_design_blueprint: async (a) => { a = a || {}; const goal = typeof a.goal === 'string' ? a.goal.trim() : toolFn_bad('goal (string) is required');
|
|
4215
|
+
if (!goal) toolFn_bad('goal (string) is required');
|
|
4216
|
+
const refs = awardStrArr(a.references, 'references', 5) || toolFn_bad('references (array of 2-5 slugs) is required');
|
|
4217
|
+
if (refs.length < 2 || refs.length > 5) toolFn_bad('references must hold 2-5 slugs — got ' + refs.length);
|
|
4218
|
+
const normed = refs.map((s) => awardNormalizeSlug(s));
|
|
4219
|
+
const technology = toolFn_str(a.technology, 'technology'); const visualDirection = toolFn_str(a.visualDirection, 'visualDirection'); const responsive = toolFn_str(a.responsive, 'responsive');
|
|
4220
|
+
const constraints = Array.isArray(a.constraints) ? a.constraints.filter((c) => typeof c === 'string').map((c) => c.slice(0, 200)).slice(0, 8) : [];
|
|
4221
|
+
const includeStructure = a.includeStructure === undefined ? false : toolFn_bool(a.includeStructure, 'includeStructure');
|
|
4222
|
+
const details = (await Promise.all(normed.map(async (s) => {
|
|
4223
|
+
try { return await awardDetailFor(s, { withStatic: true }); }
|
|
4224
|
+
catch (e) {
|
|
4225
|
+
if (e && e.code === 'NOT_FOUND') throw e;
|
|
4226
|
+
return { slug: s, summary: awardIndex.getSite(s), detail: null, detailStatus: 'unavailable', liveError: String((e && e.message) || e).slice(0, 160) };
|
|
4227
|
+
}
|
|
4228
|
+
})));
|
|
4229
|
+
// Aura catalogue matches (real rows only; failures stay unavailable).
|
|
4230
|
+
// One query PER proposed section so every section gets a candidate —
|
|
4231
|
+
// theme words from the goal ride along (dark/light re-rank server-side).
|
|
4232
|
+
const auraMatches = []; let auraUnavailable = null; let sysSlugForScaffold = null;
|
|
4233
|
+
const SECTIONS = AWARD_SECTIONS;
|
|
4234
|
+
try {
|
|
4235
|
+
if (!catalog || typeof catalog.searchCatalog !== 'function') throw new Error('catalogue not wired');
|
|
4236
|
+
const themeWord = /\b(dark|light)\b/i.test(goal + ' ' + (visualDirection || '')) ? goal.match(/\b(dark|light)\b/i)[1].toLowerCase() : undefined;
|
|
4237
|
+
const seen = new Set();
|
|
4238
|
+
const queries = [];
|
|
4239
|
+
for (const sec of SECTIONS) {
|
|
4240
|
+
const refWords = [];
|
|
4241
|
+
for (const d of details) {
|
|
4242
|
+
const toks = awardTokens(((d.detail && d.detail.title) || '') + ' ' + ((d.detail && (d.detail.elements || []).join(' ')) || '')).slice(0, 2);
|
|
4243
|
+
if (toks.length) refWords.push(toks[0]);
|
|
4244
|
+
}
|
|
4245
|
+
const q = [...new Set([sec, ...refWords.slice(0, 1), themeWord].filter(Boolean))].join(' ');
|
|
4246
|
+
queries.push({ section: sec, q });
|
|
4247
|
+
}
|
|
4248
|
+
// Section queries run concurrently (catalogue coalesces + caches);
|
|
4249
|
+
// results re-assemble in section order with duplicate ids collapsed.
|
|
4250
|
+
const sectionResults = await Promise.all(queries.map(async ({ section, q }) => {
|
|
4251
|
+
try {
|
|
4252
|
+
const r = await catalog.searchCatalog('components', { query: q, freeOnly: true, limit: 2, ...(themeWord ? { theme: themeWord } : {}) });
|
|
4253
|
+
return { section, q, items: (r.items || []).slice(0, 1), fallback: r && r.isFallback ? (r.fallback || 'fallback') : null };
|
|
4254
|
+
} catch { return { section, q, items: [], fallback: null }; }
|
|
4255
|
+
}));
|
|
4256
|
+
const fbBySection = {};
|
|
4257
|
+
for (const { section, fallback } of sectionResults) if (fallback && !(section in fbBySection)) fbBySection[section] = fallback;
|
|
4258
|
+
for (const { section, q, items } of sectionResults) {
|
|
4259
|
+
if (auraMatches.length >= 12) break;
|
|
4260
|
+
for (const it of items) {
|
|
4261
|
+
if (!it || it.premium !== false) continue;
|
|
4262
|
+
const key = String(it.id || it.slug);
|
|
4263
|
+
if (seen.has(key)) continue;
|
|
4264
|
+
seen.add(key);
|
|
4265
|
+
auraMatches.push({ section, componentId: it.id ?? it.slug, componentTitle: it.title, pageUrl: it.page_url, why: 'catalogue match for "' + q + '"', matchEvidence: { fallback: fbBySection[section] || null } });
|
|
4266
|
+
break;
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
try {
|
|
4270
|
+
const sys = await catalog.searchCatalog('design_systems', { query: goal, limit: 1 });
|
|
4271
|
+
if (sys.items && sys.items[0]) { auraMatches.unshift({ section: 'tokens', componentId: sys.items[0].slug || sys.items[0].id, componentTitle: sys.items[0].title, pageUrl: sys.items[0].page_url, why: 'design-system tokens first' }); sysSlugForScaffold = sys.items[0].slug || sys.items[0].id; }
|
|
4272
|
+
} catch { /* optional */ }
|
|
4273
|
+
const covered = new Set(auraMatches.map((m) => m.section));
|
|
4274
|
+
const uncovered = SECTIONS.filter((s) => !covered.has(s));
|
|
4275
|
+
if (uncovered.length) auraUnavailable = (auraUnavailable ? auraUnavailable + '; ' : '') + 'no free catalogue match for sections: ' + uncovered.join(', ') + ' — build these from tokens + closest matched section';
|
|
4276
|
+
// Weak-consensus fallback: fewer than 3 component matches is not an
|
|
4277
|
+
// actionable brief. Fill from human-grade editorial picks, LABELED as
|
|
4278
|
+
// such (editorial:true) — never silently presented as query matches.
|
|
4279
|
+
const compCount = auraMatches.filter((m) => m.section !== 'tokens').length;
|
|
4280
|
+
if (compCount < 3 && catalog && typeof catalog.editorialPicks === 'function') {
|
|
4281
|
+
try {
|
|
4282
|
+
const picks = await catalog.editorialPicks(3);
|
|
4283
|
+
for (const p of (picks || [])) {
|
|
4284
|
+
if (auraMatches.length >= 12) break;
|
|
4285
|
+
if (!p || p.premium !== false) continue;
|
|
4286
|
+
const key = String(p.id || p.slug);
|
|
4287
|
+
if (seen.has(key)) continue;
|
|
4288
|
+
seen.add(key);
|
|
4289
|
+
auraMatches.push({ section: 'general', componentId: p.id ?? p.slug, componentTitle: p.title, pageUrl: p.page_url, why: 'editorial fallback: goal matched nothing directly — human-grade free pick', editorial: true });
|
|
4290
|
+
}
|
|
4291
|
+
if ((picks || []).length) auraUnavailable = (auraUnavailable ? auraUnavailable + '; ' : '') + 'section queries matched thinly — editorial free picks fill the kit (marked editorial:true)';
|
|
4292
|
+
} catch { /* brief stays thin but honest */ }
|
|
4293
|
+
}
|
|
4294
|
+
} catch (e) { auraUnavailable = String((e && e.message) || e).slice(0, 200); }
|
|
4295
|
+
let structureNotes = null;
|
|
4296
|
+
if (includeStructure) {
|
|
4297
|
+
if (!awardBrowser) structureNotes = 'structure not captured: browser unavailable (install Chrome or set AURA_CHROME_PATH)';
|
|
4298
|
+
else {
|
|
4299
|
+
const parts = await Promise.all(normed.slice(0, 3).map(async (s) => {
|
|
4300
|
+
const d = details.find((x) => x.slug === s);
|
|
4301
|
+
const live = d && d.detail && d.detail.liveUrl;
|
|
4302
|
+
if (!live) return s + ': no live URL in evidence';
|
|
4303
|
+
try { const st = await awardBrowser.structure(live, { viewport: 'desktop' }); return s + ': ' + st.bands.length + ' bands over ' + st.totalHeight + 'px (live-dom)'; }
|
|
4304
|
+
catch (e) { return s + ': structure unavailable (' + String((e && e.message) || e).slice(0, 120) + ')'; }
|
|
4305
|
+
}));
|
|
4306
|
+
structureNotes = parts.join('; ');
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
let motionNotes = null;
|
|
4310
|
+
const gaps = [];
|
|
4311
|
+
for (const d of details) {
|
|
4312
|
+
if (!d.detail) gaps.push(d.slug + ': reference detail unavailable' + (d.liveError ? ' (' + d.liveError + ')' : ''));
|
|
4313
|
+
}
|
|
4314
|
+
const staticCues = [];
|
|
4315
|
+
for (const d of details) {
|
|
4316
|
+
const line = awardStaticSummary(d.slug, d.motion_static);
|
|
4317
|
+
if (line) staticCues.push(line);
|
|
4318
|
+
}
|
|
4319
|
+
let motionRecorded = false;
|
|
4320
|
+
try {
|
|
4321
|
+
const mr = awardIndex.searchMotion({ limit: 25 });
|
|
4322
|
+
const hits = (mr.items || []).filter((m) => normed.some((s) => String(m.url || '').toLowerCase().includes(s)));
|
|
4323
|
+
if (hits.length) { motionRecorded = true; motionNotes = 'Recorded motion for ' + hits.map((h) => h.url).join(', ') + ': prefer ' + hits.map((h) => (h.stack && h.stack.libs || []).join('+') || 'css').join('; '); }
|
|
4324
|
+
else {
|
|
4325
|
+
if (staticCues.length) motionNotes = 'Static markup cues (NOT runtime observation — record a study for trigger counts/easing): ' + staticCues.join(' | ');
|
|
4326
|
+
gaps.push('motion: no recorded studies for these refs — run aura_motion_study {mode:inspect} on their live URLs, or follow the hand-written <500ms + prefers-reduced-motion guidance');
|
|
4327
|
+
}
|
|
4328
|
+
} catch { /* ignore */ }
|
|
4329
|
+
if (!includeStructure) gaps.push('structure: not requested (pass includeStructure:true for measured band evidence)');
|
|
4330
|
+
if (auraUnavailable) gaps.push('catalogue: ' + auraUnavailable);
|
|
4331
|
+
// Requested palette synthesis: hexes named in the goal/direction become
|
|
4332
|
+
// PROPOSED tokens (never presented as observed ref evidence), mapped
|
|
4333
|
+
// against the refs' observed luminance bands.
|
|
4334
|
+
const requestedPalette = [...new Set([...String(goal + ' ' + (visualDirection || '')).matchAll(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g)].map((m) => { const h = m[1]; return '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h).toUpperCase(); }))].slice(0, 6);
|
|
4335
|
+
const built = awardBuildBlueprint({ goal, refs: normed, details, auraMatches, structureNotes, motionNotes, motionRecorded, constraints, technology, visualDirection, responsive, requestedPalette, gaps });
|
|
4336
|
+
// next_calls: immediately executable follow-ups built ONLY from real
|
|
4337
|
+
// ids/urls gathered above — the agent acts without another search round.
|
|
4338
|
+
const compMatches = auraMatches.filter((m) => m.section !== 'tokens' && !m.editorial).slice(0, 3);
|
|
4339
|
+
const nextCalls = compMatches.map((m) => ({ tool: 'aura_get_component', arguments: { id: m.componentId }, why: 'read full markup for the ' + m.section + ' pick before pasting' }));
|
|
4340
|
+
const scaffoldComps = auraMatches.filter((m) => m.section !== 'tokens').slice(0, 8).map((m) => String(m.componentId));
|
|
4341
|
+
if (scaffoldComps.length) nextCalls.push({ tool: 'aura_scaffold_page', arguments: { goal, ...(sysSlugForScaffold ? { system: sysSlugForScaffold } : {}), components: scaffoldComps }, why: 'one-turn page build from the matched kit (tokens first, then markup)' });
|
|
4342
|
+
for (const d of details) {
|
|
4343
|
+
if (d.detail && d.detail.liveUrl) nextCalls.push({ tool: 'aura_motion_study', arguments: { mode: 'inspect', url: d.detail.liveUrl }, why: 'record real motion evidence for ' + d.slug + ' before animating' });
|
|
4344
|
+
if (nextCalls.length >= 6) break;
|
|
4345
|
+
}
|
|
4346
|
+
return textResult({ goal, references: normed, markdown: built.markdown, spec: built.spec, auraEvidence: auraMatches.length ? { matched: auraMatches.length, sectionsCovered: [...new Set(auraMatches.map((m) => m.section))] } : { matched: 0, unavailable: auraUnavailable || 'catalogue lookup failed or matched nothing' }, next_calls: nextCalls, license_note: 'Reference design belongs to its creators (see sourceUrl per ref); Aura matches are all-rights-reserved per Aura Terms §4 — adapt patterns, never copy source.' }); },
|
|
4347
|
+
aura_visual_audit: async (a) => { a = a || {}; const mode = awardMode(a.mode, ['capture', 'structure', 'compare'], 'mode');
|
|
4348
|
+
const viewport = a.viewport === undefined ? 'desktop' : awardMode(a.viewport, ['desktop', 'mobile'], 'viewport');
|
|
4349
|
+
const waitStrategy = a.waitStrategy === undefined ? 'load' : awardMode(a.waitStrategy, ['load', 'networkidle'], 'waitStrategy');
|
|
4350
|
+
const settleMs = a.settleMs === undefined ? 1500 : toolFn_num(a.settleMs, 'settleMs');
|
|
4351
|
+
if (typeof settleMs !== 'number' || settleMs < 0 || settleMs > 8000) toolFn_bad('settleMs must be 0-8000');
|
|
4352
|
+
const allowLocal = a.allowLocal === undefined ? false : toolFn_bool(a.allowLocal, 'allowLocal');
|
|
4353
|
+
// Local-file audit (P0-2): a file:// build path or plain local path is
|
|
4354
|
+
// readable WITHOUT a browser when allowLocal:true — parsed as labeled
|
|
4355
|
+
// static-html estimates (source:local-file). Screenshots still need http.
|
|
4356
|
+
const readLocalSource = async (raw, label) => {
|
|
4357
|
+
if (!allowLocal) { const e = new Error(label + ' is a local path: pass allowLocal:true to audit local builds (structure mode reads the file directly; capture needs a served http URL)'); e.code = 'UNSUPPORTED_OPERATION'; throw e; }
|
|
4358
|
+
let fs, path;
|
|
4359
|
+
try {
|
|
4360
|
+
const g = globalThis.process && globalThis.process.getBuiltinModule ? globalThis.process.getBuiltinModule : null;
|
|
4361
|
+
if (typeof g !== "function") throw new Error("needs Node 22+");
|
|
4362
|
+
fs = g.call(globalThis.process, "node:fs"); path = g.call(globalThis.process, "node:path");
|
|
4363
|
+
} catch { const e = new Error('local file audit needs Node 22+'); e.code = 'UNSUPPORTED_OPERATION'; throw e; }
|
|
4364
|
+
let file = String(raw);
|
|
4365
|
+
if (/^file:\/\//i.test(file)) { try { file = new URL(file).pathname; } catch { toolFn_bad(label + ' is not a readable file URL'); } if (/^\/[A-Za-z]:\//.test(file)) file = file.slice(1); }
|
|
4366
|
+
if (/[\0]/.test(file) || file.length > 1000) toolFn_bad(label + ' is not a readable local file');
|
|
4367
|
+
let html;
|
|
4368
|
+
try { html = fs.readFileSync(file, "utf8"); }
|
|
4369
|
+
catch { const e = new Error(label + ' not found or unreadable: ' + String(file).slice(0, 160)); e.code = 'NOT_FOUND'; throw e; }
|
|
4370
|
+
if (html.length > 1500000) html = html.slice(0, 1500000);
|
|
4371
|
+
const bands = cdpBandsFromHtml(html, raw);
|
|
4372
|
+
return { ...bands, source: 'local-file', note: 'local file estimate — tags/landmarks only, no measured layout; serve over http + allowLocal:true for live-dom bands' };
|
|
4373
|
+
};
|
|
4374
|
+
const isLocalInput = (raw) => typeof raw === 'string' && (/^file:\/\//i.test(raw) || /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith('/') || /^[^/:?#]+\.html?([?#]|$)/i.test(raw));
|
|
4375
|
+
const fetchStatic = async (target) => {
|
|
4376
|
+
// Shared guarded fetch: manual redirects with per-hop URL
|
|
4377
|
+
// classification + DNS guard, streaming bounded read. The old
|
|
4378
|
+
// redirect:'follow' fetch could land on a blocked host after a
|
|
4379
|
+
// server-side redirect; this one re-validates every hop.
|
|
4380
|
+
const got = await awardFetchGuardedHtml(target, { fetchImpl: globalThis.fetch, allowLocal, label: 'audited page' });
|
|
4381
|
+
if (!got.ok) {
|
|
4382
|
+
if (got.status === 404) { const e = new Error('page not found: ' + target); e.code = 'NOT_FOUND'; throw e; }
|
|
4383
|
+
throw awardErr('UPSTREAM_ERROR', 'static fetch failed for ' + target + ': ' + String(got.reason).slice(0, 200));
|
|
4384
|
+
}
|
|
4385
|
+
return got.html;
|
|
4386
|
+
};
|
|
4387
|
+
const structureOf = async (rawTarget, label) => {
|
|
4388
|
+
if (isLocalInput(rawTarget)) return readLocalSource(rawTarget, label || 'url');
|
|
4389
|
+
const target = rawTarget;
|
|
4390
|
+
if (awardBrowser) {
|
|
4391
|
+
try { return await awardBrowser.structure(target, { viewport, allowLocal }); }
|
|
4392
|
+
catch (e) {
|
|
4393
|
+
// Any live-dom failure degrades to labeled static estimates —
|
|
4394
|
+
// the audit stays usable, and the downgrade is disclosed.
|
|
4395
|
+
if (e && (e.code === 'BROWSER_UNAVAILABLE' || e.code === 'CAPTURE_TIMEOUT' || e.code === 'UPSTREAM_ERROR')) {
|
|
4396
|
+
try {
|
|
4397
|
+
const html = await fetchStatic(target);
|
|
4398
|
+
return { ...cdpBandsFromHtml(html, target), fallbackFrom: 'live-dom', fallbackWhy: String((e && e.message) || e).slice(0, 200) };
|
|
4399
|
+
} catch (fe) {
|
|
4400
|
+
const both = awardErr('UPSTREAM_ERROR', 'live-dom failed (' + String((e && e.message) || e).slice(0, 160) + ') AND static fetch failed (' + String((fe && fe.message) || fe).slice(0, 160) + ') — is the dev server running? For http://127.0.0.1 targets serve the build first (e.g. npx serve) with allowLocal:true; for files pass the path directly with allowLocal:true.');
|
|
4401
|
+
throw both;
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
throw e;
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
const html = await fetchStatic(target);
|
|
4408
|
+
return cdpBandsFromHtml(html, target);
|
|
4409
|
+
};
|
|
4410
|
+
if (mode === 'capture') {
|
|
4411
|
+
if (typeof a.url === 'string' && isLocalInput(a.url) && !/^https?:\/\//i.test(a.url)) toolFn_bad('capture needs an http(s) URL — screenshots require a browser navigating a served page. Serve the build (e.g. npx serve) and pass its http://127.0.0.1:PORT URL with allowLocal:true.');
|
|
4412
|
+
const target = awardValidateHttpUrl(a.url || toolFn_bad('url (string) is required'), { allowLocal });
|
|
4413
|
+
if (!awardBrowser) throw awardErr('BROWSER_UNAVAILABLE', 'capture needs Chrome: install it or set AURA_CHROME_PATH. Fallback: aura_visual_audit {mode:structure} estimates sections from static HTML without a browser.');
|
|
4414
|
+
try { return textResult(await awardBrowser.capture(target, { viewport, waitStrategy, settleMs, allowLocal })); }
|
|
4415
|
+
catch (e) {
|
|
4416
|
+
if (e && (e.code === 'BROWSER_UNAVAILABLE' || e.code === 'UNSUPPORTED_OPERATION')) throw e;
|
|
4417
|
+
throw awardErr('UPSTREAM_ERROR', 'capture failed: ' + String((e && e.message) || e).slice(0, 220) + ' — retry, or use mode:structure for a static estimate');
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4420
|
+
if (mode === 'structure') {
|
|
4421
|
+
const raw = a.url || toolFn_bad('url (string) is required');
|
|
4422
|
+
const target = isLocalInput(raw) ? raw : awardValidateHttpUrl(raw, { allowLocal });
|
|
4423
|
+
return textResult({ mode, ...(await structureOf(target, 'url')) });
|
|
4424
|
+
}
|
|
4425
|
+
const rawRef = a.referenceUrl || toolFn_bad('referenceUrl (string) is required');
|
|
4426
|
+
const rawBuild = a.buildUrl || toolFn_bad('buildUrl (string) is required');
|
|
4427
|
+
const normRef = isLocalInput(rawRef) ? rawRef : awardValidateHttpUrl(rawRef, { allowLocal });
|
|
4428
|
+
const normBuild = isLocalInput(rawBuild) ? rawBuild : awardValidateHttpUrl(rawBuild, { allowLocal });
|
|
4429
|
+
const [rs, bs] = await Promise.all([structureOf(normRef, 'referenceUrl'), structureOf(normBuild, 'buildUrl')]);
|
|
4430
|
+
const mismatches = [];
|
|
4431
|
+
if (rs.bands.length !== bs.bands.length) mismatches.push({ kind: 'section-count', detail: 'reference has ' + rs.bands.length + ' bands, build has ' + bs.bands.length });
|
|
4432
|
+
const seq = (x) => x.bands.map((b) => b.tag + ':' + (b.label || '')).join(' > ');
|
|
4433
|
+
if (seq(rs) !== seq(bs)) mismatches.push({ kind: 'section-order', detail: 'band tag sequence differs', reference: seq(rs).slice(0, 300), build: seq(bs).slice(0, 300) });
|
|
4434
|
+
if (rs.totalHeight && bs.totalHeight) {
|
|
4435
|
+
const ratio = bs.totalHeight / Math.max(1, rs.totalHeight);
|
|
4436
|
+
if (ratio < 0.7 || ratio > 1.4) mismatches.push({ kind: 'page-height', detail: 'build height ' + bs.totalHeight + 'px vs reference ' + rs.totalHeight + 'px (ratio ' + ratio.toFixed(2) + ')' });
|
|
4437
|
+
}
|
|
4438
|
+
const pal = (x) => new Set(x.bands.map((b) => String(b.background || '').toLowerCase()).filter(Boolean));
|
|
4439
|
+
const rp = pal(rs), bp = pal(bs);
|
|
4440
|
+
const onlyRef = [...rp].filter((c) => ![...bp].some((d) => d === c));
|
|
4441
|
+
if (onlyRef.length >= 2) mismatches.push({ kind: 'palette', detail: onlyRef.length + ' reference backgrounds absent in build: ' + onlyRef.slice(0, 4).join(', ') });
|
|
4442
|
+
return textResult({ mode, reference: rs, build: bs, mismatches, manualJudgment: ['imagery tone and spacing rhythm need human eyes', 'motion/interaction parity is not covered here — see aura_motion_study'], metrics: { refBands: rs.bands.length, buildBands: bs.bands.length }, sources: [rs.source, bs.source], ...((rs.source !== 'live-dom' || bs.source !== 'live-dom') ? { estimateNote: 'one or both sides are layout estimates (no measured offsets) — counts/order still comparable, heights are not' } : {}) }); },
|
|
4443
|
+
aura_motion_study: async (a) => { a = a || {}; const mode = awardMode(a.mode, ['inspect', 'record', 'search'], 'mode');
|
|
4444
|
+
if (mode === 'search') {
|
|
4445
|
+
const r = awardIndex.searchMotion({ library: toolFn_str(a.library, 'library'), easing: toolFn_str(a.easing, 'easing'), scrollModel: a.scrollModel === undefined ? undefined : String(a.scrollModel), limit: awardLimit(a.frames, 10, 25) });
|
|
4446
|
+
if (r.empty) return textResult({ mode, total: 0, items: [], emptyCorpus: true, note: 'no motion studies stored yet' });
|
|
4447
|
+
return textResult({ mode, total: r.total, items: r.items, emptyCorpus: false });
|
|
4448
|
+
}
|
|
4449
|
+
const allowLocal = a.allowLocal === undefined ? false : toolFn_bool(a.allowLocal, 'allowLocal');
|
|
4450
|
+
const viewport = a.viewport === undefined ? 'desktop' : awardMode(a.viewport, ['desktop', 'mobile'], 'viewport');
|
|
4451
|
+
const target = awardValidateHttpUrl(a.url || toolFn_bad('url (string) is required'), { allowLocal });
|
|
4452
|
+
if (!awardBrowser) throw awardErr('BROWSER_UNAVAILABLE', 'motion ' + mode + ' needs Chrome: install it or set AURA_CHROME_PATH. Fallback motion plan (generic, NOT observed): entrances <500ms ease-out; scroll reveals via IntersectionObserver; hover 1.03 + shadow; easing ease-out/cubic-bezier(.22,1,.36,1); prefers-reduced-motion disables parallax/scrub. aura_motion_study {mode:search} still queries stored studies.');
|
|
4453
|
+
if (mode === 'inspect') {
|
|
4454
|
+
const study = await awardBrowser.motionInspect(target, { viewport, allowLocal });
|
|
4455
|
+
try { awardIndex.recordMotion(study); } catch { /* memory fallback never fails */ }
|
|
4456
|
+
return textResult({ mode, study });
|
|
4457
|
+
}
|
|
4458
|
+
const frames = a.frames === undefined ? 8 : toolFn_num(a.frames, 'frames');
|
|
4459
|
+
if (typeof frames !== 'number' || frames < 2 || frames > 16) toolFn_bad('frames must be 2-16');
|
|
4460
|
+
const durationMs = a.durationMs === undefined ? 12000 : toolFn_num(a.durationMs, 'durationMs');
|
|
4461
|
+
if (typeof durationMs !== 'number' || durationMs < 4000 || durationMs > 30000) toolFn_bad('durationMs must be 4000-30000');
|
|
4462
|
+
const rec = await awardBrowser.recordFrames(target, { frames: Math.floor(frames), viewport, allowLocal, durationMs: Math.floor(durationMs) });
|
|
4463
|
+
if (rec.study) { try { awardIndex.recordMotion(rec.study); } catch { /* ignore */ } }
|
|
4464
|
+
let video = null, videoAvailable = false, videoHint = null;
|
|
4465
|
+
try {
|
|
4466
|
+
const enc = await awardBrowser.encodeWebm(rec.frames, {});
|
|
4467
|
+
video = enc.video; videoAvailable = !!enc.available; videoHint = enc.hint || null;
|
|
4468
|
+
} catch (e) { videoHint = String((e && e.message) || e).slice(0, 160); }
|
|
4469
|
+
if (!videoAvailable && !videoHint) videoHint = 'ffmpeg not found; install ffmpeg or set AURA_FFMPEG_PATH for .webm output — frame JPEGs retained';
|
|
4470
|
+
return textResult({ mode, url: target, study: rec.study, frames: rec.frames, frameCount: rec.frameCount, video, video_available: videoAvailable, ...(videoHint ? { videoHint } : {}), ...(rec.retentionNote ? { retentionNote: rec.retentionNote } : {}) }); },
|
|
4471
|
+
aura_award_index: async (a) => { a = a || {}; const action = awardMode(a.action, ['status', 'rebuild', 'resume', 'refresh', 'new_winners', 'watch_add', 'watch_list', 'watch_remove'], 'action');
|
|
4472
|
+
if (action === 'status') return textResult({ action, ...awardIndex.getStatus() });
|
|
4473
|
+
if (action === 'watch_list') return textResult({ action, watches: awardIndex.watchList() });
|
|
4474
|
+
if (action === 'watch_add') {
|
|
4475
|
+
const kind = awardMode(a.kind, ['studio', 'tag', 'url'], 'kind');
|
|
4476
|
+
if (typeof a.pattern !== 'string' || !a.pattern.trim()) toolFn_bad('pattern (string) is required');
|
|
4477
|
+
return textResult({ action, watch: awardIndex.watchAdd({ kind, pattern: a.pattern, award: a.award }) });
|
|
4478
|
+
}
|
|
4479
|
+
if (action === 'watch_remove') {
|
|
4480
|
+
const kind = awardMode(a.kind, ['studio', 'tag', 'url'], 'kind');
|
|
4481
|
+
if (typeof a.pattern !== 'string' || !a.pattern.trim()) toolFn_bad('pattern (string) is required');
|
|
4482
|
+
return textResult({ action, ...awardIndex.watchRemove({ kind, pattern: a.pattern }) });
|
|
4483
|
+
}
|
|
4484
|
+
if (action === 'new_winners') {
|
|
4485
|
+
if (!awardClient) throw awardErr('UNSUPPORTED_OPERATION', 'new_winners needs an award client (server wiring)');
|
|
4486
|
+
const r = await awardIndex.newWinners({ client: awardClient, award: a.award || 'sotd' });
|
|
4487
|
+
let deltas = []; let seeded = [];
|
|
4488
|
+
try {
|
|
4489
|
+
if (awardIndex.checkWatchesDetailed) { const w = awardIndex.checkWatchesDetailed(); deltas = w.deltas || []; seeded = w.seeded || []; }
|
|
4490
|
+
else deltas = awardIndex.checkWatches ? awardIndex.checkWatches() : [];
|
|
4491
|
+
} catch { deltas = []; seeded = []; }
|
|
4492
|
+
return textResult({ action, ...r, watchDeltas: deltas, ...(seeded.length ? { watchBaselineSeeded: seeded, watchBaselineNote: 'first check for these watches seeded their baseline silently — future deltas report newly indexed matches only' } : {}) });
|
|
4493
|
+
}
|
|
4494
|
+
if (!awardClient) throw awardErr('UNSUPPORTED_OPERATION', action + ' needs an award client (server wiring)');
|
|
4495
|
+
const maxPages = a.maxPages === undefined ? 5 : toolFn_num(a.maxPages, 'maxPages');
|
|
4496
|
+
if (typeof maxPages !== 'number' || maxPages < 1 || maxPages > 20) toolFn_bad('maxPages must be 1-20 (bounded crawl per call; resume for more)');
|
|
4497
|
+
// Bounded crawl: the index layer owns the plan; maxPages caps fetches
|
|
4498
|
+
// THIS call so a plan advances across calls instead of re-crawling the
|
|
4499
|
+
// same prefix. No tag slicing here — the stored plan defines the pages.
|
|
4500
|
+
const args = { client: awardClient, maxPages: Math.floor(maxPages) };
|
|
4501
|
+
const r = action === 'refresh' ? await awardIndex.refresh(args) : action === 'resume' ? await awardIndex.resume(args) : await awardIndex.rebuild(args);
|
|
4502
|
+
return textResult({ action, ...r, note: r.note || 'page budget reached; call resume to continue the same plan' }); },
|
|
1884
4503
|
};
|
|
1885
4504
|
};
|
|
1886
4505
|
|
|
@@ -1896,12 +4515,22 @@ function createHandlers(catalog, icons, version) {
|
|
|
1896
4515
|
|
|
1897
4516
|
|
|
1898
4517
|
|
|
4518
|
+
|
|
4519
|
+
|
|
4520
|
+
|
|
1899
4521
|
const config = loadConfig();
|
|
1900
4522
|
const fetcher = createFetcher({ fetchImpl: globalThis.fetch, timeoutMs: config.timeoutMs, retries: config.retries, userAgent: config.userAgent });
|
|
1901
4523
|
const catalog = createCatalog({ fetcher, config, facetsFn: facets });
|
|
1902
4524
|
const icons = createIcons({ fetcher });
|
|
1903
4525
|
const version = createVersion({ fetcher, running: SERVER_INFO.version });
|
|
1904
|
-
|
|
4526
|
+
// Award subsystem: persistent file index, polite live client, optional CDP
|
|
4527
|
+
// browser. No auto-crawl here — indexing runs only via aura_award_index.
|
|
4528
|
+
const awardClient = createAwardClient({ fetchImpl: globalThis.fetch });
|
|
4529
|
+
let awardIndex = null;
|
|
4530
|
+
try { awardIndex = createAwardIndex({}); } catch { awardIndex = null; }
|
|
4531
|
+
let awardBrowser = null;
|
|
4532
|
+
try { awardBrowser = createBrowserAdapter({}); } catch { awardBrowser = null; }
|
|
4533
|
+
const handlers = createHandlers(catalog, icons, version, { awardClient, awardIndex, browser: awardBrowser });
|
|
1905
4534
|
|
|
1906
4535
|
let buffer = "";
|
|
1907
4536
|
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|