/** * Helpers shared by the g1-cards-eu-jp connectors (cardmarket-priceguide, limitless-tcg, yuyu-tei, * hareruya, magi, cardrush, cardtrader). Kept inside connectors/api (not the framework). */ import { normalizeCondition, parseGradeFromTitle } from '@rareindex/taxonomy'; export const BOT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; contact data@rareindex.io)'; export const HTML_HEADERS = { 'user-agent': BOT_UA, accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'accept-language': 'ja,en;q=0.8' }; export const JSON_HEADERS = { 'user-agent': BOT_UA, accept: 'application/json, text/plain, */*;q=0.8' }; /** UTC midnight of a Date (observation dates are day-precise). */ export function dayOf(d: Date): Date { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } /** "2026-09-07T02:48:04+0200" | "2026-09-07" → UTC midnight of that calendar day (source's own date), else null. */ export function isoDay(s: string | null | undefined): Date | null { if (!s) return null; const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (!m) return null; const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); return Number.isNaN(d.getTime()) ? null : d; } /** "118,000 円" | "¥ 187,614" | "780円" | "¥1,590" | "4200000" → integer yen; null when absent or ≤ 0. */ export function yen(s: string | number | null | undefined): number | null { if (s === null || s === undefined) return null; if (typeof s === 'number') return Number.isFinite(s) && s > 0 ? Math.round(s) : null; const m = s.replace(/[,,\s]/g, '').match(/(\d+)/); if (!m) return null; const n = Number.parseInt(m[1]!, 10); return Number.isFinite(n) && n > 0 ? n : null; } /** "$0.25" | "0.02€" | "1,585.73€" | "€1,179.09" → { amount, currency } (EN number format), else null. */ export function usdEur(s: string | null | undefined): { amount: number; currency: 'USD' | 'EUR' } | null { if (!s) return null; const t = s.trim(); const currency = /€|\bEUR\b/.test(t) ? 'EUR' : /\$|\bUSD\b/.test(t) ? 'USD' : null; if (!currency) return null; const m = t.replace(/,/g, '').match(/(\d+(?:\.\d+)?)/); if (!m) return null; const amount = Number.parseFloat(m[1]!); return Number.isFinite(amount) && amount > 0 ? { amount, currency } : null; } export function cleanText(s: string | null | undefined): string | null { if (!s) return null; const t = s.replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim(); return t || null; } /** "113/076" → { number: "113", total: "076" }; "OP15-118" → { number: "OP15-118", total: null }; "-" → nulls. */ export function splitCardNumber(s: string | null | undefined): { number: string | null; total: string | null } { if (!s) return { number: null, total: null }; const t = s.trim(); if (!t || t === '-' || t === '—') return { number: null, total: null }; const m = t.match(/^([A-Za-z0-9]+)\s*\/\s*([A-Za-z0-9-]+)$/); if (m) return { number: m[1]!, total: m[2]! }; return { number: t, total: null }; } /** Grade parser tolerant of Japanese shop notation: "PSA10鑑定済", "【PSA10】", "BGS9.5" → grader/grade. */ export function jpGrade(title: string): { grader: string | null; grade: string | null; qualifier: string | null } { const spaced = title.replace(/(PSA|BGS|CGC|SGC|ACE|TAG)(\d)/gi, '$1 $2').replace(/鑑定済/g, ' graded '); const g = parseGradeFromTitle(spaced); return g; } /** Japanese shop condition labels → taxonomy condition slug; null when the scale is shop-specific (状態A/B…) — never guessed. */ export function jpCondition(raw: string | null | undefined): string | null { if (!raw) return null; const t = raw.trim(); const direct = normalizeCondition('trading_cards', t); if (direct) return direct; if (/^(NM|near mint)$/i.test(t)) return 'near_mint'; if (/^EX[+-]?$/i.test(t)) return 'excellent'; if (/^(HP|heavily played)$/i.test(t)) return 'good'; if (/^(DMG|damaged)$/i.test(t)) return 'poor'; return null; } export interface JpCardTitle { /** card name with all tags stripped (EN half preferred for bilingual "JP/EN" names) */ name: string; /** contents of the leading 〔…〕/[…] tags: condition or grading ("状態A-", "PSA10鑑定済", "PLD") */ conditionRaw: string | null; /** 【…】 tag: rarity (Pokémon/One Piece shops) or set code (MTG shops) — caller decides via `bracket` */ rarity: string | null; setCode: string | null; /** {…} tag: "013/068" → number 013, total 068; "OP01-001"; "-" → null */ number: string | null; total: string | null; /** 《日本語》/《英語》 → "Japanese"/"English"; null when absent */ language: string | null; /** parenthesised hints: "(RR仕様)", "(パラレル)", "(MA仕様/英語版)" */ notes: string[]; /** trailing "N枚" (count of cards in the listing) */ quantity: number | null; sealed: boolean; grader: string | null; grade: string | null; qualifier: string | null; } const LANG_MAP: Record = { 日本語: 'Japanese', 英語: 'English', 韓国語: 'Korean', 中国語: 'Chinese', 簡体字: 'Chinese', 繁体字: 'Chinese', ドイツ語: 'German', フランス語: 'French', イタリア語: 'Italian', スペイン語: 'Spanish', ポルトガル語: 'Portuguese', ロシア語: 'Russian' }; /** * Parse the title conventions shared by Japanese single-card shops (Cardrush, Magi sellers, Yuyu-tei): * "〔PSA10鑑定済〕ミロカロスδ-デルタ種【★】{013/068}" * "〔状態A-〕ミュウツーV(RR仕様)【P】{273/S-P} 1枚" * "[PLD](黒枠)ラノワールのエルフ/Llanowar Elves《日本語》【4ED】" * "ブースターパック 受け継がれる意志【未開封BOX】{-}" * `bracket` tells whether the 【…】 tag is a rarity (default) or a set code (MTG shops). */ export function parseJpCardTitle(title: string, opts: { bracket?: 'rarity' | 'set' } = {}): JpCardTitle { let t = title.replace(/\s+/g, ' ').trim(); const conds: string[] = []; // leading condition/grading tags for (;;) { const m = t.match(/^(?:〔([^〕]*)〕|\[([^\]]*)\])\s*/); if (!m) break; conds.push((m[1] ?? m[2] ?? '').trim()); t = t.slice(m[0].length); } let sealed = false; let rarity: string | null = null; let setCode: string | null = null; t = t.replace(/【([^】]*)】/g, (_, inner: string) => { const v = inner.trim(); if (/^(PSA|BGS|CGC|SGC|ACE|TAG|ARS)\s*\d/i.test(v)) conds.push(v); // "【PSA10】" = grading tag, not a rarity else if (/未開封|BOX|パック|カートン/i.test(v)) sealed = true; else if (opts.bracket === 'set') setCode = setCode ?? (v || null); else rarity = rarity ?? (v && v !== '-' ? v : null); return ' '; }); const g = jpGrade(conds.join(' ')); let number: string | null = null; let total: string | null = null; t = t.replace(/\{([^}]*)\}/g, (_, inner: string) => { // "{ST15-005[OP16]}" → number ST15-005, set code OP16 (reprint origin) const withSet = inner.match(/^(.*?)\[([A-Za-z0-9-]+)\]\s*$/); const s = splitCardNumber(withSet ? withSet[1]! : inner); if (withSet && opts.bracket !== 'set') setCode = setCode ?? withSet[2]!; if (s.number && number === null) { number = s.number; total = s.total; } return ' '; }); let language: string | null = null; t = t.replace(/《([^》]*)》/g, (_, inner: string) => { language = language ?? LANG_MAP[inner.trim()] ?? inner.trim() ?? null; return ' '; }); let quantity: number | null = null; const q = t.match(/(?:^|\s)(\d{1,3})枚(?:セット|組)?\s*$/); if (q) { quantity = Number(q[1]); t = t.slice(0, q.index).trim(); } const notes: string[] = []; t = t.replace(/[((]([^()()]*)[))]/g, (_, inner: string) => { const v = inner.trim(); if (v) notes.push(v); if (/未開封/.test(v)) sealed = true; return ' '; }); // bare "120/114" card number outside braces (private sellers often omit the braces) if (number === null) { const bare = t.match(/(?:^|\s)(\d{1,3})\s*\/\s*(\d{1,3})(?=\s|$)/); if (bare) { number = bare[1]!; total = bare[2]!; t = t.replace(bare[0], ' '); } } let name = t.replace(/\s+/g, ' ').trim(); // bilingual MTG names "ラノワールのエルフ/Llanowar Elves" → prefer the Latin half const bi = name.match(/^(.+?)\/(.+)$/); if (bi && /[A-Za-z]/.test(bi[2]!) && !/[A-Za-z]/.test(bi[1]!)) name = bi[2]!.trim(); const conditionRaw = conds.filter((c) => c && !/PSA|BGS|CGC|SGC|ACE|TAG|鑑定/i.test(c)).join(' / ') || (conds.length ? conds.join(' / ') : null); return { name: name || title.trim(), conditionRaw, rarity, setCode, number, total, language, notes, quantity, sealed, grader: g.grader, grade: g.grade, qualifier: g.qualifier }; } /** * JSON.parse for JSON-LD blocks that embed raw line breaks inside string values (magi item pages do): * escapes control characters found inside strings, then parses. Returns null on failure. */ export function lenientJsonParse(text: string): unknown | null { try { return JSON.parse(text); } catch { let out = ''; let inStr = false; for (let i = 0; i < text.length; i++) { const ch = text[i]!; if (inStr) { if (ch === '\\') { out += ch + (text[i + 1] ?? ''); i++; continue; } if (ch === '"') inStr = false; else if (ch === '\n') { out += '\\n'; continue; } else if (ch === '\r') continue; else if (ch === '\t') { out += '\\t'; continue; } } else if (ch === '"') inStr = true; out += ch; } try { return JSON.parse(out); } catch { return null; } } } /** All JSON-LD objects of a given @type in a document (lenient about raw newlines inside strings). */ export function jsonLdObjects(doc: string, type: string): Record[] { const out: Record[] = []; const re = /]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi; let m: RegExpExecArray | null; while ((m = re.exec(doc))) { const parsed = lenientJsonParse(m[1]!.trim()); const items = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' && '@graph' in (parsed as object) ? ((parsed as { '@graph': unknown[] })['@graph'] ?? []) : [parsed]; for (const it of items) { if (!it || typeof it !== 'object') continue; const t = (it as { '@type'?: string | string[] })['@type']; const types = Array.isArray(t) ? t : t ? [t] : []; if (types.includes(type)) out.push(it as Record); } } return out; } /** Variant label from Japanese printing hints: パラレル → Parallel, ミラー → Mirror, SR仕様 → "SR". */ export function jpVariant(notes: string[], name?: string): string | null { const hay = [...notes, name ?? ''].join(' '); if (/パラレル/.test(hay)) return 'Parallel'; if (/マスターボールミラー/.test(hay)) return 'Master Ball Mirror'; if (/モンスターボールミラー/.test(hay)) return 'Poké Ball Mirror'; if (/ミラー/.test(hay)) return 'Mirror'; if (/コミックパラレル|コミパラ/.test(hay)) return 'Comic Parallel'; if (/箔押し|foil/i.test(hay)) return 'Foil'; return null; } /** Mystery packs / grab bags / lots sold by Japanese shops — never priced as cards. */ export const JP_EXCLUDE_RE = /オリパ|福袋|まとめ売り|詰め合わせ|セット販売|スリーブ|プレイマット|デッキケース|サプライ|ストレージ|ローダー|募集用/; /** Bundle detection: "3枚", "4コン", "セット", "まとめ" (single card listings say "1枚"). */ export function isJpBundle(title: string, quantity: number | null): boolean { if (quantity !== null && quantity > 1) return true; return /\d+コン|セット(?!ブースター)|まとめ|複数枚/.test(title) && !/スターターセット|ex?スタート|構築済み/.test(title); }