SPB Git

Repository Public

Commit

Connector layer expansion: SDK (capabilities, domain policies, circuit breaker, adapters Shopify/WooCommerce/sitemap/RSS/PDF/schema.org), resumable backfills, schema-drift + health metrics, certificates provenance, multi-currency views, source registry (443 sources), connector:new scaffolder, admin explorer + coverage dashboard, docs; +119 connectors (212 total) across cards EU/JP, grading cert lookups, Shopify fleets NA/intl, numismatics, comics/toys/games, auction houses NA + EU/APAC, Asia marketplaces, watches, gated eBay/Etsy/Trade Me/Rakuten/Yahoo/Numista/CardTrader

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Sep 8, 2026 parent 88754b4
256 changed files +19,785 −62 Browse files
256 changed files
modified CLAUDE.md +1 −1
@@ -183,7 +183,7 @@ One canonical asset graph containing every collectible, every important variatio
183 183
184 184 - Package manager `pnpm`; `pnpm typecheck`, `pnpm test`, `pnpm build`. Workers run with `tsx`.
185 185 - Secrets only in `.env` (git-ignored) or the `mld` manifest on the cluster. Never in code or fixtures.
186 −- Add a connector: `connectors/<engine>/<id>/{meta.json,index.ts,index.test.ts}` + `data/fixtures/<id>/*.json`, then `pnpm tsx scripts/build-registry.ts` and `pnpm db:seed`.
186 +- Add a connector: `pnpm connector:new <id> --url … [--adapter shopify|woocommerce]` → `connectors/<engine>/<id>/{meta.json,index.ts,index.test.ts,README.md}` + real fixtures in `data/fixtures/<id>/`, source entry in `data/sources/entries/<group>.json`, host policy in `connectors/domains.d/<group>.json`; then `pnpm registry`, `pnpm sources:build`, `pnpm db:seed`. Guide: `docs/connectors/ADDING_A_CONNECTOR.md`; catalogue: `docs/connectors/SOURCES.md`.
187 187 - DB changes: edit `packages/database/src/schema/*`, run `pnpm db:generate`, commit the SQL migration.
188 188 - UI: dense, neutral, tabular numbers (`num` utility), `<Unavailable/>` for missing evidence, every number with its sample size / confidence / freshness when it is an estimate.
189 189 - Member accounts: email + password, email verification code and multi-factor code delivered through Resend (`RESEND_API_KEY`, `EMAIL_FROM`).
modified apps/web/src/app/admin/connectors/[id]/page.tsx +107 −7
@@ -4,6 +4,7 @@ import { requireAdmin } from '@/lib/admin/auth';
4 4 import { connectorDetail } from '@/lib/admin/queries';
5 5 import { connectorAction, updateConnectorConfig } from '@/lib/admin/actions';
6 6 import { AdminShell, ActionButton, StatusPill, Kpi, fmtTs, n } from '@/components/admin/shell';
7 +import { HealthLabel, SourceLogo, Progress, Chip, Json } from '@/components/admin/connector-bits';
7 8 import { Table, th, td, tdNum } from '@/components/ui/primitives';
8 9
9 10 export default async function ConnectorInspectPage({ params }: { params: Promise<{ id: string }> }) {
@@ -14,32 +15,119 @@ export default async function ConnectorInspectPage({ params }: { params: Promise
14 15 const c = d.connector;
15 16 const health = (d.health?.health ?? {}) as Record<string, unknown>;
16 17 const outputs = (d.outputs ?? {}) as Record<string, unknown>;
18 + const meta = (c.meta ?? {}) as Record<string, unknown>;
19 + const activeBackfill = d.backfills.find((b) => b.status === 'running' || b.status === 'paused');
20 + const fieldStats = d.fieldStats as Array<{ field: string; day: string; total: number; nulls: number }>;
21 + const today = new Date().toISOString().slice(0, 10);
22 + const nullRates = Object.values(
23 + fieldStats.reduce<Record<string, { field: string; today: [number, number]; base: [number, number] }>>((acc, r) => {
24 + const e = (acc[r.field] ??= { field: r.field, today: [0, 0], base: [0, 0] });
25 + const t = r.day === today ? e.today : e.base;
26 + t[0] += Number(r.total);
27 + t[1] += Number(r.nulls);
28 + return acc;
29 + }, {}),
30 + );
31 + const actions = [
32 + ['probe', 'Test', 'neutral'],
33 + ['run', 'Run now', 'neutral'],
34 + ['backfill', activeBackfill ? 'Continue backfill' : 'Backfill', 'neutral'],
35 + ...(activeBackfill?.status === 'running' ? [['backfill_pause', 'Pause backfill', 'neutral']] : []),
36 + ...(activeBackfill ? [['backfill_reset', 'Reset backfill', 'danger']] : []),
37 + ['retry', 'Retry from cursor', 'neutral'],
38 + c.status === 'active' ? ['pause', 'Pause', 'danger'] : ['resume', 'Resume', 'primary'],
39 + c.status === 'maintenance' ? ['resume', 'End maintenance', 'primary'] : ['maintenance', 'Maintenance', 'neutral'],
40 + ] as const;
17 41 return (
18 42 <AdminShell
19 43 current="/admin/connectors"
20 44 title={String(c.display_name)}
21 − subtitle={<>{String(c.id)} · source <span className="font-mono">{String(c.source_id)}</span> · {(c.engine_priority as string[]).join(' → ')} · categories {(c.categories as string[]).join(', ')} · <Link href="/admin/connectors" className="underline">back</Link></>}
45 + subtitle={
46 + <>
47 + <SourceLogo domain={String(meta.domain ?? '')} name={String(c.display_name)} /> {String(c.id)} · source <span className="font-mono">{String(c.source_id)}</span> · {String(meta.domain ?? '')} · {String(meta.country ?? (c.regions as string[])?.[0] ?? 'global')} · {(c.engine_priority as string[]).join(' → ')} · {String(meta.acquisitionMethod ?? '')} · categories {(c.categories as string[]).join(', ')} · <Link href="/admin/connectors" className="underline">back</Link>
48 + </>
49 + }
22 50 actions={
23 51 <div className="flex flex-wrap gap-1">
24 − {(['run', 'probe', 'recrawl', 'retry', c.status === 'paused' ? 'resume' : 'pause'] as const).map((a) => (
52 + {actions.map(([a, label, tone]) => (
25 53 <form key={a} action={connectorAction}>
26 54 <input type="hidden" name="id" value={String(c.id)} />
27 55 <input type="hidden" name="action" value={a} />
28 − <ActionButton label={a} small={false} tone={a === 'pause' ? 'danger' : a === 'resume' ? 'primary' : 'neutral'} />
56 + <ActionButton label={label} small={false} tone={tone as 'neutral' | 'danger' | 'primary'} />
29 57 </form>
30 58 ))}
31 59 </div>
32 60 }
33 61 >
62 + <div className="mb-3 flex flex-wrap gap-1">
63 + {((meta.capabilities as string[]) ?? []).map((cap) => (
64 + <Chip key={cap} tone="index">{cap}</Chip>
65 + ))}
66 + {((meta.requires as string[]) ?? []).map((r) => (
67 + <Chip key={r} tone="alert">requires {r}</Chip>
68 + ))}
69 + <Chip>refresh {String(meta.refreshClass ?? '')} · {n(c.refresh_frequency_minutes)} min</Chip>
70 + <Chip>history {String(meta.historicalDepth ?? 'none')}</Chip>
71 + {meta.termsUrl ? (
72 + <a href={String(meta.termsUrl)} target="_blank" rel="noreferrer noopener" className="text-[10px] underline text-subtle">terms</a>
73 + ) : null}
74 + </div>
34 75 <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">
35 − <Kpi label="Status" value={<StatusPill status={String(c.status)} />} sub={<>health <StatusPill status={(d.health?.status as string) ?? 'unknown'} /></>} />
76 + <Kpi label="Status" value={<StatusPill status={String(c.status)} />} sub={<>health <HealthLabel status={(d.health?.status as string) ?? 'unknown'} /></>} />
36 77 <Kpi label="Success 24h" value={health.success_rate_24h != null ? `${Math.round(Number(health.success_rate_24h) * 100)}%` : '—'} sub={`${n(health.pages_success)}/${n(health.pages_attempted)} pages`} />
37 78 <Kpi label="Firecrawl / Scrapfly" value={`${health.firecrawl_success_rate != null ? Math.round(Number(health.firecrawl_success_rate) * 100) + '%' : '—'} / ${health.scrapfly_fallback_rate != null ? Math.round(Number(health.scrapfly_fallback_rate) * 100) + '%' : '—'}`} sub="success / fallback share" />
38 79 <Kpi label="Parse failures" value={health.parse_failure_rate != null ? `${(Number(health.parse_failure_rate) * 100).toFixed(1)}%` : '—'} sub={`${n(health.duplicates_24h)} duplicates 24h`} />
39 80 <Kpi label="Outputs" value={`${n(outputs.sales)} sales`} sub={`${n(outputs.listings)} listings · ${n(outputs.observations)} observations`} />
40 − <Kpi label="Last success" value={fmtTs(c.last_success_at).slice(0, 16)} sub={`next ${fmtTs(c.next_run_at).slice(0, 16)}`} />
81 + <Kpi label="Last success" value={fmtTs(c.last_success_at).slice(0, 16)} sub={`next ${fmtTs(c.next_run_at).slice(0, 16)} · fresh ${health.data_freshness ? fmtTs(health.data_freshness).slice(0, 10) : '—'}`} />
82 + <Kpi label="Requests 24h" value={n(health.requests_24h)} sub={`${health.latency_ms_avg != null ? n(health.latency_ms_avg) + ' ms avg' : '—'} · ${n(health.challenges_24h)} challenges · ${n(health.rate_limited_24h)}× 429`} tone={Number(health.challenges_24h) > 0 ? 'alert' : undefined} />
83 + <Kpi label="HTTP errors 24h" value={Object.entries((health.http_errors as Record<string, number>) ?? {}).map(([k, v]) => `${k}×${v}`).join(' ') || '0'} sub={`${n(health.circuit_refusals_24h)} refused by circuit`} tone={Object.keys((health.http_errors as Record<string, number>) ?? {}).length ? 'loss' : undefined} />
84 + <Kpi label="Volume baseline" value={health.records_7d_daily_avg != null ? `${n(health.records_7d_daily_avg)}/day` : '—'} sub={`${n(health.records_24h)} raw in 24h`} tone={((health.anomalies as string[]) ?? []).some((a) => a.startsWith('result_count_collapse')) ? 'alert' : undefined} />
85 + <Kpi label="Certificates" value={n(outputs.certificates)} sub="cert numbers seen via this source" />
41 86 </div>
42 87
88 + {activeBackfill || d.backfills.length ? (
89 + <section className="card mt-6">
90 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Historical backfill campaigns</h2></header>
91 + <Table>
92 + <thead><tr><th className={th}>Started</th><th className={th}>Status</th><th className={th}>Progress</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Items</th><th className={th}>Reached date</th><th className={`${th} text-right`}>Runs</th><th className={`${th} text-right`}>Errors</th><th className={th}>Last cursor</th><th className={th}>Last error</th></tr></thead>
93 + <tbody>
94 + {d.backfills.map((b) => (
95 + <tr key={String(b.id)}>
96 + <td className={td}>{fmtTs(b.started_at)}</td>
97 + <td className={td}><StatusPill status={String(b.status)} /></td>
98 + <td className={td}><Progress percent={b.percent as number | null} status={String(b.status)} /></td>
99 + <td className={tdNum}>{n(b.pages_processed)}{b.total_pages ? ` / ${n(b.total_pages)}` : ''}</td>
100 + <td className={tdNum}>{n(b.items_processed)}</td>
101 + <td className={td}>{b.reached_date ? String(b.reached_date) : '—'}</td>
102 + <td className={tdNum}>{n(b.runs)}</td>
103 + <td className={tdNum}>{n(b.errors)}</td>
104 + <td className={`${td} max-w-[260px] truncate font-mono text-[10px]`} title={JSON.stringify(b.last_cursor)}>{JSON.stringify(b.last_cursor)}</td>
105 + <td className={`${td} max-w-[220px] truncate text-loss`} title={String(b.last_error ?? '')}>{String(b.last_error ?? '')}</td>
106 + </tr>
107 + ))}
108 + </tbody>
109 + </Table>
110 + </section>
111 + ) : null}
112 +
113 + {nullRates.length ? (
114 + <section className="card mt-6">
115 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Field presence (schema-drift monitor)</h2></header>
116 + <div className="grid grid-cols-2 gap-x-6 gap-y-1 px-4 py-3 text-xs md:grid-cols-4 xl:grid-cols-6">
117 + {nullRates.map((f) => {
118 + const tRate = f.today[0] ? f.today[1] / f.today[0] : null;
119 + const bRate = f.base[0] ? f.base[1] / f.base[0] : null;
120 + const drift = tRate !== null && bRate !== null && tRate - bRate >= 0.3;
121 + return (
122 + <div key={f.field} className={drift ? 'text-alert' : ''}>
123 + <span className="font-mono">{f.field}</span> <span className="text-subtle">null today {tRate === null ? '—' : `${Math.round(tRate * 100)}%`} · 7d {bRate === null ? '—' : `${Math.round(bRate * 100)}%`}</span>
124 + </div>
125 + );
126 + })}
127 + </div>
128 + </section>
129 + ) : null}
130 +
43 131 {d.anomalies.length ? (
44 132 <section className="card mt-6">
45 133 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold text-alert">Anomalies (recent runs)</h2></header>
@@ -81,7 +169,10 @@ export default async function ConnectorInspectPage({ params }: { params: Promise
81 169 <li key={String(r.id)} className="px-4 py-2">
82 170 <div className="flex items-center justify-between gap-2"><span className="font-mono text-[11px]">{String(r.kind)} · {String(r.engine)} · {String(r.http_status ?? '')}</span><span className="text-subtle">{fmtTs(r.fetched_at)}</span></div>
83 171 <a href={String(r.url)} target="_blank" rel="noreferrer noopener" className="block truncate text-muted hover:text-fg">{String(r.url)}</a>
84 − {r.process_error ? <p className="text-loss">{String(r.process_error)}</p> : <p className="text-[10px] text-subtle">{r.processed_at ? `processed ${fmtTs(r.processed_at)}` : 'unprocessed'}</p>}
172 + <div className="flex items-center justify-between">
173 + {r.process_error ? <p className="text-loss">{String(r.process_error)}</p> : <p className="text-[10px] text-subtle">{r.processed_at ? `processed ${fmtTs(r.processed_at)}` : 'unprocessed'}</p>}
174 + <Link href={`/admin/connectors/${String(c.id)}/raw/${String(r.id)}`} className="text-[10px] underline">view payload</Link>
175 + </div>
85 176 </li>
86 177 ))}
87 178 </ul>
@@ -93,7 +184,10 @@ export default async function ConnectorInspectPage({ params }: { params: Promise
93 184 {d.normalizedSample.map((r) => (
94 185 <li key={String(r.id)} className="px-4 py-2">
95 186 <div className="flex items-center justify-between gap-2"><span className="truncate">{String(r.raw_title ?? '')}</span><StatusPill status={String(r.status)} /></div>
96 − <p className="text-[10px] text-subtle">{String(r.kind)} · {r.price ? `${String(r.price)} ${String(r.currency ?? '')}` : ''} · {r.match_method ? `${String(r.match_method)} ${r.match_confidence ? Math.round(Number(r.match_confidence) * 100) + '%' : ''}` : ''} {r.reject_reason ? `· ${String(r.reject_reason)}` : ''}</p>
187 + <div className="flex items-center justify-between">
188 + <p className="text-[10px] text-subtle">{String(r.kind)} · {r.price ? `${String(r.price)} ${String(r.currency ?? '')}` : ''} · {r.match_method ? `${String(r.match_method)} ${r.match_confidence ? Math.round(Number(r.match_confidence) * 100) + '%' : ''}` : ''} {r.reject_reason ? `· ${String(r.reject_reason)}` : ''}</p>
189 + <Link href={`/admin/connectors/${String(c.id)}/record/${String(r.id)}`} className="text-[10px] underline">view record</Link>
190 + </div>
97 191 </li>
98 192 ))}
99 193 </ul>
@@ -128,6 +222,12 @@ export default async function ConnectorInspectPage({ params }: { params: Promise
128 222 </form>
129 223 </section>
130 224 </div>
225 +
226 + <section className="card mt-6">
227 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Access & compliance notes (meta.json)</h2></header>
228 + <div className="px-4 py-3 text-xs leading-relaxed text-muted">{String(meta.accessNotes ?? 'No accessNotes recorded — add them to meta.json (SPEC §36).')}</div>
229 + <details className="border-t border-border px-4 py-2 text-xs"><summary className="cursor-pointer text-subtle">registry entry</summary><Json value={meta} className="mt-2" /></details>
230 + </section>
131 231 </AdminShell>
132 232 );
133 233 }
added apps/web/src/app/admin/connectors/[id]/raw/[rawId]/page.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import Link from 'next/link';
2 +import { notFound } from 'next/navigation';
3 +import { requireAdmin } from '@/lib/admin/auth';
4 +import { rawRecordDetail } from '@/lib/admin/queries';
5 +import { AdminShell, fmtTs } from '@/components/admin/shell';
6 +import { Json } from '@/components/admin/connector-bits';
7 +
8 +/** "View last payload" (SPEC §29): the immutable raw capture with its provenance (engine, parser/connector versions, snapshot ref). */
9 +export default async function RawRecordPage({ params }: { params: Promise<{ id: string; rawId: string }> }) {
10 + await requireAdmin();
11 + const { id, rawId } = await params;
12 + const r = await rawRecordDetail(rawId);
13 + if (!r || String(r.connector_id) !== id) notFound();
14 + const payload = r.payload as Record<string, unknown> | null;
15 + const snapshot = payload && typeof payload === 'object' && typeof payload.snapshot === 'string' ? (payload.snapshot as string) : null;
16 + const rest = snapshot && payload ? Object.fromEntries(Object.entries(payload).filter(([k]) => k !== 'snapshot')) : payload;
17 + return (
18 + <AdminShell current="/admin/connectors" title={`Raw payload ${rawId}`} subtitle={<><Link href={`/admin/connectors/${id}`} className="underline">{id}</Link> · {String(r.kind)} · engine {String(r.engine)} · HTTP {String(r.http_status ?? '—')} · fetched {fmtTs(r.fetched_at)} · parser v{String(r.parser_version)} · connector v{String(r.connector_version)} · run {String(r.run_id ?? '—')}</>}>
19 + <div className="mb-3 text-xs text-muted">
20 + <a href={String(r.url)} target="_blank" rel="noreferrer noopener" className="underline">{String(r.url)}</a> · external id <span className="font-mono">{String(r.external_id ?? '—')}</span> · hash <span className="font-mono">{String(r.content_hash).slice(0, 16)}…</span>
21 + {r.snapshot_ref ? <> · snapshot on disk: <span className="font-mono">{String(r.snapshot_ref)}</span></> : null}
22 + {r.process_error ? <div className="mt-1 text-loss">process error: {String(r.process_error)}</div> : <div className="mt-1 text-subtle">{r.processed_at ? `normalised ${fmtTs(r.processed_at)}` : 'not yet normalised'}</div>}
23 + </div>
24 + <Json value={rest} />
25 + {snapshot ? (
26 + <details className="mt-4 text-xs">
27 + <summary className="cursor-pointer text-subtle">inline snapshot ({snapshot.length.toLocaleString()} chars)</summary>
28 + <Json value={snapshot.slice(0, 200_000)} className="mt-2" />
29 + </details>
30 + ) : null}
31 + </AdminShell>
32 + );
33 +}
added apps/web/src/app/admin/connectors/[id]/record/[recId]/page.tsx +25 −0
@@ -0,0 +1,25 @@
1 +import Link from 'next/link';
2 +import { notFound } from 'next/navigation';
3 +import { requireAdmin } from '@/lib/admin/auth';
4 +import { normalizedRecordDetail } from '@/lib/admin/queries';
5 +import { AdminShell, StatusPill, fmtTs } from '@/components/admin/shell';
6 +import { Json } from '@/components/admin/connector-bits';
7 +
8 +/** "View last parsed record" (SPEC §29): the canonical normalised record and its resolution decision. */
9 +export default async function NormalizedRecordPage({ params }: { params: Promise<{ id: string; recId: string }> }) {
10 + await requireAdmin();
11 + const { id, recId } = await params;
12 + const r = await normalizedRecordDetail(recId);
13 + if (!r || String(r.connector_id) !== id) notFound();
14 + return (
15 + <AdminShell current="/admin/connectors" title={`Normalised record ${recId}`} subtitle={<><Link href={`/admin/connectors/${id}`} className="underline">{id}</Link> · {String(r.kind)} · <StatusPill status={String(r.status)} /> · created {fmtTs(r.created_at)} · from raw <Link href={`/admin/connectors/${id}/raw/${String(r.raw_record_id)}`} className="underline">{String(r.raw_record_id)}</Link></>}>
16 + <div className="mb-3 grid gap-1 text-xs text-muted md:grid-cols-2">
17 + <div>match: <span className="font-mono">{String(r.match_method ?? '—')}</span> {r.match_confidence ? `(${Math.round(Number(r.match_confidence) * 100)}%)` : ''}</div>
18 + <div>asset: {r.asset_id ? <Link href={`/admin/data-quality?asset=${String(r.asset_id)}`} className="font-mono underline">{String(r.asset_id)}</Link> : '—'} · variant <span className="font-mono">{String(r.variant_id ?? '—')}</span></div>
19 + <div>target: <span className="font-mono">{String(r.target_id ?? '—')}</span></div>
20 + <div className={r.reject_reason ? 'text-loss' : ''}>reason: {String(r.reject_reason ?? '—')}</div>
21 + </div>
22 + <Json value={r.payload} />
23 + </AdminShell>
24 + );
25 +}
modified apps/web/src/app/admin/connectors/page.tsx +184 −42
@@ -3,75 +3,210 @@ import { requireAdmin } from '@/lib/admin/auth';
3 3 import { connectorsOverview } from '@/lib/admin/queries';
4 4 import { connectorAction } from '@/lib/admin/actions';
5 5 import { AdminShell, ActionButton, StatusPill, fmtTs, n } from '@/components/admin/shell';
6 +import { HealthLabel, SourceLogo, Pct, Progress, Chip } from '@/components/admin/connector-bits';
6 7 import { Table, th, td, tdNum } from '@/components/ui/primitives';
7 8
8 −function pct(v: unknown): string {
9 − const x = Number(v);
10 − return Number.isFinite(x) ? `${Math.round(x * 100)}%` : '—';
9 +type Row = Record<string, unknown>;
10 +
11 +function str(v: unknown): string {
12 + return v === null || v === undefined ? '' : String(v);
11 13 }
12 14
13 −export default async function ConnectorsPage() {
15 +/** Connector explorer (SPEC §29): every connector with source, geography, acquisition, health, usage, backfill and actions. */
16 +export default async function ConnectorsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
14 17 await requireAdmin();
15 − const rows = await connectorsOverview();
18 + const sp = await searchParams;
19 + const rowsAll = (await connectorsOverview()) as Row[];
20 + const q = (sp.q ?? '').toLowerCase();
21 + const status = sp.status ?? '';
22 + const category = sp.category ?? '';
23 + const country = sp.country ?? '';
24 + const rows = rowsAll.filter((r) => {
25 + const meta = (r.meta ?? {}) as Row;
26 + if (status && String(r.health_status ?? 'unknown') !== status && String(r.status) !== status) return false;
27 + if (category && !((r.categories as string[]) ?? []).includes(category)) return false;
28 + if (country && String(meta.country ?? (r.regions as string[])?.[0] ?? '') !== country) return false;
29 + if (q && !`${str(r.id)} ${str(r.display_name)} ${str(meta.domain)} ${((r.categories as string[]) ?? []).join(' ')}`.toLowerCase().includes(q)) return false;
30 + return true;
31 + });
32 + const counts = rowsAll.reduce<Record<string, number>>((acc, r) => {
33 + const k = String(r.health_status ?? 'unknown');
34 + acc[k] = (acc[k] ?? 0) + 1;
35 + return acc;
36 + }, {});
37 + const categories = [...new Set(rowsAll.flatMap((r) => (r.categories as string[]) ?? []))].sort();
38 + const countries = [...new Set(rowsAll.map((r) => String(((r.meta ?? {}) as Row).country ?? (r.regions as string[])?.[0] ?? 'global')))].sort();
39 +
16 40 return (
17 − <AdminShell current="/admin/connectors" title="Connector control center" subtitle={`${rows.length} connectors registered · actions enqueue jobs on the crawl.run queue consumed by the worker`}>
18 − <div className="card">
41 + <AdminShell
42 + current="/admin/connectors"
43 + title="Connector explorer"
44 + subtitle={
45 + <>
46 + {rowsAll.length} connectors · {Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(' · ')} · <Link href="/admin/coverage" className="underline">coverage dashboard</Link> · <Link href="/api/admin/connectors" className="underline">JSON</Link>
47 + </>
48 + }
49 + actions={
50 + <form className="flex flex-wrap items-center gap-1 text-xs" method="get">
51 + <input name="q" defaultValue={sp.q ?? ''} placeholder="search id, domain, category" className="rounded-md border border-border bg-sunken px-2 py-1 text-xs" />
52 + <select name="status" defaultValue={status} className="rounded-md border border-border bg-sunken px-2 py-1 text-xs">
53 + <option value="">any health</option>
54 + {['healthy', 'degraded', 'failing', 'disabled', 'paused', 'maintenance', 'unknown'].map((s) => (
55 + <option key={s} value={s}>{s}</option>
56 + ))}
57 + </select>
58 + <select name="category" defaultValue={category} className="rounded-md border border-border bg-sunken px-2 py-1 text-xs">
59 + <option value="">any category</option>
60 + {categories.map((c) => (
61 + <option key={c} value={c}>{c}</option>
62 + ))}
63 + </select>
64 + <select name="country" defaultValue={country} className="rounded-md border border-border bg-sunken px-2 py-1 text-xs">
65 + <option value="">any country</option>
66 + {countries.map((c) => (
67 + <option key={c} value={c}>{c}</option>
68 + ))}
69 + </select>
70 + <button type="submit" className="rounded-md border border-border px-2 py-1 text-xs hover:bg-inset">Filter</button>
71 + </form>
72 + }
73 + >
74 + <div className="card overflow-x-auto">
19 75 <Table>
20 76 <thead>
21 77 <tr>
22 − <th className={th}>Connector</th>
78 + <th className={th}>Source</th>
79 + <th className={th}>Category · country</th>
23 80 <th className={th}>Health</th>
24 81 <th className={th}>Status</th>
25 − <th className={`${th} text-right`}>Pages/min</th>
82 + <th className={th}>Acquisition</th>
83 + <th className={th}>Last sync</th>
84 + <th className={`${th} text-right`}>Items 24h</th>
26 85 <th className={`${th} text-right`}>Errors</th>
27 − <th className={th}>Last crawl</th>
28 − <th className={`${th} text-right`}>Records 24h</th>
29 − <th className={`${th} text-right`}>Dup.</th>
30 − <th className={`${th} text-right`}>FC rate</th>
31 − <th className={`${th} text-right`}>SF rate</th>
32 − <th className={`${th} text-right`}>Parser conf.</th>
33 − <th className={th}>Schema</th>
86 + <th className={`${th} text-right`}>FC · SF credits</th>
87 + <th className={th}>Rate limit</th>
88 + <th className={th}>Priority</th>
89 + <th className={th}>Backfill</th>
34 90 <th className={th}>Actions</th>
35 91 </tr>
36 92 </thead>
37 93 <tbody>
38 − {rows.length === 0 ? <tr><td className={td} colSpan={13}><span className="text-subtle">No connectors in the registry yet. Add meta.json files under connectors/ and run pnpm db:seed.</span></td></tr> : null}
94 + {rows.length === 0 ? (
95 + <tr>
96 + <td className={td} colSpan={13}>
97 + <span className="text-subtle">No connector matches. Add meta.json files under connectors/, run pnpm registry && pnpm db:seed.</span>
98 + </td>
99 + </tr>
100 + ) : null}
39 101 {rows.map((r) => {
40 − const health = (r.health ?? {}) as Record<string, unknown>;
102 + const meta = (r.meta ?? {}) as Row;
103 + const health = (r.health ?? {}) as Row;
41 104 const anomalies = Array.isArray(r.last_anomalies) ? (r.last_anomalies as string[]) : [];
42 − const drift = anomalies.some((a) => /schema|selector|missing_field|redesign/.test(a));
105 + const drift = ((health.schema_drift as string[]) ?? []).length > 0 || anomalies.some((a) => /schema_drift|selector|pagination_failure|result_count_collapse/.test(a));
106 + const httpErrors = Object.values((health.http_errors as Record<string, number>) ?? {}).reduce((a, b) => a + b, 0);
107 + const cats = (r.categories as string[]) ?? [];
108 + const id = str(r.id);
109 + const domain = str(meta.domain);
110 + const country = str(meta.country ?? (r.regions as string[])?.[0] ?? 'global');
111 + const refresh = Number(r.refresh_frequency_minutes);
112 + const refreshClass = str(meta.refreshClass) || (refresh <= 5 ? 'hot' : refresh <= 60 ? 'active' : refresh <= 1440 ? 'normal' : 'archive');
113 + const missing = (health.missing_requirements as string[]) ?? [];
43 114 return (
44 − <tr key={String(r.id)} className="align-top">
115 + <tr key={id} className="align-top">
116 + <td className={td}>
117 + <div className="flex items-center gap-2">
118 + <SourceLogo domain={domain} name={str(r.display_name)} logoUrl={str(meta.logoUrl) || null} />
119 + <div>
120 + <Link href={`/admin/connectors/${id}`} className="font-medium hover:underline">{str(r.display_name)}</Link>
121 + <div className="text-[11px] text-subtle">
122 + {domain ? (
123 + <a href={`https://${domain}`} target="_blank" rel="noreferrer noopener" className="hover:underline">{domain}</a>
124 + ) : (
125 + id
126 + )}
127 + {' · '}
128 + {id}
129 + </div>
130 + </div>
131 + </div>
132 + </td>
133 + <td className={td}>
134 + <div className="flex max-w-[220px] flex-wrap gap-1">
135 + {cats.slice(0, 3).map((c) => (
136 + <Chip key={c}>{c}</Chip>
137 + ))}
138 + {cats.length > 3 ? <Chip>+{cats.length - 3}</Chip> : null}
139 + <Chip tone="index">{country}</Chip>
140 + </div>
141 + </td>
142 + <td className={td}>
143 + <HealthLabel status={str(r.health_status) || 'unknown'} />
144 + <div className="text-[10px] text-subtle">
145 + {health.success_rate_24h != null ? <>success <Pct value={health.success_rate_24h} /></> : 'no runs 24h'}
146 + {r.health_at ? ` · ${fmtTs(r.health_at).slice(5, 16)}` : ''}
147 + </div>
148 + {drift ? <div className="text-[10px] text-alert">schema drift?</div> : null}
149 + {missing.length ? <div className="text-[10px] text-alert">needs {missing.join(', ')}</div> : null}
150 + </td>
151 + <td className={td}>
152 + <StatusPill status={str(r.status)} />
153 + {r.last_run_status ? <div className="text-[10px] text-subtle">run: {str(r.last_run_status)}</div> : null}
154 + </td>
155 + <td className={td}>
156 + <div className="text-xs">{str(meta.acquisitionMethod) || (r.engine_priority as string[]).join(' → ')}</div>
157 + <div className="text-[10px] text-subtle">{(r.engine_priority as string[]).join('→')} · {refreshClass} · every {n(refresh)} min</div>
158 + </td>
45 159 <td className={td}>
46 − <Link href={`/admin/connectors/${String(r.id)}`} className="font-medium hover:underline">{String(r.display_name)}</Link>
47 − <div className="text-[11px] text-subtle">{String(r.id)} · {(r.engine_priority as string[]).join('→')} · {String(r.priority)} · every {n(r.refresh_frequency_minutes)} min</div>
160 + {fmtTs(r.last_started_at)}
161 + <div className="text-[10px] text-subtle">ok: {fmtTs(r.last_success_at).slice(0, 16)}</div>
162 + <div className="text-[10px] text-subtle">fresh: {health.data_freshness ? fmtTs(health.data_freshness).slice(0, 10) : '—'}</div>
163 + </td>
164 + <td className={tdNum}>
165 + {n(r.records_24h)}
166 + <div className="text-[10px] text-subtle">{n(r.normalized_24h)} norm. · {n(r.duplicates_24h)} dup.</div>
167 + <div className="text-[10px] text-subtle">{n(r.pages_per_min, 1)} pages/min</div>
168 + </td>
169 + <td className={`${tdNum} ${r.last_error || httpErrors ? 'text-loss' : ''}`} title={str(r.last_error)}>
170 + {httpErrors || (r.last_error ? 1 : 0)}
171 + {anomalies.length ? <div className="text-[10px] text-alert">{anomalies.length} anomal.</div> : null}
172 + {Number(health.challenges_24h) > 0 ? <div className="text-[10px] text-alert">{n(health.challenges_24h)} challenges</div> : null}
173 + </td>
174 + <td className={tdNum}>
175 + {n(r.fc_credits_24h)} · {n(r.sf_credits_24h)}
176 + <div className="text-[10px] text-subtle">
177 + FC <Pct value={health.firecrawl_success_rate} /> · SF <Pct value={health.scrapfly_fallback_rate} />
178 + </div>
48 179 </td>
49 − <td className={td}><StatusPill status={(r.health_status as string) ?? 'unknown'} />{r.health_at ? <div className="text-[10px] text-subtle">{fmtTs(r.health_at)}</div> : null}</td>
50 − <td className={td}><StatusPill status={String(r.status)} />{r.last_run_status ? <div className="text-[10px] text-subtle">run: {String(r.last_run_status)}</div> : null}</td>
51 − <td className={tdNum}>{n(r.pages_per_min, 1)}</td>
52 − <td className={`${tdNum} ${r.last_error ? 'text-loss' : ''}`} title={String(r.last_error ?? '')}>{r.last_error ? '1' : '0'}{anomalies.length ? <div className="text-[10px] text-alert">{anomalies.length} anomal.</div> : null}</td>
53 − <td className={td}>{fmtTs(r.last_started_at)}<div className="text-[10px] text-subtle">ok: {fmtTs(r.last_success_at)}</div></td>
54 − <td className={tdNum}>{n(r.records_24h)}<div className="text-[10px] text-subtle">{n(r.normalized_24h)} norm.</div></td>
55 − <td className={tdNum}>{n(r.duplicates_24h)}</td>
56 − <td className={tdNum}>{pct(health.firecrawl_success_rate)}</td>
57 − <td className={tdNum}>{pct(health.scrapfly_fallback_rate)}</td>
58 − <td className={tdNum}>{pct(r.parser_confidence)}</td>
59 − <td className={td}>v{String(r.schema_version)}{drift ? <div className="text-[10px] text-alert">drift?</div> : null}</td>
60 180 <td className={td}>
61 − <div className="flex flex-wrap gap-1">
62 − {(['run', 'probe', 'recrawl', 'retry'] as const).map((a) => (
181 + <div className="text-xs">{health.latency_ms_avg != null ? `${n(health.latency_ms_avg)} ms` : '—'}</div>
182 + <div className="text-[10px] text-subtle">{Number(health.rate_limited_24h) > 0 ? `${n(health.rate_limited_24h)}× 429` : 'no 429'} · {n(health.requests_24h)} req</div>
183 + </td>
184 + <td className={td}>
185 + <Chip tone={str(r.priority) === 'high' ? 'index' : 'neutral'}>{str(r.priority)}</Chip>
186 + <div className="text-[10px] text-subtle">trust {source_trust(meta)}</div>
187 + </td>
188 + <td className={td}>{r.backfill_id ? <Progress percent={r.backfill_percent as number | null} status={str(r.backfill_status)} /> : <span className="text-[10px] text-subtle">{str(meta.historicalDepth) && str(meta.historicalDepth) !== 'none' ? `history: ${str(meta.historicalDepth)}` : '—'}</span>}</td>
189 + <td className={td}>
190 + <div className="flex max-w-[210px] flex-wrap gap-1">
191 + {(
192 + [
193 + ['probe', 'Test'],
194 + ['run', 'Run now'],
195 + ['backfill', 'Backfill'],
196 + ] as const
197 + ).map(([a, label]) => (
63 198 <form key={a} action={connectorAction}>
64 − <input type="hidden" name="id" value={String(r.id)} />
199 + <input type="hidden" name="id" value={id} />
65 200 <input type="hidden" name="action" value={a} />
66 − <ActionButton label={a} />
201 + <ActionButton label={label} />
67 202 </form>
68 203 ))}
69 204 <form action={connectorAction}>
70 − <input type="hidden" name="id" value={String(r.id)} />
71 − <input type="hidden" name="action" value={r.status === 'paused' ? 'resume' : 'pause'} />
72 − <ActionButton label={r.status === 'paused' ? 'resume' : 'pause'} tone={r.status === 'paused' ? 'primary' : 'danger'} />
205 + <input type="hidden" name="id" value={id} />
206 + <input type="hidden" name="action" value={r.status === 'active' ? 'pause' : 'resume'} />
207 + <ActionButton label={r.status === 'active' ? 'Pause' : 'Resume'} tone={r.status === 'active' ? 'danger' : 'primary'} />
73 208 </form>
74 − <Link href={`/admin/connectors/${String(r.id)}`} className="rounded-md border border-border px-2 py-0.5 text-[11px] font-medium hover:bg-inset">inspect</Link>
209 + <Link href={`/admin/connectors/${id}`} className="rounded-md border border-border px-2 py-0.5 text-[11px] font-medium hover:bg-inset">Logs</Link>
75 210 </div>
76 211 </td>
77 212 </tr>
@@ -80,7 +215,14 @@ export default async function ConnectorsPage() {
80 215 </tbody>
81 216 </Table>
82 217 </div>
83 − <p className="mt-3 text-[11px] text-subtle">run = incremental crawl · probe = 25-record smoke test · recrawl = backfill mode · retry = re-run from the last cursor. FC = Firecrawl success rate, SF = share of pages that needed Scrapfly (from the 24 h health snapshot).</p>
218 + <p className="mt-3 text-[11px] text-subtle">
219 + Test = 25-record probe · Run now = incremental crawl · Backfill = resumable historical campaign (progress in the Backfill column) · Pause/Resume flips scheduling; Maintenance is available on the connector page. FC/SF = Firecrawl/Scrapfly credits spent in 24 h and success/fallback rates. Health labels: UP · DEGRADED · BROKEN · DISABLED · MAINTENANCE.
220 + </p>
84 221 </AdminShell>
85 222 );
86 223 }
224 +
225 +function source_trust(meta: Row): string {
226 + const t = Number(meta.trustScore);
227 + return Number.isFinite(t) ? `${Math.round(t * 100)}%` : '—';
228 +}
added apps/web/src/app/api/admin/connectors/route.ts +58 −0
@@ -0,0 +1,58 @@
1 +import { NextResponse } from 'next/server';
2 +import { connectorsOverview } from '@/lib/admin/queries';
3 +
4 +export const runtime = 'nodejs';
5 +export const dynamic = 'force-dynamic';
6 +
7 +const LABEL: Record<string, string> = { healthy: 'UP', degraded: 'DEGRADED', failing: 'BROKEN', paused: 'DISABLED', disabled: 'DISABLED', maintenance: 'MAINTENANCE', unknown: 'UNKNOWN' };
8 +
9 +/** Connector health API (SPEC §12): one object per connector with status, usage, errors, freshness and backfill progress. */
10 +export async function GET() {
11 + const rows = await connectorsOverview();
12 + return NextResponse.json({
13 + as_of: new Date().toISOString(),
14 + connectors: rows.map((r) => {
15 + const h = (r.health ?? {}) as Record<string, unknown>;
16 + const m = (r.meta ?? {}) as Record<string, unknown>;
17 + return {
18 + id: r.id,
19 + name: r.display_name,
20 + source: r.source_id,
21 + domain: m.domain ?? null,
22 + country: m.country ?? null,
23 + categories: r.categories,
24 + capabilities: m.capabilities ?? [],
25 + acquisition: m.acquisitionMethod ?? (r.engine_priority as string[]).join('>'),
26 + engines: r.engine_priority,
27 + priority: r.priority,
28 + refresh_minutes: r.refresh_frequency_minutes,
29 + refresh_class: m.refreshClass ?? null,
30 + status: r.status,
31 + health: r.health_status ?? 'unknown',
32 + health_label: LABEL[String(r.health_status ?? 'unknown')] ?? 'UNKNOWN',
33 + health_at: r.health_at,
34 + success_rate_24h: h.success_rate_24h ?? null,
35 + requests_24h: h.requests_24h ?? null,
36 + items_24h: r.records_24h ?? 0,
37 + normalized_24h: r.normalized_24h ?? 0,
38 + duplicates_24h: r.duplicates_24h ?? 0,
39 + latest_success: r.last_success_at,
40 + last_run: r.last_started_at,
41 + last_run_status: r.last_run_status,
42 + last_error: r.last_error,
43 + http_errors: h.http_errors ?? {},
44 + parse_failure_rate: h.parse_failure_rate ?? null,
45 + schema_drift: h.schema_drift ?? [],
46 + latency_ms_avg: h.latency_ms_avg ?? null,
47 + rate_limited_24h: h.rate_limited_24h ?? 0,
48 + challenges_24h: h.challenges_24h ?? 0,
49 + data_freshness: h.data_freshness ?? null,
50 + firecrawl: { credits_24h: r.fc_credits_24h ?? 0, success_rate: h.firecrawl_success_rate ?? null },
51 + scrapfly: { credits_24h: r.sf_credits_24h ?? 0, fallback_rate: h.scrapfly_fallback_rate ?? null },
52 + missing_requirements: h.missing_requirements ?? [],
53 + backfill: r.backfill_id ? { id: r.backfill_id, status: r.backfill_status, percent: r.backfill_percent, pages: r.backfill_pages, items: r.backfill_items, reached_date: r.backfill_reached_date, updated_at: r.backfill_updated_at } : null,
54 + anomalies: r.last_anomalies ?? [],
55 + };
56 + }),
57 + });
58 +}
added apps/web/src/components/admin/connector-bits.tsx +52 −0
@@ -0,0 +1,52 @@
1 +import type { ReactNode } from 'react';
2 +import { cn } from '@/lib/format';
3 +
4 +/** Operator labels for health statuses (SPEC §12): UP · DEGRADED · BROKEN · DISABLED · MAINTENANCE. */
5 +const LABELS: Record<string, { label: string; cls: string }> = {
6 + healthy: { label: 'UP', cls: 'bg-gain-bg text-gain' },
7 + degraded: { label: 'DEGRADED', cls: 'bg-alert-bg text-alert' },
8 + failing: { label: 'BROKEN', cls: 'bg-loss-bg text-loss' },
9 + paused: { label: 'DISABLED', cls: 'bg-inset text-muted' },
10 + disabled: { label: 'DISABLED', cls: 'bg-inset text-muted' },
11 + maintenance: { label: 'MAINTENANCE', cls: 'bg-index-bg text-index' },
12 + unknown: { label: 'UNKNOWN', cls: 'bg-inset text-subtle' },
13 +};
14 +
15 +export function HealthLabel({ status, small = true }: { status: string | null | undefined; small?: boolean }) {
16 + const l = LABELS[status ?? 'unknown'] ?? LABELS.unknown!;
17 + return <span className={cn('inline-flex rounded-sm font-semibold tracking-wide', small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs', l.cls)}>{l.label}</span>;
18 +}
19 +
20 +/** Source favicon (Google s2 service, falls back to an initial). */
21 +export function SourceLogo({ domain, name, logoUrl, size = 18 }: { domain: string | null | undefined; name: string; logoUrl?: string | null; size?: number }) {
22 + const src = logoUrl ?? (domain ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${size * 2}` : null);
23 + if (!src) return <span className="inline-flex h-[18px] w-[18px] items-center justify-center rounded-sm bg-inset text-[10px] font-semibold text-muted">{name.slice(0, 1).toUpperCase()}</span>;
24 + // eslint-disable-next-line @next/next/no-img-element
25 + return <img src={src} alt="" width={size} height={size} loading="lazy" className="inline-block rounded-sm bg-white/5" />;
26 +}
27 +
28 +export function Pct({ value, digits = 0 }: { value: unknown; digits?: number }) {
29 + const x = Number(value);
30 + return <>{Number.isFinite(x) ? `${(x * 100).toFixed(digits)}%` : '—'}</>;
31 +}
32 +
33 +/** Thin progress bar for backfill campaigns. */
34 +export function Progress({ percent, status }: { percent: number | null | undefined; status?: string | null }) {
35 + const p = percent === null || percent === undefined ? null : Math.max(0, Math.min(100, Number(percent)));
36 + return (
37 + <div className="min-w-[72px]">
38 + <div className="h-1.5 w-full overflow-hidden rounded-full bg-inset">
39 + <div className={cn('h-full rounded-full', status === 'completed' ? 'bg-gain' : status === 'failed' ? 'bg-loss' : status === 'paused' ? 'bg-alert' : 'bg-index')} style={{ width: `${p ?? (status === 'running' ? 8 : 0)}%` }} />
40 + </div>
41 + <div className="mt-0.5 text-[10px] text-subtle">{p === null ? (status ?? '—') : `${p.toFixed(0)}% · ${status ?? ''}`}</div>
42 + </div>
43 + );
44 +}
45 +
46 +export function Json({ value, className }: { value: unknown; className?: string }) {
47 + return <pre className={cn('max-h-[70vh] overflow-auto rounded-md border border-border bg-sunken p-3 font-mono text-[11px] leading-relaxed', className)}>{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre>;
48 +}
49 +
50 +export function Chip({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'index' | 'alert' }) {
51 + return <span className={cn('inline-flex rounded-sm px-1.5 py-0.5 text-[10px]', tone === 'index' ? 'bg-index-bg text-index' : tone === 'alert' ? 'bg-alert-bg text-alert' : 'bg-inset text-muted')}>{children}</span>;
52 +}
modified apps/web/src/components/admin/shell.tsx +2 −1
@@ -5,6 +5,7 @@ import { cn } from '@/lib/format';
5 5 export const ADMIN_NAV = [
6 6 { href: '/admin', label: 'Overview' },
7 7 { href: '/admin/connectors', label: 'Connectors' },
8 + { href: '/admin/coverage', label: 'Coverage' },
8 9 { href: '/admin/data-quality', label: 'Data quality' },
9 10 { href: '/admin/costs', label: 'Costs' },
10 11 { href: '/admin/taxonomy', label: 'Taxonomy' },
@@ -41,7 +42,7 @@ export function AdminShell({ children, title, subtitle, actions, current }: { ch
41 42
42 43 export function StatusPill({ status }: { status: string | null | undefined }) {
43 44 const s = status ?? 'unknown';
44 − const tone: Record<string, string> = { healthy: 'bg-gain-bg text-gain', active: 'bg-gain-bg text-gain', success: 'bg-gain-bg text-gain', valid: 'bg-gain-bg text-gain', degraded: 'bg-alert-bg text-alert', partial: 'bg-alert-bg text-alert', paused: 'bg-alert-bg text-alert', flagged: 'bg-alert-bg text-alert', pending: 'bg-alert-bg text-alert', running: 'bg-index-bg text-index', failing: 'bg-loss-bg text-loss', failed: 'bg-loss-bg text-loss', excluded: 'bg-loss-bg text-loss', disabled: 'bg-inset text-muted', unknown: 'bg-inset text-muted' };
45 + const tone: Record<string, string> = { maintenance: 'bg-index-bg text-index', completed: 'bg-gain-bg text-gain', healthy: 'bg-gain-bg text-gain', active: 'bg-gain-bg text-gain', success: 'bg-gain-bg text-gain', valid: 'bg-gain-bg text-gain', degraded: 'bg-alert-bg text-alert', partial: 'bg-alert-bg text-alert', paused: 'bg-alert-bg text-alert', flagged: 'bg-alert-bg text-alert', pending: 'bg-alert-bg text-alert', running: 'bg-index-bg text-index', failing: 'bg-loss-bg text-loss', failed: 'bg-loss-bg text-loss', excluded: 'bg-loss-bg text-loss', disabled: 'bg-inset text-muted', unknown: 'bg-inset text-muted' };
45 46 return <span className={cn('inline-flex rounded-sm px-1.5 py-0.5 text-[11px] font-medium', tone[s] ?? 'bg-inset text-muted')}>{s}</span>;
46 47 }
47 48
modified apps/web/src/lib/admin/actions.ts +15 −6
@@ -4,7 +4,7 @@ import { cookies } from 'next/headers';
4 4 import { redirect } from 'next/navigation';
5 5 import { revalidatePath } from 'next/cache';
6 6 import { z } from 'zod';
7 −import { getDb, connectors, connectorRuns, sales, normalizedRecords, taxonomyProposals, categories, auditLog, events, eq, sql } from '@rareindex/database';
7 +import { getDb, connectors, connectorRuns, connectorBackfills, sales, normalizedRecords, taxonomyProposals, categories, auditLog, events, eq, and, inArray, sql } from '@rareindex/database';
8 8 import { newId } from '@rareindex/shared';
9 9 import { ADMIN_COOKIE, adminCookieValue, isAdmin, tokenMatches } from './auth';
10 10 import { enqueue, QUEUES } from './queue';
@@ -33,9 +33,12 @@ export async function adminLogout(): Promise<void> {
33 33 redirect('/admin/login');
34 34 }
35 35
36 −const ConnectorAction = z.enum(['run', 'probe', 'recrawl', 'retry', 'pause', 'resume']);
36 +const ConnectorAction = z.enum(['run', 'probe', 'recrawl', 'backfill', 'backfill_pause', 'backfill_reset', 'retry', 'pause', 'resume', 'maintenance']);
37 37
38 −/** Connector control-center actions (§144): run/recrawl/probe enqueue `crawl.run`; pause/resume flip status. */
38 +/**
39 + * Connector control-center actions (§144, SPEC §29): run/probe/backfill enqueue `crawl.run`;
40 + * pause/resume/maintenance flip status; backfill_pause/backfill_reset manage the resumable campaign.
41 + */
39 42 export async function connectorAction(formData: FormData): Promise<void> {
40 43 await guard();
41 44 const id = String(formData.get('id') ?? '');
@@ -43,11 +46,17 @@ export async function connectorAction(formData: FormData): Promise<void> {
43 46 const db = getDb();
44 47 const [c] = await db.select().from(connectors).where(eq(connectors.id, id)).limit(1);
45 48 if (!c) throw new Error(`unknown connector ${id}`);
46 − if (action === 'pause' || action === 'resume') {
47 − await db.update(connectors).set({ status: action === 'pause' ? 'paused' : 'active', updatedAt: new Date() }).where(eq(connectors.id, id));
49 + if (action === 'pause' || action === 'resume' || action === 'maintenance') {
50 + await db.update(connectors).set({ status: action === 'pause' ? 'paused' : action === 'maintenance' ? 'maintenance' : 'active', updatedAt: new Date() }).where(eq(connectors.id, id));
48 51 await audit('connector', id, action, `admin ${action}`);
52 + } else if (action === 'backfill_pause') {
53 + await db.update(connectorBackfills).set({ status: 'paused', updatedAt: new Date() }).where(and(eq(connectorBackfills.connectorId, id), eq(connectorBackfills.status, 'running')));
54 + await audit('connector', id, action, 'admin paused backfill campaign');
55 + } else if (action === 'backfill_reset') {
56 + await db.update(connectorBackfills).set({ status: 'failed', lastError: 'reset by admin', finishedAt: new Date(), updatedAt: new Date() }).where(and(eq(connectorBackfills.connectorId, id), inArray(connectorBackfills.status, ['running', 'paused'])));
57 + await audit('connector', id, action, 'admin reset backfill campaign');
49 58 } else {
50 − const mode = action === 'recrawl' ? 'backfill' : action === 'probe' ? 'probe' : 'incremental';
59 + const mode = action === 'recrawl' || action === 'backfill' ? 'backfill' : action === 'probe' ? 'probe' : 'incremental';
51 60 const payload: Record<string, unknown> = { connectorId: id, mode, trigger: action === 'retry' ? 'retry' : 'manual', requestedAt: new Date().toISOString() };
52 61 if (action === 'probe') payload.limit = 25;
53 62 if (action === 'retry') {
modified apps/web/src/lib/admin/queries.ts +63 −5
@@ -43,17 +43,25 @@ export async function connectorsOverview() {
43 43 greatest(1, extract(epoch from (max(coalesce(finished_at, now())) - min(started_at))) / 60.0) as minutes
44 44 from connector_runs where started_at >= now() - interval '24 hours' group by connector_id),
45 45 conf as (
46 − select connector_id, avg((payload->>'confidence')::float) as parser_confidence, count(*)::int as normalized_24h from normalized_records where created_at >= now() - interval '24 hours' group by connector_id)
47 − select c.id, c.display_name, c.source_id, c.status, c.priority, c.engine_priority, c.categories, c.refresh_frequency_minutes, c.schema_version, c.connector_version, c.last_run_at, c.last_success_at, c.next_run_at,
46 + select connector_id, avg((payload->>'confidence')::float) as parser_confidence, count(*)::int as normalized_24h from normalized_records where created_at >= now() - interval '24 hours' group by connector_id),
47 + spend as (
48 + select connector_id, sum(credits) filter (where kind = 'firecrawl')::float as fc_credits_24h, sum(credits) filter (where kind = 'scrapfly')::float as sf_credits_24h from costs where occurred_at >= now() - interval '24 hours' and connector_id is not null group by connector_id),
49 + bf as (
50 + select distinct on (connector_id) connector_id, id as backfill_id, status as backfill_status, percent as backfill_percent, pages_processed as backfill_pages, items_processed as backfill_items, updated_at as backfill_updated_at, reached_date as backfill_reached_date from connector_backfills order by connector_id, started_at desc)
51 + select c.id, c.display_name, c.source_id, c.status, c.priority, c.engine_priority, c.categories, c.regions, c.refresh_frequency_minutes, c.schema_version, c.connector_version, c.last_run_at, c.last_success_at, c.next_run_at, c.meta,
48 52 h.status as health_status, h.computed_at as health_at, h.health,
49 53 lr.id as last_run_id, lr.status as last_run_status, lr.error as last_error, lr.started_at as last_started_at, lr.finished_at as last_finished_at, lr.pages_attempted as last_pages, lr.records_raw as last_records, lr.anomalies as last_anomalies,
50 54 d.pages as pages_24h, d.records as records_24h, d.duplicates as duplicates_24h, d.credits as credits_24h, (coalesce(d.pages,0) / coalesce(d.minutes,1)) as pages_per_min,
51 − cf.parser_confidence, cf.normalized_24h
55 + cf.parser_confidence, cf.normalized_24h,
56 + sp.fc_credits_24h, sp.sf_credits_24h,
57 + bf.backfill_id, bf.backfill_status, bf.backfill_percent, bf.backfill_pages, bf.backfill_items, bf.backfill_updated_at, bf.backfill_reached_date
52 58 from connectors c
53 59 left join connector_health h on h.connector_id = c.id
54 60 left join last_run lr on lr.connector_id = c.id
55 61 left join day d on d.connector_id = c.id
56 62 left join conf cf on cf.connector_id = c.id
63 + left join spend sp on sp.connector_id = c.id
64 + left join bf on bf.connector_id = c.id
57 65 order by c.priority = 'high' desc, c.id`);
58 66 }
59 67
@@ -67,9 +75,59 @@ export async function connectorDetail(id: string) {
67 75 const normalized = await run(sql`select status, count(*)::int as n from normalized_records where connector_id = ${id} group by status`);
68 76 const normalizedSample = await run(sql`select id, kind, status, match_method, match_confidence, asset_id, reject_reason, created_at, payload->>'rawTitle' as raw_title, payload->>'price' as price, payload->>'currency' as currency from normalized_records where connector_id = ${id} order by created_at desc limit 10`);
69 77 const costs = await run(sql`select date_trunc('day', occurred_at)::date as day, kind, sum(credits)::float as credits, sum(usd_est)::float as usd from costs where connector_id = ${id} and occurred_at >= now() - interval '30 days' group by 1, 2 order by 1 desc`);
70 − const outputs = await one(sql`select (select count(*)::int from sales where connector_id = ${id}) as sales, (select count(*)::int from listings where connector_id = ${id}) as listings, (select count(*)::int from price_observations where connector_id = ${id}) as observations`);
78 + const outputs = await one(sql`select (select count(*)::int from sales where connector_id = ${id}) as sales, (select count(*)::int from listings where connector_id = ${id}) as listings, (select count(*)::int from price_observations where connector_id = ${id}) as observations, (select count(*)::int from certificates where ${id} = any(source_ids) or last_source_url like '%' || ${connector.source_id as string} || '%') as certificates`);
79 + const backfills = await run(sql`select * from connector_backfills where connector_id = ${id} order by started_at desc limit 10`);
80 + const fieldStats = await run(sql`select field, day::text as day, total, nulls from connector_field_stats where connector_id = ${id} and day >= (current_date - interval '7 days')::date order by day desc, field`);
71 81 const anomalies = runs.flatMap((r) => (Array.isArray(r.anomalies) ? (r.anomalies as string[]) : [])).slice(0, 30);
72 − return { connector, source, health, runs, rawSample, normalized, normalizedSample, costs, outputs, anomalies };
82 + return { connector, source, health, runs, rawSample, normalized, normalizedSample, costs, outputs, anomalies, backfills, fieldStats };
83 +}
84 +
85 +export async function rawRecordDetail(id: string) {
86 + return one(sql`select id, connector_id, source_id, run_id, engine, url, external_id, kind, fetched_at, content_hash, http_status, payload, snapshot_ref, parser_version, connector_version, processed_at, process_error from raw_records where id = ${id}`);
87 +}
88 +
89 +export async function normalizedRecordDetail(id: string) {
90 + return one(sql`select n.*, r.url as raw_url, r.engine as raw_engine from normalized_records n left join raw_records r on r.id = n.raw_record_id where n.id = ${id}`);
91 +}
92 +
93 +/** Global coverage dashboard (SPEC §30). */
94 +export async function coverage() {
95 + const totals = await one(sql`select
96 + (select count(*)::int from connectors) as connectors,
97 + (select count(*)::int from connectors where status = 'active') as connectors_active,
98 + (select count(*)::int from listings where availability = 'available') as live_listings,
99 + (select count(*)::int from sales) as sales,
100 + (select count(*)::int from sales where sale_date < now() - interval '1 year') as sales_older_1y,
101 + (select min(sale_date) from sales) as oldest_sale,
102 + (select count(*)::int from auction_lots) as auction_lots,
103 + (select count(distinct auction_house) from auctions) as auction_houses,
104 + (select count(distinct grader) from certificates) as graders_with_certs,
105 + (select count(*)::int from certificates) as certificates,
106 + (select count(*)::int from population_reports) as population_reports,
107 + (select count(*)::int from assets) as assets,
108 + (select count(*)::int from asset_variants) as variants,
109 + (select count(*)::int from price_observations) as observations,
110 + (select count(*)::int from raw_records where fetched_at >= now() - interval '24 hours') as raw_24h,
111 + (select count(*)::int from normalized_records where created_at >= now() - interval '24 hours') as normalized_24h,
112 + (select count(*)::int from sales where created_at >= now() - interval '24 hours') as sales_24h,
113 + (select count(*)::int from listings where first_seen_at >= now() - interval '24 hours') as listings_24h,
114 + (select count(*)::int from connector_backfills where status = 'running') as backfills_running,
115 + (select count(*)::int from connector_backfills where status = 'completed') as backfills_completed`);
116 + const byStatus = await run(sql`select coalesce(h.status, 'unknown') as status, count(*)::int as n from connectors c left join connector_health h on h.connector_id = c.id group by 1 order by n desc`);
117 + const byCategory = await run(sql`select cat as category, count(distinct c.id)::int as connectors, count(distinct c.id) filter (where h.status = 'healthy')::int as healthy from connectors c cross join unnest(c.categories) as cat left join connector_health h on h.connector_id = c.id group by 1 order by connectors desc, 1`);
118 + const byCountry = await run(sql`select coalesce(c.meta->>'country', c.regions[1], 'global') as country, count(*)::int as connectors from connectors c group by 1 order by connectors desc`);
119 + const bySourceType = await run(sql`select s.source_type, count(*)::int as connectors from connectors c join sources s on s.id = c.source_id group by 1 order by 2 desc`);
120 + const byEngine = await run(sql`select engine_priority[1] as engine, count(*)::int as connectors from connectors group by 1 order by 2 desc`);
121 + const byCapability = await run(sql`select cap as capability, count(*)::int as connectors from connectors c cross join jsonb_array_elements_text(coalesce(c.meta->'capabilities', '[]'::jsonb)) as cap group by 1 order by 2 desc`);
122 + const salesByFamily = await run(sql`select a.family_slug, count(*)::int as sales, count(*) filter (where s.sale_date >= now() - interval '30 days')::int as sales_30d, min(s.sale_date)::date as oldest from sales s join assets a on a.id = s.asset_id group by 1 order by sales desc`);
123 + const listingsByFamily = await run(sql`select a.family_slug, count(*)::int as live from listings l join assets a on a.id = l.asset_id where l.availability = 'available' group by 1 order by live desc`);
124 + const daily = await run(sql`select d::date as day,
125 + (select count(*)::int from raw_records r where r.fetched_at >= d and r.fetched_at < d + interval '1 day') as raw,
126 + (select count(*)::int from sales s where s.created_at >= d and s.created_at < d + interval '1 day') as sales,
127 + (select count(*)::int from listings l where l.first_seen_at >= d and l.first_seen_at < d + interval '1 day') as listings
128 + from generate_series(current_date - interval '13 days', current_date, interval '1 day') d order by 1`);
129 + const salesByYear = await run(sql`select extract(year from sale_date)::int as year, count(*)::int as sales from sales group by 1 order by 1`);
130 + return { totals, byStatus, byCategory, byCountry, bySourceType, byEngine, byCapability, salesByFamily, listingsByFamily, daily, salesByYear };
73 131 }
74 132
75 133 export async function costsOverview(days = 30) {
modified connectors/README.md +4 −0
@@ -16,3 +16,7 @@ Rules: never invent data, keep source quirks inside the connector, store the sou
16 16 keep the native currency, respect robots/terms/rate limits, and never bypass access controls (§179).
17 17
18 18 Module contract: `export default (meta: ConnectorMeta) => RareIndexConnector`.
19 +
20 +Full guide: `docs/connectors/ADDING_A_CONNECTOR.md` (scaffold with `pnpm connector:new`, adapters for Shopify/
21 +WooCommerce/sitemaps/RSS/PDF/JSON-LD, per-host policies in `domains.json` + `domains.d/`, source catalogue in
22 +`data/sources/entries/`, resumable backfills, health and drift monitoring).
added connectors/api/1stdibs/README.md +13 −0
@@ -0,0 +1,13 @@
1 +# 1stdibs — 1stDibs marketplace (dealer listings)
2 +
3 +Browse pages (`/furniture/seating/`, `/jewelry/`, `/jewelry/watches/wrist-watches/`,
4 +`/fashion/handbags-purses-bags/`, `/art/prints-works-on-paper/` … `?page=N`, 60 items, ≤ 50 pages) embed
5 +the server-rendered Relay store in `<script id="serverVars_data">`. `parseBrowseHtml` dereferences the
6 +`Item` records: title, `serviceId`, display prices in 10 currencies (USD kept), seller company/id,
7 +designers, location, period/style line, materials, measurements, photos, sold/hold flags.
8 +
9 +- One raw record per page (trimmed items, not the 3 MB store), one `listing` per item.
10 +- Category from seed vertical + category code + attribute line/title (`design_furniture` / `antiques` /
11 + decorative-object slugs / jewelry / watch brands / `luxury_handbags` / art).
12 +- Cursor `{seedIndex, page}` with seed rotation; 5 s politeness.
13 +- Identifiers: `firstdibs_item_id` (`f_51602042`), `firstdibs_seller_id`.
added connectors/api/1stdibs/index.test.ts +54 −0
@@ -0,0 +1,54 @@
1 +import { describe, expect, it } from 'vitest';
2 +import metaJson from './meta.json' with { type: 'json' };
3 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
4 +import { localMeta } from '../_lib/local-meta.js';
5 +import createConnector, { makeDeref, pageUrl, parseBrowseHtml } from './index.js';
6 +
7 +const connector = createConnector(localMeta(metaJson));
8 +
9 +describe('1stdibs', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('parses Item records out of the serverVars_data Relay store', () => {
13 + const html = String((loadFixture('1stdibs', 'seating-chairs-p1').raw.payload as { snapshot?: string }).snapshot ?? '');
14 + const p = parseBrowseHtml(html);
15 + expect(p).not.toBeNull();
16 + expect(p!.items.length).toBeGreaterThanOrEqual(2);
17 + expect(p!.totalResults).toBeGreaterThan(1000);
18 + expect(p!.maxPages).toBe(50);
19 + const it0 = p!.items[0]!;
20 + expect(it0.serviceId).toMatch(/^f_\d+$/);
21 + expect(it0.url).toMatch(/^https:\/\/www\.1stdibs\.com\/furniture\/.+\/id-f_\d+\/$/);
22 + expect(it0.prices.USD).toBeGreaterThan(0);
23 + expect(it0.prices.EUR).toBeGreaterThan(0);
24 + expect(it0.seller.serviceId).toMatch(/^f_\d+$/);
25 + expect(it0.images[0]).toMatch(/1stdibscdn\.com/);
26 + expect(it0.categoryCode).toMatch(/^F_/);
27 + expect(pageUrl('/furniture/seating/', 2)).toBe('https://www.1stdibs.com/furniture/seating/?page=2');
28 + });
29 +
30 + it('dereferences __ref / __refs chains', () => {
31 + const store = { a: { __typename: 'A', b: { __ref: 'b' }, list: { __refs: ['c', 'c'] } }, b: { __typename: 'B', v: 1 }, c: { __typename: 'C', v: 2 } };
32 + const deref = makeDeref(store as never);
33 + expect(deref(store.a)).toEqual({ __typename: 'A', b: { __typename: 'B', v: 1 }, list: [{ __typename: 'C', v: 2 }, { __typename: 'C', v: 2 }] });
34 + expect(parseBrowseHtml('<html><body>no store</body></html>')).toBeNull();
35 + });
36 +
37 + it('normalises items into USD dealer listings with designer, seller and identifiers', async () => {
38 + const fx = loadFixture('1stdibs', 'seating-chairs-p1');
39 + const out = await connector.normalize(fx.raw);
40 + expect(out.length).toBeGreaterThan(0);
41 + for (const r of out) {
42 + if (r.kind !== 'listing') throw new Error('expected listing');
43 + expect(r.currency).toBe('USD');
44 + expect(r.price).toBeGreaterThan(0);
45 + expect(r.attributes.identifiers.firstdibs_item_id).toMatch(/^f_\d+$/);
46 + expect(['design_furniture', 'antiques']).toContain(r.attributes.categorySlug);
47 + expect(r.seller).toBeTruthy();
48 + }
49 + const jw = loadFixture('1stdibs', 'wrist-watches-p1');
50 + const jwOut = await connector.normalize(jw.raw);
51 + expect(jwOut.length).toBeGreaterThan(0);
52 + for (const r of jwOut) expect(['rolex', 'omega', 'patek_philippe', 'audemars_piguet', 'other_watches']).toContain((r as { attributes: { categorySlug: string } }).attributes.categorySlug);
53 + });
54 +});
added connectors/api/1stdibs/index.ts +324 −0
@@ -0,0 +1,324 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared';
4 +import { designSlug, plainText, readSeedCursor, yearOrDecade, type DesignVertical } from '../_g10-lib/index.js';
5 +
6 +/**
7 + * 1stDibs — the largest online marketplace for vintage/antique furniture, design, jewelry, watches,
8 + * fashion and art (USD by default). Browse pages (/furniture/seating/, /jewelry/, … ?page=N, 60 items)
9 + * embed the server-rendered Relay store in <script id="serverVars_data" type="application/json">;
10 + * we read the `Item` records from it (title, serviceId, prices in ten currencies, seller company,
11 + * creators/designers, location, period/style attributes, materials, measurements, photos, sold/hold
12 + * flags) and never call 1stDibs' GraphQL endpoint ourselves. Asking prices → listings.
13 + */
14 +const BASE = 'https://www.1stdibs.com';
15 +const PARSER_VERSION = '1.0.0';
16 +const PAGE_SIZE = 60;
17 +
18 +export const SeedSchema = z.object({ path: z.string(), slug: z.string().nullable().optional(), vertical: z.enum(['furniture', 'lighting', 'decor', 'art', 'jewelry', 'watches', 'fashion', 'tableware', 'rugs', 'pens', 'unknown']).default('unknown') });
19 +export type Seed = z.infer<typeof SeedSchema>;
20 +
21 +export const ItemSchema = z.object({
22 + serviceId: z.string(),
23 + title: z.string(),
24 + url: z.string(),
25 + prices: z.record(z.string(), z.number()),
26 + amountType: z.string().nullable(),
27 + isSold: z.boolean(),
28 + isOnHold: z.boolean(),
29 + isUnavailable: z.boolean(),
30 + isNewListing: z.boolean().nullable(),
31 + seller: z.object({ serviceId: z.string().nullable(), company: z.string().nullable() }),
32 + creators: z.array(z.string()),
33 + country: z.string().nullable(),
34 + location: z.string().nullable(),
35 + attributesText: z.string().nullable(),
36 + materials: z.string().nullable(),
37 + description: z.string().nullable(),
38 + measurement: z.string().nullable(),
39 + categoryCode: z.string().nullable(),
40 + browseUrl: z.string().nullable(),
41 + vertical: z.string().nullable(),
42 + images: z.array(z.string()),
43 +});
44 +export type Item = z.infer<typeof ItemSchema>;
45 +
46 +export const PagePayloadSchema = z.object({ kind: z.literal('listing_page'), url: z.string(), seed: SeedSchema, page: z.number().int(), totalResults: z.number().int().nullable(), maxPages: z.number().int().nullable(), items: z.array(ItemSchema), snapshot: z.string().optional() });
47 +export type PagePayload = z.infer<typeof PagePayloadSchema>;
48 +
49 +const ConfigSchema = z.object({ seeds: z.array(SeedSchema).min(1), pagesPerSeed: z.number().int().min(1).default(1), seedsPerRun: z.number().int().min(1).default(3), maxPagesPerSeed: z.number().int().min(1).default(50) });
50 +
51 +type Rec = Record<string, unknown>;
52 +
53 +/** Relay normalized store → dereferenced view (bounded depth). */
54 +export function makeDeref(store: Record<string, Rec>) {
55 + const deref = (v: unknown, depth = 0): unknown => {
56 + if (depth > 4 || v === null || typeof v !== 'object') return v;
57 + if (Array.isArray(v)) return v.map((x) => deref(x, depth + 1));
58 + const o = v as Rec;
59 + if (typeof o.__ref === 'string') return deref(store[o.__ref] ?? null, depth + 1);
60 + if (Array.isArray(o.__refs)) return o.__refs.map((r) => deref(store[String(r)] ?? null, depth + 1));
61 + const out: Rec = {};
62 + for (const [k, x] of Object.entries(o)) if (k !== '__id') out[k] = deref(x, depth + 1);
63 + return out;
64 + };
65 + return deref;
66 +}
67 +
68 +function firstKey(o: Rec, prefix: string): unknown {
69 + const k = Object.keys(o).find((x) => x === prefix || x.startsWith(`${prefix}(`));
70 + return k ? o[k] : undefined;
71 +}
72 +
73 +const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
74 +
75 +/** Parse the serverVars_data bootstrap of a browse page. Exported for tests. */
76 +export function parseBrowseHtml(htmlText: string): { items: Item[]; totalResults: number | null; maxPages: number | null } | null {
77 + const m = htmlText.match(/<script id="serverVars_data" type="application\/json">([\s\S]*?)<\/script>/);
78 + if (!m) return null;
79 + let root: Rec;
80 + try {
81 + root = JSON.parse(m[1]!) as Rec;
82 + } catch {
83 + return null;
84 + }
85 + const store = ((root.dbl as Rec | undefined)?.relayData ?? (root.relay as Rec | undefined)?.store ?? null) as Record<string, Rec> | null;
86 + if (!store || typeof store !== 'object') return null;
87 + const deref = makeDeref(store);
88 + const items: Item[] = [];
89 + let totalResults: number | null = null;
90 + let maxPages: number | null = null;
91 + for (const rec of Object.values(store)) {
92 + if (!rec || typeof rec !== 'object') continue;
93 + if (rec.__typename === 'ItemSearchQueryConnection') {
94 + if (typeof rec.totalResults === 'number') totalResults = rec.totalResults;
95 + if (typeof rec.displayMaxNumberOfPages === 'number') maxPages = rec.displayMaxNumberOfPages;
96 + continue;
97 + }
98 + if (rec.__typename !== 'Item' || typeof rec.serviceId !== 'string' || typeof rec.title !== 'string') continue;
99 + const dp = deref(firstKey(rec, 'displayPrice')) as Array<{ convertedAmountList?: Array<{ amount?: number; currency?: string }>; amountType?: string }> | undefined;
100 + const prices: Record<string, number> = {};
101 + let amountType: string | null = null;
102 + for (const d of dp ?? []) {
103 + amountType ??= d.amountType ?? null;
104 + for (const c of d.convertedAmountList ?? []) if (c.currency && typeof c.amount === 'number' && c.amount > 0) prices[c.currency] ??= c.amount;
105 + }
106 + const track = rec.ecommerceTrackingParams as { price?: number; convertedAmounts?: Record<string, number> } | undefined;
107 + if (!Object.keys(prices).length && track?.convertedAmounts) for (const [k, v] of Object.entries(track.convertedAmounts)) if (typeof v === 'number' && v > 0) prices[k] = v;
108 + const seller = deref(rec.seller) as { serviceId?: string; sellerProfile?: { company?: string } } | null;
109 + const creators = (deref(rec.creators) as Array<{ creator?: { displayName?: string } }> | null) ?? [];
110 + const address = deref(rec.address) as { englishCountryName?: string } | null;
111 + const qv = deref(rec.quickViewDisplay) as { paragraphs?: Array<{ key?: string; text?: string }> } | null;
112 + const para = (key: string) => str(qv?.paragraphs?.find((p) => p.key === key)?.text);
113 + const meas = deref(rec.measurement) as Rec | null;
114 + const measList = meas ? (firstKey(meas, 'display') as Array<{ unit?: string; value?: string }> | undefined) : undefined;
115 + const photos = (deref(firstKey(rec, 'photos')) as Array<{ masterOrZoomPath?: string; versions?: Array<{ webPath?: string }> }> | null) ?? [];
116 + const images = photos.map((p) => p.versions?.find((v) => v.webPath?.includes('width=768'))?.webPath ?? p.masterOrZoomPath ?? null).filter((x): x is string => Boolean(x));
117 + const link = deref(rec.linkData) as { path?: string } | null;
118 + const path = str(rec.localizedPdpUrl) ?? str(link?.path);
119 + if (!path) continue;
120 + items.push({
121 + serviceId: rec.serviceId,
122 + title: rec.title,
123 + url: path.startsWith('http') ? path : `${BASE}${path}`,
124 + prices,
125 + amountType,
126 + isSold: rec.isSold === true,
127 + isOnHold: rec.isOnHold === true,
128 + isUnavailable: rec.isUnavailable === true,
129 + isNewListing: typeof rec.isNewListing === 'boolean' ? rec.isNewListing : null,
130 + seller: { serviceId: str(seller?.serviceId), company: str(seller?.sellerProfile?.company) },
131 + creators: creators.map((c) => str(c?.creator?.displayName)).filter((x): x is string => Boolean(x)),
132 + country: str(address?.englishCountryName),
133 + location: para('location'),
134 + attributesText: para('attributes'),
135 + materials: para('materials'),
136 + description: plainText(para('description'), 1200),
137 + measurement: str(measList?.find((x) => x.unit === 'IN')?.value ?? measList?.[0]?.value),
138 + categoryCode: str(rec.categoryCode),
139 + browseUrl: str(rec.browseUrl),
140 + vertical: str(rec.vertical),
141 + images: [...new Set(images)].slice(0, 4),
142 + });
143 + }
144 + return { items, totalResults, maxPages };
145 +}
146 +
147 +export function pageUrl(seedPath: string, page: number): string {
148 + const p = seedPath.endsWith('/') ? seedPath : `${seedPath}/`;
149 + return `${BASE}${p}${page > 1 ? `?page=${page}` : ''}`;
150 +}
151 +
152 +/**
153 + * Fixture helper: rebuild a minimal page whose serverVars_data store only holds the search connection
154 + * record plus the first `n` Item records and everything reachable from them through __ref/__refs.
155 + */
156 +export function trimStoreHtml(htmlText: string, n = 3): string {
157 + const m = htmlText.match(/<script id="serverVars_data" type="application\/json">([\s\S]*?)<\/script>/);
158 + if (!m) return '';
159 + const root = JSON.parse(m[1]!) as Rec;
160 + const store = ((root.dbl as Rec | undefined)?.relayData ?? {}) as Record<string, Rec>;
161 + const keep = new Set<string>();
162 + const queue: string[] = [];
163 + for (const [k, v] of Object.entries(store)) {
164 + if (v?.__typename === 'ItemSearchQueryConnection') keep.add(k);
165 + }
166 + let picked = 0;
167 + for (const [k, v] of Object.entries(store)) {
168 + if (v?.__typename === 'Item' && picked < n) {
169 + queue.push(k);
170 + picked++;
171 + }
172 + }
173 + const collect = (v: unknown) => {
174 + if (!v || typeof v !== 'object') return;
175 + if (Array.isArray(v)) return v.forEach(collect);
176 + const o = v as Rec;
177 + if (typeof o.__ref === 'string') queue.push(o.__ref);
178 + if (Array.isArray(o.__refs)) for (const r of o.__refs) queue.push(String(r));
179 + for (const [k, x] of Object.entries(o)) if (k !== '__ref' && k !== '__refs') collect(x);
180 + };
181 + while (queue.length) {
182 + const k = queue.shift()!;
183 + if (keep.has(k) || !store[k]) continue;
184 + keep.add(k);
185 + collect(store[k]);
186 + }
187 + const small: Record<string, Rec> = {};
188 + for (const k of keep) {
189 + const rec = store[k]!;
190 + // the connection record references every item on the page; keep only its scalar fields
191 + small[k] = rec.__typename === 'ItemSearchQueryConnection' ? Object.fromEntries(Object.entries(rec).filter(([, v]) => v === null || typeof v !== 'object')) : rec;
192 + }
193 + return `<!doctype html><html><head><script id="serverVars_data" type="application/json">${JSON.stringify({ dbl: { relayData: small } })}</script></head><body></body></html>`;
194 +}
195 +
196 +function verticalFor(seed: Seed, item: Item): DesignVertical {
197 + if (seed.vertical !== 'unknown') return seed.vertical;
198 + const v = item.vertical ?? '';
199 + const code = item.categoryCode ?? '';
200 + if (v === 'jewelry') return code.startsWith('J_WAT') ? 'watches' : 'jewelry';
201 + if (v === 'fashion') return 'fashion';
202 + if (v === 'art') return 'art';
203 + if (code.startsWith('F_LIG')) return 'lighting';
204 + if (code.startsWith('F_DEC') || code.startsWith('F_SER')) return 'decor';
205 + if (code.startsWith('F_RUG') || code.startsWith('F_TEX')) return 'rugs';
206 + return v === 'furniture' ? 'furniture' : 'unknown';
207 +}
208 +
209 +export class FirstDibsConnector extends BaseConnector {
210 + readonly version = '1.0.0';
211 + readonly parserVersion = PARSER_VERSION;
212 + protected override minIntervalMs = 5000;
213 + override readonly urlPatterns = [/^https?:\/\/(www\.)?1stdibs\.com\/(?:furniture|jewelry|fashion|art)\/.+\/id-([a-z]_\d+)\/?/i];
214 + private readonly cfg: z.infer<typeof ConfigSchema>;
215 +
216 + constructor(meta: ConnectorMeta) {
217 + super(meta);
218 + this.cfg = ConfigSchema.parse(meta.config);
219 + }
220 +
221 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
222 + const seeds = ctx.options.seeds?.length ? ctx.options.seeds.map((s) => SeedSchema.parse({ path: s.startsWith('/') ? s : new URL(s).pathname })) : this.cfg.seeds;
223 + const backfill = ctx.options.mode === 'backfill';
224 + const maxPages = backfill ? Math.min(this.policy.backfillMaxPages, this.cfg.maxPagesPerSeed) : this.cfg.pagesPerSeed;
225 + const start = readSeedCursor(ctx.options.cursor, seeds.length);
226 + const seedsThisRun = backfill ? seeds.length : Math.min(seeds.length, this.cfg.seedsPerRun);
227 + let count = 0;
228 + let items = 0;
229 + for (let k = 0; k < seedsThisRun; k++) {
230 + const seedIndex = (start.seedIndex + k) % seeds.length;
231 + const seed = seeds[seedIndex]!;
232 + let page = k === 0 ? start.page : 1;
233 + for (; page <= maxPages; page++) {
234 + if (ctx.signal?.aborted || this.reached(ctx, count)) return;
235 + const url = pageUrl(seed.path, page);
236 + await this.throttle(url);
237 + const res = await ctx.fetch(url, {
238 + engines: ['api'],
239 + responseType: 'text',
240 + timeoutMs: 90_000,
241 + expect: ['title', 'price', 'currency'],
242 + parse: (r) => {
243 + const p = r.html ? parseBrowseHtml(r.html) : null;
244 + const priced = p?.items.find((i) => i.prices.USD);
245 + return p?.items.length ? { title: p.items[0]!.title, price: priced?.prices.USD ?? null, currency: priced ? 'USD' : null } : null;
246 + },
247 + minQuality: 0.3,
248 + });
249 + const parsed = res.success && res.html ? parseBrowseHtml(res.html) : null;
250 + if (!parsed) {
251 + ctx.anomaly(res.success ? 'schema_drift' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'serverVars_data missing'}`);
252 + break;
253 + }
254 + if (!parsed.items.length) {
255 + if (page === 1) ctx.anomaly('parse_failure_page', `${url}: no Item records`);
256 + break;
257 + }
258 + count++;
259 + items += parsed.items.length;
260 + const payload: PagePayload = { kind: 'listing_page', url, seed, page, totalResults: parsed.totalResults, maxPages: parsed.maxPages, items: parsed.items };
261 + yield { url, externalId: `${seed.path}:p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
262 + await ctx.setCursor({ seedIndex, page: page + 1, at: new Date().toISOString() });
263 + await ctx.progress({ page, totalPages: parsed.maxPages, itemsProcessed: items });
264 + if (parsed.items.length < PAGE_SIZE || (parsed.maxPages !== null && page >= parsed.maxPages)) break;
265 + }
266 + const nextSeed = (seedIndex + 1) % seeds.length;
267 + await ctx.setCursor({ seedIndex: nextSeed, page: 1, at: new Date().toISOString(), ...(backfill && nextSeed === 0 ? { done: true } : {}) });
268 + }
269 + }
270 +
271 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
272 + const p = PagePayloadSchema.parse(raw.payload);
273 + const out: NormalizedRecord[] = [];
274 + for (const it of p.items) {
275 + const vertical = verticalFor(p.seed, it);
276 + const categorySlug = p.seed.slug ?? designSlug(it.attributesText ?? it.browseUrl, `${it.title} ${it.attributesText ?? ''}`, vertical);
277 + if (!categorySlug) continue;
278 + const currency = it.prices.USD ? 'USD' : Object.keys(it.prices)[0] ?? null;
279 + const price = currency ? it.prices[currency]! : null;
280 + const { year, decade } = yearOrDecade(`${it.title} ${it.attributesText ?? ''}`);
281 + const period = it.attributesText?.match(/\b(1[5-9]th Century|20th Century|21st Century|Antique|Vintage|Mid-Century Modern|Art Deco|Art Nouveau|Victorian|Georgian|Regency|Louis X[VI]+|Bauhaus|Scandinavian Modern|Hollywood Regency|Brutalist|Space Age|Postmodern|Memphis)\b/i)?.[0] ?? null;
282 + const attributes = AssetAttributesSchema.parse({
283 + categorySlug,
284 + brand: it.creators[0] ?? null,
285 + name: it.title,
286 + year,
287 + material: it.materials,
288 + size: it.measurement,
289 + country: null,
290 + identifiers: { firstdibs_item_id: it.serviceId, ...(it.seller.serviceId ? { firstdibs_seller_id: it.seller.serviceId } : {}) },
291 + metadata: { decade, period, creators: it.creators, attributes_text: it.attributesText, category_code: it.categoryCode, browse_url: it.browseUrl, vertical: it.vertical, converted_prices: it.prices, amount_type: it.amountType, item_country: it.country, is_new_listing: it.isNewListing },
292 + });
293 + out.push(
294 + NormalizedListingSchema.parse({
295 + kind: 'listing',
296 + connectorId: this.meta.id,
297 + sourceId: this.meta.sourceId,
298 + sourceUrl: it.url,
299 + externalId: it.serviceId,
300 + rawTitle: it.title,
301 + description: it.description,
302 + imageUrls: it.images,
303 + attributes,
304 + grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },
305 + condition: { condition: null, conditionRaw: null, completeness: null },
306 + observedAt: raw.fetchedAt,
307 + confidence: price ? 0.82 : 0.6,
308 + parserVersion: PARSER_VERSION,
309 + listingType: 'fixed_price',
310 + price,
311 + currency,
312 + seller: it.seller.company,
313 + location: it.location ?? it.country,
314 + availability: it.isSold ? 'sold' : it.isOnHold || it.isUnavailable ? 'ended' : 'available',
315 + }),
316 + );
317 + }
318 + return out;
319 + }
320 +}
321 +
322 +export default function createConnector(meta: ConnectorMeta): FirstDibsConnector {
323 + return new FirstDibsConnector(meta);
324 +}
added connectors/api/1stdibs/meta.json +47 −0
@@ -0,0 +1,47 @@
1 +{
2 + "id": "1stdibs",
3 + "displayName": "1stDibs (vintage design, antiques, jewelry, watches, fashion & art listings)",
4 + "sourceId": "1stdibs",
5 + "sourceName": "1stDibs",
6 + "sourceType": "marketplace",
7 + "sourceUrl": "https://www.1stdibs.com",
8 + "module": "api/1stdibs",
9 + "enginePriority": ["api"],
10 + "categories": ["design_furniture", "antiques", "porcelain", "glass_crystal", "silver", "clocks", "jewelry", "gemstones", "rolex", "patek_philippe", "audemars_piguet", "omega", "other_watches", "luxury_handbags", "art", "contemporary_art", "photography"],
11 + "regions": ["US", "GB", "FR", "IT", "DE"],
12 + "languages": ["en"],
13 + "currency": ["USD"],
14 + "supportsListings": true,
15 + "supportsSold": false,
16 + "supportsAuctions": false,
17 + "supportsImages": true,
18 + "supportsCatalog": false,
19 + "supportsPopulation": false,
20 + "supportsLookup": false,
21 + "refreshFrequencyMinutes": 1440,
22 + "priority": "medium",
23 + "trustScore": 0.8,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.1stdibs.com/about/user-agreement/",
26 + "acquisitionMethod": "public HTML — server-rendered Relay store (serverVars_data JSON) on browse pages",
27 + "historicalDepth": "none",
28 + "accessNotes": "1stDibs browse pages (/furniture/seating/, /furniture/lighting/, /furniture/decorative-objects/, /jewelry/, /jewelry/watches/wrist-watches/, /fashion/handbags-purses-bags/, /art/prints-works-on-paper/ … with ?page=N, 60 items per page, at most 50 pages per browse path as capped by the site) are served to the honest RareIndexBot UA as ~3 MB server-rendered documents whose <script id=\"serverVars_data\"> JSON contains the Relay store used to hydrate the grid. We read the `Item` records from that store: title, serviceId (f_########), the retail display price converted by 1stDibs into USD/EUR/GBP/CAD/AUD/CHF/MXN/NOK/SEK/DKK (USD kept as the listing price, the others in metadata), seller company + dealer id, designer/creator names, dealer city/country, period-style attribute line, materials, measurements, photos, sold/on-hold flags. We never call 1stDibs' GraphQL (/soa/graphql — reserved to the site, robots.txt allows only that exact path) nor the /search/ paths robots.txt disallows; product detail pages are not fetched. The page carries an F5/Shape client-detection script, but no challenge was served to plain HTTPS requests; if 1stDibs starts blocking, the connector fails loudly (schema_drift/page_fetch_failed) — no evasion engine is configured. User Agreement (checked 2026-09-08) has no clause on automated access to public pages. Dealer asking prices only → listings (never sales); 5 s between requests, 1 in flight, 3 browse paths × 1 page per daily run with seed rotation (≈180 listings/run, ~9 MB), backfill capped at the site's 50-page window. Category mapping: seed vertical + category code + attribute line/title → design_furniture (default), antiques (period evidence), porcelain/glass/silver/clocks (decorative objects), jewelry/gemstones, watch brand slugs, luxury_handbags, art/photography/contemporary_art; rugs & textiles skipped.",
29 + "enabled": true,
30 + "schemaVersion": "1.0",
31 + "config": {
32 + "pagesPerSeed": 1,
33 + "seedsPerRun": 3,
34 + "maxPagesPerSeed": 50,
35 + "seeds": [
36 + { "path": "/furniture/seating/", "vertical": "furniture" },
37 + { "path": "/furniture/tables/", "vertical": "furniture" },
38 + { "path": "/furniture/storage-case-pieces/", "vertical": "furniture" },
39 + { "path": "/furniture/lighting/", "vertical": "lighting" },
40 + { "path": "/furniture/decorative-objects/", "vertical": "decor" },
41 + { "path": "/jewelry/", "vertical": "jewelry" },
42 + { "path": "/jewelry/watches/wrist-watches/", "vertical": "watches" },
43 + { "path": "/fashion/handbags-purses-bags/", "slug": "luxury_handbags", "vertical": "fashion" },
44 + { "path": "/art/prints-works-on-paper/", "vertical": "art" }
45 + ]
46 + }
47 +}
added connectors/api/401-games/README.md +44 −0
@@ -0,0 +1,44 @@
1 +# 401 Games connector (`401-games`)
2 +
3 +- Source: https://store.401games.ca · dealer · CA · CAD · Shopify storefront
4 +- Acquisition: public `/collections/<handle>/products.json?limit=250&page=N` (shared `ShopifyStoreConnector` adapter wrapped by `MarketPinnedShopifyConnector`, which adds a `localization=CA` cookie so Shopify Markets never geo-converts prices; no store-specific code)
5 +- Records: `listing` (asking prices, one per variant; out-of-stock variants → `ended`)
6 +- Refresh: every 12 h (ACTIVE→NORMAL boundary, large catalogue) · history: none (listings only)
7 +
8 +Canada's largest TCG/board-game retailer (Toronto). Public Shopify storefront with one umbrella collection per game plus sports and non-sport cards.
9 +
10 +## Collections → taxonomy
11 +| collection handle | category | brand / franchise / series |
12 +|---|---|---|
13 +| `all-magic-the-gathering` | `magic_the_gathering` | Magic: The Gathering |
14 +| `all-pokemon` | `pokemon` | Pokémon |
15 +| `all-yugioh` | `yugioh` | Yu-Gi-Oh! |
16 +| `all-one-piece-card-game` | `one_piece_card_game` | One Piece |
17 +| `all-disney-lorcana` | `disney_lorcana` | Disney Lorcana |
18 +| `all-weiss-schwarz` | `weiss_schwarz` | Weiß Schwarz |
19 +| `all-digimon-card-game` | `digimon_tcg` | Digimon |
20 +| `all-dragon-ball-super` | `dragon_ball_tcg` | Dragon Ball Super |
21 +| `all-star-wars-unlimited` | `star_wars_tcg` | Star Wars Unlimited |
22 +| `all-cardfight-vanguard` | `other_tcg` | Cardfight!! Vanguard |
23 +| `all-riftbound-league-of-legends-tcg` | `other_tcg` | Riftbound |
24 +| `all-gundam-card-game` | `other_tcg` | Gundam Card Game |
25 +| `all-sports-cards` | `sports_cards` | — |
26 +| `all-non-sports-trading-cards` | `non_sport_cards` | — |
27 +| `all-board-games` | `board_games` | — |
28 +
29 +Rules (first match wins, applied to "title | product_type | tags | collection"):
30 +- `^(?=[\s\S]*\bhockey\b)[\s\S]*\| all-sports-cards$` → `hockey_cards`
31 +- `^(?=[\s\S]*\bbaseball\b)[\s\S]*\| all-sports-cards$` → `baseball_cards`
32 +- `^(?=[\s\S]*\bbasketball\b)[\s\S]*\| all-sports-cards$` → `basketball_cards`
33 +- `^(?=[\s\S]*\bfootball\b)[\s\S]*\| all-sports-cards$` → `football_cards`
34 +- `^(?=[\s\S]*\b(soccer|premier league|uefa|fifa)\b)[\s\S]*\| all-sports-cards$` → `soccer_cards`
35 +- `^(?=[\s\S]*\b(formula 1|formula one|\bf1\b)\b)[\s\S]*\| all-sports-cards$` → `f1_cards`
36 +
37 +Excluded (regex): `gift card|sleeves?|deck box|binder|playmat|toploader|top loader|storage box|dice|tokens? only|bundle of|mystery|buylist|submission|event ticket|\bentry\b|tournament|store credit|pre-?release event|supplies|card savers?|\brepack|bulk lot|\blot of\b|\bpaints?\b|primer|brushes|online (pack|code)|digital code|code card|unused digital|ptcgo|ptcg live`
38 +
39 +## Access & compliance
40 +See `accessNotes` in meta.json: robots.txt verified 2026-09-08 (standard Shopify rules, products.json paths allowed), honest UA, 1 req / 1.5 s, listings only, no personal data. Not fetched: supplies/dice/modelling collections, event tickets, staff-loyalty and "acorn" migration collections, the 0.00-priced Easy-Buy buylist placeholders (dropped: no price).
41 +
42 +## Fixtures & tests
43 +`data/fixtures/401-games/` — live captures (`pnpm tsx connectors/api/_g3-shops-na-lib/capture.ts 401-games`), trimmed single-product payloads incl. a sold-out variant and a graded item.
44 +`pnpm vitest run connectors/api/401-games` · live probe: `pnpm tsx connectors/api/_g3-shops-na-lib/probe.ts 401-games`.
added connectors/api/401-games/index.test.ts +46 −0
@@ -0,0 +1,46 @@
1 +import { describe, expect, it } from 'vitest';
2 +import meta from './meta.json' with { type: 'json' };
3 +import { describeShopifyStore } from '../_g3-shops-na-lib/store-suite.js';
4 +import createConnector from './index.js';
5 +
6 +/**
7 + * 401 Games — metadata-only Shopify store connector. The shared suite checks the taxonomy mapping of every
8 + * configured collection/rule, the exclusion regex on real title shapes, and normalises the live-captured
9 + * fixtures in data/fixtures/401-games/ (runFixtureSuite invariants + CAD currency, seller, SKUs, ended variants).
10 + */
11 +describeShopifyStore(meta, createConnector, { describe, it, expect }, {
12 + "cases": [
13 + {
14 + "title": "2023-24 Upper Deck Series 2 Hockey Hobby Box",
15 + "productType": "Sports Cards",
16 + "collection": "all-sports-cards",
17 + "categorySlug": "hockey_cards"
18 + },
19 + {
20 + "title": "2024 Topps Chrome Baseball Hobby Box",
21 + "collection": "all-sports-cards",
22 + "categorySlug": "baseball_cards"
23 + },
24 + {
25 + "title": "Easy-Buy Buylist Submission (Pokemon)",
26 + "collection": "all-pokemon",
27 + "categorySlug": null
28 + },
29 + {
30 + "title": "Pokemon - SWSH Darkness Ablaze Online Pack (Unused Digital Code)",
31 + "collection": "all-pokemon",
32 + "categorySlug": null
33 + },
34 + {
35 + "title": "Ultra Pro Deck Box - Black",
36 + "collection": "all-magic-the-gathering",
37 + "categorySlug": null
38 + },
39 + {
40 + "title": "Mareep (Japanese) - 036/172 - No Rarity",
41 + "collection": "all-pokemon",
42 + "categorySlug": "pokemon",
43 + "franchise": "Pokémon"
44 + }
45 + ]
46 +});
added connectors/api/401-games/index.ts +9 −0
@@ -0,0 +1,9 @@
1 +import type { ConnectorMeta } from '@rareindex/connectors';
2 +import { MarketPinnedShopifyConnector } from '../_g3-shops-na-lib/shopify-market.js';
3 +
4 +/**
5 + * 401 Games — Shopify storefront read through the shared Shopify adapter, pinned to the shop's home market
6 + * (localization cookie) so Shopify Markets never geo-converts prices. All source-specific knowledge lives in
7 + * meta.json (collections → taxonomy mapping, rules, exclusions, currency, market).
8 + */
9 +export default (meta: ConnectorMeta) => new MarketPinnedShopifyConnector(meta);
added connectors/api/401-games/meta.json +172 −0
@@ -0,0 +1,172 @@
1 +{
2 + "id": "401-games",
3 + "displayName": "401 Games (Canadian TCG & sports-card & board-game store, CAD)",
4 + "sourceId": "401-games",
5 + "sourceName": "401 Games",
6 + "sourceType": "dealer",
7 + "sourceUrl": "https://store.401games.ca",
8 + "module": "api/401-games",
9 + "enginePriority": [
10 + "api"
11 + ],
12 + "categories": [
13 + "magic_the_gathering",
14 + "pokemon",
15 + "yugioh",
16 + "one_piece_card_game",
17 + "disney_lorcana",
18 + "weiss_schwarz",
19 + "digimon_tcg",
20 + "dragon_ball_tcg",
21 + "star_wars_tcg",
22 + "other_tcg",
23 + "sports_cards",
24 + "non_sport_cards",
25 + "board_games",
26 + "hockey_cards",
27 + "baseball_cards",
28 + "basketball_cards",
29 + "football_cards",
30 + "soccer_cards",
31 + "f1_cards"
32 + ],
33 + "regions": [
34 + "CA"
35 + ],
36 + "languages": [
37 + "en"
38 + ],
39 + "currency": [
40 + "CAD"
41 + ],
42 + "supportsListings": true,
43 + "supportsSold": false,
44 + "supportsAuctions": false,
45 + "supportsImages": true,
46 + "supportsCatalog": false,
47 + "supportsPopulation": false,
48 + "supportsLookup": true,
49 + "refreshFrequencyMinutes": 720,
50 + "priority": "medium",
51 + "trustScore": 0.75,
52 + "attributionRequired": true,
53 + "termsUrl": "https://store.401games.ca/policies/terms-of-service",
54 + "accessNotes": "401 Games (store.401games.ca) runs on Shopify. The connector reads ONLY the public, unauthenticated storefront JSON that every Shopify shop serves to any visitor: /collections/<handle>/products.json?limit=250&page=N for the 15 configured collections (all-magic-the-gathering, all-pokemon, all-yugioh, all-one-piece-card-game, all-disney-lorcana, all-weiss-schwarz, all-digimon-card-game, all-dragon-ball-super … (+7 more, see config.collections)) and /products/<handle>.json for URL lookups (~430k products; MTG 158k, Yu-Gi-Oh! 60k, Pokémon 54k). robots.txt (verified 2026-09-08, User-agent *): standard Shopify rules — Disallow /admin, /cart, /checkout(s), /orders, /account, /services, /sf_*, /cart.js, /recommendations/products, /cdn/wpm/*.js and the filter/sort crawl traps (/collections/*sort_by*, /collections/*+*, /collections/*filter*&*filter*); no Crawl-delay for *. The /collections/<handle>/products.json and /products/<handle>.json paths we read carry none of those patterns and are allowed. Sitemap declared at /sitemap.xml (not needed). Access behaviour: plain HTTP 200 JSON with the honest RareIndexBot UA, no anti-bot challenge, no login, no key. Market pinning: Shopify Markets localises products.json prices by the requester IP as soon as an Accept-Language header is present (observed on Markets-enabled shops: a Canadian crawler receives CAD-converted prices with content-language en-CA while the JSON carries no currency field). Every request therefore sends the public localization=CA cookie — the shop's own home market, exactly what a CA visitor gets — so prices are always the declared CAD (verified 2026-09-08: 30.00 vs 43.00 on a Markets shop). Politeness: 1 request per 1.5 s, concurrency 1 per host (connectors/domains.d/g3-shops-na.json), crawlDepth pages per collection per incremental run, backfill bounded by backfillMaxPages. Data: asking prices in CAD (the shop's declared currency) — these are LISTINGS, never sales (§111); one listing per variant (condition/size/edition), variant SKU kept as identifier, out-of-stock variants kept as 'ended' listings, 0.00-priced placeholders dropped. Dates: published_at only; no fetch time is ever used as a sale date. Third-party grades in titles (PSA/BGS/CGC/ICCS/PMG…) are parsed by parseGradeFromTitle; cert numbers are not extracted. Deliberately NOT fetched: cart/checkout/account/customer endpoints, per-product .js barcode enrichment (fetchBarcodes=false: one extra request per product is not worth it for listings), the whole-shop /products.json, and unmapped collections — supplies/dice/modelling collections, event tickets, staff-loyalty and \"acorn\" migration collections, the 0.00-priced Easy-Buy buylist placeholders (dropped: no price). No personal data is collected; seller = the store itself.",
55 + "enabled": true,
56 + "schemaVersion": "1.0",
57 + "acquisitionMethod": "Shopify storefront products.json",
58 + "historicalDepth": "none",
59 + "requires": [],
60 + "config": {
61 + "currency": "CAD",
62 + "market": "CA",
63 + "seller": "401 Games",
64 + "location": "Toronto, ON, Canada",
65 + "collections": [
66 + {
67 + "handle": "all-magic-the-gathering",
68 + "categorySlug": "magic_the_gathering",
69 + "franchise": "Magic: The Gathering"
70 + },
71 + {
72 + "handle": "all-pokemon",
73 + "categorySlug": "pokemon",
74 + "franchise": "Pokémon"
75 + },
76 + {
77 + "handle": "all-yugioh",
78 + "categorySlug": "yugioh",
79 + "franchise": "Yu-Gi-Oh!"
80 + },
81 + {
82 + "handle": "all-one-piece-card-game",
83 + "categorySlug": "one_piece_card_game",
84 + "franchise": "One Piece"
85 + },
86 + {
87 + "handle": "all-disney-lorcana",
88 + "categorySlug": "disney_lorcana",
89 + "franchise": "Disney Lorcana"
90 + },
91 + {
92 + "handle": "all-weiss-schwarz",
93 + "categorySlug": "weiss_schwarz",
94 + "franchise": "Weiß Schwarz"
95 + },
96 + {
97 + "handle": "all-digimon-card-game",
98 + "categorySlug": "digimon_tcg",
99 + "franchise": "Digimon"
100 + },
101 + {
102 + "handle": "all-dragon-ball-super",
103 + "categorySlug": "dragon_ball_tcg",
104 + "franchise": "Dragon Ball Super"
105 + },
106 + {
107 + "handle": "all-star-wars-unlimited",
108 + "categorySlug": "star_wars_tcg",
109 + "franchise": "Star Wars Unlimited"
110 + },
111 + {
112 + "handle": "all-cardfight-vanguard",
113 + "categorySlug": "other_tcg",
114 + "franchise": "Cardfight!! Vanguard"
115 + },
116 + {
117 + "handle": "all-riftbound-league-of-legends-tcg",
118 + "categorySlug": "other_tcg",
119 + "franchise": "Riftbound"
120 + },
121 + {
122 + "handle": "all-gundam-card-game",
123 + "categorySlug": "other_tcg",
124 + "franchise": "Gundam Card Game"
125 + },
126 + {
127 + "handle": "all-sports-cards",
128 + "categorySlug": "sports_cards"
129 + },
130 + {
131 + "handle": "all-non-sports-trading-cards",
132 + "categorySlug": "non_sport_cards"
133 + },
134 + {
135 + "handle": "all-board-games",
136 + "categorySlug": "board_games"
137 + }
138 + ],
139 + "rules": [
140 + {
141 + "match": "^(?=[\\s\\S]*\\bhockey\\b)[\\s\\S]*\\| all-sports-cards$",
142 + "categorySlug": "hockey_cards"
143 + },
144 + {
145 + "match": "^(?=[\\s\\S]*\\bbaseball\\b)[\\s\\S]*\\| all-sports-cards$",
146 + "categorySlug": "baseball_cards"
147 + },
148 + {
149 + "match": "^(?=[\\s\\S]*\\bbasketball\\b)[\\s\\S]*\\| all-sports-cards$",
150 + "categorySlug": "basketball_cards"
151 + },
152 + {
153 + "match": "^(?=[\\s\\S]*\\bfootball\\b)[\\s\\S]*\\| all-sports-cards$",
154 + "categorySlug": "football_cards"
155 + },
156 + {
157 + "match": "^(?=[\\s\\S]*\\b(soccer|premier league|uefa|fifa)\\b)[\\s\\S]*\\| all-sports-cards$",
158 + "categorySlug": "soccer_cards"
159 + },
160 + {
161 + "match": "^(?=[\\s\\S]*\\b(formula 1|formula one|\\bf1\\b)\\b)[\\s\\S]*\\| all-sports-cards$",
162 + "categorySlug": "f1_cards"
163 + }
164 + ],
165 + "defaultCategory": null,
166 + "exclude": "gift card|sleeves?|deck box|binder|playmat|toploader|top loader|storage box|dice|tokens? only|bundle of|mystery|buylist|submission|event ticket|\\bentry\\b|tournament|store credit|pre-?release event|supplies|card savers?|\\brepack|bulk lot|\\blot of\\b|\\bpaints?\\b|primer|brushes|online (pack|code)|digital code|code card|unused digital|ptcgo|ptcg live",
167 + "keepOutOfStock": true,
168 + "fetchBarcodes": false,
169 + "wholeShop": false,
170 + "pageSize": 250
171 + }
172 +}
added connectors/api/_g1-cards-eu-jp-lib/index.ts +261 −0
@@ -0,0 +1,261 @@
1 +/**
2 + * Helpers shared by the g1-cards-eu-jp connectors (cardmarket-priceguide, limitless-tcg, yuyu-tei,
3 + * hareruya, magi, cardrush, cardtrader). Kept inside connectors/api (not the framework).
4 + */
5 +import { normalizeCondition, parseGradeFromTitle } from '@rareindex/taxonomy';
6 +
7 +export const BOT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; contact data@rareindex.io)';
8 +export const HTML_HEADERS = { 'user-agent': BOT_UA, accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'accept-language': 'ja,en;q=0.8' };
9 +export const JSON_HEADERS = { 'user-agent': BOT_UA, accept: 'application/json, text/plain, */*;q=0.8' };
10 +
11 +/** UTC midnight of a Date (observation dates are day-precise). */
12 +export function dayOf(d: Date): Date {
13 + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
14 +}
15 +
16 +/** "2026-09-07T02:48:04+0200" | "2026-09-07" → UTC midnight of that calendar day (source's own date), else null. */
17 +export function isoDay(s: string | null | undefined): Date | null {
18 + if (!s) return null;
19 + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
20 + if (!m) return null;
21 + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
22 + return Number.isNaN(d.getTime()) ? null : d;
23 +}
24 +
25 +/** "118,000 円" | "¥ 187,614" | "780円" | "¥1,590" | "4200000" → integer yen; null when absent or ≤ 0. */
26 +export function yen(s: string | number | null | undefined): number | null {
27 + if (s === null || s === undefined) return null;
28 + if (typeof s === 'number') return Number.isFinite(s) && s > 0 ? Math.round(s) : null;
29 + const m = s.replace(/[,,\s]/g, '').match(/(\d+)/);
30 + if (!m) return null;
31 + const n = Number.parseInt(m[1]!, 10);
32 + return Number.isFinite(n) && n > 0 ? n : null;
33 +}
34 +
35 +/** "$0.25" | "0.02€" | "1,585.73€" | "€1,179.09" → { amount, currency } (EN number format), else null. */
36 +export function usdEur(s: string | null | undefined): { amount: number; currency: 'USD' | 'EUR' } | null {
37 + if (!s) return null;
38 + const t = s.trim();
39 + const currency = /€|\bEUR\b/.test(t) ? 'EUR' : /\$|\bUSD\b/.test(t) ? 'USD' : null;
40 + if (!currency) return null;
41 + const m = t.replace(/,/g, '').match(/(\d+(?:\.\d+)?)/);
42 + if (!m) return null;
43 + const amount = Number.parseFloat(m[1]!);
44 + return Number.isFinite(amount) && amount > 0 ? { amount, currency } : null;
45 +}
46 +
47 +export function cleanText(s: string | null | undefined): string | null {
48 + if (!s) return null;
49 + const t = s.replace(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
50 + return t || null;
51 +}
52 +
53 +/** "113/076" → { number: "113", total: "076" }; "OP15-118" → { number: "OP15-118", total: null }; "-" → nulls. */
54 +export function splitCardNumber(s: string | null | undefined): { number: string | null; total: string | null } {
55 + if (!s) return { number: null, total: null };
56 + const t = s.trim();
57 + if (!t || t === '-' || t === '—') return { number: null, total: null };
58 + const m = t.match(/^([A-Za-z0-9]+)\s*\/\s*([A-Za-z0-9-]+)$/);
59 + if (m) return { number: m[1]!, total: m[2]! };
60 + return { number: t, total: null };
61 +}
62 +
63 +/** Grade parser tolerant of Japanese shop notation: "PSA10鑑定済", "【PSA10】", "BGS9.5" → grader/grade. */
64 +export function jpGrade(title: string): { grader: string | null; grade: string | null; qualifier: string | null } {
65 + const spaced = title.replace(/(PSA|BGS|CGC|SGC|ACE|TAG)(\d)/gi, '$1 $2').replace(/鑑定済/g, ' graded ');
66 + const g = parseGradeFromTitle(spaced);
67 + return g;
68 +}
69 +
70 +/** Japanese shop condition labels → taxonomy condition slug; null when the scale is shop-specific (状態A/B…) — never guessed. */
71 +export function jpCondition(raw: string | null | undefined): string | null {
72 + if (!raw) return null;
73 + const t = raw.trim();
74 + const direct = normalizeCondition('trading_cards', t);
75 + if (direct) return direct;
76 + if (/^(NM|near mint)$/i.test(t)) return 'near_mint';
77 + if (/^EX[+-]?$/i.test(t)) return 'excellent';
78 + if (/^(HP|heavily played)$/i.test(t)) return 'good';
79 + if (/^(DMG|damaged)$/i.test(t)) return 'poor';
80 + return null;
81 +}
82 +
83 +export interface JpCardTitle {
84 + /** card name with all tags stripped (EN half preferred for bilingual "JP/EN" names) */
85 + name: string;
86 + /** contents of the leading 〔…〕/[…] tags: condition or grading ("状態A-", "PSA10鑑定済", "PLD") */
87 + conditionRaw: string | null;
88 + /** 【…】 tag: rarity (Pokémon/One Piece shops) or set code (MTG shops) — caller decides via `bracket` */
89 + rarity: string | null;
90 + setCode: string | null;
91 + /** {…} tag: "013/068" → number 013, total 068; "OP01-001"; "-" → null */
92 + number: string | null;
93 + total: string | null;
94 + /** 《日本語》/《英語》 → "Japanese"/"English"; null when absent */
95 + language: string | null;
96 + /** parenthesised hints: "(RR仕様)", "(パラレル)", "(MA仕様/英語版)" */
97 + notes: string[];
98 + /** trailing "N枚" (count of cards in the listing) */
99 + quantity: number | null;
100 + sealed: boolean;
101 + grader: string | null;
102 + grade: string | null;
103 + qualifier: string | null;
104 +}
105 +
106 +const LANG_MAP: Record<string, string> = { 日本語: 'Japanese', 英語: 'English', 韓国語: 'Korean', 中国語: 'Chinese', 簡体字: 'Chinese', 繁体字: 'Chinese', ドイツ語: 'German', フランス語: 'French', イタリア語: 'Italian', スペイン語: 'Spanish', ポルトガル語: 'Portuguese', ロシア語: 'Russian' };
107 +
108 +/**
109 + * Parse the title conventions shared by Japanese single-card shops (Cardrush, Magi sellers, Yuyu-tei):
110 + * "〔PSA10鑑定済〕ミロカロスδ-デルタ種【★】{013/068}"
111 + * "〔状態A-〕ミュウツーV(RR仕様)【P】{273/S-P} 1枚"
112 + * "[PLD](黒枠)ラノワールのエルフ/Llanowar Elves《日本語》【4ED】"
113 + * "ブースターパック 受け継がれる意志【未開封BOX】{-}"
114 + * `bracket` tells whether the 【…】 tag is a rarity (default) or a set code (MTG shops).
115 + */
116 +export function parseJpCardTitle(title: string, opts: { bracket?: 'rarity' | 'set' } = {}): JpCardTitle {
117 + let t = title.replace(/\s+/g, ' ').trim();
118 + const conds: string[] = [];
119 + // leading condition/grading tags
120 + for (;;) {
121 + const m = t.match(/^(?:〔([^〕]*)〕|\[([^\]]*)\])\s*/);
122 + if (!m) break;
123 + conds.push((m[1] ?? m[2] ?? '').trim());
124 + t = t.slice(m[0].length);
125 + }
126 + let sealed = false;
127 + let rarity: string | null = null;
128 + let setCode: string | null = null;
129 + t = t.replace(/【([^】]*)】/g, (_, inner: string) => {
130 + const v = inner.trim();
131 + if (/^(PSA|BGS|CGC|SGC|ACE|TAG|ARS)\s*\d/i.test(v)) conds.push(v); // "【PSA10】" = grading tag, not a rarity
132 + else if (/未開封|BOX|パック|カートン/i.test(v)) sealed = true;
133 + else if (opts.bracket === 'set') setCode = setCode ?? (v || null);
134 + else rarity = rarity ?? (v && v !== '-' ? v : null);
135 + return ' ';
136 + });
137 + const g = jpGrade(conds.join(' '));
138 + let number: string | null = null;
139 + let total: string | null = null;
140 + t = t.replace(/\{([^}]*)\}/g, (_, inner: string) => {
141 + // "{ST15-005[OP16]}" → number ST15-005, set code OP16 (reprint origin)
142 + const withSet = inner.match(/^(.*?)\[([A-Za-z0-9-]+)\]\s*$/);
143 + const s = splitCardNumber(withSet ? withSet[1]! : inner);
144 + if (withSet && opts.bracket !== 'set') setCode = setCode ?? withSet[2]!;
145 + if (s.number && number === null) {
146 + number = s.number;
147 + total = s.total;
148 + }
149 + return ' ';
150 + });
151 + let language: string | null = null;
152 + t = t.replace(/《([^》]*)》/g, (_, inner: string) => {
153 + language = language ?? LANG_MAP[inner.trim()] ?? inner.trim() ?? null;
154 + return ' ';
155 + });
156 + let quantity: number | null = null;
157 + const q = t.match(/(?:^|\s)(\d{1,3})枚(?:セット|組)?\s*$/);
158 + if (q) {
159 + quantity = Number(q[1]);
160 + t = t.slice(0, q.index).trim();
161 + }
162 + const notes: string[] = [];
163 + t = t.replace(/[((]([^()()]*)[))]/g, (_, inner: string) => {
164 + const v = inner.trim();
165 + if (v) notes.push(v);
166 + if (/未開封/.test(v)) sealed = true;
167 + return ' ';
168 + });
169 + // bare "120/114" card number outside braces (private sellers often omit the braces)
170 + if (number === null) {
171 + const bare = t.match(/(?:^|\s)(\d{1,3})\s*\/\s*(\d{1,3})(?=\s|$)/);
172 + if (bare) {
173 + number = bare[1]!;
174 + total = bare[2]!;
175 + t = t.replace(bare[0], ' ');
176 + }
177 + }
178 + let name = t.replace(/\s+/g, ' ').trim();
179 + // bilingual MTG names "ラノワールのエルフ/Llanowar Elves" → prefer the Latin half
180 + const bi = name.match(/^(.+?)\/(.+)$/);
181 + if (bi && /[A-Za-z]/.test(bi[2]!) && !/[A-Za-z]/.test(bi[1]!)) name = bi[2]!.trim();
182 + const conditionRaw = conds.filter((c) => c && !/PSA|BGS|CGC|SGC|ACE|TAG|鑑定/i.test(c)).join(' / ') || (conds.length ? conds.join(' / ') : null);
183 + return { name: name || title.trim(), conditionRaw, rarity, setCode, number, total, language, notes, quantity, sealed, grader: g.grader, grade: g.grade, qualifier: g.qualifier };
184 +}
185 +
186 +/**
187 + * JSON.parse for JSON-LD blocks that embed raw line breaks inside string values (magi item pages do):
188 + * escapes control characters found inside strings, then parses. Returns null on failure.
189 + */
190 +export function lenientJsonParse(text: string): unknown | null {
191 + try {
192 + return JSON.parse(text);
193 + } catch {
194 + let out = '';
195 + let inStr = false;
196 + for (let i = 0; i < text.length; i++) {
197 + const ch = text[i]!;
198 + if (inStr) {
199 + if (ch === '\\') {
200 + out += ch + (text[i + 1] ?? '');
201 + i++;
202 + continue;
203 + }
204 + if (ch === '"') inStr = false;
205 + else if (ch === '\n') {
206 + out += '\\n';
207 + continue;
208 + } else if (ch === '\r') continue;
209 + else if (ch === '\t') {
210 + out += '\\t';
211 + continue;
212 + }
213 + } else if (ch === '"') inStr = true;
214 + out += ch;
215 + }
216 + try {
217 + return JSON.parse(out);
218 + } catch {
219 + return null;
220 + }
221 + }
222 +}
223 +
224 +/** All JSON-LD objects of a given @type in a document (lenient about raw newlines inside strings). */
225 +export function jsonLdObjects(doc: string, type: string): Record<string, unknown>[] {
226 + const out: Record<string, unknown>[] = [];
227 + const re = /<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
228 + let m: RegExpExecArray | null;
229 + while ((m = re.exec(doc))) {
230 + const parsed = lenientJsonParse(m[1]!.trim());
231 + const items = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' && '@graph' in (parsed as object) ? ((parsed as { '@graph': unknown[] })['@graph'] ?? []) : [parsed];
232 + for (const it of items) {
233 + if (!it || typeof it !== 'object') continue;
234 + const t = (it as { '@type'?: string | string[] })['@type'];
235 + const types = Array.isArray(t) ? t : t ? [t] : [];
236 + if (types.includes(type)) out.push(it as Record<string, unknown>);
237 + }
238 + }
239 + return out;
240 +}
241 +
242 +/** Variant label from Japanese printing hints: パラレル → Parallel, ミラー → Mirror, SR仕様 → "SR". */
243 +export function jpVariant(notes: string[], name?: string): string | null {
244 + const hay = [...notes, name ?? ''].join(' ');
245 + if (/パラレル/.test(hay)) return 'Parallel';
246 + if (/マスターボールミラー/.test(hay)) return 'Master Ball Mirror';
247 + if (/モンスターボールミラー/.test(hay)) return 'Poké Ball Mirror';
248 + if (/ミラー/.test(hay)) return 'Mirror';
249 + if (/コミックパラレル|コミパラ/.test(hay)) return 'Comic Parallel';
250 + if (/箔押し|foil/i.test(hay)) return 'Foil';
251 + return null;
252 +}
253 +
254 +/** Mystery packs / grab bags / lots sold by Japanese shops — never priced as cards. */
255 +export const JP_EXCLUDE_RE = /オリパ|福袋|まとめ売り|詰め合わせ|セット販売|スリーブ|プレイマット|デッキケース|サプライ|ストレージ|ローダー|募集用/;
256 +
257 +/** Bundle detection: "3枚", "4コン", "セット", "まとめ" (single card listings say "1枚"). */
258 +export function isJpBundle(title: string, quantity: number | null): boolean {
259 + if (quantity !== null && quantity > 1) return true;
260 + return /\d+コン|セット(?!ブースター)|まとめ|複数枚/.test(title) && !/スターターセット|ex?スタート|構築済み/.test(title);
261 +}
added connectors/api/_g1-cards-eu-jp-lib/smoke.ts +61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g10-lib/capture.ts +139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g10-lib/index.ts +211 −0
Large diff (211 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g3-shops-na-lib/capture.ts +144 −0
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g3-shops-na-lib/probe.ts +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g3-shops-na-lib/shopify-market.ts +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g3-shops-na-lib/store-suite.ts +209 −0
Large diff (209 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g4-shops-intl-lib/capture.ts +108 −0
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g4-shops-intl-lib/smoke.ts +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g4-shops-intl-lib/suite.ts +124 −0
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g6-comics-toys-games-lib/_inspect.ts +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g6-comics-toys-games-lib/capture-env.ts +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g6-comics-toys-games-lib/capture.ts +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g6-comics-toys-games-lib/comics.ts +141 −0
Large diff (141 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g7-auctions-na-lib/index.ts +263 −0
Large diff (263 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g7-auctions-na-lib/smoke.ts +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g8-auctions-eu-apac-lib/capture.ts +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g8-auctions-eu-apac-lib/drouot-platform.ts +108 −0
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g8-auctions-eu-apac-lib/goauction-platform.ts +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g8-auctions-eu-apac-lib/index.ts +317 −0
Large diff (317 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g8-auctions-eu-apac-lib/sale-results.ts +383 −0
Large diff (383 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g9-asia-watch-sneaker-lib/capture.ts +103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/_g9-asia-watch-sneaker-lib/index.ts +220 −0
Large diff (220 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/ader/README.md +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/ader/index.test.ts +77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/ader/index.ts +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/ader/meta.json +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/aguttes/README.md +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/aguttes/index.test.ts +94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/aguttes/index.ts +132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/aguttes/meta.json +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/aste-bolaffi/README.md +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/aste-bolaffi/index.test.ts +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/aste-bolaffi/index.ts +107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/aste-bolaffi/meta.json +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/auctionet/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/auctionet/_capture.ts +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/auctionet/index.test.ts +193 −0
Large diff (193 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/auctionet/index.ts +390 −0
Large diff (390 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/auctionet/meta.json +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/bernaerts/README.md +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/bernaerts/index.test.ts +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/bernaerts/index.ts +125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/bernaerts/meta.json +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/big-b-comics/README.md +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/big-b-comics/index.test.ts +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/big-b-comics/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/big-b-comics/meta.json +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/bunjang/README.md +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/bunjang/index.test.ts +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/bunjang/index.ts +174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/bunjang/meta.json +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/capsule-toronto/README.md +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/capsule-toronto/index.test.ts +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/capsule-toronto/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/capsule-toronto/meta.json +107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardboard-memories/README.md +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardboard-memories/index.test.ts +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardboard-memories/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardboard-memories/meta.json +170 −0
Large diff (170 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmarket-priceguide/README.md +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmarket-priceguide/_capture.ts +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmarket-priceguide/index.test.ts +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmarket-priceguide/index.ts +289 −0
Large diff (289 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmarket-priceguide/meta.json +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmerchant-nz/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmerchant-nz/index.test.ts +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmerchant-nz/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardmerchant-nz/meta.json +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardrush/README.md +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardrush/_capture.ts +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardrush/index.test.ts +68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardrush/index.ts +176 −0
Large diff (176 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardrush/meta.json +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardtrader/README.md +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardtrader/index.test.ts +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardtrader/index.ts +217 −0
Large diff (217 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cardtrader/meta.json +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cdncoin/README.md +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cdncoin/index.test.ts +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cdncoin/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cdncoin/meta.json +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chairish/README.md +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chairish/index.test.ts +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chairish/index.ts +255 −0
Large diff (255 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chairish/meta.json +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chatterley-luxuries/README.md +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chatterley-luxuries/index.test.ts +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chatterley-luxuries/index.ts +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chatterley-luxuries/meta.json +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cherrycollectables/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cherrycollectables/index.test.ts +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cherrycollectables/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/cherrycollectables/meta.json +235 −0
Large diff (235 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chiswick-auctions/README.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chiswick-auctions/index.test.ts +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chiswick-auctions/index.ts +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chiswick-auctions/meta.json +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chronext/README.md +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chronext/index.test.ts +72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chronext/index.ts +210 −0
Large diff (210 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/chronext/meta.json +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/clean-sweep/README.md +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/clean-sweep/index.test.ts +83 −0
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/clean-sweep/index.ts +269 −0
Large diff (269 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/clean-sweep/meta.json +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/colonial-acres/README.md +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/colonial-acres/index.test.ts +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/colonial-acres/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/colonial-acres/meta.json +200 −0
Large diff (200 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/corinphila/README.md +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/corinphila/_smoke.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/corinphila/index.test.ts +59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/corinphila/index.ts +232 −0
Large diff (232 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/corinphila/meta.json +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/crepslocker/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/crepslocker/index.test.ts +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/crepslocker/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/crepslocker/meta.json +154 −0
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dlrc/README.md +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dlrc/_smoke.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dlrc/index.test.ts +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dlrc/index.ts +294 −0
Large diff (294 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dlrc/meta.json +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dominic-winter/README.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dominic-winter/index.test.ts +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dominic-winter/index.ts +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dominic-winter/meta.json +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/doyle/README.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/doyle/index.test.ts +83 −0
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/doyle/index.ts +279 −0
Large diff (279 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/doyle/meta.json +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dunbar-sloane/README.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dunbar-sloane/index.test.ts +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dunbar-sloane/index.ts +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/dunbar-sloane/meta.json +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/ebay-browse/README.md +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/ebay-browse/index.test.ts +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/ebay-browse/index.ts +313 −0
Large diff (313 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/ebay-browse/meta.json +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/etsy/README.md +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/etsy/index.test.ts +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/etsy/index.ts +307 −0
Large diff (307 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/etsy/meta.json +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/european-watch-company/README.md +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/european-watch-company/index.test.ts +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/european-watch-company/index.ts +208 −0
Large diff (208 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/european-watch-company/meta.json +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/everything-games/README.md +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/everything-games/index.test.ts +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/everything-games/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/everything-games/meta.json +102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/face-to-face-games/README.md +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/face-to-face-games/index.test.ts +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/face-to-face-games/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/face-to-face-games/meta.json +125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/fanatics-collect/README.md +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/fanatics-collect/index.test.ts +137 −0
Large diff (137 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/fanatics-collect/index.ts +276 −0
Large diff (276 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/fanatics-collect/meta.json +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/flagshipgames/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/flagshipgames/index.test.ts +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/flagshipgames/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/flagshipgames/meta.json +182 −0
Large diff (182 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/footdistrict/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/footdistrict/index.test.ts +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/footdistrict/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/footdistrict/meta.json +116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/fusion-gaming/README.md +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/fusion-gaming/index.test.ts +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/fusion-gaming/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/fusion-gaming/meta.json +113 −0
Large diff (113 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gameology/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gameology/index.test.ts +77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gameology/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gameology/meta.json +283 −0
Large diff (283 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gamesportal/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gamesportal/index.test.ts +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gamesportal/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gamesportal/meta.json +124 −0
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gamezilla/README.md +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gamezilla/index.test.ts +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gamezilla/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gamezilla/meta.json +200 −0
Large diff (200 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gcd/README.md +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gcd/_capture.ts +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gcd/index.test.ts +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gcd/index.ts +369 −0
Large diff (369 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/gcd/meta.json +70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/goblingames-nz/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/goblingames-nz/index.test.ts +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/goblingames-nz/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/goblingames-nz/meta.json +125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/goodgames/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/goodgames/index.test.ts +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/goodgames/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/goodgames/meta.json +188 −0
Large diff (188 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hareruya/README.md +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hareruya/_capture.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hareruya/index.test.ts +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hareruya/index.ts +193 −0
Large diff (193 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hareruya/meta.json +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hibid/README.md +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hibid/categories.ts +97 −0
Large diff (97 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hibid/fixtures.state.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hibid/index.test.ts +115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hibid/index.ts +295 −0
Large diff (295 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hibid/meta.json +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hobbiesville/README.md +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hobbiesville/index.test.ts +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hobbiesville/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hobbiesville/meta.json +125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hodinkee-shop/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hodinkee-shop/index.test.ts +90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hodinkee-shop/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/hodinkee-shop/meta.json +163 −0
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kanzen-games/README.md +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kanzen-games/index.test.ts +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kanzen-games/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kanzen-games/meta.json +177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kapowtoys/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kapowtoys/index.test.ts +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kapowtoys/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kapowtoys/meta.json +166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kickgame/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kickgame/index.test.ts +91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kickgame/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kickgame/meta.json +164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kidrobot/README.md +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kidrobot/index.test.ts +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kidrobot/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kidrobot/meta.json +120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kingscomics/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kingscomics/index.test.ts +127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kingscomics/index.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/api/kingscomics/meta.json +112 −0
Large diff (112 lines) is not loaded by default to keep the page fast.Load diff

Diff truncated — file too large.

Menu

Theme