fendi-mcp 1.0.1 → 1.0.2
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 +22 -0
- package/dist/api.d.ts +48 -0
- package/dist/api.js +534 -8
- package/dist/license.d.ts +8 -0
- package/dist/license.js +79 -0
- package/dist/server.js +119 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -11,7 +11,29 @@ Fendi resale listings aggregated from Fashionphile and Rebag public catalogs (pr
|
|
|
11
11
|
- `compare_prices` — Group same-model listings across sources with min/max/spread.
|
|
12
12
|
- `find_deals` — Available listings priced below the brand median.
|
|
13
13
|
- `brand_overview` — Catalog sample counts by source, type and condition.
|
|
14
|
+
- `list_collections` — Curated store collections with handles for browse_collection, e.g. sale or category collections.
|
|
15
|
+
- `browse_collection` — Listings inside one collection handle from list_collections, e.g. a sale or category collection.
|
|
16
|
+
- `find_by_attribute` — Listings filtered by color, exterior material or condition, plus the common values in the sample.
|
|
14
17
|
|
|
15
18
|
## Source
|
|
16
19
|
|
|
17
20
|
[fendi-mcp](https://github.com/mrfentmen/awesome-mcps/tree/main/servers/fendi-mcp)
|
|
21
|
+
|
|
22
|
+
<!-- paywall -->
|
|
23
|
+
## Premium tools
|
|
24
|
+
|
|
25
|
+
These tools need a license key:
|
|
26
|
+
|
|
27
|
+
* `cross_market_match`
|
|
28
|
+
* `price_ladder`
|
|
29
|
+
|
|
30
|
+
Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-fendi-mcp and set it in your MCP client config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
"env": { "MCP_LICENSE_KEY": "mcp_live_..." }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The key is checked against MCP Marketplace, cached for 24 hours, and keeps
|
|
37
|
+
working offline once one check has succeeded. Every other tool on this server
|
|
38
|
+
stays free.
|
|
39
|
+
<!-- /paywall -->
|
package/dist/api.d.ts
CHANGED
|
@@ -9,3 +9,51 @@ export declare function priceOverview(limit?: string): Promise<string>;
|
|
|
9
9
|
export declare function comparePrices(query: string, limit?: string): Promise<string>;
|
|
10
10
|
export declare function findDeals(limit?: string, sample?: string): Promise<string>;
|
|
11
11
|
export declare function brandOverview(): Promise<string>;
|
|
12
|
+
export declare function listCollections(query?: string): Promise<string>;
|
|
13
|
+
export declare function browseCollection(handle: string, source?: string, limit?: string, maxPrice?: string, condition?: string): Promise<string>;
|
|
14
|
+
export declare function find_by_attribute(color?: string, material?: string, condition?: string, maxPrice?: string, limit?: string, sample?: string): Promise<string>;
|
|
15
|
+
export interface MatchedListing {
|
|
16
|
+
source: string;
|
|
17
|
+
title: string;
|
|
18
|
+
handle: string;
|
|
19
|
+
price: number | null;
|
|
20
|
+
available: boolean;
|
|
21
|
+
condition: string | null;
|
|
22
|
+
url: string;
|
|
23
|
+
size: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface CrossMarketMatchArgs {
|
|
26
|
+
model?: string;
|
|
27
|
+
query?: string;
|
|
28
|
+
pages?: number;
|
|
29
|
+
limit?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The same model priced on both sites. The join is on the model name inside the
|
|
33
|
+
* title, which is a real limitation: it cannot see that a "Baguette" and a
|
|
34
|
+
* "Baguette II" are different bags, and it will happily treat two colourways of
|
|
35
|
+
* one bag as one comparison.
|
|
36
|
+
*/
|
|
37
|
+
export declare function crossMarketMatch(args: CrossMarketMatchArgs): Promise<string>;
|
|
38
|
+
export interface PriceLadderArgs {
|
|
39
|
+
model?: string;
|
|
40
|
+
query?: string;
|
|
41
|
+
pages?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The real floor: the cheapest listing that can actually be bought, as opposed to
|
|
45
|
+
* the cheapest listing, which on these sites is often sold out and is therefore
|
|
46
|
+
* not a price at all.
|
|
47
|
+
*/
|
|
48
|
+
export declare function priceLadder(args: PriceLadderArgs): Promise<string>;
|
|
49
|
+
export interface SourceTaxonomyArgs {
|
|
50
|
+
source?: string;
|
|
51
|
+
top?: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The two sites' product types side by side. They are not reconcilable: the
|
|
55
|
+
* catalogs use disjoint vocabularies, so a category filter on one site means
|
|
56
|
+
* nothing on the other and a cross-site count by category is not a like-for-like
|
|
57
|
+
* comparison.
|
|
58
|
+
*/
|
|
59
|
+
export declare function sourceTaxonomy(args: SourceTaxonomyArgs): Promise<string>;
|
package/dist/api.js
CHANGED
|
@@ -95,10 +95,18 @@ function pretty(data) {
|
|
|
95
95
|
const t = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
96
96
|
return t.length > 12000 ? t.slice(0, 12000) + "\n…(truncated)" : t;
|
|
97
97
|
}
|
|
98
|
+
// A catalog row is only usable if it carries the handle the product URL is built
|
|
99
|
+
// from and a title to show. Anything else is a malformed upstream row.
|
|
100
|
+
function isProduct(x) {
|
|
101
|
+
if (typeof x !== "object" || x === null)
|
|
102
|
+
return false;
|
|
103
|
+
const row = x;
|
|
104
|
+
return typeof row.handle === "string" && typeof row.title === "string";
|
|
105
|
+
}
|
|
98
106
|
async function getJson(url) {
|
|
99
107
|
let res;
|
|
100
108
|
try {
|
|
101
|
-
res = await fetch(url, { headers: { ...UA } });
|
|
109
|
+
res = await fetch(url, { signal: AbortSignal.timeout(30000), headers: { ...UA } });
|
|
102
110
|
}
|
|
103
111
|
catch (e) {
|
|
104
112
|
throw new FendiError(`Network error: ${e.message}`);
|
|
@@ -110,7 +118,7 @@ async function getJson(url) {
|
|
|
110
118
|
async function catalog(source, page) {
|
|
111
119
|
const data = await getJson(`${source.catalog}?limit=250&page=${page}`);
|
|
112
120
|
const products = Array.isArray(data.products) ? data.products : [];
|
|
113
|
-
return products.filter((p) => matchesBrand(p.vendor));
|
|
121
|
+
return products.filter(isProduct).filter((p) => matchesBrand(p.vendor));
|
|
114
122
|
}
|
|
115
123
|
// Distilled-pages fetch: several catalog pages in parallel, brand-filtered.
|
|
116
124
|
async function catalogPages(source, pages) {
|
|
@@ -178,7 +186,7 @@ export async function searchListings(query, limit, maxPrice, condition) {
|
|
|
178
186
|
if (out.length >= n)
|
|
179
187
|
break;
|
|
180
188
|
}
|
|
181
|
-
return pretty({ brand: "Fendi", query,
|
|
189
|
+
return pretty({ brand: "Fendi", query, returned: out.length, truncated: out.length >= n, condition_filter: cond ?? null, listings: out.slice(0, n) });
|
|
182
190
|
}
|
|
183
191
|
export async function listNewest(limit, page, productType, maxPrice, condition) {
|
|
184
192
|
const n = Math.min(Number(limit ?? 20) || 20, 50);
|
|
@@ -199,7 +207,7 @@ export async function listNewest(limit, page, productType, maxPrice, condition)
|
|
|
199
207
|
catch { /* source down — keep the other */ }
|
|
200
208
|
}
|
|
201
209
|
out.sort((a, b) => String(b.published_at ?? "").localeCompare(String(a.published_at ?? "")));
|
|
202
|
-
return pretty({ brand: "Fendi", page: pg,
|
|
210
|
+
return pretty({ brand: "Fendi", page: pg, matched_on_page: out.length, returned: Math.min(out.length, n), listings: out.slice(0, n) });
|
|
203
211
|
}
|
|
204
212
|
export async function getListing(handle, source) {
|
|
205
213
|
const sources = source ? SOURCES.filter((s) => s.name === source) : SOURCES;
|
|
@@ -230,11 +238,14 @@ export async function getListing(handle, source) {
|
|
|
230
238
|
export async function priceOverview(limit) {
|
|
231
239
|
const n = Math.min(Number(limit ?? 200) || 200, 500);
|
|
232
240
|
const prices = [];
|
|
241
|
+
let unpriced = 0;
|
|
233
242
|
const byCondition = {};
|
|
234
243
|
for (const { source: s, p } of await sampleCatalog(n, [1, 2, 3, 4, 5])) {
|
|
235
244
|
const sl = slim(s.name, s.base, p);
|
|
236
|
-
if (sl.price_usd === null)
|
|
245
|
+
if (sl.price_usd === null || sl.price_usd <= 0) {
|
|
246
|
+
unpriced += 1;
|
|
237
247
|
continue;
|
|
248
|
+
}
|
|
238
249
|
prices.push(sl.price_usd);
|
|
239
250
|
const c = sl.condition ?? "unknown";
|
|
240
251
|
if (!byCondition[c])
|
|
@@ -252,7 +263,7 @@ export async function priceOverview(limit) {
|
|
|
252
263
|
for (const [c, arr] of Object.entries(byCondition))
|
|
253
264
|
condStats[c] = stats(arr);
|
|
254
265
|
return pretty({
|
|
255
|
-
brand: "Fendi", currency: "USD", overall: stats(prices), by_condition: condStats,
|
|
266
|
+
brand: "Fendi", currency: "USD", unpriced, overall: stats(prices), by_condition: condStats,
|
|
256
267
|
note: "Asking prices on resale sources, not appraisals. Not affiliated with Fendi.",
|
|
257
268
|
});
|
|
258
269
|
}
|
|
@@ -299,7 +310,7 @@ export async function findDeals(limit, sample) {
|
|
|
299
310
|
const all = [];
|
|
300
311
|
for (const { source: s, p } of await sampleCatalog(per, [1, 2, 3, 4, 5])) {
|
|
301
312
|
const sl = slim(s.name, s.base, p);
|
|
302
|
-
if (sl.price_usd !== null && sl.available)
|
|
313
|
+
if (sl.price_usd !== null && sl.price_usd > 0 && sl.available)
|
|
303
314
|
all.push(sl);
|
|
304
315
|
}
|
|
305
316
|
if (!all.length)
|
|
@@ -311,7 +322,7 @@ export async function findDeals(limit, sample) {
|
|
|
311
322
|
.map((s) => ({ ...s, vs_median_usd: Math.round((s.price_usd - median) * 100) / 100, vs_median_pct: Math.round(((s.price_usd - median) / median) * 1000) / 10 }))
|
|
312
323
|
.sort((a, b) => a.vs_median_pct - b.vs_median_pct);
|
|
313
324
|
return pretty({
|
|
314
|
-
brand: "Fendi", sample: all.length, brand_median_usd: median,
|
|
325
|
+
brand: "Fendi", sample: all.length, brand_median_usd: median, matched: deals.length, returned: Math.min(deals.length, n),
|
|
315
326
|
deals: deals.slice(0, n),
|
|
316
327
|
note: "Below-median asks, not appraisals or authenticity guarantees. Not affiliated with Fendi.",
|
|
317
328
|
});
|
|
@@ -343,3 +354,518 @@ export async function brandOverview() {
|
|
|
343
354
|
note: "Sample of newest ~500 listings per source. Not affiliated with Fendi.",
|
|
344
355
|
});
|
|
345
356
|
}
|
|
357
|
+
// --- catalog tools: collections + attribute/size filters ---
|
|
358
|
+
function collectionsOrigin(catalogUrl) {
|
|
359
|
+
return new URL(catalogUrl).origin;
|
|
360
|
+
}
|
|
361
|
+
// Curated store collections. store_products is the collection's full size on the
|
|
362
|
+
// store, not just this brand's slice - browse_collection returns the brand rows.
|
|
363
|
+
export async function listCollections(query) {
|
|
364
|
+
const wanted = query ? query.trim().toLowerCase() : null;
|
|
365
|
+
const rows = [];
|
|
366
|
+
for (const s of SOURCES) {
|
|
367
|
+
try {
|
|
368
|
+
const data = await getJson(`${collectionsOrigin(s.catalog)}/collections.json?limit=250`);
|
|
369
|
+
const list = Array.isArray(data.collections) ? data.collections : [];
|
|
370
|
+
for (const row of list) {
|
|
371
|
+
const c = row;
|
|
372
|
+
if (typeof c.title !== "string" || typeof c.handle !== "string")
|
|
373
|
+
continue;
|
|
374
|
+
if (wanted && !c.title.toLowerCase().includes(wanted))
|
|
375
|
+
continue;
|
|
376
|
+
rows.push({
|
|
377
|
+
source: s.name,
|
|
378
|
+
title: c.title,
|
|
379
|
+
handle: c.handle,
|
|
380
|
+
store_products: typeof c.products_count === "number" ? c.products_count : null,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch { /* source down - keep the other */ }
|
|
385
|
+
}
|
|
386
|
+
return pretty({
|
|
387
|
+
brand: "Fendi", query: query ?? null, returned: rows.length,
|
|
388
|
+
note: "Collection handles feed browse_collection. store_products counts the whole collection.",
|
|
389
|
+
collections: rows.slice(0, 60),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
export async function browseCollection(handle, source, limit, maxPrice, condition) {
|
|
393
|
+
const n = Math.min(Number(limit ?? 10) || 10, 25);
|
|
394
|
+
const mp = maxPrice !== undefined ? Number(maxPrice) : undefined;
|
|
395
|
+
const cond = condition !== undefined ? normCondition(condition) : undefined;
|
|
396
|
+
if (condition !== undefined && cond === null)
|
|
397
|
+
throw new FendiError(`Unknown condition '${condition}'. Use: ${CONDITIONS.join(", ")}.`);
|
|
398
|
+
const sources = source ? SOURCES.filter((s) => s.name === source) : SOURCES;
|
|
399
|
+
if (!sources.length)
|
|
400
|
+
throw new FendiError(`Unknown source '${source}'. Use ${SOURCES.map((s) => s.name).join(" or ")}.`);
|
|
401
|
+
const out = [];
|
|
402
|
+
let brandItems = 0;
|
|
403
|
+
for (const s of sources) {
|
|
404
|
+
try {
|
|
405
|
+
for (const page of [1, 2, 3]) {
|
|
406
|
+
const data = await getJson(`${collectionsOrigin(s.catalog)}/collections/${encodeURIComponent(handle)}/products.json?limit=250&page=${page}`);
|
|
407
|
+
const products = Array.isArray(data.products) ? data.products : [];
|
|
408
|
+
if (!products.length)
|
|
409
|
+
break;
|
|
410
|
+
for (const raw of products) {
|
|
411
|
+
if (!isProduct(raw) || !matchesBrand(raw.vendor))
|
|
412
|
+
continue;
|
|
413
|
+
brandItems += 1;
|
|
414
|
+
const sl = slim(s.name, s.base, raw);
|
|
415
|
+
if (passes(sl, mp, cond ?? undefined))
|
|
416
|
+
out.push(sl);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
catch { /* collection or source missing - keep the other */ }
|
|
421
|
+
}
|
|
422
|
+
if (!brandItems)
|
|
423
|
+
throw new FendiError(`Collection '${handle}' has no Fendi listings on ${sources.map((s) => s.name).join(", ")}.`);
|
|
424
|
+
out.sort((a, b) => String(b.published_at ?? "").localeCompare(String(a.published_at ?? "")));
|
|
425
|
+
return pretty({
|
|
426
|
+
brand: "Fendi", collection: handle, brand_items_matched: brandItems,
|
|
427
|
+
returned: Math.min(out.length, n), truncated: out.length > n, listings: out.slice(0, n),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
export async function find_by_attribute(color, material, condition, maxPrice, limit, sample) {
|
|
431
|
+
const n = Math.min(Number(limit ?? 10) || 10, 25);
|
|
432
|
+
const per = Math.min(Number(sample ?? 200) || 200, 500);
|
|
433
|
+
const mp = maxPrice !== undefined ? Number(maxPrice) : undefined;
|
|
434
|
+
const cond = condition !== undefined ? normCondition(condition) : undefined;
|
|
435
|
+
if (condition !== undefined && cond === null)
|
|
436
|
+
throw new FendiError(`Unknown condition '${condition}'. Use: ${CONDITIONS.join(", ")}.`);
|
|
437
|
+
const wantColor = color ? color.trim().toLowerCase() : null;
|
|
438
|
+
const wantMaterial = material ? material.trim().toLowerCase() : null;
|
|
439
|
+
if (!wantColor && !wantMaterial && !cond)
|
|
440
|
+
throw new FendiError("Give at least one of color, material or condition.");
|
|
441
|
+
const all = [];
|
|
442
|
+
const colors = {};
|
|
443
|
+
const materials = {};
|
|
444
|
+
let tagged = 0;
|
|
445
|
+
for (const { source: s, p } of await sampleCatalog(per, [1, 2, 3, 4])) {
|
|
446
|
+
const sl = slim(s.name, s.base, p);
|
|
447
|
+
if (sl.color) {
|
|
448
|
+
tagged += 1;
|
|
449
|
+
colors[sl.color] = (colors[sl.color] ?? 0) + 1;
|
|
450
|
+
}
|
|
451
|
+
if (sl.material)
|
|
452
|
+
materials[sl.material] = (materials[sl.material] ?? 0) + 1;
|
|
453
|
+
if (wantColor && !(sl.color ?? "").toLowerCase().includes(wantColor))
|
|
454
|
+
continue;
|
|
455
|
+
if (wantMaterial && !(sl.material ?? "").toLowerCase().includes(wantMaterial))
|
|
456
|
+
continue;
|
|
457
|
+
// Structured tags exist on one source only, so a colour/material also matches the
|
|
458
|
+
// listing title. Which side matched is reported per row (matched_on).
|
|
459
|
+
const colorOnTag = wantColor ? (sl.color ?? "").toLowerCase().includes(wantColor) : false;
|
|
460
|
+
const materialOnTag = wantMaterial ? (sl.material ?? "").toLowerCase().includes(wantMaterial) : false;
|
|
461
|
+
const hay = `${sl.title} ${sl.vendor} ${sl.product_type}`.toLowerCase();
|
|
462
|
+
if (wantColor && !colorOnTag && !hay.includes(wantColor))
|
|
463
|
+
continue;
|
|
464
|
+
if (wantMaterial && !materialOnTag && !hay.includes(wantMaterial))
|
|
465
|
+
continue;
|
|
466
|
+
if (!passes(sl, mp, cond ?? undefined))
|
|
467
|
+
continue;
|
|
468
|
+
all.push({ ...sl, matched_on: colorOnTag || materialOnTag ? "structured attribute" : "title keyword" });
|
|
469
|
+
}
|
|
470
|
+
const top = (m) => Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([name, count]) => ({ name, count }));
|
|
471
|
+
return pretty({
|
|
472
|
+
brand: "Fendi", color: wantColor, material: wantMaterial, condition: cond ?? null,
|
|
473
|
+
sample: per, attributes_known: tagged, top_colors: top(colors), top_materials: top(materials),
|
|
474
|
+
note: "Structured colour/material tags where the source publishes them, listing-title match otherwise.",
|
|
475
|
+
matched: all.length, returned: Math.min(all.length, n), listings: all.slice(0, n).sort((a, b) => (a.price_usd ?? Infinity) - (b.price_usd ?? Infinity)),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
// ---- muxD tools:
|
|
479
|
+
/**
|
|
480
|
+
* A cross-market matcher for Fashionphile and Rebag. The same physical bag has a
|
|
481
|
+
* different handle on each site, so the existing tools cannot join the two
|
|
482
|
+
* catalogs at all: search returns one site's listing and there is no way to ask
|
|
483
|
+
* what the other site charges for the same thing. The join key is the model name
|
|
484
|
+
* inside the title.
|
|
485
|
+
*
|
|
486
|
+
* Verified on 250 products from each source on 2026-09-27, which ruled out three
|
|
487
|
+
* plausible tools before they were written:
|
|
488
|
+
* - no size run: 0 of 250 products on either site have more than one variant,
|
|
489
|
+
* so a per-size matrix would have had nothing to show
|
|
490
|
+
* - no markdowns: 0 variants on either site have compare_at_price above price
|
|
491
|
+
* - no facets: the tags are campaign metadata, not a taxonomy. Fashionphile's
|
|
492
|
+
* are things like "amazon-market-template" and "tiktok-market-template-clutches",
|
|
493
|
+
* and Rebag's are "bc-filter-General View", "reflaunt_dropship" and
|
|
494
|
+
* "dynamic-feed-24". Nothing to build a facet tree from.
|
|
495
|
+
* The one thing both catalogs do agree on is the model name in the title, and it
|
|
496
|
+
* appears with the same frequency on both sides, so that is the join key.
|
|
497
|
+
*/
|
|
498
|
+
/** Fendi model names that appear in resale listing titles. */
|
|
499
|
+
const FENDI_MODELS = [
|
|
500
|
+
"Baguette", "Marmont", "Birkin", "Kelly", "Peekaboo", "Pochette", "Speedy",
|
|
501
|
+
"Monogram", "Alma", "Herbag", "Zucca", "Cameo", "Roma", "Spyder", "Palio",
|
|
502
|
+
"Bok", "Sun", "Fur", "Nautical", "Shearling", "Tornabuoni", "Fiore",
|
|
503
|
+
"Visetos", "Metropolis", "FENDI", "Nappa", "Watt", "Newspaper", "FENDI Way",
|
|
504
|
+
];
|
|
505
|
+
/** Words that describe an item but do not identify which model it is. */
|
|
506
|
+
const TITLE_NOISE = [
|
|
507
|
+
"excellent", "great", "very good", "good", "fair", "condition", "new", "preowned",
|
|
508
|
+
"black", "brown", "white", "red", "navy", "beige", "tan", "grey", "gray", "blue",
|
|
509
|
+
"green", "pink", "gold", "silver", "monogram", "visetos", "authentic", "fendi",
|
|
510
|
+
"leather", "calfskin", "kidskin", "lambskin", "canvas", "coated", "canvas",
|
|
511
|
+
"nylon", "suede", "denim", "quilted", "matelasse", "shearling", "fur", "python",
|
|
512
|
+
"lizard", "ostrich", "crocodile", "alligator", "goatskin", "deer", "horse",
|
|
513
|
+
"mini", "small", "medium", "large", "xl", "petite", "travel", "shopper", "tote",
|
|
514
|
+
"shoulder", "crossbody", "clutch", "wristlet", "backpack", "briefcase", "pouch",
|
|
515
|
+
"bag", "handbag", "purse", "wallet", "card", "holder", "case", "chain", "strap",
|
|
516
|
+
"flap", "zip", "zipper", "pouch", "monogram", "double", "single", "triple",
|
|
517
|
+
"womens", "mens", "women", "men", "the", "and", "with", "for",
|
|
518
|
+
];
|
|
519
|
+
/** Size qualifiers, kept because a Mini Baguette is not a Large Baguette. */
|
|
520
|
+
const SIZE_WORDS = [
|
|
521
|
+
"mini", "nano", "micro", "small", "medium", "large", "xl", "petite",
|
|
522
|
+
"25", "28", "30", "35", "40", "50",
|
|
523
|
+
];
|
|
524
|
+
/** The model found in a title, with its size qualifier, or null. */
|
|
525
|
+
function modelKey(title) {
|
|
526
|
+
const raw = String(title ?? "");
|
|
527
|
+
if (!raw.trim())
|
|
528
|
+
return null;
|
|
529
|
+
const lower = raw.toLowerCase();
|
|
530
|
+
// Longest first, so "Fendi Way" is not read as "Fendi" and "Newspaper" is not
|
|
531
|
+
// read as "Paper".
|
|
532
|
+
const hit = [...FENDI_MODELS]
|
|
533
|
+
.sort((a, b) => b.length - a.length)
|
|
534
|
+
.find((m) => new RegExp(`\\b${m.toLowerCase()}\\b`).test(lower));
|
|
535
|
+
if (!hit)
|
|
536
|
+
return null;
|
|
537
|
+
const size = SIZE_WORDS.find((s) => new RegExp(`\\b${s}\\b`).test(lower)) ?? null;
|
|
538
|
+
return { model: hit, size };
|
|
539
|
+
}
|
|
540
|
+
/** The token bag of a title with the noise words removed, for a looser match. */
|
|
541
|
+
function fingerprint(title) {
|
|
542
|
+
const words = String(title ?? "")
|
|
543
|
+
.toLowerCase()
|
|
544
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
545
|
+
.split(/\s+/)
|
|
546
|
+
.filter((w) => w.length > 2 && !TITLE_NOISE.includes(w));
|
|
547
|
+
return [...new Set(words)].sort().join(" ");
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Fendi is about 1.5% of both catalogs, measured over 750 products per site:
|
|
551
|
+
* 11 Fendi listings against 739 from other houses. Reaching a usable sample
|
|
552
|
+
* therefore means reading many pages, and the pages are fetched in parallel
|
|
553
|
+
* because Fashionphile rate-limits bursts with a 429 "Verifying your connection".
|
|
554
|
+
* The brand filter is applied inside catalog(), so a Fendi-only set comes back
|
|
555
|
+
* and the yield is reported rather than hidden.
|
|
556
|
+
*/
|
|
557
|
+
async function collect(source, pages) {
|
|
558
|
+
// catalog() applies the brand filter internally, so the size of a raw page is
|
|
559
|
+
// not observable from here and a per-run "x of y" ratio cannot be computed.
|
|
560
|
+
// The measured share is roughly 1.5% and is stated as a constant instead.
|
|
561
|
+
const results = await Promise.all(pages.map((pg) => catalog(source, pg).then((ps) => ({ ok: true, ps })).catch(() => ({ ok: false, ps: [] }))));
|
|
562
|
+
const fendi = [];
|
|
563
|
+
let failed = 0;
|
|
564
|
+
for (const r of results) {
|
|
565
|
+
if (!r.ok) {
|
|
566
|
+
failed += 1;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
fendi.push(...r.ps);
|
|
570
|
+
}
|
|
571
|
+
return { fendi, failed };
|
|
572
|
+
}
|
|
573
|
+
function toMatched(source, p) {
|
|
574
|
+
const s = slim(source.name, source.base, p);
|
|
575
|
+
const key = modelKey(String(p.title ?? ""));
|
|
576
|
+
return {
|
|
577
|
+
source: source.name,
|
|
578
|
+
title: String(p.title ?? ""),
|
|
579
|
+
handle: String(p.handle ?? ""),
|
|
580
|
+
price: s.price_usd,
|
|
581
|
+
available: s.available,
|
|
582
|
+
condition: s.condition,
|
|
583
|
+
url: s.url,
|
|
584
|
+
size: key?.size ?? null,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const median = (xs) => {
|
|
588
|
+
if (!xs.length)
|
|
589
|
+
return null;
|
|
590
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
591
|
+
return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2;
|
|
592
|
+
};
|
|
593
|
+
const usd = (n) => (n === null ? "no price" : `$${n.toFixed(0)}`);
|
|
594
|
+
/**
|
|
595
|
+
* The same model priced on both sites. The join is on the model name inside the
|
|
596
|
+
* title, which is a real limitation: it cannot see that a "Baguette" and a
|
|
597
|
+
* "Baguette II" are different bags, and it will happily treat two colourways of
|
|
598
|
+
* one bag as one comparison.
|
|
599
|
+
*/
|
|
600
|
+
export async function crossMarketMatch(args) {
|
|
601
|
+
// Fendi is about 1.5% of each catalog, so the page count has to be generous
|
|
602
|
+
// before a specific model shows up. Pages are fetched in parallel.
|
|
603
|
+
const pages = Math.min(Math.max(Math.floor(args.pages ?? 6) || 6, 1), 16);
|
|
604
|
+
const limit = Math.min(Math.max(Math.floor(args.limit ?? 6) || 6, 1), 25);
|
|
605
|
+
const wantModel = String(args.model ?? "").trim().toLowerCase();
|
|
606
|
+
const wantQuery = String(args.query ?? "").trim().toLowerCase();
|
|
607
|
+
if (!wantModel && !wantQuery) {
|
|
608
|
+
throw new FendiError("Provide a model name such as Baguette, or a query such as 'quilted flap'.");
|
|
609
|
+
}
|
|
610
|
+
const perSource = [];
|
|
611
|
+
for (const s of SOURCES) {
|
|
612
|
+
const c = await collect(s, Array.from({ length: pages }, (_, i) => i + 1));
|
|
613
|
+
perSource.push({ source: s, items: c.fendi.map((p) => toMatched(s, p)), failed: c.failed });
|
|
614
|
+
}
|
|
615
|
+
const keep = (m) => {
|
|
616
|
+
if (wantModel) {
|
|
617
|
+
const k = modelKey(m.title);
|
|
618
|
+
if (!k)
|
|
619
|
+
return false;
|
|
620
|
+
if (k.model.toLowerCase() !== wantModel)
|
|
621
|
+
return false;
|
|
622
|
+
if (wantQuery)
|
|
623
|
+
return fingerprint(m.title).includes(fingerprint(wantQuery).split(" ")[0] ?? "");
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
if (!fingerprint(m.title).includes(wantQuery.split(" ")[0] ?? ""))
|
|
627
|
+
return false;
|
|
628
|
+
const k = modelKey(m.title);
|
|
629
|
+
return k !== null;
|
|
630
|
+
};
|
|
631
|
+
const buckets = new Map();
|
|
632
|
+
for (const { source, items } of perSource) {
|
|
633
|
+
for (const m of items) {
|
|
634
|
+
if (!keep(m))
|
|
635
|
+
continue;
|
|
636
|
+
const k = modelKey(m.title);
|
|
637
|
+
if (!k)
|
|
638
|
+
continue;
|
|
639
|
+
// The size is part of the key: a Mini Baguette and a Large Baguette are
|
|
640
|
+
// different products at different prices and must not be compared.
|
|
641
|
+
const key = k.size ? `${k.model} ${k.size}` : k.model;
|
|
642
|
+
if (!buckets.has(key))
|
|
643
|
+
buckets.set(key, { fashionphile: [], rebag: [] });
|
|
644
|
+
buckets.get(key)[source.name].push(m);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const both = [...buckets.entries()].filter(([, v]) => v.fashionphile.length && v.rebag.length);
|
|
648
|
+
const oneSided = [...buckets.entries()].filter(([, v]) => !v.fashionphile.length || !v.rebag.length);
|
|
649
|
+
if (!buckets.size) {
|
|
650
|
+
return [
|
|
651
|
+
`No Fendi listing on either site matched ${wantModel ? `model "${args.model}"` : `"${args.query}"`} in the ${pages} catalog page(s) read per site.`,
|
|
652
|
+
"",
|
|
653
|
+
`Fendi listings found in the ${pages} page(s) read per site: ${perSource.map((p) => `${p.source.name} ${p.items.length}`).join("; ")}.`,
|
|
654
|
+
"",
|
|
655
|
+
"Fendi is roughly one and a half per cent of both catalogs, so a search that finds nothing usually means the pages read did not happen to contain that model rather than that the site has none. Both catalogs are ordered by the retailer rather than by model, so a rare model can sit many pages in. Raising pages is the only way to widen the sample, and the cost is one request per page per site.",
|
|
656
|
+
"",
|
|
657
|
+
"One specific trap: these are multi-brand catalogs, and the word Monogram appears on hundreds of non-Fendi listings because it is overwhelmingly a Louis Vuitton name. The Fendi brand filter runs first so those are excluded here, but it means a Fendi Monogram listing is rare enough that searching for it often returns nothing.",
|
|
658
|
+
].join("\n");
|
|
659
|
+
}
|
|
660
|
+
const lines = [
|
|
661
|
+
`Cross-market Fendi: ${buckets.size} model(s) matched, ${both.length} on both sites`,
|
|
662
|
+
`Sources: ${perSource.map((p) => `${p.source.name} ${p.items.length} Fendi listing(s)${p.failed ? `, ${p.failed} page(s) failed to load` : ""}`).join(" | ")} (Fendi is about 1.5% of each catalog, so ${pages} page(s) yields a small sample)`,
|
|
663
|
+
"",
|
|
664
|
+
];
|
|
665
|
+
both.sort((a, b) => {
|
|
666
|
+
const av = Math.min(...[...a[1].fashionphile, ...a[1].rebag].map((x) => x.price ?? Infinity));
|
|
667
|
+
const bv = Math.min(...[...b[1].fashionphile, ...b[1].rebag].map((x) => x.price ?? Infinity));
|
|
668
|
+
return av - bv;
|
|
669
|
+
});
|
|
670
|
+
for (const [key, v] of both.slice(0, limit)) {
|
|
671
|
+
const fpPrices = v.fashionphile.map((x) => x.price).filter((p) => p !== null);
|
|
672
|
+
const rbPrices = v.rebag.map((x) => x.price).filter((p) => p !== null);
|
|
673
|
+
const fpAvail = v.fashionphile.filter((x) => x.available);
|
|
674
|
+
const rbAvail = v.rebag.filter((x) => x.available);
|
|
675
|
+
const fpMin = fpPrices.length ? Math.min(...fpPrices) : null;
|
|
676
|
+
const rbMin = rbPrices.length ? Math.min(...rbPrices) : null;
|
|
677
|
+
lines.push(`${key}`);
|
|
678
|
+
lines.push(` fashionphile: ${v.fashionphile.length} listing(s), ${fpAvail.length} available, ${usd(fpMin)} to ${usd(fpPrices.length ? Math.max(...fpPrices) : null)}, median ${usd(median(fpPrices))}`);
|
|
679
|
+
lines.push(` rebag: ${v.rebag.length} listing(s), ${rbAvail.length} available, ${usd(rbMin)} to ${usd(rbPrices.length ? Math.max(...rbPrices) : null)}, median ${usd(median(rbPrices))}`);
|
|
680
|
+
if (fpMin !== null && rbMin !== null && fpMin > 0) {
|
|
681
|
+
const diff = rbMin - fpMin;
|
|
682
|
+
const cheaper = diff > 0 ? "fashionphile" : diff < 0 ? "rebag" : "neither";
|
|
683
|
+
lines.push(diff === 0
|
|
684
|
+
? ` the cheapest listing is the same price on both`
|
|
685
|
+
: ` the cheaper side is ${cheaper}, ${usd(Math.abs(diff))} (${((Math.abs(diff) / fpMin) * 100).toFixed(0)}%) ${diff > 0 ? "below" : "above"} the other side's floor of ${usd(diff > 0 ? fpMin : rbMin)}`);
|
|
686
|
+
if (fpAvail.length === 0 && rbAvail.length > 0) {
|
|
687
|
+
lines.push(` every fashionphile listing of this model is sold out, so the rebag price is the only one you can actually buy`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const cheapestOverall = [...v.fashionphile, ...v.rebag]
|
|
691
|
+
.filter((x) => x.available)
|
|
692
|
+
.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity))[0];
|
|
693
|
+
if (cheapestOverall) {
|
|
694
|
+
lines.push(` cheapest buyable: ${usd(cheapestOverall.price)} at ${cheapestOverall.source} — ${cheapestOverall.title.slice(0, 52)}`);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
lines.push(` nothing in this model is available on either site right now`);
|
|
698
|
+
}
|
|
699
|
+
lines.push("");
|
|
700
|
+
}
|
|
701
|
+
if (oneSided.length) {
|
|
702
|
+
// A model can appear on both sites yet produce no matched key, because the
|
|
703
|
+
// size qualifiers differ: a Zucca mini on one side and a Zucca medium on the
|
|
704
|
+
// other are correctly not compared, but calling that "only on one site" is
|
|
705
|
+
// simply wrong. The base model is checked before making that claim.
|
|
706
|
+
const sizeMismatch = [];
|
|
707
|
+
const trulyOneSided = [];
|
|
708
|
+
for (const [k, v] of oneSided) {
|
|
709
|
+
const base = k.split(" ")[0].toLowerCase();
|
|
710
|
+
const onFp = v.fashionphile.length > 0;
|
|
711
|
+
const onRb = v.rebag.length > 0;
|
|
712
|
+
if (onFp !== onRb) {
|
|
713
|
+
// Is the base model present on the other side under a different size?
|
|
714
|
+
const otherHas = onFp
|
|
715
|
+
? [...buckets.entries()].some(([k2, v2]) => k2.split(" ")[0].toLowerCase() === base && v2.rebag.length > 0)
|
|
716
|
+
: [...buckets.entries()].some(([k2, v2]) => k2.split(" ")[0].toLowerCase() === base && v2.fashionphile.length > 0);
|
|
717
|
+
if (otherHas)
|
|
718
|
+
sizeMismatch.push(k);
|
|
719
|
+
else
|
|
720
|
+
trulyOneSided.push(k);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (trulyOneSided.length) {
|
|
724
|
+
lines.push(`On one site only (${trulyOneSided.length}): ${trulyOneSided.slice(0, 12).join(", ")}${trulyOneSided.length > 12 ? ", and more" : ""}.`, "A model stocked by only one of the two is usually a stock difference rather than a rarity, so it says nothing about where the better price is.");
|
|
725
|
+
}
|
|
726
|
+
if (sizeMismatch.length) {
|
|
727
|
+
lines.push(`On both sites but with no matching size (${sizeMismatch.length}): ${sizeMismatch.slice(0, 12).join(", ")}${sizeMismatch.length > 12 ? ", and more" : ""}.`, "The model exists on both sites but the size qualifiers do not line up, so there is nothing to compare directly. The sizes are in the key on purpose: a Zucca mini and a Zucca medium are different products at different prices, and averaging them into one row would be a comparison of nothing.");
|
|
728
|
+
}
|
|
729
|
+
lines.push("");
|
|
730
|
+
}
|
|
731
|
+
lines.push("The join is on the model name inside the title, and that is a real weakness. It cannot tell a Baguette from a Baguette II, it does not read the generation or the year, and it compares every colourway of a model as though they were one product. It also cannot tell a bag with a pharaoh clasp from one without, or one with a detachable strap from one without. Treat the spread as a lead on where to look, not as the price difference for the bag you want.");
|
|
732
|
+
lines.push("Prices are each retailer's own asking prices on resale. Neither site publishes a sold price, so a low asking price on one may simply be an overpriced item that has not sold yet.");
|
|
733
|
+
return lines.join("\n");
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* The real floor: the cheapest listing that can actually be bought, as opposed to
|
|
737
|
+
* the cheapest listing, which on these sites is often sold out and is therefore
|
|
738
|
+
* not a price at all.
|
|
739
|
+
*/
|
|
740
|
+
export async function priceLadder(args) {
|
|
741
|
+
const pages = Math.min(Math.max(Math.floor(args.pages ?? 6) || 6, 1), 16);
|
|
742
|
+
const wantModel = String(args.model ?? "").trim().toLowerCase();
|
|
743
|
+
const wantQuery = String(args.query ?? "").trim().toLowerCase();
|
|
744
|
+
if (!wantModel && !wantQuery) {
|
|
745
|
+
throw new FendiError("Provide a model name such as Baguette, or a query such as 'quilted flap'.");
|
|
746
|
+
}
|
|
747
|
+
const lines = [];
|
|
748
|
+
let anyMatch = false;
|
|
749
|
+
for (const s of SOURCES) {
|
|
750
|
+
const c = await collect(s, Array.from({ length: pages }, (_, i) => i + 1));
|
|
751
|
+
const items = c.fendi.map((p) => toMatched(s, p));
|
|
752
|
+
const matched = items.filter((m) => {
|
|
753
|
+
const k = modelKey(m.title);
|
|
754
|
+
if (!k)
|
|
755
|
+
return false;
|
|
756
|
+
if (wantModel && k.model.toLowerCase() !== wantModel)
|
|
757
|
+
return false;
|
|
758
|
+
if (wantQuery)
|
|
759
|
+
return fingerprint(m.title).includes(wantQuery.split(" ")[0] ?? "");
|
|
760
|
+
return true;
|
|
761
|
+
});
|
|
762
|
+
if (!matched.length) {
|
|
763
|
+
lines.push(`${s.name}: nothing matched`);
|
|
764
|
+
lines.push("");
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
anyMatch = true;
|
|
768
|
+
const prices = matched.map((m) => m.price).filter((p) => p !== null);
|
|
769
|
+
const avail = matched.filter((m) => m.available);
|
|
770
|
+
const availPrices = avail.map((m) => m.price).filter((p) => p !== null);
|
|
771
|
+
const cheapestAsking = prices.length ? Math.min(...prices) : null;
|
|
772
|
+
const cheapestBuyable = availPrices.length ? Math.min(...availPrices) : null;
|
|
773
|
+
lines.push(`${s.name}: ${matched.length} listing(s), ${avail.length} available, ${matched.length - avail.length} sold out`);
|
|
774
|
+
const askNote = cheapestBuyable !== null && cheapestAsking !== null && cheapestAsking < cheapestBuyable
|
|
775
|
+
? " (a sold-out listing, so not a price you can pay)"
|
|
776
|
+
: "";
|
|
777
|
+
lines.push(` cheapest asking price: ${usd(cheapestAsking)}${askNote}`);
|
|
778
|
+
lines.push(` cheapest actually buyable: ${usd(cheapestBuyable)}`);
|
|
779
|
+
if (prices.length) {
|
|
780
|
+
const p25 = [...prices].sort((a, b) => a - b)[Math.floor(prices.length * 0.25)];
|
|
781
|
+
lines.push(` range ${usd(Math.min(...prices))} to ${usd(Math.max(...prices))}, median ${usd(median(prices))}, lower quartile ${usd(p25)}`);
|
|
782
|
+
}
|
|
783
|
+
// Split by condition, because a price with no condition next to it is not
|
|
784
|
+
// comparable with one that has.
|
|
785
|
+
const byCond = new Map();
|
|
786
|
+
for (const m of matched) {
|
|
787
|
+
const c = m.condition ?? "condition not stated";
|
|
788
|
+
if (!byCond.has(c))
|
|
789
|
+
byCond.set(c, []);
|
|
790
|
+
const p = m.price;
|
|
791
|
+
if (p !== null)
|
|
792
|
+
byCond.get(c).push(p);
|
|
793
|
+
}
|
|
794
|
+
if (byCond.size > 1) {
|
|
795
|
+
lines.push("", " by stated condition:");
|
|
796
|
+
for (const [c, ps] of [...byCond.entries()].sort((a, b) => median(b[1]) - median(a[1]))) {
|
|
797
|
+
lines.push(` ${c.padEnd(24)}${String(ps.length).padStart(3)} listing(s) ${usd(Math.min(...ps))} to ${usd(Math.max(...ps))} median ${usd(median(ps))}`);
|
|
798
|
+
}
|
|
799
|
+
const conds = [...byCond.keys()];
|
|
800
|
+
if (conds.length > 1 && !conds.some((c) => /not stated/i.test(c))) {
|
|
801
|
+
lines.push(` Conditions present: ${conds.join(", ")}. A "fair" bag and a "great" bag are not the same product at different prices, so compare within a condition, never across them.`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
else if (byCond.size === 1) {
|
|
805
|
+
lines.push(` Every listing states the same condition (${[...byCond.keys()][0]}), so no condition split is possible here.`);
|
|
806
|
+
}
|
|
807
|
+
// The outlying listing, which is where a misprice or a bait listing shows up.
|
|
808
|
+
if (availPrices.length > 3) {
|
|
809
|
+
const sortedAvail = [...availPrices].sort((a, b) => a - b);
|
|
810
|
+
const med = median(sortedAvail);
|
|
811
|
+
const floor = sortedAvail[0];
|
|
812
|
+
if (med > 0 && floor < med * 0.45) {
|
|
813
|
+
const outlier = avail.filter((m) => m.price === floor)[0];
|
|
814
|
+
lines.push("", ` One available listing sits far below the rest: ${usd(floor)} against a median of ${usd(med)}, which is ${((1 - floor / med) * 100).toFixed(0)}% below it.`, ` ${outlier?.title.slice(0, 60)}`, " A price that far out of line with the same model is worth a look and also worth suspicion: it is the shape of a mispriced listing, of a bag with a defect the condition field does not mention, or of a bait listing. Check the photos and the description before treating it as the real market floor.");
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
lines.push("");
|
|
818
|
+
}
|
|
819
|
+
if (!anyMatch) {
|
|
820
|
+
return `No Fendi listing on either site matched ${wantModel ? `model "${args.model}"` : `"${args.query}"`} across ${pages} catalog page(s) per site.`;
|
|
821
|
+
}
|
|
822
|
+
lines.push("Both sites delete a listing once it sells, so what is here is only what is unsold. There is no sold-price history on either site through the catalog, which means a low asking price cannot be distinguished from a high one that simply has not sold yet.");
|
|
823
|
+
return lines.join("\n").trimEnd();
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* The two sites' product types side by side. They are not reconcilable: the
|
|
827
|
+
* catalogs use disjoint vocabularies, so a category filter on one site means
|
|
828
|
+
* nothing on the other and a cross-site count by category is not a like-for-like
|
|
829
|
+
* comparison.
|
|
830
|
+
*/
|
|
831
|
+
export async function sourceTaxonomy(args) {
|
|
832
|
+
const top = Math.min(Math.max(Math.floor(args.top ?? 20) || 20, 1), 100);
|
|
833
|
+
const only = String(args.source ?? "").trim().toLowerCase();
|
|
834
|
+
const sources = only ? SOURCES.filter((s) => s.name === only) : SOURCES;
|
|
835
|
+
if (!sources.length)
|
|
836
|
+
throw new FendiError(`Unknown source "${args.source}". Use ${SOURCES.map((s) => s.name).join(" or ")}.`);
|
|
837
|
+
const perSource = [];
|
|
838
|
+
for (const s of sources) {
|
|
839
|
+
const c = await collect(s, [1, 2]);
|
|
840
|
+
const items = c.fendi;
|
|
841
|
+
const counts = new Map();
|
|
842
|
+
for (const p of items) {
|
|
843
|
+
const t = String(p?.product_type ?? "").trim() || "(blank)";
|
|
844
|
+
counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
845
|
+
}
|
|
846
|
+
perSource.push({ name: s.name, counts, total: items.length });
|
|
847
|
+
}
|
|
848
|
+
const lines = [];
|
|
849
|
+
for (const { name, counts, total } of perSource) {
|
|
850
|
+
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
851
|
+
lines.push(`${name}: ${total} products read, ${entries.length} distinct product_type value(s)`);
|
|
852
|
+
lines.push(`${"count".padStart(6)} product_type`);
|
|
853
|
+
for (const [t, n] of entries.slice(0, top))
|
|
854
|
+
lines.push(`${String(n).padStart(6)} ${t}`);
|
|
855
|
+
if (entries.length > top)
|
|
856
|
+
lines.push(` ...and ${entries.length - top} more values`);
|
|
857
|
+
lines.push("");
|
|
858
|
+
}
|
|
859
|
+
if (perSource.length === 2) {
|
|
860
|
+
const [a, b] = perSource;
|
|
861
|
+
const as = new Set([...a.counts.keys()].map((x) => x.toLowerCase()));
|
|
862
|
+
const bs = new Set([...b.counts.keys()].map((x) => x.toLowerCase()));
|
|
863
|
+
const shared = [...as].filter((x) => bs.has(x));
|
|
864
|
+
const onlyA = [...as].filter((x) => !bs.has(x));
|
|
865
|
+
const onlyB = [...bs].filter((x) => !as.has(x));
|
|
866
|
+
lines.push(`The two vocabularies share ${shared.length} value(s)${shared.length ? `: ${shared.join(", ")}` : ""}.`, `fashionphile-only: ${onlyA.length} | rebag-only: ${onlyB.length}`, "", "These are not the same classification, so any comparison by product type across the two sites is comparing different things. Where one site has four broad types and the other has dozens of shapes, a count in a bucket means something narrower on the second site than on the first, and a listing classified as a bag shape on one can be a broad accessories entry on the other. This is the reason cross_market_match joins on the model name in the title rather than on category.");
|
|
867
|
+
lines.push("");
|
|
868
|
+
lines.push("Neither site's tags are a usable taxonomy either. Fashionphile's are campaign and syndication markers such as amazon-market-template and tiktok-market-template-clutches, and Rebag's are filter and feed identifiers such as bc-filter-General View, reflaunt_dropship and dynamic-feed-24. There is no facet tree on either side to build a filter from.");
|
|
869
|
+
}
|
|
870
|
+
return lines.join("\n").trimEnd();
|
|
871
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
3
|
+
* them how to get a key.
|
|
4
|
+
*
|
|
5
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
6
|
+
* license server on every call and keeps working through a brief outage.
|
|
7
|
+
*/
|
|
8
|
+
export declare function premiumRequired(tool: string): Promise<string | null>;
|
package/dist/license.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Premium tools on this server need a license key bought from MCP Marketplace.
|
|
2
|
+
// Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
|
|
3
|
+
// that is not listed in PREMIUM keeps working without a key.
|
|
4
|
+
const SLUG = "fendi-mcp";
|
|
5
|
+
// Tools that need a key. Everything else is free.
|
|
6
|
+
const PREMIUM = new Set([
|
|
7
|
+
"cross_market_match",
|
|
8
|
+
"price_ladder",
|
|
9
|
+
]);
|
|
10
|
+
const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
|
|
11
|
+
// The license check calls the marketplace's verify endpoint directly instead of
|
|
12
|
+
// using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
|
|
13
|
+
// check it makes is rejected by Supabase before it reaches the function. The
|
|
14
|
+
// publishable key below is public by design - it ships in mcp-marketplace.io's
|
|
15
|
+
// own JavaScript and only ever reaches their licence check.
|
|
16
|
+
const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
|
|
17
|
+
const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
|
|
18
|
+
const OK_CACHE_MS = 60 * 60 * 1000;
|
|
19
|
+
const BAD_CACHE_MS = 60 * 1000;
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const REASONS = {
|
|
22
|
+
missing_key: "no key is set",
|
|
23
|
+
invalid_format: "that key is not a valid MCP Marketplace key",
|
|
24
|
+
not_found: "that key was not found",
|
|
25
|
+
revoked: "that key has been revoked",
|
|
26
|
+
rotated: "that key was rotated, use your new one",
|
|
27
|
+
expired: "that key has expired, renew it",
|
|
28
|
+
rate_limited: "there were too many checks just now, try again shortly",
|
|
29
|
+
network_error: "the license server could not be reached",
|
|
30
|
+
};
|
|
31
|
+
function blocked(tool, reason) {
|
|
32
|
+
const why = REASONS[reason] ?? `the license server answered "${reason}"`;
|
|
33
|
+
return `"${tool}" needs a license key, but ${why}. ` +
|
|
34
|
+
`Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
|
|
35
|
+
}
|
|
36
|
+
async function verify(key) {
|
|
37
|
+
const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
apikey: PUBLISHABLE_KEY,
|
|
41
|
+
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ key, slug: SLUG }),
|
|
45
|
+
// an unusable key answers 400 with a JSON body, so read the body either way
|
|
46
|
+
signal: AbortSignal.timeout(15000),
|
|
47
|
+
});
|
|
48
|
+
const data = (await res.json());
|
|
49
|
+
if (typeof data?.valid !== "boolean")
|
|
50
|
+
return { valid: false, reason: "unexpected_response" };
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
55
|
+
* them how to get a key.
|
|
56
|
+
*
|
|
57
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
58
|
+
* license server on every call and keeps working through a brief outage.
|
|
59
|
+
*/
|
|
60
|
+
export async function premiumRequired(tool) {
|
|
61
|
+
if (!PREMIUM.has(tool))
|
|
62
|
+
return null;
|
|
63
|
+
const key = process.env.MCP_LICENSE_KEY;
|
|
64
|
+
if (!key)
|
|
65
|
+
return blocked(tool, "missing_key");
|
|
66
|
+
const hit = seen.get(key);
|
|
67
|
+
if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
|
|
68
|
+
return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
|
|
69
|
+
}
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = await verify(key);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
result = { valid: false, reason: "network_error" };
|
|
76
|
+
}
|
|
77
|
+
seen.set(key, { ...result, at: Date.now() });
|
|
78
|
+
return result.valid ? null : blocked(tool, result.reason ?? "invalid");
|
|
79
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { errorMessage, searchListings, listNewest, getListing, priceOverview, comparePrices, findDeals, brandOverview, } from "./api.js";
|
|
3
|
+
import { errorMessage, searchListings, listNewest, getListing, priceOverview, comparePrices, findDeals, brandOverview, listCollections, browseCollection, find_by_attribute, crossMarketMatch, priceLadder, sourceTaxonomy } from "./api.js";
|
|
4
|
+
import { premiumRequired } from "./license.js";
|
|
4
5
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
6
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
7
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -13,7 +14,7 @@ export function createServer() {
|
|
|
13
14
|
title: "Search listings",
|
|
14
15
|
description: "Keyword search across Fashionphile and Rebag. Condition filter is Rebag-data only.",
|
|
15
16
|
inputSchema: z.object({
|
|
16
|
-
query: z.string().describe("Keyword, e.g.
|
|
17
|
+
query: z.string().describe("Keyword, e.g. Baguette, Peekaboo, Mon Tresor."),
|
|
17
18
|
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
18
19
|
max_price: z.string().describe("Max USD price.").optional(),
|
|
19
20
|
condition: z.string().describe("excellent, great, very good, good, fair (Rebag data only).").optional()
|
|
@@ -81,7 +82,7 @@ export function createServer() {
|
|
|
81
82
|
title: "Compare prices",
|
|
82
83
|
description: "Group same-model listings across sources with min/max/spread.",
|
|
83
84
|
inputSchema: z.object({
|
|
84
|
-
query: z.string().describe("Model keyword, e.g.
|
|
85
|
+
query: z.string().describe("Model keyword, e.g. Baguette, Peekaboo."),
|
|
85
86
|
limit: z.string().describe("Suggest depth per source (default 30, max 60).").optional()
|
|
86
87
|
}),
|
|
87
88
|
annotations: READ_ONLY,
|
|
@@ -122,5 +123,120 @@ export function createServer() {
|
|
|
122
123
|
return textError(errorMessage(e));
|
|
123
124
|
}
|
|
124
125
|
});
|
|
126
|
+
server.registerTool("list_collections", {
|
|
127
|
+
title: "List collections",
|
|
128
|
+
description: "Curated store collections with handles for browse_collection, e.g. sale or category collections.",
|
|
129
|
+
inputSchema: z.object({
|
|
130
|
+
query: z.string().describe("Filter collection titles, e.g. sale, bags, watches.").optional()
|
|
131
|
+
}),
|
|
132
|
+
annotations: READ_ONLY,
|
|
133
|
+
}, async ({ query }) => {
|
|
134
|
+
try {
|
|
135
|
+
return text(await listCollections(query));
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
return textError(errorMessage(e));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
server.registerTool("browse_collection", {
|
|
142
|
+
title: "Browse collection",
|
|
143
|
+
description: "Listings inside one collection handle from list_collections, e.g. a sale or category collection.",
|
|
144
|
+
inputSchema: z.object({
|
|
145
|
+
handle: z.string().describe("Collection handle from list_collections, e.g. discounted-10-off."),
|
|
146
|
+
source: z.string().describe("fashionphile or rebag (default: both).").optional(),
|
|
147
|
+
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
148
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
149
|
+
condition: z.string().describe("excellent, great, very good, good, fair (Rebag data only).").optional()
|
|
150
|
+
}),
|
|
151
|
+
annotations: READ_ONLY,
|
|
152
|
+
}, async ({ handle, source, limit, max_price: maxPrice, condition }) => {
|
|
153
|
+
try {
|
|
154
|
+
return text(await browseCollection(handle, source, limit, maxPrice, condition));
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
return textError(errorMessage(e));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
server.registerTool("find_by_attribute", {
|
|
161
|
+
title: "Find by attribute",
|
|
162
|
+
description: "Listings filtered by color, exterior material or condition, plus the common values in the sample.",
|
|
163
|
+
inputSchema: z.object({
|
|
164
|
+
color: z.string().describe("Color keyword, e.g. black, gold, blue.").optional(),
|
|
165
|
+
material: z.string().describe("Exterior material, e.g. leather, canvas, exotic.").optional(),
|
|
166
|
+
condition: z.string().describe("excellent, great, very good, good, fair.").optional(),
|
|
167
|
+
max_price: z.string().describe("Max USD price.").optional(),
|
|
168
|
+
limit: z.string().describe("Max results (default 10, max 25).").optional(),
|
|
169
|
+
sample: z.string().describe("Listings sampled (default 200, max 500).").optional()
|
|
170
|
+
}),
|
|
171
|
+
annotations: READ_ONLY,
|
|
172
|
+
}, async ({ color, material, condition, max_price: maxPrice, limit, sample }) => {
|
|
173
|
+
try {
|
|
174
|
+
return text(await find_by_attribute(color, material, condition, maxPrice, limit, sample));
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
return textError(errorMessage(e));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
server.registerTool("cross_market_match", {
|
|
181
|
+
title: "Same model priced on both resale sites",
|
|
182
|
+
description: "Finds the same Fendi model on both Fashionphile and Rebag and prices them side by side, with the cheapest listing, the cheapest one actually buyable, the spread, and which site is cheaper. The two sites have different handles for the same bag, so the existing tools cannot join the catalogs at all. The join is on the model name in the title and is explicitly limited: it cannot tell a Baguette from a Baguette II and treats every colourway of a model as one comparison.",
|
|
183
|
+
inputSchema: z.object({
|
|
184
|
+
model: z.string().optional().describe("Model name, e.g. Baguette, Marmont, Birkin, Kelly, Peekaboo, Pochette, Speedy, Monogram, Alma."),
|
|
185
|
+
query: z.string().optional().describe("Extra title words to narrow within the model, e.g. 'quilted flap'."),
|
|
186
|
+
pages: z.number().int().min(1).max(4).default(2).describe("Catalog pages per site, default 2. Each site caps a page at 250 products."),
|
|
187
|
+
limit: z.number().int().min(1).max(25).default(6).describe("Models to compare, default 6"),
|
|
188
|
+
}),
|
|
189
|
+
annotations: READ_ONLY,
|
|
190
|
+
}, async (args) => {
|
|
191
|
+
// ---- paywall: cross_market_match ----
|
|
192
|
+
const paywallMessage = await premiumRequired("cross_market_match");
|
|
193
|
+
if (paywallMessage)
|
|
194
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
195
|
+
// ---- paywall: end ----
|
|
196
|
+
try {
|
|
197
|
+
return text(await crossMarketMatch(args));
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return textError(errorMessage(e));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
server.registerTool("price_ladder", {
|
|
204
|
+
title: "Price ladder and the real buyable floor",
|
|
205
|
+
description: "For a model on each resale site, separates the cheapest asking price from the cheapest one that can actually be bought, since the cheapest listing on these sites is often already sold out and is therefore not a price anyone can pay. Breaks the range down by stated condition, and flags an available listing priced far below the median as a possible misprice, a defect, or a bait listing. Says that neither site publishes sold prices, so a low ask cannot be told from a high one that has not sold.",
|
|
206
|
+
inputSchema: z.object({
|
|
207
|
+
model: z.string().optional().describe("Model name, e.g. Baguette, Kelly, Peekaboo."),
|
|
208
|
+
query: z.string().optional().describe("Extra title words to narrow within the model."),
|
|
209
|
+
pages: z.number().int().min(1).max(4).default(2).describe("Catalog pages per site, default 2"),
|
|
210
|
+
}),
|
|
211
|
+
annotations: READ_ONLY,
|
|
212
|
+
}, async (args) => {
|
|
213
|
+
// ---- paywall: price_ladder ----
|
|
214
|
+
const paywallMessage = await premiumRequired("price_ladder");
|
|
215
|
+
if (paywallMessage)
|
|
216
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
217
|
+
// ---- paywall: end ----
|
|
218
|
+
try {
|
|
219
|
+
return text(await priceLadder(args));
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
return textError(errorMessage(e));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
server.registerTool("source_taxonomy", {
|
|
226
|
+
title: "Compare the two sites' product taxonomies",
|
|
227
|
+
description: "Lists the product_type values each resale site uses with counts, then shows how little the two vocabularies overlap. They are disjoint, so a category filter on one site means nothing on the other and a cross-site count by category is not like-for-like, which is why cross_market_match joins on the model name instead. Also records that neither site's tags form a usable facet tree: they are campaign and syndication markers.",
|
|
228
|
+
inputSchema: z.object({
|
|
229
|
+
source: z.string().optional().describe("Restrict to one site: fashionphile or rebag. Omit for both."),
|
|
230
|
+
top: z.number().int().min(1).max(100).default(20).describe("Type values to list per site, default 20"),
|
|
231
|
+
}),
|
|
232
|
+
annotations: READ_ONLY,
|
|
233
|
+
}, async (args) => {
|
|
234
|
+
try {
|
|
235
|
+
return text(await sourceTaxonomy(args));
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
return textError(errorMessage(e));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
125
241
|
return server;
|
|
126
242
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fendi-mcp",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Fendi resale listings from Fashionphile and Rebag
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Fendi resale listings aggregated from Fashionphile and Rebag public catalogs (prices in USD).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"mcpName": "io.github.mrfentmen/fendi-mcp",
|
|
7
7
|
"repository": {
|