update
This commit is contained in:
56
backend/src/database/migrations/030_extend_dienstgrad.sql
Normal file
56
backend/src/database/migrations/030_extend_dienstgrad.sql
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
-- Extends the dienstgrad CHECK constraint to include:
|
||||||
|
-- Jugendfeuerwehrmann, Probefeuerwehrmann, Verwaltungsmeister family, Verwalter,
|
||||||
|
-- and Ehren- prefixed variants of all Dienstgrade.
|
||||||
|
|
||||||
|
ALTER TABLE mitglieder_profile
|
||||||
|
DROP CONSTRAINT IF EXISTS mitglieder_profile_dienstgrad_check;
|
||||||
|
|
||||||
|
ALTER TABLE mitglieder_profile
|
||||||
|
ADD CONSTRAINT mitglieder_profile_dienstgrad_check
|
||||||
|
CHECK (dienstgrad IS NULL OR dienstgrad IN (
|
||||||
|
-- Standard Dienstgrade
|
||||||
|
'Feuerwehranwärter',
|
||||||
|
'Jugendfeuerwehrmann',
|
||||||
|
'Probefeuerwehrmann',
|
||||||
|
'Feuerwehrmann',
|
||||||
|
'Feuerwehrfrau',
|
||||||
|
'Oberfeuerwehrmann',
|
||||||
|
'Oberfeuerwehrfrau',
|
||||||
|
'Hauptfeuerwehrmann',
|
||||||
|
'Hauptfeuerwehrfrau',
|
||||||
|
'Löschmeister',
|
||||||
|
'Oberlöschmeister',
|
||||||
|
'Hauptlöschmeister',
|
||||||
|
'Brandmeister',
|
||||||
|
'Oberbrandmeister',
|
||||||
|
'Hauptbrandmeister',
|
||||||
|
'Brandinspektor',
|
||||||
|
'Oberbrandinspektor',
|
||||||
|
'Brandoberinspektor',
|
||||||
|
'Brandamtmann',
|
||||||
|
'Verwaltungsmeister',
|
||||||
|
'Oberverwaltungsmeister',
|
||||||
|
'Hauptverwaltungsmeister',
|
||||||
|
'Verwalter',
|
||||||
|
-- Ehrendienstgrade
|
||||||
|
'Ehren-Feuerwehrmann',
|
||||||
|
'Ehren-Feuerwehrfrau',
|
||||||
|
'Ehren-Oberfeuerwehrmann',
|
||||||
|
'Ehren-Oberfeuerwehrfrau',
|
||||||
|
'Ehren-Hauptfeuerwehrmann',
|
||||||
|
'Ehren-Hauptfeuerwehrfrau',
|
||||||
|
'Ehren-Löschmeister',
|
||||||
|
'Ehren-Oberlöschmeister',
|
||||||
|
'Ehren-Hauptlöschmeister',
|
||||||
|
'Ehren-Brandmeister',
|
||||||
|
'Ehren-Oberbrandmeister',
|
||||||
|
'Ehren-Hauptbrandmeister',
|
||||||
|
'Ehren-Brandinspektor',
|
||||||
|
'Ehren-Oberbrandinspektor',
|
||||||
|
'Ehren-Brandoberinspektor',
|
||||||
|
'Ehren-Brandamtmann',
|
||||||
|
'Ehren-Verwaltungsmeister',
|
||||||
|
'Ehren-Oberverwaltungsmeister',
|
||||||
|
'Ehren-Hauptverwaltungsmeister',
|
||||||
|
'Ehren-Verwalter'
|
||||||
|
));
|
||||||
@@ -10,9 +10,10 @@ function log(msg: string) {
|
|||||||
* Returns null if no match found — the field will be left unchanged.
|
* Returns null if no match found — the field will be left unchanged.
|
||||||
*/
|
*/
|
||||||
function mapDienstgrad(raw: string): string | null {
|
function mapDienstgrad(raw: string): string | null {
|
||||||
const map: Record<string, string> = {
|
const abbrevMap: Record<string, string> = {
|
||||||
// Abbreviations
|
|
||||||
'fa': 'Feuerwehranwärter',
|
'fa': 'Feuerwehranwärter',
|
||||||
|
'jfm': 'Jugendfeuerwehrmann',
|
||||||
|
'pfm': 'Probefeuerwehrmann',
|
||||||
'fm': 'Feuerwehrmann',
|
'fm': 'Feuerwehrmann',
|
||||||
'ff': 'Feuerwehrfrau',
|
'ff': 'Feuerwehrfrau',
|
||||||
'ofm': 'Oberfeuerwehrmann',
|
'ofm': 'Oberfeuerwehrmann',
|
||||||
@@ -29,26 +30,33 @@ function mapDienstgrad(raw: string): string | null {
|
|||||||
'obi': 'Oberbrandinspektor',
|
'obi': 'Oberbrandinspektor',
|
||||||
'boi': 'Brandoberinspektor',
|
'boi': 'Brandoberinspektor',
|
||||||
'bam': 'Brandamtmann',
|
'bam': 'Brandamtmann',
|
||||||
// Full names (pass-through if already matching)
|
'vm': 'Verwaltungsmeister',
|
||||||
'feuerwehranwärter': 'Feuerwehranwärter',
|
'ovm': 'Oberverwaltungsmeister',
|
||||||
'feuerwehrmann': 'Feuerwehrmann',
|
'hvm': 'Hauptverwaltungsmeister',
|
||||||
'feuerwehrfrau': 'Feuerwehrfrau',
|
'v': 'Verwalter',
|
||||||
'oberfeuerwehrmann': 'Oberfeuerwehrmann',
|
|
||||||
'oberfeuerwehrfrau': 'Oberfeuerwehrfrau',
|
|
||||||
'hauptfeuerwehrmann': 'Hauptfeuerwehrmann',
|
|
||||||
'hauptfeuerwehrfrau': 'Hauptfeuerwehrfrau',
|
|
||||||
'löschmeister': 'Löschmeister',
|
|
||||||
'oberlöschmeister': 'Oberlöschmeister',
|
|
||||||
'hauptlöschmeister': 'Hauptlöschmeister',
|
|
||||||
'brandmeister': 'Brandmeister',
|
|
||||||
'oberbrandmeister': 'Oberbrandmeister',
|
|
||||||
'hauptbrandmeister': 'Hauptbrandmeister',
|
|
||||||
'brandinspektor': 'Brandinspektor',
|
|
||||||
'oberbrandinspektor': 'Oberbrandinspektor',
|
|
||||||
'brandoberinspektor': 'Brandoberinspektor',
|
|
||||||
'brandamtmann': 'Brandamtmann',
|
|
||||||
};
|
};
|
||||||
return map[raw.trim().toLowerCase()] ?? null;
|
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Direct abbreviation match
|
||||||
|
if (abbrevMap[normalized]) return abbrevMap[normalized];
|
||||||
|
|
||||||
|
// Ehrendienstgrad: starts with 'e', rest maps to a known abbreviation
|
||||||
|
// e.g. EOLM → Ehren-Oberlöschmeister, EVM → Ehren-Verwaltungsmeister
|
||||||
|
if (normalized.startsWith('e') && normalized.length > 1) {
|
||||||
|
const base = abbrevMap[normalized.slice(1)];
|
||||||
|
if (base) return `Ehren-${base}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full name pass-through (case-insensitive)
|
||||||
|
const allValues = Object.values(abbrevMap);
|
||||||
|
const match = allValues.find(v => v.toLowerCase() === normalized);
|
||||||
|
if (match) return match;
|
||||||
|
// Also match Ehren- full names
|
||||||
|
const ehrenMatch = allValues.find(v => `ehren-${v.toLowerCase()}` === normalized);
|
||||||
|
if (ehrenMatch) return `Ehren-${ehrenMatch}`;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncToDatabase(
|
export async function syncToDatabase(
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
|
|||||||
// Parse "Datensatz X-Y von Z" to check if more pages exist
|
// Parse "Datensatz X-Y von Z" to check if more pages exist
|
||||||
const pagMatch = pagination.match(/(\d+)-(\d+)\s+von\s+(\d+)/i);
|
const pagMatch = pagination.match(/(\d+)-(\d+)\s+von\s+(\d+)/i);
|
||||||
if (pagMatch) {
|
if (pagMatch) {
|
||||||
|
const from = parseInt(pagMatch[1], 10);
|
||||||
const to = parseInt(pagMatch[2], 10);
|
const to = parseInt(pagMatch[2], 10);
|
||||||
const total = parseInt(pagMatch[3], 10);
|
const total = parseInt(pagMatch[3], 10);
|
||||||
if (to >= total) {
|
if (to >= total) {
|
||||||
@@ -243,31 +244,43 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
log(`Loaded ${to} of ${total} — navigating to next page`);
|
log(`Loaded ${to} of ${total} — navigating to next page`);
|
||||||
} else {
|
|
||||||
// No pagination indicator found — assume single page
|
|
||||||
log('No pagination indicator found — assuming single page');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click the "next page" link in FdcLayListNav
|
// Calculate next page number to use as a fallback click target
|
||||||
// FDISK uses __doPostBack links; find an <a> or <input> pointing to the next page
|
const pageSize = to - from + 1;
|
||||||
const nextClicked = await frame.evaluate(() => {
|
const nextPageNum = Math.floor(to / pageSize) + 1;
|
||||||
|
|
||||||
|
// Click the "next page" link in FdcLayListNav.
|
||||||
|
// Strategy 1: text ">" or ">>" (common in FDISK)
|
||||||
|
// Strategy 2: title/alt containing navigation words
|
||||||
|
// Strategy 3: link text is the next page number (e.g. "2" when on page 1)
|
||||||
|
const nextClicked = await frame.evaluate((nextPg: number) => {
|
||||||
const nav = document.querySelector('table.FdcLayListNav');
|
const nav = document.querySelector('table.FdcLayListNav');
|
||||||
if (!nav) return false;
|
if (!nav) return false;
|
||||||
const links = Array.from(nav.querySelectorAll('a, input[type="button"], input[type="submit"]'));
|
const links = Array.from(nav.querySelectorAll('a, input[type="button"], input[type="submit"]'));
|
||||||
// Look for next-page indicator: ">" alone, ">>" alone, or title/alt "weiter"/"next"
|
|
||||||
for (const el of links) {
|
for (const el of links) {
|
||||||
const text = ((el as HTMLElement).textContent ?? '').trim();
|
const text = ((el as HTMLElement).textContent ?? '').trim();
|
||||||
const title = ((el as HTMLElement).getAttribute('title') ?? '').toLowerCase();
|
const title = ((el as HTMLElement).getAttribute('title') ?? '').toLowerCase();
|
||||||
const alt = ((el as HTMLImageElement).alt ?? '').toLowerCase();
|
const alt = ((el as HTMLImageElement).alt ?? '').toLowerCase();
|
||||||
if (text === '>' || text === '>>' || title.includes('nächst') || title.includes('weiter') ||
|
if (text === '>' || text === '>>' ||
|
||||||
title.includes('next') || alt.includes('next') || alt.includes('weiter')) {
|
title.includes('nächst') || title.includes('weiter') || title.includes('next') || title.includes('vor') ||
|
||||||
|
alt.includes('next') || alt.includes('weiter') || alt.includes('vor')) {
|
||||||
(el as HTMLElement).click();
|
(el as HTMLElement).click();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: find a link whose text is exactly the next page number
|
||||||
|
for (const el of links) {
|
||||||
|
const text = ((el as HTMLElement).textContent ?? '').trim();
|
||||||
|
if (text === String(nextPg)) {
|
||||||
|
(el as HTMLElement).click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
}, nextPageNum);
|
||||||
|
|
||||||
if (!nextClicked) {
|
if (!nextClicked) {
|
||||||
// Dump nav HTML to help diagnose the missing next-page link
|
// Dump nav HTML to help diagnose the missing next-page link
|
||||||
@@ -275,14 +288,19 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
|
|||||||
const nav = document.querySelector('table.FdcLayListNav');
|
const nav = document.querySelector('table.FdcLayListNav');
|
||||||
return nav?.innerHTML?.replace(/\s+/g, ' ').trim() ?? '(not found)';
|
return nav?.innerHTML?.replace(/\s+/g, ' ').trim() ?? '(not found)';
|
||||||
});
|
});
|
||||||
log(`WARN: could not find next-page link — stopping pagination`);
|
log(`WARN: could not find next-page link (tried ">" and page "${nextPageNum}") — stopping pagination`);
|
||||||
log(`FdcLayListNav HTML: ${navHtml}`);
|
log(`FdcLayListNav HTML: ${navHtml}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await frame.waitForLoadState('networkidle', { timeout: 30000 });
|
await frame.waitForLoadState('networkidle', { timeout: 30000 });
|
||||||
pageNum++;
|
pageNum++;
|
||||||
|
} else {
|
||||||
|
// No pagination indicator found — assume single page
|
||||||
|
log('No pagination indicator found — assuming single page');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
} // end while
|
||||||
|
|
||||||
log(`Parsed ${allRows.length} rows total across ${pageNum} page(s)`);
|
log(`Parsed ${allRows.length} rows total across ${pageNum} page(s)`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user