TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1/**2 * Helpers shared by the g1-cards-eu-jp connectors (cardmarket-priceguide, limitless-tcg, yuyu-tei,3 * hareruya, magi, cardrush, cardtrader). Kept inside connectors/api (not the framework).4 */5import { normalizeCondition, parseGradeFromTitle } from '@rareindex/taxonomy';67export const BOT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; contact data@rareindex.io)';8export 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' };9export const JSON_HEADERS = { 'user-agent': BOT_UA, accept: 'application/json, text/plain, */*;q=0.8' };1011/** UTC midnight of a Date (observation dates are day-precise). */12export function dayOf(d: Date): Date {13 return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));14}1516/** "2026-09-07T02:48:04+0200" | "2026-09-07" → UTC midnight of that calendar day (source's own date), else null. */17export function isoDay(s: string | null | undefined): Date | null {18 if (!s) return null;19 const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);20 if (!m) return null;21 const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));22 return Number.isNaN(d.getTime()) ? null : d;23}2425/** "118,000 円" | "¥ 187,614" | "780円" | "¥1,590" | "4200000" → integer yen; null when absent or ≤ 0. */26export function yen(s: string | number | null | undefined): number | null {27 if (s === null || s === undefined) return null;28 if (typeof s === 'number') return Number.isFinite(s) && s > 0 ? Math.round(s) : null;29 const m = s.replace(/[,,\s]/g, '').match(/(\d+)/);30 if (!m) return null;31 const n = Number.parseInt(m[1]!, 10);32 return Number.isFinite(n) && n > 0 ? n : null;33}3435/** "$0.25" | "0.02€" | "1,585.73€" | "€1,179.09" → { amount, currency } (EN number format), else null. */36export function usdEur(s: string | null | undefined): { amount: number; currency: 'USD' | 'EUR' } | null {37 if (!s) return null;38 const t = s.trim();39 const currency = /€|\bEUR\b/.test(t) ? 'EUR' : /\$|\bUSD\b/.test(t) ? 'USD' : null;40 if (!currency) return null;41 const m = t.replace(/,/g, '').match(/(\d+(?:\.\d+)?)/);42 if (!m) return null;43 const amount = Number.parseFloat(m[1]!);44 return Number.isFinite(amount) && amount > 0 ? { amount, currency } : null;45}4647export function cleanText(s: string | null | undefined): string | null {48 if (!s) return null;49 const t = s.replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim();50 return t || null;51}5253/** "113/076" → { number: "113", total: "076" }; "OP15-118" → { number: "OP15-118", total: null }; "-" → nulls. */54export function splitCardNumber(s: string | null | undefined): { number: string | null; total: string | null } {55 if (!s) return { number: null, total: null };56 const t = s.trim();57 if (!t || t === '-' || t === '—') return { number: null, total: null };58 const m = t.match(/^([A-Za-z0-9]+)\s*\/\s*([A-Za-z0-9-]+)$/);59 if (m) return { number: m[1]!, total: m[2]! };60 return { number: t, total: null };61}6263/** Grade parser tolerant of Japanese shop notation: "PSA10鑑定済", "【PSA10】", "BGS9.5" → grader/grade. */64export function jpGrade(title: string): { grader: string | null; grade: string | null; qualifier: string | null } {65 const spaced = title.replace(/(PSA|BGS|CGC|SGC|ACE|TAG)(\d)/gi, '$1 $2').replace(/鑑定済/g, ' graded ');66 const g = parseGradeFromTitle(spaced);67 return g;68}6970/** Japanese shop condition labels → taxonomy condition slug; null when the scale is shop-specific (状態A/B…) — never guessed. */71export function jpCondition(raw: string | null | undefined): string | null {72 if (!raw) return null;73 const t = raw.trim();74 const direct = normalizeCondition('trading_cards', t);75 if (direct) return direct;76 if (/^(NM|near mint)$/i.test(t)) return 'near_mint';77 if (/^EX[+-]?$/i.test(t)) return 'excellent';78 if (/^(HP|heavily played)$/i.test(t)) return 'good';79 if (/^(DMG|damaged)$/i.test(t)) return 'poor';80 return null;81}8283export interface JpCardTitle {84 /** card name with all tags stripped (EN half preferred for bilingual "JP/EN" names) */85 name: string;86 /** contents of the leading 〔…〕/[…] tags: condition or grading ("状態A-", "PSA10鑑定済", "PLD") */87 conditionRaw: string | null;88 /** 【…】 tag: rarity (Pokémon/One Piece shops) or set code (MTG shops) — caller decides via `bracket` */89 rarity: string | null;90 setCode: string | null;91 /** {…} tag: "013/068" → number 013, total 068; "OP01-001"; "-" → null */92 number: string | null;93 total: string | null;94 /** 《日本語》/《英語》 → "Japanese"/"English"; null when absent */95 language: string | null;96 /** parenthesised hints: "(RR仕様)", "(パラレル)", "(MA仕様/英語版)" */97 notes: string[];98 /** trailing "N枚" (count of cards in the listing) */99 quantity: number | null;100 sealed: boolean;101 grader: string | null;102 grade: string | null;103 qualifier: string | null;104}105106const LANG_MAP: Record<string, string> = { 日本語: 'Japanese', 英語: 'English', 韓国語: 'Korean', 中国語: 'Chinese', 簡体字: 'Chinese', 繁体字: 'Chinese', ドイツ語: 'German', フランス語: 'French', イタリア語: 'Italian', スペイン語: 'Spanish', ポルトガル語: 'Portuguese', ロシア語: 'Russian' };107108/**109 * Parse the title conventions shared by Japanese single-card shops (Cardrush, Magi sellers, Yuyu-tei):110 * "〔PSA10鑑定済〕ミロカロスδ-デルタ種【★】{013/068}"111 * "〔状態A-〕ミュウツーV(RR仕様)【P】{273/S-P} 1枚"112 * "[PLD](黒枠)ラノワールのエルフ/Llanowar Elves《日本語》【4ED】"113 * "ブースターパック 受け継がれる意志【未開封BOX】{-}"114 * `bracket` tells whether the 【…】 tag is a rarity (default) or a set code (MTG shops).115 */116export function parseJpCardTitle(title: string, opts: { bracket?: 'rarity' | 'set' } = {}): JpCardTitle {117 let t = title.replace(/\s+/g, ' ').trim();118 const conds: string[] = [];119 // leading condition/grading tags120 for (;;) {121 const m = t.match(/^(?:〔([^〕]*)〕|\[([^\]]*)\])\s*/);122 if (!m) break;123 conds.push((m[1] ?? m[2] ?? '').trim());124 t = t.slice(m[0].length);125 }126 let sealed = false;127 let rarity: string | null = null;128 let setCode: string | null = null;129 t = t.replace(/【([^】]*)】/g, (_, inner: string) => {130 const v = inner.trim();131 if (/^(PSA|BGS|CGC|SGC|ACE|TAG|ARS)\s*\d/i.test(v)) conds.push(v); // "【PSA10】" = grading tag, not a rarity132 else if (/未開封|BOX|パック|カートン/i.test(v)) sealed = true;133 else if (opts.bracket === 'set') setCode = setCode ?? (v || null);134 else rarity = rarity ?? (v && v !== '-' ? v : null);135 return ' ';136 });137 const g = jpGrade(conds.join(' '));138 let number: string | null = null;139 let total: string | null = null;140 t = t.replace(/\{([^}]*)\}/g, (_, inner: string) => {141 // "{ST15-005[OP16]}" → number ST15-005, set code OP16 (reprint origin)142 const withSet = inner.match(/^(.*?)\[([A-Za-z0-9-]+)\]\s*$/);143 const s = splitCardNumber(withSet ? withSet[1]! : inner);144 if (withSet && opts.bracket !== 'set') setCode = setCode ?? withSet[2]!;145 if (s.number && number === null) {146 number = s.number;147 total = s.total;148 }149 return ' ';150 });151 let language: string | null = null;152 t = t.replace(/《([^》]*)》/g, (_, inner: string) => {153 language = language ?? LANG_MAP[inner.trim()] ?? inner.trim() ?? null;154 return ' ';155 });156 let quantity: number | null = null;157 const q = t.match(/(?:^|\s)(\d{1,3})枚(?:セット|組)?\s*$/);158 if (q) {159 quantity = Number(q[1]);160 t = t.slice(0, q.index).trim();161 }162 const notes: string[] = [];163 t = t.replace(/[((]([^()()]*)[))]/g, (_, inner: string) => {164 const v = inner.trim();165 if (v) notes.push(v);166 if (/未開封/.test(v)) sealed = true;167 return ' ';168 });169 // bare "120/114" card number outside braces (private sellers often omit the braces)170 if (number === null) {171 const bare = t.match(/(?:^|\s)(\d{1,3})\s*\/\s*(\d{1,3})(?=\s|$)/);172 if (bare) {173 number = bare[1]!;174 total = bare[2]!;175 t = t.replace(bare[0], ' ');176 }177 }178 let name = t.replace(/\s+/g, ' ').trim();179 // bilingual MTG names "ラノワールのエルフ/Llanowar Elves" → prefer the Latin half180 const bi = name.match(/^(.+?)\/(.+)$/);181 if (bi && /[A-Za-z]/.test(bi[2]!) && !/[A-Za-z]/.test(bi[1]!)) name = bi[2]!.trim();182 const conditionRaw = conds.filter((c) => c && !/PSA|BGS|CGC|SGC|ACE|TAG|鑑定/i.test(c)).join(' / ') || (conds.length ? conds.join(' / ') : null);183 return { name: name || title.trim(), conditionRaw, rarity, setCode, number, total, language, notes, quantity, sealed, grader: g.grader, grade: g.grade, qualifier: g.qualifier };184}185186/**187 * JSON.parse for JSON-LD blocks that embed raw line breaks inside string values (magi item pages do):188 * escapes control characters found inside strings, then parses. Returns null on failure.189 */190export function lenientJsonParse(text: string): unknown | null {191 try {192 return JSON.parse(text);193 } catch {194 let out = '';195 let inStr = false;196 for (let i = 0; i < text.length; i++) {197 const ch = text[i]!;198 if (inStr) {199 if (ch === '\\') {200 out += ch + (text[i + 1] ?? '');201 i++;202 continue;203 }204 if (ch === '"') inStr = false;205 else if (ch === '\n') {206 out += '\\n';207 continue;208 } else if (ch === '\r') continue;209 else if (ch === '\t') {210 out += '\\t';211 continue;212 }213 } else if (ch === '"') inStr = true;214 out += ch;215 }216 try {217 return JSON.parse(out);218 } catch {219 return null;220 }221 }222}223224/** All JSON-LD objects of a given @type in a document (lenient about raw newlines inside strings). */225export function jsonLdObjects(doc: string, type: string): Record<string, unknown>[] {226 const out: Record<string, unknown>[] = [];227 const re = /<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;228 let m: RegExpExecArray | null;229 while ((m = re.exec(doc))) {230 const parsed = lenientJsonParse(m[1]!.trim());231 const items = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' && '@graph' in (parsed as object) ? ((parsed as { '@graph': unknown[] })['@graph'] ?? []) : [parsed];232 for (const it of items) {233 if (!it || typeof it !== 'object') continue;234 const t = (it as { '@type'?: string | string[] })['@type'];235 const types = Array.isArray(t) ? t : t ? [t] : [];236 if (types.includes(type)) out.push(it as Record<string, unknown>);237 }238 }239 return out;240}241242/** Variant label from Japanese printing hints: パラレル → Parallel, ミラー → Mirror, SR仕様 → "SR". */243export function jpVariant(notes: string[], name?: string): string | null {244 const hay = [...notes, name ?? ''].join(' ');245 if (/パラレル/.test(hay)) return 'Parallel';246 if (/マスターボールミラー/.test(hay)) return 'Master Ball Mirror';247 if (/モンスターボールミラー/.test(hay)) return 'Poké Ball Mirror';248 if (/ミラー/.test(hay)) return 'Mirror';249 if (/コミックパラレル|コミパラ/.test(hay)) return 'Comic Parallel';250 if (/箔押し|foil/i.test(hay)) return 'Foil';251 return null;252}253254/** Mystery packs / grab bags / lots sold by Japanese shops — never priced as cards. */255export const JP_EXCLUDE_RE = /オリパ|福袋|まとめ売り|詰め合わせ|セット販売|スリーブ|プレイマット|デッキケース|サプライ|ストレージ|ローダー|募集用/;256257/** Bundle detection: "3枚", "4コン", "セット", "まとめ" (single card listings say "1枚"). */258export function isJpBundle(title: string, quantity: number | null): boolean {259 if (quantity !== null && quantity > 1) return true;260 return /\d+コン|セット(?!ブースター)|まとめ|複数枚/.test(title) && !/スターターセット|ex?スタート|構築済み/.test(title);261}262