feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+292
View File
@@ -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(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/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(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/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(`![商品画像](${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<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) => [
`![価格推移](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<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
switch (name) {
case 'SearchAmazon':
return executeSearchAmazon(input, ctx);
default:
return null;
}
}