/** * Livia Research-PoC — die drei realen Quellen. * * Bewusst drei einzelne Funktionen statt einer Connector-Abstraktion: es sind * drei Quellen, und jede liest anders. Eine gemeinsame Schnittstelle würde * hier nur die Unterschiede verstecken, die man beim Debuggen braucht. * * Kein Browser, kein Playwright, kein Scheduler — nur `fetch()` und ein * HTML-Parser. Fällt eine Quelle aus, liefert sie einen Fehler und die anderen * beiden laufen weiter (§21). */ import * as cheerio from 'cheerio' import { RESEARCH_SOURCES } from '../../src/lib/researchSources.js' /** Gemeinsames Minimalformat aller Quellen (§9). */ export interface ResearchItem { source: string title: string url: string text: string publishedAt?: string } /** * Die Adressen kommen aus `src/lib/researchSources.ts` — derselben Liste, die * die Oberfläche unter «Angebundene Kanäle & Systeme» anzeigt. Damit kann der * angezeigte Link nicht von dem abweichen, was tatsächlich abgerufen wird. */ export const SOURCES = RESEARCH_SOURCES /** * Wie viele Übersichtsseiten je Newsquelle gelesen werden. * * Eine Seite allein reicht nicht: Die Handelskammer erneuert ihre obersten * zehn Meldungen im Lauf eines Tages, und wenn dort gerade Personalien und * Meinungsbeiträge stehen, findet Livia zu Recht nichts. Drei Seiten decken * bei der Handelskammer rund 40 und bei Greater Zurich rund 27 Meldungen ab — * damit hängt das Ergebnis nicht mehr am Zufall eines einzelnen Tages. */ const LIST_PAGES = 3 /** Höchstzahl Artikel je Newsquelle — hält Laufzeit und Tokenverbrauch im Rahmen (§22). */ const MAX_ARTICLES = 24 /** Gleichzeitige Artikelabrufe. Nacheinander wären 24 Abrufe je Quelle zu langsam. */ const FETCH_CONCURRENCY = 8 const FETCH_TIMEOUT_MS = 15_000 /** * Eigenes Zeitlimit für die Zefix-CSV. * * Die Datei ist 10,5 MB gross und wird unkomprimiert ausgeliefert — der Server * bietet kein gzip an. Von einem Schweizer Anschluss dauert der Abruf keine * zwei Sekunden, aus dem Rechenzentrum der Anwendung aber mehr als fünfzehn, * und genau daran ist die Quelle im ersten Produktionslauf gescheitert. */ const CSV_TIMEOUT_MS = 30_000 const UA = 'Mozilla/5.0 (compatible; PropertyMatch-LiviaResearch/1.0; +https://property-match-virid.vercel.app)' async function getText(url: string, timeoutMs: number = FETCH_TIMEOUT_MS): Promise { const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), timeoutMs) try { const res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept-Language': 'de-CH,de;q=0.9' }, redirect: 'follow', signal: ctrl.signal, }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return await res.text() } finally { clearTimeout(timer) } } /** * Container, in denen der Artikeltext bevorzugt gesucht wird — von eng nach weit. * * Die Reihenfolge ist der Unterschied zwischen brauchbarem und unbrauchbarem * Input: Nimmt man gleich alle `

` der Seite, stehen bei der Handelskammer * zuerst Telefonnummern, Öffnungszeiten und Spam-geschützte Mailadressen im * Text — und das Modell beurteilt dann die Fusszeile mit. */ const ARTICLE_CONTAINERS = ['.news-single', '.news-detail', 'article', 'main'] /** Zeilen, die erkennbar Kontaktangaben statt Inhalt sind. */ const BOILERPLATE = /(dont-like-spam|ich-will-kein-spam|^\+41|Montag\s*[–-]\s*Freitag)/i /** * Haupttext einer Artikelseite. * * Absichtlich grob: Skripte und Navigation raus, Absätze aus dem engsten * passenden Container einsammeln, kürzen. Für die Beurteilung genügen die * ersten Absätze — ein vollwertiger Lesemodus wäre für einen * Machbarkeitsnachweis Aufwand ohne zusätzliche Aussage. */ function extractArticleText(html: string): string { const $ = cheerio.load(html) $('script, style, nav, header, footer, noscript, form').remove() const sammle = (selector: string): string[] => { const parts: string[] = [] $(selector).each((_, el) => { const t = $(el).text().replace(/\s+/g, ' ').trim() if (t.length > 40 && !BOILERPLATE.test(t)) parts.push(t) }) return parts } for (const container of ARTICLE_CONTAINERS) { if ($(container).length === 0) continue const parts = sammle(`${container} p`) const text = parts.join('\n') if (text.length > 200) return text.slice(0, 2500) } return sammle('p').join('\n').slice(0, 2500) } /** * Der Linktext trägt bei der Handelskammer das Publikationsdatum vorneweg * («28. August 2026 Bystronic übernimmt …») und hinten ein »-Zeichen. Beides * gehört nicht in den Titel, den das Modell zu lesen bekommt. */ function cleanTitle(raw: string): string { return raw .replace(/^\d{1,2}\.\s*\p{L}+\s+\d{4}\s*/u, '') .replace(/\s*[»›>]\s*$/, '') .trim() } function absolute(href: string, base: string): string { try { return new URL(href, base).toString() } catch { return href } } /** Kartiert mit begrenzter Gleichzeitigkeit — schneller als nacheinander, ohne die Quelle zu fluten. */ async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { const out: R[] = new Array(items.length) let next = 0 const worker = async (): Promise => { for (;;) { const i = next++ if (i >= items.length) return out[i] = await fn(items[i]) } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) return out } interface Artikellink { url: string; title: string } /** * Artikel zu Einträgen machen — parallel, und ein einzelner Ausfall kippt den * Lauf nicht. Statt einer Ausnahme entsteht dann schlicht kein Eintrag. */ async function ladeArtikel(links: Artikellink[], quelle: string): Promise { const items = await mapLimit(links, FETCH_CONCURRENCY, async (l): Promise => { try { const html = await getText(l.url) const $a = cheerio.load(html) return { source: quelle, title: cleanTitle(l.title) || $a('h1').first().text().replace(/\s+/g, ' ').trim(), url: l.url, text: extractArticleText(html), publishedAt: $a('time').first().attr('datetime') || $a('meta[property="article:published_time"]').attr('content') || undefined, } } catch { return null } }) return items.filter((i): i is ResearchItem => i !== null && i.text.length > 120) } /** * Artikellinks aus mehreren Übersichtsseiten einsammeln. * * Die Listenseiten werden parallel geholt, das Ergebnis aber in Seitenreihenfolge * zusammengesetzt: Seite 1 trägt die neuesten Meldungen, und die sollen zuerst * kommen, wenn die Obergrenze greift. */ async function sammleLinks( seitenUrls: string[], basis: string, selektor: string, ausschluss?: RegExp, ): Promise { const seiten = await Promise.all( seitenUrls.map(async u => { try { return await getText(u) } catch { return '' } }), ) const links: Artikellink[] = [] const seen = new Set() for (const html of seiten) { if (!html) continue const $ = cheerio.load(html) $(selektor).each((_, el) => { const href = $(el).attr('href') if (!href) return const url = absolute(href, basis) if (seen.has(url)) return if (ausschluss?.test(url)) return seen.add(url) links.push({ url, title: $(el).text().replace(/\s+/g, ' ').trim() }) }) } return links.slice(0, MAX_ARTICLES) } /** Quelle A — Zürcher Handelskammer, Unternehmensmeldungen aus der Region (§4). */ export async function fetchZhkResearch(): Promise { // Seite 1 liegt unter der Basisadresse, die Folgeseiten unter `…/page/N.html`. const seiten = [ SOURCES.ZHK.url, ...Array.from({ length: LIST_PAGES - 1 }, (_, i) => `https://www.zhk.ch/de/wirtschaft-und-politik/news-liste/page/${i + 2}.html`), ] const links = await sammleLinks( seiten, SOURCES.ZHK.url, 'a[href*="/de/wirtschaft-und-politik/news/"]', ) return ladeArtikel(links, SOURCES.ZHK.name) } /** Quelle B — Greater Zurich Area, Ansiedlungen und Markteintritte (§5). */ export async function fetchGreaterZurichResearch(): Promise { // Paginierung über `?page=N`, beginnend bei 0. const seiten = Array.from({ length: LIST_PAGES }, (_, i) => i === 0 ? SOURCES.GZA.url : `${SOURCES.GZA.url}?page=${i}`) const links = await sammleLinks( seiten, SOURCES.GZA.url, 'a[href*="/de/news/"]', /\/de\/news\/?$/, ) return ladeArtikel(links, SOURCES.GZA.name) } /** * Quelle C — Zefix / Open Data Kanton Zürich, direkt als CSV (§6). * * **Wichtige Feststellung zur Datenlage:** Die Datei enthält keine Firmennamen. * Sie führt Tageszahlen von Neugründungen je NOGA-Branche für den Kanton * Zürich — Spalten `br_abschnitt_desc, br_abschnitt_code, wirtschaftssektor_desc, * wirtschaftssektor_code, location, date, value`. * * Daraus lässt sich deshalb kein Unternehmenslead bauen, sondern nur ein * aggregiertes Frühsignal zum Zielmarkt. Genau so wird es weitergegeben. * Firmennamen zu erfinden, um das erwartete Format zu treffen, wäre die eine * Sache, die Livia nie tun darf. */ export async function fetchZefixResearch(): Promise { const csv = await getText(SOURCES.ZEFIX.url, CSV_TIMEOUT_MS) const lines = csv.split(/\r?\n/) if (lines.length < 2) throw new Error('CSV enthält keine Daten') // Die Beschreibung kann Kommas enthalten — die sechs Pflichtfelder stehen // deshalb von hinten, der Rest davor ist die Branchenbezeichnung. interface Row { branche: string; datum: string; anzahl: number } const rows: Row[] = [] let neuestesDatum = '' for (let i = 1; i < lines.length; i++) { const line = lines[i] if (!line) continue const parts = line.split(',') if (parts.length < 7) continue const anzahl = Number(parts[parts.length - 1]) const datum = parts[parts.length - 2] const ort = parts[parts.length - 3] const branche = parts.slice(0, parts.length - 6).join(',').trim() if (ort !== 'ZH' || !Number.isFinite(anzahl)) continue if (datum > neuestesDatum) neuestesDatum = datum rows.push({ branche, datum, anzahl }) } if (!neuestesDatum) throw new Error('Keine Zürcher Datenzeilen gefunden') // Fenster: die letzten 30 Tage vor dem jüngsten Datum im Bestand. const bis = new Date(neuestesDatum) const von = new Date(bis.getTime() - 30 * 24 * 60 * 60 * 1000) const vonIso = von.toISOString().slice(0, 10) const jeBranche = new Map() let gesamt = 0 for (const r of rows) { if (r.datum < vonIso || r.datum > neuestesDatum) continue if (r.branche === 'Alle Branchen') continue if (r.anzahl <= 0) continue jeBranche.set(r.branche, (jeBranche.get(r.branche) ?? 0) + r.anzahl) gesamt += r.anzahl } const top = [...jeBranche.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8) if (top.length === 0) throw new Error('Keine Neugründungen im Auswertungsfenster') const text = [ `Amtliche Statistik des Kantons Zürich zu Handelsregister-Neugründungen.`, `Auswertungsfenster ${vonIso} bis ${neuestesDatum}: insgesamt ${gesamt} Neugründungen im Kanton Zürich.`, `Verteilung nach Branche:`, ...top.map(([b, n]) => `- ${b}: ${n}`), ``, `Hinweis zur Datenart: Der Datensatz enthält ausschliesslich Tageszahlen je Branche.`, `Firmennamen, Adressen und Gemeinden sind darin nicht enthalten.`, ].join('\n') return [{ source: SOURCES.ZEFIX.name, title: `Kanton Zürich: ${gesamt} Handelsregister-Neugründungen in 30 Tagen`, url: SOURCES.ZEFIX.url, text, publishedAt: neuestesDatum, }] }