// Best-effort mirror of the 5chan directories folder from GitHub. // Keeps src/data/5chan-directories/ a byte-for-byte copy of // https://github.com/bitsocialnet/lists/tree/master/5chan-directories so the app has an // offline fallback (loaded via src/data/vendored-directory-lists.ts) when GitHub is down. // Never fails the build: if the fetch fails (offline, rate-limited, etc.), existing files are kept. import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'; import { isAbsolute, join, dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const GITHUB_CONTENTS_URL = 'https://api.github.com/repos/bitsocialnet/lists/contents/5chan-directories?ref=master'; const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories'; const DIRECTORIES_SOURCE_PATH = process.env.DIRECTORIES_SOURCE_PATH; const OUTPUT_DIR = join(__dirname, '..', 'src', 'data', '5chan-directories'); const TIMEOUT_MS = 5000; const isJsonFile = (fileName) => typeof fileName === 'string' && fileName.endsWith('.json'); const isRecord = (value) => typeof value === 'object' && value !== null; const getErrorMessage = (error) => (error instanceof Error ? error.message : String(error)); const getSourceLabel = () => { if (!DIRECTORIES_SOURCE_PATH) { return `GitHub folder: ${GITHUB_CONTENTS_URL}`; } const resolvedSourcePath = isAbsolute(DIRECTORIES_SOURCE_PATH) ? DIRECTORIES_SOURCE_PATH : resolve(process.cwd(), DIRECTORIES_SOURCE_PATH); return `local directory: ${resolvedSourcePath}`; }; const fetchWithTimeout = async (url, asJson) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return asJson ? response.json() : response.text(); } finally { clearTimeout(timeout); } }; // Load the { fileName -> verbatim text } map from a local mirror directory. const loadFromLocalDirectory = (directoryPath) => { console.log(`ℹ️ Mirroring directories from local directory: ${directoryPath}`); const files = {}; for (const fileName of readdirSync(directoryPath).filter(isJsonFile)) { files[fileName] = readFileSync(join(directoryPath, fileName), 'utf8'); } return files; }; // Load the { fileName -> verbatim text } map from the GitHub folder. const loadFromGitHub = async () => { console.log(`ℹ️ Mirroring directories from GitHub folder: ${GITHUB_CONTENTS_URL}`); const contents = await fetchWithTimeout(GITHUB_CONTENTS_URL, true); if (!Array.isArray(contents)) { throw new Error('Invalid GitHub directory listing'); } const fileNames = contents .filter((entry) => isRecord(entry) && entry.type === 'file' && isJsonFile(entry.name)) .map((entry) => entry.name) .sort(); const files = {}; await Promise.all( fileNames.map(async (fileName) => { files[fileName] = await fetchWithTimeout(`${GITHUB_RAW_BASE_URL}/${fileName}`, false); }), ); return files; }; const loadSourceFiles = async () => { if (DIRECTORIES_SOURCE_PATH) { const resolvedSourcePath = isAbsolute(DIRECTORIES_SOURCE_PATH) ? DIRECTORIES_SOURCE_PATH : resolve(process.cwd(), DIRECTORIES_SOURCE_PATH); if (!existsSync(resolvedSourcePath) || !statSync(resolvedSourcePath).isDirectory()) { throw new Error(`Local directories source folder not found: ${resolvedSourcePath}`); } return loadFromLocalDirectory(resolvedSourcePath); } return loadFromGitHub(); }; const sync = async () => { try { const files = await loadSourceFiles(); const fileNames = Object.keys(files); if (fileNames.length === 0) { throw new Error('No directory files found in source'); } // Validate every file parses as JSON before touching disk, so a transient HTML error page // (or a truncated download) can never overwrite a good vendored mirror. for (const [fileName, text] of Object.entries(files)) { try { JSON.parse(text); } catch { throw new Error(`Invalid JSON for ${fileName}`); } } mkdirSync(OUTPUT_DIR, { recursive: true }); let written = 0; for (const [fileName, text] of Object.entries(files)) { const outputPath = join(OUTPUT_DIR, fileName); // Write verbatim; the upstream files are the source of truth, mirror them byte-for-byte. const existing = existsSync(outputPath) ? readFileSync(outputPath, 'utf8') : null; if (existing !== text) { writeFileSync(outputPath, text, 'utf8'); written += 1; } } // Prune local json files that no longer exist upstream so the mirror stays exact. const sourceNames = new Set(fileNames); let removed = 0; for (const fileName of readdirSync(OUTPUT_DIR).filter(isJsonFile)) { if (!sourceNames.has(fileName)) { rmSync(join(OUTPUT_DIR, fileName)); removed += 1; } } if (written === 0 && removed === 0) { console.log(`✅ Vendored directories already up to date (${fileNames.length} files)`); return; } console.log(`✅ Mirrored directories (${fileNames.length} files, ${written} updated, ${removed} removed)`); } catch (e) { console.warn(`⚠️ Could not mirror directories from ${getSourceLabel()} (keeping existing files): ${getErrorMessage(e)}`); } }; sync();