feat(livia): Runde 10 Prompt 2 — eine Leadliste, dynamische Quellen, zeitliche Relevanz
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>
This commit is contained in:
1 parent
1c4b3f7dd8
commit
30daa77937
33 files changed
+2275
-301
No files matched your search
@@ -336,3 +336,105 @@ export async function fetchZefixResearch(): Promise<ResearchItem[]> {
|
||||
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,
|
||||
}]
|
||||
}
|
||||
Reference in new issue
Block a user