Livia arbeitet nicht mehr neben dem Leadbestand, sondern in ihn hinein. - Systemzugänge tragen Titel, Beschreibung und Adresse; die Liste zeigt je Eintrag nur noch Aktiv/Inaktiv und Lesen/Schreiben — kein Berechtigungsbalken, kein Auge-Icon, keine Lese-/Schreibgruppen - Die Quellenregistry ist dynamisch: Der Lauf liest genau die aktiven Lesezugänge mit Adresse aus Livias Personalblatt. Für die drei gepflegten Quellen greift weiterhin ihr eigener Leser, für alles andere ein allgemeiner — eine Übersichtsseite, bis zu zehn Unterseiten, kein Spider. Freie Adressen werden auf http/https geprüft und gegen interne Netze gesperrt - Manuell abgelegte PDF- und Word-Dokumente fliessen in denselben Lauf. Text wird beim Ablegen nativ extrahiert (ZIP + Flate über DecompressionStream, ohne neue Abhängigkeit), Datei und Text liegen in IndexedDB - Neuer harter Filter «Zeitliche Relevanz»: das Modell beurteilt die Ereigniszeit der Veränderung, nicht das Publikationsdatum. «unknown» fällt bewusst nicht durch — ein erfundenes Datum wäre die schlechtere Antwort - Quellentypen und Beobachtungsraum sind Chips mit Freitext; jeder Wert lässt sich einzeln entfernen, auch die vorgegebenen - Info-Knopf bei «Zeitliche Relevanz» und «Mindestrelevanz» erklärt, wie der Wert entsteht — ohne erfundene Prozentgewichte - Gefundene Leads gehen in den zentralen Signalbestand statt in eine zweite Ergebnisliste; die Herkunft steht am Signal (origin: RESEARCH_RUN) - Der Lauf erzeugt einen echten Protokolleintrag mit den Zahlen des Laufs - Lead-Detail: zeitliche Einordnung mit Fundstelle, semantisches Suchprofil bei Chancen mit «Matching starten», und bei Risiko ausdrücklich «Kein internes Objekt eindeutig zugeordnet» statt einer beliebigen Mock-Immobilie - Aufgabenliste nach §11 bereinigt, «Unsichere Signale in Review Queue legen» von Nora übernommen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
441 lines
16 KiB
TypeScript
441 lines
16 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,
|
||
}]
|
||
}
|
||
|
||
// ── Frei angebundene Quellen (Runde 10, §2.3) ─────────────────────────────────
|
||
|
||
/**
|
||
* Adressbereiche, die eine serverlose Funktion niemals abrufen soll.
|
||
*
|
||
* Der Nutzer gibt die Adresse frei ein, und die Funktion ruft sie mit den
|
||
* Rechten des Rechenzentrums ab. Ohne diese Sperre wäre das Eingabefeld ein
|
||
* Weg, interne Dienste des Hosters anzusprechen. Geprüft wird der Hostname,
|
||
* weil eine serverlose Funktion keine Namensauflösung vorab machen kann —
|
||
* das deckt die versehentlichen Fälle und die offensichtlichen Versuche ab.
|
||
*/
|
||
const PRIVATE_HOSTS =
|
||
/^(localhost$|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?$|.*\.local$|.*\.internal$)/i
|
||
|
||
export interface QuellenAdresse {
|
||
id: string
|
||
name: string
|
||
url: string
|
||
}
|
||
|
||
/** Wirft mit einer Meldung, die so in der Oberfläche stehen darf. */
|
||
export function pruefeQuellenAdresse(url: string): URL {
|
||
let parsed: URL
|
||
try {
|
||
parsed = new URL(url)
|
||
} catch {
|
||
throw new Error('Keine gültige Adresse.')
|
||
}
|
||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||
throw new Error('Nur http- und https-Adressen können gelesen werden.')
|
||
}
|
||
if (PRIVATE_HOSTS.test(parsed.hostname)) {
|
||
throw new Error('Adressen im internen Netz werden nicht abgerufen.')
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
/** Höchstzahl Artikel je frei angebundener Quelle — bewusst kleiner als bei den gepflegten drei. */
|
||
const GENERIC_MAX_ARTICLES = 10
|
||
|
||
/**
|
||
* Eine beliebige Seite als Recherchequelle lesen.
|
||
*
|
||
* Bewusst kein Spider: Es wird genau eine Übersichtsseite geholt. Führt sie
|
||
* Links auf Unterseiten desselben Hosts, werden bis zu zehn davon gelesen —
|
||
* sonst zählt der Text der Seite selbst als einziger Eintrag. Das reicht für
|
||
* eine Newsseite und kostet nie mehr als elf Abrufe.
|
||
*
|
||
* Was hier ausdrücklich nicht passiert: keine zweite Ebene, keine Warteschlange,
|
||
* kein Zeitplan, kein Browser. Eine frei angebundene Quelle liefert damit
|
||
* weniger als die drei gepflegten — das ist der ehrliche Preis dafür, dass
|
||
* niemand für sie einen eigenen Leser geschrieben hat.
|
||
*/
|
||
export async function fetchGenericSource(quelle: QuellenAdresse): Promise<ResearchItem[]> {
|
||
const basis = pruefeQuellenAdresse(quelle.url)
|
||
const html = await getText(quelle.url)
|
||
|
||
const $ = cheerio.load(html)
|
||
$('script, style, nav, header, footer, noscript, form').remove()
|
||
|
||
// Kandidaten: Links auf denselben Host, mindestens zwei Pfadsegmente tief.
|
||
// Eine Startseite verlinkt so ihre Artikel; Impressum und Kontakt liegen
|
||
// typischerweise flacher und fallen dadurch von selbst heraus.
|
||
const links: Artikellink[] = []
|
||
const gesehen = new Set<string>()
|
||
$('a[href]').each((_, el) => {
|
||
const href = $(el).attr('href')
|
||
if (!href) return
|
||
let ziel: URL
|
||
try {
|
||
ziel = new URL(href, basis)
|
||
} catch {
|
||
return
|
||
}
|
||
if (ziel.hostname !== basis.hostname) return
|
||
if (ziel.pathname.split('/').filter(Boolean).length < 2) return
|
||
const url = `${ziel.origin}${ziel.pathname}`
|
||
if (url === `${basis.origin}${basis.pathname}` || gesehen.has(url)) return
|
||
const title = $(el).text().replace(/\s+/g, ' ').trim()
|
||
if (title.length < 12) return
|
||
gesehen.add(url)
|
||
links.push({ url, title })
|
||
})
|
||
|
||
if (links.length > 0) {
|
||
const items = await ladeArtikel(links.slice(0, GENERIC_MAX_ARTICLES), quelle.name)
|
||
if (items.length > 0) return items
|
||
}
|
||
|
||
// Kein brauchbarer Unterseiten-Fund: die Seite selbst ist der Eintrag.
|
||
const text = extractArticleText(html)
|
||
if (text.length < 120) {
|
||
throw new Error('Die Seite lieferte zu wenig lesbaren Text.')
|
||
}
|
||
return [{
|
||
source: quelle.name,
|
||
title: cleanTitle($('h1').first().text().replace(/\s+/g, ' ').trim()) || quelle.name,
|
||
url: quelle.url,
|
||
text,
|
||
}]
|
||
}
|