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%
10.1 KB · 211 lines typescript
Raw Blame History
1import { z } from 'zod';2import { BaseConnector, html, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared';4import { attrs, catalogItem, makeTitle } from '../_lib/shared.js';5import { HTML_HEADERS, cleanText, jpVariant, splitCardNumber, yen } from '../_g1-cards-eu-jp-lib/index.js';67/**8 * Yuyu-tei (遊々亭): per-expansion sell pages listing every single with rarity, number, JPY price9 * and stock. One raw record per expansion page; normalize emits a catalog item and a listing per card.10 */11const SITE = 'https://yuyu-tei.jp';12const PARSER_VERSION = '1.0.0';1314const GameCfgSchema = z.object({ slug: z.string(), name: z.string(), franchise: z.string().nullable().optional(), brand: z.string().nullable().optional() });15export type GameCfg = z.infer<typeof GameCfgSchema>;1617export const ItemSchema = z.object({18  cardId: z.string(),19  url: z.string(),20  name: z.string(),21  number: z.string().nullable(),22  rarity: z.string().nullable(),23  priceJpy: z.number().nullable(),24  stock: z.number().int().nullable(),25  image: z.string().nullable(),26  kizu: z.boolean(),27});28export type YuyuItem = z.infer<typeof ItemSchema>;29const RawPayloadSchema = z.object({ game: z.string(), gameCfg: GameCfgSchema, set: z.object({ code: z.string(), name: z.string() }), items: z.array(ItemSchema) });30export type YuyuPayload = z.infer<typeof RawPayloadSchema>;3132/** Expansion list from a game top page: <input name="vers[]" value="m06"> + <label>[M6] 拡張パック …</label>. */33export function parseVersions(doc: string): Array<{ code: string; name: string }> {34  const $ = html.load(doc);35  const out: Array<{ code: string; name: string }> = [];36  const seen = new Set<string>();37  $('input[name="vers[]"]').each((_, el) => {38    const code = $(el).attr('value')?.trim();39    if (!code || seen.has(code)) return;40    const id = $(el).attr('id');41    const label = cleanText((id ? $(`label[for="${id}"]`).first().text() : '') || $(el).next('label').text()) ?? code;42    seen.add(code);43    out.push({ code, name: label });44  });45  return out;46}4748/** Parse a sell page (/sell/<game>/s/<ver>): rarity sections → .card-product blocks. */49export function parseSellPage(doc: string): { setName: string | null; items: YuyuItem[] } {50  const $ = html.load(doc);51  const title = cleanText($('title').first().text());52  const setName = title ? title.split('|')[0]!.trim() || null : null;53  const items: YuyuItem[] = [];54  $('.cards-list').each((_, section) => {55    const rarity = cleanText($(section).find('h3 span').first().text());56    $(section)57      .find('.card-product')58      .each((__, el) => {59        const $el = $(el);60        const a = $el.find('a[href*="/card/"]').first();61        const href = a.attr('href');62        if (!href) return;63        const cardId = $el.find('.cart_cid').attr('value')?.trim() || href.split('/').pop() || '';64        const name = cleanText($el.find('h4').first().text());65        if (!name || !cardId) return;66        const number = cleanText($el.find('span.border').first().text());67        const priceText = cleanText($el.find('strong').first().text());68        const stockText = cleanText($el.find('.cart_sell_zaiko').first().text()) ?? '';69        const stockM = stockText.replace(/[,,]/g, '').match(/(\d+)\s*点/);70        const stock = stockM ? Number(stockM[1]) : /在庫\s*:?\s*なし|売り切れ|SOLD/i.test(stockText) ? 0 : null;71        const img = $el.find('img.card').attr('src') ?? null;72        const kizu = ($el.find('.cart_kizu').attr('value') ?? '0') !== '0';73        items.push({ cardId, url: href.startsWith('http') ? href : `${SITE}${href}`, name, number: number && number !== '-' ? number : null, rarity: rarity && rarity !== '-' ? rarity : null, priceJpy: yen(priceText), stock, image: img, kizu });74      });75  });76  return { setName, items };77}7879export class YuyuTeiConnector extends BaseConnector {80  readonly version = '1.0.0';81  readonly parserVersion = PARSER_VERSION;82  protected override minIntervalMs = 4000;8384  private games(): Array<[string, GameCfg]> {85    const cfg = (this.meta.config.games ?? {}) as Record<string, unknown>;86    return Object.entries(cfg).map(([code, c]) => [code, GameCfgSchema.parse(c)] as [string, GameCfg]);87  }8889  private async page(ctx: CrawlContext, url: string) {90    await this.throttle(url);91    return ctx.fetch(url, { engines: ['api'], headers: HTML_HEADERS, responseType: 'text', minQuality: 0 });92  }9394  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {95    let games = this.games();96    if (ctx.options.seeds?.length) games = games.filter(([code]) => ctx.options.seeds!.some((s) => s === code || s.startsWith(`${code}:`)));97    const backfill = ctx.options.mode === 'backfill';98    const perGame = backfill ? Infinity : Number(this.meta.config.setsPerGame ?? 6);99    let gameIdx = Number(ctx.options.cursor?.gameIdx ?? 0);100    let setIdx = Number(ctx.options.cursor?.setIdx ?? 0);101    let count = 0;102    for (; gameIdx < games.length; gameIdx++, setIdx = 0) {103      const [game, gameCfg] = games[gameIdx]!;104      const topUrl = `${SITE}/top/${game}`;105      const top = await this.page(ctx, topUrl);106      if (!top.success || !top.html) {107        ctx.anomaly('page_fetch_failed', `${topUrl}: ${top.error ?? top.httpStatus}`);108        continue;109      }110      let versions = parseVersions(top.html);111      if (!versions.length) {112        ctx.anomaly('selector_missing', `${topUrl}: no vers[] checkboxes`);113        continue;114      }115      const wanted = ctx.options.seeds?.filter((s) => s.startsWith(`${game}:`)).map((s) => s.slice(game.length + 1)) ?? [];116      if (wanted.length) versions = versions.filter((v) => wanted.includes(v.code));117      versions = versions.slice(0, Number.isFinite(perGame) ? perGame : versions.length);118      for (; setIdx < versions.length; setIdx++) {119        if (ctx.signal?.aborted) return;120        if (this.reached(ctx, count)) {121          await ctx.setCursor({ gameIdx, setIdx });122          return;123        }124        const v = versions[setIdx]!;125        const url = `${SITE}/sell/${game}/s/${v.code}`;126        const res = await this.page(ctx, url);127        if (!res.success || !res.html) {128          ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);129          continue;130        }131        const { setName, items } = parseSellPage(res.html);132        if (!items.length) {133          ctx.anomaly('parse_failure_page', `${url}: no .card-product blocks`);134          continue;135        }136        const noPrice = items.filter((i) => i.priceJpy === null).length;137        if (noPrice > items.length / 2) ctx.anomaly('price_parse_failure', `${url}: ${noPrice}/${items.length} items without price`);138        count++;139        const payload: YuyuPayload = { game, gameCfg, set: { code: v.code, name: setName ?? v.name }, items };140        yield { url, externalId: `${game}:${v.code}`, kind: 'listing', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };141        await ctx.setCursor({ gameIdx, setIdx: setIdx + 1 });142        await ctx.progress({ page: setIdx + 1, totalPages: versions.length, itemsProcessed: count });143      }144    }145    await ctx.setCursor({ gameIdx: 0, setIdx: 0, completedAt: new Date().toISOString(), done: true });146  }147148  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {149    const p = RawPayloadSchema.parse(raw.payload);150    const out: NormalizedRecord[] = [];151    const observedAt = raw.fetchedAt;152    // "[M6] 拡張パック ストームエメラルダ | シングルカード販売 | …" → code "M6", name "拡張パック ストームエメラルダ"153    const sm = p.set.name.match(/^\[([^\]]+)\]\s*(.+?)(?:\s*シングルカード販売)?$/);154    const setCode = sm?.[1] ?? p.set.code.toUpperCase();155    const setName = sm?.[2]?.trim() || p.set.name;156    const seen = new Set<string>();157    for (const it of p.items) {158      if (seen.has(it.cardId)) continue;159      seen.add(it.cardId);160      const { number, total } = splitCardNumber(it.number);161      const variant = jpVariant([], it.name);162      const name = it.name.replace(/[((][^()()]*(パラレル|ミラー)[^()()]*[))]/g, '').replace(/\s+/g, ' ').trim() || it.name;163      const a = attrs({164        categorySlug: p.gameCfg.slug,165        franchise: p.gameCfg.franchise ?? null,166        brand: p.gameCfg.brand ?? null,167        set: setName,168        setCode,169        name,170        number,171        variant,172        language: 'Japanese',173        rarity: it.rarity,174        identifiers: { yuyutei_card: `${p.game}/${p.set.code}/${it.cardId}` },175        metadata: { game: p.gameCfg.name, total, yuyutei_version: p.set.code, damaged: it.kizu },176      });177      const rawTitle = makeTitle({ name: it.name, set: setName, number, total, variant });178      const images = it.image ? [it.image.replace('/100_140/', '/front/')] : [];179      out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: it.url, externalId: `card:${p.game}:${p.set.code}:${it.cardId}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.8, parserVersion: PARSER_VERSION, releaseDate: null }));180      if (it.priceJpy === null) continue;181      out.push(182        NormalizedListingSchema.parse({183          kind: 'listing',184          connectorId: this.meta.id,185          sourceId: this.meta.sourceId,186          sourceUrl: it.url,187          externalId: `${p.game}:${p.set.code}:${it.cardId}${it.kizu ? ':kizu' : ''}`,188          rawTitle,189          imageUrls: images,190          attributes: a,191          grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },192          condition: { condition: null, conditionRaw: it.kizu ? 'キズあり' : null, completeness: null },193          observedAt,194          confidence: 0.8,195          parserVersion: PARSER_VERSION,196          listingType: 'fixed_price',197          price: it.priceJpy,198          currency: 'JPY',199          seller: 'Yuyu-tei',200          location: 'JP',201          quantity: it.stock,202          availability: it.stock === 0 ? 'ended' : 'available',203        }),204      );205    }206    return out;207  }208}209210export default (meta: ConnectorMeta) => new YuyuTeiConnector(meta);211