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 = { SearchAmazon: SEARCH_AMAZON_DEF, }; interface AmazonProduct { asin: string; title: string; price?: string; rating?: string; reviewCount?: string; imageUrl?: string; } async function fetchAmazonSearch(query: string): Promise { 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 = /]+data-asin="(B[A-Z0-9]{9})"[^>]+data-component-type="s-search-result"[^>]*>([\s\S]*?)(?=]+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

const titleMatch = block.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/span>/i); if (titleMatch) { product.title = titleMatch[1].replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").trim(); } // Extract price: ...¥12,980 const priceMatch = block.match(/]*>[\s\S]*?([\s\S]*?)<\/span>/i); if (priceMatch) { product.price = priceMatch[1].replace(/<[^>]+>/g, '').trim(); } // Extract rating: 5つ星のうち4.5 // The actual rating is the number AFTER のうち; the one before is the scale. const ratingMatch = block.match(/[\d.]+つ星のうち([\d.]+)<\/span>/i) || block.match(/つ星のうち\s*(\d+(?:\.\d+)?)/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(/]*>([\d,]+)<\/span>\s*件の評価/i); if (reviewMatch) { product.reviewCount = reviewMatch[1]; } // Extract image URL const imgMatch = block.match(/]+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(); 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(/]*>[\s\S]*?]*>([\s\S]*?)<\/span>/i); if (titleMatch) { product.title = titleMatch[1].replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").trim(); } const priceMatch = section.match(/([¥¥][\d,]+)<\/span>/i); if (priceMatch) { product.price = priceMatch[1].trim(); } const imgMatch = section.match(/]+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(`![商品画像](${p.imageUrl})`); 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(`![価格推移](${keepaGraph})`); lines.push(''); } return lines.join('\n'); } async function executeSearchAmazon( input: Record, ctx: ToolContext, ): Promise { 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) => [ `![価格推移](https://graph.keepa.com/pricehistory.png?asin=${asin}&domain=co.jp)`, `[Keepa で詳細を見る](https://keepa.com/#!product/5-${asin})`, '', ]), ]; return text + section.join('\n'); } export async function executeTool( name: string, input: Record, ctx: ToolContext, ): Promise { switch (name) { case 'SearchAmazon': return executeSearchAmazon(input, ctx); default: return null; } }