/** * S01 – Extrahiert Rohtext + strukturierte Phrasen aus allen Berichtsheft-.docx. * Ausgabe: extracted-all.txt, raw-phrases.json (+ Aggregat für spätere Schritte). */ import { readdirSync, readFileSync, writeFileSync, statSync } from "fs"; import { dirname, join, resolve, basename } from "path"; import { fileURLToPath } from "url"; import PizZip from "pizzip"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, "..", ".."); const YEAR_DIRS = [ join(ROOT, "1. Ausbildungsjahr"), join(ROOT, "2. Ausbildungsjahr"), ]; function listDocx(dir) { try { return readdirSync(dir) .filter((f) => f.toLowerCase().endsWith(".docx") && !f.startsWith("~$")) .map((f) => join(dir, f)) .filter((p) => statSync(p).isFile()); } catch { return []; } } function docxToLines(path) { const zip = new PizZip(readFileSync(path)); const file = zip.file("word/document.xml"); if (!file) return []; const xml = file.asText(); return xml .split(/<\/w:p>/) .map((p) => [...p.matchAll(/]*>([^<]*)<\/w:t>/g)] .map((m) => m[1]) .join("") .replace(/\s+/g, " ") .trim(), ) .filter(Boolean); } function dedupeConsecutive(lines) { /** Content is duplicated (two table columns) – keep first occurrence. */ const out = []; const seenInDoc = new Set(); for (const line of lines) { const key = line.toLowerCase(); if (seenInDoc.has(key)) continue; seenInDoc.add(key); out.push(line); } return out; } function sliceBetween(lines, startRe, endRe) { const start = lines.findIndex((l) => startRe.test(l)); if (start < 0) return []; const rest = lines.slice(start + 1); const end = rest.findIndex((l) => endRe.test(l)); return end < 0 ? rest : rest.slice(0, end); } function decodeEntities(s) { return s .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))); } function stripLeadingJunk(s) { return decodeEntities(s) .replace(/^[\d\s]+/, "") .replace(/^[\s\-•·]+/, "") .replace(/^\d{4,}/, "") .replace(/\s+/g, " ") .trim() .replace(/[,\s]+$/, "") .trim(); } function isHeaderNoise(s) { return /^(Abteilung|Ausbildungsnachweis|Für die Zeit|Name|Datum|Zweites|Erstes|Drittes|Betriebliche|Zuordnung|Thema der Woche|Berufsschule|Lfd\.?\s*Nr|Ausbilderin|Ausbilder)/i.test( s, ); } const JUNK_RE = /^(43180|Nico|Baumann|Ferien|Feiertag|Urlaub|Schultag|Krank|Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag|………………|…………|\.+|-+|_+|Additive,?$|\d{4,}|\d+$)/i; function isJunk(s) { if (!s || s.length < 3) return true; if (JUNK_RE.test(s)) return true; if (/^[.\-_\s…]+$/.test(s)) return true; if (/Unterschrift/i.test(s)) return true; if ( /Ausbildungsnachweis|Für die Zeit|Betriebliche Tätig|Zuordnung|Thema der Woche|Berufsschule|Lfd\.?\s*Nr/i.test( s, ) ) return true; if (/^\d{5,}/.test(s)) return true; if (/Zwischenprüfung\s+Freitag/i.test(s)) return true; if (!/[a-zäöüß]/i.test(s)) return true; return false; } function cleanPhrase(text) { const t = stripLeadingJunk(text); if (isJunk(t) || isHeaderNoise(t)) return null; if (t.length < 3 || t.length > 180) return null; return t; } function isFrameworkRef(s) { return ( /^Lfd\.?\s*Nr/i.test(s) || /^\d+(\.\d+)+$/.test(s) || /Ausbildungsrahmenplan/i.test(s) || /^Zuordnung der Tätigkeit/i.test(s) ); } function isPlausibleTheme(s) { if (!s || s.length < 3 || s.length > 55) return false; if (/[.]{2,}/.test(s)) return false; if (/\b(abgeholt|gefahren|gebucht|auffüllen|einsortieren|anbieten|festhalten)\b/i.test(s)) return false; if (/^(bei |zum |was |welche |wenn |mehr |immer )/i.test(s)) return false; return true; } function parseSections(lines) { const unique = dedupeConsecutive(lines); const betriebIdx = unique.findIndex((l) => /^Betriebliche Tätigkeiten/i.test(l)); let activityLines = []; if (betriebIdx > 0) { let start = 0; for (let i = 0; i < betriebIdx; i++) { if (/^Name\b/i.test(unique[i]) || /^Datum\b/i.test(unique[i])) start = i + 1; } activityLines = unique.slice(start, betriebIdx); } // Rahmenplan-Refs zwischen „Betriebliche Tätigkeiten“ und „Thema der Woche“ const frameworkRaw = sliceBetween( unique, /^Betriebliche Tätigkeiten/i, /^Thema der Woche/i, ); const themeAfter = sliceBetween( unique, /^Thema der Woche/i, /^(Zuordnung zum Lernziel|Berufsschule)/i, ); const schoolLines = sliceBetween( unique, /^Berufsschule/i, /^(Datum und Unterschrift|\.{3,}|…{2,})/i, ).filter((l) => !/^(Datum und Unterschrift|\.{3,}|…)/i.test(l)); return { activityLines, frameworkRaw, themeLines: themeAfter, schoolLines, }; } function addCount(map, text) { const t = cleanPhrase(text); if (!t) return; const key = t.toLowerCase(); const prev = map.get(key); if (prev) prev.count += 1; else map.set(key, { text: t, count: 1 }); } function toSortedList(map, { minLen = 4, maxLen = 180 } = {}) { return [...map.values()] .filter((x) => x.text.length >= minLen && x.text.length <= maxLen) .sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de")); } function structureHeft(path, lines) { const { activityLines, frameworkRaw, themeLines, schoolLines } = parseSections(lines); const activities = []; for (const a of activityLines) { const t = cleanPhrase(a); if (t) activities.push(t); } const frameworkRefs = []; for (const f of frameworkRaw) { const raw = decodeEntities(f).replace(/\s+/g, " ").trim(); if (!raw) continue; if (isFrameworkRef(raw) || /^Lfd/i.test(raw)) { frameworkRefs.push(raw); continue; } // kurze Stichworte vor „Thema der Woche“ oft Rahmenplan-/Themenreste – behalten zum Filtern const cleaned = stripLeadingJunk(raw); if ( cleaned && cleaned.length <= 40 && !isJunk(cleaned) && !/^[.\-…]+$/.test(cleaned) ) { frameworkRefs.push(cleaned); } } const weekThemes = []; for (const t of themeLines) { const cleaned = stripLeadingJunk(t); if (!cleaned || cleaned === "-") continue; if (/ferien/i.test(cleaned)) continue; if (!isPlausibleTheme(cleaned)) continue; if (isJunk(cleaned) || isHeaderNoise(cleaned)) continue; weekThemes.push(cleaned); } const schoolTopics = []; for (const s of schoolLines) { if (/ferien/i.test(s)) continue; const t = cleanPhrase(s); if (t) schoolTopics.push(t); } return { sourceFile: basename(path), activities, weekThemes, schoolTopics, frameworkRefs, }; } async function main() { const files = YEAR_DIRS.flatMap(listDocx).sort((a, b) => basename(a).localeCompare(basename(b), "de"), ); if (!files.length) { console.error("Keine .docx in 1./2. Ausbildungsjahr gefunden."); process.exit(1); } const activities = new Map(); const themes = new Map(); const school = new Map(); const dump = []; const rawPhrases = []; for (const path of files) { const lines = docxToLines(path); const name = basename(path); dump.push(`===== ${name} =====`, "", ...lines, "", ""); const structured = structureHeft(path, lines); rawPhrases.push(structured); for (const a of structured.activities) addCount(activities, a); for (const t of structured.weekThemes) addCount(themes, t); for (const s of structured.schoolTopics) addCount(school, s); } const actList = toSortedList(activities, { minLen: 6, maxLen: 160 }).filter( (a) => !/^\d/.test(a.text) && !/Lfd/i.test(a.text) && /[a-zäöüß]{3,}/i.test(a.text), ); const themeList = toSortedList(themes, { minLen: 3, maxLen: 80 }).filter( (t) => !/^[.\-]+$/.test(t.text) && t.text !== "-", ); const schoolList = toSortedList(school, { minLen: 4, maxLen: 160 }).filter( (s) => !/^krank$/i.test(s.text) && !/stillarbeit$/i.test(s.text), ); const clean = { activities: actList, themes: themeList.map((t) => t.text), themesWeighted: themeList, school: schoolList.map((s) => s.text), schoolWeighted: schoolList, meta: { files: files.length, activities: actList.length, themes: themeList.length, school: schoolList.length, generatedAt: new Date().toISOString(), }, }; const rawOut = { meta: { files: files.length, hefte: rawPhrases.length, generatedAt: new Date().toISOString(), }, hefte: rawPhrases, }; writeFileSync(join(__dirname, "extracted-all.txt"), dump.join("\n"), "utf8"); writeFileSync(join(__dirname, "raw-phrases.json"), JSON.stringify(rawOut, null, 2), "utf8"); // Aggregat nur als Zwischenstand; saubere Form kommt aus S02 (clean-phrases.mjs) writeFileSync(join(__dirname, "parsed-phrases.json"), JSON.stringify(clean, null, 2), "utf8"); const withActs = rawPhrases.filter((h) => h.activities.length > 0).length; const emptyActs = rawPhrases.filter((h) => h.activities.length === 0); console.log( `Parsed ${files.length} docx → raw-phrases.json (${withActs} mit Activities, ${emptyActs.length} ohne)`, ); console.log( `Aggregat: ${actList.length} Tätigkeiten, ${themeList.length} Themen, ${schoolList.length} Schule`, ); if (emptyActs.length) { console.log( "Ohne Activities:", emptyActs.map((h) => h.sourceFile).join(" · "), ); } } main().catch((err) => { console.error(err); process.exit(1); });