778 行
26 KiB
TypeScript
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");
|
|
}
|