Die Zefix-CSV nennt keine Firmennamen, sondern Tageszahlen je Branche. Als Lead-Karte zwischen sechs echten Firmenmeldungen war sie ein Fremdkörper: Man kann eine Branchenzahl nicht anrufen, und genau das verspricht eine Lead-Karte mit «Empfohlene nächste Handlung». Aggregierte Einträge werden weiterhin gelesen und beurteilt — die Quelle bleibt angebunden und belegt, dass Livia auch strukturierte Daten verarbeitet —, zählen aber als Beobachtungsposten statt als Lead. Das entspricht der «Early Watch»-Einordnung der Vorgabe (§19), und die Zahlen der Kopfzeile gehen weiterhin auf. Die Quellenbeschreibung sagt jetzt selbst, warum dort keine Karte erscheint, statt dass sich das jemand aus dem Ausbleiben erschliessen muss. Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, Build erfolgreich, Live-Lauf 49 Einträge — Zefix erscheint nicht mehr als Lead, Summe aus verworfen, Watchlist und Leads ergibt die Gesamtzahl. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
/**
|
||
* 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
|
||
/**
|
||
* Aggregierte Statistik statt einer Meldung über ein einzelnes Unternehmen.
|
||
* Solche Einträge werden gelesen und beurteilt, erscheinen aber nie als Lead —
|
||
* eine Branchenzahl kann man nicht anrufen (§19: «Early Watch»).
|
||
*/
|
||
aggregate?: boolean
|
||
}
|
||
|
||
/**
|
||
* 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<string> {
|
||
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 `<p>` 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<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||
const out: R[] = new Array<R>(items.length)
|
||
let next = 0
|
||
const worker = async (): Promise<void> => {
|
||
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<ResearchItem[]> {
|
||
const items = await mapLimit(links, FETCH_CONCURRENCY, async (l): Promise<ResearchItem | null> => {
|
||
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<Artikellink[]> {
|
||
const seiten = await Promise.all(
|
||
seitenUrls.map(async u => {
|
||
try {
|
||
return await getText(u)
|
||
} catch {
|
||
return ''
|
||
}
|
||
}),
|
||
)
|
||
|
||
const links: Artikellink[] = []
|
||
const seen = new Set<string>()
|
||
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<ResearchItem[]> {
|
||
// 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<ResearchItem[]> {
|
||
// 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<ResearchItem[]> {
|
||
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<string, number>()
|
||
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,
|
||
aggregate: true,
|
||
}]
|
||
}
|