diff --git a/api/_lib/researchAnalysis.ts b/api/_lib/researchAnalysis.ts index 55c894d..bc2069e 100644 --- a/api/_lib/researchAnalysis.ts +++ b/api/_lib/researchAnalysis.ts @@ -90,6 +90,15 @@ function extractJson(text: string): unknown { */ const BATCH_SIZE = 5 +/** + * 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(list: T[], size: number): T[][] { const out: T[][] = [] for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size)) @@ -148,11 +157,28 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise analyzeBatch(client, b)), + // 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>[] = new Array(teilstapel.length) + let next = 0 + const worker = async (): Promise => { + for (;;) { + const i = next++ + if (i >= teilstapel.length) return + // Ein gescheiterter Teilstapel darf die übrigen nicht mitreissen. + try { + ergebnisse[i] = await analyzeBatch(client, teilstapel[i]) + } catch { + ergebnisse[i] = [] + } + } + } + await Promise.all( + Array.from({ length: Math.min(ANALYSIS_CONCURRENCY, teilstapel.length) }, worker), ) + const batches = ergebnisse const leads: ResearchLead[] = [] let discarded = 0 diff --git a/api/_lib/researchSources.ts b/api/_lib/researchSources.ts index f78dc9c..3c2d352 100644 --- a/api/_lib/researchSources.ts +++ b/api/_lib/researchSources.ts @@ -29,8 +29,23 @@ export interface ResearchItem { */ export const SOURCES = RESEARCH_SOURCES -/** Höchstzahl Artikel je Newsquelle — hält Laufzeit und Tokenverbrauch klein (§22). */ -const MAX_ARTICLES = 10 +/** + * 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 const UA = 'Mozilla/5.0 (compatible; PropertyMatch-LiviaResearch/1.0; +https://property-match-virid.vercel.app)' @@ -114,86 +129,118 @@ function absolute(href: string, base: string): string { } } -/** Quelle A — Zürcher Handelskammer, Unternehmensmeldungen aus der Region (§4). */ -export async function fetchZhkResearch(): Promise { - const listHtml = await getText(SOURCES.ZHK.url) - const $ = cheerio.load(listHtml) - - const links: { url: string; title: string }[] = [] - const seen = new Set() - $('a[href*="/de/wirtschaft-und-politik/news/"]').each((_, el) => { - const href = $(el).attr('href') - if (!href) return - const url = absolute(href, SOURCES.ZHK.url) - if (seen.has(url)) return - seen.add(url) - const title = $(el).text().replace(/\s+/g, ' ').trim() - links.push({ url, title }) - }) - - const chosen = links.slice(0, MAX_ARTICLES) - const items: ResearchItem[] = [] - for (const l of chosen) { - try { - const html = await getText(l.url) - const $a = cheerio.load(html) - const title = cleanTitle(l.title) || $a('h1').first().text().replace(/\s+/g, ' ').trim() - const published = - $a('time').first().attr('datetime') || - $a('meta[property="article:published_time"]').attr('content') || - undefined - items.push({ - source: SOURCES.ZHK.name, - title, - url: l.url, - text: extractArticleText(html), - publishedAt: published, - }) - } catch { - // Ein einzelner Artikel darf den Lauf nicht kippen. +/** Kartiert mit begrenzter Gleichzeitigkeit — schneller als nacheinander, ohne die Quelle zu fluten. */ +async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out: R[] = new Array(items.length) + let next = 0 + const worker = async (): Promise => { + for (;;) { + const i = next++ + if (i >= items.length) return + out[i] = await fn(items[i]) } } - return items + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return out } -/** Quelle B — Greater Zurich Area, Ansiedlungen und Markteintritte (§5). */ -export async function fetchGreaterZurichResearch(): Promise { - const listHtml = await getText(SOURCES.GZA.url) - const $ = cheerio.load(listHtml) +interface Artikellink { url: string; title: string } - const links: { url: string; title: string }[] = [] - const seen = new Set() - $('a[href*="/de/news/"]').each((_, el) => { - const href = $(el).attr('href') - if (!href) return - const url = absolute(href, SOURCES.GZA.url) - if (seen.has(url) || /\/de\/news\/?$/.test(url)) return - seen.add(url) - links.push({ url, title: $(el).text().replace(/\s+/g, ' ').trim() }) - }) - - const chosen = links.slice(0, MAX_ARTICLES) - const items: ResearchItem[] = [] - for (const l of chosen) { +/** + * Artikel zu Einträgen machen — parallel, und ein einzelner Ausfall kippt den + * Lauf nicht. Statt einer Ausnahme entsteht dann schlicht kein Eintrag. + */ +async function ladeArtikel(links: Artikellink[], quelle: string): Promise { + const items = await mapLimit(links, FETCH_CONCURRENCY, async (l): Promise => { try { const html = await getText(l.url) const $a = cheerio.load(html) - const title = cleanTitle(l.title) || $a('h1').first().text().replace(/\s+/g, ' ').trim() - items.push({ - source: SOURCES.GZA.name, - title, + 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 { - // siehe oben + return null } + }) + return items.filter((i): i is ResearchItem => i !== null && i.text.length > 120) +} + +/** + * Artikellinks aus mehreren Übersichtsseiten einsammeln. + * + * Die Listenseiten werden parallel geholt, das Ergebnis aber in Seitenreihenfolge + * zusammengesetzt: Seite 1 trägt die neuesten Meldungen, und die sollen zuerst + * kommen, wenn die Obergrenze greift. + */ +async function sammleLinks( + seitenUrls: string[], + basis: string, + selektor: string, + ausschluss?: RegExp, +): Promise { + const seiten = await Promise.all( + seitenUrls.map(async u => { + try { + return await getText(u) + } catch { + return '' + } + }), + ) + + const links: Artikellink[] = [] + const seen = new Set() + for (const html of seiten) { + if (!html) continue + const $ = cheerio.load(html) + $(selektor).each((_, el) => { + const href = $(el).attr('href') + if (!href) return + const url = absolute(href, basis) + if (seen.has(url)) return + if (ausschluss?.test(url)) return + seen.add(url) + links.push({ url, title: $(el).text().replace(/\s+/g, ' ').trim() }) + }) } - return items + return links.slice(0, MAX_ARTICLES) +} + +/** Quelle A — Zürcher Handelskammer, Unternehmensmeldungen aus der Region (§4). */ +export async function fetchZhkResearch(): Promise { + // Seite 1 liegt unter der Basisadresse, die Folgeseiten unter `…/page/N.html`. + const seiten = [ + SOURCES.ZHK.url, + ...Array.from({ length: LIST_PAGES - 1 }, (_, i) => + `https://www.zhk.ch/de/wirtschaft-und-politik/news-liste/page/${i + 2}.html`), + ] + const links = await sammleLinks( + seiten, + SOURCES.ZHK.url, + 'a[href*="/de/wirtschaft-und-politik/news/"]', + ) + return ladeArtikel(links, SOURCES.ZHK.name) +} + +/** Quelle B — Greater Zurich Area, Ansiedlungen und Markteintritte (§5). */ +export async function fetchGreaterZurichResearch(): Promise { + // Paginierung über `?page=N`, beginnend bei 0. + const seiten = Array.from({ length: LIST_PAGES }, (_, i) => + i === 0 ? SOURCES.GZA.url : `${SOURCES.GZA.url}?page=${i}`) + const links = await sammleLinks( + seiten, + SOURCES.GZA.url, + 'a[href*="/de/news/"]', + /\/de\/news\/?$/, + ) + return ladeArtikel(links, SOURCES.GZA.name) } /**