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%
9.0 KB · 177 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 Engine, type NormalizedRecord } from '@rareindex/shared';4import { attrs } from '../_lib/shared.js';5import { HTML_HEADERS, JP_EXCLUDE_RE, cleanText, isJpBundle, jpCondition, jpVariant, parseJpCardTitle, yen } from '../_g1-cards-eu-jp-lib/index.js';67/**8 * Cardrush (カードラッシュ) — one storefront per game on the same shop platform. Category listing pages9 * (product-list/<id>?page=n) → one raw record per page; normalize emits one JPY listing per product.10 */11const PARSER_VERSION = '1.0.0';1213const StoreSchema = z.object({14  host: z.string(),15  categorySlug: z.string(),16  franchise: z.string().nullable().optional(),17  brand: z.string().nullable().optional(),18  bracket: z.enum(['rarity', 'set']).default('rarity'),19  engines: z.array(z.enum(['api', 'feed', 'firecrawl', 'scrapfly', 'browser', 'manual'])).optional(),20  pagesPerList: z.number().int().positive().optional(),21  lists: z.record(z.string(), z.string()),22});23export type CardrushStore = z.infer<typeof StoreSchema>;2425export const ItemSchema = z.object({ productId: z.string(), url: z.string(), title: z.string(), modelNumber: z.string().nullable(), priceJpy: z.number().nullable(), stock: z.number().int().nullable(), soldOut: z.boolean(), image: z.string().nullable() });26export type CardrushItem = z.infer<typeof ItemSchema>;27const RawPayloadSchema = z.object({ store: StoreSchema.omit({ lists: true, engines: true, pagesPerList: true }), listId: z.string(), listName: z.string(), page: z.number().int(), items: z.array(ItemSchema) });28export type CardrushPayload = z.infer<typeof RawPayloadSchema>;2930/** Parse a product-list page: .item_data blocks + pager. */31export function parseListPage(doc: string, host: string): { title: string | null; items: CardrushItem[]; hasNext: boolean } {32  const $ = html.load(doc);33  const items: CardrushItem[] = [];34  $('.item_data').each((_, el) => {35    const $el = $(el);36    const productId = $el.attr('data-product-id')?.trim() || $el.find('a.item_data_link').attr('href')?.match(/\/product\/(\d+)/)?.[1] || '';37    const href = $el.find('a.item_data_link').attr('href') ?? (productId ? `https://${host}/product/${productId}` : null);38    const title = cleanText($el.find('.goods_name').first().text());39    if (!productId || !href || !title) return;40    const modelNumber = cleanText($el.find('.model_number_value').first().text());41    const priceJpy = yen(cleanText($el.find('.selling_price .figure').first().text()));42    const stockText = cleanText($el.find('.stock').first().text()) ?? '';43    const soldOut = $el.find('.stock.soldout').length > 0 || /在庫なし|売り切れ|SOLD/i.test(stockText);44    const stockM = stockText.replace(/[,,]/g, '').match(/(\d+)\s*(?:枚|点|個)/);45    const stock = soldOut ? 0 : stockM ? Number(stockM[1]) : null;46    const img = $el.find('.global_photo img').first();47    const image = img.attr('data-x2') ?? img.attr('src') ?? null;48    items.push({ productId, url: href.startsWith('http') ? href : `https://${host}${href}`, title, modelNumber, priceJpy, stock, soldOut, image });49  });50  const hasNext = $('.pager a.to_next_page').length > 0;51  const title = cleanText($('title').first().text())?.split(' - ')[0]?.trim() ?? null;52  return { title, items, hasNext };53}5455export class CardrushConnector extends BaseConnector {56  readonly version = '1.0.0';57  readonly parserVersion = PARSER_VERSION;58  protected override minIntervalMs = 4000;5960  private stores(): CardrushStore[] {61    return z.array(StoreSchema).parse(this.meta.config.stores ?? []);62  }6364  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {65    let stores = this.stores();66    if (ctx.options.seeds?.length) stores = stores.filter((s) => ctx.options.seeds!.some((seed) => seed === s.host || seed === s.categorySlug || seed.startsWith(`${s.host}/`)));67    const backfill = ctx.options.mode === 'backfill';68    let storeIdx = Number(ctx.options.cursor?.storeIdx ?? 0);69    let listIdx = Number(ctx.options.cursor?.listIdx ?? 0);70    let page = Number(ctx.options.cursor?.page ?? 1);71    let count = 0;72    for (; storeIdx < stores.length; storeIdx++, listIdx = 0) {73      const store = stores[storeIdx]!;74      const lists = Object.entries(store.lists);75      const maxPages = backfill ? this.policy.backfillMaxPages : Number(store.pagesPerList ?? this.meta.config.pagesPerList ?? 3);76      const engines = (store.engines ?? ['api']) as Engine[];77      for (; listIdx < lists.length; listIdx++, page = 1) {78        const [listId, listName] = lists[listIdx]!;79        for (; page <= maxPages; page++) {80          if (ctx.signal?.aborted) return;81          if (this.reached(ctx, count)) {82            await ctx.setCursor({ storeIdx, listIdx, page });83            return;84          }85          const url = `https://${store.host}/product-list/${listId}${page > 1 ? `?page=${page}` : ''}`;86          await this.throttle(url);87          const res = await ctx.fetch(url, {88            engines,89            headers: HTML_HEADERS,90            responseType: 'text',91            renderJs: false,92            country: 'jp',93            expect: ['title', 'price'],94            parse: (r) => {95              const parsed = parseListPage(r.html ?? '', store.host);96              return { title: parsed.items.length ? 'ok' : null, price: parsed.items.some((i) => i.priceJpy) ? 1 : null };97            },98          });99          if (!res.success || !res.html) {100            ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);101            break;102          }103          const { title, items, hasNext } = parseListPage(res.html, store.host);104          if (!items.length) {105            if (page === 1) ctx.anomaly('parse_failure_page', `${url}: no .item_data blocks`);106            break;107          }108          count++;109          const payload: CardrushPayload = { store: { host: store.host, categorySlug: store.categorySlug, franchise: store.franchise ?? null, brand: store.brand ?? null, bracket: store.bracket }, listId, listName: title ?? listName, page, items };110          yield { url, externalId: `${store.host}:${listId}:p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };111          await ctx.setCursor({ storeIdx, listIdx, page: page + 1 });112          await ctx.progress({ page, totalPages: null, itemsProcessed: count });113          if (!hasNext) break;114        }115      }116    }117    await ctx.setCursor({ storeIdx: 0, listIdx: 0, page: 1, completedAt: new Date().toISOString(), done: true });118  }119120  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {121    const p = RawPayloadSchema.parse(raw.payload);122    const out: NormalizedRecord[] = [];123    const observedAt = raw.fetchedAt;124    const seen = new Set<string>();125    for (const it of p.items) {126      if (it.priceJpy === null || seen.has(it.productId)) continue;127      seen.add(it.productId);128      if (JP_EXCLUDE_RE.test(it.title)) continue;129      const t = parseJpCardTitle(it.title, { bracket: p.store.bracket });130      const language = t.language ?? (t.notes.some((n) => /英語/.test(n)) ? 'English' : 'Japanese');131      const isBundle = isJpBundle(it.title, t.quantity);132      const variant = jpVariant(t.notes, t.name);133      const a = attrs({134        categorySlug: p.store.categorySlug,135        franchise: p.store.franchise ?? null,136        brand: p.store.brand ?? null,137        setCode: t.setCode,138        name: t.name,139        number: t.number,140        variant,141        language,142        rarity: t.rarity,143        // model_number is the shop's katakana/romaji reading of the name (a search key), NOT a SKU/GTIN → never emitted as `sku`144        identifiers: { cardrush_product_id: `${p.store.host}/${it.productId}`, ...(it.modelNumber ? { cardrush_model_number: it.modelNumber } : {}) },145        metadata: { store: p.store.host, list_id: p.listId, list_name: p.listName, total: t.total, notes: t.notes, quantity: t.quantity, sealed: t.sealed },146      });147      out.push(148        NormalizedListingSchema.parse({149          kind: 'listing',150          connectorId: this.meta.id,151          sourceId: this.meta.sourceId,152          sourceUrl: it.url,153          externalId: `${p.store.host}:${it.productId}`,154          rawTitle: it.title,155          imageUrls: it.image ? [it.image] : [],156          attributes: a,157          grade: { grader: t.grader, grade: t.grade, qualifier: t.qualifier, certificationNumber: null },158          condition: { condition: jpCondition(t.conditionRaw), conditionRaw: t.conditionRaw, completeness: t.sealed ? 'sealed' : null },159          observedAt,160          confidence: t.grade ? 0.8 : 0.75,161          parserVersion: PARSER_VERSION,162          listingType: 'fixed_price',163          price: it.priceJpy,164          currency: 'JPY',165          seller: 'Cardrush',166          location: 'JP',167          quantity: isBundle ? t.quantity : it.stock,168          availability: it.soldOut ? 'ended' : 'available',169        }),170      );171    }172    return out;173  }174}175176export default (meta: ConnectorMeta) => new CardrushConnector(meta);177