feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { StructuredBlock, AmazonProductItem } from './structured-blocks.js';
|
||||
|
||||
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
||||
const SEARCH_TIMEOUT = 15_000;
|
||||
|
||||
const SEARCH_AMAZON_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'SearchAmazon',
|
||||
description: 'Amazon.co.jp で商品検索し、商品画像・価格・Keepa グラフ付き Markdown を返す(画像要素は省略せずそのまま最終回答に埋め込むこと)。詳細は ReadToolDoc({ name: "SearchAmazon" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '検索キーワード' },
|
||||
max_results: { type: 'number', description: '取得件数(デフォルト: 5, 最大: 10)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
SearchAmazon: SEARCH_AMAZON_DEF,
|
||||
};
|
||||
|
||||
interface AmazonProduct {
|
||||
asin: string;
|
||||
title: string;
|
||||
price?: string;
|
||||
rating?: string;
|
||||
reviewCount?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
async function fetchAmazonSearch(query: string): Promise<string> {
|
||||
const url = `https://www.amazon.co.jp/s?k=${encodeURIComponent(query)}&language=ja_JP`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept-Language': 'ja-JP,ja;q=0.9,en;q=0.8',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Amazon returned ${res.status}`);
|
||||
return await res.text();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function parseProducts(html: string, maxResults: number): AmazonProduct[] {
|
||||
const products: AmazonProduct[] = [];
|
||||
|
||||
// Match product containers: data-asin and data-component-type can appear in either order
|
||||
const blockRegex = /<div[^>]+data-asin="(B[A-Z0-9]{9})"[^>]+data-component-type="s-search-result"[^>]*>([\s\S]*?)(?=<div[^>]+data-asin="B[A-Z0-9]{9}"[^>]+data-component-type="s-search-result"|$)/gi;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Try the block approach first
|
||||
while ((match = blockRegex.exec(html)) !== null && products.length < maxResults) {
|
||||
const asin = match[1];
|
||||
const block = match[2];
|
||||
if (!asin || asin === 'undefined') continue;
|
||||
|
||||
const product: AmazonProduct = { asin, title: '' };
|
||||
|
||||
// Extract title: usually in <h2> <a> <span>
|
||||
const titleMatch = block.match(/<h2[^>]*>[\s\S]*?<span[^>]*>([\s\S]*?)<\/span>/i);
|
||||
if (titleMatch) {
|
||||
product.title = titleMatch[1].replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").trim();
|
||||
}
|
||||
|
||||
// Extract price: <span class="a-price">...<span class="a-offscreen">¥12,980</span>
|
||||
const priceMatch = block.match(/<span class="a-price"[^>]*>[\s\S]*?<span class="a-offscreen">([\s\S]*?)<\/span>/i);
|
||||
if (priceMatch) {
|
||||
product.price = priceMatch[1].replace(/<[^>]+>/g, '').trim();
|
||||
}
|
||||
|
||||
// Extract rating: <span class="a-icon-alt">5つ星のうち4.5</span>
|
||||
const ratingMatch = block.match(/<span class="a-icon-alt">([\d.]+つ星のうち[\d.]+)<\/span>/i)
|
||||
|| block.match(/(\d+(?:\.\d+)?)\s*つ星のうち/i);
|
||||
if (ratingMatch) {
|
||||
const rVal = ratingMatch[1].match(/(\d+(?:\.\d+)?)/);
|
||||
if (rVal) product.rating = rVal[1];
|
||||
}
|
||||
|
||||
// Extract review count
|
||||
const reviewMatch = block.match(/aria-label="([\d,]+)件の評価"/i)
|
||||
|| block.match(/<span[^>]*>([\d,]+)<\/span>\s*件の評価/i);
|
||||
if (reviewMatch) {
|
||||
product.reviewCount = reviewMatch[1];
|
||||
}
|
||||
|
||||
// Extract image URL
|
||||
const imgMatch = block.match(/<img[^>]+class="s-image"[^>]+src="([^"]+)"/i);
|
||||
if (imgMatch) {
|
||||
product.imageUrl = imgMatch[1];
|
||||
}
|
||||
|
||||
if (product.title) {
|
||||
products.push(product);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: simpler extraction if block approach found nothing
|
||||
if (products.length === 0) {
|
||||
const asinRegex = /data-asin="(B[A-Z0-9]{9})"/g;
|
||||
const asins = new Set<string>();
|
||||
let asinMatch: RegExpExecArray | null;
|
||||
while ((asinMatch = asinRegex.exec(html)) !== null) {
|
||||
asins.add(asinMatch[1]);
|
||||
}
|
||||
|
||||
for (const asin of asins) {
|
||||
if (products.length >= maxResults) break;
|
||||
|
||||
// Find the section around this ASIN
|
||||
const asinIdx = html.indexOf(`data-asin="${asin}"`);
|
||||
if (asinIdx === -1) continue;
|
||||
const section = html.slice(asinIdx, asinIdx + 5000);
|
||||
|
||||
const product: AmazonProduct = { asin, title: '' };
|
||||
|
||||
const titleMatch = section.match(/<h2[^>]*>[\s\S]*?<span[^>]*>([\s\S]*?)<\/span>/i);
|
||||
if (titleMatch) {
|
||||
product.title = titleMatch[1].replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").trim();
|
||||
}
|
||||
|
||||
const priceMatch = section.match(/<span class="a-offscreen">([¥¥][\d,]+)<\/span>/i);
|
||||
if (priceMatch) {
|
||||
product.price = priceMatch[1].trim();
|
||||
}
|
||||
|
||||
const imgMatch = section.match(/<img[^>]+class="s-image"[^>]+src="([^"]+)"/i);
|
||||
if (imgMatch) {
|
||||
product.imageUrl = imgMatch[1];
|
||||
}
|
||||
|
||||
if (product.title) {
|
||||
products.push(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
function formatResults(
|
||||
products: AmazonProduct[],
|
||||
query: string,
|
||||
affiliateTag?: string,
|
||||
): string {
|
||||
if (products.length === 0) {
|
||||
return `Amazon.co.jp で「${query}」の検索結果が取得できませんでした。Amazon がアクセスをブロックしている可能性があります。BrowseWeb で https://www.amazon.co.jp/s?k=${encodeURIComponent(query)} にアクセスして手動で検索してください。`;
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`## Amazon.co.jp 検索結果: 「${query}」`,
|
||||
'',
|
||||
'> **注意**: 以下の出力には商品画像と Keepa 価格推移グラフが含まれています。画像要素(``)を省略せず、そのまま最終回答に含めてください。',
|
||||
'',
|
||||
];
|
||||
|
||||
for (let i = 0; i < products.length; i++) {
|
||||
const p = products[i];
|
||||
const productUrl = affiliateTag
|
||||
? `https://www.amazon.co.jp/dp/${p.asin}?tag=${affiliateTag}`
|
||||
: `https://www.amazon.co.jp/dp/${p.asin}`;
|
||||
const keepaUrl = `https://keepa.com/#!product/5-${p.asin}`;
|
||||
const keepaGraph = `https://graph.keepa.com/pricehistory.png?asin=${p.asin}&domain=co.jp`;
|
||||
|
||||
lines.push(`### ${i + 1}. ${p.title}`);
|
||||
lines.push('');
|
||||
if (p.imageUrl) lines.push(``);
|
||||
lines.push('');
|
||||
if (p.price) lines.push(`- **価格**: ${p.price}`);
|
||||
if (p.rating) lines.push(`- **評価**: ${p.rating}${p.reviewCount ? ` (${p.reviewCount}件)` : ''}`);
|
||||
lines.push(`- **ASIN**: ${p.asin}`);
|
||||
lines.push(`- **商品リンク**: ${productUrl}`);
|
||||
lines.push(`- **Keepa 価格推移**: [グラフを見る](${keepaUrl})`);
|
||||
lines.push('');
|
||||
lines.push(``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function executeSearchAmazon(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
const query = input['query'] as string;
|
||||
if (!query) {
|
||||
return { output: 'query is required', isError: true };
|
||||
}
|
||||
|
||||
const maxResults = Math.min(10, Math.max(1, typeof input['max_results'] === 'number' ? Math.floor(input['max_results']) : 5));
|
||||
const affiliateTag = ctx.toolsConfig?.amazonAffiliateTag;
|
||||
|
||||
try {
|
||||
logger.info(`[SearchAmazon] searching: ${query}`);
|
||||
const html = await fetchAmazonSearch(query);
|
||||
const products = parseProducts(html, maxResults);
|
||||
const output = formatResults(products, query, affiliateTag);
|
||||
|
||||
// 構造化データを生成
|
||||
const refId = `amazon-${Date.now()}`;
|
||||
const structuredBlocks: StructuredBlock[] = [{
|
||||
refId,
|
||||
type: 'amazon_products',
|
||||
title: `Amazon 検索結果: 「${query}」`,
|
||||
data: {
|
||||
query,
|
||||
products: products.map((p): AmazonProductItem => ({
|
||||
asin: p.asin,
|
||||
title: p.title,
|
||||
price: p.price,
|
||||
rating: p.rating ? parseFloat(p.rating) : undefined,
|
||||
reviewCount: p.reviewCount ? parseInt(p.reviewCount.replace(/,/g, ''), 10) : undefined,
|
||||
imageUrl: p.imageUrl,
|
||||
productUrl: affiliateTag
|
||||
? `https://www.amazon.co.jp/dp/${p.asin}?tag=${affiliateTag}`
|
||||
: `https://www.amazon.co.jp/dp/${p.asin}`,
|
||||
keepaGraphUrl: `https://graph.keepa.com/pricehistory.png?asin=${p.asin}&domain=co.jp`,
|
||||
keepaDetailUrl: `https://keepa.com/#!product/5-${p.asin}`,
|
||||
})),
|
||||
},
|
||||
}];
|
||||
|
||||
return { output: `${output}\n\n[[embed:${refId}]]`, isError: false, structuredBlocks };
|
||||
} catch (e) {
|
||||
const msg = (e as Error).name === 'AbortError'
|
||||
? `Amazon 検索がタイムアウトしました (${SEARCH_TIMEOUT / 1000}秒)`
|
||||
: `Amazon 検索に失敗しました: ${(e as Error).message}`;
|
||||
return { output: `${msg}\n\nBrowseWeb で https://www.amazon.co.jp/s?k=${encodeURIComponent(query)} にアクセスして手動で検索してください。`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* テキスト中の Amazon ASIN に対して Keepa 価格推移グラフが欠落していれば末尾に補完する。
|
||||
* LLM がツール出力から Keepa グラフを省略した場合のセーフティネット。
|
||||
*/
|
||||
export function ensureKeepaGraphs(text: string): string {
|
||||
const asinRegex = /amazon\.co\.jp\/dp\/(B[A-Z0-9]{9})/g;
|
||||
const asins: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = asinRegex.exec(text)) !== null) {
|
||||
if (!asins.includes(m[1])) asins.push(m[1]);
|
||||
}
|
||||
if (asins.length === 0) return text;
|
||||
|
||||
const missing = asins.filter(
|
||||
(asin) => !text.includes(`graph.keepa.com/pricehistory.png?asin=${asin}`),
|
||||
);
|
||||
if (missing.length === 0) return text;
|
||||
|
||||
const section = [
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### 価格推移 (Keepa)',
|
||||
'',
|
||||
...missing.flatMap((asin) => [
|
||||
``,
|
||||
`[Keepa で詳細を見る](https://keepa.com/#!product/5-${asin})`,
|
||||
'',
|
||||
]),
|
||||
];
|
||||
return text + section.join('\n');
|
||||
}
|
||||
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
switch (name) {
|
||||
case 'SearchAmazon':
|
||||
return executeSearchAmazon(input, ctx);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user