feat: 이베이 부품 AI 리스팅 툴 — 실제 크롤링·AI·가격 모듈 구현
[핵심 모듈 (lib/ebay-tools/)] - types.ts: 검색 요청/결과/크롤링/가격 공통 타입 정의 - crawler.ts: RockAuto HTTP 크롤러 + eBay 검색 (cheerio, UA 로테이션) - ai-analyzer.ts: Claude API Tool Use로 크롤링 결과 구조화 (lazy 클라이언트, 런타임 검증) - pricing.ts: 환율 API 연동 + HS Code 관세 + VAT + 소액면세 계산 [검색 API] - Mock 데이터 → 실제 크롤링+AI+가격 파이프라인으로 교체 - AI 실패 시 fallback 결과 생성 - 입력값 50자 제한 + 허용 문자 검증 [프론트엔드] - 중복 타입 제거 → lib/ebay-tools/types import - 가격 탭에 VAT, 총 수입비용, 면세 여부, 면책 문구 추가 [DB] - 004_ebay_search_history.sql: 검색 이력 테이블 + RLS (anon 전체 권한 제거) [Evaluator 반영] - anon RLS 보안 취약점 수정 - AI 응답 런타임 필드 검증 추가 - Anthropic 클라이언트 lazy 초기화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
133
lib/ebay-tools/crawler.ts
Normal file
133
lib/ebay-tools/crawler.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import type { CrawlResult } from './types';
|
||||
|
||||
// 크롤러 설정
|
||||
const CRAWL_CONFIG = {
|
||||
rockAuto: {
|
||||
baseUrl: 'https://www.rockauto.com',
|
||||
searchUrl: 'https://www.rockauto.com/en/partsearch/',
|
||||
type: 'http' as const,
|
||||
rateLimit: 3000, // ms between requests
|
||||
},
|
||||
// 향후 사이트 추가
|
||||
};
|
||||
|
||||
// User-Agent 로테이션
|
||||
const USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',
|
||||
];
|
||||
|
||||
function getRandomUA() {
|
||||
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
||||
}
|
||||
|
||||
// 딜레이 함수
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// HTTP 기반 크롤러
|
||||
async function fetchPage(url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': getRandomUA(),
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// RockAuto 검색
|
||||
export async function crawlRockAuto(partNumber: string): Promise<CrawlResult> {
|
||||
try {
|
||||
const searchUrl = `${CRAWL_CONFIG.rockAuto.searchUrl}?partnum=${encodeURIComponent(partNumber)}`;
|
||||
const html = await fetchPage(searchUrl);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// RockAuto 검색 결과 파싱
|
||||
// MVP: 기본 구조만 추출 (실제 셀렉터는 사이트 구조에 따라 조정 필요)
|
||||
const results: Record<string, unknown> = {
|
||||
searchUrl,
|
||||
title: $('title').text().trim(),
|
||||
// 부품 정보 추출 시도
|
||||
parts: [] as Array<Record<string, string>>,
|
||||
};
|
||||
|
||||
// 검색 결과에서 부품 정보 추출 시도
|
||||
// RockAuto의 실제 DOM 구조에 맞게 셀렉터 조정 필요
|
||||
const partsList: Array<Record<string, string>> = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
$('[id^="vPRD"]').each((_: number, el: any) => {
|
||||
const $el = $(el);
|
||||
partsList.push({
|
||||
name: $el.find('.listing-text-row-moreinfo-truck').text().trim() || $el.text().trim().slice(0, 100),
|
||||
price: $el.find('.listing-final-price').text().trim(),
|
||||
brand: $el.find('.listing-text-row-moreinfo-pair .listing-text-row-moreinfo-value').first().text().trim(),
|
||||
});
|
||||
});
|
||||
results.parts = partsList;
|
||||
|
||||
// 페이지 전체 텍스트도 보관 (AI 분석용)
|
||||
results.pageText = $('body').text().replace(/\s+/g, ' ').trim().slice(0, 5000);
|
||||
|
||||
return { source: 'RockAuto', success: true, data: results };
|
||||
} catch (error) {
|
||||
return {
|
||||
source: 'RockAuto',
|
||||
success: false,
|
||||
data: {},
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// eBay 검색 (공식 API — MVP에서는 간소화된 Browse API 호출)
|
||||
export async function searchEbay(partNumber: string): Promise<CrawlResult> {
|
||||
try {
|
||||
// eBay Browse API (인증 필요 — MVP에서는 비인증 검색)
|
||||
// 실제 구현 시 OAuth 토큰 필요
|
||||
const searchUrl = `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(partNumber)}&_sacat=6028`;
|
||||
const html = await fetchPage(searchUrl);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const listings: Array<Record<string, string>> = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
$('.s-item').slice(0, 5).each((_: number, el: any) => {
|
||||
const $el = $(el);
|
||||
listings.push({
|
||||
title: $el.find('.s-item__title').text().trim(),
|
||||
price: $el.find('.s-item__price').text().trim(),
|
||||
url: $el.find('.s-item__link').attr('href') || '',
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'eBay',
|
||||
success: true,
|
||||
data: { listings, searchUrl, pageText: $('body').text().replace(/\s+/g, ' ').trim().slice(0, 3000) },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
source: 'eBay',
|
||||
success: false,
|
||||
data: {},
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 크롤링 오케스트레이터
|
||||
export async function crawlAll(partNumber: string): Promise<CrawlResult[]> {
|
||||
const results: CrawlResult[] = [];
|
||||
|
||||
// 순차 실행 (rate limiting 준수)
|
||||
results.push(await crawlRockAuto(partNumber));
|
||||
await delay(CRAWL_CONFIG.rockAuto.rateLimit);
|
||||
results.push(await searchEbay(partNumber));
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user