294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
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>
|
|
// The actual rating is the number AFTER のうち; the one before is the scale.
|
|
const ratingMatch = block.match(/<span class="a-icon-alt">[\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(/<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;
|
|
}
|
|
}
|