| import crypto from "crypto"; |
|
|
| export function nowIso() { |
| return new Date().toISOString(); |
| } |
|
|
| export async function sleep(ms: number) { |
| if (ms <= 0) return; |
| await new Promise<void>((resolve) => setTimeout(resolve, ms)); |
| } |
|
|
| export function normalizeUrl(url: string) { |
| const u = url.trim(); |
| if (u.startsWith("//")) return `https:${u}`; |
| if (u.startsWith("http://") || u.startsWith("https://")) return u; |
| return `https://${u}`; |
| } |
|
|
| export function parsePrice(text: string | null) { |
| if (!text) return null; |
| const t = text.replace(/[¥¥,\s]/g, ""); |
| const m = t.match(/(\d+(?:\.\d+)?)/); |
| if (!m) return null; |
| const n = Number(m[1]); |
| return Number.isFinite(n) ? n : null; |
| } |
|
|
| export function parseSales(text: string | null) { |
| if (!text) return null; |
| const t = text.replace(/[,,\s]/g, ""); |
| const m = t.match(/(\d+(?:\.\d+)?)(万|千)?/); |
| if (!m) return null; |
| const base = Number(m[1]); |
| if (!Number.isFinite(base)) return null; |
| const unit = m[2]; |
| if (unit === "万") return Math.round(base * 10_000); |
| if (unit === "千") return Math.round(base * 1000); |
| return Math.round(base); |
| } |
|
|
| export function parseScore(text: string | null) { |
| if (!text) return null; |
| const t = text.replace(/[^\d.]/g, ""); |
| const n = Number(t); |
| if (!Number.isFinite(n)) return null; |
| if (n > 10) return null; |
| return n; |
| } |
|
|
| export function stableId(input: string) { |
| return crypto.createHash("sha256").update(input).digest("hex").slice(0, 16); |
| } |
|
|
| export function computeGroupKey(title: string) { |
| const t = title |
| .toLowerCase() |
| .replace(/[【】\[\]()()]/g, " ") |
| .replace(/[^\p{L}\p{N}]+/gu, " ") |
| .trim(); |
| const tokens = t.split(/\s+/).filter(Boolean); |
| const core = tokens.slice(0, 8).join("-"); |
| return core.slice(0, 64) || stableId(title); |
| } |
|
|
| export function clamp(n: number, min: number, max: number) { |
| return Math.min(max, Math.max(min, n)); |
| } |
|
|
| export async function asyncPool<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>) { |
| const limit = Math.max(1, Math.floor(concurrency)); |
| const out: R[] = new Array(items.length); |
| let i = 0; |
|
|
| async function worker() { |
| while (true) { |
| const idx = i; |
| i += 1; |
| if (idx >= items.length) return; |
| out[idx] = await fn(items[idx]!); |
| } |
| } |
|
|
| const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker()); |
| await Promise.all(workers); |
| return out; |
| } |
|
|
| export async function withRetry<T>(fn: () => Promise<T>, opts: { retries: number; baseMs: number; maxMs: number; jitter: number }) { |
| let attempt = 0; |
| while (true) { |
| try { |
| return await fn(); |
| } catch (e) { |
| if (attempt >= opts.retries) throw e; |
| const exp = opts.baseMs * 2 ** attempt; |
| const ms = Math.min(opts.maxMs, exp); |
| const j = Math.floor(ms * opts.jitter * Math.random()); |
| await sleep(ms + j); |
| attempt += 1; |
| } |
| } |
| } |
|
|
| export function createRateLimiter(qps: number) { |
| const intervalMs = qps <= 0 ? 0 : Math.floor(1000 / qps); |
| let last = 0; |
| return async () => { |
| if (intervalMs <= 0) return; |
| const now = Date.now(); |
| const wait = Math.max(0, last + intervalMs - now); |
| last = now + wait; |
| await sleep(wait); |
| }; |
| } |
|
|