awwwards-mcp 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/cache.js +84 -0
- package/dist/capture.js +7 -1
- package/dist/cli.js +7 -0
- package/dist/motion.js +11 -2
- package/dist/server.js +88 -25
- package/dist/structure.js +7 -1
- package/dist/viewport.js +7 -0
- package/package.json +1 -1
- package/skills/awwwards-inspiration/SKILL.md +10 -3
- package/skills/awwwards-motion-study/SKILL.md +3 -0
package/README.md
CHANGED
|
@@ -9,18 +9,22 @@ sourced from the web's best award-winning websites.
|
|
|
9
9
|
Your agent searches in natural language ("dark 3D portfolio sites", "soft pastel
|
|
10
10
|
e-commerce"), sees **real screenshots inline**, and can pull the **design DNA**
|
|
11
11
|
of any site: color palette, tech stack, design elements, award history.
|
|
12
|
+
Free-text queries run on a porter-stemmed, prefix-matching FTS5 index with BM25
|
|
13
|
+
ranking — "magazines" now finds Magazine-tagged sites (68 on the live index),
|
|
14
|
+
best matches first, where the old substring path returned zero. Multi-word
|
|
15
|
+
queries keep AND semantics: every token must hit the same site.
|
|
12
16
|
|
|
13
17
|
## Tools
|
|
14
18
|
|
|
15
19
|
| Tool | What it does |
|
|
16
20
|
|------|--------------|
|
|
17
|
-
| `search_sites` | Search by color, tags, technology or
|
|
21
|
+
| `search_sites` | Search by color, tags, technology, award type or free-text query. Multi-word queries match against the local FTS5 index and rank BM25 (title hits lead); zero results come with loose-match and taxonomy-tag hints. Returns site cards with inline screenshots. |
|
|
18
22
|
| `get_site_details` | Full design DNA for one site: palette, technologies, elements, awards, description. |
|
|
19
23
|
| `get_site_elements` | Component-level visuals for one site: each element's poster image inline (3D models, video content, mobile layouts, microcopy…) + video URLs. |
|
|
20
24
|
| `list_categories` | Every filter the agent can search by (200+ tags, 27 colors). |
|
|
21
|
-
| `capture_live_site` | Optional: fresh full-page screenshot of any live URL. Waits for `load` + a settle window with a bounded pre-scroll, so heavy sites work (`waitStrategy: "networkidle"` available). (needs [playwright](https://playwright.dev)). |
|
|
22
|
-
| `analyze_page_structure` | Section band map of any page (live URL or local file:// build): tag, background, offset, height per band. Compare a reference site's structure against your build. Same heavy-site-friendly wait (`waitStrategy: "networkidle"` available). (needs [playwright](https://playwright.dev)). |
|
|
23
|
-
| `record_site_motion` | Optional: short motion-through video of a live URL — preloader, scroll-triggered and hover/cursor animations. Returns an inline filmstrip JPEG plus the saved .webm path. (needs [playwright](https://playwright.dev) + ffmpeg-static). |
|
|
25
|
+
| `capture_live_site` | Optional: fresh full-page screenshot of any live URL. Waits for `load` + a settle window with a bounded pre-scroll, so heavy sites work (`waitStrategy: "networkidle"` available). Pass `viewport: "mobile"` for the 390×844 iPhone-class render (`"desktop"` 1440×900 default). (needs [playwright](https://playwright.dev)). |
|
|
26
|
+
| `analyze_page_structure` | Section band map of any page (live URL or local file:// build): tag, background, offset, height per band. Compare a reference site's structure against your build. Same heavy-site-friendly wait (`waitStrategy: "networkidle"` available); `viewport: "mobile"` analyzes the phone-class layout (`"desktop"` default). (needs [playwright](https://playwright.dev)). |
|
|
27
|
+
| `record_site_motion` | Optional: short motion-through video of a live URL — preloader, scroll-triggered and hover/cursor animations. Returns an inline filmstrip JPEG plus the saved .webm path. `viewport: "mobile"` records at phone size — the filmstrip renders at the selected viewport, no pillarboxing (`"desktop"` default). (needs [playwright](https://playwright.dev) + ffmpeg-static). |
|
|
24
28
|
|
|
25
29
|
## Setup
|
|
26
30
|
|
package/dist/cache.js
CHANGED
|
@@ -17,6 +17,28 @@ const SCHEMA = `
|
|
|
17
17
|
key TEXT PRIMARY KEY, value TEXT, fetchedAt INTEGER
|
|
18
18
|
);
|
|
19
19
|
`;
|
|
20
|
+
// FTS5 full-text layer over the sites table (derived — rebuildable at any
|
|
21
|
+
// time). Probe-guarded: if this Node build ships without FTS5, every fts
|
|
22
|
+
// statement is skipped and searchSites returns null (server keeps the legacy
|
|
23
|
+
// substring path). Columns mirror sites; tags/awards stay JSON strings —
|
|
24
|
+
// unicode61 tokenizes around brackets/quotes, so tokens extract cleanly.
|
|
25
|
+
const FTS_SCHEMA = `
|
|
26
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS sites_fts USING fts5(
|
|
27
|
+
slug UNINDEXED, title, tags, awards, tokenize='porter unicode61'
|
|
28
|
+
);
|
|
29
|
+
CREATE TRIGGER IF NOT EXISTS sites_fts_ai AFTER INSERT ON sites BEGIN
|
|
30
|
+
INSERT INTO sites_fts (slug, title, tags, awards)
|
|
31
|
+
VALUES (new.slug, new.title, new.tags, new.awards);
|
|
32
|
+
END;
|
|
33
|
+
CREATE TRIGGER IF NOT EXISTS sites_fts_au AFTER UPDATE OF title, tags, awards ON sites BEGIN
|
|
34
|
+
DELETE FROM sites_fts WHERE slug = new.slug;
|
|
35
|
+
INSERT INTO sites_fts (slug, title, tags, awards)
|
|
36
|
+
VALUES (new.slug, new.title, new.tags, new.awards);
|
|
37
|
+
END;
|
|
38
|
+
CREATE TRIGGER IF NOT EXISTS sites_fts_ad AFTER DELETE ON sites BEGIN
|
|
39
|
+
DELETE FROM sites_fts WHERE slug = old.slug;
|
|
40
|
+
END;
|
|
41
|
+
`;
|
|
20
42
|
function rowToSite(r) {
|
|
21
43
|
return {
|
|
22
44
|
slug: r.slug,
|
|
@@ -34,6 +56,12 @@ export class Cache {
|
|
|
34
56
|
dbPath;
|
|
35
57
|
now;
|
|
36
58
|
imagesDir;
|
|
59
|
+
// FTS5 capability of this Node build, probed on the first DB open (the
|
|
60
|
+
// constructor's eager withDb). null = not yet probed; false = FTS5 compiled
|
|
61
|
+
// out → searchSites returns null and callers keep the legacy substring
|
|
62
|
+
// path. Instance-cached so searchSites never re-probes; public so the
|
|
63
|
+
// server layer and tests can branch on it.
|
|
64
|
+
ftsAvailable = null;
|
|
37
65
|
constructor(rootDir, now = Date.now) {
|
|
38
66
|
this.now = now;
|
|
39
67
|
mkdirSync(rootDir, { recursive: true });
|
|
@@ -52,12 +80,40 @@ export class Cache {
|
|
|
52
80
|
const db = new DatabaseSync(this.dbPath);
|
|
53
81
|
try {
|
|
54
82
|
db.exec(SCHEMA);
|
|
83
|
+
if (this.ftsAvailable === null) {
|
|
84
|
+
try {
|
|
85
|
+
db.exec(FTS_SCHEMA);
|
|
86
|
+
this.ftsAvailable = true;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
this.ftsAvailable = false; // FTS5 compiled out → legacy fallback
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (this.ftsAvailable) {
|
|
93
|
+
// The fts table is derived and must never gate correctness: if sites
|
|
94
|
+
// has rows but sites_fts is empty (pre-FTS database opened for the
|
|
95
|
+
// first time), rebuild the index. The guard lives inside the INSERT
|
|
96
|
+
// itself (slug NOT IN sites_fts), not only in the counts above: the
|
|
97
|
+
// counts are read non-atomically, so `npm run index` and a first open
|
|
98
|
+
// can both pass them and both run this statement — sites_fts.slug has
|
|
99
|
+
// no unique constraint, so an unguarded re-run would double-index.
|
|
100
|
+
// Afterwards triggers keep it synced.
|
|
101
|
+
const { s: sitesN } = db.prepare("SELECT COUNT(*) AS s FROM sites").get();
|
|
102
|
+
const { s: ftsN } = db.prepare("SELECT COUNT(*) AS s FROM sites_fts").get();
|
|
103
|
+
if (sitesN > 0 && ftsN === 0) {
|
|
104
|
+
db.exec("INSERT INTO sites_fts (slug, title, tags, awards) SELECT slug, title, tags, awards FROM sites WHERE slug NOT IN (SELECT slug FROM sites_fts)");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
55
107
|
return fn(db);
|
|
56
108
|
}
|
|
57
109
|
finally {
|
|
58
110
|
db.close();
|
|
59
111
|
}
|
|
60
112
|
}
|
|
113
|
+
/** @visibleForTesting */
|
|
114
|
+
withDbForTest(fn) {
|
|
115
|
+
this.withDb(fn);
|
|
116
|
+
}
|
|
61
117
|
upsertSites(sites) {
|
|
62
118
|
this.withDb((db) => {
|
|
63
119
|
const stmt = db.prepare(`INSERT INTO sites (slug, id, title, createdAt, tags, thumbnailPath, liveUrl, detailPath, awards, fetchedAt)
|
|
@@ -99,6 +155,34 @@ export class Cache {
|
|
|
99
155
|
return rowToSite(row);
|
|
100
156
|
});
|
|
101
157
|
}
|
|
158
|
+
// Full-text search over cached sites. matchMode "AND" (default) requires
|
|
159
|
+
// every token; "OR" matches rows containing any token (the server uses OR
|
|
160
|
+
// for its zero-result "loose matches" hint). Each token is a porter-stemmed
|
|
161
|
+
// prefix term, so multi-word queries match rows where the words are
|
|
162
|
+
// scattered across title/tags/awards. Results are bm25-ascending (best
|
|
163
|
+
// match first). Returns null when FTS5 is unavailable on this build or the
|
|
164
|
+
// query has no usable tokens — callers fall back to legacy search.
|
|
165
|
+
searchSites(query, maxAgeMs, limit = 200, matchMode = "AND") {
|
|
166
|
+
// Sanitization strips quotes/parens/operators, leaving [a-z0-9-] only —
|
|
167
|
+
// the quoted `"tok"*` MATCH string below cannot inject FTS syntax.
|
|
168
|
+
const tokens = query.toLowerCase().split(/\s+/)
|
|
169
|
+
.map((t) => t.replace(/[^a-z0-9-]/g, ""))
|
|
170
|
+
.filter((t) => t.length > 0);
|
|
171
|
+
if (!tokens.length)
|
|
172
|
+
return null;
|
|
173
|
+
return this.withDb((db) => {
|
|
174
|
+
if (!this.ftsAvailable)
|
|
175
|
+
return null;
|
|
176
|
+
const match = tokens.map((t) => `"${t}"*`).join(matchMode === "OR" ? " OR " : " AND ");
|
|
177
|
+
const min = this.now() - maxAgeMs;
|
|
178
|
+
const rows = db.prepare(`SELECT s.* FROM sites_fts
|
|
179
|
+
JOIN sites s ON s.slug = sites_fts.slug
|
|
180
|
+
WHERE sites_fts MATCH ? AND s.fetchedAt > ?
|
|
181
|
+
ORDER BY bm25(sites_fts) ASC
|
|
182
|
+
LIMIT ?`).all(match, min, limit);
|
|
183
|
+
return rows.map(rowToSite);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
102
186
|
setMeta(key, value) {
|
|
103
187
|
this.withDb((db) => {
|
|
104
188
|
db.prepare(`INSERT INTO meta (key, value, fetchedAt) VALUES (?, ?, ?)
|
package/dist/capture.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
// CAPTURE_INSTALL_HINT from here) — both sides only use the other's bindings
|
|
7
7
|
// at call time, which ESM resolves fine.
|
|
8
8
|
import { preScroll } from "./structure.js";
|
|
9
|
+
import { resolveViewport } from "./viewport.js";
|
|
9
10
|
export const CAPTURE_INSTALL_HINT = "Full-page capture needs Playwright, which is an optional dependency.\n" +
|
|
10
11
|
"Install it with: npm install -D playwright && npx playwright install chromium\n" +
|
|
11
12
|
"Then retry the capture or structure tool.";
|
|
@@ -29,7 +30,12 @@ loader = () => import("playwright"), opts) {
|
|
|
29
30
|
}
|
|
30
31
|
try {
|
|
31
32
|
const waitStrategy = opts?.waitStrategy ?? "load";
|
|
32
|
-
|
|
33
|
+
// Same viewport-profile split as analyzePageStructure (structure.ts):
|
|
34
|
+
// width/height fill the `viewport` key, mobile-profile flags spread in as
|
|
35
|
+
// sibling context options. Desktop resolves to no extra fields, so the
|
|
36
|
+
// default call shape is unchanged.
|
|
37
|
+
const { width, height, ...contextOpts } = resolveViewport(opts?.viewport);
|
|
38
|
+
const page = await browser.newPage({ viewport: { width, height }, ...contextOpts });
|
|
33
39
|
await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
|
|
34
40
|
// "load" can fire before late XHRs settle, so give the page a fixed
|
|
35
41
|
// settle window; networkidle already means the network went quiet.
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,10 @@ const waitStrategySchema = z
|
|
|
23
23
|
.enum(["load", "networkidle"])
|
|
24
24
|
.default("load")
|
|
25
25
|
.describe("'load' + settle works on heavy sites; 'networkidle' waits for total quiet");
|
|
26
|
+
const viewportSchema = z
|
|
27
|
+
.enum(["desktop", "mobile"])
|
|
28
|
+
.default("desktop")
|
|
29
|
+
.describe("desktop = 1440x900 (default); mobile = 390x844 iPhone-class with deviceScaleFactor 3, isMobile + hasTouch");
|
|
26
30
|
const cacheRoot = process.env.AWWWARDS_CACHE_DIR ?? join(homedir(), ".awwwards-mcp");
|
|
27
31
|
let cache;
|
|
28
32
|
try {
|
|
@@ -77,11 +81,13 @@ server.tool("list_categories", "List the filter taxonomy available on Awwwards:
|
|
|
77
81
|
server.tool("capture_live_site", "Take a fresh full-page screenshot of a live website URL using a headless browser. Requires the optional playwright dependency.", {
|
|
78
82
|
url: z.string().url().describe("Absolute URL of the site to capture"),
|
|
79
83
|
waitStrategy: waitStrategySchema,
|
|
84
|
+
viewport: viewportSchema,
|
|
80
85
|
}, (args) => asMcpResult(handlers.capture_live_site(args)));
|
|
81
86
|
server.tool("analyze_page_structure", "Extract a page's section band map (tag, label, background color, offset, height per band) via a headless browser. Works on live URLs and file:// paths — use it to compare a reference site's structure against your local build.", {
|
|
82
87
|
url: z.string().url().describe("Absolute URL (https:// or file://) of the page to analyze"),
|
|
83
88
|
maxBands: z.number().int().min(5).max(60).default(40).describe("Cap on returned bands"),
|
|
84
89
|
waitStrategy: waitStrategySchema,
|
|
90
|
+
viewport: viewportSchema,
|
|
85
91
|
}, (args) => asMcpResult(handlers.analyze_page_structure(args)));
|
|
86
92
|
server.tool("record_site_motion", "Record a short motion-through video of a live website — preloader, scroll-triggered and hover/cursor animations — and return an inline filmstrip JPEG plus the .webm path. Requires the optional playwright and ffmpeg-static dependencies.", {
|
|
87
93
|
url: z.string().url().describe("Absolute URL of the site to record"),
|
|
@@ -93,6 +99,7 @@ server.tool("record_site_motion", "Record a short motion-through video of a live
|
|
|
93
99
|
.default(16)
|
|
94
100
|
.describe("Filmstrip tile count (default 16 → a 4x4 grid)"),
|
|
95
101
|
waitStrategy: waitStrategySchema,
|
|
102
|
+
viewport: viewportSchema,
|
|
96
103
|
}, (args) => asMcpResult(handlers.record_site_motion(args)));
|
|
97
104
|
// Auto-refresh: if the index is stale (or absent) and no crawl is running,
|
|
98
105
|
// re-index in the background. Serving is never blocked; errors are stderr-only.
|
package/dist/motion.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, st
|
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { CAPTURE_INSTALL_HINT } from "./capture.js";
|
|
7
|
+
import { resolveViewport } from "./viewport.js";
|
|
7
8
|
export const MOTION_FFMPEG_HINT = "Motion recording needs ffmpeg-static, which is an optional dependency.\n" +
|
|
8
9
|
"Install it with: npm install -D ffmpeg-static\n" +
|
|
9
10
|
"Then retry record_site_motion.";
|
|
@@ -167,9 +168,17 @@ export async function recordSiteMotion(url, opts) {
|
|
|
167
168
|
const videoTmp = mkdtempSync(join(opts.cacheImagesDir, ".video-tmp-"));
|
|
168
169
|
const waitStrategy = opts.waitStrategy ?? "load";
|
|
169
170
|
try {
|
|
171
|
+
// Viewport profile split (same as structure/capture): width/height fill
|
|
172
|
+
// the `viewport` key; the mobile-profile flags (deviceScaleFactor/
|
|
173
|
+
// isMobile/hasTouch) spread in as sibling context options AFTER the
|
|
174
|
+
// existing fields. recordVideo.size derives from the same profile so the
|
|
175
|
+
// video canvas matches the viewport (desktop keeps the 1440x900 canvas; a
|
|
176
|
+
// mobile recording gets a 390x844 canvas instead of a pillarboxed one).
|
|
177
|
+
const { width, height, ...contextOpts } = resolveViewport(opts.viewport);
|
|
170
178
|
const context = await browser.newContext({
|
|
171
|
-
viewport: { width
|
|
172
|
-
recordVideo: { dir: videoTmp, size: { width
|
|
179
|
+
viewport: { width, height },
|
|
180
|
+
recordVideo: { dir: videoTmp, size: { width, height } },
|
|
181
|
+
...contextOpts,
|
|
173
182
|
});
|
|
174
183
|
try {
|
|
175
184
|
const page = await context.newPage();
|
package/dist/server.js
CHANGED
|
@@ -76,7 +76,12 @@ export function createHandlers(deps) {
|
|
|
76
76
|
// filter was applied, so check every client-checkable filter. Color is
|
|
77
77
|
// never client-checkable (site rows carry no colors); it is handled by
|
|
78
78
|
// never serving color searches from cache (see search_sites).
|
|
79
|
-
|
|
79
|
+
// skipQueryCheck=true: the rows already matched the free-text query through
|
|
80
|
+
// FTS (prefix+stem semantics); re-checking with substring semantics would
|
|
81
|
+
// wrongly drop stem matches ("magazines" → "Magazine"), so only the
|
|
82
|
+
// non-query filters run. Scraped rows keep the check (default false) — they
|
|
83
|
+
// never came from FTS.
|
|
84
|
+
function matchesFilters(s, f, honorUrlSource, skipQueryCheck = false) {
|
|
80
85
|
const source = urlSource(f);
|
|
81
86
|
if (f.tags?.length) {
|
|
82
87
|
const tagsToCheck = honorUrlSource && source === "tag" ? f.tags.slice(1) : f.tags;
|
|
@@ -95,7 +100,7 @@ export function createHandlers(deps) {
|
|
|
95
100
|
if (!s.awards.includes(AWARD_FILTER_LABELS[f.award]))
|
|
96
101
|
return false;
|
|
97
102
|
}
|
|
98
|
-
if (f.query) {
|
|
103
|
+
if (f.query && !skipQueryCheck) {
|
|
99
104
|
const queryTokens = tokenizeQuery(f.query);
|
|
100
105
|
if (queryTokens.length) {
|
|
101
106
|
const hay = (s.title + " " + s.tags.join(" ")).toLowerCase();
|
|
@@ -136,18 +141,44 @@ export function createHandlers(deps) {
|
|
|
136
141
|
// Color can't be verified client-side (site rows carry no colors), so a
|
|
137
142
|
// color search always scrapes its filter page; everything else is
|
|
138
143
|
// client-checkable against the index.
|
|
139
|
-
let sites
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
let sites;
|
|
145
|
+
let ftsRows = false; // query served from FTS-ranked rows (bm25 order)
|
|
146
|
+
let ftsEmpty = false; // FTS ran and matched nothing → loose hints apply
|
|
147
|
+
if (args.color) {
|
|
148
|
+
sites = [];
|
|
149
|
+
}
|
|
150
|
+
else if (args.query) {
|
|
151
|
+
const ranked = cache.searchSites(args.query, SITE_TTL_MS);
|
|
152
|
+
if (ranked) {
|
|
153
|
+
ftsRows = true;
|
|
154
|
+
ftsEmpty = ranked.length === 0;
|
|
155
|
+
// FTS already applied the query (prefix+stem, bm25-ranked). Re-check
|
|
156
|
+
// only the non-query filters; re-checking the query here with
|
|
157
|
+
// substring semantics would wrongly drop prefix/stem matches.
|
|
158
|
+
// Array.filter preserves the bm25 order.
|
|
159
|
+
sites = ranked.filter((s) => matchesFilters(s, args, false, true));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// FTS5 unavailable or no usable tokens: legacy substring path.
|
|
163
|
+
sites = cache.getSites(SITE_TTL_MS).filter((s) => matchesFilters(s, args, false));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
sites = cache.getSites(SITE_TTL_MS).filter((s) => matchesFilters(s, args, false));
|
|
168
|
+
}
|
|
142
169
|
// A scrape normally REPLACES the matched rows (fresh rows are only
|
|
143
170
|
// guaranteed the URL filter), so it must run when the cache cannot serve
|
|
144
171
|
// the requested page window at all. When the window is only PARTIALLY
|
|
145
|
-
// filled (e.g. a
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
172
|
+
// filled (e.g. a query matched 3 cached rows for count 6), replacing
|
|
173
|
+
// would discard already-verified matches — those runs scrape the filter
|
|
174
|
+
// page and MERGE instead: dedupe by slug, verified cache rows win
|
|
175
|
+
// duplicate slugs, scraped-only rows appended; the merge result is
|
|
176
|
+
// client-checked as before (cache rows already passed matchesFilters,
|
|
177
|
+
// scraped rows matchesFilters(true)). Non-FTS runs sort everything
|
|
178
|
+
// newest-first like getSites; FTS runs keep the bm25-ranked cache rows
|
|
179
|
+
// first in rank order and append the scraped-only rows newest-first
|
|
180
|
+
// among themselves — re-sorting ranked rows by createdAt would destroy
|
|
181
|
+
// the ranking the query asked for.
|
|
151
182
|
const pageWindowEmpty = sites.slice((page - 1) * count, page * count).length === 0;
|
|
152
183
|
const pageWindowPartial = !pageWindowEmpty && sites.length < page * count;
|
|
153
184
|
if (pageWindowEmpty) {
|
|
@@ -180,14 +211,26 @@ export function createHandlers(deps) {
|
|
|
180
211
|
if (parsed.length > 0) {
|
|
181
212
|
cache.upsertSites(parsed);
|
|
182
213
|
const fresh = parsed.filter((s) => matchesFilters(s, args, true));
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
214
|
+
if (ftsRows) {
|
|
215
|
+
// Ranked cache rows keep their bm25 order; scraped-only rows
|
|
216
|
+
// append newest-first (cache rows win duplicate slugs, as
|
|
217
|
+
// everywhere — a slug in the ranked set is never re-added).
|
|
218
|
+
const rankedSlugs = new Set(sites.map((s) => s.slug));
|
|
219
|
+
const scrapedOnly = fresh
|
|
220
|
+
.filter((s) => !rankedSlugs.has(s.slug))
|
|
221
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
222
|
+
sites = [...sites, ...scrapedOnly];
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const bySlug = new Map();
|
|
226
|
+
// Scraped rows seed the map; verified cache rows then overwrite any
|
|
227
|
+
// duplicate slug, so they always win.
|
|
228
|
+
for (const s of fresh)
|
|
229
|
+
bySlug.set(s.slug, s);
|
|
230
|
+
for (const s of sites)
|
|
231
|
+
bySlug.set(s.slug, s);
|
|
232
|
+
sites = [...bySlug.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
233
|
+
}
|
|
191
234
|
}
|
|
192
235
|
}
|
|
193
236
|
catch {
|
|
@@ -197,6 +240,17 @@ export function createHandlers(deps) {
|
|
|
197
240
|
const ordered = orderByScore(sites, args.sortBy);
|
|
198
241
|
const slice = ordered.slice((page - 1) * count, page * count);
|
|
199
242
|
if (slice.length === 0) {
|
|
243
|
+
// Loose-match hint, computed once: only for genuine FTS zero results
|
|
244
|
+
// (FTS ran and matched nothing) — rows dropped by the non-query
|
|
245
|
+
// filters are not "loose matches". Up to 3 OR-relaxed slugs.
|
|
246
|
+
let looseHint = "";
|
|
247
|
+
if (ftsEmpty && args.query) {
|
|
248
|
+
const orSlugs = (cache.searchSites(args.query, SITE_TTL_MS, 3, "OR") ?? [])
|
|
249
|
+
.map((s) => s.slug);
|
|
250
|
+
if (orSlugs.length > 0) {
|
|
251
|
+
looseHint = `\nLoose matches (any token): ${orSlugs.join(", ")}.`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
200
254
|
if (sites.length === 0) {
|
|
201
255
|
// True zero-result search: suggest the closest taxonomy tags. The
|
|
202
256
|
// taxonomy comes from the cache only — never a live fetch just to
|
|
@@ -209,15 +263,17 @@ export function createHandlers(deps) {
|
|
|
209
263
|
return {
|
|
210
264
|
content: [
|
|
211
265
|
text(`No sites matched the search. Closest filter tags: ${suggestions.join(", ")}. ` +
|
|
212
|
-
|
|
266
|
+
`Run list_categories for the full taxonomy.${looseHint}`),
|
|
213
267
|
],
|
|
214
268
|
};
|
|
215
269
|
}
|
|
216
270
|
}
|
|
217
271
|
return {
|
|
218
272
|
content: [
|
|
219
|
-
text(
|
|
220
|
-
"(Deep pagination is unavailable by design: awwwards.com's robots.txt disallows it.)"
|
|
273
|
+
text(`No sites matched the search on this page. Try fewer filters or run list_categories. ` +
|
|
274
|
+
"(Deep pagination is unavailable by design: awwwards.com's robots.txt disallows it.)" +
|
|
275
|
+
// looseHint is empty unless this is a true FTS zero result.
|
|
276
|
+
looseHint),
|
|
221
277
|
],
|
|
222
278
|
};
|
|
223
279
|
}
|
|
@@ -419,7 +475,12 @@ export function createHandlers(deps) {
|
|
|
419
475
|
// the injectable playwright loader — opts must land in fourth place.
|
|
420
476
|
const capture = deps.captureFn ??
|
|
421
477
|
((url, imagesDir, opts) => import("./capture.js").then((m) => m.captureLiveSite(url, imagesDir, undefined, opts)));
|
|
422
|
-
|
|
478
|
+
// The tool schema defaults viewport to "desktop" (zod); the ?? keeps
|
|
479
|
+
// direct handler calls on the same explicit path.
|
|
480
|
+
const result = await capture(args.url, cache.imagesDir, {
|
|
481
|
+
waitStrategy: args.waitStrategy,
|
|
482
|
+
viewport: args.viewport ?? "desktop",
|
|
483
|
+
});
|
|
423
484
|
if ("error" in result)
|
|
424
485
|
return { content: [text(result.error)], isError: true };
|
|
425
486
|
return {
|
|
@@ -442,6 +503,7 @@ export function createHandlers(deps) {
|
|
|
442
503
|
((url, maxBands, opts) => import("./structure.js").then((m) => m.analyzePageStructure(url, undefined, maxBands, opts)));
|
|
443
504
|
const structure = await analyze(args.url, args.maxBands, {
|
|
444
505
|
waitStrategy: args.waitStrategy,
|
|
506
|
+
viewport: args.viewport ?? "desktop",
|
|
445
507
|
});
|
|
446
508
|
if ("error" in structure)
|
|
447
509
|
return { content: [text(structure.error)], isError: true };
|
|
@@ -454,14 +516,15 @@ export function createHandlers(deps) {
|
|
|
454
516
|
async function record_site_motion(args) {
|
|
455
517
|
try {
|
|
456
518
|
// Lazy default: playwright/ffmpeg are only touched when the tool runs.
|
|
457
|
-
// The default forwards motionOpts wholesale, so waitStrategy
|
|
458
|
-
// recordSiteMotion's MotionOpts (which already accepts
|
|
519
|
+
// The default forwards motionOpts wholesale, so waitStrategy and viewport
|
|
520
|
+
// flow into recordSiteMotion's MotionOpts (which already accepts both).
|
|
459
521
|
const motion = deps.motionFn ??
|
|
460
522
|
((url, motionOpts) => import("./motion.js").then((m) => m.recordSiteMotion(url, motionOpts)));
|
|
461
523
|
const result = await motion(args.url, {
|
|
462
524
|
cacheImagesDir: cache.imagesDir,
|
|
463
525
|
frames: args.frames,
|
|
464
526
|
waitStrategy: args.waitStrategy,
|
|
527
|
+
viewport: args.viewport ?? "desktop",
|
|
465
528
|
});
|
|
466
529
|
if ("error" in result)
|
|
467
530
|
return { content: [text(result.error)], isError: true };
|
package/dist/structure.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CAPTURE_INSTALL_HINT } from "./capture.js";
|
|
2
|
+
import { resolveViewport } from "./viewport.js";
|
|
2
3
|
// Runs IN THE PAGE via page.evaluate. Collects full-width, tall, opaque
|
|
3
4
|
// elements as band candidates; body is always the base candidate. Gradient
|
|
4
5
|
// shorthand backgrounds leave backgroundColor transparent — such sections
|
|
@@ -182,7 +183,12 @@ export async function analyzePageStructure(url, loader = () => import("playwrigh
|
|
|
182
183
|
}
|
|
183
184
|
try {
|
|
184
185
|
const waitStrategy = opts?.waitStrategy ?? "load";
|
|
185
|
-
|
|
186
|
+
// Viewport profile split: width/height fill playwright's `viewport` key;
|
|
187
|
+
// the mobile-profile flags (deviceScaleFactor/isMobile/hasTouch) are
|
|
188
|
+
// sibling context options. The desktop profile resolves to no extra
|
|
189
|
+
// fields, so the default call shape is unchanged.
|
|
190
|
+
const { width, height, ...contextOpts } = resolveViewport(opts?.viewport);
|
|
191
|
+
const page = await browser.newPage({ viewport: { width, height }, ...contextOpts });
|
|
186
192
|
await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
|
|
187
193
|
// "load" can fire before late XHRs settle, so give the page a fixed
|
|
188
194
|
// settle window; networkidle already means the network went quiet.
|
package/dist/viewport.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "awwwards-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Free MCP server giving AI agents design inspiration from Awwwards: search award-winning sites with inline screenshots and extract design DNA.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,9 @@ Run this loop before building anything visual:
|
|
|
31
31
|
3. **Search.** Call `search_sites` with 1–3 filters (e.g.
|
|
32
32
|
`{ color: "#404040", tags: ["3d", "portfolio"] }`). Judge the results from
|
|
33
33
|
the inline screenshots, not just titles. Shortlist 2–3 candidates.
|
|
34
|
+
Free-text queries are porter-stem + prefix-matched and BM25-ranked
|
|
35
|
+
(`"magazines"` finds Magazine-tagged sites, best matches first); multi-word
|
|
36
|
+
queries keep AND semantics — both tokens must hit the same site.
|
|
34
37
|
4. **Live reference URL named? Capture it full-page first.** (and later capture
|
|
35
38
|
your own build the same way — compare both against each other) If the user
|
|
36
39
|
points at a specific live site (e.g. "recreate cerebrium.ai"), call
|
|
@@ -38,7 +41,9 @@ Run this loop before building anything visual:
|
|
|
38
41
|
full-page PNG — every section, top to bottom. Cached Awwwards screenshots
|
|
39
42
|
are hero-only crops (~880×660) and hide everything below the fold: the
|
|
40
43
|
sections that make a site's structure distinctive (pricing, feature
|
|
41
|
-
layouts, contrast breaks, footer) were never visible in them.
|
|
44
|
+
layouts, contrast breaks, footer) were never visible in them. For
|
|
45
|
+
mobile-excellence references pass `viewport: "mobile"` — and capture BOTH
|
|
46
|
+
viewports when the desktop and mobile designs diverge.
|
|
42
47
|
5. **Get the design DNA.** Call `get_site_details` on the top pick for its
|
|
43
48
|
palette, technologies, design elements, awards, and description. If it
|
|
44
49
|
reports a layout-drift error, fall back to judging the shortlisted
|
|
@@ -68,8 +73,10 @@ Run this loop before building anything visual:
|
|
|
68
73
|
8. **Verify structure, then polish.** After building, capture your own build
|
|
69
74
|
full-page (`capture_live_site` on its `file://` or served URL) and run
|
|
70
75
|
`analyze_page_structure` on BOTH the reference and the build. Compare band
|
|
71
|
-
maps section by section (count, order, backgrounds, heights).
|
|
72
|
-
|
|
76
|
+
maps section by section (count, order, backgrounds, heights). Match the
|
|
77
|
+
reference's viewport when comparing: for mobile-excellence references pass
|
|
78
|
+
`viewport: "mobile"`, and capture BOTH viewports when the design diverges.
|
|
79
|
+
Fix distribution mismatches first — a section that is 3× the reference's height
|
|
73
80
|
is a structural bug no amount of pixel polish fixes. Match the reference's
|
|
74
81
|
band structure, never just its total height.
|
|
75
82
|
|
|
@@ -68,6 +68,9 @@ Capture rules that prevent re-shoots:
|
|
|
68
68
|
- **Capture BEFORE building** — the doctrine step. Review first, code second.
|
|
69
69
|
- Use a **consistent viewport** (1440×900 matches the QA scripts) so your
|
|
70
70
|
reference tiles and build tiles are comparable.
|
|
71
|
+
- For phone-class references pass `record_site_motion` a `viewport: "mobile"`
|
|
72
|
+
(390×844 @3x with isMobile + hasTouch) — and capture BOTH viewports when
|
|
73
|
+
the desktop and mobile designs diverge.
|
|
71
74
|
- **Pre-scroll** to fire lazy content, scroll back to top, then record —
|
|
72
75
|
otherwise reveal-on-scroll sections record as blank boxes.
|
|
73
76
|
- For multi-page sites, record **each page** you'll rebuild (home, projects,
|