Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2926d6bcb | ||
|
|
f0ef9c57d4 | ||
|
|
c19f071acc | ||
|
|
e986e9f274 | ||
|
|
a028d1a21d | ||
|
|
97b7110a44 | ||
|
|
31a098f219 | ||
|
|
f58d841e7d | ||
|
|
f53f3be1e8 | ||
|
|
93ecc852e3 | ||
|
|
2a0ba287f6 |
No files matched your search
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Livia Research-PoC — die Beurteilung der gelesenen Einträge.
|
||||||
|
*
|
||||||
|
* Ein einziger Aufruf für alle Einträge statt einer Anfrage je Artikel (§22).
|
||||||
|
* Das hält Laufzeit und Kosten klein und gibt dem Modell zugleich den
|
||||||
|
* Gesamtblick, den es für die Einordnung «relevant oder nicht» braucht.
|
||||||
|
*
|
||||||
|
* Der Schlüssel steht ausschliesslich serverseitig in `ANTHROPIC_API_KEY` —
|
||||||
|
* diese Datei läuft in der serverlosen Funktion und landet nie im Bundle (§10).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { ResearchItem } from './researchSources.js'
|
||||||
|
import type { ResearchLead } from '../../src/domain/researchLead.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Beurteilungsauftrag (§12).
|
||||||
|
*
|
||||||
|
* Bewusst kurz. Zwei Sätze tragen die eigentliche Arbeit: der Relevanzbegriff
|
||||||
|
* und das Verbot, etwas hinzuzuerfinden. Alles Weitere wäre Ausschmückung, die
|
||||||
|
* das Modell eher zu geschmeidigen als zu belegten Antworten verleitet.
|
||||||
|
*/
|
||||||
|
const SYSTEM_PROMPT = `Du bist Livia, Research & Market Intelligence Agent für professionelle Schweizer Gewerbeimmobilien-Bewirtschaftungen.
|
||||||
|
|
||||||
|
Analysiere die bereitgestellten Unternehmensmeldungen.
|
||||||
|
|
||||||
|
Relevant sind nur Entwicklungen, die plausibel zu zusätzlichem Gewerbeflächenbedarf, einem neuen Standort, einer Standortveränderung, Flächenreduktion oder zukünftiger Wiedervermietung führen könnten.
|
||||||
|
|
||||||
|
Allgemeine Unternehmensnachrichten ohne plausiblen Immobilienbezug sind IRRELEVANT.
|
||||||
|
|
||||||
|
Bewerte konservativ. Erfinde keine Informationen, die nicht in der Quelle stehen. Wenn eine Meldung keinen Firmennamen nennt, schreibe in "company" das, was die Quelle tatsächlich benennt — erfinde keinen Namen.
|
||||||
|
|
||||||
|
Antworte ausschliesslich mit einem JSON-Objekt der Form:
|
||||||
|
{"results":[{"index":0,"company":"...","eventType":"EXPANSION","headline":"...","location":"...","summary":"...","relevance":"high","confidence":0.8,"reason":"...","recommendedAction":"..."}]}
|
||||||
|
|
||||||
|
Gib für JEDEN Eingabeeintrag genau ein Ergebnis mit dem passenden "index" zurück.
|
||||||
|
|
||||||
|
eventType ist einer von: EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT, M_AND_A, RESTRUCTURING, SITE_CLOSURE, NEW_COMPANY, IRRELEVANT.
|
||||||
|
relevance ist "high", "medium" oder "low". confidence ist eine Zahl zwischen 0 und 1.
|
||||||
|
Deutsch, Schweizer Rechtschreibung (ss statt ß). Keine langen Analysen.`
|
||||||
|
|
||||||
|
const AnalysisSchema = z.object({
|
||||||
|
results: z.array(z.object({
|
||||||
|
index: z.number().int().min(0),
|
||||||
|
company: z.string().min(1),
|
||||||
|
eventType: z.enum([
|
||||||
|
'EXPANSION', 'NEW_LOCATION', 'HEADCOUNT_GROWTH', 'INVESTMENT',
|
||||||
|
'M_AND_A', 'RESTRUCTURING', 'SITE_CLOSURE', 'NEW_COMPANY', 'IRRELEVANT',
|
||||||
|
]),
|
||||||
|
headline: z.string().min(1),
|
||||||
|
location: z.string().optional(),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
relevance: z.enum(['high', 'medium', 'low']),
|
||||||
|
confidence: z.number().min(0).max(1),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
recommendedAction: z.string().optional(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface AnalysisOutcome {
|
||||||
|
leads: ResearchLead[]
|
||||||
|
/** Einträge ohne plausiblen Immobilienbezug. */
|
||||||
|
discarded: number
|
||||||
|
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
||||||
|
watchlist: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das erste JSON-Objekt aus der Antwort — falls das Modell doch etwas drumherum schreibt. */
|
||||||
|
function extractJson(text: string): unknown {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
const start = trimmed.indexOf('{')
|
||||||
|
const end = trimmed.lastIndexOf('}')
|
||||||
|
if (start === -1 || end <= start) throw new Error('Antwort enthält kein JSON-Objekt')
|
||||||
|
return JSON.parse(trimmed.slice(start, end + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Einträge ein Aufruf beurteilt.
|
||||||
|
*
|
||||||
|
* Der Wert ist gemessen, nicht geraten. Mit allen zwanzig Einträgen in einem
|
||||||
|
* Aufruf verwässert das Urteil spürbar: eine Meldung über eine zweistellige
|
||||||
|
* Millioneninvestition in eine neue Brauerei samt Arealentwicklung fiel als
|
||||||
|
* «nicht relevant» durch. Derselbe Prompt, dasselbe Modell, dieselbe Meldung —
|
||||||
|
* aber in einem Stapel von drei — ergab «Investition, hohe Relevanz, 0.85».
|
||||||
|
*
|
||||||
|
* Eine höhere Effort-Stufe half nicht, sie machte es sogar schlechter. Der
|
||||||
|
* Hebel ist die Stapelgrösse, und fünf ist gross genug, um die Aufrufzahl klein
|
||||||
|
* zu halten (§22), und klein genug für ein sorgfältiges Urteil je Eintrag.
|
||||||
|
*/
|
||||||
|
const BATCH_SIZE = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welches Modell die Einordnung vornimmt.
|
||||||
|
*
|
||||||
|
* Über `LIVIA_RESEARCH_MODEL` umstellbar, ohne den Code anzufassen — die
|
||||||
|
* Wahl ist eine Kosten- und Qualitätsabwägung und gehört damit dem Betreiber,
|
||||||
|
* nicht dem Quelltext.
|
||||||
|
*
|
||||||
|
* Grössenordnung je Lauf über rund fünfzig Meldungen:
|
||||||
|
* claude-opus-5 ≈ 0.40 USD stärkstes Urteil
|
||||||
|
* claude-sonnet-5 ≈ 0.20 USD
|
||||||
|
* claude-haiku-4-5 ≈ 0.08 USD für eine Ja/Nein-Einordnung meist ausreichend
|
||||||
|
*/
|
||||||
|
export const MODEL = process.env.LIVIA_RESEARCH_MODEL ?? 'claude-opus-5'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viel Artikeltext das Modell zu lesen bekommt.
|
||||||
|
*
|
||||||
|
* Ebenfalls umstellbar, weil es der zweite grosse Kostenhebel ist: Der
|
||||||
|
* Immobilienbezug einer Firmenmeldung steht fast immer im ersten Absatz, und
|
||||||
|
* jede Halbierung halbiert die Eingabekosten.
|
||||||
|
*/
|
||||||
|
const TEXT_LIMIT = Number(process.env.LIVIA_RESEARCH_TEXT_LIMIT ?? 2000)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Modelle den `effort`-Schalter kennen.
|
||||||
|
*
|
||||||
|
* Nicht alle tun das: Haiku 4.5 weist die Anfrage mit «This model does not
|
||||||
|
* support the effort parameter» ab. Da das Modell über eine Umgebungsvariable
|
||||||
|
* frei wählbar ist, darf der Schalter nicht bedingungslos mitgeschickt werden —
|
||||||
|
* sonst macht eine Kostenoptimierung die Funktion kaputt, und zwar erst zur
|
||||||
|
* Laufzeit.
|
||||||
|
*
|
||||||
|
* Bewusst als Erlaubnisliste: Ein unbekanntes Modell läuft dann ohne den
|
||||||
|
* Schalter. Das kostet etwas mehr, funktioniert aber — die umgekehrte
|
||||||
|
* Voreinstellung würde raten und scheitern.
|
||||||
|
*/
|
||||||
|
const EFFORT_MODELS = /^claude-(fable|mythos|opus|sonnet)-/
|
||||||
|
function supportsEffort(model: string): boolean {
|
||||||
|
return EFFORT_MODELS.test(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Teilstapel gleichzeitig beurteilt werden.
|
||||||
|
*
|
||||||
|
* Alles auf einmal loszuschicken wäre schneller, läuft aber in die Drosselung
|
||||||
|
* des Anbieters — und ein gedrosselter Lauf sieht für den Nutzer aus wie ein
|
||||||
|
* kaputter.
|
||||||
|
*/
|
||||||
|
const ANALYSIS_CONCURRENCY = 5
|
||||||
|
|
||||||
|
function chunk<T>(list: T[], size: number): T[][] {
|
||||||
|
const out: T[][] = []
|
||||||
|
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Beurteilungsaufruf für einen Teilstapel. */
|
||||||
|
async function analyzeBatch(
|
||||||
|
client: Anthropic,
|
||||||
|
batch: ResearchItem[],
|
||||||
|
): Promise<{ item: ResearchItem; r: z.infer<typeof AnalysisSchema>['results'][number] }[]> {
|
||||||
|
const eingabe = batch
|
||||||
|
.map((it, i) => [
|
||||||
|
`### Eintrag ${i}`,
|
||||||
|
`Quelle: ${it.source}`,
|
||||||
|
`Titel: ${it.title}`,
|
||||||
|
it.publishedAt ? `Publiziert: ${it.publishedAt}` : '',
|
||||||
|
`Text: ${it.text.slice(0, TEXT_LIMIT)}`,
|
||||||
|
].filter(Boolean).join('\n'))
|
||||||
|
.join('\n\n')
|
||||||
|
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: MODEL,
|
||||||
|
max_tokens: 8000,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
// Einordnen ist bei fünf Einträgen eine Routineaufgabe — die niedrige Stufe
|
||||||
|
// spart Zeit und Kosten. Nur die grossen Modelle kennen den Schalter; bei
|
||||||
|
// den kleineren wird die Anfrage sonst abgewiesen (siehe SUPPORTS_EFFORT).
|
||||||
|
...(supportsEffort(MODEL) ? { output_config: { effort: 'low' as const } } : {}),
|
||||||
|
messages: [{ role: 'user', content: eingabe }],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.stop_reason === 'refusal') {
|
||||||
|
throw new Error('Die Beurteilung wurde vom Modell abgelehnt.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = response.content
|
||||||
|
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
|
||||||
|
.map(b => b.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const parsed = AnalysisSchema.parse(extractJson(text))
|
||||||
|
return parsed.results
|
||||||
|
.filter(r => batch[r.index] !== undefined)
|
||||||
|
.map(r => ({ item: batch[r.index], r }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeResearchItems(items: ResearchItem[]): Promise<AnalysisOutcome> {
|
||||||
|
if (items.length === 0) return { leads: [], discarded: 0, watchlist: 0 }
|
||||||
|
|
||||||
|
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
'ANTHROPIC_API_KEY ist serverseitig nicht gesetzt — ohne Schlüssel findet keine Beurteilung statt.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Anthropic({ apiKey })
|
||||||
|
|
||||||
|
// Parallel, aber begrenzt: die Laufzeit ist damit nicht die Summe der
|
||||||
|
// Teilstapel, und gleichzeitig laufen nie so viele Anfragen, dass der
|
||||||
|
// Anbieter drosselt.
|
||||||
|
const teilstapel = chunk(items, BATCH_SIZE)
|
||||||
|
const ergebnisse: Awaited<ReturnType<typeof analyzeBatch>>[] = new Array(teilstapel.length)
|
||||||
|
const fehler: string[] = []
|
||||||
|
let next = 0
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++
|
||||||
|
if (i >= teilstapel.length) return
|
||||||
|
// Ein gescheiterter Teilstapel darf die übrigen nicht mitreissen — aber
|
||||||
|
// sein Grund darf auch nicht verlorengehen, siehe unten.
|
||||||
|
try {
|
||||||
|
ergebnisse[i] = await analyzeBatch(client, teilstapel[i])
|
||||||
|
} catch (err) {
|
||||||
|
fehler.push(err instanceof Error ? err.message : String(err))
|
||||||
|
ergebnisse[i] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(ANALYSIS_CONCURRENCY, teilstapel.length) }, worker),
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Scheitern alle Teilstapel, ist das kein leeres Ergebnis, sondern ein
|
||||||
|
* Ausfall — und der muss gesagt werden. Vorher wurde er verschluckt und die
|
||||||
|
* Oberfläche meldete «49 analysiert · 0 relevante Entwicklungen», was wie ein
|
||||||
|
* sauberer Lauf ohne Treffer aussieht. Aufgefallen ist das erst, als das
|
||||||
|
* Guthaben aufgebraucht war: der Fehler «credit balance is too low» erschien
|
||||||
|
* nirgends, die Zahlen sahen bloss unauffällig aus.
|
||||||
|
*/
|
||||||
|
if (fehler.length === teilstapel.length) {
|
||||||
|
throw new Error(fehler[0])
|
||||||
|
}
|
||||||
|
const batches = ergebnisse
|
||||||
|
|
||||||
|
const leads: ResearchLead[] = []
|
||||||
|
let discarded = 0
|
||||||
|
let watchlist = 0
|
||||||
|
|
||||||
|
for (const { item, r } of batches.flat()) {
|
||||||
|
if (r.eventType === 'IRRELEVANT') { discarded += 1; continue }
|
||||||
|
if (r.relevance === 'low') { watchlist += 1; continue }
|
||||||
|
// Aggregierte Statistik wird gelesen und beurteilt, wird aber nie zur
|
||||||
|
// Lead-Karte: eine Branchenzahl nennt kein Unternehmen, das man
|
||||||
|
// kontaktieren könnte. Sie zählt als Beobachtungsposten (§19).
|
||||||
|
if (item.aggregate) { watchlist += 1; continue }
|
||||||
|
|
||||||
|
leads.push({
|
||||||
|
company: r.company,
|
||||||
|
eventType: r.eventType,
|
||||||
|
headline: r.headline,
|
||||||
|
location: r.location,
|
||||||
|
summary: r.summary,
|
||||||
|
relevance: r.relevance,
|
||||||
|
confidence: r.confidence,
|
||||||
|
reason: r.reason,
|
||||||
|
recommendedAction: r.recommendedAction,
|
||||||
|
source: item.source,
|
||||||
|
sourceUrl: item.url,
|
||||||
|
publishedAt: item.publishedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stärkste Einordnung zuerst — die Liste soll oben beginnen, wo gehandelt wird.
|
||||||
|
leads.sort((a, b) =>
|
||||||
|
(a.relevance === b.relevance ? b.confidence - a.confidence : a.relevance === 'high' ? -1 : 1))
|
||||||
|
|
||||||
|
return { leads, discarded, watchlist }
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Livia Research-PoC — der eine serverseitige Endpoint (§20).
|
||||||
|
*
|
||||||
|
* POST /api/livia/research/refresh
|
||||||
|
*
|
||||||
|
* Ruft die drei Quellen ab, normalisiert, beurteilt einmal per LLM und gibt
|
||||||
|
* die relevanten Leads zurück. Kein Scheduler, keine Queue, keine Datenbank —
|
||||||
|
* der Lauf beginnt und endet mit dieser Anfrage.
|
||||||
|
*
|
||||||
|
* Läuft als serverlose Funktion auf derselben Vercel-Instanz, die schon die
|
||||||
|
* Anwendung ausliefert. Das ist der Grund, warum für den PoC gar keine neue
|
||||||
|
* Infrastruktur nötig war: der Hoster kann bereits Serverfunktionen, es hat
|
||||||
|
* bisher nur niemand eine gebraucht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||||
|
import {
|
||||||
|
SOURCES,
|
||||||
|
fetchZhkResearch,
|
||||||
|
fetchGreaterZurichResearch,
|
||||||
|
fetchZefixResearch,
|
||||||
|
} from '../../_lib/researchSources.js'
|
||||||
|
import type { ResearchItem } from '../../_lib/researchSources.js'
|
||||||
|
import { MODEL, analyzeResearchItems } from '../../_lib/researchAnalysis.js'
|
||||||
|
import type { ResearchRefreshResult, ResearchSourceResult } from '../../../src/domain/researchLead.js'
|
||||||
|
|
||||||
|
interface Quelle {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
laden: () => Promise<ResearchItem[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUELLEN: Quelle[] = [
|
||||||
|
{ ...SOURCES.ZHK, laden: fetchZhkResearch },
|
||||||
|
{ ...SOURCES.GZA, laden: fetchGreaterZurichResearch },
|
||||||
|
{ ...SOURCES.ZEFIX, laden: fetchZefixResearch },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
// Ein Live-Lauf soll nie aus einem Zwischenspeicher beantwortet werden.
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.statusCode = 405
|
||||||
|
res.end(JSON.stringify({ error: 'Nur POST' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle drei parallel: eine langsame Quelle soll die beiden anderen nicht aufhalten.
|
||||||
|
const ergebnisse = await Promise.all(
|
||||||
|
QUELLEN.map(async (q): Promise<{ meta: ResearchSourceResult; items: ResearchItem[] }> => {
|
||||||
|
try {
|
||||||
|
const items = await q.laden()
|
||||||
|
return {
|
||||||
|
meta: { id: q.id, name: q.name, url: q.url, ok: true, itemCount: items.length },
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Eine kaputte Quelle darf die Demo nicht zerstören (§21).
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
id: q.id, name: q.name, url: q.url, ok: false, itemCount: 0,
|
||||||
|
error: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sources = ergebnisse.map(e => e.meta)
|
||||||
|
const items = ergebnisse.flatMap(e => e.items)
|
||||||
|
|
||||||
|
const basis: ResearchRefreshResult = {
|
||||||
|
sources,
|
||||||
|
analyzed: items.length,
|
||||||
|
discarded: 0,
|
||||||
|
watchlist: 0,
|
||||||
|
leads: [],
|
||||||
|
refreshedAt: new Date().toISOString(),
|
||||||
|
model: MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
...basis,
|
||||||
|
analysisError: 'Keine der drei Quellen lieferte Einträge.',
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { leads, discarded, watchlist } = await analyzeResearchItems(items)
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end(JSON.stringify({ ...basis, leads, discarded, watchlist }))
|
||||||
|
} catch (err) {
|
||||||
|
// Gelesen wurde trotzdem — das sagen wir offen, statt eine leere Liste als
|
||||||
|
// «nichts gefunden» auszugeben.
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
...basis,
|
||||||
|
analysisError: err instanceof Error ? err.message : 'Die Beurteilung schlug fehl.',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+400
-1
@@ -8,6 +8,7 @@
|
|||||||
"name": "property-match",
|
"name": "property-match",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.122.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"@mui/icons-material": "^9.0.1",
|
"@mui/icons-material": "^9.0.1",
|
||||||
"@mui/material": "^9.0.1",
|
"@mui/material": "^9.0.1",
|
||||||
"@tanstack/react-query": "^5.75.2",
|
"@tanstack/react-query": "^5.75.2",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -58,6 +60,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.122.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.122.0.tgz",
|
||||||
|
"integrity": "sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "5.1.11",
|
"version": "5.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
@@ -1563,6 +1586,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2677,6 +2706,12 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
@@ -2764,6 +2799,81 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"encoding-sniffer": "^0.2.1",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
|
"parse5-parser-stream": "^7.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
|
"whatwg-mimetype": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -2832,6 +2942,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
@@ -2846,6 +2972,18 @@
|
|||||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css.escape": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -2942,6 +3080,73 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dom-serializer/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.356",
|
"version": "1.5.356",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
|
||||||
@@ -2949,6 +3154,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-sniffer": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.21.3",
|
"version": "5.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||||
@@ -3246,6 +3464,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -3469,6 +3693,49 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -3704,6 +3971,19 @@
|
|||||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -4216,6 +4496,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -4329,6 +4621,79 @@
|
|||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -4694,6 +5059,12 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
@@ -4785,6 +5156,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
@@ -4960,6 +5341,12 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -5034,7 +5421,6 @@
|
|||||||
"version": "7.25.0",
|
"version": "7.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
@@ -5279,6 +5665,19 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-mimetype": {
|
"node_modules/whatwg-mimetype": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"check:tokens": "node scripts/check-tokens.js"
|
"check:tokens": "node scripts/check-tokens.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.122.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@mui/icons-material": "^9.0.1",
|
"@mui/icons-material": "^9.0.1",
|
||||||
"@mui/material": "^9.0.1",
|
"@mui/material": "^9.0.1",
|
||||||
"@tanstack/react-query": "^5.75.2",
|
"@tanstack/react-query": "^5.75.2",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
|
import { ExternalLink, MapPin } from 'lucide-react'
|
||||||
|
import type { ResearchLead } from '../../domain/researchLead'
|
||||||
|
import { RESEARCH_EVENT_TYPE_LABELS, RESEARCH_RELEVANCE_LABELS } from '../../lib/constants'
|
||||||
|
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein aus einer echten Quelle abgeleiteter Lead (§16).
|
||||||
|
*
|
||||||
|
* Die Karte beantwortet in fester Reihenfolge: was ist passiert, bei wem, wo,
|
||||||
|
* warum ist es für Gewerbeflächen relevant, wie sicher ist die Einordnung und
|
||||||
|
* woher stammt sie. Der Quellenlink steht unten und ist echt — er zeigt auf
|
||||||
|
* genau die Seite, die Livia gelesen hat.
|
||||||
|
*
|
||||||
|
* Confidence steht als Zahl da und nicht als Balken: ein Balken suggeriert
|
||||||
|
* Messgenauigkeit, die eine Modellschätzung nicht hat.
|
||||||
|
*/
|
||||||
|
export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead: ResearchLead }) {
|
||||||
|
const hoch = lead.relevance === 'high'
|
||||||
|
const akzent = hoch ? DS_COLORS.leadSignal.CHANCE : { bg: DS_SLATE[100], fg: DS_SLATE[600] }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderLeft: '3px solid',
|
||||||
|
borderLeftColor: hoch ? DS_COLORS.leadSignal.CHANCE.fg : DS_SLATE[300],
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'white',
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, color: DS_SLATE[900], lineHeight: 1.35, mb: 0.75 }}>
|
||||||
|
{lead.headline}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25, alignItems: 'center' }}>
|
||||||
|
<Chip
|
||||||
|
label={RESEARCH_EVENT_TYPE_LABELS[lead.eventType] ?? lead.eventType}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: akzent.bg, color: akzent.fg }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{RESEARCH_RELEVANCE_LABELS[lead.relevance]}
|
||||||
|
</Typography>
|
||||||
|
{lead.location && (
|
||||||
|
<Chip
|
||||||
|
icon={<MapPin size={11} />}
|
||||||
|
label={lead.location}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20, fontSize: '0.72rem', bgcolor: DS_SLATE[100], color: DS_SLATE[600],
|
||||||
|
'& .MuiChip-icon': { color: DS_SLATE[600], ml: 0.5, mr: -0.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 0.25 }}>
|
||||||
|
Unternehmen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 1 }}>
|
||||||
|
{lead.company}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mb: 1.25 }}>
|
||||||
|
{lead.summary}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
Warum relevant
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: lead.recommendedAction ? 1.25 : 1.5 }}>
|
||||||
|
{lead.reason}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{lead.recommendedAction && (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
Empfohlene nächste Handlung
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: 1.5 }}>
|
||||||
|
{lead.recommendedAction}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1,
|
||||||
|
pt: 1.25, borderTop: '1px solid #f1f5f9',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||||
|
Quelle: <strong>{lead.source}</strong>
|
||||||
|
{lead.publishedAt && ` · ${new Date(lead.publishedAt).toLocaleDateString('de-CH')}`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||||
|
Confidence: <strong>{Math.round(lead.confidence * 100)} %</strong>
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={lead.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 0.5, ml: 'auto',
|
||||||
|
color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none',
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Originalquelle öffnen <ExternalLink size={11} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
|
import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'
|
||||||
|
import type { ResearchSourceResult } from '../../domain/researchLead'
|
||||||
|
import { RESEARCH_SOURCE_LIST } from '../../lib/researchSources'
|
||||||
|
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Ergebnisse des letzten Laufs; vor dem ersten Lauf leer. */
|
||||||
|
results?: ResearchSourceResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Angebundene Kanäle & Systeme» auf Livias Arbeitsplatz (§2, §15).
|
||||||
|
*
|
||||||
|
* Zeigt die drei real abgerufenen Quellen mit Namen, Status und anklickbarem
|
||||||
|
* Originallink. Die Links stehen hier nicht als Zierde: wer eine Aussage von
|
||||||
|
* Livia prüfen will, soll die Quelle in einem Klick öffnen können, ohne sie
|
||||||
|
* selbst suchen zu müssen.
|
||||||
|
*
|
||||||
|
* Vor dem ersten Lauf steht «Bereit» und nicht «Verbunden» — verbunden ist,
|
||||||
|
* was tatsächlich geantwortet hat, und das weiss man erst danach.
|
||||||
|
*/
|
||||||
|
export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results }: Props) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: 'white', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 2, py: 1, bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
Angebundene Kanäle & Systeme
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{RESEARCH_SOURCE_LIST.map(quelle => {
|
||||||
|
const ergebnis = results?.find(r => r.id === quelle.id)
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={quelle.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 1.5,
|
||||||
|
px: 2, py: 1.5,
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
'&:last-of-type': { borderBottom: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
||||||
|
{quelle.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', lineHeight: 1.4 }}>
|
||||||
|
{quelle.purpose}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={quelle.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.5,
|
||||||
|
color: DS_BRAND.main, fontSize: '0.72rem', fontWeight: 600,
|
||||||
|
textDecoration: 'none', wordBreak: 'break-all',
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{quelle.url} <ExternalLink size={11} style={{ flexShrink: 0 }} />
|
||||||
|
</Box>
|
||||||
|
{ergebnis?.error && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_COLORS.leadSignal.RISIKO.fg, display: 'block', mt: 0.25 }}>
|
||||||
|
{ergebnis.error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flexShrink: 0, pt: 0.25 }}>
|
||||||
|
{ergebnis === undefined ? (
|
||||||
|
<Chip
|
||||||
|
label="Bereit"
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: DS_SLATE[100], color: DS_SLATE[600] }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
icon={ergebnis.ok ? <CheckCircle2 size={11} /> : <XCircle size={11} />}
|
||||||
|
/*
|
||||||
|
* Mit Einheit statt nur der Zahl: «Live · 24» liest sich sonst
|
||||||
|
* genauso gut als «24 gefundene Leads», und genau so wurde es
|
||||||
|
* beim ersten Zeigen auch verstanden.
|
||||||
|
*/
|
||||||
|
label={ergebnis.ok
|
||||||
|
? `Live · ${ergebnis.itemCount} ${ergebnis.itemCount === 1 ? 'Eintrag' : 'Einträge'}`
|
||||||
|
: 'Nicht erreichbar'}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20, fontSize: '0.68rem', fontWeight: 700,
|
||||||
|
bgcolor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.bg : DS_COLORS.leadSignal.RISIKO.bg,
|
||||||
|
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||||
|
'& .MuiChip-icon': {
|
||||||
|
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||||
|
ml: 0.5, mr: -0.25,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
import { Alert, Box, Button, CircularProgress, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
import { LeadDetail, UnifiedLeadList } from '../market-leads'
|
import { LeadDetail, UnifiedLeadList } from '../market-leads'
|
||||||
|
import { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||||
|
import { ResearchLeadCard } from './ResearchLeadCard'
|
||||||
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
|
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
|
||||||
|
import { useLiviaResearch } from '../../hooks/useLiviaResearch'
|
||||||
import { LeadChannel } from '../../domain/unifiedLead'
|
import { LeadChannel } from '../../domain/unifiedLead'
|
||||||
import type { UnifiedLead } from '../../domain/unifiedLead'
|
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||||
import { LeadSignalType } from '../../domain/futureSignal'
|
import { LeadSignalType } from '../../domain/futureSignal'
|
||||||
import { DS_TEXT } from '../../lib/ds'
|
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1).
|
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15).
|
||||||
*
|
*
|
||||||
* Die Liste ist dieselbe Komponente wie bei Nora — bewusst, damit sich beide
|
* Zwei Bestände nebeneinander, bewusst getrennt gehalten:
|
||||||
* Arbeitsplätze gleich lesen und eine spätere Änderung nur an einer Stelle
|
|
||||||
* anfällt. Zwei Unterschiede: die Kanalspalte entfällt, weil hier jeder Eintrag
|
|
||||||
* aus Livias eigener Recherche stammt, und die Detailansicht läuft mit
|
|
||||||
* `showMatching={false}`.
|
|
||||||
*
|
*
|
||||||
* Damit ist die Grenze zwischen den beiden Agentinnen im Code gezogen und nicht
|
* 1. **Live-Recherche** — was Livia beim Knopfdruck aus den drei realen
|
||||||
* bloss in der Beschriftung: Livia recherchiert und verdichtet, das Matching
|
* Quellen gelesen und beurteilt hat. Diese Leads sind nicht gespeichert;
|
||||||
* gegen den eigenen Bestand bleibt Noras Arbeit (§1, §2).
|
* sie leben im Zustand dieser Komponente, bis der nächste Lauf sie ersetzt.
|
||||||
|
* 2. **Erfasste Leads** — der bestehende Signalbestand, den Nora abgleicht.
|
||||||
*
|
*
|
||||||
* Gelesen wird aus demselben Bestand, den Nora anzeigt. Es gibt keine zweite
|
* Die Trennung ist Absicht: ein live gelesener Artikel ist etwas anderes als
|
||||||
* Lead-Ablage und keine Übergabekopie — ein Lead, zwei Arbeitsplätze.
|
* ein redaktionell erfasster Lead, und beides in eine Liste zu mischen würde
|
||||||
|
* genau diesen Unterschied verwischen. Die Liste unten ist dieselbe Komponente
|
||||||
|
* wie bei Nora — mit `showMatching={false}`, weil das Matching ihre Arbeit ist.
|
||||||
*/
|
*/
|
||||||
export function ResearchWorkspace() {
|
export function ResearchWorkspace() {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -29,6 +32,8 @@ export function ResearchWorkspace() {
|
|||||||
|
|
||||||
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||||
const { data: leads, isLoading } = useUnifiedLeads()
|
const { data: leads, isLoading } = useUnifiedLeads()
|
||||||
|
const refresh = useLiviaResearch()
|
||||||
|
const ergebnis = refresh.data
|
||||||
|
|
||||||
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||||
const { research, chancen, risiken } = useMemo(() => {
|
const { research, chancen, risiken } = useMemo(() => {
|
||||||
@@ -45,24 +50,83 @@ export function ResearchWorkspace() {
|
|||||||
}, [leads])
|
}, [leads])
|
||||||
|
|
||||||
const closeDetail = useCallback(() => setSelected(null), [])
|
const closeDetail = useCallback(() => setSelected(null), [])
|
||||||
|
const starten = useCallback(() => refresh.mutate(), [refresh])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
{/* ── Live-Recherche ──────────────────────────────────────────────── */}
|
||||||
{isLoading
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
? 'Leads werden geladen …'
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
: `${research.length} ${research.length === 1 ? 'Lead' : 'Leads'} aus externen Quellen · ` +
|
<Button
|
||||||
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
|
variant="contained"
|
||||||
</Typography>
|
startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />}
|
||||||
|
disabled={refresh.isPending}
|
||||||
|
onClick={starten}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Research aktualisieren
|
||||||
|
</Button>
|
||||||
|
|
||||||
<UnifiedLeadList
|
{refresh.isPending && (
|
||||||
leads={research}
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||||
isLoading={isLoading}
|
Livia liest aktuelle Marktinformationen …
|
||||||
selectedId={selected?.id ?? null}
|
</Typography>
|
||||||
onSelect={setSelected}
|
)}
|
||||||
showChannel={false}
|
|
||||||
emptyText="Noch keine Leads aus der Marktbeobachtung. Sobald eine verknüpfte Quelle eine relevante Veränderung meldet, erscheint sie hier."
|
{!refresh.isPending && ergebnis && (
|
||||||
/>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
{`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
|
||||||
|
{`${ergebnis.discarded} nicht relevant · `}
|
||||||
|
{`${ergebnis.watchlist} Watchlist · `}
|
||||||
|
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'relevante Entwicklung' : 'relevante Entwicklungen'}`}
|
||||||
|
{` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
|
||||||
|
{ergebnis.model && ` · Modell ${ergebnis.model}`}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ResearchSourcesPanel results={ergebnis?.sources} />
|
||||||
|
|
||||||
|
{ergebnis?.analysisError && (
|
||||||
|
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||||
|
Die Quellen wurden gelesen, die Beurteilung konnte aber nicht laufen: {ergebnis.analysisError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis && ergebnis.leads.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{ergebnis.leads.map(lead => (
|
||||||
|
<ResearchLeadCard key={`${lead.sourceUrl}-${lead.headline}`} lead={lead} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis && ergebnis.leads.length === 0 && !ergebnis.analysisError && (
|
||||||
|
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||||
|
Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug.
|
||||||
|
Gelesen wurden {ergebnis.analyzed} Einträge.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Erfasste Leads ──────────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1, borderTop: `1px solid ${DS_SLATE[200]}` }}>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
{isLoading
|
||||||
|
? 'Leads werden geladen …'
|
||||||
|
: `${research.length} ${research.length === 1 ? 'erfasster Lead' : 'erfasste Leads'} · ` +
|
||||||
|
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<UnifiedLeadList
|
||||||
|
leads={research}
|
||||||
|
isLoading={isLoading}
|
||||||
|
selectedId={selected?.id ?? null}
|
||||||
|
onSelect={setSelected}
|
||||||
|
showChannel={false}
|
||||||
|
emptyText="Noch keine erfassten Leads. Was Livia gerade live gelesen hat, steht oberhalb."
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor="right"
|
anchor="right"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
// Livia — Markt-Recherche (Runde 8, §1). Barrel-Export (CLAUDE.md §13).
|
// Livia — Markt-Recherche (Runde 8, §1; Research-PoC). Barrel-Export (CLAUDE.md §13).
|
||||||
|
|
||||||
export { ResearchWorkspace } from './ResearchWorkspace'
|
export { ResearchWorkspace } from './ResearchWorkspace'
|
||||||
|
export { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||||
|
export { ResearchLeadCard } from './ResearchLeadCard'
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Livias Research-PoC: echte Quellen statt Mockdaten.
|
||||||
|
*
|
||||||
|
* Diese Typen werden von beiden Seiten benutzt — von der serverlosen Funktion
|
||||||
|
* unter `api/` und vom Frontend. Sie liegen deshalb im Domänenordner und nicht
|
||||||
|
* neben dem Endpoint: ein zweites, fast gleiches Typenpaar wäre die erste
|
||||||
|
* Stelle, an der Server und Oberfläche auseinanderlaufen.
|
||||||
|
*
|
||||||
|
* Bewusst klein gehalten (PoC, §9/§13). Es ist ausdrücklich **nicht** der
|
||||||
|
* `FutureSignal`-Typ: der trägt Wahrscheinlichkeiten, Datenqualität und
|
||||||
|
* Portfoliobezug, die eine Live-Recherche nicht liefern kann. Ein Feld zu
|
||||||
|
* füllen, für das keine Grundlage existiert, wäre genau die Behauptung, die
|
||||||
|
* Livia nicht aufstellen darf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Die neun Kategorien des PoC (§11). */
|
||||||
|
export const ResearchEventType = {
|
||||||
|
EXPANSION: 'EXPANSION',
|
||||||
|
NEW_LOCATION: 'NEW_LOCATION',
|
||||||
|
HEADCOUNT_GROWTH: 'HEADCOUNT_GROWTH',
|
||||||
|
INVESTMENT: 'INVESTMENT',
|
||||||
|
M_AND_A: 'M_AND_A',
|
||||||
|
RESTRUCTURING: 'RESTRUCTURING',
|
||||||
|
SITE_CLOSURE: 'SITE_CLOSURE',
|
||||||
|
NEW_COMPANY: 'NEW_COMPANY',
|
||||||
|
IRRELEVANT: 'IRRELEVANT',
|
||||||
|
} as const
|
||||||
|
export type ResearchEventType = typeof ResearchEventType[keyof typeof ResearchEventType]
|
||||||
|
|
||||||
|
export type ResearchRelevance = 'high' | 'medium' | 'low'
|
||||||
|
|
||||||
|
/** Ein von Livia aus einer echten Quelle abgeleiteter Lead (§13). */
|
||||||
|
export interface ResearchLead {
|
||||||
|
company: string
|
||||||
|
eventType: ResearchEventType
|
||||||
|
headline: string
|
||||||
|
location?: string
|
||||||
|
summary: string
|
||||||
|
relevance: ResearchRelevance
|
||||||
|
/** 0–1. Wie sicher ist die Einordnung angesichts der Quellenlage? */
|
||||||
|
confidence: number
|
||||||
|
reason: string
|
||||||
|
recommendedAction?: string
|
||||||
|
/** Anzeigename der Quelle, z. B. «Zürcher Handelskammer». */
|
||||||
|
source: string
|
||||||
|
/** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */
|
||||||
|
sourceUrl: string
|
||||||
|
publishedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis je abgerufener Quelle — auch im Fehlerfall (§21). */
|
||||||
|
export interface ResearchSourceResult {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
ok: boolean
|
||||||
|
/** Gelesene Einträge; 0, wenn die Quelle nicht erreichbar war. */
|
||||||
|
itemCount: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort des Refresh-Endpoints. */
|
||||||
|
export interface ResearchRefreshResult {
|
||||||
|
sources: ResearchSourceResult[]
|
||||||
|
/** Wie viele Einträge insgesamt gelesen wurden. */
|
||||||
|
analyzed: number
|
||||||
|
/** Davon als nicht relevant verworfen — der Kern des PoC (§14). */
|
||||||
|
discarded: number
|
||||||
|
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
||||||
|
watchlist: number
|
||||||
|
/** Nur `high` und `medium` erscheinen hier. */
|
||||||
|
leads: ResearchLead[]
|
||||||
|
/** Zeitpunkt des Laufs, ISO. */
|
||||||
|
refreshedAt: string
|
||||||
|
/**
|
||||||
|
* Welches Modell die Einordnung vorgenommen hat.
|
||||||
|
*
|
||||||
|
* Wird angezeigt, weil die Modellwahl über eine Umgebungsvariable läuft und
|
||||||
|
* man sonst nirgends in der Anwendung sähe, welche gerade gilt — eine
|
||||||
|
* Einstellung, die man nur im Hosting-Dashboard prüfen kann, prüft niemand.
|
||||||
|
*/
|
||||||
|
model: string
|
||||||
|
/**
|
||||||
|
* Gesetzt, wenn die Analyse nicht laufen konnte — etwa weil serverseitig
|
||||||
|
* kein Schlüssel hinterlegt ist. Die Oberfläche sagt das dann offen, statt
|
||||||
|
* eine leere Liste als «nichts gefunden» auszugeben.
|
||||||
|
*/
|
||||||
|
analysisError?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import type { ResearchRefreshResult } from '../domain/researchLead'
|
||||||
|
import { RESEARCH_REFRESH_ENDPOINT } from '../lib/researchSources'
|
||||||
|
import { useToastStore } from '../stores/toastStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Research aktualisieren» — der eine Aufruf des PoC.
|
||||||
|
*
|
||||||
|
* Eine Mutation und keine Query: der Lauf ist eine Handlung des Nutzers, kein
|
||||||
|
* Zustand, der sich von selbst aktualisiert. Deshalb auch bewusst kein
|
||||||
|
* `staleTime` und keine Zwischenspeicherung — wer auf den Knopf drückt, will
|
||||||
|
* den Stand von jetzt und nicht den von vorhin.
|
||||||
|
*
|
||||||
|
* Das Ergebnis lebt im Zustand der Komponente. Für den Machbarkeitsnachweis
|
||||||
|
* ist keine Persistenz nötig (§17).
|
||||||
|
*/
|
||||||
|
export function useLiviaResearch() {
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
|
return useMutation<ResearchRefreshResult, Error>({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, { method: 'POST' })
|
||||||
|
|
||||||
|
// Ohne laufende Serverfunktion liefert der Hoster die Anwendung selbst
|
||||||
|
// zurück — dann steht hier HTML statt JSON. Das ist der häufigste Fall
|
||||||
|
// in der lokalen Entwicklung und verdient eine klare Meldung statt eines
|
||||||
|
// «Unexpected token <».
|
||||||
|
const typ = res.headers.get('content-type') ?? ''
|
||||||
|
if (!typ.includes('application/json')) {
|
||||||
|
throw new Error(
|
||||||
|
'Der Recherche-Endpoint antwortet nicht. Lokal braucht es dafür «vercel dev» statt «vite».',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`Der Recherche-Lauf schlug fehl (HTTP ${res.status}).`)
|
||||||
|
|
||||||
|
return await res.json() as ResearchRefreshResult
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showToast(err.message, 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -356,6 +356,26 @@ export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
|
|||||||
RISIKO: 'Risiko',
|
RISIKO: 'Risiko',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Livia — Recherche aus realen Quellen (PoC, §11/§13) ──────────────────────
|
||||||
|
|
||||||
|
export const RESEARCH_EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
EXPANSION: 'Expansion',
|
||||||
|
NEW_LOCATION: 'Neuer Standort',
|
||||||
|
HEADCOUNT_GROWTH: 'Personalwachstum',
|
||||||
|
INVESTMENT: 'Investition',
|
||||||
|
M_AND_A: 'M&A',
|
||||||
|
RESTRUCTURING: 'Restrukturierung',
|
||||||
|
SITE_CLOSURE: 'Standortschliessung',
|
||||||
|
NEW_COMPANY: 'Neugründung',
|
||||||
|
IRRELEVANT: 'Nicht relevant',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESEARCH_RELEVANCE_LABELS: Record<string, string> = {
|
||||||
|
high: 'hohe Relevanz',
|
||||||
|
medium: 'mittlere Relevanz',
|
||||||
|
low: 'geringe Relevanz',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
|
// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Property On — die real angebundenen Recherchequellen von Livia.
|
||||||
|
*
|
||||||
|
* Eine einzige Liste für beide Seiten: die serverlose Funktion liest daraus
|
||||||
|
* die abzurufenden Adressen, die Oberfläche zeigt daraus «Angebundene Kanäle
|
||||||
|
* & Systeme». Stünden die URLs an zwei Stellen, könnte der angezeigte Link
|
||||||
|
* irgendwann auf etwas anderes zeigen als der tatsächliche Abruf — und genau
|
||||||
|
* die Nachvollziehbarkeit ist der Zweck der Anzeige (§2).
|
||||||
|
*
|
||||||
|
* Die Datei enthält bewusst nur Daten und keine Abruflogik: so kann `api/`
|
||||||
|
* sie importieren, ohne dass der HTML-Parser im Frontend-Bundle landet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ResearchSourceDescriptor {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
/** Wofür die Quelle im PoC steht — erscheint in der Oberfläche. */
|
||||||
|
purpose: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESEARCH_SOURCES = {
|
||||||
|
ZHK: {
|
||||||
|
id: 'zhk',
|
||||||
|
name: 'Zürcher Handelskammer — News',
|
||||||
|
url: 'https://www.zhk.ch/de/wirtschaft-und-politik/news-liste.html',
|
||||||
|
purpose: 'Regionale Unternehmensmeldungen, Expansion, Investitionen, Kapazitätsausbau',
|
||||||
|
},
|
||||||
|
GZA: {
|
||||||
|
id: 'gza',
|
||||||
|
name: 'Greater Zurich Area — News',
|
||||||
|
url: 'https://www.greaterzuricharea.com/de/alle-news',
|
||||||
|
purpose: 'Ansiedlungen, Markteintritte, internationale Expansionen und neue Standorte',
|
||||||
|
},
|
||||||
|
ZEFIX: {
|
||||||
|
id: 'zefix',
|
||||||
|
name: 'Zefix / Open Data Kanton Zürich',
|
||||||
|
url: 'https://www.web.statistik.zh.ch/ogd/data/KANTON_ZUERICH_ZEFIX_Neugruendungen.csv',
|
||||||
|
purpose:
|
||||||
|
'Strukturierte Daten zu Zürcher Neugründungen; CSV wird direkt eingelesen — '
|
||||||
|
+ 'Marktindikator ohne Firmennamen, erscheint deshalb nicht als Lead',
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, ResearchSourceDescriptor>
|
||||||
|
|
||||||
|
export const RESEARCH_SOURCE_LIST: ResearchSourceDescriptor[] = [
|
||||||
|
RESEARCH_SOURCES.ZHK,
|
||||||
|
RESEARCH_SOURCES.GZA,
|
||||||
|
RESEARCH_SOURCES.ZEFIX,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Der eine Endpoint des PoC (§20). */
|
||||||
|
export const RESEARCH_REFRESH_ENDPOINT = '/api/livia/research/refresh'
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.api.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
/*
|
||||||
|
* Die serverlosen Funktionen unter `api/` teilen Typen mit dem Frontend —
|
||||||
|
* deshalb sind die geteilten Dateien aus `src/` mitaufgeführt. Ohne dieses
|
||||||
|
* eigene Projekt liefe `tsc -b` an `api/` vorbei, und ein Tippfehler im
|
||||||
|
* Endpoint fiele erst beim Deployment auf.
|
||||||
|
*/
|
||||||
|
"include": ["api", "src/domain/researchLead.ts", "src/lib/researchSources.ts"]
|
||||||
|
}
|
||||||
+2
-1
@@ -2,6 +2,7 @@
|
|||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.api.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,10 @@
|
|||||||
"buildCommand": "npx vite build",
|
"buildCommand": "npx vite build",
|
||||||
"outputDirectory": "dist",
|
"outputDirectory": "dist",
|
||||||
"framework": null,
|
"framework": null,
|
||||||
|
"functions": {
|
||||||
|
"api/**/*.ts": {
|
||||||
|
"maxDuration": 60
|
||||||
|
}
|
||||||
|
},
|
||||||
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,46 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Livias Recherche-Endpoint in der lokalen Entwicklung.
|
||||||
|
*
|
||||||
|
* In der Produktion liefert Vercel `api/livia/research/refresh.ts` als
|
||||||
|
* serverlose Funktion aus. `vite` kennt keine Serverfunktionen — ohne diesen
|
||||||
|
* Zugang liefe der Aufruf in den `/api`-Proxy darunter und käme als HTML
|
||||||
|
* zurück. Das Plugin lädt dieselbe Datei über Vites SSR-Lader und ruft sie mit
|
||||||
|
* demselben `(req, res)` auf, das auch Vercel übergibt; es gibt also keine
|
||||||
|
* zweite Fassung des Endpoints, die auseinanderlaufen könnte.
|
||||||
|
*
|
||||||
|
* Nur für `serve` — in den Produktionsbau geht davon nichts ein.
|
||||||
|
*/
|
||||||
|
function liviaResearchDevEndpoint(): Plugin {
|
||||||
|
const PFAD = '/api/livia/research/refresh'
|
||||||
|
return {
|
||||||
|
name: 'livia-research-dev-endpoint',
|
||||||
|
apply: 'serve',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
if (!req.url?.startsWith(PFAD)) return next()
|
||||||
|
void server
|
||||||
|
.ssrLoadModule('/api/livia/research/refresh.ts')
|
||||||
|
.then(mod => (mod as { default: (q: typeof req, s: typeof res) => Promise<void> }).default(req, res))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
res.statusCode = 500
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
liviaResearchDevEndpoint(),
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user