302 lines
11 KiB
TypeScript
302 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
|
|
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<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
|
|
}
|
|
}
|