文件
2026-04-07 11:00:03 +08:00

778 行
26 KiB
TypeScript

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<MarketSource, string> = {
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<string, unknown> | 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<string, "elite" | "mainstream" | "value"> = {
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<string, unknown>;
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>(),
): 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<string, unknown>)) {
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<unknown>()): Array<Record<string, unknown>[]> {
if (node == null || depth > 6) return [];
if (typeof node !== "object") return [];
if (seen.has(node)) return [];
seen.add(node);
const arrays: Array<Record<string, unknown>[]> = [];
if (Array.isArray(node)) {
if (node.length > 0 && node.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
arrays.push(node as Array<Record<string, unknown>>);
}
for (const item of node) {
arrays.push(...collectObjectArrays(item, depth + 1, seen));
}
return arrays;
}
for (const value of Object.values(node as Record<string, unknown>)) {
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<RacketWatchRule, "brand" | "modelKeyword" | "seriesKeyword" | "category">) {
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<MarketConfig> {
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<string, string> = {
"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<string, unknown>) {
const url = firstString(findValuesByKey(candidate, /(url|href|jumpUrl|itemUrl|detailUrl)/i));
if (!url) return null;
return url;
}
function pickImageUrl(candidate: Record<string, unknown>) {
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<string, unknown>) {
const keys = /(seller|nick|owner|userName|shopName|publisher|author)/i;
return firstString(findValuesByKey(candidate, keys));
}
function mapGenericCandidate(source: MarketSource, candidate: Record<string, unknown>) {
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<string, RawSourceListing>();
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<SearchSourceResult> {
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<SearchSourceResult> {
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<SearchSourceResult> {
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<SearchSourceResult> {
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<InsertRacketListing, "price" | "brand" | "title" | "model" | "series" | "category" | "weightGram">,
rule: Pick<RacketWatchRule, "brand" | "modelKeyword" | "seriesKeyword" | "category" | "weightMinGram" | "weightMaxGram" | "targetPrice">,
) {
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");
}