#!/usr/bin/env node /** * Combined German company register, Unternehmensregister + Bundesanzeiger. * * The two DE sources are complementary: * - Unternehmensregister (de-unternehmensregister.js): fast, no browser, no CAPTCHA, a * *superset* listing, it aggregates UR-published (GJ 2022+) and Bundesanzeiger-published * (GJ ≤2021) reports, plus register metadata (EUID, HRB, court, legal form). Retrieving * the actual document figures is a separate, partly paid step. * - Bundesanzeiger (de-bundesanzeiger.js): its report pages render as free HTML that the * CAPTCHA+Claude extractor turns into real numbers, but only up to GJ 2021. * * THREE RETRIEVAL MODES (listing is always CAPTCHA-free): * latest (default), Unternehmensregister only, newest report per company. Fastest (~0.5 s). * all-ur , Unternehmensregister only, ALL reports (no CAPTCHA). ~0.5-0.7 s. * all , Unternehmensregister + Bundesanzeiger in parallel, ALL reports; adds * the free-extractable Bundesanzeiger URLs for historical figures. ~1.1 s. * * A CAPTCHA is only ever touched by `analyze` (on-demand figures), never by listing. * * Usage: * node de-combined.js search "Company Name" # mode: latest (default) * node de-combined.js search "Company Name" --all-ur # all Unternehmensregister reports * node de-combined.js search "Company Name" --all # + Bundesanzeiger (historical) * node de-combined.js analyze "Company Name" [--index N] [--year YYYY] * * Flags: --verbose/-v keep the raw encrypted payloads/URLs in the printed JSON. */ const ur = require('./de-unternehmensregister.js'); const ba = require('./de-bundesanzeiger.js'); const { spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const norm = (s) => (s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[.,]/g, '').trim(); /** Locate the browser skill's Python (has cloakbrowser). Override with UR_PYTHON. */ function findPython() { if (process.env.UR_PYTHON) return process.env.UR_PYTHON; const home = process.env.HOME || process.env.USERPROFILE || ''; const candidates = [ path.join(home, '.claude/skills/browser/.venv/bin/python'), path.join(home, '.claude/skills/browser/.venv/Scripts/python.exe'), ]; for (const c of candidates) { if (fs.existsSync(c)) return c; } return 'python3'; } /** * Fetch the checkbox-gated Unternehmensregister documents for the given years (one browser * session for all of them) and Claude-extract each into figures. Returns { year -> financial }. * Best-effort: on any failure the year simply isn't in the map (caller marks it gated). */ function fetchURDocs(companyName, years, index) { if (!years.length) return []; const script = path.join(__dirname, 'ur_fetch_document.py'); const py = findPython(); const res = spawnSync(py, [script, '--company', companyName, '--years', years.join(','), '--index', String(index || 0)], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 240000 }); if (res.status !== 0 || !res.stdout) { console.error('UR document fetch failed:', (res.stderr || res.error || '').toString().slice(0, 300)); return []; } try { return JSON.parse(res.stdout).results || []; } catch (e) { return []; } // [{year, ok, text}] } // ── Full-text persistence ──────────────────────────────────────────────────────────────────── // The document text scraped from either source (the full Jahresabschluss / Lagebericht) used to // be discarded after the figure extraction. It is now captured on `report`/`analyze` and, by // default, written to one .txt file per year so nothing has to be re-scraped later. /** Sanitize a string into a safe filename part. */ function sanitizeFilePart(s) { return String(s == null ? '' : s).replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'x'; } /** Resolve text-saving options (defaults: save ON, dir ./register-texts, no inline text). */ function resolveTextOpts(opts = {}) { return { save: opts.saveText !== false, // default ON, the point of the feature inline: !!opts.inlineText, // also embed the text in the JSON output dir: opts.textDir || process.env.REGISTER_TEXT_DIR || path.join(process.cwd(), 'register-texts'), }; } /** * Write a report's full text to /__GJ_.txt (with a short * provenance header) and return the absolute path. Throws on I/O error (caller records it). */ function saveReportText(meta, text, dir) { fs.mkdirSync(dir, { recursive: true }); const fname = [ sanitizeFilePart(meta.court || 'DE'), sanitizeFilePart(meta.register || meta.euid || meta.company_name), 'GJ' + sanitizeFilePart(meta.fiscal_year || 'x'), sanitizeFilePart(meta.source || 'de'), ].join('_') + '.txt'; const fpath = path.join(dir, fname); const header = [ `# ${meta.company_name || ''}`, `# Register: ${meta.register || ''} Court: ${meta.court || ''} EUID: ${meta.euid || ''}`, `# Geschäftsjahr: ${meta.fiscal_year || ''} Quelle: ${meta.source || ''}`, `# Bericht: ${meta.report_name || ''}`, `# Zeichen: ${text.length}`, '', '', ].join('\n'); fs.writeFileSync(fpath, header + text, 'utf8'); return fpath; } /** * Attach the captured full text to a per-year result row: always record `report_chars`; embed * `report_text` when inline is on; write the .txt file and record `report_text_file` when saving. * A no-op when there is no text (extraction failed / listed-only year). */ function applyText(row, text, source, company, T) { if (!text) return; row.report_chars = text.length; if (T.inline) row.report_text = text; if (T.save) { try { row.report_text_file = saveReportText({ company_name: company.name, register: company.register, court: company.court, euid: company.euid, fiscal_year: row.fiscal_year, report_name: row.report_name, source, }, text, T.dir); console.error('Saved report text →', row.report_text_file); } catch (e) { row.text_save_error = e.message; } } } /** Merge the UR + BA search results into one per-company, per-fiscal-year view. */ function mergeSearch(searchedName, urRes, baRes, urError, baError) { const companies = new Map(); const ensure = (name, meta = {}) => { const k = norm(name); if (!companies.has(k)) { companies.set(k, { name, location: null, euid: null, register: null, register_number: null, court: null, state: null, reports: new Map(), }); } const co = companies.get(k); for (const f of ['location', 'euid', 'register', 'register_number', 'court', 'state']) { if (meta[f] != null && co[f] == null) co[f] = meta[f]; } return co; }; const addReport = (co, { fiscal_year, name, date, source, ba_url, ur_payload }) => { const key = fiscal_year || `${source}:${date}:${name}`; if (!co.reports.has(key)) { co.reports.set(key, { fiscal_year: fiscal_year || null, report_name: name || null, date: date || null, sources: [], free_extract: false, // true => a free Bundesanzeiger figure extraction is available _ba_url: null, // internal: consumed by analyze, stripped from printed output _ur_payload: null, // internal }); } const r = co.reports.get(key); if (source && !r.sources.includes(source)) r.sources.push(source); if (ba_url && !r._ba_url) { r._ba_url = ba_url; r.free_extract = true; } if (ur_payload && !r._ur_payload) r._ur_payload = ur_payload; if (!r.report_name && name) r.report_name = name; if (!r.date && date) r.date = date; }; if (urRes && urRes.found) { for (const c of urRes.companies) { const co = ensure(c.name, c); for (const rep of c.reports) { addReport(co, { fiscal_year: rep.fiscal_year, name: rep.name, date: rep.date, source: rep.source, ur_payload: rep.source === 'unternehmensregister' ? rep.encrypted_payload : null, }); } } } if (baRes && baRes.found) { for (const c of baRes.companies) { const co = ensure(c.name); for (const rep of c.reports) { addReport(co, { fiscal_year: ur.fiscalYearFromTitle(rep.name), name: rep.name, date: rep.date ? rep.date.split('.').reverse().join('-') : null, // DD.MM.YYYY -> ISO source: 'bundesanzeiger', ba_url: rep.url, }); } } } // Relevance of a company name to the search term: exact > prefix > contains > token overlap. const nq = norm(searchedName); const qTokens = nq.split(' ').filter(Boolean); const relevance = (name) => { const n = norm(name); if (n === nq) return 100; if (n.startsWith(nq)) return 80; if (n.includes(nq)) return 60; const hits = qTokens.filter((t) => t.length > 2 && n.includes(t)).length; return qTokens.length ? Math.round((hits / qTokens.length) * 40) : 0; }; const out = []; for (const co of companies.values()) { const reports = [...co.reports.values()].sort((a, b) => { const fy = (parseInt(b.fiscal_year, 10) || 0) - (parseInt(a.fiscal_year, 10) || 0); if (fy !== 0) return fy; return String(b.date || '').localeCompare(String(a.date || '')); }); out.push({ name: co.name, location: co.location, euid: co.euid, register: co.register, register_number: co.register_number, court: co.court, state: co.state, _relevance: relevance(co.name), report_count: reports.length, newest_fiscal_year: reports.length ? reports[0].fiscal_year : null, reports, }); } // Best name match first; ties broken by how many reports the company has. out.sort((a, b) => (b._relevance - a._relevance) || (b.report_count - a.report_count)); return { country: 'de', searched_name: searchedName, found: out.length > 0, sources_queried: baRes || baError ? ['unternehmensregister', 'bundesanzeiger'] : ['unternehmensregister'], source_errors: { unternehmensregister: urError || null, bundesanzeiger: baError || null }, companies_count: out.length, companies: out, }; } /** * Fast combined listing. Never triggers a CAPTCHA. * opts.mode: 'latest' (default) | 'all-ur' | 'all' */ async function combinedSearch(companyName, opts = {}) { const mode = opts.mode || 'latest'; const runBA = mode === 'all'; const t0 = Date.now(); let urRes = null, baRes = null, urError = null, baError = null; const timing = {}; const urP = (async () => { const s = Date.now(); // 1 UR page by default (complete for a precise name); --deep paginates for fuzzy search. try { urRes = await ur.searchUR(companyName, { deep: !!opts.deep }); } catch (e) { urError = e.message; } timing.unternehmensregister_ms = Date.now() - s; })(); const baP = runBA ? (async () => { const s = Date.now(); try { baRes = await ba.searchCompanies(companyName); } catch (e) { baError = e.message; } timing.bundesanzeiger_ms = Date.now() - s; })() : Promise.resolve(); await Promise.all([urP, baP]); const merged = mergeSearch(companyName, urRes, baRes, urError, baError); // latest: keep only the newest report per company. if (mode === 'latest') { for (const co of merged.companies) { co.reports = co.reports.slice(0, 1); co.report_count = co.reports.length; } } merged.mode = mode; merged.timing_ms = { ...timing, total_ms: Date.now() - t0 }; return merged; } /** * On-demand figure extraction for one fiscal year of one company. Runs in 'all' mode so the * free Bundesanzeiger URLs are available; extracts via the free CAPTCHA+Claude path when a * Bundesanzeiger source exists (≤2021), else reports the UR-only (partly paid) limitation. */ async function combinedAnalyze(companyName, opts = {}) { // A requested year ≥2022 is post-DiRUG → definitely Unternehmensregister-only (gated), // so skip the Bundesanzeiger call entirely. Otherwise run 'all' to get the free BA URL. const gatedOnly = opts.year && parseInt(opts.year, 10) >= 2022; const listing = await combinedSearch(companyName, { mode: gatedOnly ? 'all-ur' : 'all', deep: opts.deep }); if (!listing.found) return { ...listing, error: 'Company not found in either register' }; const company = listing.companies[opts.index || 0]; if (!company) { return { country: 'de', found: true, error: `Index ${opts.index} out of range (${listing.companies_count} companies)` }; } let target = company.reports[0]; if (opts.year) target = company.reports.find((r) => r.fiscal_year === String(opts.year)) || null; if (!target) { return { country: 'de', found: true, company_name: company.name, error: `No report for fiscal year ${opts.year}. Available: ${company.reports.map((r) => r.fiscal_year).filter(Boolean).join(', ')}`, }; } const base = { country: 'de', company_name: company.name, euid: company.euid, register: company.register, court: company.court, fiscal_year: target.fiscal_year, report_name: target.report_name, report_date: target.date, sources: target.sources, }; const T = resolveTextOpts(opts); if (target._ba_url) { const text = await ba.fetchReportText(target._ba_url); const financial = text ? await ba.extractFinancialData(text) : null; const out = { ...base, extraction_source: 'bundesanzeiger', financial_data: financial ? { ...financial, currency: 'EUR' } : null }; applyText(out, text, 'bundesanzeiger', company, T); if (T.save) out.text_dir = T.dir; return out; } // Unternehmensregister-only year (post-DiRUG): free but behind a checkbox → browser extract. if (!opts.noUR) { const docs = fetchURDocs(company.name, [target.fiscal_year], opts.index || 0); const d = docs.find((x) => String(x.year) === target.fiscal_year); if (d && d.ok && d.text) { const financial = await ba.extractFinancialData(d.text); const out = { ...base, extraction_source: 'unternehmensregister', financial_data: financial ? { ...financial, currency: 'EUR' } : null }; applyText(out, d.text, 'unternehmensregister', company, T); if (T.save) out.text_dir = T.dir; return out; } return { ...base, extraction_source: 'unternehmensregister', financial_data: null, note: 'Unternehmensregister document fetch/extraction failed (browser or checkbox issue). Retry, or use --no-ur to skip.' }; } return { ...base, extraction_source: 'unternehmensregister', financial_data: null, note: 'UR-only year; browser extraction skipped (--no-ur).' }; } /** * One-shot multi-year company report: ONE search, then extract every free (Bundesanzeiger) * fiscal year in a single process, the efficient path for "analyse company X over all years". * Gated (Unternehmensregister-only, post-DiRUG) years are listed with a note, not re-searched. * Extraction is sequential (the Bundesanzeiger session is shared) and capped by --max-extract. */ async function combinedReport(companyName, opts = {}) { const listing = await combinedSearch(companyName, { mode: 'all', deep: opts.deep }); if (!listing.found) return { ...listing, error: 'Company not found in either register' }; const company = listing.companies[opts.index || 0]; if (!company) { return { country: 'de', found: true, error: `Index ${opts.index} out of range (${listing.companies_count} companies)` }; } let reports = company.reports; if (opts.years && opts.years.length) reports = reports.filter((r) => opts.years.includes(r.fiscal_year)); const maxExtract = opts.maxExtract != null ? opts.maxExtract : 5; const toExtract = reports.slice(0, maxExtract); // newest-first const extractSet = new Set(toExtract); // Route each target year: Bundesanzeiger URL => free HTTP path; else Unternehmensregister browser. const urTargets = opts.noUR ? [] : toExtract.filter((r) => !r._ba_url && r.fiscal_year); const T = resolveTextOpts(opts); // 1) Unternehmensregister documents, all target years in ONE browser session, then Claude-extract // them CONCURRENTLY (independent `claude` processes) instead of one-after-another. Keep the // full document text (urTexts) so it can be persisted below, not just the extracted figures. const urFigures = {}; // fiscal_year -> financial | null const urTexts = {}; // fiscal_year -> full document text if (urTargets.length) { const docs = fetchURDocs(company.name, urTargets.map((r) => r.fiscal_year), opts.index || 0); const byYear = {}; for (const d of docs) byYear[String(d.year)] = d; await Promise.all(urTargets.map(async (r) => { const d = byYear[r.fiscal_year]; if (d && d.ok && d.text) { urTexts[r.fiscal_year] = d.text; urFigures[r.fiscal_year] = await ba.extractFinancialDataAsync(d.text); } else { urFigures[r.fiscal_year] = null; } })); } // 2) Build per-year rows (Bundesanzeiger years extracted sequentially, the session is shared). const years = []; for (const r of reports) { const row = { fiscal_year: r.fiscal_year, report_name: r.report_name, date: r.date, sources: r.sources }; if (!extractSet.has(r)) { row.status = 'listed_only'; // beyond the --max-extract window row.financial_data = null; } else if (r._ba_url) { const text = await ba.fetchReportText(r._ba_url); const fin = text ? await ba.extractFinancialData(text) : null; row.status = fin ? 'extracted' : 'extraction_failed'; row.extraction_source = 'bundesanzeiger'; row.financial_data = fin ? { ...fin, currency: 'EUR' } : null; applyText(row, text, 'bundesanzeiger', company, T); } else if (r.fiscal_year in urFigures) { const fin = urFigures[r.fiscal_year]; row.status = fin ? 'extracted' : 'extraction_failed'; row.extraction_source = 'unternehmensregister'; row.financial_data = fin ? { ...fin, currency: 'EUR' } : null; applyText(row, urTexts[r.fiscal_year], 'unternehmensregister', company, T); } else { row.status = 'gated_skipped'; // --no-ur: UR-only year, browser extraction skipped row.financial_data = null; } years.push(row); } const extracted = years.filter((y) => y.status === 'extracted').length; const savedTexts = years.filter((y) => y.report_text_file).length; return { country: 'de', company_name: company.name, location: company.location, euid: company.euid, register: company.register, court: company.court, state: company.state, report_count: company.report_count, extracted_count: extracted, text_dir: T.save && savedTexts ? T.dir : undefined, saved_text_count: T.save ? savedTexts : undefined, note: reports.length > maxExtract ? `Extracted the newest ${maxExtract} of ${reports.length} years. Raise --max-extract for more.` : undefined, years, }; } /** Remove heavy/internal fields (encrypted blobs, long URLs) from the printed JSON. */ function cleanForPrint(result, verbose) { if (verbose || !result || !result.companies) return result; const clone = JSON.parse(JSON.stringify(result)); for (const co of clone.companies) { delete co.encrypted_id; delete co._relevance; for (const r of co.reports || []) { delete r._ba_url; delete r._ur_payload; delete r.source_raw; } } return clone; } // CLI async function main() { const args = process.argv.slice(2); const command = args[0]; const companyName = args[1]; const verbose = args.includes('--verbose') || args.includes('-v'); const deep = args.includes('--deep'); const noUR = args.includes('--no-ur'); const saveText = !args.includes('--no-save-text'); // full text is saved to files by default const inlineText = args.includes('--inline-text'); // also embed the text in the JSON output let mode = 'latest'; if (args.includes('--all')) mode = 'all'; else if (args.includes('--all-ur') || args.includes('--ur')) mode = 'all-ur'; const flagVal = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; }; const index = parseInt(flagVal('--index') || '0', 10); const year = flagVal('--year'); const maxExtractRaw = flagVal('--max-extract'); const maxExtract = maxExtractRaw != null ? parseInt(maxExtractRaw, 10) : undefined; const years = (flagVal('--years') || '').split(',').map((s) => s.trim()).filter(Boolean); const textDir = flagVal('--text-dir'); if (!command || !companyName) { console.log(`Usage: node de-combined.js search "Company Name" # latest report only (default, fastest ~0.4s) node de-combined.js search "Company Name" --all-ur # all Unternehmensregister reports (~0.5s) node de-combined.js search "Company Name" --all # + Bundesanzeiger, free-extract URLs (~0.9s) node de-combined.js report "Company Name" [--max-extract N] [--years 2021,2020] # ONE search + extract all free years' figures node de-combined.js analyze "Company Name" [--index N] [--year YYYY] # one year's figures Flags: --deep (paginate UR for fuzzy names) · --index N (pick company) · -v (raw payloads) Full text (report/analyze): the scraped Jahresabschluss/Lagebericht text is saved to one .txt per year by DEFAULT. --text-dir (default ./register-texts) · --no-save-text (figures only) · --inline-text (also embed the text in the JSON). Env: REGISTER_TEXT_DIR.`); process.exit(command ? 1 : 0); } let result; if (command === 'search') result = await combinedSearch(companyName, { mode, deep }); else if (command === 'analyze') result = await combinedAnalyze(companyName, { index, year, deep, noUR, saveText, inlineText, textDir }); else if (command === 'report') result = await combinedReport(companyName, { index, years, maxExtract, deep, noUR, saveText, inlineText, textDir }); else { console.error(`Unknown command: ${command}`); process.exit(1); } // Flush stdout, then exit hard: the HTTP keep-alive sockets otherwise idle ~5 s. process.stdout.write(JSON.stringify(cleanForPrint(result, verbose), null, 2) + '\n', () => process.exit(0)); } module.exports = { combinedSearch, combinedAnalyze, combinedReport, mergeSearch, cleanForPrint, saveReportText, resolveTextOpts, sanitizeFilePart }; if (require.main === module) { main().catch((e) => { console.error('Error:', e.message); process.exit(1); }); }