import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { NormalizedAuctionLotSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; import { normalizeCondition } from '@rareindex/taxonomy'; import { attrs } from '../../api/_lib/shared.js'; import { monthIndex, parseComicGrade, parseComicTitle, parseLabelNumber, publisherCategory, usd } from '../../api/_g6-comics-toys-games-lib/comics.js'; /** * MyComicShop (Lone Star Comics) — public title pages /search?TID=&mingr=0 list every issue of a * series with its in-stock copies as schema.org/Product microdata (name incl. grade, sku = ItemID, price, * availability) plus CGC/CBCS label numbers, paper quality, consignment/premium notes. Auction copies * appear on the same page ("Auction Item: CGC 8.5", current bid, time left) → auction_lot records. * The site is fronted by Imperva Incapsula → Scrapfly (no JS rendering, ~1 credit per title page). */ const SITE = 'https://www.mycomicshop.com'; const PARSER_VERSION = '1.0.0'; export const StockItemSchema = z.object({ itemId: z.string(), name: z.string(), url: z.string(), /** microdata price (for consignments this already includes the 3% buyer's premium) */ priceMicro: z.number().nullable(), /** price as displayed to shoppers (seller's asking price) */ priceShown: z.number().nullable(), availability: z.enum(['available', 'sold', 'ended', 'unknown']), gradeText: z.string().nullable(), label: z.string().nullable(), notes: z.array(z.string()), bestOffer: z.boolean(), consignment: z.boolean(), consignor: z.string().nullable(), auction: z.object({ currentBid: z.number().nullable(), bids: z.number().nullable(), timeLeft: z.string().nullable(), opens: z.string().nullable() }).nullable(), }); export type StockItem = z.infer; export const IssueBlockSchema = z.object({ ivid: z.string(), seriesTitle: z.string(), tid: z.string().nullable(), issue: z.string().nullable(), published: z.string().nullable(), publisher: z.string().nullable(), image: z.string().nullable(), tags: z.array(z.string()), items: z.array(StockItemSchema), }); export type IssueBlock = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('title_page'), url: z.string(), tid: z.string(), title: z.string().nullable(), issues: z.array(IssueBlockSchema), snapshot: z.string().optional() }); export type PagePayload = z.infer; function availabilityOf(v: string | undefined): StockItem['availability'] { const s = (v ?? '').toLowerCase(); if (/instock|preorder|limited/.test(s)) return 'available'; if (/soldout/.test(s)) return 'sold'; if (/outofstock|discontinued/.test(s)) return 'ended'; return 'unknown'; } /** Parse a title page into issue blocks with their stock rows. Exported for tests. */ export function parseTitlePage(htmlText: string, url: string, tid: string): PagePayload { const $ = H.load(htmlText); const title = H.text($('title')) ?? null; const issues: IssueBlock[] = []; $('li.issue').each((_, li) => { const $li = $(li); const ivid = $li.find('a[name]').first().attr('name') ?? $li.find('.fancyboxthis').first().attr('id') ?? ''; if (!ivid) return; const titleA = $li.find('.othercolleft .title a').first(); const seriesTitle = H.text(titleA) ?? ''; const tidM = (titleA.attr('href') ?? '').match(/TID=(\d+)/); const issue = (H.text($li.find('.othercolleft .title .issuenum')) ?? '').replace(/^#/, '') || null; const right = H.text($li.find('.othercolright')) ?? ''; const published = right.match(/Published\s+(.+?)(?:\s+by\s|$)/)?.[1]?.trim() ?? null; const publisher = H.text($li.find('.othercolright a[href*="pl="]')) ?? null; const image = $li.find('.imgcol img').first().attr('src') ?? null; const tags = $li.find('.indentrow a').map((__, a) => H.text($(a)) ?? '').get().filter(Boolean); const items: StockItem[] = []; $li.find('td[itemtype="http://schema.org/Product"]').each((__, td) => { const $td = $(td); const meta = (prop: string) => $td.find(`meta[itemprop="${prop}"]`).first().attr('content'); const auctionA = $td.find('a[title="View Auction"]').first(); const isAuction = auctionA.length > 0; const itemId = meta('sku') ?? (auctionA.attr('href') ?? $td.find('a[href*="ItemID="]').first().attr('href') ?? '').match(/ItemID=(\d+)/)?.[1] ?? ''; if (!itemId) return; const notes = $td.find('ul li').map((___, l) => (H.text($(l)) ?? '').replace(/\s+/g, ' ').trim()).get().filter(Boolean); const cartText = H.text($td.find('.addcart a')) ?? ''; const gradeText = isAuction ? (H.text(auctionA) ?? '').replace(/^Auction Item:\s*/i, '') || null : cartText.replace(/^Add to cart\s*/i, '').trim() || null; const shownM = ($td.find('.hasscan').first().text() ?? '').match(/\$\s?[\d,]+(?:\.\d{2})?/); const groupText = H.text($td) ?? ''; const currentBid = usd(groupText.match(/Current bid:\s*(\$[\d,.]+)/i)?.[1]); const bids = groupText.match(/(\d+)\s+bids?\b/i)?.[1]; const timeLeft = groupText.match(/Time left:\s*([^\n<]+?)(?:\s{2,}|$)/i)?.[1]?.trim() ?? null; const opens = groupText.match(/Auction opens\s+([A-Za-z]+\s+\d{1,2})/i)?.[1] ?? null; const consignmentNote = notes.find((n) => /consignment/i.test(n)) ?? null; const consignor = consignmentNote?.match(/consigned by\s+(.+)$/i)?.[1]?.trim() ?? null; items.push({ itemId, name: meta('name') ?? `${seriesTitle} ${issue ?? ''} ${gradeText ?? ''}`.trim(), url: meta('url') ?? `${SITE}/search?ItemID=${itemId}`, priceMicro: usd(meta('price')), priceShown: shownM ? usd(shownM[0]) : null, availability: isAuction ? 'unknown' : availabilityOf(meta('availability')), gradeText, label: parseLabelNumber(notes.join(' | ')), notes: notes.filter((n) => !/^Label\s*#/i.test(n)), bestOffer: /Best Offer/i.test(groupText), consignment: Boolean(consignmentNote), consignor, auction: isAuction ? { currentBid, bids: bids ? Number(bids) : null, timeLeft, opens } : null, }); }); issues.push({ ivid, seriesTitle, tid: tidM?.[1] ?? null, issue, published, publisher, image, tags, items }); }); return { kind: 'title_page', url, tid, title, issues }; } /** "May 1988" → 1988; "Aug 2026" → 2026. */ export function yearOfPublished(s: string | null | undefined): number | null { const m = s?.match(/\b(1[89]\d{2}|20\d{2})\b/); return m ? Number(m[1]) : null; } /** "Auction opens September 14" (no year on the page) → null (never invent a year). */ export function auctionStatus(a: NonNullable): 'upcoming' | 'live' | 'unknown' { if (a.opens) return 'upcoming'; if (a.timeLeft || a.currentBid !== null) return 'live'; return 'unknown'; } export class MyComicShopConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 3000; override readonly urlPatterns = [/^https?:\/\/(?:www\.)?mycomicshop\.com\/search\?(?:.*&)?TID=(\d+)/i]; private async fetchTitle(ctx: CrawlContext, tid: string): Promise { const url = `${SITE}/search?TID=${tid}&mingr=0`; await this.throttle(url); const res = await ctx.fetch(url, { engines: ['scrapfly'], renderJs: false, country: 'us', timeoutMs: 120_000, expect: ['title', 'price', 'identifiers'], parse: (r) => { if (!r.html) return null; const p = parseTitlePage(r.html, url, tid); const first = p.issues.flatMap((i) => i.items)[0]; return { title: p.issues[0]?.seriesTitle ?? null, price: first?.priceMicro ?? first?.priceShown ?? null, identifiers: first?.itemId ?? null }; }, }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } const payload = parseTitlePage(res.html, url, tid); if (!payload.issues.length) { ctx.anomaly('parse_failure_page', url); return null; } return { url, externalId: `tid:${tid}`, kind: 'listing', engine: 'scrapfly', httpStatus: res.httpStatus, payload, snapshot: res.html, fetchedAt: res.fetchedAt }; } async *crawl(ctx: CrawlContext): AsyncIterable { const titles = ((this.meta.config.titles as Array<{ tid: string; name: string }> | undefined) ?? []).map((t) => String(t.tid)); const seeds = ctx.options.seeds?.length ? ctx.options.seeds.map((s) => s.match(/TID=(\d+)/i)?.[1] ?? s.replace(/\D/g, '')).filter(Boolean) : titles; if (!seeds.length) { ctx.anomaly('config_missing', 'no titles configured'); return; } const backfill = ctx.options.mode === 'backfill'; const perRun = ctx.options.mode === 'probe' ? Math.max(1, ctx.options.limit ?? 1) : backfill ? seeds.length : Number(this.meta.config.titlesPerRun ?? 6); const cursor = (ctx.options.cursor ?? {}) as { index?: number }; let index = ctx.options.seeds?.length ? 0 : Math.max(0, Number(cursor.index ?? 0)) % seeds.length; let count = 0; for (let n = 0; n < perRun && n < seeds.length; n++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const tid = seeds[index]!; const rec = await this.fetchTitle(ctx, tid); index = (index + 1) % seeds.length; if (rec) { count++; yield rec; } await ctx.setCursor({ index, updatedAt: new Date().toISOString() }); if (backfill) await ctx.progress({ page: n + 1, totalPages: seeds.length, itemsProcessed: count }); } if (backfill) await ctx.setCursor({ done: true, index: 0, updatedAt: new Date().toISOString() }); } async lookup(url: string, ctx: CrawlContext): Promise { const tid = url.match(this.urlPatterns[0]!)?.[1]; if (!tid) return []; const rec = await this.fetchTitle(ctx, tid); return rec ? [rec] : []; } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; for (const blk of p.issues) { // seriesTitle = "Amazing Spider-Man (1963 1st Series)"; the issue number comes from the page, never re-parsed. const t = parseComicTitle(blk.seriesTitle); const categorySlug = publisherCategory(blk.publisher); const year = yearOfPublished(blk.published) ?? t.year; const series = blk.seriesTitle; for (const it of blk.items) { const g = parseComicGrade(it.gradeText ?? it.name); const graded = g.grader && g.grader !== 'raw'; const qualifier = it.notes.some((n) => /Signature Series/i.test(n)) ? 'Signature Series' : g.qualifier; const attributes = attrs({ categorySlug, brand: blk.publisher, series, set: t.series, name: `${t.series}${blk.issue ? ` #${blk.issue}` : ''}`, number: blk.issue, year, variant: t.variant, language: 'English', country: 'US', identifiers: { mycomicshop_item_id: it.itemId, mycomicshop_ivid: blk.ivid, ...(blk.tid ? { mycomicshop_tid: blk.tid } : {}) }, metadata: { published: blk.published, tags: blk.tags.slice(0, 10), notes: it.notes.slice(0, 8), grade_label: g.label, consignment: it.consignment, consignor: it.consignor, price_incl_buyer_premium: it.consignment ? it.priceMicro : null, buyer_premium_pct: it.consignment ? 3 : null, best_offer: it.bestOffer }, }); const grade = { grader: g.grader, grade: graded ? g.grade : null, qualifier, certificationNumber: graded ? it.label : null }; const condition = { condition: !graded ? normalizeCondition(categorySlug, g.label) : null, conditionRaw: !graded && g.label ? `${g.label}${g.grade ? ` ${g.grade}` : ''}` : null, completeness: null }; const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: it.url, externalId: it.itemId, rawTitle: it.name, description: it.notes.length ? it.notes.join('; ') : null, imageUrls: blk.image ? [blk.image.replace('/n_iv/120/', '/n_iv/600/')] : [], attributes, grade, condition, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, }; if (it.auction) { out.push( NormalizedAuctionLotSchema.parse({ ...base, kind: 'auction_lot', confidence: 0.8, auctionHouse: 'MyComicShop', auctionName: null, lotNumber: it.itemId, currentBid: it.auction.currentBid, currency: 'USD', status: auctionStatus(it.auction), }), ); continue; } const price = it.priceShown ?? it.priceMicro; if (price === null) continue; out.push( NormalizedListingSchema.parse({ ...base, kind: 'listing', confidence: graded && it.label ? 0.9 : 0.82, listingType: it.bestOffer ? 'best_offer' : 'fixed_price', price, currency: 'USD', seller: it.consignment ? (it.consignor ? `Consignment (${it.consignor})` : 'Consignment via MyComicShop') : 'MyComicShop', location: 'US', availability: it.availability, }), ); } } return out; } } export default (meta: ConnectorMeta) => new MyComicShopConnector(meta);