import crypto from "node:crypto"; import type { InsertRacketListing, RacketWatchRule } from "../drizzle/schema"; import * as db from "./db"; import { fetchWithTimeout } from "./_core/fetch"; export const MARKET_SOURCES = ["xianyu", "jd", "zhuanzhuan"] as const; export type MarketSource = typeof MARKET_SOURCES[number]; export const MARKET_SOURCE_LABELS: Record = { xianyu: "闲鱼", jd: "京东", zhuanzhuan: "转转", }; export const MARKET_CATEGORY_LABELS = { adult: "成人球拍", junior: "儿童球拍", competitive: "比赛拍", recreational: "娱乐拍", unknown: "未知", } as const; export type MarketCategory = keyof typeof MARKET_CATEGORY_LABELS; export const MARKET_CONDITION_LABELS = { brand_new: "全新", almost_new: "几乎全新", used_good: "正常使用", used_fair: "磕碰明显", unknown: "未知", } as const; export type MarketConditionLevel = keyof typeof MARKET_CONDITION_LABELS; export const MARKET_GRADE_LABELS = { high_value: "高性价比", standard: "标准价", overpriced: "偏高", pending_review: "待确认", } as const; export type MarketGradeLevel = keyof typeof MARKET_GRADE_LABELS; export type RawSourceListing = { source: MarketSource; sourceListingId: string; title: string; listingUrl: string; description?: string | null; imageUrl?: string | null; price: number; originalPrice?: number | null; sellerName?: string | null; location?: string | null; publishedAtRaw?: string | null; extra?: Record | null; }; export type SearchSourceResult = { source: MarketSource; query: string; ok: boolean; blocked: boolean; message: string; listings: RawSourceListing[]; }; export type MarketConfig = { defaultFeishuWebhook: string; refreshIntervalMinutes: number; repushDelta: number; sourceTimeoutMs: number; sourceRetryCount: number; xianyuCookie: string; xianyuUserAgent: string; jdCookie: string; jdUserAgent: string; zhuanzhuanCookie: string; zhuanzhuanUserAgent: string; zhuanzhuanSearchUrlTemplate: string; }; const XIANYU_API = "mtop.taobao.idlemtopsearch.pc.search"; const XIANYU_APP_KEY = "34839810"; const BRAND_ALIASES: Array<{ canonical: string; patterns: RegExp[] }> = [ { canonical: "Yonex", patterns: [/\byonex\b/i, /尤尼克斯/i, /yonex/i, /yy\b/i] }, { canonical: "Wilson", patterns: [/\bwilson\b/i, /威尔胜/i] }, { canonical: "Babolat", patterns: [/\bbabolat\b/i, /百保力/i] }, { canonical: "Head", patterns: [/\bhead\b/i, /海德/i] }, { canonical: "Tecnifibre", patterns: [/\btecnifibre\b/i, /泰克尼纤维/i, /tfight/i] }, { canonical: "Prince", patterns: [/\bprince\b/i, /王子/i] }, { canonical: "Dunlop", patterns: [/\bdunlop\b/i, /登禄普/i] }, { canonical: "Li-Ning", patterns: [/\bli-ning\b/i, /李宁/i] }, { canonical: "Kawasaki", patterns: [/\bkawasaki\b/i, /川崎/i] }, { canonical: "Teloon", patterns: [/\bteloon\b/i, /天龙/i] }, ]; const SERIES_PATTERNS: Array<{ series: string; pattern: RegExp }> = [ { series: "Ezone", pattern: /\bezone\b/i }, { series: "Vcore", pattern: /\bv-?core\b/i }, { series: "Percept", pattern: /\bpercept\b/i }, { series: "Blade", pattern: /\bblade\b/i }, { series: "Clash", pattern: /\bclash\b/i }, { series: "Ultra", pattern: /\bultra\b/i }, { series: "Pro Staff", pattern: /\bpro\s?staff\b/i }, { series: "Burn", pattern: /\bburn\b/i }, { series: "Pure Drive", pattern: /\bpure\s?drive\b/i }, { series: "Pure Aero", pattern: /\bpure\s?aero\b/i }, { series: "Pure Strike", pattern: /\bpure\s?strike\b/i }, { series: "Boom", pattern: /\bboom\b/i }, { series: "Radical", pattern: /\bradical\b/i }, { series: "Speed", pattern: /\bspeed\b/i }, { series: "Gravity", pattern: /\bgravity\b/i }, { series: "Extreme", pattern: /\bextreme\b/i }, { series: "TFight", pattern: /\btfight\b/i }, ]; const BRAND_TIER: Record = { Yonex: "elite", Wilson: "elite", Babolat: "elite", Head: "elite", Tecnifibre: "elite", Prince: "mainstream", Dunlop: "mainstream", "Li-Ning": "mainstream", Kawasaki: "value", Teloon: "value", }; const POSITIVE_RACKET_PATTERN = /(网球拍|球拍|tennis|racquet|racket)/i; const NEGATIVE_ACCESSORY_PATTERN = /(拍包|背包|线|拍线|手胶|减震器|护线|包邮补差|网球包|球线)/i; function normalizeWhitespace(value: string) { return value.replace(/\s+/g, " ").trim(); } function stripHtml(value: string) { return normalizeWhitespace(value.replace(/<[^>]+>/g, " ")); } function toNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const match = value.replace(/,/g, "").match(/(\d+(?:\.\d+)?)/); if (!match) return null; const parsed = Number.parseFloat(match[1]); return Number.isFinite(parsed) ? parsed : null; } if (value && typeof value === "object") { const obj = value as Record; return toNumber(obj.value ?? obj.price ?? obj.amount ?? obj.priceText); } return null; } function firstString(values: unknown[]) { for (const value of values) { if (typeof value === "string" && value.trim()) { return normalizeWhitespace(value); } } return null; } function findValuesByKey( node: unknown, matcher: RegExp, depth = 0, seen = new Set(), ): unknown[] { if (node == null || depth > 6) return []; if (typeof node !== "object") return []; if (seen.has(node)) return []; seen.add(node); const results: unknown[] = []; if (Array.isArray(node)) { for (const item of node) { results.push(...findValuesByKey(item, matcher, depth + 1, seen)); } return results; } for (const [key, value] of Object.entries(node as Record)) { if (matcher.test(key)) { results.push(value); } results.push(...findValuesByKey(value, matcher, depth + 1, seen)); } return results; } function collectObjectArrays(node: unknown, depth = 0, seen = new Set()): Array[]> { if (node == null || depth > 6) return []; if (typeof node !== "object") return []; if (seen.has(node)) return []; seen.add(node); const arrays: Array[]> = []; if (Array.isArray(node)) { if (node.length > 0 && node.every((item) => item && typeof item === "object" && !Array.isArray(item))) { arrays.push(node as Array>); } for (const item of node) { arrays.push(...collectObjectArrays(item, depth + 1, seen)); } return arrays; } for (const value of Object.values(node as Record)) { arrays.push(...collectObjectArrays(value, depth + 1, seen)); } return arrays; } function guessUrlFromValue(value: string | null, source: MarketSource, sourceListingId: string) { if (value && /^https?:\/\//i.test(value)) return value; if (value && value.startsWith("/")) { switch (source) { case "xianyu": return `https://www.goofish.com${value}`; case "jd": return `https://item.jd.com${value}`; case "zhuanzhuan": return `https://www.zhuanzhuan.com${value}`; } } switch (source) { case "xianyu": return `https://www.goofish.com/item?id=${encodeURIComponent(sourceListingId)}`; case "jd": return `https://item.jd.com/${encodeURIComponent(sourceListingId)}.html`; case "zhuanzhuan": return `https://www.zhuanzhuan.com/`; } } function looksLikeRacketTitle(title: string) { const normalized = normalizeWhitespace(title); return POSITIVE_RACKET_PATTERN.test(normalized) && !NEGATIVE_ACCESSORY_PATTERN.test(normalized); } export function detectBrand(text: string) { for (const item of BRAND_ALIASES) { if (item.patterns.some((pattern) => pattern.test(text))) { return item.canonical; } } return null; } export function detectSeries(text: string) { const hit = SERIES_PATTERNS.find((item) => item.pattern.test(text)); return hit?.series ?? null; } export function detectWeightGram(text: string) { const direct = text.match(/(?:^|[^0-9])([23]\d{2})(?:g|克)\b/i); if (direct) { const weight = Number.parseInt(direct[1], 10); if (weight >= 230 && weight <= 340) return weight; } const unstrung = text.match(/([23]\d{2})\s*\/?\s*(?:unstrung|裸拍)/i); if (unstrung) { const weight = Number.parseInt(unstrung[1], 10); if (weight >= 230 && weight <= 340) return weight; } return null; } export function detectConditionLevel(text: string): MarketConditionLevel { if (/(全新未拆|全新|仅拆封|吊牌)/i.test(text)) return "brand_new"; if (/(99新|98新|95新|几乎全新|微瑕|试打|试用)/i.test(text)) return "almost_new"; if (/(正常使用|二手|使用痕迹|轻微磕碰|轻微掉漆)/i.test(text)) return "used_good"; if (/(磕碰|掉漆|裂纹|修补|明显使用痕迹)/i.test(text)) return "used_fair"; return "unknown"; } export function detectCategory(text: string, weightGram: number | null): MarketCategory { if (/(junior|jr|儿童|青少年|25寸|26寸)/i.test(text)) return "junior"; if (/(pro|tour|比赛|竞技|98\b|97\b|95\b)/i.test(text) || (weightGram != null && weightGram >= 295)) { return "competitive"; } if (/(初学|入门|娱乐|练习)/i.test(text) || (weightGram != null && weightGram <= 285)) { return "recreational"; } if (POSITIVE_RACKET_PATTERN.test(text)) return "adult"; return "unknown"; } function deriveModel(text: string, brand: string | null, series: string | null) { let value = text; if (brand) { value = value.replace(new RegExp(brand, "ig"), " "); } if (series) { value = value.replace(new RegExp(series.replace(/\s+/g, "\\s*"), "ig"), " "); } value = value.replace(/(网球拍|球拍|tennis|racquet|racket|全新|二手|99新|95新|98新)/ig, " "); value = value.replace(/([23]\d{2})(?:g|克)/ig, " "); value = normalizeWhitespace(value); return value ? value.slice(0, 96) : null; } function fingerprintForListing(source: MarketSource, sourceListingId: string, title: string, price: number) { return crypto .createHash("sha1") .update(`${source}:${sourceListingId}:${title}:${price}`) .digest("hex"); } export function gradeRacketListing(input: { brand: string | null; price: number; conditionLevel: MarketConditionLevel; category: MarketCategory; weightGram: number | null; }) { const tier = input.brand ? (BRAND_TIER[input.brand] ?? "value") : "value"; const conditionBand = { brand_new: tier === "elite" ? 1200 : tier === "mainstream" ? 800 : 500, almost_new: tier === "elite" ? 900 : tier === "mainstream" ? 650 : 400, used_good: tier === "elite" ? 650 : tier === "mainstream" ? 480 : 280, used_fair: tier === "elite" ? 450 : tier === "mainstream" ? 320 : 200, unknown: tier === "elite" ? 700 : tier === "mainstream" ? 500 : 280, }[input.conditionLevel]; const standardBand = Math.round(conditionBand * 1.45); const reasons: string[] = []; if (input.brand) reasons.push(`品牌 ${input.brand}`); reasons.push(`成色 ${MARKET_CONDITION_LABELS[input.conditionLevel]}`); if (input.weightGram != null) reasons.push(`重量 ${Math.round(input.weightGram)}g`); if (input.category !== "unknown") reasons.push(MARKET_CATEGORY_LABELS[input.category]); let gradeLevel: MarketGradeLevel = "pending_review"; if (input.price <= conditionBand) { gradeLevel = "high_value"; reasons.push("当前价格落在低价区间"); } else if (input.price <= standardBand) { gradeLevel = "standard"; reasons.push("当前价格处于常见成交区间"); } else if (input.price > standardBand) { gradeLevel = "overpriced"; reasons.push("当前价格高于常见区间"); } return { gradeLevel, gradeReason: reasons.join(" · "), isLowPriceCandidate: gradeLevel === "high_value", }; } export function buildMarketSearchQuery(rule: Pick) { const parts = [ rule.brand, rule.seriesKeyword ?? "", rule.modelKeyword ?? "", rule.category === "junior" ? "儿童" : "", "网球拍", ]; return normalizeWhitespace(parts.filter(Boolean).join(" ")); } export function deriveWatchRuleTitle(input: { title?: string | null; brand: string; modelKeyword?: string | null; seriesKeyword?: string | null; targetPrice: number; }) { const trimmed = input.title?.trim(); if (trimmed) return trimmed; const core = normalizeWhitespace([input.brand, input.seriesKeyword ?? "", input.modelKeyword ?? ""].filter(Boolean).join(" ")); return `${core || input.brand} ≤ ¥${Math.round(input.targetPrice)}`; } export async function loadMarketConfig(): Promise { return { defaultFeishuWebhook: await db.getAppSettingValue("market_default_feishu_webhook", ""), refreshIntervalMinutes: await db.getAppSettingValue("market_watch_refresh_interval_minutes", 30), repushDelta: await db.getAppSettingValue("market_price_repush_delta", 20), sourceTimeoutMs: await db.getAppSettingValue("market_source_timeout_ms", 12000), sourceRetryCount: await db.getAppSettingValue("market_source_retry_count", 1), xianyuCookie: await db.getAppSettingValue("market_xianyu_cookie", ""), xianyuUserAgent: await db.getAppSettingValue( "market_xianyu_user_agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", ), jdCookie: await db.getAppSettingValue("market_jd_cookie", ""), jdUserAgent: await db.getAppSettingValue( "market_jd_user_agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", ), zhuanzhuanCookie: await db.getAppSettingValue("market_zhuanzhuan_cookie", ""), zhuanzhuanUserAgent: await db.getAppSettingValue( "market_zhuanzhuan_user_agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", ), zhuanzhuanSearchUrlTemplate: await db.getAppSettingValue("market_zhuanzhuan_search_url_template", ""), }; } function buildHeaders(userAgent: string, cookie?: string) { const headers: Record = { "accept-language": "zh-CN,zh;q=0.9", "content-type": "application/json", "user-agent": userAgent, }; if (cookie?.trim()) headers.cookie = cookie.trim(); return headers; } function pickFirstUrl(candidate: Record) { const url = firstString(findValuesByKey(candidate, /(url|href|jumpUrl|itemUrl|detailUrl)/i)); if (!url) return null; return url; } function pickImageUrl(candidate: Record) { const image = firstString(findValuesByKey(candidate, /(image|img|pic|cover)/i)); if (!image) return null; if (/^https?:\/\//i.test(image)) return image; if (image.startsWith("//")) return `https:${image}`; return image; } function pickSellerName(candidate: Record) { const keys = /(seller|nick|owner|userName|shopName|publisher|author)/i; return firstString(findValuesByKey(candidate, keys)); } function mapGenericCandidate(source: MarketSource, candidate: Record) { const sourceListingId = firstString(findValuesByKey(candidate, /^(itemId|id|spuId|wareId|item_id)$/i)); const title = firstString(findValuesByKey(candidate, /(title|itemTitle|name|summary|subject)/i)); const priceCandidates = findValuesByKey(candidate, /(price|amount|showPrice|salePrice|soldPrice)/i) .map((value) => toNumber(value)) .filter((value): value is number => value != null && value > 0); const price = priceCandidates[0] ?? null; if (!sourceListingId || !title || price == null || !looksLikeRacketTitle(title)) { return null; } const originalPrice = priceCandidates.find((value) => value > price) ?? null; const listingUrl = guessUrlFromValue(pickFirstUrl(candidate), source, sourceListingId); const sellerName = pickSellerName(candidate); const location = firstString(findValuesByKey(candidate, /(location|city|area|province|region)/i)); const publishedAtRaw = firstString(findValuesByKey(candidate, /(publish|createTime|time|publishAt|gmtCreate)/i)); return { source, sourceListingId, title, listingUrl, imageUrl: pickImageUrl(candidate), price, originalPrice, sellerName, location, publishedAtRaw, extra: candidate, } satisfies RawSourceListing; } function extractCandidatesFromPayload(source: MarketSource, payload: unknown) { const arrays = collectObjectArrays(payload); const mapped: RawSourceListing[] = []; for (const array of arrays) { for (const candidate of array) { const listing = mapGenericCandidate(source, candidate); if (listing) mapped.push(listing); } } const unique = new Map(); for (const item of mapped) { unique.set(`${item.source}:${item.sourceListingId}`, item); } return Array.from(unique.values()).slice(0, 30); } function parseJsonSafely(text: string) { try { return JSON.parse(text); } catch { return null; } } async function fetchJson(url: string, init: RequestInit, timeoutMs: number, retries: number) { const response = await fetchWithTimeout(url, init, { timeoutMs, retries, retryStatuses: [408, 425, 429, 500, 502, 503, 504], }); const text = await response.text(); return { response, text, json: parseJsonSafely(text), }; } async function searchXianyu(query: string, config: MarketConfig): Promise { const endpoint = "https://acs.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/"; const data = JSON.stringify({ pageNumber: 1, keyword: query, rowsPerPage: 30, searchReqFromPage: "pcSearch", }); const tokenMatch = config.xianyuCookie.match(/_m_h5_tk=([^_;]+)/); const t = Date.now().toString(); const sign = tokenMatch ? crypto.createHash("md5").update(`${tokenMatch[1]}&${t}&${XIANYU_APP_KEY}&${data}`).digest("hex") : ""; const url = `${endpoint}?jsv=2.7.2&appKey=${XIANYU_APP_KEY}&t=${t}&sign=${sign}&api=${XIANYU_API}&v=1.0&type=originaljson&dataType=json&timeout=20000&data=${encodeURIComponent(data)}`; const { response, text, json } = await fetchJson(url, { method: "GET", headers: buildHeaders(config.xianyuUserAgent, config.xianyuCookie), }, config.sourceTimeoutMs, config.sourceRetryCount); const blocked = response.status >= 400 || /RGV587_ERROR|FAIL_SYS_ILLEGAL_ACCESS|passport\.goofish\.com|被挤爆|安全校验/i.test(text); if (blocked) { return { source: "xianyu", query, ok: false, blocked: true, message: "闲鱼搜索接口返回登录或风控校验,需要补充有效 Cookie 或降低请求频率。", listings: [], }; } const listings = extractCandidatesFromPayload("xianyu", json); return { source: "xianyu", query, ok: true, blocked: false, message: listings.length > 0 ? `闲鱼返回 ${listings.length} 条结果` : "闲鱼接口成功但未提取到可用商品数据", listings, }; } async function searchJd(query: string, config: MarketConfig): Promise { const url = `https://so.m.jd.com/ware/searchList.action?_format_=json&keyword=${encodeURIComponent(query)}&page=1`; const { response, text, json } = await fetchJson(url, { method: "GET", headers: buildHeaders(config.jdUserAgent, config.jdCookie), }, config.sourceTimeoutMs, config.sourceRetryCount); const blocked = response.status >= 400 || /403 Forbidden|京东验证|risk_handler|JDR_shields/i.test(text); if (blocked) { return { source: "jd", query, ok: false, blocked: true, message: "京东搜索接口当前返回风控页或 403,需补充 Cookie 或降低抓取频率。", listings: [], }; } const listings = extractCandidatesFromPayload("jd", json); return { source: "jd", query, ok: true, blocked: false, message: listings.length > 0 ? `京东返回 ${listings.length} 条结果` : "京东接口成功但未提取到可用商品数据", listings, }; } async function searchZhuanzhuan(query: string, config: MarketConfig): Promise { if (!config.zhuanzhuanSearchUrlTemplate.includes("{query}")) { return { source: "zhuanzhuan", query, ok: false, blocked: false, message: "转转搜索 URL 模板尚未配置,当前来源处于待接线状态。", listings: [], }; } const url = config.zhuanzhuanSearchUrlTemplate.replace(/\{query\}/g, encodeURIComponent(query)); const { response, text, json } = await fetchJson(url, { method: "GET", headers: buildHeaders(config.zhuanzhuanUserAgent, config.zhuanzhuanCookie), }, config.sourceTimeoutMs, config.sourceRetryCount); if (response.status >= 400) { return { source: "zhuanzhuan", query, ok: false, blocked: response.status === 403, message: `转转搜索模板返回 ${response.status},请检查配置的模板地址。`, listings: [], }; } const payload = json ?? text; const listings = extractCandidatesFromPayload("zhuanzhuan", payload); return { source: "zhuanzhuan", query, ok: true, blocked: false, message: listings.length > 0 ? `转转返回 ${listings.length} 条结果` : "转转响应成功但未提取到可用商品数据", listings, }; } export async function searchMarketSource(source: MarketSource, query: string, config: MarketConfig): Promise { try { switch (source) { case "xianyu": return await searchXianyu(query, config); case "jd": return await searchJd(query, config); case "zhuanzhuan": return await searchZhuanzhuan(query, config); } } catch (error) { const message = error instanceof Error ? error.message : "Unknown source error"; return { source, query, ok: false, blocked: /403|校验|risk|timeout/i.test(message), message, listings: [], }; } } export function enrichRacketListing(raw: RawSourceListing): InsertRacketListing { const material = normalizeWhitespace([raw.title, raw.description ?? ""].join(" ")); const brand = detectBrand(material); const series = detectSeries(material); const weightGram = detectWeightGram(material); const category = detectCategory(material, weightGram); const conditionLevel = detectConditionLevel(material); const { gradeLevel, gradeReason, isLowPriceCandidate } = gradeRacketListing({ brand, price: raw.price, conditionLevel, category, weightGram, }); return { source: raw.source, sourceListingId: raw.sourceListingId, title: raw.title.slice(0, 512), description: raw.description ?? null, listingUrl: raw.listingUrl, imageUrl: raw.imageUrl ?? null, price: raw.price, originalPrice: raw.originalPrice ?? null, sellerName: raw.sellerName ?? null, location: raw.location ?? null, publishedAtRaw: raw.publishedAtRaw ?? null, brand, model: deriveModel(material, brand, series), series, category, weightGram, conditionLevel, gradeLevel, gradeReason, isLowPriceCandidate: isLowPriceCandidate ? 1 : 0, fingerprint: fingerprintForListing(raw.source, raw.sourceListingId, raw.title, raw.price), extra: raw.extra ?? null, fetchedAt: new Date(), }; } export function listingMatchesWatchRule( listing: Pick, rule: Pick, ) { if ((listing.price ?? Number.MAX_SAFE_INTEGER) > rule.targetPrice) return false; const haystack = normalizeWhitespace([ listing.brand ?? "", listing.title ?? "", listing.model ?? "", listing.series ?? "", ].join(" ")).toLowerCase(); if (rule.brand && !haystack.includes(rule.brand.toLowerCase())) return false; if (rule.modelKeyword && !haystack.includes(rule.modelKeyword.toLowerCase())) return false; if (rule.seriesKeyword && !haystack.includes(rule.seriesKeyword.toLowerCase())) return false; if (rule.category && listing.category && rule.category !== listing.category) return false; if (rule.weightMinGram != null && listing.weightGram != null && listing.weightGram < rule.weightMinGram) return false; if (rule.weightMaxGram != null && listing.weightGram != null && listing.weightGram > rule.weightMaxGram) return false; return true; } export function applyComparablePriceBenchmark( listing: InsertRacketListing, comparablePrices: number[], ) { if (comparablePrices.length < 2) return listing; const avg = comparablePrices.reduce((sum, value) => sum + value, 0) / comparablePrices.length; if (listing.price <= avg * 0.85) { return { ...listing, isLowPriceCandidate: 1, gradeReason: normalizeWhitespace(`${listing.gradeReason ?? ""} · 相比站内同型号样本均价更低`), }; } return listing; } export function maskWebhookUrl(url: string) { if (!url.trim()) return ""; if (url.length <= 20) return url; return `${url.slice(0, 38)}...${url.slice(-8)}`; } export function formatMarketPushText(payload: { ruleTitle: string; source: MarketSource; title: string; price: number; targetPrice: number; brand?: string | null; model?: string | null; category?: string | null; weightGram?: number | null; gradeLevel?: MarketGradeLevel | null; gradeReason?: string | null; listingUrl: string; fetchedAt?: string | Date | null; }) { const lines = [ `命中监控: ${payload.ruleTitle}`, `来源: ${MARKET_SOURCE_LABELS[payload.source]}`, `标题: ${payload.title}`, `价格: ¥${payload.price.toFixed(0)} / 目标 ¥${payload.targetPrice.toFixed(0)}`, ]; const tags = [ payload.brand ? `品牌 ${payload.brand}` : "", payload.model ? `型号 ${payload.model}` : "", payload.category ? `品类 ${MARKET_CATEGORY_LABELS[payload.category as MarketCategory] ?? payload.category}` : "", payload.weightGram != null ? `重量 ${Math.round(payload.weightGram)}g` : "", payload.gradeLevel ? `分级 ${MARKET_GRADE_LABELS[payload.gradeLevel]}` : "", ].filter(Boolean); if (tags.length > 0) { lines.push(`标签: ${tags.join(" · ")}`); } if (payload.gradeReason) { lines.push(`判断: ${payload.gradeReason}`); } if (payload.fetchedAt) { lines.push(`抓取时间: ${new Date(payload.fetchedAt).toISOString()}`); } lines.push(`链接: ${payload.listingUrl}`); return lines.join("\n"); }