@sunkimkk/inspo-mcp 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sunkimk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # Inspo MCP
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pinterest and Dribbble for your AI agent in **one** MCP server. Search, look at thumbnails, browse boards and designers, and download originals (images and MP4s). No login, no API key, no browser.
4
+
5
+ It merges [pinterest-mcp](https://github.com/sunkimk/pinterest-mcp) and dribbble-mcp. Tool names and inputs are unchanged, so prompts and skills written for either keep working.
6
+
7
+ ## Tools
8
+
9
+ | Source | Tools |
10
+ |---|---|
11
+ | Pinterest | `pinterest_search`, `pinterest_get_pin`, `pinterest_related_pins`, `pinterest_board_pins`, `pinterest_preview_images`, `pinterest_download` |
12
+ | Dribbble | `dribbble_search`, `dribbble_explore`, `dribbble_user_shots`, `dribbble_preview_images`, `dribbble_download` |
13
+
14
+ Known issue: since November 2026 Pinterest requires login for related pins, so `pinterest_related_pins` returns "Login required". Search, boards and single pins still work.
15
+
16
+ ## Install
17
+
18
+ Claude Code:
19
+
20
+ ```bash
21
+ claude mcp add inspo -s user -- npx -y @sunkimkk/inspo-mcp
22
+ ```
23
+
24
+ Any other MCP client: run `npx -y @sunkimkk/inspo-mcp` over stdio. Needs Node.js 20.18.1 or later.
25
+
26
+ ## Settings (environment variables, all optional)
27
+
28
+ | Variable | Default |
29
+ |---|---|
30
+ | `MCP_PINTEREST_DOWNLOAD_DIR` | `~/Downloads/pinterest-mcp` |
31
+ | `MCP_DRIBBBLE_DOWNLOAD_DIR` | `~/Downloads/dribbble-mcp` |
32
+ | `MCP_PINTEREST_PROXY`, `MCP_DRIBBBLE_PROXY` (or `HTTPS_PROXY`) | none; set one if the site is blocked on your network |
33
+ | `MCP_PINTEREST_LOCALE` | `en-US` |
34
+
35
+ ## Local extensions
36
+
37
+ You can add your own tools without forking the package: write an ES module that default-exports a function, and list its absolute path either in `MCP_INSPO_EXTENSIONS` (separate several with `:`) or on its own line in `~/.config/inspo-mcp/extensions`. The file works even when the server is started by a plugin you cannot configure.
38
+
39
+ ```js
40
+ // ~/my-tools/hello.js
41
+ export default ({ tool, z, json, READ_ONLY }) =>
42
+ tool('hello', { description: 'Say hi', inputSchema: { name: z.string() }, annotations: READ_ONLY },
43
+ async ({ name }) => ({ content: [json({ hi: name })] }));
44
+ ```
45
+
46
+ The function gets `{ tool, z, json, log, READ_ONLY, MAX_PREVIEWS }`. An extension that fails to load is logged to stderr and skipped. Extensions run with your user's permissions, so only list files you wrote or trust.
47
+
48
+ ## Credit
49
+
50
+ Pins and shots belong to their creators. Use downloads as private references and keep the source link for credit. Not affiliated with Pinterest or Dribbble.
51
+
52
+ ## Develop
53
+
54
+ ```bash
55
+ cd src && npm install && npm test # live tests against pinterest.com and dribbble.com
56
+ ```
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@sunkimkk/inspo-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Pinterest and Dribbble for your AI agent in one MCP server: search, see thumbnails, follow related pins, browse designers and download originals. No login, no API key, no browser.",
5
+ "keywords": [
6
+ "mcp",
7
+ "mcp-server",
8
+ "model-context-protocol",
9
+ "pinterest",
10
+ "dribbble",
11
+ "claude-code",
12
+ "codex",
13
+ "design-inspiration",
14
+ "moodboard",
15
+ "ui-design"
16
+ ],
17
+ "type": "module",
18
+ "license": "MIT",
19
+ "bin": {
20
+ "inspo-mcp": "src/server.js"
21
+ },
22
+ "main": "src/server.js",
23
+ "files": [
24
+ "src"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20.18.1"
28
+ },
29
+ "scripts": {
30
+ "start": "node src/server.js",
31
+ "test": "node --test test/*.test.js",
32
+ "prepack": "cp ../README.md ../LICENSE .",
33
+ "postpack": "rm -f README.md LICENSE"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.32.1",
37
+ "undici": "^7.30.1",
38
+ "zod": "^4.6.7"
39
+ },
40
+ "author": "sunkimk",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
package/src/server.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // Inspo MCP server (stdio): Pinterest and Dribbble in one server, plus optional local
3
+ // extensions. Everything diagnostic goes to stderr: stdout carries JSON-RPC.
4
+ //
5
+ // Extensions are ES modules on this machine that add more tools. They are listed, as
6
+ // absolute paths, in MCP_INSPO_EXTENSIONS (separated by ":" on macOS/Linux) and/or one per
7
+ // line in ~/.config/inspo-mcp/extensions (lines starting with # are comments). The file lets
8
+ // a server started by a plugin pick up local extensions without touching the plugin.
9
+ // Each module default-exports a function that receives the same helpers the built-in sources
10
+ // use ({ tool, z, json, READ_ONLY, MAX_PREVIEWS, log }). A broken extension is logged and
11
+ // skipped; it never stops the server.
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+ import { z } from 'zod';
19
+ import registerDribbble from './tools/dribbble.js';
20
+ import registerPinterest from './tools/pinterest.js';
21
+
22
+ const VERSION = '1.0.0';
23
+ const server = new McpServer({ name: 'inspo', version: VERSION });
24
+
25
+ const log = (msg) => process.stderr.write(`[inspo] ${msg}\n`);
26
+ const json = (value) => ({ type: 'text', text: JSON.stringify(value) });
27
+
28
+ function tool(name, config, handler) {
29
+ server.registerTool(name, config, async (args) => {
30
+ try {
31
+ return await handler(args);
32
+ } catch (err) {
33
+ log(`${name} failed: ${err.stack || err.message}`);
34
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
35
+ }
36
+ });
37
+ }
38
+
39
+ const ctx = { tool, z, json, log, READ_ONLY: { readOnlyHint: true, openWorldHint: true }, MAX_PREVIEWS: 16 };
40
+
41
+ registerPinterest(ctx);
42
+ registerDribbble(ctx);
43
+
44
+ const CONFIG_FILE =
45
+ process.env.MCP_INSPO_EXTENSIONS_FILE ||
46
+ path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'inspo-mcp', 'extensions');
47
+
48
+ function listExtensions() {
49
+ const fromEnv = (process.env.MCP_INSPO_EXTENSIONS || '').split(path.delimiter);
50
+ let fromFile = [];
51
+ try {
52
+ fromFile = fs.readFileSync(CONFIG_FILE, 'utf8').split(/\r?\n/).filter((l) => !l.trim().startsWith('#'));
53
+ } catch (err) {
54
+ if (err.code !== 'ENOENT') log(`could not read ${CONFIG_FILE}: ${err.message}`);
55
+ }
56
+ return [...new Set([...fromEnv, ...fromFile].map((s) => s.trim()).filter(Boolean))];
57
+ }
58
+
59
+ for (const file of listExtensions()) {
60
+ try {
61
+ if (!path.isAbsolute(file)) throw new Error('path must be absolute');
62
+ const mod = await import(pathToFileURL(file).href);
63
+ if (typeof mod.default !== 'function') throw new Error('module has no default-exported function');
64
+ await mod.default(ctx);
65
+ log(`loaded extension ${file}`);
66
+ } catch (err) {
67
+ log(`skipped extension ${file}: ${err.message}`);
68
+ }
69
+ }
70
+
71
+ await server.connect(new StdioServerTransport());
72
+ log(`MCP server ${VERSION} ready on stdio`);
@@ -0,0 +1,358 @@
1
+ // Dribbble client built on the HTML fragments the dribbble.com web app loads while you
2
+ // scroll (the same requests the site's infinite scroll makes). No login, no API key, no
3
+ // browser. Dribbble's official API only exposes your own shots, so it is not used.
4
+ //
5
+ // Limits we know about, and do not try to get around:
6
+ // - Shot pages (dribbble.com/shots/<id>) answer plain HTTP clients with an empty bot check
7
+ // (HTTP 202), so there is no per-shot details tool; list results carry what we need.
8
+ // - Logged-out search only returns the first batch of results (about 36-48 shots); later
9
+ // pages repeat it. Explore feeds and designer profiles paginate normally.
10
+ import { fetch, ProxyAgent } from 'undici';
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs/promises';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ export const BASE = 'https://dribbble.com';
17
+ const UA =
18
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
19
+ const TIMEOUT_MS = Number(process.env.MCP_DRIBBBLE_TIMEOUT_MS) || 20000;
20
+ const PER_PAGE = 24;
21
+ const MAX_PAGES = 8;
22
+
23
+ const proxyUrl =
24
+ process.env.MCP_DRIBBBLE_PROXY ||
25
+ process.env.HTTPS_PROXY ||
26
+ process.env.https_proxy ||
27
+ process.env.HTTP_PROXY ||
28
+ process.env.http_proxy ||
29
+ '';
30
+ const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
31
+
32
+ export const DOWNLOAD_ROOT =
33
+ process.env.MCP_DRIBBBLE_DOWNLOAD_DIR || path.join(os.homedir(), 'Downloads', 'dribbble-mcp');
34
+
35
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
+
37
+ // `timeout` covers the whole response, body included, so large videos get a longer one.
38
+ async function request(url, { timeout = TIMEOUT_MS, ...init } = {}, retries = 2) {
39
+ for (let attempt = 0; ; attempt++) {
40
+ let res;
41
+ try {
42
+ res = await fetch(url, {
43
+ ...init,
44
+ dispatcher,
45
+ signal: AbortSignal.timeout(timeout),
46
+ headers: { 'User-Agent': UA, 'Accept-Language': 'en-US', ...init.headers },
47
+ });
48
+ } catch (err) {
49
+ if (attempt < retries) {
50
+ await sleep(600 * (attempt + 1));
51
+ continue;
52
+ }
53
+ const hint = proxyUrl ? '' : ' (if dribbble.com is blocked on your network, set MCP_DRIBBBLE_PROXY)';
54
+ throw new Error(`Network error reaching ${new URL(url).host}: ${err.cause?.message || err.message}${hint}`);
55
+ }
56
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
57
+ await sleep(1000 * (attempt + 1));
58
+ continue;
59
+ }
60
+ return res;
61
+ }
62
+ }
63
+
64
+ // One page of a shot grid, as the site's infinite scroll requests it.
65
+ async function gridPage(pathname, params) {
66
+ const qs = new URLSearchParams({ ...params, per_page: String(PER_PAGE) });
67
+ const url = `${BASE}${pathname}?${qs}`;
68
+ const res = await request(url, {
69
+ headers: { Accept: 'text/html, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest' },
70
+ });
71
+ const html = await res.text();
72
+ if (res.status === 404) {
73
+ const err = new Error(`Dribbble has no page at ${pathname}`);
74
+ err.status = 404;
75
+ throw err;
76
+ }
77
+ if (res.status === 202 || (res.ok && !html.trim())) {
78
+ throw new Error(
79
+ `Dribbble answered ${pathname} with a bot check (HTTP ${res.status}, empty body). ` +
80
+ 'This server does not try to get past it; wait a while and try again.'
81
+ );
82
+ }
83
+ if (!res.ok) throw new Error(`Dribbble ${pathname}: HTTP ${res.status}`);
84
+ return parseShots(html);
85
+ }
86
+
87
+ // ---------- parsing ----------
88
+
89
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
90
+ export function decode(s) {
91
+ return String(s).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
92
+ if (e[0] === '#') {
93
+ const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
94
+ return Number.isFinite(n) ? String.fromCodePoint(n) : m;
95
+ }
96
+ return ENTITIES[e.toLowerCase()] ?? m;
97
+ });
98
+ }
99
+
100
+ const clean = (s, max = 280) => {
101
+ if (!s || typeof s !== 'string') return undefined;
102
+ const t = decode(s).replace(/\s+/g, ' ').trim();
103
+ if (!t) return undefined;
104
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
105
+ };
106
+
107
+ function strip(obj) {
108
+ for (const k of Object.keys(obj)) if (obj[k] === undefined || obj[k] === null || obj[k] === '') delete obj[k];
109
+ return obj;
110
+ }
111
+
112
+ const attr = (html, name) => html.match(new RegExp(`${name}="([^"]*)"`))?.[1];
113
+ const baseUrl = (u) => (u ? decode(u).split('?')[0] : undefined);
114
+
115
+ // "107k" / "1.2m" / "66" -> number
116
+ export function count(s) {
117
+ const m = String(s ?? '').trim().toLowerCase().replace(/,/g, '').match(/^([\d.]+)\s*([km])?$/);
118
+ if (!m) return undefined;
119
+ const n = Number(m[1]) * (m[2] === 'k' ? 1e3 : m[2] === 'm' ? 1e6 : 1);
120
+ return Number.isFinite(n) ? Math.round(n) : undefined;
121
+ }
122
+
123
+ // The fragment also ships a `newestShots = [...]` array with publish dates and counts.
124
+ function shotMeta(html) {
125
+ const m = html.match(/newestShots\s*=\s*(\[[\s\S]*?\])\s*\n/);
126
+ if (!m) return new Map();
127
+ try {
128
+ return new Map(JSON.parse(m[1]).map((s) => [String(s.id), s]));
129
+ } catch {
130
+ return new Map();
131
+ }
132
+ }
133
+
134
+ const MONTHS = 'January February March April May June July August September October November December'.split(' ');
135
+ function isoDate(s) {
136
+ const m = String(s || '').match(/^(\w+) (\d{1,2}), (\d{4})$/);
137
+ const mi = m ? MONTHS.indexOf(m[1]) : -1;
138
+ if (mi < 0) return undefined;
139
+ return `${m[3]}-${String(mi + 1).padStart(2, '0')}-${m[2].padStart(2, '0')}`;
140
+ }
141
+
142
+ export function parseShots(html) {
143
+ const meta = shotMeta(html);
144
+ const starts = [...html.matchAll(/<li\b[^>]*\bdata-thumbnail-id="(\d+)"/g)];
145
+ const shots = [];
146
+ for (let i = 0; i < starts.length; i++) {
147
+ const id = starts[i][1];
148
+ const chunk = html.slice(starts[i].index, starts[i + 1]?.index ?? html.length);
149
+ const shot = normalizeShot(id, chunk, meta.get(id));
150
+ if (shot) shots.push(shot);
151
+ }
152
+ return shots;
153
+ }
154
+
155
+ function normalizeShot(id, chunk, meta = {}) {
156
+ const img = chunk.match(/<img\b[^>]*\bdata-src="([^"]+)"[^>]*>/) || chunk.match(/<noscript>\s*<img\b[^>]*\bsrc="([^"]+)"/);
157
+ const image = baseUrl(img?.[1]);
158
+ if (!image || !/\/userupload\//.test(image)) return null;
159
+ const titleHtml = chunk.match(/<div class="shot-title">([\s\S]*?)<\/div>/)?.[1];
160
+ const title = clean(titleHtml, 140) || clean(meta.title, 140);
161
+ const alt = clean(attr(chunk, 'alt'), 400);
162
+ // The alt text is the title followed by the shot's tags.
163
+ const tags = alt && title && alt.startsWith(title) ? clean(alt.slice(title.length), 200) : undefined;
164
+ const animated = baseUrl(attr(chunk, 'data-animated-url'));
165
+ const teaser = baseUrl(attr(chunk, 'data-video-teaser-large'));
166
+ const video = [animated, teaser].find((u) => u && /\.mp4$/i.test(u));
167
+ const gif = animated && /\.gif$/i.test(animated) ? animated : undefined;
168
+ const pathname = chunk.match(/href="(\/shots\/\d+[^"]*)"/)?.[1] || meta.path;
169
+ const user = chunk.match(/<a\b[^>]*rel="contact"[^>]*href="\/([^"/?]+)"/)?.[1];
170
+ const designer = clean(chunk.match(/<span class="display-name">([\s\S]*?)<\/span>/)?.[1], 80);
171
+ const likes = count(chunk.match(/data-shot-like-count>\s*([^<]+)</)?.[1]) ?? count(meta.likes_count);
172
+ const views = count(chunk.match(/js-shot-views-count[^>]*>\s*([^<]+)</)?.[1]) ?? count(meta.view_count);
173
+ return strip({
174
+ id,
175
+ shot_url: pathname ? `${BASE}${decode(pathname)}` : `${BASE}/shots/${id}`,
176
+ title,
177
+ tags,
178
+ image_url: image,
179
+ video_url: video,
180
+ gif_url: gif,
181
+ dominant_color: chunk.match(/background-color:\s*(#[0-9a-f]{3,8})/i)?.[1],
182
+ designer,
183
+ designer_url: user ? `${BASE}/${user}` : undefined,
184
+ team_or_pro: /pro-badge/.test(chunk) || undefined,
185
+ likes,
186
+ views,
187
+ attachments: meta.attachments_count || undefined,
188
+ published: isoDate(meta.published_at),
189
+ });
190
+ }
191
+
192
+ // ---------- paging ----------
193
+
194
+ const MEDIA = {
195
+ any: () => true,
196
+ video: (s) => Boolean(s.video_url || s.gif_url),
197
+ image: (s) => !s.video_url && !s.gif_url,
198
+ };
199
+
200
+ // Walks pages until `limit` shots are collected. A page that adds nothing new ends the
201
+ // feed (logged-out search replays its first batch instead of paging).
202
+ async function paged(fetchPage, { limit, page = 1, media = 'any' }) {
203
+ const keep = MEDIA[media] || MEDIA.any;
204
+ const out = [];
205
+ const seen = new Set();
206
+ let p = page;
207
+ let more = true;
208
+ for (let n = 0; n < MAX_PAGES && out.length < limit; n++, p++) {
209
+ const shots = await fetchPage(p);
210
+ let fresh = 0;
211
+ for (const s of shots) {
212
+ if (seen.has(s.id)) continue;
213
+ seen.add(s.id);
214
+ fresh++;
215
+ if (out.length < limit && keep(s)) out.push(s);
216
+ }
217
+ if (!fresh || shots.length < PER_PAGE) {
218
+ more = false;
219
+ break;
220
+ }
221
+ }
222
+ return { shots: out, next_page: more ? p : undefined };
223
+ }
224
+
225
+ // ---------- public API ----------
226
+
227
+ export const SORTS = ['popular', 'recent'];
228
+
229
+ export async function searchShots(query, { limit = 24, page = 1, sort = 'popular', media = 'any' } = {}) {
230
+ return paged((p) => gridPage(`/search/shots/${sort}`, { q: query, page: String(p) }), { limit, page, media });
231
+ }
232
+
233
+ export const CATEGORIES = [
234
+ 'animation',
235
+ 'branding',
236
+ 'illustration',
237
+ 'mobile',
238
+ 'print',
239
+ 'product-design',
240
+ 'typography',
241
+ 'web-design',
242
+ ];
243
+
244
+ export async function exploreShots({ category, sort = 'popular', limit = 24, page = 1, media = 'any' } = {}) {
245
+ const pathname = `/shots/${sort}${category ? `/${category}` : ''}`;
246
+ return paged((p) => gridPage(pathname, { page: String(p) }), { limit, page, media });
247
+ }
248
+
249
+ export function parseUser(input) {
250
+ let s = String(input).trim().replace(/^@/, '');
251
+ if (/^https?:\/\//.test(s) || /dribbble\.com/.test(s)) {
252
+ s = new URL(/^https?:\/\//.test(s) ? s : `https://${s}`).pathname.split('/').filter(Boolean)[0] || '';
253
+ }
254
+ if (!/^[\w-]+$/.test(s) || s === 'shots') throw new Error(`Expected a designer profile like dribbble.com/<name>, got: ${input}`);
255
+ return s;
256
+ }
257
+
258
+ export async function userShots(input, { limit = 24, page = 1, media = 'any' } = {}) {
259
+ const user = parseUser(input);
260
+ const designer_url = `${BASE}/${user}`;
261
+ const res = await paged(
262
+ async (p) => (await gridPage(`/${user}/shots`, { page: String(p) })).map((s) => ({ ...s, designer_url: s.designer_url || designer_url })),
263
+ { limit, page, media }
264
+ );
265
+ return { designer_url, ...res };
266
+ }
267
+
268
+ // ---------- images ----------
269
+
270
+ function assertCdn(u) {
271
+ let url;
272
+ try {
273
+ url = new URL(u);
274
+ } catch {
275
+ throw new Error(`Invalid media URL: ${u}`);
276
+ }
277
+ if (url.protocol !== 'https:' || url.hostname !== 'cdn.dribbble.com') {
278
+ throw new Error(`Only https://cdn.dribbble.com URLs are allowed: ${u}`);
279
+ }
280
+ return url;
281
+ }
282
+
283
+ // The CDN serves some .mp4 uploads as video/quicktime; those keep the URL's extension.
284
+ const EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'video/mp4': 'mp4' };
285
+ const MAX_BYTES = 200 * 1024 * 1024;
286
+ const VIDEO_TIMEOUT_MS = Math.max(TIMEOUT_MS, 180000);
287
+
288
+ async function fetchMedia(u, kind) {
289
+ const timeout = kind === 'video' ? VIDEO_TIMEOUT_MS : TIMEOUT_MS;
290
+ const res = await request(u, { timeout, headers: { Referer: `${BASE}/` } }, 1);
291
+ const type = (res.headers.get('content-type') || '').split(';')[0];
292
+ if (!res.ok || !type.startsWith(`${kind}/`)) {
293
+ await res.body?.cancel();
294
+ throw new Error(`Could not fetch ${u}: HTTP ${res.status}${res.ok ? ` (${type})` : ''}`);
295
+ }
296
+ if (Number(res.headers.get('content-length')) > MAX_BYTES) {
297
+ await res.body?.cancel();
298
+ throw new Error(`${u} is larger than ${MAX_BYTES / 1024 / 1024} MB`);
299
+ }
300
+ return { type, buf: Buffer.from(await res.arrayBuffer()) };
301
+ }
302
+
303
+ // The CDN resizes and converts on the fly; a JPEG thumbnail keeps token cost low.
304
+ export async function fetchPreview(u, width = 400) {
305
+ const url = assertCdn(u);
306
+ if (/\.(mp4|webm)$/i.test(url.pathname)) throw new Error('Videos cannot be previewed; pass the shot\'s image_url instead');
307
+ const h = Math.round((width * 3) / 4);
308
+ const { type, buf } = await fetchMedia(
309
+ `${url.origin}${url.pathname}?format=jpg&resize=${width}x${h}&vertical=center`,
310
+ 'image'
311
+ );
312
+ return { mimeType: type, data: buf.toString('base64') };
313
+ }
314
+
315
+ async function mapLimit(items, n, fn) {
316
+ const out = new Array(items.length);
317
+ let i = 0;
318
+ await Promise.all(
319
+ Array.from({ length: Math.min(n, items.length) }, async () => {
320
+ while (i < items.length) {
321
+ const idx = i++;
322
+ out[idx] = await fn(items[idx], idx);
323
+ }
324
+ })
325
+ );
326
+ return out;
327
+ }
328
+
329
+ const safeName = (s) =>
330
+ String(s || '')
331
+ .normalize('NFKC')
332
+ .replace(/[\\/:*?"<>|\x00-\x1f]+/g, ' ')
333
+ .replace(/\s+/g, '_')
334
+ .replace(/^[._]+|[._]+$/g, '')
335
+ .slice(0, 60);
336
+
337
+ export async function downloadMedia(urls, { folder, directory } = {}) {
338
+ const root = path.resolve(directory || DOWNLOAD_ROOT);
339
+ const dir = folder ? path.join(root, safeName(folder) || 'shots') : root;
340
+ await fs.mkdir(dir, { recursive: true });
341
+ const results = await mapLimit(urls, 4, async (u, idx) => {
342
+ try {
343
+ const url = assertCdn(u);
344
+ const clean = `${url.origin}${url.pathname}`;
345
+ const kind = /\.(mp4|webm)$/i.test(url.pathname) ? 'video' : 'image';
346
+ const media = await fetchMedia(clean, kind);
347
+ const stem = path.basename(url.pathname).replace(/\.\w+$/, '') ||
348
+ crypto.createHash('sha1').update(clean).digest('hex').slice(0, 16);
349
+ const ext = EXT[media.type] || path.extname(url.pathname).slice(1).toLowerCase() || 'bin';
350
+ const file = path.join(dir, `${String(idx + 1).padStart(2, '0')}_${stem}.${ext}`);
351
+ await fs.writeFile(file, media.buf);
352
+ return { ok: true, url: clean, path: file, bytes: media.buf.length };
353
+ } catch (err) {
354
+ return { ok: false, url: u, error: err.message };
355
+ }
356
+ });
357
+ return { directory: dir, saved: results.filter((r) => r.ok), failed: results.filter((r) => !r.ok) };
358
+ }