SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
11.9 KB · 262 lines typescript
Raw Blame History
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(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/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