2026-05-31 11:39:40 +07:00
|
|
|
|
// 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.
|
2026-02-15 14:14:21 +08:00
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs';
|
2026-04-13 14:54:23 +07:00
|
|
|
|
import { isAbsolute, join, dirname, resolve } from 'path';
|
2026-02-15 14:14:21 +08:00
|
|
|
|
import { fileURLToPath } from 'url';
|
|
|
|
|
|
|
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
|
|
|
|
const __dirname = dirname(__filename);
|
|
|
|
|
|
|
2026-05-20 23:31:58 +07:00
|
|
|
|
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';
|
2026-04-13 14:54:23 +07:00
|
|
|
|
const DIRECTORIES_SOURCE_PATH = process.env.DIRECTORIES_SOURCE_PATH;
|
2026-05-31 11:39:40 +07:00
|
|
|
|
const OUTPUT_DIR = join(__dirname, '..', 'src', 'data', '5chan-directories');
|
2026-02-15 14:14:21 +08:00
|
|
|
|
const TIMEOUT_MS = 5000;
|
2026-05-30 16:07:41 +07:00
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
const isJsonFile = (fileName) => typeof fileName === 'string' && fileName.endsWith('.json');
|
2026-05-20 23:31:58 +07:00
|
|
|
|
const isRecord = (value) => typeof value === 'object' && value !== null;
|
2026-02-15 18:27:12 +08:00
|
|
|
|
const getErrorMessage = (error) => (error instanceof Error ? error.message : String(error));
|
2026-05-31 11:39:40 +07:00
|
|
|
|
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}`;
|
|
|
|
|
|
};
|
2026-02-15 14:14:21 +08:00
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
const fetchWithTimeout = async (url, asJson) => {
|
2026-04-13 14:54:23 +07:00
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
|
|
|
|
try {
|
2026-05-20 23:31:58 +07:00
|
|
|
|
const response = await fetch(url, { signal: controller.signal });
|
2026-04-13 14:54:23 +07:00
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`HTTP ${response.status}`);
|
|
|
|
|
|
}
|
2026-05-31 11:39:40 +07:00
|
|
|
|
return asJson ? response.json() : response.text();
|
2026-04-13 14:54:23 +07:00
|
|
|
|
} finally {
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
// Load the { fileName -> verbatim text } map from a local mirror directory.
|
2026-05-20 23:31:58 +07:00
|
|
|
|
const loadFromLocalDirectory = (directoryPath) => {
|
2026-05-31 11:39:40 +07:00
|
|
|
|
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;
|
2026-05-20 23:31:58 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
// 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);
|
2026-05-20 23:31:58 +07:00
|
|
|
|
if (!Array.isArray(contents)) {
|
|
|
|
|
|
throw new Error('Invalid GitHub directory listing');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 15:06:47 +07:00
|
|
|
|
const fileNames = contents
|
2026-05-31 11:39:40 +07:00
|
|
|
|
.filter((entry) => isRecord(entry) && entry.type === 'file' && isJsonFile(entry.name))
|
|
|
|
|
|
.map((entry) => entry.name)
|
2026-05-30 15:06:47 +07:00
|
|
|
|
.sort();
|
2026-05-31 11:39:40 +07:00
|
|
|
|
|
|
|
|
|
|
const files = {};
|
|
|
|
|
|
await Promise.all(
|
2026-05-20 23:31:58 +07:00
|
|
|
|
fileNames.map(async (fileName) => {
|
2026-05-31 11:39:40 +07:00
|
|
|
|
files[fileName] = await fetchWithTimeout(`${GITHUB_RAW_BASE_URL}/${fileName}`, false);
|
2026-05-20 23:31:58 +07:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-05-31 11:39:40 +07:00
|
|
|
|
return files;
|
|
|
|
|
|
};
|
2026-05-20 23:31:58 +07:00
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
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();
|
2026-05-20 23:31:58 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-15 14:14:21 +08:00
|
|
|
|
const sync = async () => {
|
|
|
|
|
|
try {
|
2026-05-31 11:39:40 +07:00
|
|
|
|
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}`);
|
2026-02-15 18:27:12 +08:00
|
|
|
|
}
|
2026-02-15 14:14:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-15 14:14:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-15 14:14:21 +08:00
|
|
|
|
|
2026-05-31 11:39:40 +07:00
|
|
|
|
if (written === 0 && removed === 0) {
|
|
|
|
|
|
console.log(`✅ Vendored directories already up to date (${fileNames.length} files)`);
|
2026-02-15 14:14:21 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-31 11:39:40 +07:00
|
|
|
|
console.log(`✅ Mirrored directories (${fileNames.length} files, ${written} updated, ${removed} removed)`);
|
2026-02-15 14:14:21 +08:00
|
|
|
|
} catch (e) {
|
2026-05-31 11:39:40 +07:00
|
|
|
|
console.warn(`⚠️ Could not mirror directories from ${getSourceLabel()} (keeping existing files): ${getErrorMessage(e)}`);
|
2026-02-15 14:14:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
sync();
|