From b3266afbf8bedfcb2c48094ce9c7e05ed53e16dc Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 13 Mar 2026 21:27:07 +0100 Subject: [PATCH] update --- .../032_add_fdisk_profile_fields.sql | 2 +- .../migrations/036_widen_geschlecht.sql | 2 + backend/src/models/member.model.ts | 2 +- sync/src/scraper.ts | 95 ++++++++++--------- 4 files changed, 54 insertions(+), 47 deletions(-) create mode 100644 backend/src/database/migrations/036_widen_geschlecht.sql diff --git a/backend/src/database/migrations/032_add_fdisk_profile_fields.sql b/backend/src/database/migrations/032_add_fdisk_profile_fields.sql index 5871f28..09a81fd 100644 --- a/backend/src/database/migrations/032_add_fdisk_profile_fields.sql +++ b/backend/src/database/migrations/032_add_fdisk_profile_fields.sql @@ -1,6 +1,6 @@ -- Migration 032: Add FDISK-scraped profile fields to mitglieder_profile ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS geburtsort VARCHAR(128); -ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS geschlecht VARCHAR(1); +ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS geschlecht VARCHAR(32); ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS beruf VARCHAR(255); ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS wohnort VARCHAR(128); ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS plz VARCHAR(16); diff --git a/backend/src/database/migrations/036_widen_geschlecht.sql b/backend/src/database/migrations/036_widen_geschlecht.sql new file mode 100644 index 0000000..c8c0234 --- /dev/null +++ b/backend/src/database/migrations/036_widen_geschlecht.sql @@ -0,0 +1,2 @@ +-- Migration 036: Widen geschlecht column (was VARCHAR(1), stores full text like 'männlich') +ALTER TABLE mitglieder_profile ALTER COLUMN geschlecht TYPE VARCHAR(32); diff --git a/backend/src/models/member.model.ts b/backend/src/models/member.model.ts index 9c20ba3..714ab9f 100644 --- a/backend/src/models/member.model.ts +++ b/backend/src/models/member.model.ts @@ -87,7 +87,7 @@ export interface MitgliederProfile { // FDISK-synced extended profile fields geburtsort: string | null; - geschlecht: string | null; + geschlecht: string | null; // full text, e.g. 'männlich', 'weiblich' beruf: string | null; wohnort: string | null; plz: string | null; diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index 64b485c..ce35a19 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -92,20 +92,36 @@ export async function scrapeAll(username: string, password: string): Promise<{ member.wohnort = profileFields.wohnort; member.plz = profileFields.plz; + // Extract mitgliedschaft params from the current URL for constructing sub-section URLs. + // PersonenForm.aspx is in the personen module; sub-sections are in mitgliedschaften module. + // The links to Beförderungen/Untersuchungen/Fahrgenehmigungen live in the navigation + // frame (not the content mainFrame), so we construct the URLs directly. + const currentUrl = mainFrame.url(); + const urlObj = new URL(currentUrl); + const idMitgliedschaft = urlObj.searchParams.get('id_mitgliedschaften'); + const idInstanzen = urlObj.searchParams.get('id_instanzen') ?? ID_INSTANZEN; + const idFeuerwehren = urlObj.searchParams.get('id_feuerwehren') ?? ID_FEUERWEHREN; + // Ausbildungen const quals = await scrapeAusbildungenFromDetailPage(mainFrame, member); ausbildungen.push(...quals); // Beförderungen - const befos = await scrapeMemberBefoerderungen(mainFrame, member.standesbuchNr); + const befos = idMitgliedschaft + ? await scrapeMemberBefoerderungen(mainFrame, member.standesbuchNr, idMitgliedschaft, idInstanzen, idFeuerwehren) + : []; befoerderungen.push(...befos); // Untersuchungen - const unters = await scrapeMemberUntersuchungen(mainFrame, member.standesbuchNr); + const unters = idMitgliedschaft + ? await scrapeMemberUntersuchungen(mainFrame, member.standesbuchNr, idMitgliedschaft, idInstanzen, idFeuerwehren) + : []; untersuchungen.push(...unters); // Fahrgenehmigungen - const fahrg = await scrapeMemberFahrgenehmigungen(mainFrame, member.standesbuchNr); + const fahrg = idMitgliedschaft + ? await scrapeMemberFahrgenehmigungen(mainFrame, member.standesbuchNr, idMitgliedschaft, idInstanzen, idFeuerwehren) + : []; fahrgenehmigungen.push(...fahrg); log(` ${member.vorname} ${member.zuname}: ${quals.length} Ausbildungen, ${befos.length} Beförderungen, ${unters.length} Untersuchungen, ${fahrg.length} Fahrgenehmigungen`); @@ -602,21 +618,17 @@ async function scrapeAusbildungenFromDetailPage(frame: Frame, member: FdiskMembe /** * Navigate to the Beförderungen sub-page and scrape all promotions. - * Navigates back to the member detail page afterwards. + * URL is constructed from the mitgliedschaft ID extracted from PersonenForm URL. */ -async function scrapeMemberBefoerderungen(frame: Frame, standesbuchNr: string): Promise { - // Find sidebar link to Beförderungen - const link = frame.locator('a[href*="befoerderungenList.aspx"], a[href*="BefoerderungenList.aspx"]').first(); - const hasLink = await link.isVisible().catch(() => false); - if (!hasLink) { - log(` No Beförderungen link for StNr ${standesbuchNr}`); - return []; - } - - const href = await link.getAttribute('href'); - if (!href) return []; - - const url = href.startsWith('http') ? href : new URL(href, frame.url()).toString(); +async function scrapeMemberBefoerderungen( + frame: Frame, + standesbuchNr: string, + idMitgliedschaft: string, + idInstanzen: string, + idFeuerwehren: string, +): Promise { + const url = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/befoerderungenList.aspx` + + `?id_mitgliedschaften=${idMitgliedschaft}&id_instanzen=${idInstanzen}&id_feuerwehren=${idFeuerwehren}`; await frame_goto(frame, url); const results: FdiskBefoerderung[] = []; @@ -643,7 +655,7 @@ async function scrapeMemberBefoerderungen(frame: Frame, standesbuchNr: string): log(` ${b.datum ?? '—'} ${b.dienstgrad}`); } } catch { - log(` WARN: could not parse Beförderungen table for StNr ${standesbuchNr}`); + log(` WARN: could not parse Beförderungen table for StNr ${standesbuchNr} (url: ${url})`); } return results; @@ -653,18 +665,15 @@ async function scrapeMemberBefoerderungen(frame: Frame, standesbuchNr: string): * Navigate to the Untersuchungen sub-page and scrape all medical exams. * Keeps all rows (one per art+datum); DB stores all, queries filter latest per category. */ -async function scrapeMemberUntersuchungen(frame: Frame, standesbuchNr: string): Promise { - const link = frame.locator('a[href*="UntersuchungenList.aspx"]').first(); - const hasLink = await link.isVisible().catch(() => false); - if (!hasLink) { - log(` No Untersuchungen link for StNr ${standesbuchNr}`); - return []; - } - - const href = await link.getAttribute('href'); - if (!href) return []; - - const url = href.startsWith('http') ? href : new URL(href, frame.url()).toString(); +async function scrapeMemberUntersuchungen( + frame: Frame, + standesbuchNr: string, + idMitgliedschaft: string, + idInstanzen: string, + idFeuerwehren: string, +): Promise { + const url = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/UntersuchungenList.aspx` + + `?id_mitgliedschaften=${idMitgliedschaft}&id_instanzen=${idInstanzen}&id_feuerwehren=${idFeuerwehren}`; await frame_goto(frame, url); const results: FdiskUntersuchung[] = []; @@ -704,7 +713,7 @@ async function scrapeMemberUntersuchungen(frame: Frame, standesbuchNr: string): log(` ${u.datum ?? '—'} [${u.art}] ${u.ergebnis ?? '—'} | ${u.anmerkungen ?? ''}`); } } catch { - log(` WARN: could not parse Untersuchungen table for StNr ${standesbuchNr}`); + log(` WARN: could not parse Untersuchungen table for StNr ${standesbuchNr} (url: ${url})`); } return results; @@ -714,18 +723,15 @@ async function scrapeMemberUntersuchungen(frame: Frame, standesbuchNr: string): * Navigate to the Gesetzliche Fahrgenehmigungen sub-page and scrape all entries. * This is an inline-edit (ListEdit) page — values are in fields. */ -async function scrapeMemberFahrgenehmigungen(frame: Frame, standesbuchNr: string): Promise { - const link = frame.locator('a[href*="Ges_fahrgenehmigungenListEdit.aspx"], a[href*="ges_fahrgenehmigungenListEdit.aspx"]').first(); - const hasLink = await link.isVisible().catch(() => false); - if (!hasLink) { - log(` No Fahrgenehmigungen link for StNr ${standesbuchNr}`); - return []; - } - - const href = await link.getAttribute('href'); - if (!href) return []; - - const url = href.startsWith('http') ? href : new URL(href, frame.url()).toString(); +async function scrapeMemberFahrgenehmigungen( + frame: Frame, + standesbuchNr: string, + idMitgliedschaft: string, + idInstanzen: string, + idFeuerwehren: string, +): Promise { + const url = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/Ges_fahrgenehmigungenListEdit.aspx` + + `?id_mitgliedschaften=${idMitgliedschaft}&id_instanzen=${idInstanzen}&id_feuerwehren=${idFeuerwehren}`; await frame_goto(frame, url); const results: FdiskFahrgenehmigung[] = []; @@ -741,7 +747,6 @@ async function scrapeMemberFahrgenehmigungen(frame: Frame, standesbuchNr: string const cellVal = (i: number): string => { const cell = cells[i]; if (!cell) return ''; - // Prefer input value, then select text, then textContent const input = cell.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null; if (input) return input.value?.trim() ?? ''; const select = cell.querySelector('select') as HTMLSelectElement | null; @@ -781,7 +786,7 @@ async function scrapeMemberFahrgenehmigungen(frame: Frame, standesbuchNr: string log(` ${f.ausstellungsdatum ?? '—'} [${f.klasse}] ${f.behoerde ?? ''} ${f.nummer ?? ''}`); } } catch { - log(` WARN: could not parse Fahrgenehmigungen table for StNr ${standesbuchNr}`); + log(` WARN: could not parse Fahrgenehmigungen table for StNr ${standesbuchNr} (url: ${url})`); } return results;