MyTime: Initialstand (scripts/scrape-bridge-days.mjs)
Docker Image / build (push) Canceled after 2m45s
Docker Image / build (push) Canceled after 2m45s
This commit is contained in:
1 parent
56f7b78520
commit
f7694ceb9c
1 file changed
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Yearly bridge-days refresh from ferien-feiertage.de.
|
||||
*
|
||||
* Fetches `/bundesland/<slug>/<year>/brueckentage/` for every German state
|
||||
* and writes the result to `src/data/bridge-days.generated.ts`, which the
|
||||
* Brückentage tab reads (offline-friendly, bundled at build time).
|
||||
*
|
||||
* Run once a year, e.g.:
|
||||
* npm run scrape:bridge
|
||||
* npm run scrape:bridge -- --years=2027 --states=HH,BY
|
||||
*
|
||||
* Please be polite: the script pauses between requests and sends an
|
||||
* identifying User-Agent. robots.txt currently allows crawling (/).
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { parseBridgeDaysTable } from "./parse-bridge-days.mjs";
|
||||
|
||||
const BASE = "https://ferien-feiertage.de";
|
||||
const USER_AGENT =
|
||||
"MyUrlaubZeit/1.0 (personal holiday planner; yearly refresh; contact via repo owner)";
|
||||
const OUT = new URL("../src/data/bridge-days.generated.ts", import.meta.url);
|
||||
const STORE = new URL("../src/data/bridge-days.json", import.meta.url);
|
||||
|
||||
const STATES = [
|
||||
["BW", "Baden-Württemberg"],
|
||||
["BY", "Bayern"],
|
||||
["BE", "Berlin"],
|
||||
["BB", "Brandenburg"],
|
||||
["HB", "Bremen"],
|
||||
["HH", "Hamburg"],
|
||||
["HE", "Hessen"],
|
||||
["MV", "Mecklenburg-Vorpommern"],
|
||||
["NI", "Niedersachsen"],
|
||||
["NW", "Nordrhein-Westfalen"],
|
||||
["RP", "Rheinland-Pfalz"],
|
||||
["SL", "Saarland"],
|
||||
["SN", "Sachsen"],
|
||||
["ST", "Sachsen-Anhalt"],
|
||||
["SH", "Schleswig-Holstein"],
|
||||
["TH", "Thüringen"],
|
||||
];
|
||||
|
||||
export function slugOf(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { years: null, states: null, delay: 600 };
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--years=")) {
|
||||
args.years = arg.slice("--years=".length).split(",").map(Number);
|
||||
} else if (arg.startsWith("--states=")) {
|
||||
args.states = arg.slice("--states=".length).split(",").map((s) => s.toUpperCase());
|
||||
} else if (arg.startsWith("--delay=")) {
|
||||
args.delay = Number(arg.slice("--delay=".length));
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function fetchTable(slug, year) {
|
||||
const url = `${BASE}/bundesland/${slug}/${year}/brueckentage/`;
|
||||
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
return parseBridgeDaysTable(await response.text(), year);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const now = new Date().getFullYear();
|
||||
const years = args.years ?? [now, now + 1];
|
||||
const states = args.states
|
||||
? STATES.filter(([code]) => args.states.includes(code))
|
||||
: STATES;
|
||||
if (states.length === 0) {
|
||||
throw new Error("No matching states");
|
||||
}
|
||||
const data = {};
|
||||
try {
|
||||
Object.assign(data, JSON.parse(readFileSync(STORE, "utf-8")));
|
||||
console.log(`merged ${Object.keys(data).length} existing keys`);
|
||||
} catch {
|
||||
console.log("no existing store, starting fresh");
|
||||
}
|
||||
const missing = [];
|
||||
for (const [code, name] of states) {
|
||||
const slug = slugOf(name);
|
||||
for (const year of years) {
|
||||
const key = `${code}-${year}`;
|
||||
try {
|
||||
data[key] = await fetchTable(slug, year);
|
||||
console.log(`ok ${key}: ${data[key].length} entries`);
|
||||
} catch (error) {
|
||||
missing.push(`${key} (${error.message})`);
|
||||
console.error(`fail ${key}: ${error.message}`);
|
||||
}
|
||||
await sleep(args.delay);
|
||||
}
|
||||
}
|
||||
const fetchedAt = new Date().toISOString();
|
||||
writeFileSync(STORE, JSON.stringify(data, null, 2) + "\n");
|
||||
const storedYears = [...new Set(Object.keys(data).map((key) => Number(key.slice(-4))))].sort();
|
||||
const storedStates = [...new Set(Object.keys(data).map((key) => key.slice(0, -5)))].sort();
|
||||
const payload =
|
||||
`// AUTO-GENERATED by scripts/scrape-bridge-days.mjs — do not edit by hand.\n` +
|
||||
`// Source: ${BASE} (robots.txt allows crawling). Refresh yearly via \`npm run scrape:bridge\`.\n` +
|
||||
`export interface ScrapedBridgeDay {\n` +
|
||||
` start: string;\n` +
|
||||
` end: string;\n` +
|
||||
` occasion: string;\n` +
|
||||
` vacationDays: number;\n` +
|
||||
` freeDays: number;\n` +
|
||||
` freeStart: string;\n` +
|
||||
` freeEnd: string;\n` +
|
||||
`}\n\n` +
|
||||
`export const SCRAPED_BRIDGE_META = ${JSON.stringify({ source: BASE, fetchedAt, years: storedYears, states: storedStates }, null, 2)} as const;\n\n` +
|
||||
`export const SCRAPED_BRIDGE_DAYS: Record<string, ScrapedBridgeDay[]> = ${JSON.stringify(data, null, 2)};\n`;
|
||||
writeFileSync(OUT, payload);
|
||||
console.log(`wrote ${OUT.pathname} (${Object.keys(data).length} keys)`);
|
||||
if (missing.length > 0) {
|
||||
console.error(`missing: ${missing.join("; ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await main();
|
||||
}
|
||||
Reference in new issue
Block a user