Listing_SuggestPrice/backend/app/services/ebay_service.ts

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=100`,
{
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)
}
}