Listing_SuggestPrice/backend/app/services/ebay_scraper_service.ts

301 lines
11 KiB
TypeScript

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<string, string> = {
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<Browser> | 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<Browser> {
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<void> {
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<ScrapedSoldItem[]> {
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
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<string> {
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<string, number> {
const defaults: Record<string, number> = {
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<string, number>
): number | null {
if (from === to) return amount
const rFrom = fx[from]
const rTo = fx[to]
if (!rFrom || !rTo) return null
return (amount * rFrom) / rTo
}
}