import logger from '@adonisjs/core/services/logger' import type { Browser, Page } from 'puppeteer' /** 1 listing đã bán scrape được từ trang kết quả eBay. */ export interface ScrapedSoldItem { id: string link_detail: string title: string description: string condition_item: string price: number currencyID: string priceText: string date: string source: 'ebay-scrape' [k: string]: any } /** Map marketplace id -> domain eBay tương ứng để build URL search. */ const MARKETPLACE_DOMAIN: Record = { EBAY_US: 'www.ebay.com', EBAY_AU: 'www.ebay.com.au', EBAY_GB: 'www.ebay.co.uk', EBAY_DE: 'www.ebay.de', EBAY_CA: 'www.ebay.ca', } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) /** * Scrape listing ĐÃ BÁN (sold/completed) trên eBay bằng Puppeteer. * * Dùng khi không có quyền gọi Marketplace Insights API (Limited Release). * Browser được tái sử dụng (lazy singleton) để tiết kiệm tài nguyên khi * chạy batch; nhớ gọi `EbayScraperService.close()` khi tiến trình kết thúc * (command / worker) để giải phóng Chromium. */ export default class EbayScraperService { private static _browser: Browser | null = null private static _launching: Promise | null = null /** Lấy (hoặc khởi tạo) browser dùng chung. Tránh launch trùng khi gọi song song. */ private static async getBrowser(): Promise { if (this._browser?.connected) return this._browser if (this._launching) return this._launching this._launching = (async () => { const { default: puppeteer } = await import('puppeteer') const browser = await puppeteer.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--no-zygote', ], }) this._browser = browser this._launching = null return browser })() return this._launching } /** Đóng browser dùng chung (gọi khi command/worker tắt). */ static async close(): Promise { if (this._browser) { await this._browser.close().catch(() => { }) this._browser = null } } /** Build URL trang kết quả "đã bán" của eBay cho 1 sku + condition. */ private static buildSoldUrl(sku: string, conditionId: string | undefined, marketplace: string): string { const domain = MARKETPLACE_DOMAIN[marketplace] || 'www.ebay.com' const params = new URLSearchParams({ _nkw: sku, LH_Sold: '1', LH_Complete: '1', _ipg: '60', // items per page }) if (conditionId) params.set('LH_ItemCondition', conditionId) return `https://${domain}/sch/i.html?${params.toString()}` } /** * Scrape danh sách listing đã bán cho 1 SKU. * @param conditionId ID condition của eBay (1000/3000...) để lọc trên URL. * @param marketplace Marketplace eBay (EBAY_US/EBAY_AU...) -> chọn domain. */ static async scrapeSold( rawSku: string, conditionId?: string, marketplace: string = 'EBAY_AU' ): Promise { const sku = rawSku?.trim() if (!sku) return [] const url = this.buildSoldUrl(sku, conditionId, marketplace) const timeout = Number(process.env.EBAY_SCRAPE_TIMEOUT || 60000) const maxRetries = Number(process.env.EBAY_SCRAPE_RETRIES || 10) const browser = await this.getBrowser() let page: Page | null = null try { page = await browser.newPage() await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ) await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' }) // Ẩn dấu hiệu automation để giảm khả năng bị eBay chặn. await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }) }) // Tối ưu băng thông: chặn ảnh, font, media (KHÔNG chặn stylesheet/script // để trang search render đúng, tránh bị eBay trả "Error Page"). await page.setRequestInterception(true) page.on('request', (req) => { if (['image', 'font', 'media'].includes(req.resourceType())) req.abort() else req.continue() }) // QUAN TRỌNG: eBay chặn truy cập trực tiếp vào /sch/i.html (trả Error Page). // Phải vào homepage trước để lấy cookie/session, rồi mới điều hướng tới search. const origin = new URL(url).origin await page.goto(origin + '/', { waitUntil: 'domcontentloaded', timeout }).catch(() => { }) await wait(1500) await page.goto(url, { waitUntil: 'networkidle2', timeout }) // Chờ qua màn anti-bot / Error Page / chờ card xuất hiện. let retries = 0 while (retries < maxRetries) { const html = await this.safeGetContent(page) const blocked = html.includes('Checking your browser') || html.includes('Pardon Our Interruption') || html.includes('Something went wrong on our end') if (blocked) { // Bị chặn -> quay lại homepage rồi vào lại search. await page.goto(new URL(url).origin + '/', { waitUntil: 'domcontentloaded', timeout }).catch(() => { }) await wait(1500) await page.goto(url, { waitUntil: 'networkidle2', timeout }).catch(() => { }) retries++ continue } if (await page.$('li.s-card--horizontal, li.s-item, li.s-card')) break await wait(2000) retries++ } // Chỉ lấy text thô từ DOM; toàn bộ parse/quy đổi tiền tệ làm ở Node để dễ kiểm thử. const raw = await page.$$eval( 'li.s-card--horizontal, li.s-item, li.s-card', (nodes) => nodes.map((node) => { const linkEl: any = node.querySelector('div.su-image a') || node.querySelector('a.s-card__link, a.s-item__link, a.su-link') return { link_detail: linkEl && linkEl.href ? linkEl.href : '', listingId: node.getAttribute('data-listingid') || '', title: (node.querySelector('.s-card__title, .s-item__title')?.textContent || '') .replace(/New\s*listing/i, '') .trim(), condition_item: ( node.querySelector('.s-card__subtitle, .SECONDARY_INFO, .s-item__subtitle')?.textContent || '' ).trim(), caption: ( node.querySelector('.s-card__caption, .s-item__caption, .POSITIVE')?.textContent || '' ).trim(), priceText: (node.querySelector('.s-card__price, .s-item__price')?.textContent || '').trim(), } }) ) const targetCurrency = (process.env.EBAY_TARGET_CURRENCY || 'USD').toUpperCase() const fx = this.fxRates() let skippedFx = 0 const normalized = raw .map((it) => { // Bỏ qua dòng quảng cáo "Shop on eBay" / không phải listing đã bán. if (!/sold/i.test(it.caption)) return null const idMatch = it.link_detail.match(/\/itm\/(\d+)/) const id = idMatch ? idMatch[1] : it.listingId if (!id) return null if (!it.title?.includes(sku)) return null const { amount, currency } = this.parsePrice(it.priceText) if (!Number.isFinite(amount) || amount <= 0) return null // Quy đổi về target currency (eBay đổi tiền theo geo-IP nên có thể trả VND/AUD...). const cur = currency || targetCurrency const converted = this.convert(amount, cur, targetCurrency, fx) if (converted == null) { skippedFx++ return null } return { id, link_detail: it.link_detail, title: it.title || sku, description: it.title || '', condition_item: it.condition_item, price: Math.round(converted * 100) / 100, currencyID: targetCurrency, priceText: it.priceText, date: this.parseDate(it.caption), source: 'ebay-scrape' as const, } }) .filter(Boolean) as ScrapedSoldItem[] logger.info( { sku, count: normalized.length, skippedFx, targetCurrency, url }, 'eBay scrape sold xong' ) return normalized } catch (err) { logger.error({ err, url }, `eBay scrape lỗi cho SKU ${sku}`) return [] } finally { if (page) await page.close().catch(() => { }) } } /** Lấy content an toàn (page có thể đang điều hướng). */ private static async safeGetContent(page: Page): Promise { try { return await page.content() } catch { return '' } } /** Parse text ngày eBay (vd "Sold Mar 12, 2024") -> "YYYY-MM-DD"; lỗi thì rỗng. */ private static parseDate(text?: string): string { if (!text) return '' const m = text.match(/([A-Za-z]{3,}\.?\s+\d{1,2},?\s+\d{4})/) const cleaned = (m ? m[1] : text.replace(/sold\s*/i, '')).trim() const ts = Date.parse(cleaned) if (Number.isNaN(ts)) return '' return new Date(ts).toISOString().slice(0, 10) } /** Tách số tiền + mã tiền tệ từ text giá eBay (vd "AU $1,234.50", "2.702.130 VND"). */ private static parsePrice(text: string): { amount: number; currency: string } { const t = (text || '').toUpperCase() let currency = '' if (t.includes('VND') || text.includes('₫')) currency = 'VND' else if (t.includes('GBP') || text.includes('£')) currency = 'GBP' else if (t.includes('EUR') || text.includes('€')) currency = 'EUR' else if (t.includes('AUD') || t.includes('AU $') || t.includes('AU$')) currency = 'AUD' else if (t.includes('CAD') || t.includes('C $') || t.includes('C$')) currency = 'CAD' else if (t.includes('USD') || t.includes('US $') || t.includes('US$') || text.includes('$')) currency = 'USD' // Lấy số đầu tiên (eBay có thể hiện khoảng giá "x to y" -> lấy x). Comma = phân cách nghìn. const numMatch = text.replace(/,/g, '').match(/\d+(\.\d+)?/) const amount = numMatch ? Number(numMatch[0]) : NaN return { amount, currency } } /** Tỉ giá quy về USD (1 đơn vị tiền -> USD). Override qua env EBAY_FX_RATES (JSON). */ private static fxRates(): Record { const defaults: Record = { USD: 1, VND: 0.00004, AUD: 0.65, GBP: 1.27, EUR: 1.08, CAD: 0.73, } try { const override = process.env.EBAY_FX_RATES ? JSON.parse(process.env.EBAY_FX_RATES) : {} return { ...defaults, ...override } } catch { return defaults } } /** Quy đổi amount từ `from` sang `to`. Trả null nếu thiếu tỉ giá (để bỏ qua item). */ private static convert( amount: number, from: string, to: string, fx: Record ): number | null { if (from === to) return amount const rFrom = fx[from] const rTo = fx[to] if (!rFrom || !rTo) return null return (amount * rFrom) / rTo } }