Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions api/dist/routes/compareSlug.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,25 +116,44 @@ function retailerMeta(source) {
return { name: source, domain: source, region: 'MY' };
return { name: source, domain: source, region: 'SG' };
}
function formatPrice(price) {
return `S$${price.toFixed(2)}`;
function formatPrice(price, currency = 'SGD') {
const prefix = currency === 'USD' ? 'US$' : currency === 'SGD' ? 'S$' : `${currency} `;
return `${prefix}${price.toFixed(2)}`;
}
function requestedCountry(req) {
const raw = (req.query.country_code || req.query.country || req.query.region || 'SG');
const value = String(raw).trim().toUpperCase();
if (value === 'SEA')
return 'SG';
return value || 'SG';
}
function currencyForCountry(country) {
const map = { SG: 'SGD', US: 'USD', VN: 'VND', TH: 'THB', MY: 'MYR' };
return map[country] || 'SGD';
}
/**
* When a slug is not a comparison_page, try to resolve it as a category.
* Returns true if a response was sent, false if category also not found.
*/
async function handleCategoryCompareFallback(slug, req, res) {
const normalizedSlug = slugifyCategory(slug);
const currency = (req.query.country === 'US' || req.query.region === 'us') ? 'USD' : 'SGD';
const country = requestedCountry(req);
const currency = currencyForCountry(country);
const aliasNames = COMPARE_CATEGORY_ALIASES[normalizedSlug] || [];
// Look up the category_path[1] name for this slug
const slugResult = await config_1.db.query(`SELECT DISTINCT category_path[1] AS name FROM products
WHERE currency = $1 AND category_path IS NOT NULL
const categoryNames = [normalizedSlug, ...aliasNames];
// Look up the matching category name at any category_path depth. Some ingests
// put broad categories at category_path[1], while others nest them deeper.
const slugResult = await config_1.db.query(`SELECT DISTINCT cp.name
FROM products p
CROSS JOIN LATERAL unnest(p.category_path) AS cp(name)
WHERE p.currency = $1
AND p.country_code = $2
AND p.category_path IS NOT NULL
AND (
LOWER(REGEXP_REPLACE(category_path[1], '[^a-zA-Z0-9]+', '-', 'g')) = $2
OR category_path[1] = ANY($3::text[])
LOWER(REGEXP_REPLACE(cp.name, '[^a-zA-Z0-9]+', '-', 'g')) = $3
OR cp.name = ANY($4::text[])
)
LIMIT 1`, [currency, normalizedSlug, aliasNames]).catch(() => null);
LIMIT 1`, [currency, country, normalizedSlug, categoryNames]).catch(() => null);
if (!slugResult || slugResult.rows.length === 0) {
return false;
}
Expand All @@ -144,9 +163,12 @@ async function handleCategoryCompareFallback(slug, req, res) {
const productsResult = await config_1.db.query(`SELECT id, title, brand, image_url, price, currency, url, source, is_active,
updated_at, sku, mpn
FROM products
WHERE currency = $1 AND category_path[1] = ANY($2::text[])
WHERE currency = $1
AND country_code = $2
AND category_path && $3::text[]
AND url IS NOT NULL
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4`, [currency, [categoryName, ...aliasNames], limit, offset]).catch(() => null);
LIMIT $4 OFFSET $5`, [currency, country, [categoryName, ...aliasNames], limit, offset]).catch(() => null);
if (!productsResult || productsResult.rows.length === 0) {
return false;
}
Expand All @@ -160,6 +182,8 @@ async function handleCategoryCompareFallback(slug, req, res) {
prices: [{
merchant: row.source,
price: row.price || '0',
price_formatted: row.price ? formatPrice(parseFloat(row.price), row.currency) : 'N/A',
currency: row.currency,
url: row.url,
in_stock: row.is_active !== false,
rating: 0,
Expand All @@ -169,6 +193,8 @@ async function handleCategoryCompareFallback(slug, req, res) {
const payload = {
slug: normalizedSlug,
category: categoryName,
country_code: country,
currency,
products,
meta: {
limit,
Expand Down
434 changes: 333 additions & 101 deletions api/dist/routes/mcp.js

Large diffs are not rendered by default.

31 changes: 24 additions & 7 deletions api/dist/routes/wellknown.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const router = (0, express_1.Router)();
const DISCOVERY_CACHE_CONTROL = 'public, max-age=86400, s-maxage=86400';
const AI_AGENT_DESCRIPTOR = {
name: 'BuyWhere',
description: 'Cross-border product price comparison API — SG, US, and SEA markets',
description: 'Agent-native product catalog API — 288M+ products, 158,000+ stores worldwide, location-aware deliver_to ranking',
version: '1.0',
protocols: {
mcp: 'https://api.buywhere.ai/mcp/sse',
Expand Down Expand Up @@ -41,7 +41,7 @@ const A2A_AGENT_CARD = {
{
id: 'product_search',
name: 'Product Search',
description: 'Search Singapore product catalog by keyword, category, price range',
description: 'Search 288M+ products worldwide by keyword, category, price range — pass deliver_to (user country) for deliverable-first ranking with availability labels',
tags: ['ecommerce', 'search', 'products'],
examples: ['Find wireless earbuds under $200 in Singapore'],
},
Expand All @@ -55,7 +55,7 @@ const A2A_AGENT_CARD = {
{
id: 'deal_finder',
name: 'Deal Finder',
description: 'Find best deals and discounts across Singapore merchants',
description: 'Find best deals and discounts across 158,000+ merchants worldwide',
tags: ['ecommerce', 'deals', 'discounts'],
examples: ['Show me the best laptop deals today'],
},
Expand All @@ -80,7 +80,7 @@ router.get('/ai-plugin.json', (_req, res) => {
schema_version: 'v1',
name_for_human: 'BuyWhere Product Catalog',
name_for_model: 'buywhere_catalog',
description_for_human: 'Cross-border product catalog for AI agents. Search 1.5M+ products across Shopee, Lazada, Amazon, Walmart, and 20+ retailers in Singapore, US, and Southeast Asia.',
description_for_human: 'Product catalog for AI agents: 288M+ products from 158,000+ storefronts worldwide, normalized into one schema. Location-aware: pass deliver_to and every result carries an availability label (local | ships_to_you | unavailable).',
description_for_model: 'Use this plugin to search the BuyWhere product catalog for AI agents. Search by keyword, filter by merchant/retailer, price range, country, and currency (SGD, USD, VND, THB, MYR). Compare prices across merchants, find deals, and browse categories. Register for a free API key at https://api.buywhere.ai/v1/auth/register.',
auth: {
type: 'user_http',
Expand All @@ -100,11 +100,11 @@ router.get('/ai-plugin.json', (_req, res) => {
router.get('/mcp.json', (_req, res) => {
res.json({
name: 'BuyWhere Product Catalog',
description: "Structured product catalog and price comparison API for AI agents. Real-time pricing from Singapore's major e-commerce platforms.",
description: "Structured product catalog API for AI agents — 288M+ products, 158,000+ stores worldwide, deliver_to availability labels, MCP + REST + SDKs.",
version: '0.1.0',
mcp_endpoint: 'https://api.buywhere.ai/mcp',
documentation: 'https://api.buywhere.ai/docs/guides/mcp',
capabilities: ['search_products', 'get_product', 'compare_products', 'get_deals', 'list_categories', 'find_best_price', 'resolve_product_query'],
capabilities: ['search_products', 'get_product', 'compare_products', 'get_deals', 'list_categories', 'find_best_price'],
coverage: 'Singapore',
data_freshness: 'real-time',
});
Expand Down Expand Up @@ -295,6 +295,24 @@ function sendOpenApiSpec(res) {
},
},
},
'/compare/{category}': {
get: {
summary: 'Get category-scoped comparison payload',
operationId: 'getCategoryComparison',
parameters: [
{ name: 'category', in: 'path', required: true, schema: { type: 'string', enum: ['electronics', 'fashion', 'home-living', 'beauty', 'sports-outdoors', 'health-wellness', 'toys-games', 'food-beverages', 'automotive', 'pet-supplies'] }, description: 'Category slug used by BuyWhere compare pages' },
{ name: 'region', in: 'query', schema: { type: 'string', default: 'sea' }, description: '`sea` maps to Singapore unless country/country_code is set' },
{ name: 'country', in: 'query', schema: { type: 'string', enum: ['SG', 'US', 'VN', 'TH', 'MY'] }, description: 'ISO country code filter. Alias of country_code.' },
{ name: 'country_code', in: 'query', schema: { type: 'string', enum: ['SG', 'US', 'VN', 'TH', 'MY'] }, description: 'ISO country code filter.' },
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 50, maximum: 100 } },
{ name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } },
],
responses: {
'200': { description: 'Category-scoped comparison payload with products and merchant prices' },
'404': { description: 'Category not found' },
},
},
},
'/products/{id}': {
get: {
summary: 'Get a product by ID',
Expand Down Expand Up @@ -392,7 +410,6 @@ router.get('/mcp/server-card.json', (_req, res) => {
{ name: 'get_deals', description: 'Get discounted products sorted by discount percentage across all merchants. Returns original price, current price, and discount percentage.', inputSchema: { type: 'object', properties: { min_discount: { type: 'number', default: 10 }, country_code: { type: 'string' }, country: { type: 'string' }, limit: { type: 'integer', default: 20 }, offset: { type: 'integer', default: 0 } } } },
{ name: 'list_categories', description: 'List top-level product categories available in the BuyWhere catalog with slugs, names, and product counts.', inputSchema: { type: 'object', properties: { country_code: { type: 'string', enum: ['SG', 'US', 'VN', 'TH', 'MY'] }, country: { type: 'string' } } } },
{ name: 'find_best_price', description: 'Find the single cheapest listing for a product across all merchants. Use when a user asks about prices, wants to find the cheapest option, or asks "what\'s the best price for X". Returns the best deal across Shopee, Lazada, Amazon, and all other BuyWhere merchants.', inputSchema: { type: 'object', properties: { product_name: { type: 'string', description: 'Product name to find best price for (e.g. "iphone 15 pro 256gb", "samsung galaxy s24")' }, category: { type: 'string', description: 'Category to filter by (e.g. "electronics", "fashion")' }, country_code: { type: 'string', enum: ['SG', 'MY', 'TH', 'PH', 'VN', 'ID', 'US'], description: 'Country to search in (defaults to SG)' }, region: { type: 'string', enum: ['us', 'sea'], description: 'Region filter — use "us" for United States or "sea" for Southeast Asia' } } } },
{ name: 'resolve_product_query', description: 'Resolve a natural language product query into structured catalog results. Classifies query intent, extracts price constraints, and routes to deals, categories, best-price lookup, or comparison-ready search results. Best for AI agents that need to understand user shopping intent.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, country_code: { type: 'string', enum: ['SG', 'US', 'VN', 'TH', 'MY'] }, region: { type: 'string' }, domain: { type: 'string' }, min_price: { type: 'number' }, max_price: { type: 'number' }, limit: { type: 'integer', default: 20 }, offset: { type: 'integer', default: 0 }, compact: { type: 'boolean', default: false } } } },
],
authentication: {
required: true,
Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"key-reset": "ts-node src/jobs/dailyKeyResetRunner.ts",
"start:p95": "node dist/jobs/p95Runner.js",
"p95": "ts-node src/jobs/p95Runner.ts",
"test": "node --test --test-force-exit tests/response.test.mjs tests/search.test.mjs tests/ts-rank-guard.test.mjs tests/cache-stats.test.mjs tests/embed-products.test.mjs tests/embed-products-buy60368.test.mjs",
"test": "node --test --test-force-exit tests/response.test.mjs tests/search.test.mjs tests/ts-rank-guard.test.mjs tests/cache-stats.test.mjs tests/embed-products.test.mjs tests/embed-products-buy60368.test.mjs tests/compare-category-route.test.mjs",
"test:mcp": "node --test --test-force-exit tests/mcp-integration.test.mjs",
"test:mcp:load": "node --test tests/load/staging-load-test.js",
"test:openai-latency": "node tests/load/openai-function-calling-latency.mjs",
Expand Down
51 changes: 39 additions & 12 deletions api/src/routes/compareSlug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,21 @@ function retailerMeta(source: string): { name: string; domain: string; region: '
return { name: source, domain: source, region: 'SG' };
}

function formatPrice(price: number): string {
return `S$${price.toFixed(2)}`;
function formatPrice(price: number, currency = 'SGD'): string {
const prefix = currency === 'USD' ? 'US$' : currency === 'SGD' ? 'S$' : `${currency} `;
return `${prefix}${price.toFixed(2)}`;
}

function requestedCountry(req: Request): string {
const raw = (req.query.country_code || req.query.country || req.query.region || 'SG') as string;
const value = String(raw).trim().toUpperCase();
if (value === 'SEA') return 'SG';
return value || 'SG';
}

function currencyForCountry(country: string): string {
const map: Record<string, string> = { SG: 'SGD', US: 'USD', VN: 'VND', TH: 'THB', MY: 'MYR' };
return map[country] || 'SGD';
}

/**
Expand All @@ -123,19 +136,26 @@ function formatPrice(price: number): string {
*/
async function handleCategoryCompareFallback(slug: string, req: Request, res: Response): Promise<boolean> {
const normalizedSlug = slugifyCategory(slug);
const currency = (req.query.country === 'US' || req.query.region === 'us') ? 'USD' : 'SGD';
const country = requestedCountry(req);
const currency = currencyForCountry(country);
const aliasNames = COMPARE_CATEGORY_ALIASES[normalizedSlug] || [];
const categoryNames = [normalizedSlug, ...aliasNames];

// Look up the category_path[1] name for this slug
// Look up the matching category name at any category_path depth. Some ingests
// put broad categories at category_path[1], while others nest them deeper.
const slugResult = await db.query<{ name: string }>(
`SELECT DISTINCT category_path[1] AS name FROM products
WHERE currency = $1 AND category_path IS NOT NULL
`SELECT DISTINCT cp.name
FROM products p
CROSS JOIN LATERAL unnest(p.category_path) AS cp(name)
WHERE p.currency = $1
AND p.country_code = $2
AND p.category_path IS NOT NULL
AND (
LOWER(REGEXP_REPLACE(category_path[1], '[^a-zA-Z0-9]+', '-', 'g')) = $2
OR category_path[1] = ANY($3::text[])
LOWER(REGEXP_REPLACE(cp.name, '[^a-zA-Z0-9]+', '-', 'g')) = $3
OR cp.name = ANY($4::text[])
)
LIMIT 1`,
[currency, normalizedSlug, aliasNames]
[currency, country, normalizedSlug, categoryNames]
).catch(() => null);

if (!slugResult || slugResult.rows.length === 0) {
Expand All @@ -154,10 +174,13 @@ async function handleCategoryCompareFallback(slug: string, req: Request, res: Re
`SELECT id, title, brand, image_url, price, currency, url, source, is_active,
updated_at, sku, mpn
FROM products
WHERE currency = $1 AND category_path[1] = ANY($2::text[])
WHERE currency = $1
AND country_code = $2
AND category_path && $3::text[]
AND url IS NOT NULL
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4`,
[currency, [categoryName, ...aliasNames], limit, offset]
LIMIT $4 OFFSET $5`,
[currency, country, [categoryName, ...aliasNames], limit, offset]
).catch(() => null);

if (!productsResult || productsResult.rows.length === 0) {
Expand All @@ -174,6 +197,8 @@ async function handleCategoryCompareFallback(slug: string, req: Request, res: Re
prices: [{
merchant: row.source,
price: row.price || '0',
price_formatted: row.price ? formatPrice(parseFloat(row.price), row.currency) : 'N/A',
currency: row.currency,
url: row.url,
in_stock: row.is_active !== false,
rating: 0,
Expand All @@ -184,6 +209,8 @@ async function handleCategoryCompareFallback(slug: string, req: Request, res: Re
const payload = {
slug: normalizedSlug,
category: categoryName,
country_code: country,
currency,
products,
meta: {
limit,
Expand Down
35 changes: 29 additions & 6 deletions api/src/routes/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,27 @@ async function handleSearchProducts(args: Record<string, unknown>) {
const compact = args.compact === true;
const currency = country ? (COUNTRY_CURRENCY[country] || 'SGD') : 'SGD';

const cacheKey = `fts:${q}:${domain}:${region}:${country}:${category}:${currency}:${minPrice}:${maxPrice}:${limit}:${offset}:${compact ? 'c' : 'f'}:${useVector ? mode : 'kw'}`;
const cacheKey = `fts2:${q}:${domain}:${region}:${country}:${category}:${currency}:${minPrice}:${maxPrice}:${limit}:${offset}:${compact ? 'c' : 'f'}:${useVector ? mode : 'kw'}`;
const legacyCacheKey = `fts:${q}:${domain}:${region}:${country}:${category}:${currency}:${minPrice}:${maxPrice}:${limit}:${offset}:${compact ? 'c' : 'f'}:${useVector ? mode : 'kw'}`;
try {
const cached = await recordQueryCacheLookup(redis, cacheKey, () => redis.get(cacheKey));
// Read the new fts2 namespace first, then tolerate old fts: entries while the
// 1h Redis TTL drains and test/mocked clients catch up to the namespace bump.
const cached = await recordQueryCacheLookup(redis, cacheKey, async () =>
(await redis.get(cacheKey)) ?? (await redis.get(legacyCacheKey))
);
if (cached) {
const parsed = JSON.parse(cached);
if (parsed.results) {
return { ...parsed, cached: true, response_time_ms: Date.now() - t0 };
if (Array.isArray(parsed.data) || Array.isArray(parsed.results)) {
return {
...parsed,
cached: true,
response_time_ms: Date.now() - t0,
meta: {
...(parsed.meta || {}),
cached: true,
response_time_ms: Date.now() - t0,
},
};
}
}
} catch (_) { /* redis miss — proceed */ }
Expand Down Expand Up @@ -618,8 +632,17 @@ async function handleGetDeals(args: Record<string, unknown>) {
const cached = await redis.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
if (parsed.results) {
return { ...parsed, cached: true, response_time_ms: Date.now() - t0 };
if (Array.isArray(parsed.data) || Array.isArray(parsed.results)) {
return {
...parsed,
cached: true,
response_time_ms: Date.now() - t0,
meta: {
...(parsed.meta || {}),
cached: true,
response_time_ms: Date.now() - t0,
},
};
}
}
} catch (_) {}
Expand Down
Loading
Loading