@nooticr/mcp 1.26.36 → 1.26.37
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 +3 -0
- package/dist/shared/amazon.js +9 -4
- package/dist/shared/loading-plans.js +42 -0
- package/dist/shared/marketplace.js +374 -0
- package/dist/shared/output-schemas.js +21 -0
- package/dist/shared/tools-def.js +18 -0
- package/dist/shared/tools.js +13 -1
- package/dist/shared/ui-template.js +162 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,6 +145,9 @@ a `scanId` for the rest.
|
|
|
145
145
|
| `scan_amazon_category` | 3 per listing collected (30 by default) | The category, collected: listings with prices, star histograms, Amazon's own review-aspect counts and the review text itself, plus the arithmetic over them — price spread, rating spread, which aspects recur across how many brands, and the share of each listing's ratings sitting at 1-2 stars, read from the histogram rather than from the handful of reviews a scrape returns. Pass `query` to search the category, `asins` to pin the competitors a customer named, or both; named competitors keep their place at the front. Because the price is set by an argument, it confirms the total before spending it. |
|
|
146
146
|
| `amazon_scan_status` | free | Picks a running collection back up by `scanId` — the listings collected since, with the same rollup. Free: it is the poll `scan_amazon_category` asked for, and billing the second half of one answer would charge for a wait the server chose. |
|
|
147
147
|
| `get_amazon_product` | 3 | One listing by ASIN or URL: price, rating, the full star histogram, features, specs, Amazon's review digest and aspect breakdown, and the reviews. |
|
|
148
|
+
| `scan_marketplace_category` | 3 per product collected (30 by default) | The same category read on ten more sites — AliExpress, Cdiscount, Flipkart, Mercado Libre, Lazada, Otto, Rakuten, Trendyol, Jumia and Temu. `marketplace` picks the site; `market` picks the storefront for the sites that have several, and the argument names the real set per site so a model cannot send Otto a Lazada region. Same rollup, same confirmation before spending. |
|
|
149
|
+
| `marketplace_scan_status` | free | Picks a running marketplace collection back up by `scanId` and the same `marketplace`. Free, for the same reason `amazon_scan_status` is. |
|
|
150
|
+
| `get_marketplace_product` | 3 | One product from any of the eleven sites, by id or URL: price, rating, the full star histogram, specs and the reviews. |
|
|
148
151
|
| `show_amazon_category_insights` | free | Draws the read **your model** wrote — purchase drivers, barriers, what each brand does well, the gaps, the positioning angles — beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. The conclusions are attributed to your model, never presented as a nooticr rating of anyone's product. Makes no requests. |
|
|
149
152
|
|
|
150
153
|
### Make something
|
package/dist/shared/amazon.js
CHANGED
|
@@ -104,8 +104,11 @@ export function allReviews(products) {
|
|
|
104
104
|
* rule in CLAUDE.md.
|
|
105
105
|
*/
|
|
106
106
|
export function categoryGuidance(opts) {
|
|
107
|
+
const site = opts.site ?? "Amazon";
|
|
108
|
+
const statusTool = opts.statusTool ?? "amazon_scan_status";
|
|
109
|
+
const insightsTool = opts.insightsTool === undefined ? "show_amazon_category_insights" : opts.insightsTool;
|
|
107
110
|
const lines = [];
|
|
108
|
-
lines.push(`Here are ${opts.products}
|
|
111
|
+
lines.push(`Here are ${opts.products} ${site} listing${opts.products === 1 ? "" : "s"} for ${opts.label}` +
|
|
109
112
|
`, carrying ${opts.reviews} review${opts.reviews === 1 ? "" : "s"}` +
|
|
110
113
|
(opts.ratingsRepresented
|
|
111
114
|
? ` and the star histograms behind ${opts.ratingsRepresented.toLocaleString("en-US")} ratings.`
|
|
@@ -114,7 +117,7 @@ export function categoryGuidance(opts) {
|
|
|
114
117
|
lines.push(`Brands in the set: ${opts.brands.join(", ")}.`);
|
|
115
118
|
if (!opts.complete) {
|
|
116
119
|
lines.push(`Collection is still running — ${opts.pending} listing${opts.pending === 1 ? "" : "s"} to go. ` +
|
|
117
|
-
`Everything below is real and usable now; call
|
|
120
|
+
`Everything below is real and usable now; call ${statusTool} with scanId "${opts.scanId}" ` +
|
|
118
121
|
`for the rest before you write a final read.`);
|
|
119
122
|
}
|
|
120
123
|
lines.push("");
|
|
@@ -127,8 +130,10 @@ export function categoryGuidance(opts) {
|
|
|
127
130
|
}
|
|
128
131
|
lines.push("");
|
|
129
132
|
lines.push("Judge only from what the reviews say. Where the evidence is thin, say so rather than", "producing a confident line the text does not support — a category read that overstates its", "basis is worse than a short one. Prices, ratings and mention counts are in the payload;", "do not re-derive them by hand.");
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
if (insightsTool) {
|
|
134
|
+
lines.push("");
|
|
135
|
+
lines.push("To show the result in the conversation — products, their reviews, and your read side by", `side — call ${insightsTool} with what you concluded. It costs nothing and`, "makes no further requests.");
|
|
136
|
+
}
|
|
132
137
|
return lines.join("\n");
|
|
133
138
|
}
|
|
134
139
|
/**
|
|
@@ -384,6 +384,48 @@ export const LOADING_PLANS = {
|
|
|
384
384
|
},
|
|
385
385
|
],
|
|
386
386
|
},
|
|
387
|
+
// The same three jobs on the other ten sites. Priced identically because
|
|
388
|
+
// they are the same work — a real browser render behind the site's bot
|
|
389
|
+
// defences — and the note does not name Amazon, because ten of the eleven
|
|
390
|
+
// are not it.
|
|
391
|
+
scan_marketplace_category: {
|
|
392
|
+
label: "Collecting the category",
|
|
393
|
+
kind: "list",
|
|
394
|
+
n: 6,
|
|
395
|
+
steps: [],
|
|
396
|
+
perUnit: [
|
|
397
|
+
{
|
|
398
|
+
via: "scan_marketplace_category",
|
|
399
|
+
label: "Collecting a product",
|
|
400
|
+
detail: "price, stars and its reviews",
|
|
401
|
+
credits: 3,
|
|
402
|
+
arg: "limit",
|
|
403
|
+
defaultCount: 10,
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
note: "Each site is collected politely, so this is minutes rather than seconds. Partial results come back either way.",
|
|
407
|
+
},
|
|
408
|
+
// Free: the poll of a collection already paid for.
|
|
409
|
+
marketplace_scan_status: {
|
|
410
|
+
label: "Checking the collection",
|
|
411
|
+
kind: "list",
|
|
412
|
+
n: 6,
|
|
413
|
+
steps: [],
|
|
414
|
+
free: true,
|
|
415
|
+
},
|
|
416
|
+
get_marketplace_product: {
|
|
417
|
+
label: "Reading the product",
|
|
418
|
+
kind: "text",
|
|
419
|
+
n: 1,
|
|
420
|
+
steps: [
|
|
421
|
+
{
|
|
422
|
+
via: "get_marketplace_product",
|
|
423
|
+
label: "Fetching the product",
|
|
424
|
+
detail: "price, stars and its reviews",
|
|
425
|
+
credits: 3,
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
},
|
|
387
429
|
// ─── Own-account reads: nooticr's own stored rows ───
|
|
388
430
|
get_scheduled_posts: { label: "Reading your pipeline", kind: "list", n: 5, steps: [], free: true },
|
|
389
431
|
get_post_performance: { label: "Reading your numbers", kind: "list", n: 5, steps: [], free: true },
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
3
|
+
import { viewMeta } from "./view-meta.js";
|
|
4
|
+
import { withEvidence } from "./evidence-digest.js";
|
|
5
|
+
import { confirmSpend, declinedResult } from "./spend.js";
|
|
6
|
+
import { allReviews, categoryGuidance, listingCount, normaliseProducts, scanCost } from "./amazon.js";
|
|
7
|
+
/**
|
|
8
|
+
* The eleven sites, mirroring `MARKETPLACES` in nooticr-server.
|
|
9
|
+
*
|
|
10
|
+
* Kept in the same order and the same words deliberately: a model reads this
|
|
11
|
+
* text to decide what to send, and the server refuses on the same terms. Two
|
|
12
|
+
* registries that drift are how a tool starts describing an argument the
|
|
13
|
+
* collector no longer accepts.
|
|
14
|
+
*/
|
|
15
|
+
export const MARKETS = [
|
|
16
|
+
{
|
|
17
|
+
slug: "amazon",
|
|
18
|
+
label: "Amazon",
|
|
19
|
+
idLabel: "an ASIN or a full Amazon product URL",
|
|
20
|
+
region: {
|
|
21
|
+
field: "domain",
|
|
22
|
+
hint: "a country code or host — com (US), co.uk, de, fr, it, es, ca, co.jp, in, com.br, " +
|
|
23
|
+
"com.mx, com.au, nl, se, pl, com.be, com.tr, ae, sa, eg, sg, cn. Default www.amazon.com",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
slug: "aliexpress",
|
|
28
|
+
label: "AliExpress",
|
|
29
|
+
idLabel: "the numeric id from /item/<id>.html, or a full AliExpress product URL",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
slug: "cdiscount",
|
|
33
|
+
label: "Cdiscount",
|
|
34
|
+
idLabel: "the SKU from a product URL's /f-<category>-<sku>.html, or the full URL",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
slug: "flipkart",
|
|
38
|
+
label: "Flipkart",
|
|
39
|
+
idLabel: "the 16-character PID from a product URL's pid= parameter, or the full URL",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
slug: "mercadolibre",
|
|
43
|
+
label: "Mercado Libre",
|
|
44
|
+
idLabel: "a site id such as MLA904253674, or a full Mercado Libre product URL",
|
|
45
|
+
region: {
|
|
46
|
+
field: "market",
|
|
47
|
+
hint: "one of eighteen markets, named however it comes to hand — MLB, br, Brazil or " +
|
|
48
|
+
"mercadolivre.com.br. Inferred from the ids when omitted, and a job runs against one " +
|
|
49
|
+
"market: an id from another is refused rather than mis-filed",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
slug: "lazada",
|
|
54
|
+
label: "Lazada",
|
|
55
|
+
idLabel: "the number after -i in a product URL (13711387806 in /products/<slug>-i13711387806.html), " +
|
|
56
|
+
"or the full URL",
|
|
57
|
+
region: {
|
|
58
|
+
field: "market",
|
|
59
|
+
hint: "sg, my, th, vn, ph or id. An item id is scoped to one of them — the same id on another " +
|
|
60
|
+
"Lazada host is a different product or nothing at all",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
slug: "otto",
|
|
65
|
+
label: "Otto",
|
|
66
|
+
idLabel: "the tail of the product path — C1720803139 on otto.de, or the SKU AKLBB2029107352 on " +
|
|
67
|
+
"ottoversand.at",
|
|
68
|
+
region: {
|
|
69
|
+
field: "market",
|
|
70
|
+
hint: "de or at. The two are not interchangeable: an id from one does not address a product " +
|
|
71
|
+
"on the other",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
slug: "rakuten",
|
|
76
|
+
label: "Rakuten",
|
|
77
|
+
idLabel: "on Ichiba `<shopId>_<itemId>` (411453_10000003) or `<shop>/<manageNumber>`; on Taiwan " +
|
|
78
|
+
"`<shop>/<productCode>`. A full product URL works too",
|
|
79
|
+
region: {
|
|
80
|
+
field: "market",
|
|
81
|
+
hint: "jp (Ichiba) or tw. Taiwan publishes no review text, so a category study there reads on " +
|
|
82
|
+
"price and rating alone",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
slug: "trendyol",
|
|
87
|
+
label: "Trendyol",
|
|
88
|
+
idLabel: "the number after -p- in a product URL (819066262 in /brand/name-p-819066262), or the full URL",
|
|
89
|
+
region: {
|
|
90
|
+
field: "market",
|
|
91
|
+
hint: "one of 47 storefronts — tr, de, ae, sa, at, be, fr, it, nl, pl, ro and the rest. An id " +
|
|
92
|
+
"is scoped to one: the same number 404s on another country's",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
slug: "jumia",
|
|
97
|
+
label: "Jumia",
|
|
98
|
+
idLabel: "a SKU such as OR537EA86PWGTNAFAMZ. A product URL does NOT contain it — the number at the " +
|
|
99
|
+
"end of a product path is a different handle — so reach a product through `query` instead",
|
|
100
|
+
region: { field: "market", hint: "ng, eg, ke, ma, ci, gh, sn, tn, ug or dz" },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
slug: "temu",
|
|
104
|
+
label: "Temu",
|
|
105
|
+
idLabel: "the goods id after -g- in a product URL, or the full URL",
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
const BY_SLUG = new Map(MARKETS.map((m) => [m.slug, m]));
|
|
109
|
+
export function marketOf(slug) {
|
|
110
|
+
return typeof slug === "string" ? BY_SLUG.get(slug.toLowerCase().trim()) : undefined;
|
|
111
|
+
}
|
|
112
|
+
/** "amazon | aliexpress | ..." — the set, for a description. */
|
|
113
|
+
const SLUGS = MARKETS.map((m) => m.slug).join(", ");
|
|
114
|
+
/**
|
|
115
|
+
* The storefront argument, described site by site.
|
|
116
|
+
*
|
|
117
|
+
* One string rather than eleven schemas, because a tool takes one `marketplace`
|
|
118
|
+
* per call and the host shows the whole description whichever it picks. Sites
|
|
119
|
+
* with a single storefront are named as taking none, so a model does not
|
|
120
|
+
* invent one for Temu.
|
|
121
|
+
*/
|
|
122
|
+
const MARKET_HINT = MARKETS.filter((m) => m.region)
|
|
123
|
+
.map((m) => `${m.label}: ${m.region.hint}`)
|
|
124
|
+
.join(" · ") +
|
|
125
|
+
` · ${MARKETS.filter((m) => !m.region).map((m) => m.label).join(", ")} have one storefront each ` +
|
|
126
|
+
"and take no market argument.";
|
|
127
|
+
/** The product ids each site addresses by, for `items`. */
|
|
128
|
+
const ID_HINT = MARKETS.map((m) => `${m.label}: ${m.idLabel}`).join(" · ");
|
|
129
|
+
function structured(res) {
|
|
130
|
+
return (res.structured ?? {});
|
|
131
|
+
}
|
|
132
|
+
function toolError(what, err) {
|
|
133
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
134
|
+
return { isError: true, content: [{ type: "text", text: `${what}: ${message}` }] };
|
|
135
|
+
}
|
|
136
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
137
|
+
/**
|
|
138
|
+
* The scan, in the shape the view and the digest read.
|
|
139
|
+
*
|
|
140
|
+
* The Amazon twin of this hardcodes its own slug and `domain`; a scan here
|
|
141
|
+
* carries whichever site it ran against and the storefront under the one name
|
|
142
|
+
* `market`, because each collector calls it something different and a caller
|
|
143
|
+
* should not have to know which.
|
|
144
|
+
*/
|
|
145
|
+
function marketplaceResult(payload, m, focus) {
|
|
146
|
+
const products = normaliseProducts(payload.products);
|
|
147
|
+
const rollup = (payload.rollup ?? {});
|
|
148
|
+
const reviews = allReviews(products);
|
|
149
|
+
const progress = (payload.progress ?? {});
|
|
150
|
+
const total = num(progress.total) ?? products.length;
|
|
151
|
+
const done = num(progress.done) ?? products.length;
|
|
152
|
+
const label = String(payload.query ?? "") ||
|
|
153
|
+
(products.length === 1 ? String(products[0]?.title ?? "this listing") : "this set of listings");
|
|
154
|
+
const guidance = categoryGuidance({
|
|
155
|
+
label,
|
|
156
|
+
products: products.length,
|
|
157
|
+
reviews: reviews.length,
|
|
158
|
+
ratingsRepresented: num(rollup.ratingsRepresented) ?? 0,
|
|
159
|
+
brands: Array.isArray(rollup.brands) ? rollup.brands : [],
|
|
160
|
+
complete: payload.complete !== false,
|
|
161
|
+
scanId: String(payload.scanId ?? ""),
|
|
162
|
+
pending: Math.max(0, total - done),
|
|
163
|
+
focus,
|
|
164
|
+
site: m.label,
|
|
165
|
+
statusTool: "marketplace_scan_status",
|
|
166
|
+
// There is no free insights view for these sites yet, and pointing a model
|
|
167
|
+
// at show_amazon_category_insights would have it draw a Lazada scan as an
|
|
168
|
+
// Amazon one. Better to say nothing than to name the wrong tool.
|
|
169
|
+
insightsTool: null,
|
|
170
|
+
});
|
|
171
|
+
const structuredPayload = {
|
|
172
|
+
guidance,
|
|
173
|
+
mode: "evidence",
|
|
174
|
+
marketplace: m.slug,
|
|
175
|
+
scanId: payload.scanId ?? null,
|
|
176
|
+
status: payload.status ?? null,
|
|
177
|
+
complete: payload.complete !== false,
|
|
178
|
+
query: payload.query ?? null,
|
|
179
|
+
market: payload.market ?? payload.domain ?? null,
|
|
180
|
+
progress,
|
|
181
|
+
rollup,
|
|
182
|
+
products,
|
|
183
|
+
reviews,
|
|
184
|
+
errors: payload.errors ?? [],
|
|
185
|
+
mcpCredits: payload.mcpCredits ?? null,
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text", text: withEvidence(guidance, structuredPayload) }],
|
|
189
|
+
structuredContent: structuredPayload,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** The `marketplace` argument, shared by all three tools. */
|
|
193
|
+
const marketplaceArg = z
|
|
194
|
+
.enum(MARKETS.map((m) => m.slug))
|
|
195
|
+
.describe(`Which site to read. One of: ${SLUGS}.`);
|
|
196
|
+
export function registerMarketplaceTools(server, makeClient) {
|
|
197
|
+
server.registerTool("scan_marketplace_category", {
|
|
198
|
+
title: "Scan Marketplace Category",
|
|
199
|
+
_meta: viewMeta("scan_marketplace_category"),
|
|
200
|
+
description: "Collect a category from any of eleven marketplaces and read it: products with prices, " +
|
|
201
|
+
"star histograms, the review text itself, and the arithmetic over them (price spread, " +
|
|
202
|
+
"rating spread, which aspects recur across how many brands, and the share of each " +
|
|
203
|
+
"product's ratings sitting at 1-2 stars). `marketplace` picks the site — one of " +
|
|
204
|
+
`${SLUGS}. ` +
|
|
205
|
+
"Pass `query` to search the category, `items` to pin named competitors by id or URL, or " +
|
|
206
|
+
"both. You write the read: purchase drivers, barriers, what the incumbents do well, the " +
|
|
207
|
+
"gaps, and how a new entrant could position. " +
|
|
208
|
+
"Costs 3 nooticr credits per product collected — a product with reviews is a real browser " +
|
|
209
|
+
"render behind the site's bot defences. " +
|
|
210
|
+
"Collection runs in the background: this returns everything ready within `waitSeconds` " +
|
|
211
|
+
"plus a scanId to continue with marketplace_scan_status, which is free and worth " +
|
|
212
|
+
"repeating until the scan is done. " +
|
|
213
|
+
"For Amazon specifically, scan_amazon_category returns the same shape plus Amazon's own " +
|
|
214
|
+
"review-aspect counts.",
|
|
215
|
+
annotations: {
|
|
216
|
+
readOnlyHint: true,
|
|
217
|
+
destructiveHint: false,
|
|
218
|
+
idempotentHint: false,
|
|
219
|
+
openWorldHint: true,
|
|
220
|
+
},
|
|
221
|
+
outputSchema: OUTPUT_SCHEMAS.scan_marketplace_category,
|
|
222
|
+
inputSchema: z
|
|
223
|
+
.object({
|
|
224
|
+
marketplace: marketplaceArg,
|
|
225
|
+
query: z.string().optional().describe("Category or keyword to search, e.g. 'cast iron skillet'."),
|
|
226
|
+
items: z
|
|
227
|
+
.array(z.string())
|
|
228
|
+
.optional()
|
|
229
|
+
.describe(`Named competitors, by product id or URL. ${ID_HINT}`),
|
|
230
|
+
limit: z.number().int().optional().describe("Products to collect (default 10, max 50)."),
|
|
231
|
+
reviews: z
|
|
232
|
+
.boolean()
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("Collect review text too (default true). False is faster and much cheaper to run, " +
|
|
235
|
+
"but leaves only the aggregates."),
|
|
236
|
+
market: z
|
|
237
|
+
.string()
|
|
238
|
+
.optional()
|
|
239
|
+
.describe(`The storefront to read, for the sites that have more than one. ${MARKET_HINT}`),
|
|
240
|
+
domain: z
|
|
241
|
+
.string()
|
|
242
|
+
.optional()
|
|
243
|
+
.describe("Amazon only: the marketplace host, e.g. www.amazon.co.uk. Other sites use `market`."),
|
|
244
|
+
waitSeconds: z
|
|
245
|
+
.number()
|
|
246
|
+
.int()
|
|
247
|
+
.optional()
|
|
248
|
+
.describe("How long to wait before returning partial results (default 45, max 55). " +
|
|
249
|
+
"Capped under the 60s at which hosts abandon a tool call — asking for longer " +
|
|
250
|
+
"returns nothing and loses the scanId, not a bigger answer. " +
|
|
251
|
+
"Poll marketplace_scan_status for the rest instead; it is free and repeatable."),
|
|
252
|
+
focus: z
|
|
253
|
+
.string()
|
|
254
|
+
.optional()
|
|
255
|
+
.describe("An extra question to answer from the reviews, e.g. 'what do buyers say about sizing?'"),
|
|
256
|
+
})
|
|
257
|
+
.strict(),
|
|
258
|
+
}, async (args, extra) => {
|
|
259
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
260
|
+
const m = marketOf(args.marketplace);
|
|
261
|
+
if (!m)
|
|
262
|
+
return toolError("scan_marketplace_category failed", new Error(`unknown marketplace — one of ${SLUGS}`));
|
|
263
|
+
try {
|
|
264
|
+
// `focus` steers the guidance, not the collection — sending it upstream
|
|
265
|
+
// would be an argument the backend silently drops.
|
|
266
|
+
const { focus, ...upstream } = args;
|
|
267
|
+
// Priced and confirmed before the scan starts, on the same terms as
|
|
268
|
+
// the Amazon one: a started scan has already spent the expensive half.
|
|
269
|
+
const credits = scanCost(args);
|
|
270
|
+
const decision = await confirmSpend(server.server, {
|
|
271
|
+
credits,
|
|
272
|
+
summary: `Collect ${listingCount(args)} ${m.label} product(s)` +
|
|
273
|
+
(typeof args.query === "string" && args.query ? ` for "${args.query}"` : "") +
|
|
274
|
+
`, with their reviews.`,
|
|
275
|
+
cheaper: 'Lower "limit", or name the competitors you care about in "items".',
|
|
276
|
+
});
|
|
277
|
+
if (!decision.proceed) {
|
|
278
|
+
return declinedResult(credits, `Collecting ${listingCount(args)} ${m.label} product(s)`, 'Lower "limit", or name the competitors you care about in "items".');
|
|
279
|
+
}
|
|
280
|
+
const res = await client.callTool("scan_marketplace_category", upstream);
|
|
281
|
+
return marketplaceResult(structured(res), m, typeof focus === "string" ? focus : undefined);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
return toolError("scan_marketplace_category failed", err);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
server.registerTool("marketplace_scan_status", {
|
|
288
|
+
title: "Marketplace Scan Status",
|
|
289
|
+
_meta: viewMeta("marketplace_scan_status"),
|
|
290
|
+
description: "Pick up a scan_marketplace_category collection that was still running, by scanId and the " +
|
|
291
|
+
"same marketplace — the products and reviews collected since, with the same rollup and " +
|
|
292
|
+
"the same instructions for reading them. Free: it is the poll scan_marketplace_category " +
|
|
293
|
+
"asked for, and charging for the second half of one answer would bill a wait this server " +
|
|
294
|
+
"chose. Call it when a scan came back incomplete, then write your read from the full set.",
|
|
295
|
+
annotations: {
|
|
296
|
+
readOnlyHint: true,
|
|
297
|
+
destructiveHint: false,
|
|
298
|
+
idempotentHint: true,
|
|
299
|
+
openWorldHint: true,
|
|
300
|
+
},
|
|
301
|
+
outputSchema: OUTPUT_SCHEMAS.marketplace_scan_status,
|
|
302
|
+
inputSchema: z
|
|
303
|
+
.object({
|
|
304
|
+
marketplace: marketplaceArg,
|
|
305
|
+
scanId: z.string().describe("The scanId scan_marketplace_category returned."),
|
|
306
|
+
waitSeconds: z
|
|
307
|
+
.number()
|
|
308
|
+
.int()
|
|
309
|
+
.optional()
|
|
310
|
+
.describe("How long to wait for more products before answering (default 30, max 55). " +
|
|
311
|
+
"Call it again as often as you need rather than asking for one long wait."),
|
|
312
|
+
focus: z.string().optional().describe("Same optional steer as scan_marketplace_category."),
|
|
313
|
+
})
|
|
314
|
+
.strict(),
|
|
315
|
+
}, async (args, extra) => {
|
|
316
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
317
|
+
const m = marketOf(args.marketplace);
|
|
318
|
+
if (!m)
|
|
319
|
+
return toolError("marketplace_scan_status failed", new Error(`unknown marketplace — one of ${SLUGS}`));
|
|
320
|
+
try {
|
|
321
|
+
const { focus, ...upstream } = args;
|
|
322
|
+
const res = await client.callTool("marketplace_scan_status", upstream);
|
|
323
|
+
return marketplaceResult(structured(res), m, typeof focus === "string" ? focus : undefined);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
return toolError("marketplace_scan_status failed", err);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
server.registerTool("get_marketplace_product", {
|
|
330
|
+
title: "Get Marketplace Product",
|
|
331
|
+
_meta: viewMeta("get_marketplace_product"),
|
|
332
|
+
description: "Fetch one product from any of eleven marketplaces by id or URL: price, rating, the full " +
|
|
333
|
+
"star histogram, specs, and the review text. `marketplace` picks the site — one of " +
|
|
334
|
+
`${SLUGS}. ` +
|
|
335
|
+
"Costs 3 nooticr credits. " +
|
|
336
|
+
"Use for a single product; for a category or a competitive set, scan_marketplace_category " +
|
|
337
|
+
"collects many in one job and rolls them up.",
|
|
338
|
+
annotations: {
|
|
339
|
+
readOnlyHint: true,
|
|
340
|
+
destructiveHint: false,
|
|
341
|
+
idempotentHint: true,
|
|
342
|
+
openWorldHint: true,
|
|
343
|
+
},
|
|
344
|
+
outputSchema: OUTPUT_SCHEMAS.get_marketplace_product,
|
|
345
|
+
inputSchema: z
|
|
346
|
+
.object({
|
|
347
|
+
marketplace: marketplaceArg,
|
|
348
|
+
id: z.string().optional().describe(`The product id. ${ID_HINT}`),
|
|
349
|
+
url: z.string().optional().describe("The full product URL, if you have that instead."),
|
|
350
|
+
reviews: z.boolean().optional().describe("Collect review text too (default true)."),
|
|
351
|
+
market: z
|
|
352
|
+
.string()
|
|
353
|
+
.optional()
|
|
354
|
+
.describe(`The storefront the product is on, for sites that have more than one. ${MARKET_HINT}`),
|
|
355
|
+
domain: z
|
|
356
|
+
.string()
|
|
357
|
+
.optional()
|
|
358
|
+
.describe("Amazon only: the marketplace host. Other sites use `market`."),
|
|
359
|
+
})
|
|
360
|
+
.strict(),
|
|
361
|
+
}, async (args, extra) => {
|
|
362
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
363
|
+
const m = marketOf(args.marketplace);
|
|
364
|
+
if (!m)
|
|
365
|
+
return toolError("get_marketplace_product failed", new Error(`unknown marketplace — one of ${SLUGS}`));
|
|
366
|
+
try {
|
|
367
|
+
const res = await client.callTool("get_marketplace_product", args);
|
|
368
|
+
return marketplaceResult(structured(res), m);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
return toolError("get_marketplace_product failed", err);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
@@ -293,6 +293,16 @@ const amazonScanShape = {
|
|
|
293
293
|
errors: anyList(),
|
|
294
294
|
mcpCredits,
|
|
295
295
|
};
|
|
296
|
+
/**
|
|
297
|
+
* A marketplace scan: the Amazon shape with the storefront reported under one
|
|
298
|
+
* name. `domain` is Amazon's word for it and the others each have their own,
|
|
299
|
+
* so the generic tools answer with `market` whichever site ran.
|
|
300
|
+
*/
|
|
301
|
+
const marketplaceScanShape = {
|
|
302
|
+
...amazonScanShape,
|
|
303
|
+
market: scalar().describe("The storefront this ran against, however the site names it."),
|
|
304
|
+
};
|
|
305
|
+
const marketplaceScan = open(marketplaceScanShape);
|
|
296
306
|
const amazonScan = open(amazonScanShape);
|
|
297
307
|
export const OUTPUT_SCHEMAS = {
|
|
298
308
|
analyze_post: open({ ...analyzed, ...evidence }),
|
|
@@ -1378,6 +1388,17 @@ export const OUTPUT_SCHEMAS = {
|
|
|
1378
1388
|
// names for one payload and three places for the view to drift.
|
|
1379
1389
|
scan_amazon_category: amazonScan,
|
|
1380
1390
|
amazon_scan_status: amazonScan,
|
|
1391
|
+
// The same shape on ten more sites. `marketplace` says which one and
|
|
1392
|
+
// `market` which of its storefronts — the Amazon pair reports the storefront
|
|
1393
|
+
// as `domain`, because that is what Amazon calls it, and every other
|
|
1394
|
+
// collector names it differently again. One key here, so a caller reading
|
|
1395
|
+
// the result does not have to know which site it asked.
|
|
1396
|
+
scan_marketplace_category: marketplaceScan,
|
|
1397
|
+
marketplace_scan_status: marketplaceScan,
|
|
1398
|
+
get_marketplace_product: open({
|
|
1399
|
+
...marketplaceScanShape,
|
|
1400
|
+
product: amazonProduct.nullish().describe("The single product, also present as products[0]."),
|
|
1401
|
+
}),
|
|
1381
1402
|
get_amazon_product: open({
|
|
1382
1403
|
...amazonScanShape,
|
|
1383
1404
|
product: amazonProduct.nullish().describe("The single listing, also present as products[0]."),
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -432,6 +432,24 @@ export const TOOL_DEFINITIONS = [
|
|
|
432
432
|
description: "Fetch one Amazon listing by ASIN or URL: price, rating, the full star histogram, features, specs, Amazon's review digest and aspect breakdown, and the review text. Use for a single product; for a category or a competitive set, scan_amazon_category collects many in one job and rolls them up. Consumes 3 nooticr credits.",
|
|
433
433
|
inputSchema: z.object({ asin: z.string().optional().describe("ASIN, or a full Amazon product URL."), url: z.string().optional().describe("Full Amazon product URL, if you have that instead."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), domain: z.string().optional().describe("Marketplace host (default www.amazon.com).") }).strict(),
|
|
434
434
|
},
|
|
435
|
+
{
|
|
436
|
+
name: "scan_marketplace_category",
|
|
437
|
+
title: "Scan Marketplace Category",
|
|
438
|
+
description: "Collect a category from any of eleven marketplaces and read it: products with prices, star histograms, the review text itself, and the arithmetic over them (price spread, rating spread, which aspects recur across how many brands, and the share of each product's ratings sitting at 1-2 stars). marketplace picks the site — one of amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu. Pass query to search the category, items to pin named competitors by id or URL, or both. You write the read: purchase drivers, barriers, what the incumbents do well, the gaps, and how a new entrant could position. Collection runs in the background, so this returns everything ready within waitSeconds plus a scanId to continue with marketplace_scan_status — that is free, and repeating it is how a scan gets more time. For Amazon specifically, scan_amazon_category returns the same shape plus Amazon's own review-aspect counts. Consumes 3 nooticr credits per product collected — a product with reviews is a real browser render behind the site's bot defences.",
|
|
439
|
+
inputSchema: z.object({ marketplace: z.string().describe("Which site to read. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu."), query: z.string().optional().describe("Category or keyword to search, e.g. 'cast iron skillet'."), items: z.array(z.string()).optional().describe("Named competitors, by product id or URL."), limit: z.number().int().optional().describe("Products to collect (default 10, max 50)."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront to read, for the sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market."), waitSeconds: z.number().int().optional().describe("How long to wait before returning partial results (default 45, max 55). Poll marketplace_scan_status for the rest instead; it is free and repeatable."), focus: z.string().optional().describe("An extra question to answer from the reviews.") }).strict(),
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "marketplace_scan_status",
|
|
443
|
+
title: "Marketplace Scan Status",
|
|
444
|
+
description: "Pick up a scan_marketplace_category collection that was still running, by scanId and the same marketplace — the products and reviews collected since, with the same rollup and the same instructions for reading them. Free: it is the poll scan_marketplace_category asked for, and charging for the second half of one answer would bill a wait this server chose. Call it when a scan came back incomplete, then write your read from the full set.",
|
|
445
|
+
inputSchema: z.object({ marketplace: z.string().describe("The same site the scan ran against."), scanId: z.string().describe("The scanId scan_marketplace_category returned."), waitSeconds: z.number().int().optional().describe("How long to wait for more products before answering (default 30, max 55)."), focus: z.string().optional().describe("Same optional steer as scan_marketplace_category.") }).strict(),
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: "get_marketplace_product",
|
|
449
|
+
title: "Get Marketplace Product",
|
|
450
|
+
description: "Fetch one product from any of eleven marketplaces by id or URL: price, rating, the full star histogram, specs, and the review text. marketplace picks the site — one of amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu. Use for a single product; for a category or a competitive set, scan_marketplace_category collects many in one job and rolls them up. Consumes 3 nooticr credits.",
|
|
451
|
+
inputSchema: z.object({ marketplace: z.string().describe("Which site the product is on. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu."), id: z.string().optional().describe("The product id, in whatever form the site addresses products by."), url: z.string().optional().describe("The full product URL, if you have that instead."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront the product is on, for sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market.") }).strict(),
|
|
452
|
+
},
|
|
435
453
|
{
|
|
436
454
|
name: "show_amazon_category_insights",
|
|
437
455
|
title: "Show Amazon Category Insights",
|
package/dist/shared/tools.js
CHANGED
|
@@ -26,8 +26,9 @@ import { loadingPlansJson } from "./loading-plans.js";
|
|
|
26
26
|
import { registerHandoff } from "./handoff.js";
|
|
27
27
|
import { registerCollabTools } from "./collab.js";
|
|
28
28
|
import { registerAmazonTools } from "./amazon.js";
|
|
29
|
+
import { registerMarketplaceTools } from "./marketplace.js";
|
|
29
30
|
/** Current MCP server version — bumped on every deploy for traceability. */
|
|
30
|
-
export const MCP_SERVER_VERSION = "1.26.
|
|
31
|
+
export const MCP_SERVER_VERSION = "1.26.37";
|
|
31
32
|
/** MCP Apps extension identifier */
|
|
32
33
|
const UI_EXTENSION = "io.modelcontextprotocol/ui";
|
|
33
34
|
/** MIME type for MCP Apps HTML resources */
|
|
@@ -645,6 +646,12 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
645
646
|
"amazon_scan_status",
|
|
646
647
|
"get_amazon_product",
|
|
647
648
|
"show_amazon_category_insights",
|
|
649
|
+
// The marketplace-agnostic trio. Every name here gets a ui://nooticr/<tool>
|
|
650
|
+
// resource, which is what gives a tool its card — a tool missing from this
|
|
651
|
+
// list is one the host is told to render and then 404s on.
|
|
652
|
+
"scan_marketplace_category",
|
|
653
|
+
"marketplace_scan_status",
|
|
654
|
+
"get_marketplace_product",
|
|
648
655
|
];
|
|
649
656
|
// Human-readable resource name per tool (used in resources/list + tools/list).
|
|
650
657
|
function resourceName(tool) {
|
|
@@ -2387,5 +2394,10 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
2387
2394
|
// that picks it back up, a single listing, and the free view for the read
|
|
2388
2395
|
// the model writes out of them.
|
|
2389
2396
|
registerAmazonTools(server, makeClient);
|
|
2397
|
+
// The same three jobs on the other ten sites (marketplace.ts). Registered
|
|
2398
|
+
// separately because Amazon's collector returns its own review-aspect counts
|
|
2399
|
+
// and the generic one cannot, so the two descriptions are not the same
|
|
2400
|
+
// promise — not because the question differs.
|
|
2401
|
+
registerMarketplaceTools(server, makeClient);
|
|
2390
2402
|
return server;
|
|
2391
2403
|
}
|
|
@@ -51,7 +51,13 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
51
51
|
list_social_connections:"Connected Accounts",
|
|
52
52
|
connect_social_account:"Connect an Account",
|
|
53
53
|
create_product:"Add a Product",
|
|
54
|
-
update_product:"Update a Product"
|
|
54
|
+
update_product:"Update a Product",
|
|
55
|
+
scan_amazon_category:"Scan Amazon Category",
|
|
56
|
+
amazon_scan_status:"Amazon Scan Status",
|
|
57
|
+
get_amazon_product:"Get Amazon Product",
|
|
58
|
+
scan_marketplace_category:"Scan Marketplace Category",
|
|
59
|
+
marketplace_scan_status:"Marketplace Scan Status",
|
|
60
|
+
get_marketplace_product:"Get Marketplace Product"
|
|
55
61
|
};
|
|
56
62
|
var TOOL_DESCS={
|
|
57
63
|
analyze_post:"Frames from the post plus its transcript, for you to read yourself.",
|
|
@@ -74,7 +80,13 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
74
80
|
list_social_connections:"What you have connected, and what each connection is allowed to do.",
|
|
75
81
|
connect_social_account:"A link you open to connect one account. Nothing connects until you approve it.",
|
|
76
82
|
create_product:"Create a product in your own workspace — the row every other own-account tool needs.",
|
|
77
|
-
update_product:"Patch a product's fields. An omitted field keeps the value it had."
|
|
83
|
+
update_product:"Patch a product's fields. An omitted field keeps the value it had.",
|
|
84
|
+
scan_amazon_category:"A category on Amazon — prices, ratings and what reviewers keep raising.",
|
|
85
|
+
amazon_scan_status:"The listings an Amazon scan has collected so far.",
|
|
86
|
+
get_amazon_product:"One Amazon listing: price, rating, star histogram and reviews.",
|
|
87
|
+
scan_marketplace_category:"A category on any of eleven marketplaces — prices, ratings and review themes.",
|
|
88
|
+
marketplace_scan_status:"The products a marketplace scan has collected so far.",
|
|
89
|
+
get_marketplace_product:"One product from any marketplace: price, rating, histogram and reviews."
|
|
78
90
|
};
|
|
79
91
|
|
|
80
92
|
// ─── The mark ───
|
|
@@ -4037,6 +4049,148 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
4037
4049
|
renderAmazon();
|
|
4038
4050
|
}
|
|
4039
4051
|
|
|
4052
|
+
/* ------------------------------------------------------------------ */
|
|
4053
|
+
/* Marketplaces */
|
|
4054
|
+
/* ------------------------------------------------------------------ */
|
|
4055
|
+
|
|
4056
|
+
/* Eleven sites reach these tools through one contract, so one view draws
|
|
4057
|
+
them all; the slug only decides what the header is called. Before this
|
|
4058
|
+
every marketplace result fell through to the JSON dump at the bottom of
|
|
4059
|
+
renderView — the tools were callable from Claude and ChatGPT and had no
|
|
4060
|
+
interactive view at all. */
|
|
4061
|
+
var MARKET_LABELS={amazon:"Amazon",aliexpress:"AliExpress",cdiscount:"Cdiscount",
|
|
4062
|
+
flipkart:"Flipkart",mercadolibre:"Mercado Libre",lazada:"Lazada",otto:"Otto",
|
|
4063
|
+
rakuten:"Rakuten",trendyol:"Trendyol",jumia:"Jumia",temu:"Temu"};
|
|
4064
|
+
function mLabel(slug){return MARKET_LABELS[String(slug||"").toLowerCase()]||(slug?String(slug):"Marketplace");}
|
|
4065
|
+
|
|
4066
|
+
/* The raw scraper record is snake_case and the rollup's copy is camelCase.
|
|
4067
|
+
Both reach this view, so both spellings are read rather than picking one
|
|
4068
|
+
and quietly dropping half the fields. */
|
|
4069
|
+
function mGet(p,a,b){var v=p[a];if(v===undefined||v===null)v=p[b];return v;}
|
|
4070
|
+
|
|
4071
|
+
/* Display price. The "price" field is already formatted by the collector
|
|
4072
|
+
("EUR 19.99",
|
|
4073
|
+
"19,99 TL"), so it wins; the number is only formatted when there is no
|
|
4074
|
+
string. A product whose price was not scraped says so rather than
|
|
4075
|
+
printing a zero, which reads as free. */
|
|
4076
|
+
function mPrice(p){
|
|
4077
|
+
var s=mGet(p,"price","price");
|
|
4078
|
+
if(typeof s==="string"&&s.trim())return s.trim();
|
|
4079
|
+
var v=mGet(p,"price_value","priceValue");
|
|
4080
|
+
if(v===undefined||v===null||!isFinite(Number(v)))return "";
|
|
4081
|
+
var c=mGet(p,"currency","currency")||"";
|
|
4082
|
+
return (c?c+" ":"")+Number(v).toFixed(2);
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
function mStars(r){
|
|
4086
|
+
r=Number(r)||0;
|
|
4087
|
+
if(!r)return "";
|
|
4088
|
+
return '<span style="color:var(--amber,#F59E0B);font-weight:700">'+r.toFixed(1)+"</span>";
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
/* One product tile. The image is the reason this view exists rather than a
|
|
4092
|
+
table: a category reads as a shelf, and the collectors already return
|
|
4093
|
+
"images". */
|
|
4094
|
+
function mTile(p){
|
|
4095
|
+
var imgs=p.images;
|
|
4096
|
+
var img=(Array.isArray(imgs)&&imgs.length?imgs[0]:"")||p.image||p.imageUrl||p.image_url||"";
|
|
4097
|
+
var title=String(p.title||p.name||"").slice(0,120);
|
|
4098
|
+
var price=mPrice(p);
|
|
4099
|
+
var rating=Number(mGet(p,"rating","rating"))||0;
|
|
4100
|
+
var count=Number(mGet(p,"rating_count","ratingCount"))||0;
|
|
4101
|
+
var url=String(p.url||"");
|
|
4102
|
+
var seller=String(mGet(p,"brand","seller")||mGet(p,"seller","brand")||"");
|
|
4103
|
+
var media=img
|
|
4104
|
+
? '<img src="'+esc(img)+'" alt="" loading="lazy" referrerpolicy="no-referrer" style="width:100%;height:110px;object-fit:contain;background:var(--tag);display:block"/>'
|
|
4105
|
+
: '<div style="width:100%;height:110px;background:var(--tag)"></div>';
|
|
4106
|
+
var body='<div style="padding:8px 9px">'
|
|
4107
|
+
+'<div style="font-size:12px;line-height:1.35;font-weight:600;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:2.7em">'+esc(title)+"</div>"
|
|
4108
|
+
+(seller?'<div style="font-size:11px;color:var(--muted);margin-top:3px">'+esc(seller)+"</div>":"")
|
|
4109
|
+
+'<div style="display:flex;align-items:baseline;gap:6px;margin-top:6px">'
|
|
4110
|
+
+(price?'<span style="font-size:13.5px;font-weight:700">'+esc(price)+"</span>":'<span style="font-size:12px;color:var(--muted)">no price</span>')
|
|
4111
|
+
+"</div>"
|
|
4112
|
+
+(rating?'<div style="font-size:11.5px;color:var(--muted);margin-top:3px">'+mStars(rating)+(count?" · "+fmtNum(count)+" ratings":"")+"</div>":"")
|
|
4113
|
+
+"</div>";
|
|
4114
|
+
var inner=media+body;
|
|
4115
|
+
return '<div style="border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--bg-surface,transparent)">'
|
|
4116
|
+
+(url?'<a href="'+esc(url)+'" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none;display:block">'+inner+"</a>":inner)
|
|
4117
|
+
+"</div>";
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
/* An aspect is coloured by whichever way its brands actually lean. A chip
|
|
4121
|
+
with no lean stays neutral rather than being guessed at, for the same
|
|
4122
|
+
reason the monitor view refuses a sentiment badge. */
|
|
4123
|
+
function mAspect(a){
|
|
4124
|
+
var pos=Number(a.positiveBrands)||0,neg=Number(a.negativeBrands)||0;
|
|
4125
|
+
var cls=pos>neg?"chip-positive":neg>pos?"chip-negative":"chip-neutral";
|
|
4126
|
+
return '<span class="chip '+cls+'">'+esc(String(a.name||""))+' <b>'+fmtNum(a.mentions||0)+"</b></span>";
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
function mStat(label,value){
|
|
4130
|
+
if(value===""||value===undefined||value===null)return "";
|
|
4131
|
+
return '<div><div style="font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">'+esc(label)+"</div>"
|
|
4132
|
+
+'<div style="font-size:15px;font-weight:700;margin-top:2px">'+value+"</div></div>";
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
function marketCard(d){
|
|
4136
|
+
var r=d.rollup||{};
|
|
4137
|
+
var single=d.product&&!Array.isArray(d.products&&d.products.length>1?d.products:null);
|
|
4138
|
+
/* Prefer the raw records: they carry the images, and the rollup's copy of
|
|
4139
|
+
each product exists for the arithmetic rather than for drawing. */
|
|
4140
|
+
var rows=(Array.isArray(d.products)&&d.products.length?d.products:(r.perProduct||[])).slice(0,24);
|
|
4141
|
+
var running=d.complete===false;
|
|
4142
|
+
var prog=d.progress||{};
|
|
4143
|
+
var done=Number(prog.done)||0,total=Number(prog.total)||0;
|
|
4144
|
+
var pct=total>0?Math.min(100,Math.round(done/total*100)):0;
|
|
4145
|
+
|
|
4146
|
+
var head='<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
|
4147
|
+
+'<span style="font-size:16px;font-weight:700">'+esc(mLabel(d.marketplace))+"</span>"
|
|
4148
|
+
+(d.query?'<span style="font-size:13px;color:var(--muted)">'+esc(String(d.query))+"</span>":"")
|
|
4149
|
+
+(d.market?'<span class="chip chip-neutral">'+esc(String(d.market))+"</span>":"")
|
|
4150
|
+
+(running?'<span class="chip chip-neutral">collecting</span>':"")
|
|
4151
|
+
+"</div>";
|
|
4152
|
+
|
|
4153
|
+
var bar=running&&total>0
|
|
4154
|
+
? '<div style="margin-top:8px"><div style="height:4px;border-radius:999px;background:var(--tag);overflow:hidden">'
|
|
4155
|
+
+'<div style="height:100%;width:'+pct+'%;background:var(--green,#16A34A)"></div></div>'
|
|
4156
|
+
+'<div style="font-size:11.5px;color:var(--muted);margin-top:4px">'+done+" of "+total
|
|
4157
|
+
+(prog.message?" · "+esc(String(prog.message)):"")+"</div></div>"
|
|
4158
|
+
: "";
|
|
4159
|
+
|
|
4160
|
+
var price=r.price||{},rating=r.rating||{};
|
|
4161
|
+
var money=function(v){return (v===undefined||v===null)?"":Number(v).toFixed(2);};
|
|
4162
|
+
var stats='<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:12px">'
|
|
4163
|
+
+mStat("Products",fmtNum(r.products||rows.length||0))
|
|
4164
|
+
+mStat("Median price",money(price.median))
|
|
4165
|
+
+mStat("Rating",rating.mean?Number(rating.mean).toFixed(2):"")
|
|
4166
|
+
+mStat("Ratings behind it",r.ratingsRepresented?fmtNum(r.ratingsRepresented):"")
|
|
4167
|
+
+"</div>";
|
|
4168
|
+
|
|
4169
|
+
/* Both numbers, because they are not the same claim: a scrape reads a few
|
|
4170
|
+
dozen review texts while the stars average tens of thousands, and
|
|
4171
|
+
showing only the first invites reading it as the category's whole
|
|
4172
|
+
voice. */
|
|
4173
|
+
var reviews=r.reviewsCollected
|
|
4174
|
+
? '<div style="font-size:11.5px;color:var(--muted);margin-top:8px">'
|
|
4175
|
+
+fmtNum(r.reviewsCollected)+" review texts read"
|
|
4176
|
+
+(r.reviewsGatedProducts?" · "+r.reviewsGatedProducts+" products gated":"")+"</div>"
|
|
4177
|
+
: "";
|
|
4178
|
+
|
|
4179
|
+
var aspects=(r.aspects||[]).slice(0,10);
|
|
4180
|
+
var aspectHtml=aspects.length
|
|
4181
|
+
? '<div style="margin-top:14px"><div class="sec-label">What reviewers raise</div>'
|
|
4182
|
+
+'<div class="chiprow">'+aspects.map(mAspect).join("")+"</div></div>"
|
|
4183
|
+
: "";
|
|
4184
|
+
|
|
4185
|
+
var grid=rows.length
|
|
4186
|
+
? '<div style="margin-top:14px"><div class="sec-label">'+(single?"Product":"Products")+"</div>"
|
|
4187
|
+
+'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px">'
|
|
4188
|
+
+rows.map(mTile).join("")+"</div></div>"
|
|
4189
|
+
: '<div style="font-size:12.5px;color:var(--muted);margin-top:12px">Nothing collected yet.</div>';
|
|
4190
|
+
|
|
4191
|
+
return '<div class="card card-wide fade-in"><div class="card-body">'+head+bar+stats+reviews+aspectHtml+grid+"</div></div>";
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4040
4194
|
function renderView(result){
|
|
4041
4195
|
var app=document.getElementById("app");if(!app)return;
|
|
4042
4196
|
var d=extractResult(result);
|
|
@@ -4498,6 +4652,12 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
4498
4652
|
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:10px">'+mpIcon("share",16)+"<span>Repurposed</span></div>"
|
|
4499
4653
|
+versionsHtml+"</div></div>";return;}
|
|
4500
4654
|
|
|
4655
|
+
// A marketplace scan or a single product. Matched on "marketplace", which
|
|
4656
|
+
// every one of these results carries and nothing else here does — the
|
|
4657
|
+
// check has to come before the "d.platform" one below, because a scan's
|
|
4658
|
+
// raw product records carry a platform of their own.
|
|
4659
|
+
if(d.marketplace&&(d.rollup||d.product||Array.isArray(d.products))){
|
|
4660
|
+
app.innerHTML=marketCard(d);setTimeout(reportSize,50);return;}
|
|
4501
4661
|
// Single post
|
|
4502
4662
|
if(d.post||d.platform){app.innerHTML=postCard(d.post||d,true);return;}
|
|
4503
4663
|
// Fallback. The guidance field is prose written for the model, not data —
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nooticr/mcp",
|
|
3
3
|
"mcpName": "com.nooticr/mcp",
|
|
4
|
-
"version": "1.26.
|
|
4
|
+
"version": "1.26.37",
|
|
5
5
|
"description": "MCP server for nooticr - fetch, discover and understand TikTok/Instagram/YouTube/X/Reddit/LinkedIn/Weibo posts with AI (media metadata, niche discovery, brand monitoring, hook & viral analysis)",
|
|
6
6
|
"homepage": "https://mcp.nooticr.com",
|
|
7
7
|
"repository": {
|