Overview
Extract clubs and fixtures data from https://fv.dribl.com/fixtures/ (Cloudflare-protected SPA) using a real browser for Cloudflare clearance, then making direct API calls via page.evaluate(fetch(...)) rather than driving the SPA UI.
Purpose: Crawl dribl.com to maintain up-to-date clubs and fixtures.
Why direct API — not SPA scraping
The SPA's default date-window views (Results tab + Fixtures upcoming) create a dead zone of 4+ rounds whenever a team is regraded to a new league mid-season. The dribl API accepts a round param; iterating round=1..N is deterministic and never drops rounds.
Raw curl returns HTTP 403 (Cloudflare). The browser context has clearance cookies — page.evaluate(fetch(...)) uses them transparently.
Architecture
Data flow:
dribl list endpoints → data/external/dribl-ids.json (ID cache, gitignored)
dribl API (per round) → data/external/fixtures/{team}/chunk-N.json (raw, gitignored)
dribl API (ladders) → data/external/table/{team}.json (raw, gitignored)
transform → data/matches/{team}.json (committed)
transform → data/table/{team}.json (committed)
Two-phase pattern:
- Extraction: Browser establishes Cloudflare clearance; direct API calls via
page.evaluate(fetch(...)); saves raw JSON per round - Transformation: Read chunk files, validate with Zod, transform, deduplicate, save
Key technologies:
- playwright-core (real Chrome browser — only for Cloudflare clearance)
page.evaluate(fetch(...))— makes authenticated API calls from browser context- Zod validation schemas
- TypeScript with tsx runner
ID Resolver
Reference: bin/commands/driblIdResolver.ts
The dribl API uses hashed IDs (e.g. season=nPmrj2rmow) not human names. IDs are resolved once from list endpoints and cached to data/external/dribl-ids.json keyed by leagueName. On regrade, the team's Sanity leagueName changes — the resolver finds no cache entry, re-resolves, and caches.
List endpoints (all require tenant param):
| What | Endpoint | Notes |
|---|---|---|
| Tenant ID | api/tenants?mc_link=fv.dribl.com&slug=fv |
Returns single object data.id |
| Season ID | api/list/seasons?disable_paging=true&tenant=… |
Match on name = "2026" |
| Competition ID | api/list/competitions?disable_paging=true&tenant=… |
Match on name (full name from Sanity competitionName) |
| League ID | api/list/leagues?disable_paging=true&tenant=…&competition=… |
Match on name; skip (Removed) prefix entries |
Tenant note: api/tenants returns data as a single object (not array). All other list endpoints return data as an array.
Cache file shape (data/external/dribl-ids.json):
{
"tenant": "w8zdBWPmBX",
"leagues": {
"Girls' West 12B": {
"season": "nPmrj2rmow",
"competition": "Bjma0p6VdR",
"league": "lNba4aGomx",
"tenant": "w8zdBWPmBX"
}
}
}
Pattern:
export async function resolveLeagueIds(
page: Page,
leagueName: string,
competitionName: string,
seasonYear: string
): Promise<DriblLeagueIds> {
const cache = loadCache();
if (cache.leagues[leagueName]) return cache.leagues[leagueName]; // cache hit
const tenant = cache.tenant || (await resolveTenant(page));
const season = await resolveSeasonId(page, tenant, seasonYear);
const competition = await resolveCompetitionId(page, tenant, competitionName);
const league = await resolveLeagueId(page, tenant, competition, leagueName);
const ids = { season, competition, league, tenant };
cache.tenant = tenant;
cache.leagues[leagueName] = ids;
saveCache(cache);
return ids;
}
Clubs Extraction
Reference: bin/commands/crawlClubs.ts
Clubs still use SPA response interception (one-off list, not per-round). Pattern unchanged.
Pattern:
const browser = await chromium.launch({ headless: false, channel: 'chrome' });
const context = await browser.newContext({
userAgent: '...',
viewport: { width: 1280, height: 720 }
});
const [clubsResponse] = await Promise.all([
page.waitForResponse((r) => r.url().startsWith(clubsApiUrl) && r.ok(), { timeout: 60_000 }),
page.goto(url, { waitUntil: 'domcontentloaded' })
]);
const rawData = await clubsResponse.json();
const validated = externalApiResponseSchema.parse(rawData);
writeFileSync(outputPath, JSON.stringify(validated, null, '\t') + '\n');
API endpoint: https://mc-api.dribl.com/api/list/clubs?disable_paging=true
Output: data/external/clubs/clubs.json
Fixtures Extraction
Reference: bin/commands/crawlFixtures.ts
Single browser session for all teams. One page.goto() at startup establishes Cloudflare clearance. Each team's rounds are fetched via page.evaluate(fetch(...)) — no SPA navigation per team.
Core helper:
async function browserFetch(page: Page, url: string): Promise<unknown> {
const raw = await page.evaluate(async (u: string) => {
const r = await fetch(u, { headers: { accept: 'application/json' } });
if (!r.ok) throw new Error(`HTTP ${r.status} fetching ${u}`);
return r.text();
}, url);
return JSON.parse(raw as string);
}
Round URL builder:
function buildApiUrl(endpoint: string, ids: LeagueIds, extra?: Record<string, string>): string {
const params = new URLSearchParams({
season: ids.season,
competition: ids.competition,
league: ids.league,
tenant: ids.tenant,
timezone: 'Australia/Melbourne',
...extra
});
return `https://mc-api.dribl.com/api/${endpoint}?${params.toString()}`;
}
Round loop pattern:
async function crawlTeamByRounds(page, team, ids, outputDir) {
clearChunkFiles(outputDir); // wipe stale chunks first
let chunkIndex = 0;
let emptyStreak = 0;
const maxConsecutiveEmptyRounds = 2;
const maxRounds = 40;
for (let round = 1; round <= maxRounds; round++) {
const url = buildApiUrl('fixtures', ids, { round: String(round) });
const json = await browserFetch(page, url);
const validated = externalFixturesApiResponseSchema.parse(json);
if (validated.data.length === 0) {
emptyStreak++;
if (emptyStreak >= maxConsecutiveEmptyRounds) break;
continue;
}
emptyStreak = 0;
writeFileSync(
`${outputDir}/chunk-${chunkIndex}.json`,
JSON.stringify(validated, null, '\t') + '\n'
);
chunkIndex++;
}
}
Why clear chunks: the old crawl wrote to both fixtures/ and results/ dirs. New crawl writes everything to fixtures/ only. clearChunkFiles removes stale round data so sync doesn't merge old chunks.
Full crawlFixtures flow:
export async function crawlFixtures(teams) {
browser = await chromium.launch({ headless: false, channel: 'chrome' });
const page = await (await browser.newContext({ userAgent: '...' })).newPage();
// ONE goto — establishes Cloudflare clearance for all subsequent fetch() calls
await page.goto('https://fv.dribl.com/fixtures/', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
for (const team of teams) {
const ids = await resolveLeagueIds(page, team.league, team.competition, team.season);
clearChunkFiles(`data/external/results/${team.team}`); // clear old results dir
await crawlTeamByRounds(page, team.team, ids, `data/external/fixtures/${team.team}`);
await crawlTeamTable(page, team.team, ids, `data/external/table`);
}
}
API endpoints:
| Endpoint | Params | Response |
|---|---|---|
api/fixtures |
season, competition, league, round, tenant, timezone | { data: fixture[], links, meta } |
api/ladders |
season, competition, league, tenant, timezone | { data: ladderEntry[] } |
Output:
- Path:
data/external/fixtures/{team}/chunk-0.json,chunk-1.json, … (one file per non-empty round) - Format: same
externalFixturesApiResponseSchemashape
CLI args:
-t, --team <slug>— repeatable; filters Sanity teams to these slugs-l, --league <name>— manual mode only; requires exactly one-t-s, --season <year>(optional, default to current year)-c, --competition <name>— full competition name (e.g. "Junior Girls Sunday (U12 - U18)")
CLI usage examples:
# All Sanity teams
pnpm run crawl:fixtures
# Specific team
pnpm run crawl:fixtures -- -t under-12-girls
# Manual mode: single team with explicit league
pnpm run crawl:fixtures -- -t my-team -l "Girls' West 12B"
Table (Ladder) Extraction
Table crawling is embedded in crawlFixtures. After round crawl, calls api/ladders directly with the same IDs. No SPA navigation needed.
Pattern:
async function crawlTeamTable(page, team, ids, outputDir) {
const url = buildApiUrl('ladders', ids);
const json = await browserFetch(page, url);
const validated = externalTableApiResponseSchema.parse(json);
if (validated.data.length === 0) return; // no ladder (MiniRoos etc.)
writeFileSync(`${outputDir}/${team}.json`, JSON.stringify(validated, null, '\t') + '\n');
}
API endpoint: https://mc-api.dribl.com/api/ladders
Output: data/external/table/{team}.json (single file per team)
Fixtures Transformation
Reference: bin/commands/syncFixtures.ts
Reads data/external/fixtures/{team}/chunk-*.json and data/external/results/{team}/chunk-*.json (both optional). Results dir will typically be empty after new crawl — dedup handles any overlap.
Pattern:
// Read chunks, validate
const responses = await readExternalFixtureFiles(dir, (required = false));
// Transform + merge results-first (so completed scores win dedup)
const { fixtures, competition, season } = mergeFixtures([...resultResponses, ...fixtureResponses]);
// Deduplicate by round + homeTeamId + awayTeamId
const unique = deduplicateFixtures(fixtures);
// Sort by round then date, write
writeFileSync(
outputPath,
JSON.stringify({ competition, season, totalFixtures, totalRounds, fixtures: sorted }, null, '\t')
);
Transform service: src/lib/matches/fixtureTransformService.ts
Output: data/matches/{team}.json
Validation Schemas
Reference: src/types/matches.ts, src/types/table.ts
externalFixturesApiResponseSchema— API response{ data: fixture[], links?, meta? }externalTableApiResponseSchema— ladder response{ data: ladderEntry[] }fixtureSchema/fixtureDataSchema— internal transformed shape
Always validate at boundaries (API response → external schema, transform output → internal schema).
CI Integration
Reference: .github/workflows/crawl.yml
- name: Install Chrome
run: npx playwright install --with-deps chrome
- name: Crawl fixtures
run: npm run crawl:fixtures:ci
crawl:fixtures:ci=xvfb-run --auto-servernum tsx bin/wsc.ts crawl fixtures(Linux needs xvfb)- Single browser session crawls all Sanity teams in one run
dribl-ids.jsoncache is rebuilt each CI run (not committed)
Best Practices
Cloudflare clearance:
- One
page.goto()per browser session is enough — clearance persists for allfetch()calls - Wait 3s after goto before starting API calls
- Keep the same page alive; don't close and reopen between teams
Empty round handling:
- Stop after
maxConsecutiveEmptyRounds=2consecutive empty rounds - Some comps have scheduling gaps mid-season — single empty round is not the end
maxRounds=40is a hard safety cap
Chunk file management:
- Always
clearChunkFiles(outputDir)before writing new chunks for a team - Also clear the old
results/dir so stale chunks don't pollute sync - One chunk file per non-empty round (not per "Load more" page like old approach)
ID cache:
- Re-resolve any
leagueNamenot in cache — handles regrades automatically - Tenant ID is stable; only re-fetch if cache is empty
(Removed)prefix in league name = old/regraded league; skip it
Logging:
- Use pino logger with child loggers per module
- Log round count per team, ID resolution events, and per-team completion
Error handling:
- Per-team failures are caught and collected; session continues to the next team
- Single throw at the end signals partial failure to the caller
- Browser closes in
finallyblock regardless of failures
File operations:
mkdirSync(path, { recursive: true })before writing- Format JSON with tabs:
JSON.stringify(data, null, '\t') + '\n' - Absolute paths via
resolve(currentDir, '../../...')
Common Patterns
Single-browser session:
let browser: Browser | undefined;
const failures: string[] = [];
try {
browser = await chromium.launch({ headless: false, channel: 'chrome' });
const page = await (await browser.newContext({ userAgent: '...' })).newPage();
await page.goto(siteUrl, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
for (const team of teams) {
try {
// direct API calls via browserFetch(page, url)
} catch (error) {
failures.push(team.slug);
}
}
} finally {
if (browser) await browser.close();
}
if (failures.length > 0) throw new Error(`Failed: ${failures.join(', ')}`);
Reading chunks (sync side):
const files = await fs.readdir(dir);
const chunks = files
.filter((f) => f.match(/^chunk-\d+\.json$/))
.sort((a, b) => {
const numA = parseInt(a.match(/\d+/)?.[0] || '0', 10);
const numB = parseInt(b.match(/\d+/)?.[0] || '0', 10);
return numA - numB;
});
Deduplication:
const seen = new Set<string>();
const unique = items.filter((item) => {
const key = computeKey(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
});