268 lines
9.3 KiB
TypeScript
268 lines
9.3 KiB
TypeScript
import type Product from '#models/product'
|
|
import type { EbayData } from '#models/history'
|
|
|
|
const EBAY_CONDITION_ID: Record<string, string> = {
|
|
NEW: '1000',
|
|
OPEN_BOX: '1500',
|
|
REFURBISHED: '2000',
|
|
USED: '3000',
|
|
FOR_PARTS: '7000',
|
|
}
|
|
|
|
/** Scope cơ bản — đủ cho Browse API (listing đang bán). */
|
|
const SCOPE_BASE = 'https://api.ebay.com/oauth/api_scope'
|
|
/**
|
|
* Scope cho Marketplace Insights API (listing đã bán).
|
|
* Lưu ý: đây là Limited Release — app phải được eBay duyệt mới được cấp scope này,
|
|
* nếu chưa duyệt thì token request trả invalid_scope / API trả 403.
|
|
*/
|
|
const SCOPE_INSIGHTS = 'https://api.ebay.com/oauth/api_scope/buy.marketplace.insights'
|
|
|
|
/**
|
|
* Lấy dữ liệu eBay: đã bán (sold) + đang bán (sale/active).
|
|
* Nếu chưa cấu hình OAuth thì trả về dữ liệu mock để hệ thống vẫn chạy.
|
|
*/
|
|
export default class EbayService {
|
|
static async getMarketData(product: Product): Promise<EbayData> {
|
|
const sku = product.sku?.trim()
|
|
const condition = this.normalizeCondition(product.condition)
|
|
|
|
if (!sku) {
|
|
return { sold: [], sale: [] }
|
|
}
|
|
|
|
const clientId = process.env.EBAY_CLIENT_ID
|
|
const clientSecret = process.env.EBAY_CLIENT_SECRET
|
|
const baseUrl = process.env.EBAY_BASE_URL || 'https://api.ebay.com'
|
|
const hasApiCreds = !!(clientId && clientSecret && baseUrl)
|
|
|
|
const conditionId = this.getConditionId(condition)
|
|
// Marketplace theo warehouse của product: US -> EBAY_US, còn lại -> EBAY_AU.
|
|
const marketplace = this.resolveMarketplace(product.warehouse)
|
|
|
|
// Hai nguồn độc lập: lỗi nguồn này không được làm mất nguồn kia.
|
|
// - sale (Browse API): cần API creds; thiếu creds thì bỏ qua (->[]).
|
|
// - sold: mặc định scrape (không cần creds) hoặc Insights API tùy EBAY_SOLD_SOURCE.
|
|
const [sale, sold] = await Promise.all([
|
|
hasApiCreds
|
|
? this.tryFetch('sale', () =>
|
|
this.getAccessToken(clientId!, clientSecret!, baseUrl, SCOPE_BASE).then((token) =>
|
|
this.searchActiveListings(token, baseUrl, sku, conditionId, marketplace)
|
|
)
|
|
)
|
|
: Promise.resolve([] as Array<Record<string, any>>),
|
|
this.tryFetch('sold', () =>
|
|
this.getSoldListings(clientId, clientSecret, baseUrl, sku, conditionId, marketplace)
|
|
),
|
|
])
|
|
|
|
// Chỉ dùng mock khi KHÔNG có dữ liệu thật nào — giữ lại nguồn nào còn sống.
|
|
if (!sale.length && !sold.length) {
|
|
return this.buildMockData(sku, condition)
|
|
}
|
|
return { sale, sold }
|
|
}
|
|
|
|
/**
|
|
* Nguồn listing ĐÃ BÁN. Chọn theo env EBAY_SOLD_SOURCE:
|
|
* - 'scrape' (mặc định): scrape bằng Puppeteer (không cần quyền Insights API).
|
|
* - 'api': gọi Marketplace Insights API (cần Limited Release approval).
|
|
*/
|
|
private static async getSoldListings(
|
|
clientId: string | undefined,
|
|
clientSecret: string | undefined,
|
|
baseUrl: string,
|
|
sku: string,
|
|
conditionId: string,
|
|
marketplace: string
|
|
): Promise<Array<Record<string, any>>> {
|
|
const source = (process.env.EBAY_SOLD_SOURCE || 'scrape').toLowerCase()
|
|
|
|
if (source === 'api') {
|
|
if (!clientId || !clientSecret) throw new Error('Thiếu EBAY creds cho Insights API')
|
|
const token = await this.getAccessToken(clientId, clientSecret, baseUrl, SCOPE_INSIGHTS)
|
|
return this.searchSoldListings(token, baseUrl, sku, conditionId, marketplace)
|
|
}
|
|
|
|
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
|
|
return EbayScraperService.scrapeSold(sku, conditionId, marketplace)
|
|
}
|
|
|
|
/** Marketplace eBay theo warehouse: 'US' -> EBAY_US, mọi giá trị khác -> EBAY_AU. */
|
|
private static resolveMarketplace(warehouse?: string | null): string {
|
|
return (warehouse || '').trim().toUpperCase() === 'US' ? 'EBAY_US' : 'EBAY_AU'
|
|
}
|
|
|
|
/** Chạy 1 nguồn dữ liệu, nuốt lỗi và trả [] để không kéo sập nguồn còn lại. */
|
|
private static async tryFetch(
|
|
label: string,
|
|
fn: () => Promise<Array<Record<string, any>>>
|
|
): Promise<Array<Record<string, any>>> {
|
|
try {
|
|
return await fn()
|
|
} catch (error) {
|
|
console.warn(`eBay ${label} fetch failed:`, (error as Error).message)
|
|
return []
|
|
}
|
|
}
|
|
|
|
private static async getAccessToken(
|
|
clientId: string,
|
|
clientSecret: string,
|
|
baseUrl: string,
|
|
scope: string = SCOPE_BASE
|
|
): Promise<string> {
|
|
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
|
|
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/identity/v1/oauth2/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Basic ${credentials}`,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: `grant_type=client_credentials&scope=${encodeURIComponent(scope)}`,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
// 400 invalid_scope -> app chưa được cấp scope (vd Insights Limited Release).
|
|
const detail = await response.text().catch(() => '')
|
|
throw new Error(`eBay OAuth lỗi: ${response.status} ${detail}`.trim())
|
|
}
|
|
|
|
const data = (await response.json()) as { access_token?: string }
|
|
if (!data.access_token) {
|
|
throw new Error('eBay OAuth không trả về access token')
|
|
}
|
|
|
|
return data.access_token
|
|
}
|
|
|
|
private static async searchActiveListings(
|
|
token: string,
|
|
baseUrl: string,
|
|
sku: string,
|
|
conditionId: string,
|
|
marketplace: string
|
|
) {
|
|
const response = await fetch(
|
|
`${baseUrl.replace(/\/$/, '')}/buy/browse/v1/item_summary/search?q=${encodeURIComponent(sku)}&filter=conditionIds:{${conditionId}}&limit=50`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'X-EBAY-C-MARKETPLACE-ID': marketplace,
|
|
},
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`eBay Browse lỗi: ${response.status}`)
|
|
}
|
|
|
|
const data = (await response.json()) as { itemSummaries?: Array<Record<string, any>> }
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
|
|
return (data.itemSummaries || [])
|
|
.map((item) => {
|
|
const price = Number(item?.price?.value ?? item?.sellingStatus?.currentPrice?.value ?? item?.price)
|
|
if (!Number.isFinite(price)) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
date: today,
|
|
price,
|
|
source: 'ebay',
|
|
title: item?.title || sku,
|
|
itemId: item?.itemId,
|
|
}
|
|
})
|
|
.filter(Boolean) as Array<Record<string, any>>
|
|
}
|
|
|
|
private static async searchSoldListings(
|
|
token: string,
|
|
baseUrl: string,
|
|
sku: string,
|
|
conditionId: string,
|
|
marketplace: string
|
|
) {
|
|
const response = await fetch(
|
|
`${baseUrl.replace(/\/$/, '')}/buy/marketplace_insights/v1_beta/item_sales/search?q=${encodeURIComponent(sku)}&filter=conditionIds:{${conditionId}}&limit=50`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'X-EBAY-C-MARKETPLACE-ID': marketplace,
|
|
},
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`eBay Insights lỗi: ${response.status}`)
|
|
}
|
|
const data = (await response.json()) as { itemSales?: Array<Record<string, any>> }
|
|
|
|
return (data.itemSales || [])
|
|
.map((item) => {
|
|
const price = Number(item?.lastSoldPrice?.value ?? item?.price?.value ?? item?.price)
|
|
const date = typeof item?.lastSoldDate === 'string' ? item.lastSoldDate.slice(0, 10) : ''
|
|
if (!date || !Number.isFinite(price)) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
date,
|
|
price,
|
|
source: 'ebay',
|
|
title: item?.title || sku,
|
|
itemId: item?.itemId,
|
|
}
|
|
})
|
|
.filter(Boolean) as Array<Record<string, any>>
|
|
}
|
|
|
|
private static buildMockData(sku: string, condition: string): EbayData {
|
|
const basePrice = 80 + this.hashString(`${sku}:${condition}`) % 220
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
|
|
const sale = Array.from({ length: 6 }, (_, index) => ({
|
|
date: today,
|
|
price: Number((basePrice * (1 + index * 0.03)).toFixed(2)),
|
|
source: 'ebay-mock',
|
|
title: sku,
|
|
condition,
|
|
}))
|
|
|
|
const sold = Array.from({ length: 5 }, (_, index) => ({
|
|
date: new Date(Date.now() - index * 86400000).toISOString().slice(0, 10),
|
|
price: Number((basePrice * 0.9 * (1 + index * 0.02)).toFixed(2)),
|
|
source: 'ebay-mock',
|
|
title: sku,
|
|
condition,
|
|
}))
|
|
|
|
return { sale, sold }
|
|
}
|
|
|
|
private static normalizeCondition(condition?: string): string {
|
|
const normalized = (condition || 'USED').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
|
return normalized || 'USED'
|
|
}
|
|
|
|
private static getConditionId(condition: string): string {
|
|
if (condition.includes('OPEN')) return EBAY_CONDITION_ID.OPEN_BOX
|
|
if (condition.includes('REFURB')) return EBAY_CONDITION_ID.REFURBISHED
|
|
if (condition.includes('PART')) return EBAY_CONDITION_ID.FOR_PARTS
|
|
if (condition.includes('NEW') || condition.includes('NIB') || condition.includes('NOB')) return EBAY_CONDITION_ID.NEW
|
|
if (condition.includes('USE')) return EBAY_CONDITION_ID.USED
|
|
|
|
return EBAY_CONDITION_ID.USED
|
|
}
|
|
|
|
private static hashString(value: string): number {
|
|
let hash = 0
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash = (hash * 31 + value.charCodeAt(index)) | 0
|
|
}
|
|
return Math.abs(hash)
|
|
}
|
|
}
|