aura-components-mcp 1.7.2 → 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 +53 -1
- package/README.md +19 -10
- package/SETUP.md +4 -2
- package/dist/server.js +762 -219
- package/package.json +1 -1
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
|
|
|
@@ -841,7 +841,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
|
|
|
841
841
|
}
|
|
842
842
|
|
|
843
843
|
// ---- protocol.mjs ----
|
|
844
|
-
const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.
|
|
844
|
+
const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.3" };
|
|
845
845
|
// Module: protocol — stdio JSON-RPC framing + MCP handshake + error codes.
|
|
846
846
|
// One interface: createSession(send) -> { dispatch(msg) }. No business logic.
|
|
847
847
|
|
|
@@ -1690,7 +1690,7 @@ function createVersion({ fetcher, running, now = () => Date.now() }) {
|
|
|
1690
1690
|
const AWARD_SCHEMA_VERSION = 1;
|
|
1691
1691
|
const AWARD_BASE_URL = "https://www.awwwards.com";
|
|
1692
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.
|
|
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
1694
|
const AWARD_ALLOWED_HOSTS = ["www.awwwards.com", "awwwards.com", "assets.awwwards.com"];
|
|
1695
1695
|
const AWARD_TIMEOUT_MS = 10000;
|
|
1696
1696
|
const AWARD_MAX_BYTES = 2 * 1024 * 1024;
|
|
@@ -1720,13 +1720,152 @@ function awardNormalizeSlug(s) {
|
|
|
1720
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
1721
|
return v;
|
|
1722
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
|
+
}
|
|
1723
1742
|
|
|
1724
1743
|
// ---- URL safety (SSRF guard) ----
|
|
1725
|
-
|
|
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
|
+
}
|
|
1726
1867
|
function awardIsBlockedHost(hostname) {
|
|
1727
|
-
|
|
1728
|
-
for (const re of AWARD_BLOCKED_HOST_RES) if (re.test(h)) return true;
|
|
1729
|
-
return false;
|
|
1868
|
+
return awardClassifyHost(hostname).kind !== "public";
|
|
1730
1869
|
}
|
|
1731
1870
|
function awardValidateHttpUrl(raw, { allowLocal = false } = {}) {
|
|
1732
1871
|
if (typeof raw !== "string" || !raw.trim()) throw awardBad("url must be a non-empty http(s) string");
|
|
@@ -1735,17 +1874,67 @@ function awardValidateHttpUrl(raw, { allowLocal = false } = {}) {
|
|
|
1735
1874
|
try { u = new URL(raw.trim()); } catch { throw awardBad("url is not parseable: " + raw.slice(0, 120)); }
|
|
1736
1875
|
if (u.protocol !== "http:" && u.protocol !== "https:") throw awardBad("only http/https urls are allowed, got: " + u.protocol);
|
|
1737
1876
|
if (u.username || u.password) throw awardBad("urls with credentials are not allowed");
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
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);
|
|
1747
1930
|
}
|
|
1748
|
-
|
|
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; }
|
|
1749
1938
|
}
|
|
1750
1939
|
function awardValidateAwardPath(pathOrUrl) {
|
|
1751
1940
|
const s = String(pathOrUrl ?? "");
|
|
@@ -1787,39 +1976,47 @@ function awardElementUrl(mediaPath) { return AWARD_ASSETS_URL + "/awards/" + Str
|
|
|
1787
1976
|
|
|
1788
1977
|
// ---- Parsers (scoped, fixture-driven, resilient to missing fields) ----
|
|
1789
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" };
|
|
1790
|
-
|
|
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) {
|
|
1791
1986
|
const src = String(html || "");
|
|
1792
|
-
|
|
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 };
|
|
1793
1989
|
const sites = [];
|
|
1794
1990
|
const seen = new Set();
|
|
1795
1991
|
const parts = src.split('data-collectable-model-value="');
|
|
1796
1992
|
const blobParts = parts.slice(1);
|
|
1993
|
+
diagnostics.blobs = blobParts.length;
|
|
1797
1994
|
for (const part of blobParts) {
|
|
1798
1995
|
const end = part.indexOf('">');
|
|
1799
|
-
if (end < 0 || end > 20000) continue;
|
|
1996
|
+
if (end < 0 || end > 20000) { diagnostics.malformed++; continue; }
|
|
1800
1997
|
let meta;
|
|
1801
|
-
try { meta = JSON.parse(awardDecodeEntities(part.slice(0, end))); } catch { continue; }
|
|
1802
|
-
if (!meta || !meta.slug || !meta.title) continue;
|
|
1803
|
-
if (meta.type && meta.type !== "submission") continue;
|
|
1804
|
-
const slug = String(meta.slug).toLowerCase();
|
|
1805
|
-
if (seen.has(slug)) continue;
|
|
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; }
|
|
1806
2003
|
const card = part.slice(end, end + 8000);
|
|
1807
2004
|
const detailMatch = card.match(/href="(\/sites\/[^"#?]+)"/);
|
|
1808
|
-
if (!detailMatch) continue;
|
|
2005
|
+
if (!detailMatch) { diagnostics.filtered++; continue; }
|
|
1809
2006
|
const liveMatch = card.match(/class="figure-rollover__bt"[^>]*href="(https?:\/\/[^"]+)"/);
|
|
1810
2007
|
const awards = [...new Set([...card.matchAll(/budget-tag--([a-z-]+)/g)].map((m) => AWARD_LABELS[m[1]] ?? m[1]))];
|
|
1811
2008
|
const studioMatch = card.match(/avatar-name__title">([^<]+)</);
|
|
1812
2009
|
seen.add(slug);
|
|
1813
2010
|
sites.push({
|
|
1814
2011
|
slug,
|
|
1815
|
-
title: awardDecodeEntities(meta.title).trim(),
|
|
2012
|
+
title: awardBoundStr(awardDecodeEntities(meta.title).trim(), AWARD_FIELD_BOUNDS.title),
|
|
1816
2013
|
createdAt: Number(meta.createdAt) || 0,
|
|
1817
|
-
tags: Array.isArray(meta.tags) ? meta.tags.map((t) => awardDecodeEntities(t)).filter(Boolean) : [],
|
|
1818
|
-
thumbnailPath: (meta.images && meta.images.thumbnail) ?
|
|
1819
|
-
liveUrl: liveMatch ? awardDecodeEntities(liveMatch[1]) : null,
|
|
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,
|
|
1820
2017
|
detailPath: detailMatch[1],
|
|
1821
2018
|
awards,
|
|
1822
|
-
studio: studioMatch ? awardDecodeEntities(studioMatch[1]).trim() : null,
|
|
2019
|
+
studio: studioMatch ? awardBoundStr(awardDecodeEntities(studioMatch[1]).trim(), AWARD_FIELD_BOUNDS.studio) : null,
|
|
1823
2020
|
source: "awwwards",
|
|
1824
2021
|
sourceUrl: AWARD_BASE_URL + detailMatch[1],
|
|
1825
2022
|
});
|
|
@@ -1836,8 +2033,22 @@ function awardParseListing(html) {
|
|
|
1836
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 });
|
|
1837
2034
|
if (sites.length >= 50) break;
|
|
1838
2035
|
}
|
|
2036
|
+
diagnostics.anchors = sites.length;
|
|
1839
2037
|
}
|
|
1840
|
-
|
|
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;
|
|
1841
2052
|
}
|
|
1842
2053
|
function awardIsNomineePage(html) { return /<h2 class="text-default">\s*Nominee\b/.test(String(html || "")); }
|
|
1843
2054
|
function awardParseScore(html) {
|
|
@@ -2091,6 +2302,45 @@ function awardSiteIdeas(detail) {
|
|
|
2091
2302
|
return ideas.slice(0, 6);
|
|
2092
2303
|
}
|
|
2093
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
|
+
|
|
2094
2344
|
// ---- Polite fetch client (1 req/s, bounded, distinct errors) ----
|
|
2095
2345
|
function createAwardClient({ fetchImpl, timeoutMs = AWARD_TIMEOUT_MS, rateMs = AWARD_RATE_MS, userAgent = AWARD_USER_AGENT } = {}) {
|
|
2096
2346
|
const fetchFn = fetchImpl || globalThis.fetch;
|
|
@@ -2107,63 +2357,87 @@ function createAwardClient({ fetchImpl, timeoutMs = AWARD_TIMEOUT_MS, rateMs = A
|
|
|
2107
2357
|
return next;
|
|
2108
2358
|
};
|
|
2109
2359
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2110
|
-
|
|
2111
|
-
const ctrl = new AbortController();
|
|
2112
|
-
const timer = setTimeout(() => ctrl.abort(new Error("timeout after " + timeoutMs + "ms fetching " + label)), timeoutMs);
|
|
2113
|
-
try {
|
|
2114
|
-
const res = await fetchFn(url, { headers: { "user-agent": userAgent, accept: "text/html,*/*" }, redirect: "manual", signal: ctrl.signal });
|
|
2115
|
-
return res;
|
|
2116
|
-
} finally { clearTimeout(timer); }
|
|
2117
|
-
}
|
|
2118
|
-
async function readBounded(res, label) {
|
|
2119
|
-
const len = res.headers && typeof res.headers.get === "function" ? Number(res.headers.get("content-length")) : NaN;
|
|
2120
|
-
if (Number.isFinite(len) && len > AWARD_MAX_BYTES) throw awardUpstream(label + " exceeds " + AWARD_MAX_BYTES + " bytes");
|
|
2121
|
-
const text = await res.text();
|
|
2122
|
-
if (text && text.length > AWARD_MAX_BYTES) throw awardUpstream(label + " exceeds " + AWARD_MAX_BYTES + " bytes");
|
|
2123
|
-
return text || "";
|
|
2124
|
-
}
|
|
2360
|
+
const isRedirect = (status) => status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
2125
2361
|
async function getAwardHtml(pathOrUrl) {
|
|
2126
2362
|
const path = awardValidateAwardPath(pathOrUrl);
|
|
2127
2363
|
const startUrl = AWARD_BASE_URL + path;
|
|
2128
2364
|
let url = startUrl;
|
|
2129
|
-
let
|
|
2130
|
-
|
|
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 (;;) {
|
|
2131
2368
|
await acquire();
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
}
|
|
2140
|
-
if (res.status === 301 || res.status === 302 || res.status === 303 || res.status === 307 || res.status === 308) {
|
|
2141
|
-
const loc = res.headers && res.headers.get ? res.headers.get("location") : null;
|
|
2142
|
-
if (!loc || attempt === 1) throw awardUpstream("redirect without location for " + url);
|
|
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 {
|
|
2143
2376
|
try {
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
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;
|
|
2165
2440
|
}
|
|
2166
|
-
throw lastErr instanceof Error ? lastErr : awardUpstream("fetch failed for " + startUrl);
|
|
2167
2441
|
}
|
|
2168
2442
|
return { getAwardHtml };
|
|
2169
2443
|
}
|
|
@@ -2196,30 +2470,80 @@ const AWARD_STATIC_LIBS = [
|
|
|
2196
2470
|
{ lib: "barba", res: [/barba/i] },
|
|
2197
2471
|
];
|
|
2198
2472
|
const AWARD_STATIC_CUES = ["data-aos", "data-scroll", "scrolltrigger", "sticky", "parallax", "marquee", "reveal", "loader", "preloader"];
|
|
2199
|
-
|
|
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" } = {}) {
|
|
2200
2480
|
if (typeof rawUrl !== "string" || !rawUrl.trim()) throw awardErr("BAD_ARGS", "url must be a non-empty string");
|
|
2201
|
-
const fail = (reason) => ({ status: "unavailable", url: String(rawUrl).slice(0, 200), reason: String(reason).slice(0, 200) });
|
|
2202
2481
|
let target;
|
|
2203
2482
|
try { target = awardValidateHttpUrl(rawUrl, { allowLocal }); }
|
|
2204
|
-
catch (e) { return
|
|
2483
|
+
catch (e) { return { ok: false, reason: (e && e.message) || String(e) }; }
|
|
2205
2484
|
const fetchFn = fetchImpl || globalThis.fetch;
|
|
2206
|
-
|
|
2207
|
-
|
|
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++) {
|
|
2208
2496
|
const ctrl = new AbortController();
|
|
2209
|
-
|
|
2497
|
+
let timedOut = false;
|
|
2498
|
+
const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
|
|
2499
|
+
let res = null;
|
|
2210
2500
|
try {
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
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;
|
|
2218
2542
|
const src = html.slice(0, maxBytes);
|
|
2219
2543
|
const count = (re) => Math.min(999, (src.match(re) || []).length);
|
|
2220
2544
|
const libs = AWARD_STATIC_LIBS.filter(({ res }) => res.some((re) => re.test(src))).map(({ lib }) => lib);
|
|
2221
2545
|
return {
|
|
2222
|
-
status: "static-markup", url:
|
|
2546
|
+
status: "static-markup", url: got.url,
|
|
2223
2547
|
libs, keyframes: count(/@keyframes\s/gi), animations: count(/animation\s*:/gi), transitions: count(/transition\s*:/gi),
|
|
2224
2548
|
canvases: count(/<canvas/gi), videos: count(/<video/gi),
|
|
2225
2549
|
scrollCues: AWARD_STATIC_CUES.filter((c) => src.toLowerCase().includes(c)),
|
|
@@ -2304,7 +2628,7 @@ function awardCompareSites(entries) {
|
|
|
2304
2628
|
}));
|
|
2305
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 };
|
|
2306
2630
|
}
|
|
2307
|
-
function awardBuildBlueprint({ goal, refs, details, auraMatches, structureNotes, motionNotes, constraints = [], technology = null, visualDirection = null, responsive = null, requestedPalette = [], gaps: extraGaps = [] }) {
|
|
2631
|
+
function awardBuildBlueprint({ goal, refs, details, auraMatches, structureNotes, motionNotes, motionRecorded = false, constraints = [], technology = null, visualDirection = null, responsive = null, requestedPalette = [], gaps: extraGaps = [] }) {
|
|
2308
2632
|
if (!goal || !String(goal).trim()) throw awardBad("goal is required");
|
|
2309
2633
|
const usable = (details || []).filter((d) => d && d.detail);
|
|
2310
2634
|
const threshold = usable.length === 2 ? 2 : usable.length >= 3 ? Math.ceil(usable.length / 2) : Infinity;
|
|
@@ -2399,15 +2723,17 @@ function awardBuildBlueprint({ goal, refs, details, auraMatches, structureNotes,
|
|
|
2399
2723
|
if (gaps.length) { for (const g of gaps) lines.push("- " + g); }
|
|
2400
2724
|
else lines.push("- None material: refs, motion/structure evidence as cited above, catalogue matches listed.");
|
|
2401
2725
|
// Evidence-scored confidence: base from usable ref count, +1 per
|
|
2402
|
-
// independent axis actually present (matched sections,
|
|
2403
|
-
// measured structure, fully-parsed tech).
|
|
2404
|
-
//
|
|
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.
|
|
2405
2731
|
const why = [];
|
|
2406
2732
|
let points = Math.min(usable.length, 3);
|
|
2407
2733
|
why.push(usable.length + " usable ref" + (usable.length === 1 ? "" : "s"));
|
|
2408
2734
|
const sectionHits = matches.filter((m) => m.section !== "tokens" && !m.editorial).length;
|
|
2409
2735
|
if (sectionHits >= 4) { points++; why.push(sectionHits + " catalogue sections matched"); }
|
|
2410
|
-
if (
|
|
2736
|
+
if (motionRecorded) { points++; why.push("recorded motion evidence"); }
|
|
2411
2737
|
if (structureNotes && structureNotes.includes("(live-dom)")) { points++; why.push("measured structure"); }
|
|
2412
2738
|
if (usable.length >= 2 && usable.every((d) => (d.detail.technologies || []).length && !d.detail.technologiesInferred)) { points++; why.push("fully-parsed technologies (not inferred)"); }
|
|
2413
2739
|
const confidence = points <= 1 ? "low" : points <= 3 ? "low-medium" : points === 4 ? "medium" : "high";
|
|
@@ -2466,6 +2792,12 @@ function awardResolveDir(override) {
|
|
|
2466
2792
|
return path.join(os.homedir(), ".aura-mcp", "awards");
|
|
2467
2793
|
}
|
|
2468
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
|
+
}
|
|
2469
2801
|
|
|
2470
2802
|
function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
2471
2803
|
const { fs, path } = awardIdxBuiltins();
|
|
@@ -2508,7 +2840,7 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
|
2508
2840
|
const emptyElements = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
|
|
2509
2841
|
const emptyTax = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), colors: [], filters: [] });
|
|
2510
2842
|
const emptyMotion = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
|
|
2511
|
-
const emptyMeta = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), indexStatus: null, progress: [], baselines: {}, watches: [] });
|
|
2843
|
+
const emptyMeta = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), indexStatus: null, progress: [], baselines: {}, watches: [], crawl: null });
|
|
2512
2844
|
|
|
2513
2845
|
function siteMap() {
|
|
2514
2846
|
const doc = readJson("sites.json", emptySites());
|
|
@@ -2516,6 +2848,20 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
|
2516
2848
|
for (const s of (doc.items || [])) if (s && s.slug) map.set(String(s.slug).toLowerCase(), s);
|
|
2517
2849
|
return { doc, map };
|
|
2518
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
|
+
}
|
|
2519
2865
|
function acquireLock() {
|
|
2520
2866
|
const lockFile = path.join(root, "lock.json");
|
|
2521
2867
|
try {
|
|
@@ -2530,6 +2876,112 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
|
2530
2876
|
fs.writeFileSync(lockFile, JSON.stringify({ startedAt: now(), pid: (globalThis.process && globalThis.process.pid) || 0 }), "utf8");
|
|
2531
2877
|
}
|
|
2532
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
|
+
};
|
|
2533
2985
|
|
|
2534
2986
|
return {
|
|
2535
2987
|
root,
|
|
@@ -2547,14 +2999,33 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
|
2547
2999
|
} catch { lock = { unreadable: true }; }
|
|
2548
3000
|
const finishedAt = meta.indexStatus && meta.indexStatus.finishedAt;
|
|
2549
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);
|
|
2550
3017
|
return {
|
|
2551
3018
|
dir: root, version: AWARD_INDEX_VERSION,
|
|
2552
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 },
|
|
2553
3020
|
freshness, stale: !finishedAt || (now() - finishedAt >= AWARD_INDEX_STALE_MS),
|
|
2554
|
-
progress: meta.indexStatus || null,
|
|
3021
|
+
progress: meta.indexStatus || null,
|
|
3022
|
+
pendingPages: meta.progress || [],
|
|
3023
|
+
completedPages: crawl ? crawl.pagesDone : (meta.progress || []).length,
|
|
3024
|
+
crawl,
|
|
2555
3025
|
lock: lock ? { held: true, since: lock.startedAt ? new Date(lock.startedAt).toISOString() : null } : { held: false },
|
|
2556
3026
|
...(sites.corrupt ? { sitesCorrupt: sites.corrupt } : {}),
|
|
2557
3027
|
...(meta.corrupt ? { metaCorrupt: meta.corrupt } : {}),
|
|
3028
|
+
...(recovered.length ? { recovered } : {}),
|
|
2558
3029
|
};
|
|
2559
3030
|
},
|
|
2560
3031
|
listSites(limit = 50) {
|
|
@@ -2566,20 +3037,7 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
|
2566
3037
|
const { map } = siteMap();
|
|
2567
3038
|
return map.get(n) || null;
|
|
2568
3039
|
},
|
|
2569
|
-
upsertSites(sites) {
|
|
2570
|
-
const { doc, map } = siteMap();
|
|
2571
|
-
let added = 0, updated = 0;
|
|
2572
|
-
for (const s of (sites || [])) {
|
|
2573
|
-
if (!s || !s.slug) continue;
|
|
2574
|
-
const k = String(s.slug).toLowerCase();
|
|
2575
|
-
if (map.has(k)) { map.set(k, { ...map.get(k), ...s }); updated++; }
|
|
2576
|
-
else { map.set(k, s); added++; }
|
|
2577
|
-
}
|
|
2578
|
-
let items = [...map.values()];
|
|
2579
|
-
if (items.length > AWARD_MAX_SITES) items = items.slice(items.length - AWARD_MAX_SITES);
|
|
2580
|
-
writeAtomic("sites.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items });
|
|
2581
|
-
return { added, updated, total: items.length };
|
|
2582
|
-
},
|
|
3040
|
+
upsertSites(sites) { return upsertSitesImpl(sites); },
|
|
2583
3041
|
getTaxonomy() { return readJson("taxonomy.json", emptyTax()); },
|
|
2584
3042
|
setTaxonomy(tax) {
|
|
2585
3043
|
const colors = Array.isArray(tax.colors) ? [...new Set(tax.colors.map(String))].slice(0, 100) : [];
|
|
@@ -2626,98 +3084,73 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
|
2626
3084
|
return { total: items.length, items: items.slice(0, Math.max(1, Math.min(limit || 10, 25))), empty: (doc.items || []).length === 0 };
|
|
2627
3085
|
},
|
|
2628
3086
|
// Index lifecycle: crawl tag pages via client, resumable, bounded.
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
const wanted = Array.isArray(tags) && tags.length ? tags.map(String).slice(0, 200) : (tax.filters || []).slice(0, 200);
|
|
2643
|
-
if (!wanted.length) throw awardErr("UPSTREAM_ERROR", "no taxonomy to crawl; index has no filters");
|
|
2644
|
-
const done = new Set(meta.progress || []);
|
|
2645
|
-
// Completed checkpoint from a prior cycle is not resumable work.
|
|
2646
|
-
if (meta.indexStatus && meta.indexStatus.finishedAt && done.size >= (meta.indexStatus.pagesTotal || 0)) { meta.progress = []; done.clear(); }
|
|
2647
|
-
let pagesDone = 0, sitesIndexed = 0;
|
|
2648
|
-
const skipped = [...wanted].filter((t) => done.has(t)).length;
|
|
2649
|
-
for (const tag of wanted) {
|
|
2650
|
-
if (done.has(tag)) continue;
|
|
2651
|
-
const { html } = await client.getAwardHtml("/websites/" + encodeURIComponent(tag) + "/");
|
|
2652
|
-
const sites = awardParseListing(html);
|
|
2653
|
-
if (!sites.length) throw awardErr("UPSTREAM_ERROR", "awwwards layout may have changed: 0 cards on /websites/" + tag + "/");
|
|
2654
|
-
this.upsertSites(sites.map((s) => ({ ...s, tags: [...new Set([...(s.tags || []), tag])] })));
|
|
2655
|
-
sitesIndexed += sites.length;
|
|
2656
|
-
pagesDone++;
|
|
2657
|
-
done.add(tag);
|
|
2658
|
-
meta = readJson("meta.json", emptyMeta());
|
|
2659
|
-
meta.progress = [...done];
|
|
2660
|
-
meta.updatedAt = new Date(now()).toISOString();
|
|
2661
|
-
writeAtomic("meta.json", meta);
|
|
2662
|
-
log("[" + done.size + "/" + wanted.length + "] " + tag + ": " + sites.length + " sites");
|
|
2663
|
-
}
|
|
2664
|
-
meta = readJson("meta.json", emptyMeta());
|
|
2665
|
-
meta.indexStatus = { startedAt, finishedAt: now(), pagesDone: done.size, pagesTotal: wanted.length, sitesIndexed };
|
|
2666
|
-
meta.progress = [];
|
|
2667
|
-
meta.updatedAt = new Date(now()).toISOString();
|
|
2668
|
-
writeAtomic("meta.json", meta);
|
|
2669
|
-
return { pagesDone, pagesTotal: wanted.length, sitesIndexed, skipped };
|
|
2670
|
-
} catch (e) {
|
|
2671
|
-
try {
|
|
2672
|
-
const meta = readJson("meta.json", emptyMeta());
|
|
2673
|
-
meta.indexStatus = { ...(meta.indexStatus || {}), lastError: String((e && e.message) || e).slice(0, 300) };
|
|
2674
|
-
writeAtomic("meta.json", meta);
|
|
2675
|
-
} catch { /* never mask */ }
|
|
2676
|
-
throw e;
|
|
2677
|
-
} finally { releaseLock(); }
|
|
2678
|
-
},
|
|
2679
|
-
async resume(args) { return this.rebuild(args); },
|
|
2680
|
-
async refresh(args) {
|
|
2681
|
-
const meta = readJson("meta.json", emptyMeta());
|
|
2682
|
-
meta.progress = [];
|
|
2683
|
-
writeAtomic("meta.json", meta);
|
|
2684
|
-
return this.rebuild(args);
|
|
2685
|
-
},
|
|
2686
|
-
// Winners: baseline seed then delta (never fabricate dates).
|
|
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.
|
|
2687
3100
|
async newWinners({ client, award = "sotd" } = {}) {
|
|
2688
3101
|
if (!client || typeof client.getAwardHtml !== "function") throw awardIdxBad("newWinners needs an award client");
|
|
2689
3102
|
if (!AWARD_FILTERS[award]) throw awardIdxBad("award must be sotd|developer|honorable");
|
|
2690
3103
|
const { html } = await client.getAwardHtml("/" + AWARD_FILTERS[award] + "/");
|
|
2691
|
-
const
|
|
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 };
|
|
2692
3108
|
const meta = readJson("meta.json", emptyMeta());
|
|
2693
3109
|
const key = "baseline_" + award;
|
|
2694
3110
|
const prev = meta.baselines && meta.baselines[key];
|
|
2695
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);
|
|
2696
3126
|
if (!prev) {
|
|
2697
|
-
meta.baselines = { ...(meta.baselines || {}), [key]: { slugs
|
|
3127
|
+
meta.baselines = { ...(meta.baselines || {}), [key]: { slugs, at: new Date(now()).toISOString() } };
|
|
2698
3128
|
writeAtomic("meta.json", meta);
|
|
2699
3129
|
this.upsertSites(parsed);
|
|
2700
|
-
return { day, seeded: true, newWinners: [], totalInListing: parsed.length, note: "baseline seeded; no historical delta available yet" };
|
|
3130
|
+
return { day, seeded: true, newWinners: [], totalInListing: parsed.length, parser: parserStamp, note: "baseline seeded; no historical delta available yet" };
|
|
2701
3131
|
}
|
|
2702
3132
|
const seen = new Set(prev.slugs || []);
|
|
2703
3133
|
const fresh = parsed.filter((s) => !seen.has(s.slug));
|
|
2704
|
-
meta.baselines[key] = { slugs
|
|
3134
|
+
meta.baselines[key] = { slugs, at: new Date(now()).toISOString() };
|
|
2705
3135
|
writeAtomic("meta.json", meta);
|
|
2706
|
-
|
|
2707
|
-
return { day, seeded: false, newWinners: fresh, totalInListing: parsed.length };
|
|
3136
|
+
this.upsertSites(parsed);
|
|
3137
|
+
return { day, seeded: false, newWinners: fresh, totalInListing: parsed.length, parser: parserStamp };
|
|
2708
3138
|
},
|
|
2709
3139
|
watchAdd({ kind, pattern, award } = {}) {
|
|
2710
3140
|
if (!["studio", "tag", "url"].includes(kind)) throw awardIdxBad("watch kind must be studio|tag|url");
|
|
2711
3141
|
if (typeof pattern !== "string" || !pattern.trim() || pattern.length > 120) throw awardIdxBad("watch pattern must be 1-120 chars");
|
|
2712
3142
|
const clean = pattern.trim().toLowerCase();
|
|
2713
|
-
|
|
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;
|
|
2714
3146
|
if (kind === "tag" && !/^[a-z0-9-]{2,60}$/.test(clean)) throw awardIdxBad("tag watch must be a slug like 'webgl'");
|
|
2715
3147
|
if (award !== undefined && !["sotd", "developer", "honorable"].includes(award)) throw awardIdxBad("watch award must be sotd|developer|honorable");
|
|
2716
3148
|
const meta = readJson("meta.json", emptyMeta());
|
|
2717
3149
|
const watches = meta.watches || [];
|
|
2718
|
-
|
|
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);
|
|
2719
3152
|
if (watches.length >= 50) throw awardIdxBad("too many watches (max 50)");
|
|
2720
|
-
const rec = { kind, pattern: clean, award: award || null, createdAt: new Date(now()).toISOString(), lastCheckedAt: null, lastSlugs: null };
|
|
3153
|
+
const rec = { kind, pattern: stored, ...(stored !== clean ? { input: pattern.trim() } : {}), award: award || null, createdAt: new Date(now()).toISOString(), lastCheckedAt: null, lastSlugs: null };
|
|
2721
3154
|
meta.watches = [...watches, rec];
|
|
2722
3155
|
writeAtomic("meta.json", meta);
|
|
2723
3156
|
return rec;
|
|
@@ -2729,20 +3162,25 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
|
2729
3162
|
watchRemove({ kind, pattern } = {}) {
|
|
2730
3163
|
const meta = readJson("meta.json", emptyMeta());
|
|
2731
3164
|
const clean = String(pattern || "").trim().toLowerCase();
|
|
3165
|
+
const target = kind === "url" ? awardUrlWatchTarget(clean) : clean;
|
|
2732
3166
|
const before = (meta.watches || []).length;
|
|
2733
|
-
meta.watches = (meta.watches || []).filter((w) => !(w.kind === kind && w.pattern === clean));
|
|
3167
|
+
meta.watches = (meta.watches || []).filter((w) => !(w.kind === kind && (w.pattern === clean || (kind === "url" && awardUrlWatchTarget(w.pattern) === target))));
|
|
2734
3168
|
writeAtomic("meta.json", meta);
|
|
2735
3169
|
return { removed: (meta.watches.length < before) };
|
|
2736
3170
|
},
|
|
2737
|
-
|
|
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() {
|
|
2738
3175
|
const meta = readJson("meta.json", emptyMeta());
|
|
2739
3176
|
const { map } = siteMap();
|
|
2740
3177
|
const rows = [...map.values()];
|
|
2741
|
-
const
|
|
3178
|
+
const deltas = [];
|
|
3179
|
+
const seeded = [];
|
|
2742
3180
|
const watches = (meta.watches || []).map((w) => ({ ...w }));
|
|
2743
3181
|
for (const w of watches) {
|
|
2744
3182
|
const hits = rows.filter((s) => {
|
|
2745
|
-
if (w.kind === "url") return String(s.slug) === w.pattern;
|
|
3183
|
+
if (w.kind === "url") return String(s.slug) === awardUrlWatchTarget(w.pattern);
|
|
2746
3184
|
if (w.kind === "studio") return String(s.studio || "").toLowerCase().includes(w.pattern);
|
|
2747
3185
|
return (s.tags || []).some((t) => String(t).toLowerCase().replace(/\W+/g, "-") === w.pattern);
|
|
2748
3186
|
});
|
|
@@ -2750,18 +3188,20 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
|
|
|
2750
3188
|
if (w.lastSlugs === null || w.lastSlugs === undefined) {
|
|
2751
3189
|
w.lastCheckedAt = new Date(now()).toISOString();
|
|
2752
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 })) });
|
|
2753
3192
|
continue;
|
|
2754
3193
|
}
|
|
2755
3194
|
const prev = new Set(w.lastSlugs || []);
|
|
2756
3195
|
const fresh = hits.filter((s) => !prev.has(s.slug));
|
|
2757
3196
|
w.lastCheckedAt = new Date(now()).toISOString();
|
|
2758
3197
|
w.lastSlugs = slugs;
|
|
2759
|
-
if (fresh.length)
|
|
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 })) });
|
|
2760
3199
|
}
|
|
2761
3200
|
meta.watches = watches;
|
|
2762
3201
|
writeAtomic("meta.json", meta);
|
|
2763
|
-
return
|
|
3202
|
+
return { deltas, seeded };
|
|
2764
3203
|
},
|
|
3204
|
+
checkWatches() { return this.checkWatchesDetailed().deltas; },
|
|
2765
3205
|
};
|
|
2766
3206
|
}
|
|
2767
3207
|
|
|
@@ -2869,6 +3309,42 @@ function cdpBandsFromHtml(html, url) {
|
|
|
2869
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" };
|
|
2870
3310
|
}
|
|
2871
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
|
+
|
|
2872
3348
|
// CDP connection: id-keyed commands over a WebSocket-like transport.
|
|
2873
3349
|
// Messages without an id are domain events (Page.loadEventFired, ...)
|
|
2874
3350
|
// dispatched to onEvent subscribers.
|
|
@@ -3074,6 +3550,26 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
|
|
|
3074
3550
|
return out;
|
|
3075
3551
|
} finally { clearTimeout(stopTimer); cleanup(); }
|
|
3076
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
|
+
}
|
|
3077
3573
|
return {
|
|
3078
3574
|
status() {
|
|
3079
3575
|
let exe = null;
|
|
@@ -3093,6 +3589,7 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
|
|
|
3093
3589
|
const settled = waitForSettled(conn, send, waitStrategy);
|
|
3094
3590
|
await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
|
|
3095
3591
|
await settled;
|
|
3592
|
+
await cdpNavGuard(send, allowLocal);
|
|
3096
3593
|
await new Promise((r) => setTimeout(r, settle));
|
|
3097
3594
|
// Pre-scroll for lazy sections (bounded).
|
|
3098
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 */ }
|
|
@@ -3102,11 +3599,27 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
|
|
|
3102
3599
|
},
|
|
3103
3600
|
});
|
|
3104
3601
|
const dir = saveDir || path.join(os.tmpdir(), "aura-captures");
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
try {
|
|
3109
|
-
|
|
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" };
|
|
3110
3623
|
},
|
|
3111
3624
|
async structure(url, { viewport = "desktop", allowLocal = false } = {}) {
|
|
3112
3625
|
const target = awardValidateHttpUrl(url, { allowLocal });
|
|
@@ -3118,6 +3631,7 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
|
|
|
3118
3631
|
const settled = waitForSettled(conn, send, "load");
|
|
3119
3632
|
await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
|
|
3120
3633
|
await settled;
|
|
3634
|
+
await cdpNavGuard(send, allowLocal);
|
|
3121
3635
|
const res = await send("Runtime.evaluate", { expression: CDP_SCAN_JS, returnByValue: true }, 10000);
|
|
3122
3636
|
return (res && res.result && res.result.value) || { title: target, totalHeight: 0, candidates: [] };
|
|
3123
3637
|
},
|
|
@@ -3135,6 +3649,7 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
|
|
|
3135
3649
|
const settled = waitForSettled(conn, send, "load");
|
|
3136
3650
|
await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
|
|
3137
3651
|
await settled;
|
|
3652
|
+
await cdpNavGuard(send, allowLocal);
|
|
3138
3653
|
await new Promise((r) => setTimeout(r, 1500));
|
|
3139
3654
|
const res = await send("Runtime.evaluate", { expression: CDP_MOTION_JS, returnByValue: true }, 10000);
|
|
3140
3655
|
return (res && res.result && res.result.value) || { libs: [], render: [], scrollTrigger: { count: 0 }, tweens: { durations: [], eases: [] }, cssBeziers: [] };
|
|
@@ -3160,6 +3675,7 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
|
|
|
3160
3675
|
const settled = waitForSettled(conn, send, "load");
|
|
3161
3676
|
await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
|
|
3162
3677
|
await settled;
|
|
3678
|
+
await cdpNavGuard(send, allowLocal);
|
|
3163
3679
|
await new Promise((r) => setTimeout(r, Math.min(4000, stepMs)));
|
|
3164
3680
|
const files = [];
|
|
3165
3681
|
for (let i = 0; i < n; i++) {
|
|
@@ -3183,17 +3699,34 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
|
|
|
3183
3699
|
},
|
|
3184
3700
|
});
|
|
3185
3701
|
const study = out.inspect ? reduceCdpScan(target, out.inspect, now(), viewport) : null;
|
|
3186
|
-
|
|
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 } : {}) };
|
|
3187
3712
|
},
|
|
3188
3713
|
async encodeWebm(frameFiles, { ffmpegPath = null } = {}) {
|
|
3714
|
+
if (!Array.isArray(frameFiles) || !frameFiles.length) throw cdpBad("no frames to encode");
|
|
3189
3715
|
const bin = ffmpegPath || findFfmpeg({});
|
|
3190
3716
|
if (!bin) return { video: null, available: false, hint: "ffmpeg not found; install ffmpeg or set AURA_FFMPEG_PATH for .webm output" };
|
|
3191
|
-
|
|
3192
|
-
|
|
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";
|
|
3193
3726
|
const video = path.join(dir, "motion.webm");
|
|
3194
|
-
const args = ["-y", "-framerate", "2", "-i", path.join(dir, "frame-%02d.jpg"), "-c:v", "libvpx", "-pix_fmt", "yuv420p", video];
|
|
3727
|
+
const args = ["-y", "-framerate", "2", "-start_number", startNumber, "-i", path.join(dir, "frame-%02d.jpg"), "-c:v", "libvpx", "-pix_fmt", "yuv420p", video];
|
|
3195
3728
|
const res = cp.spawnSync(bin, args, { timeout: 60000, windowsHide: true });
|
|
3196
|
-
if (res && res.status === 0 && fs.existsSync(video)) return { video, available: true, bytes: fs.statSync(video).size };
|
|
3729
|
+
if (res && res.status === 0 && fs.existsSync(video)) return { video, available: true, bytes: fs.statSync(video).size, framesUsed: existing.length };
|
|
3197
3730
|
return { video: null, available: false, hint: "ffmpeg encode failed (" + String(res && res.status) + "); frame JPEGs retained" };
|
|
3198
3731
|
},
|
|
3199
3732
|
};
|
|
@@ -3219,7 +3752,7 @@ function reduceCdpScan(url, scan, t, viewport) {
|
|
|
3219
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) },
|
|
3220
3753
|
easingVocab: [...agg.values()].sort((a, b) => b.uses - a.uses).slice(0, 15),
|
|
3221
3754
|
durationVocab: ms.length ? { p25: q(0.25), median: q(0.5), p75: q(0.75) } : null,
|
|
3222
|
-
capturedAt: t, toolVersion: "aura/1.7.
|
|
3755
|
+
capturedAt: t, toolVersion: "aura/1.7.3", source: "live-browser",
|
|
3223
3756
|
};
|
|
3224
3757
|
}
|
|
3225
3758
|
|
|
@@ -3783,10 +4316,11 @@ function createHandlers(catalog, icons, version, awardDeps) {
|
|
|
3783
4316
|
const line = awardStaticSummary(d.slug, d.motion_static);
|
|
3784
4317
|
if (line) staticCues.push(line);
|
|
3785
4318
|
}
|
|
4319
|
+
let motionRecorded = false;
|
|
3786
4320
|
try {
|
|
3787
4321
|
const mr = awardIndex.searchMotion({ limit: 25 });
|
|
3788
4322
|
const hits = (mr.items || []).filter((m) => normed.some((s) => String(m.url || '').toLowerCase().includes(s)));
|
|
3789
|
-
if (hits.length) motionNotes = 'Recorded motion for ' + hits.map((h) => h.url).join(', ') + ': prefer ' + hits.map((h) => (h.stack && h.stack.libs || []).join('+') || 'css').join('; ');
|
|
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('; '); }
|
|
3790
4324
|
else {
|
|
3791
4325
|
if (staticCues.length) motionNotes = 'Static markup cues (NOT runtime observation — record a study for trigger counts/easing): ' + staticCues.join(' | ');
|
|
3792
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');
|
|
@@ -3798,7 +4332,7 @@ function createHandlers(catalog, icons, version, awardDeps) {
|
|
|
3798
4332
|
// PROPOSED tokens (never presented as observed ref evidence), mapped
|
|
3799
4333
|
// against the refs' observed luminance bands.
|
|
3800
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);
|
|
3801
|
-
const built = awardBuildBlueprint({ goal, refs: normed, details, auraMatches, structureNotes, motionNotes, constraints, technology, visualDirection, responsive, requestedPalette, gaps });
|
|
4335
|
+
const built = awardBuildBlueprint({ goal, refs: normed, details, auraMatches, structureNotes, motionNotes, motionRecorded, constraints, technology, visualDirection, responsive, requestedPalette, gaps });
|
|
3802
4336
|
// next_calls: immediately executable follow-ups built ONLY from real
|
|
3803
4337
|
// ids/urls gathered above — the agent acts without another search round.
|
|
3804
4338
|
const compMatches = auraMatches.filter((m) => m.section !== 'tokens' && !m.editorial).slice(0, 3);
|
|
@@ -3839,11 +4373,16 @@ function createHandlers(catalog, icons, version, awardDeps) {
|
|
|
3839
4373
|
};
|
|
3840
4374
|
const isLocalInput = (raw) => typeof raw === 'string' && (/^file:\/\//i.test(raw) || /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith('/') || /^[^/:?#]+\.html?([?#]|$)/i.test(raw));
|
|
3841
4375
|
const fetchStatic = async (target) => {
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
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;
|
|
3847
4386
|
};
|
|
3848
4387
|
const structureOf = async (rawTarget, label) => {
|
|
3849
4388
|
if (isLocalInput(rawTarget)) return readLocalSource(rawTarget, label || 'url');
|
|
@@ -3874,7 +4413,7 @@ function createHandlers(catalog, icons, version, awardDeps) {
|
|
|
3874
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.');
|
|
3875
4414
|
try { return textResult(await awardBrowser.capture(target, { viewport, waitStrategy, settleMs, allowLocal })); }
|
|
3876
4415
|
catch (e) {
|
|
3877
|
-
if (e && e.code === 'BROWSER_UNAVAILABLE') throw e;
|
|
4416
|
+
if (e && (e.code === 'BROWSER_UNAVAILABLE' || e.code === 'UNSUPPORTED_OPERATION')) throw e;
|
|
3878
4417
|
throw awardErr('UPSTREAM_ERROR', 'capture failed: ' + String((e && e.message) || e).slice(0, 220) + ' — retry, or use mode:structure for a static estimate');
|
|
3879
4418
|
}
|
|
3880
4419
|
}
|
|
@@ -3928,7 +4467,7 @@ function createHandlers(catalog, icons, version, awardDeps) {
|
|
|
3928
4467
|
video = enc.video; videoAvailable = !!enc.available; videoHint = enc.hint || null;
|
|
3929
4468
|
} catch (e) { videoHint = String((e && e.message) || e).slice(0, 160); }
|
|
3930
4469
|
if (!videoAvailable && !videoHint) videoHint = 'ffmpeg not found; install ffmpeg or set AURA_FFMPEG_PATH for .webm output — frame JPEGs retained';
|
|
3931
|
-
return textResult({ mode, url: target, study: rec.study, frames: rec.frames, frameCount: rec.frameCount, video, video_available: videoAvailable, ...(videoHint ? { videoHint } : {}) }); },
|
|
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 } : {}) }); },
|
|
3932
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');
|
|
3933
4472
|
if (action === 'status') return textResult({ action, ...awardIndex.getStatus() });
|
|
3934
4473
|
if (action === 'watch_list') return textResult({ action, watches: awardIndex.watchList() });
|
|
@@ -3945,18 +4484,22 @@ function createHandlers(catalog, icons, version, awardDeps) {
|
|
|
3945
4484
|
if (action === 'new_winners') {
|
|
3946
4485
|
if (!awardClient) throw awardErr('UNSUPPORTED_OPERATION', 'new_winners needs an award client (server wiring)');
|
|
3947
4486
|
const r = await awardIndex.newWinners({ client: awardClient, award: a.award || 'sotd' });
|
|
3948
|
-
let deltas = [];
|
|
3949
|
-
try {
|
|
3950
|
-
|
|
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' } : {}) });
|
|
3951
4493
|
}
|
|
3952
4494
|
if (!awardClient) throw awardErr('UNSUPPORTED_OPERATION', action + ' needs an award client (server wiring)');
|
|
3953
4495
|
const maxPages = a.maxPages === undefined ? 5 : toolFn_num(a.maxPages, 'maxPages');
|
|
3954
4496
|
if (typeof maxPages !== 'number' || maxPages < 1 || maxPages > 20) toolFn_bad('maxPages must be 1-20 (bounded crawl per call; resume for more)');
|
|
3955
|
-
// Bounded crawl:
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
const
|
|
3959
|
-
|
|
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' }); },
|
|
3960
4503
|
};
|
|
3961
4504
|
};
|
|
3962
4505
|
|