import type Product from '#models/product' import type { EbayData } from '#models/history' const EBAY_CONDITION_ID: Record = { 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 { 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>), 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>> { 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>> ): Promise>> { 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 { 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> } 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> } 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> } 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> } 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) } }