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%
6.4 KB · 147 lines typescript
Raw Blame History
1import { z } from 'zod';2import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { parseGradeFromTitle } from '@rareindex/taxonomy';4import { AssetAttributesSchema, NormalizedSaleSchema, type NormalizedRecord } from '@rareindex/shared';56/**7 * aucfree — closed Yahoo! Auctions Japan lots (JPY hammer prices) for Japanese collectibles.8 * One raw record per search-result page; normalise → one sale per row.9 */1011const BASE = 'https://aucfree.com';12const PARSER_VERSION = '1.0.0';1314const SeedSchema = z.object({ q: z.string(), category: z.string(), language: z.string().nullable().default(null) });15type Seed = z.infer<typeof SeedSchema>;1617export const RowSchema = z.object({ id: z.string(), url: z.string(), title: z.string(), priceJpy: z.number(), bids: z.number().nullable(), endedOn: z.string(), image: z.string().nullable() });18export const PagePayloadSchema = z.object({ kind: z.literal('search_page'), url: z.string(), seed: SeedSchema, page: z.number(), rows: z.array(RowSchema) });19export type PagePayload = z.infer<typeof PagePayloadSchema>;2021export function parseSearchPage(htmlText: string, url: string, seed: Seed, page: number): PagePayload {22  const $ = H.load(htmlText);23  const rows: z.infer<typeof RowSchema>[] = [];24  $('tr.results_bid').each((_, tr) => {25    const $tr = $(tr);26    const a = $tr.find('a.item_title').first();27    const href = a.attr('href') ?? '';28    const id = href.match(/\/items\/([a-z0-9]+)/i)?.[1];29    const title = H.text(a);30    const priceTxt = H.text($tr.find('.item_price').first()) ?? '';31    const price = Number(priceTxt.replace(/[^\d]/g, ''));32    const bidsTxt = H.text($tr.find('td.results-bid').first());33    const bids = bidsTxt ? Number(bidsTxt.replace(/[^\d]/g, '')) : null;34    const endedOn = H.text($tr.find('td.results-limit').first()) ?? '';35    const img = $tr.find('.results_bid-image img').attr('data-src') ?? $tr.find('.results_bid-image img').attr('src') ?? null;36    if (!id || !title || !price || !endedOn) return;37    rows.push({ id, url: `${BASE}/items/${id}`, title, priceJpy: price, bids: Number.isFinite(bids as number) ? bids : null, endedOn, image: img });38  });39  return { kind: 'search_page', url, seed, page, rows };40}4142/** "2026年9月6日" → UTC date */43export function parseJapaneseDate(s: string): Date | null {44  const m = s.match(/(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日/);45  if (!m) return null;46  return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));47}4849/** Normalise "PSA10" / "PSA10" / "BGS9.5" so the shared grade parser can read them. */50export function normaliseGradeText(title: string): string {51  return title52    .normalize('NFKC')53    .replace(/\b(PSA|BGS|CGC|SGC|ARS|ACE)\s*(\d{1,2}(?:\.\d)?)/gi, '$1 $2')54    .replace(/【|】|\[|\]/g, ' ');55}5657const BUNDLE_RE = /まとめ|セット売り|大量|\d+\s*枚セット|\d+\s*点セット|おまとめ|引退品/;5859export class AucfreeConnector extends BaseConnector {60  readonly version = '1.0.0';61  readonly parserVersion = PARSER_VERSION;62  protected override minIntervalMs = 2000;6364  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {65    const seeds = z.array(SeedSchema).parse(this.meta.config.seeds ?? []);66    const pages = Number(this.meta.config.pagesPerSeed ?? 2);67    const filter = ctx.options.categories;68    let count = 0;69    for (const seed of seeds) {70      if (filter?.length && !filter.includes(seed.category)) continue;71      for (let page = 1; page <= pages; page++) {72        if (ctx.signal?.aborted || this.reached(ctx, count)) return;73        const url = `${BASE}/search?o=t2&q=${encodeURIComponent(seed.q)}${page > 1 ? `&p=${page}` : ''}`;74        await this.throttle();75        const res = await ctx.fetch(url, {76          engines: ['firecrawl', 'scrapfly'],77          expect: ['title', 'price', 'date', 'status'],78          parse: (r) => {79            const p = r.html ? parseSearchPage(r.html, url, seed, page) : null;80            const row = p?.rows[0];81            return row ? { title: row.title, price: row.priceJpy, date: row.endedOn, status: 'sold' } : null;82          },83        });84        const payload = res.success && res.html ? parseSearchPage(res.html, url, seed, page) : null;85        if (!payload || payload.rows.length === 0) {86          ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);87          break;88        }89        count++;90        yield { url, externalId: `search:${seed.q}:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };91      }92    }93  }9495  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {96    const p = PagePayloadSchema.parse(raw.payload);97    const out: NormalizedRecord[] = [];98    for (const r of p.rows) {99      const saleDate = parseJapaneseDate(r.endedOn);100      if (!saleDate) continue;101      const cleaned = normaliseGradeText(r.title);102      const g = parseGradeFromTitle(cleaned);103      const isBundle = BUNDLE_RE.test(r.title);104      const attributes = AssetAttributesSchema.parse({105        categorySlug: p.seed.category,106        name: r.title.normalize('NFKC').replace(/\s+/g, ' ').trim(),107        language: p.seed.language,108        country: 'JP',109        identifiers: { yahoo_auction_id: r.id },110        metadata: { seed_query: p.seed.q, bids: r.bids },111      });112      out.push(113        NormalizedSaleSchema.parse({114          kind: 'sale',115          connectorId: this.meta.id,116          sourceId: this.meta.sourceId,117          sourceUrl: r.url,118          externalId: r.id,119          rawTitle: r.title,120          imageUrls: r.image ? [r.image] : [],121          attributes,122          grade: { grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grade, qualifier: g.qualifier, certificationNumber: null },123          condition: { condition: null, conditionRaw: null, completeness: null },124          observedAt: raw.fetchedAt,125          confidence: 0.7,126          parserVersion: PARSER_VERSION,127          saleType: 'auction',128          saleDate,129          price: r.priceJpy,130          currency: 'JPY',131          buyerPremiumIncluded: false,132          quantity: 1,133          isBundle,134          location: 'Japan',135          auctionHouse: 'Yahoo! Auctions Japan',136          lotNumber: r.id,137        }),138      );139    }140    return out;141  }142}143144export default function createConnector(meta: ConnectorMeta) {145  return new AucfreeConnector(meta);146}147