import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; import { conditionFromParens, moneyNum, normalizeConditionWord, readSeedCursor, yearOrDecade } from '../_g10-lib/index.js'; /** * Peyton Street Pens (Santa Cruz, CA) — vintage & modern fountain-pen dealer on BigCommerce. Category * pages (//?page=N, 24 cards) are server-rendered: each
  • card carries the * product URL, title, SKU, brand, price ($, USD) and CDN image. Dealer asking prices → listings. */ const BASE = 'https://www.peytonstreetpens.com'; const PARSER_VERSION = '1.0.0'; const PAGE_SIZE = 24; export const SeedSchema = z.object({ path: z.string(), brand: z.string().nullable().optional() }); export type Seed = z.infer; export const CardSchema = z.object({ productId: z.string().nullable(), sku: z.string().nullable(), url: z.string(), title: z.string(), brand: z.string().nullable(), price: z.number().nullable(), rrp: z.number().nullable(), image: z.string().nullable(), soldOut: z.boolean().default(false) }); export type Card = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('listing_page'), url: z.string(), seed: SeedSchema, page: z.number().int(), totalPages: z.number().int().nullable(), cards: z.array(CardSchema), snapshot: z.string().optional() }); export type PagePayload = z.infer; const ConfigSchema = z.object({ seeds: z.array(SeedSchema).min(1), pagesPerSeed: z.number().int().min(1).default(2), seedsPerRun: z.number().int().min(1).default(4), /** titles matching this are accessories/consumables, not pens */ exclude: z.string().default('\\b(ink|inks|inkwell|ink well|converter|refills?|leads|notebook|paper|pen case|pen wrap|pouch|box only|parts|sac|o-ring|gift certificate|book|poster|t-shirt|stand|display)\\b'), }); /** BigCommerce "brand" on this shop is often the product type (PEN SET, BALLPOINT…) rather than a maker. */ const GENERIC_BRANDS = /^(pen set|pen sets|ballpoint|rollerball|fountain pen|mechanical pencil|pencil|pens?|desk set|dip pen|other|misc|unknown|various|n\/a|none|vintage|new|used)$/i; const PEN_TYPE = /\b(fountain pen|ballpoint pen|ballpoint|rollerball|roller ball|mechanical pencil|pencil|pen & pencil set|pen and pencil set|pen set|desk set|dip pen|felt tip|fineliner|pen\b)/i; export function parseCategoryHtml(htmlText: string): { cards: Card[]; totalPages: number | null } { const $ = H.load(htmlText); const cards: Card[] = []; $('li.product').each((_, el) => { const e = $(el); const a = e.find('.card-title a').first(); const url = a.attr('href'); const title = H.text(a); if (!url || !title) return; const priceText = H.text(e.find('.price--withoutTax').filter((_, p) => !$(p).hasClass('price--rrp')).first()); const rrpText = H.text(e.find('.price--rrp').first()); const img = e.find('img.card-image').first(); const image = img.attr('data-src') ?? (img.attr('src')?.includes('/products/') ? img.attr('src')! : null); cards.push({ productId: e.find('[data-product-id]').first().attr('data-product-id') ?? null, sku: H.text(e.find('.sku-value').first()), url: url.startsWith('http') ? url : `${BASE}${url}`, title, brand: H.text(e.find('.card-text.brand a').first()), price: moneyNum(priceText), rrp: moneyNum(rrpText), image: image ? image.replace(/\/stencil\/\d+x\d+\//, '/stencil/1280x1280/') : null, soldOut: /\b(sold out|sold)\b/i.test(e.find('.card-figcaption, .sale-flag, .card-badge, .card-text').text()) || /\bSOLD\b/.test(title), }); }); const pages = $('a[href*="?page="]').map((_, a) => Number(($(a).attr('href') ?? '').match(/[?&]page=(\d+)/)?.[1] ?? 0)).get().filter((n) => n > 0); return { cards, totalPages: pages.length ? Math.max(...pages) : cards.length ? 1 : null }; } /** First `n` product cards + pagination anchors (fixture snapshot). */ export function trimCategoryHtml(htmlText: string, n = 3): string { const $ = H.load(htmlText); const cards = $('li.product').toArray().slice(0, n).map((el) => $.html(el)); const pages = [...new Set($('a[href*="?page="]').map((_, a) => $(a).attr('href') ?? '').get())].map((h) => `p`).join(''); return `
      ${cards.join('\n')}
    `; } export function pageUrl(seedPath: string, page: number): string { const p = seedPath.endsWith('/') ? seedPath : `${seedPath}/`; return `${BASE}${p}${page > 1 ? `?page=${page}` : ''}`; } /** "Sheaffer Craftsman Fountain Pen & Pencil Set (1950s) - Burgundy w/GT, …" → brand/model/type. */ export function parsePenTitle(title: string, cardBrand: string | null): { brand: string | null; model: string | null; penType: string | null; variant: string | null } { const head = title.split(/\s+-\s+|\s*\(/)[0]?.trim() ?? title; const typeM = head.match(PEN_TYPE); const penType = typeM ? typeM[0].replace(/\s+/g, ' ').toLowerCase() : null; const beforeType = typeM ? head.slice(0, typeM.index).trim() : head; let brand = cardBrand && !GENERIC_BRANDS.test(cardBrand) ? cardBrand.replace(/\s+/g, ' ').trim() : null; if (brand && /^[A-Z0-9 &'.-]+$/.test(brand) && brand.length > 3) brand = brand.split(' ').map((w) => (w.length > 2 ? w[0] + w.slice(1).toLowerCase() : w)).join(' '); const words = beforeType.split(/\s+/).filter(Boolean); if (!brand && words.length) brand = /^(montblanc|mont|parker|sheaffer|waterman|wahl|eversharp|pelikan|pilot|namiki|sailor|platinum|nakaya|aurora|omas|visconti|montegrappa|conklin|esterbrook|conway|lamy|kaweco|cross|dunhill|cartier|tiffany|delta|stipula|ranga|lotus|leonardo|scribo|faber-castell|graf|caran|twsbi|danitrio|s\.t\.|st\.)$/i.test(words[0]!) ? (words[0] === 'Conway' || words[0] === 'Wahl' || /^(mont|caran|graf|s\.t\.|st\.)$/i.test(words[0]!) ? words.slice(0, 2).join(' ') : words[0]!) : words[0]!; let model: string | null = null; if (brand) { const rest = beforeType.replace(new RegExp(`^${brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'i'), '').trim(); model = rest && rest.length <= 40 ? rest : null; } const variant = title.match(/\s+-\s+([^()]+?)(?:\s*\(|$)/)?.[1]?.trim() ?? null; return { brand, model: model || null, penType, variant: variant && variant.length <= 80 ? variant : null }; } export class PeytonStreetPensConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 3000; private readonly cfg: z.infer; constructor(meta: ConnectorMeta) { super(meta); this.cfg = ConfigSchema.parse(meta.config); } async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = ctx.options.seeds?.length ? ctx.options.seeds.map((s) => SeedSchema.parse({ path: s.startsWith('/') ? s : new URL(s).pathname })) : this.cfg.seeds; const backfill = ctx.options.mode === 'backfill'; const maxPages = backfill ? this.policy.backfillMaxPages : this.cfg.pagesPerSeed; const start = readSeedCursor(ctx.options.cursor, seeds.length); const seedsThisRun = backfill ? seeds.length : Math.min(seeds.length, this.cfg.seedsPerRun); let count = 0; let items = 0; for (let k = 0; k < seedsThisRun; k++) { const seedIndex = (start.seedIndex + k) % seeds.length; const seed = seeds[seedIndex]!; let page = k === 0 ? start.page : 1; for (; page <= maxPages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = pageUrl(seed.path, page); await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', failOnHttpError: false, expect: ['title', 'price', 'currency'], parse: (r) => { const p = r.html ? parseCategoryHtml(r.html) : null; const priced = p?.cards.find((c) => c.price); return p?.cards.length ? { title: p.cards[0]!.title, price: priced?.price ?? null, currency: priced ? 'USD' : null } : null; }, minQuality: 0.3, }); if (res.httpStatus === 404) { ctx.anomaly('selector_missing', `${url}: category not found (404) — update config.seeds`); break; } const parsed = res.success && res.html ? parseCategoryHtml(res.html) : null; if (!parsed) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } if (!parsed.cards.length) { if (page === 1) ctx.anomaly('parse_failure_page', `${url}: no product cards`); break; } count++; items += parsed.cards.length; const payload: PagePayload = { kind: 'listing_page', url, seed, page, totalPages: parsed.totalPages, cards: parsed.cards }; yield { url, externalId: `${seed.path}:p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ seedIndex, page: page + 1, at: new Date().toISOString() }); await ctx.progress({ page, totalPages: parsed.totalPages, itemsProcessed: items }); if (parsed.cards.length < PAGE_SIZE || (parsed.totalPages !== null && page >= parsed.totalPages)) break; } const nextSeed = (seedIndex + 1) % seeds.length; await ctx.setCursor({ seedIndex: nextSeed, page: 1, at: new Date().toISOString(), ...(backfill && nextSeed === 0 ? { done: true } : {}) }); } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const exclude = new RegExp(this.cfg.exclude, 'i'); const out: NormalizedRecord[] = []; for (const c of p.cards) { if (!c.price) continue; const isPen = PEN_TYPE.test(c.title) || /\bpen\b/i.test(c.title); const isLighter = /\blighter\b/i.test(c.title); if (!isLighter && (!isPen || (exclude.test(c.title) && !/\b(fountain pen|ballpoint|rollerball|pencil set|pen set)\b/i.test(c.title)))) continue; const t = parsePenTitle(c.title, c.brand && !GENERIC_BRANDS.test(c.brand.trim()) ? c.brand : (p.seed.brand ?? null)); const condRaw = conditionFromParens(c.title); const { year, decade } = yearOrDecade(c.title); const nib = c.title.match(/\b(extra[- ]fine|fine|medium|broad|stub|italic|flex(?:ible)?|oblique)\b[^,()-]*\bnib\b/i)?.[0] ?? null; const attributes = AssetAttributesSchema.parse({ categorySlug: isLighter ? 'lighters' : 'pens', brand: t.brand, model: t.model, name: t.brand && t.model ? `${t.brand} ${t.model}${t.penType ? ` ${t.penType}` : ''}` : c.title.split(/\s+-\s+|\s*\(/)[0]!.trim(), variant: t.variant, year, identifiers: { ...(c.sku ? { peyton_sku: c.sku } : {}), ...(c.productId ? { peyton_product_id: c.productId } : {}) }, metadata: { pen_type: t.penType, decade, nib, list_price: c.rrp, seed: p.seed.path, restored: /\brestored\b/i.test(c.title), new_old_stock: /\b(new old stock|nos)\b/i.test(c.title) }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.url, externalId: c.sku ?? c.productId ?? c.url, rawTitle: c.title, imageUrls: c.image ? [c.image] : [], attributes, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: normalizeConditionWord(condRaw), conditionRaw: condRaw, completeness: /\b(new in box|in box|with box)\b/i.test(c.title) ? 'box_papers' : null }, observedAt: raw.fetchedAt, confidence: 0.78, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: c.price, currency: 'USD', seller: 'Peyton Street Pens', location: 'Santa Cruz, California, United States', availability: c.soldOut ? 'sold' : 'available', }), ); } return out; } } export default function createConnector(meta: ConnectorMeta): PeytonStreetPensConnector { return new PeytonStreetPensConnector(meta); }