@nooticr/mcp 1.26.37 → 1.26.38

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.
@@ -29,11 +29,24 @@ export function normaliseProducts(raw) {
29
29
  return [];
30
30
  return raw.map((entry) => {
31
31
  const p = (entry ?? {});
32
+ // Resolved once: the reviews below are addressed by it too, and reading the
33
+ // raw `p.asin` there gave every review on every non-Amazon product the id
34
+ // "review::0", "review::1" — colliding across products, so a model citing
35
+ // one could not be pointed back at the right listing.
36
+ const id = String(p.asin ?? p.id ?? p.item_id ?? p.sku ?? p.product_id ?? "");
32
37
  const histogram = (p.rating_histogram ?? {});
33
38
  const reviews = Array.isArray(p.reviews) ? p.reviews : [];
34
39
  const aspects = Array.isArray(p.review_aspects) ? p.review_aspects : [];
35
40
  return {
36
- asin: String(p.asin ?? ""),
41
+ // The id this product is addressed by, whatever the site calls it.
42
+ //
43
+ // The field is named `asin` because Amazon was first and the view keys
44
+ // tiles, the review drill-down and the `perProduct` match on it. Left at
45
+ // "" for the other ten sites — which is what it was — every product in a
46
+ // Lazada or Otto scan shared one blank key: the tiles rendered, and not
47
+ // one of them could open its reviews, because the click looked up a
48
+ // product by an id every product had.
49
+ asin: id,
37
50
  url: String(p.url ?? (p.asin ? `https://www.amazon.com/dp/${p.asin}` : "")),
38
51
  title: String(p.title ?? ""),
39
52
  brand: String(p.brand ?? ""),
@@ -68,8 +81,8 @@ export function normaliseProducts(raw) {
68
81
  // Addressable, so the model's analysis can point back at the exact
69
82
  // review it read a driver or a barrier out of. Same shape
70
83
  // analyze_comments mints for a comment.
71
- id: String(row.id ?? `review:${String(p.asin ?? "")}:${i}`),
72
- asin: String(p.asin ?? ""),
84
+ id: String(row.id ?? `review:${id}:${i}`),
85
+ asin: id,
73
86
  brand: String(p.brand ?? ""),
74
87
  author: String(row.author ?? ""),
75
88
  rating: num(row.rating),
@@ -28,7 +28,7 @@ import { registerCollabTools } from "./collab.js";
28
28
  import { registerAmazonTools } from "./amazon.js";
29
29
  import { registerMarketplaceTools } from "./marketplace.js";
30
30
  /** Current MCP server version — bumped on every deploy for traceability. */
31
- export const MCP_SERVER_VERSION = "1.26.37";
31
+ export const MCP_SERVER_VERSION = "1.26.38";
32
32
  /** MCP Apps extension identifier */
33
33
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
34
34
  /** MIME type for MCP Apps HTML resources */
@@ -3616,16 +3616,59 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3616
3616
  +'<path fill="#FF9900" d="'+d+'"></path></svg>';
3617
3617
  }
3618
3618
 
3619
+ /**
3620
+ * Each site's own colours, for the mark beside the header.
3621
+ *
3622
+ * Initials on the brand's colour rather than its logo. Amazon's mark is here
3623
+ * because Font Awesome publishes the path under CC BY 4.0; the other ten do
3624
+ * not have one to hand, and drawing a brand's wordmark from memory produces
3625
+ * a wrong logo rather than no logo. An <img> is not an option either: this
3626
+ * view is one self-contained file in a sandboxed iframe with no remote
3627
+ * fetches, so every asset is inline or it does not render.
3628
+ */
3629
+ var MARKET_BRAND={
3630
+ amazon:{label:"Amazon",color:"#FF9900",on:"#111"},
3631
+ aliexpress:{label:"AliExpress",color:"#E62E04",on:"#fff"},
3632
+ cdiscount:{label:"Cdiscount",color:"#C4161C",on:"#fff"},
3633
+ flipkart:{label:"Flipkart",color:"#2874F0",on:"#fff"},
3634
+ mercadolibre:{label:"Mercado Libre",color:"#FFE600",on:"#111"},
3635
+ lazada:{label:"Lazada",color:"#0F146D",on:"#fff"},
3636
+ otto:{label:"Otto",color:"#E4001B",on:"#fff"},
3637
+ rakuten:{label:"Rakuten",color:"#BF0000",on:"#fff"},
3638
+ trendyol:{label:"Trendyol",color:"#F27A1A",on:"#fff"},
3639
+ jumia:{label:"Jumia",color:"#F68B1E",on:"#111"},
3640
+ temu:{label:"Temu",color:"#FB7701",on:"#fff"}
3641
+ };
3642
+
3643
+ /** What to call the site a payload came from. */
3644
+ function marketLabel(name){
3645
+ var b=MARKET_BRAND[String(name||"").toLowerCase()];
3646
+ return b?b.label:(name?String(name):"Marketplace");
3647
+ }
3648
+
3619
3649
  /** The mark for whichever marketplace a payload came from. */
3620
3650
  function marketplaceMark(name,size){
3621
- if(String(name||"").toLowerCase()==="amazon")return amazonMark(size);
3622
- return '<span class="mkt-generic" style="font-size:'+Math.round(size*0.7)+'px">'
3623
- +esc(String(name||"market").slice(0,2).toUpperCase())+"</span>";
3651
+ var key=String(name||"").toLowerCase();
3652
+ if(key==="amazon")return amazonMark(size);
3653
+ var b=MARKET_BRAND[key];
3654
+ var initials=(b?b.label:String(name||"market")).replace(/[^A-Za-z]/g,"").slice(0,2).toUpperCase();
3655
+ return '<span class="mkt-generic" style="display:inline-flex;align-items:center;justify-content:center;'
3656
+ +"width:"+size+"px;height:"+size+"px;border-radius:5px;font-weight:800;line-height:1;"
3657
+ +"background:"+(b?b.color:"#6B7280")+";color:"+(b?b.on:"#fff")+";"
3658
+ +'font-size:'+Math.round(size*0.44)+'px" aria-label="'+esc(marketLabel(name))+'" role="img">'
3659
+ +esc(initials)+"</span>";
3624
3660
  }
3625
3661
 
3626
- /** "www.amazon.co.uk" reads as a hostname; "amazon.co.uk" reads as a market. */
3662
+ /**
3663
+ * "www.amazon.co.uk" reads as a hostname; "amazon.co.uk" reads as a market.
3664
+ *
3665
+ * Empty rather than "amazon.com" when a site did not report one: printing
3666
+ * Amazon's default under a Lazada scan states a storefront the scan never
3667
+ * ran against.
3668
+ */
3627
3669
  function marketDomain(domain){
3628
- var d=String(domain||"www.amazon.com");
3670
+ var d=String(domain||"");
3671
+ if(!d)return "";
3629
3672
  return d.indexOf("www.")===0?d.slice(4):d;
3630
3673
  }
3631
3674
 
@@ -3713,7 +3756,9 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3713
3756
  var open=amazonState.open===String(p.asin||"");
3714
3757
  var neg=null;
3715
3758
  (((amazonState.rollup||{}).perProduct)||[]).forEach(function(r){
3716
- if(String(r.asin||"")===String(p.asin||""))neg=r.negativeStarShare;
3759
+ // The Amazon rollup keys each row by "asin" and the generic one by "id";
3760
+ // reading only the first left every other site without its 1-2 star share.
3761
+ if(String(r.asin||r.id||"")===String(p.asin||""))neg=r.negativeStarShare;
3717
3762
  });
3718
3763
  return '<button type="button" class="amz-tile'+(open?" amz-tile-open":"")+'" data-amz-open="'+esc(p.asin||"")+'">'
3719
3764
  +productThumb(p)
@@ -4033,7 +4078,10 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
4033
4078
  :((insights&&amazonState)?(amazonState.rollup||{}):{});
4034
4079
  amazonState={
4035
4080
  marketplace:String(d.marketplace||"amazon"),
4036
- domain:String(d.domain||(amazonState&&amazonState.domain)||"www.amazon.com"),
4081
+ // "domain" is Amazon's word for the storefront and "market" is what the
4082
+ // generic collectors answer with. Defaulting to www.amazon.com meant a
4083
+ // Lazada scan's header read "amazon.com".
4084
+ domain:String(d.domain||d.market||(amazonState&&amazonState.domain)||""),
4037
4085
  title:insights?String(d.category||"Category read"):String(d.query||"Category scan"),
4038
4086
  products:carried,
4039
4087
  rollup:rollup,
@@ -4049,148 +4097,6 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
4049
4097
  renderAmazon();
4050
4098
  }
4051
4099
 
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
-
4194
4100
  function renderView(result){
4195
4101
  var app=document.getElementById("app");if(!app)return;
4196
4102
  var d=extractResult(result);
@@ -4202,7 +4108,7 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
4202
4108
  // distinctive — it names its marketplace — and because an insights payload
4203
4109
  // carries a products array of listings, not posts, which the gallery below
4204
4110
  // would happily draw as blank cards.
4205
- if(String(d.marketplace||"").toLowerCase()==="amazon"){renderMarketplace(d);return;}
4111
+ if(d.marketplace){renderMarketplace(d);return;}
4206
4112
 
4207
4113
  // Own account. Checked before the feeds below because all three shapes are
4208
4114
  // distinctive and none of them carries posts, threads or an analysis.
@@ -4652,12 +4558,6 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
4652
4558
  +'<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>"
4653
4559
  +versionsHtml+"</div></div>";return;}
4654
4560
 
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;}
4661
4561
  // Single post
4662
4562
  if(d.post||d.platform){app.innerHTML=postCard(d.post||d,true);return;}
4663
4563
  // 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.37",
4
+ "version": "1.26.38",
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": {