Commits vergleichen

37 Commits

Autor SHA1 Nachricht Datum
Claude Code
1647a6f50a Refresh-Intervall: Mindestzeiten je nach Quellen erzwingen
Mindest-Aktualisierungsintervall im Lage-Modal: 30 Minuten Basis, 45 bei X oder Telegram, 60 bei X und Telegram zugleich (internationale Quellen ohne Einfluss). Minutenwerte darunter sind im UI nicht mehr einstellbar (min-Attribut, Clamp am Feld und beim Speichern). Beim Umstellen von Stunden auf Minuten wird das Minimum gesetzt und als Hinweis angezeigt. Gilt für Anlegen und Bearbeiten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:17:32 +00:00
Claude Code
c53e260c6c UI: Art der Lage im Lage-Modal nach ganz oben verschoben
Die Typ-Auswahl (Live-Monitoring/Recherche) steht jetzt als erstes Feld vor Titel und Beschreibung, beim Anlegen und beim Bearbeiten (gemeinsames Modal modal-new). Auf ausdrücklichen Wunsch direkt auf main aufgespielt, Staging-Zyklus umgangen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:52:49 +00:00
c3a0ee4538 Promote develop → main (2026-06-02 17:14 UTC) 2026-06-02 19:14:06 +02:00
Claude Code
e20b3de0fa Lagebild: keine Stichwort-Fragmente und blanken Quellennummern-Dumps
Folgeregel zum Neu-am-Fix: verbietet Telegramm-Verkuerzungen
(Stichwort + [Nr]) und Auffangbloecke ohne Aussage (Fruehere Belege
[...]). Jede Quellennummer muss an einem vollstaendigen Satz haengen,
am Abschnitts- oder Lagebild-Ende keine reine Quellennummern-Liste.
2026-06-02 16:15:15 +00:00
aa36a9a38f Promote develop → main (2026-06-02 16:10 UTC) 2026-06-02 18:10:43 +02:00
Claude Code
d570e13dc6 Lagebild: keine datierten Neu-am-Verlaufsbloecke mehr
Der inkrementelle Analyse-Prompt liess das LLM neue Erkenntnisse als
datierte Changelog-Bloecke (Neu am DD.MM.) anhaengen, die nie eingefaltet
wurden. Beim Iran-Lagebild summierten sich so 151 solcher Bloecke. Punkt 3
fordert jetzt das Einarbeiten in den thematischen Abschnitt; zusaetzliche
STRUKTUR-Regel loest bestehende Neu-am-Bloecke auf. Die chronologische Sicht
bleibt der separaten Kachel Neueste Entwicklungen vorbehalten.
2026-06-02 15:52:59 +00:00
Claude Code
7777b77abd feat(pipeline): Translator als Pipeline-Step + Watchdog-Limits erhoehen
Folgefix zu 952df87. Der Translator-Block laeuft post-summary bei jp_demo
40+ Min und war bisher fuer das Frontend unsichtbar und fuer den Watchdog
ein blinder Fleck (kein Pipeline-Step-Eintrag).

Aenderungen:
- pipeline_tracker.py: neuer Step 'translate' zwischen 'summary' und 'qc'
  (DE+EN Label/Tooltip). Bewusst conditional sichtbar: erscheint nur, wenn
  fremdsprachige Artikel ohne DE-Uebersetzung vorliegen UND
  translator_enabled fuer die Org an ist.
- orchestrator.py: Translator-Block umrandet mit _pipe_start('translate')
  und _pipe_done('translate', count_value=uebersetzt, count_secondary=
  pending). Translator-Fehler schliesst Step trotzdem sauber ab.
  Bedingung 'pending_translations and translator_enabled' ersetzt das
  alte 'pending_translations' - skipped den Block sauber wenn Org-Override
  deaktiviert (war vorher redundant in translate_articles selbst).
- main.py: ORPHAN_IDLE_LIMIT 30->60 Min, ORPHAN_HARD_LIMIT 90->120 Min.
  Deckt jp_demo Translator-Phase (beobachtet bis 41 Min) mit Puffer ab,
  ohne echte Haenger durchzulassen.

Resultierend: Frontend zeigt den Uebersetzungs-Schritt mit Fortschritt
(uebersetzt/gesamt). Watchdog killt nicht mehr vorzeitig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:22:34 +00:00
b02578e48b Promote develop → main (2026-05-25 23:14 UTC) 2026-05-26 01:14:22 +02:00
Claude Code
952df87afa fix(watchdog): Refresh nicht killen wenn Pipeline noch Fortschritt zeigt
Der bisherige Watchdog markierte jeden running-Refresh nach 15 Min als
verwaist. Bei jp_demo-Lagen laeuft nach summary aber noch der Translator
(synchron, ~20 Min bei 200+ Artikeln), der den Refresh legitim ueber das
Limit traegt - er wurde dann faelschlich abgebrochen und der Orchestrator
hing in-memory weiter mit incident in _current_task.

Neuer Watchdog:
- ORPHAN_IDLE_LIMIT (30 Min): wird der Refresh nur als verwaist markiert,
  wenn seit dieser Zeit kein refresh_pipeline_steps-Eintrag Fortschritt
  zeigte (started_at oder completed_at)
- ORPHAN_HARD_LIMIT (90 Min): absolute Obergrenze gegen echte Haenger
- Wenn ueberhaupt keine Pipeline-Steps existieren -> als verwaist markieren

Folge: Long-Running-Refreshes (Translator-Block) laufen sauber durch,
nur echte Haenger werden bereinigt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:05:10 +00:00
38ce26f0be Promote develop → main (2026-05-22 19:10 UTC) 2026-05-22 21:10:42 +02:00
7f7b30c1d6 Release-Notes: Exportdialog: Ersteller manuell eintragbar 2026-05-22 21:10:28 +02:00
Claude Code
d986d611cf feat(export): Ersteller im Export-Dialog manuell eingebbar
Der Export-Dialog hat ein neues optionales Feld "Ersteller". Ist es
gefuellt, wird dieser Name im Bericht als Ersteller verwendet; bleibt es
leer, gilt wie bisher die E-Mail des Lage-Erstellers.

- export_incident: optionaler Query-Parameter creator, hat Vorrang vor
  der E-Mail-Ableitung
- exportReport (api.js) haengt creator an die Export-URL
- submitExport (app.js) liest das neue Feld aus
- Eingabefeld im Export-Modal (dashboard.html)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:08:26 +00:00
7954a78964 Promote develop → main (2026-05-22 18:55 UTC) 2026-05-22 20:55:44 +02:00
Claude (claude-dev)
453c505a7e fix(export): Cache-Buster fuer app.js/api.js erhoehen
Die Branding-Auswahl im Export blieb wirkungslos, weil der
Browser die alten gecachten app.js/api.js weiterverwendete.
Versions-Query der beiden Skripte angehoben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:52:38 +00:00
0b335263c9 Promote develop → main (2026-05-22 18:49 UTC) 2026-05-22 20:49:21 +02:00
Claude (claude-dev)
279df0f56b feat(export): neutrale Export-Variante ohne Firmenbranding
Beim Bericht-Export lässt sich im Modal nun zwischen "Mit
AegisSight-Branding" und "Ohne Firmen-Branding" wählen. Im
neutralen Modus entfallen Logo, AegisSight-Zeile auf dem
Deckblatt und Branding-Footer; die Datei-Metadaten werden
neutralisiert. Das Deckblatt mit Titel, Stand und Ersteller
bleibt erhalten. Betrifft PDF und DOCX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:39:21 +00:00
889044cc3b Promote develop → main (2026-05-22 18:16 UTC) 2026-05-22 20:16:59 +02:00
Claude Code
0c34f67194 fix(sources): X-Quellen im Monitor speicherbar machen
SOURCE_TYPE_PATTERN kannte kein x_account und SOURCE_CATEGORY_PATTERN
kein x. Dadurch schlug das Speichern einer X-Quelle ueber die Monitor-
Oberflaeche mit HTTP 422 fehl: bei neuen X-Quellen am source_type, beim
Bearbeiten bestehender X-Quellen an der Kategorie x. Beide Patterns
ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:16:54 +00:00
64f9841240 Promote develop → main (2026-05-22 18:16 UTC) 2026-05-22 20:16:51 +02:00
Claude Code
1b8961ca12 fix(sources): Typ-Filter in der Fall-Quellenuebersicht immer anzeigen
Die Filter-Chips wurden nur eingeblendet, wenn ein Fall Telegram- oder
X-Quellen hatte. Bei reinen Web-Faellen (z.B. in der Org jp_demo) fehlte
die Filterleiste damit komplett. Sie wird jetzt immer angezeigt, sobald
Quellen vorhanden sind, und zeigt zugleich, welche Quellentypen der Fall
enthaelt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:40:33 +00:00
773715a38e Promote develop → main (2026-05-22 13:45 UTC) 2026-05-22 15:45:51 +02:00
Claude Code
f69fa1b95e feat(sources): Quellenuebersicht der Lage nach Typ filterbar
Die Quellenuebersicht innerhalb einer Lage zeigt jetzt Filter-Chips
(Alle / Web / Telegram / X) und blendet die Quellen-Boxen nach
Quellentyp ein und aus. Die Chips erscheinen nur, wenn neben Web auch
Telegram- oder X-Quellen vorkommen.

- sources-summary-Endpoint liefert pro Quelle einen source_type,
  abgeleitet aus dem source-Praefix (X: / Telegram: / sonst Web)
- Filter-Chips und data-type in renderSourceOverviewFromSummary
- App.filterSourceOverview blendet die Boxen nach Typ
- Chip-Styles in style.css

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:37:44 +00:00
f1a395bb94 Promote develop → main (2026-05-22 13:32 UTC) 2026-05-22 15:32:31 +02:00
Claude Code
a0f4572a01 feat(sources): Quellenuebersicht nach Quellentyp filterbar
Der Typ-Filter im Quellen-Modal kennt jetzt auch podcast_feed, damit
alle Quellentypen (RSS, Web, Telegram, X, Podcast) filterbar sind.
Zusaetzlich zeigt jede Quelle ein korrektes Typ-Badge -- vorher zeigten
Telegram, X und Podcast faelschlich "Web".

- podcast_feed im sources-filter-type-Dropdown
- _sourceTypeLabel-Helfer, korrekte Typ-Badges im Gruppen-Header und in
  den Feed-Zeilen, x_account im Info-Tooltip-typeMap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:31:04 +00:00
9598063728 Promote develop → main (2026-05-22 09:37 UTC) 2026-05-22 11:37:35 +02:00
Claude Code
cc1f9af273 fix(x): twscrape auf GitHub-main pinnen (x-client-transaction-id-Fix)
twscrape 0.17.0 von PyPI scheitert am x-client-transaction-id-Generator
(IndexError, twscrape-Issue #248). Der main-Branch enthaelt den Fix.
Pin auf einen festen Commit fuer Reproduzierbarkeit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:54:05 +00:00
a61e45f752 Promote develop → main (2026-05-22 07:41 UTC) 2026-05-22 09:41:18 +02:00
3f45ae66df Release-Notes: X (Twitter) als neue Informationsquelle verfügbar 2026-05-22 09:41:15 +02:00
Claude Code
9c50439785 feat(x): X (Twitter) als Bezugsquelle pro Lage
X-Accounts werden analog zu Telegram als Quelle (source_type=x_account)
konfiguriert und pro Lage ueber include_x zugeschaltet. Der Scraper
(feeds/x_parser.py, twscrape) liest Account-Timelines, optional ueber
einen HTTP-Proxy mit Fallback auf direkten Abruf ueber die Server-IP.

- DB-Migration include_x, Pydantic-Modelle, incidents-Router
- Orchestrator-X-Pipeline plus Haiku-Account-Vorselektion
- sources-Router /x/validate, x_account-Typ in Stats und Frontend
- Lage-Einstellungen: X-Toggle neben international und Telegram
- twscrape als Abhaengigkeit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:52:19 +00:00
f1200743e6 Recency Frische-Suchfeed (#36) 2026-05-22 02:33:07 +02:00
86b12a156e feat(recency): Frische-Suchfeed (when:14d) + Aktualitaets-Score
Damit die Pipeline das aktuelle Bild einfaengt, nicht nur das relevanteste
(oft Monate alt). Bei der Test-Lage Qilin war der neueste Artikel 7 Wochen
alt, die Masse 6-7 Monate — weil Google-News-Volltextsuche nach Relevanz
rankt, nicht nach Datum.

- build_news_search_feeds: neuer Parameter recency_days. Wenn gesetzt, wird
  der Google-News-Operator "when:Nd" an die Query gehaengt — der Feed liefert
  nur Artikel der letzten N Tage. Eigene Domain-Gruppe '...-recent'.
- orchestrator._rss_pipeline: baut jetzt ZWEI Suchfeed-Saetze — einen
  Kontext-Feed (alle Zeiten) und einen Frische-Feed (when:14d). Beide laufen
  durch dieselbe Pipeline, Dedup entfernt Ueberschneidungen.
- rss_parser._fetch_feed: relevance_score bekommt einen Aktualitaets-Bonus
  (<=3d +0.35, <=14d +0.20, <=60d +0.05) bzw. -Malus (>180d -0.15, >365d
  -0.30). Damit ueberleben frische Artikel den Domain-Cap statt von alten
  verdraengt zu werden.

Nur adhoc-Pfad betroffen — research-Lagen ueberspringen die RSS-Pipeline
ohnehin und behalten ihre volle historische Tiefe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:32:55 +02:00
002584bdb1 Geo-Centroid + GNews-Eigennamen (#35) 2026-05-22 02:13:43 +02:00
309c97f40a fix(geo+recall): Länder-Centroid statt Hauptstadt + Eigennamen in GNews-Query
Zwei Fixes aus der jp_demo-Verifikation:

1. Geoparsing — Länder mit Centroid statt Hauptstadt
   Bisher bekam ein Land die Koordinaten seiner Hauptstadt. Damit landeten
   alle "Japan"-Marker exakt auf Tokyo (35.69, 139.69) und die Karte
   suggerierte faelschlich ein Ereignis in der Hauptstadt. Neue Tabelle
   _COUNTRY_CENTROIDS (37 Laender) verortet ein Land in seiner geografischen
   Mitte (Japan: 36.20, 138.25). Laender ohne Centroid-Eintrag fallen auf die
   Hauptstadt zurueck.

2. Recall — Eigennamen in den Google-News-Suchfeed erzwingen
   Beim ersten Refresh fehlt die Headlines-Historie, daher kamen die GNews-
   Such-Keywords aus der Feed-Selektion. Haiku legt Eigennamen (z.B. "Qilin")
   in die en-Liste, die ja-Liste hatte nur Allgemeinbegriffe — die ja-Query
   suchte ohne "Qilin". build_news_search_feeds stellt nicht-englischen
   Sprach-Queries jetzt die 2 wichtigsten en-Keywords voran (Eigennamen
   kommen auch in fremdsprachigen Artikeln lateinisch vor). Damit ist schon
   der erste Refresh spezifisch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:13:30 +02:00
51276af97a Publisher aus GNews source-Tag (#34) 2026-05-22 01:19:05 +02:00
4e9d9f92f1 fix(rss): echten Publisher aus Google-News <source>-Tag uebernehmen
Google-News-Feeds (Site-Search wie auch der neue Volltext-Suchfeed) buendeln
Artikel vieler echter Publisher unter einer Feed-URL. Bisher bekamen alle
Artikel den generischen Feed-Namen als 'source' — der Faktencheck zaehlte
damit 25 Artikel verschiedener Zeitungen als EINE Quelle, und die
Quellenuebersicht war unbrauchbar.

Fix: Bei news.google.com-Feeds wird der echte Publisher aus dem <source>-Tag
des Feed-Items uebernommen (feedparser: entry.source.title). Fallback: der
Publisher-Teil hinter dem letzten ' - ' im Google-News-Titel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:18:50 +02:00
14b98b59e0 Recall: GNews-Suchfeeds (#33) 2026-05-22 01:02:58 +02:00
0e4c78d50a feat(recall): dynamische Google-News-Volltext-Suchfeeds pro Lage
Recall-Problem: Die Pipeline durchsuchte nur ~28 feste site:-RSS-Feeds plus
Claude-WebSearch. Japanische Security-Vendor-Blogs, Fachportale und
Regionalmedien (Cybertrust, ITmedia, INTERNET Watch, Reuters Japan ...)
tauchten in keinem festen Feed auf. Bei der Test-Lage "Qilin Ransomware
Japan" fand die Pipeline 20 Kandidaten — eine generische Google-News-JP-
Suche zum selben Thema liefert 49.

Fix: researcher.build_news_search_feeds baut pro Refresh einen Google-News-
Volltext-Suchfeed je Sprache (news.google.com/rss/search?q=keywords&hl=..&gl=..).
Query = Top-4-Keywords der jeweiligen Sprache aus der Keyword-Extraktion.
Der Orchestrator haengt diese Feeds an die selektierten site:-Feeds an; sie
laufen durch dieselbe Pipeline (Keyword-Match, Pre-Topic-Translate,
Topic-Filter). Precision bleibt, Recall steigt.

- researcher.py: build_news_search_feeds + _GNEWS_LOCALE-Tabelle.
- orchestrator._rss_pipeline: Suchfeeds aus source_language_whitelist
  (jp_demo: ['ja']) bzw. output+research_language (normale Orgs) gebaut
  und an selected_feeds angehaengt.
- rss_parser._apply_domain_cap: Suchfeeds (domain 'google-news-search-<lang>')
  bekommen Cap 25 statt 10 — sie sind der Recall-Treiber, Topic-Filter
  uebernimmt die Precision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:02:47 +02:00
25 geänderte Dateien mit 1096 neuen und 79 gelöschten Zeilen

Datei anzeigen

@@ -1,4 +1,20 @@
[
{
"version": "2026-05-22T19:10Z",
"date": "2026-05-22",
"title": "Exportdialog: Ersteller manuell eintragbar",
"items": [
"Im Export-Dialog kann der Ersteller jetzt manuell eingegeben werden."
]
},
{
"version": "2026-05-22T07:41Z",
"date": "2026-05-22",
"title": "X (Twitter) als neue Informationsquelle verfügbar",
"items": [
"Nachrichten und Beiträge von X (Twitter) können jetzt als Quelle für Lageberichte genutzt werden."
]
},
{
"version": "2026-05-21T17:10Z",
"date": "2026-05-21",

Datei anzeigen

@@ -11,6 +11,8 @@ python-multipart
aiosmtplib
geonamescache>=2.0
telethon
# X/Twitter-Scraper (feeds/x_parser.py)
twscrape @ git+https://github.com/vladkens/twscrape.git@206f0942fe41149da28530399f7c772ec00be17a
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
Jinja2>=3.1
weasyprint>=68.0

Datei anzeigen

@@ -124,7 +124,7 @@ BISHERIGE QUELLEN:
AUFTRAG:
1. Aktualisiere das Lagebild basierend auf den neuen Meldungen. Das Lagebild soll so ausführlich wie nötig sein, um alle wesentlichen Themenstränge abzudecken
2. Behalte bestätigte Fakten aus dem bisherigen Lagebild bei
3. Ergänze neue Erkenntnisse und markiere wichtige neue Entwicklungen
3. Arbeite neue Erkenntnisse direkt in den thematisch passenden Abschnitt ein. Erzeuge KEINE datierten Verlaufsblöcke wie "Neu am DD.MM." oder "Neu seit ...". Das Lagebild ist eine zusammenhängende thematische Darstellung des AKTUELLEN Stands, kein chronologisches Änderungsprotokoll. Die zeitliche Abfolge der jüngsten Ereignisse wird separat in der Kachel "Neueste Entwicklungen" gepflegt und darf hier NICHT als Datums-Changelog dupliziert werden
4. Aktualisiere die Quellenverweise — neue Quellen bekommen fortlaufende Nummern nach den bisherigen
5. Entferne nur nachweislich widerlegte Informationen. Behalte alle thematischen Abschnitte bei, auch wenn sie nicht durch neue Meldungen aktualisiert werden
@@ -133,6 +133,8 @@ STRUKTUR:
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
- KEIN Fettdruck (**) verwenden
- ERZEUGE KEINE Sektion "## ZUSAMMENFASSUNG", "## ÜBERBLICK" oder "## KERNPUNKTE". Falls das BISHERIGE LAGEBILD eine solche Sektion enthält, ENTFERNE sie vollständig beim Aktualisieren. Die neuesten Entwicklungen werden separat als eigene Kachel gepflegt und dürfen im Lagebild NICHT dupliziert werden.
- KEINE datierten Verlaufsmarker im Lagebild. Einleitungen wie "Neu am 31.05./01.06.:", "Neu seit gestern:" oder vergleichbare Datums-Changelog-Phrasen sind nicht erlaubt. Falls das BISHERIGE LAGEBILD solche Blöcke enthält, LÖSE SIE AUF: integriere ihren Inhalt in den thematisch passenden Abschnitt und ENTFERNE die "Neu am"-Einleitung samt reiner Datumsgruppierung restlos. Innerhalb eines Abschnitts steht der aktuelle Stand vorne, ältere Belege werden im Fließtext zeitlich eingeordnet (z.B. "Ende Mai berichtete ...").
- KEINE stichwortartigen Fragmente und KEINE blanken Quellennummern-Sammlungen. Verboten sind Telegramm-Verkürzungen wie "Teheran-Bluff-Vorwurf [2897]. NYT-Abraham-Accords [2890]." sowie Auffangblöcke ohne Aussage wie "Frühere Belege [2806][2807]...". Jede Quellennummer muss an einem vollständigen, eigenständigen Satz hängen. Falls das BISHERIGE LAGEBILD solche Fragment- oder Sammelblöcke enthält, formuliere sie zu vollständigen Sätzen aus oder lass die betreffende Quellennummer weg. Am Ende eines Abschnitts oder des Lagebildes darf KEINE reine Aufzählung von Quellennummern stehen.
REGELN:
- Neutral und sachlich - keine Wertungen oder Spekulationen

Datei anzeigen

@@ -31,6 +31,28 @@ def _get_geonamescache():
return _gc
# Geografische Zentren (Centroids) der Laender, keyed nach ISO-2-Code.
# Wird genutzt, wenn ein Artikel ein LAND nennt (kein konkreter Ort). Vorher
# wurde dem Land die Hauptstadt zugewiesen — das stapelte z.B. alle "Japan"-
# Marker exakt auf Tokyo und suggerierte faelschlich ein Ereignis in der
# Hauptstadt. Das Centroid liegt in der Landesmitte und ist neutral.
# Laender, die hier fehlen, fallen auf die Hauptstadt zurueck (alte Logik).
_COUNTRY_CENTROIDS = {
"AF": (33.94, 67.71), "AT": (47.52, 14.55), "AZ": (40.14, 47.58),
"CH": (46.82, 8.23), "CN": (35.86, 104.20), "CY": (35.13, 33.43),
"DE": (51.17, 10.45), "EG": (26.82, 30.80), "ES": (40.46, -3.75),
"FR": (46.23, 2.21), "GB": (54.70, -3.28), "GR": (39.07, 21.82),
"IL": (31.05, 34.85), "IN": (20.59, 78.96), "IQ": (33.22, 43.68),
"IR": (32.43, 53.69), "IT": (41.87, 12.57), "JO": (30.59, 36.24),
"JP": (36.20, 138.25), "KP": (40.34, 127.51), "KR": (35.91, 127.77),
"KW": (29.31, 47.48), "LB": (33.85, 35.86), "NL": (52.13, 5.29),
"OM": (21.47, 55.98), "PK": (30.38, 69.35), "PS": (31.95, 35.23),
"QA": (25.32, 51.18), "RU": (61.52, 105.32), "SA": (23.89, 45.08),
"SY": (34.80, 38.997), "TR": (38.96, 35.24), "UA": (48.38, 31.17),
"US": (39.83, -98.58), "YE": (15.55, 48.52), "TW": (23.80, 121.00),
}
# Bekannte Laendernamen (deutsch/englisch/alternativ -> ISO-2 Code + Hauptstadt-Koordinaten)
_COUNTRY_ALIASES = {
"libanon": {"code": "LB", "name": "Lebanon", "lat": 33.8938, "lon": 35.5018},
@@ -106,9 +128,12 @@ def _geocode_offline(name: str, country_code: str = "") -> Optional[dict]:
# 1. Bekannte Laender-Aliase (schnellster + sicherster Pfad)
alias = _COUNTRY_ALIASES.get(name_lower)
if alias:
# Land -> geografisches Zentrum (Centroid) statt Hauptstadt, wo bekannt.
centroid = _COUNTRY_CENTROIDS.get(alias["code"])
lat, lon = centroid if centroid else (alias["lat"], alias["lon"])
return {
"lat": alias["lat"],
"lon": alias["lon"],
"lat": lat,
"lon": lon,
"country_code": alias["code"],
"normalized_name": alias["name"],
"confidence": 0.95,
@@ -118,9 +143,20 @@ def _geocode_offline(name: str, country_code: str = "") -> Optional[dict]:
countries = gc.get_countries()
for code, country in countries.items():
if country.get("name", "").lower() == name_lower:
# Land -> Centroid (Landesmitte), wo bekannt. Das verhindert, dass
# alle "Japan"-Marker exakt auf Tokyo gestapelt werden.
centroid = _COUNTRY_CENTROIDS.get(code)
if centroid:
return {
"lat": centroid[0],
"lon": centroid[1],
"country_code": code,
"normalized_name": country["name"],
"confidence": 0.9,
}
# Kein Centroid hinterlegt -> Fallback auf die Hauptstadt.
capital = country.get("capital", "")
if capital:
# Hauptstadt geocoden, aber als Land benennen
cap_alias = _COUNTRY_ALIASES.get(capital.lower())
if cap_alias:
return {

Datei anzeigen

@@ -34,6 +34,7 @@ CATEGORY_REPUTATION = {
"international": 0.75, # CNN, Guardian, NYT, Al Jazeera, France24
"regional": 0.65, # regionale Tageszeitungen
"telegram": 0.5, # OSINT-Kanaele — gemischte Qualitaet
"x": 0.4, # X/Twitter-Accounts, hohes Rauschen
"sonstige": 0.4, # unkategorisiert
"boulevard": 0.3, # Bild, Sun etc.
}
@@ -750,6 +751,7 @@ class AgentOrchestrator:
# Einschraenkung passiert in get_feeds_with_metadata.
# Hinweis: source_lang_whitelist wird weiter unten geladen.
include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False
include_x = bool(incident["include_x"]) if "include_x" in incident.keys() else False
visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
created_by = incident["created_by"] if "created_by" in incident.keys() else None
tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
@@ -922,7 +924,32 @@ class AgentOrchestrator:
# Feed-Selektion-Keywords nur als Fallback wenn dynamische fehlen
if not keywords:
keywords = feed_sel_keywords
articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords)
# --- Recall-Boost: dynamische Google-News-Volltext-Suchfeeds ---
# Statt nur feste site:-Feeds zu durchsuchen, baut die Pipeline
# pro Sprache einen Google-News-Suchfeed aus den Keywords. Damit
# erreichen wir Quellen, die in keinem festen Feed stehen
# (Vendor-Blogs, Fachportale, Regionalmedien).
from agents.researcher import build_news_search_feeds
if source_lang_whitelist:
_gnews_langs = list(source_lang_whitelist)
else:
_gnews_langs = list({output_language_iso, research_language_iso})
# Zwei Sets: ein Kontext-Feed (alle Zeiten) + ein Frische-Feed
# (when:14d). Der Frische-Feed garantiert, dass das aktuelle
# Bild eingefangen wird, auch wenn aeltere Artikel relevanter
# ranken. Beide laufen durch dieselbe Pipeline; Dedup entfernt
# Ueberschneidungen.
_gnews_feeds = build_news_search_feeds(keywords, _gnews_langs)
_gnews_recent = build_news_search_feeds(keywords, _gnews_langs, recency_days=14)
_all_gnews = _gnews_feeds + _gnews_recent
if _all_gnews:
logger.info(
f"Google-News-Suchfeeds ergaenzt: {len(_gnews_feeds)} Kontext "
f"+ {len(_gnews_recent)} Frische (when:14d)"
)
articles = await rss_parser.search_feeds_selective(
title, selected_feeds + _all_gnews, keywords=keywords,
)
else:
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id)
@@ -1053,20 +1080,67 @@ class AgentOrchestrator:
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
return articles, None
async def _x_pipeline():
"""X-Account-Suche (Twitter) mit KI-basierter Account-Selektion."""
from feeds.x_parser import XParser
x_parser = XParser()
# Alle X-Accounts laden
all_accounts = await x_parser._get_x_accounts(tenant_id=tenant_id)
if not all_accounts:
logger.info("Keine X-Accounts konfiguriert")
return [], None
# KI waehlt relevante Accounts aus
x_researcher = ResearcherAgent()
selected_accounts, x_sel_usage = await x_researcher.select_relevant_x_accounts(
title, description, all_accounts
)
if x_sel_usage:
usage_acc.add(x_sel_usage)
selected_ids = [acc["id"] for acc in selected_accounts]
logger.info(f"X-Selektion: {len(selected_ids)} von {len(all_accounts)} Accounts")
# Dynamische Keywords fuer X (eigener Aufruf, da parallel zu RSS)
cursor_x_hl = await db.execute(
"""SELECT COALESCE(headline_de, headline) as hl
FROM articles WHERE incident_id = ?
AND COALESCE(headline_de, headline) IS NOT NULL
ORDER BY collected_at DESC LIMIT 30""",
(incident_id,),
)
x_headlines = [row["hl"] for row in await cursor_x_hl.fetchall() if row["hl"]]
x_keywords, x_kw_usage = await x_researcher.extract_dynamic_keywords(title, x_headlines)
if x_kw_usage:
usage_acc.add(x_kw_usage)
articles = await x_parser.search_accounts(
title, tenant_id=tenant_id, keywords=x_keywords, account_ids=selected_ids
)
logger.info(f"X-Pipeline: {len(articles)} Posts")
return articles, None
# Pipeline-Schritt 2: Nachrichten sammeln (Start)
await _pipe_start("collect")
# Pipelines parallel starten (RSS + WebSearch + Podcasts + optional Telegram)
# Pipelines parallel starten (RSS + WebSearch + Podcasts + optional Telegram/X)
pipelines = [_rss_pipeline(), _web_search_pipeline(), _podcast_pipeline()]
telegram_idx = x_idx = None
if include_telegram:
telegram_idx = len(pipelines)
pipelines.append(_telegram_pipeline())
if include_x:
x_idx = len(pipelines)
pipelines.append(_x_pipeline())
pipeline_results = await asyncio.gather(*pipelines)
(rss_articles, rss_feed_usage) = pipeline_results[0]
(search_results, search_usage, search_parse_failed) = pipeline_results[1]
(podcast_articles, _podcast_usage) = pipeline_results[2]
telegram_articles = pipeline_results[3][0] if include_telegram else []
telegram_articles = pipeline_results[telegram_idx][0] if telegram_idx is not None else []
x_articles = pipeline_results[x_idx][0] if x_idx is not None else []
# Podcast-Artikel in die RSS-Liste einfuegen (gleicher Downstream-Pfad)
if podcast_articles:
@@ -1085,7 +1159,7 @@ class AgentOrchestrator:
self._check_cancelled(incident_id)
# Alle Ergebnisse zusammenführen
all_results = rss_articles + search_results + telegram_articles
all_results = rss_articles + search_results + telegram_articles + x_articles
# Pipeline-Schritt 2: Nachrichten sammeln (fertig)
try:
_delivering_sources = len({a.get("source", "") for a in all_results if a.get("source")})
@@ -1679,6 +1753,7 @@ class AgentOrchestrator:
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
_translate_step_started = False
try:
tr_cursor = await db.execute(
"""SELECT id, headline, content_original, language
@@ -1690,7 +1765,10 @@ class AgentOrchestrator:
(incident_id,),
)
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
if pending_translations:
if pending_translations and translator_enabled:
# Pipeline-Schritt 9: Artikel uebersetzen (nur sichtbar wenn was zu uebersetzen)
await _pipe_start("translate")
_translate_step_started = True
logger.info(
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
incident_id, len(pending_translations),
@@ -1721,8 +1799,11 @@ class AgentOrchestrator:
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
incident_id, len(translations), len(pending_translations),
)
await _pipe_done("translate", count_value=len(translations), count_secondary=len(pending_translations))
except Exception as e:
logger.error("Translator-Fehler fuer Incident %d: %s", incident_id, e, exc_info=True)
if _translate_step_started:
await _pipe_done("translate", count_value=0, count_secondary=0)
# Refresh trotz Translator-Fehler weiterlaufen lassen
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---

Datei anzeigen

@@ -2,12 +2,131 @@
import json
import logging
import re
import urllib.parse
from agents.claude_client import call_claude, ClaudeUsage
from config import CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.researcher")
# Google-News-Locale pro ISO-Sprachcode: (hl, gl). ceid wird daraus gebaut.
_GNEWS_LOCALE = {
"ja": ("ja", "JP"),
"de": ("de", "DE"),
"en": ("en-US", "US"),
"ru": ("ru", "RU"),
"ko": ("ko", "KR"),
"zh": ("zh-CN", "CN"),
"fr": ("fr", "FR"),
"es": ("es", "ES"),
"it": ("it", "IT"),
"ar": ("ar", "EG"),
"he": ("iw", "IL"),
"fa": ("fa", "IR"),
}
def build_news_search_feeds(
keywords_by_lang: dict | list | None,
languages: list[str],
max_keywords: int = 4,
recency_days: int | None = None,
) -> list[dict]:
"""Baut dynamische Google-News-Volltext-Such-Feeds pro Sprache.
Statt nur feste site:-RSS-Feeds zu durchsuchen, erzeugt diese Funktion pro
Sprache einen Google-News-Suchfeed (news.google.com/rss/search?q=...). Damit
erreicht die Pipeline auch Quellen, die in keinem festen Feed stehen
(Security-Vendor-Blogs, Fachportale, Regionalmedien). Der Recall steigt
massiv; die Precision bleibt, weil der nachgelagerte Topic-Filter unveraendert
greift.
Args:
keywords_by_lang: Sprach-Dict {iso: [keyword,...]} aus der Keyword-Extraktion.
languages: ISO-Codes, fuer die ein Suchfeed gebaut werden soll.
max_keywords: wie viele (spezifischste) Keywords in die Such-Query gehen.
recency_days: wenn gesetzt, wird der Google-News-Operator "when:Nd" an die
Query gehaengt — der Feed liefert dann nur Artikel der letzten N Tage.
Fuer "Frische-Suchfeeds", die das aktuelle Bild garantiert einfangen.
Returns:
Liste von Feed-Config-Dicts (kompatibel mit RSSParser._fetch_feed).
"""
if not keywords_by_lang or not isinstance(keywords_by_lang, dict):
return []
feeds: list[dict] = []
seen_queries: set[str] = set()
for lang in languages:
lang_key = (lang or "").lower().strip()
locale = _GNEWS_LOCALE.get(lang_key)
if not locale:
continue
lang_kws = [str(k).strip() for k in (keywords_by_lang.get(lang_key) or []) if str(k).strip()]
en_kws = [str(k).strip() for k in (keywords_by_lang.get("en") or []) if str(k).strip()]
if lang_key == "en":
query_terms = en_kws[:max_keywords]
else:
# Fuer nicht-englische Sprachen: die ersten 2 englischen Keywords
# voranstellen. Haiku ordnet Eigennamen/Akronyme (z.B. "Qilin",
# "Asahi") nach vorne — und die kommen auch in fremdsprachigen
# Artikeln lateinisch vor. Ohne das fehlt beim ersten Refresh (noch
# keine Headlines-Historie) der entscheidende Eigenname in der Query.
# Danach 3 sprach-spezifische Keywords.
query_terms = en_kws[:2] + lang_kws[:3]
# Wenn fuer die Sprache gar keine Keywords da sind: ganz auf en.
if not lang_kws:
query_terms = en_kws[:max_keywords]
# Dedup, Reihenfolge erhalten
seen_terms: set[str] = set()
deduped: list[str] = []
for t in query_terms:
tl = t.lower()
if tl in seen_terms:
continue
seen_terms.add(tl)
deduped.append(t)
if not deduped:
continue
query = " ".join(deduped)
# when:Nd-Operator anhaengen (Google-News-Zeitfilter)
effective_query = query
if recency_days and recency_days > 0:
effective_query = f"{query} when:{recency_days}d"
if not effective_query or effective_query in seen_queries:
continue
seen_queries.add(effective_query)
hl, gl = locale
ceid_lang = hl.split("-")[0]
url = (
"https://news.google.com/rss/search?q="
+ urllib.parse.quote(effective_query)
+ f"&hl={hl}&gl={gl}&ceid={gl}:{ceid_lang}"
)
if recency_days and recency_days > 0:
name = f"Google News Suche ({lang_key}, letzte {recency_days}d): {query}"
domain = f"google-news-search-{lang_key}-recent"
else:
name = f"Google News Suche ({lang_key}): {query}"
domain = f"google-news-search-{lang_key}"
feeds.append({
"name": name,
"url": url,
# Eigene Domain-Gruppe, damit der Domain-Cap die Such-Feeds NICHT mit
# den site:-Google-News-Feeds in einen Topf wirft.
"domain": domain,
"primary_language": lang_key,
"category": "international",
"media_type": "",
})
logger.info("Google-News-Suchfeed (%s): q=%r", lang_key, effective_query)
return feeds
class ResearcherParseError(Exception):
"""Claude hat eine nicht-leere Antwort geliefert, aus der kein JSON extrahiert werden konnte."""
@@ -377,6 +496,24 @@ REGELN:
Antworte NUR mit einem JSON-Array der Kanal-Nummern, z.B.: [1, 3, 5, 12]"""
X_ACCOUNT_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von X-Accounts (Twitter) diejenigen aus, die fuer die Lage relevant sein koennten.
LAGE: {title}
KONTEXT: {description}
X-ACCOUNTS:
{account_list}
REGELN:
- Waehle alle Accounts die thematisch relevant sein koennten
- Lieber einen Account zu viel als zu wenig auswaehlen
- Beachte die Kategorie und Beschreibung jedes Accounts
- Allgemeine OSINT-Accounts sind oft relevant
- Bei geopolitischen Themen: Relevante Laender-/Regions-Accounts waehlen
Antworte NUR mit einem JSON-Array der Account-Nummern, z.B.: [1, 3, 5, 12]"""
class ResearcherAgent:
"""Führt OSINT-Recherchen über Claude CLI WebSearch durch."""
@@ -897,3 +1034,62 @@ class ResearcherAgent:
logger.warning("Telegram-Selektion fehlgeschlagen (%s), nutze alle Kanaele", e)
return channels_metadata, None
async def select_relevant_x_accounts(
self,
title: str,
description: str,
accounts_metadata: list[dict],
) -> tuple[list[dict], ClaudeUsage | None]:
"""Laesst Claude die relevanten X-Accounts fuer eine Lage vorauswaehlen.
Nutzt Haiku (CLAUDE_MODEL_FAST) fuer diese einfache Aufgabe.
Returns:
(ausgewaehlte Accounts, usage) -- Bei Fehler: (alle Accounts, None)
"""
if len(accounts_metadata) <= 10:
logger.info("X-Selektion: Nur %d Accounts, nutze alle", len(accounts_metadata))
return accounts_metadata, None
account_lines = []
for i, acc in enumerate(accounts_metadata, 1):
cat = acc.get("category", "sonstige")
notes = (acc.get("notes") or "")[:100]
account_lines.append(f"{i}. {acc['name']} [{cat}] - {notes}")
prompt = X_ACCOUNT_SELECTION_PROMPT.format(
title=title,
description=description or "Keine weitere Beschreibung",
account_list="\n".join(account_lines),
)
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
indices = _extract_json_array(result)
if not isinstance(indices, list):
logger.warning(
"X-Selektion: Kein JSON in Antwort, nutze alle Accounts. Sample: %s",
_truncate_for_log(result),
)
return accounts_metadata, usage
selected = []
for idx in indices:
if isinstance(idx, int) and 1 <= idx <= len(accounts_metadata):
selected.append(accounts_metadata[idx - 1])
if not selected:
logger.warning("X-Selektion: Keine gueltigen Indizes, nutze alle Accounts")
return accounts_metadata, usage
logger.info(
"X-Selektion: %d von %d Accounts ausgewaehlt",
len(selected), len(accounts_metadata)
)
return selected, usage
except Exception as e:
logger.warning("X-Selektion fehlgeschlagen (%s), nutze alle Accounts", e)
return accounts_metadata, None

Datei anzeigen

@@ -97,6 +97,19 @@ TELEGRAM_API_ID = int(os.environ.get("TELEGRAM_API_ID", "0"))
TELEGRAM_API_HASH = os.environ.get("TELEGRAM_API_HASH", "")
TELEGRAM_SESSION_PATH = os.environ.get("TELEGRAM_SESSION_PATH", "/home/claude-dev/.telegram/telegram_session")
# X / Twitter (twscrape) -- siehe feeds/x_parser.py
# Scraper liest Account-Timelines konfigurierter X-Quellen (source_type='x_account').
X_SCRAPER_ENABLED = os.environ.get("X_SCRAPER_ENABLED", "true").lower() == "true"
# twscrape-Account-Store (SQLite). Liegt ausserhalb des Repos.
X_ACCOUNTS_DB_PATH = os.environ.get("X_ACCOUNTS_DB_PATH", "/home/claude-dev/.x-scraper/accounts.db")
# HTTP-Proxy fuer den X-Egress (tinyproxy am RUTX11 ueber WireGuard).
# Leer = direkter Abruf ueber die Server-IP. Bei gesetztem Wert prueft der
# Parser den Proxy vor jedem Lauf und faellt bei Ausfall auf direkt zurueck.
X_PROXY_URL = os.environ.get("X_PROXY_URL", "")
# Max. Posts pro Account-Timeline und Recency-Fenster in Tagen.
X_POST_CAP_PER_ACCOUNT = int(os.environ.get("X_POST_CAP_PER_ACCOUNT", "40"))
X_RECENCY_DAYS = int(os.environ.get("X_RECENCY_DAYS", "14"))
# Health-Check (genutzt von services/source_health.py)
HEALTH_CHECK_USER_AGENT = os.environ.get(
"HEALTH_CHECK_USER_AGENT",

Datei anzeigen

@@ -403,6 +403,11 @@ async def init_db():
await db.commit()
logger.info("Migration: include_telegram zu incidents hinzugefuegt")
if "include_x" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN include_x INTEGER DEFAULT 0")
await db.commit()
logger.info("Migration: include_x zu incidents hinzugefuegt")
if "telegram_categories" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL")
await db.commit()

Datei anzeigen

@@ -6,6 +6,11 @@ import httpx
from datetime import datetime, timezone
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
from source_rules import _extract_domain
# Cap fuer dynamische Google-News-Suchfeeds — hoeher als der normale Domain-Cap,
# weil ein Suchfeed gezielt fuer breiten Recall gebaut wird. Topic-Filter
# entscheidet danach ueber die Precision.
MAX_ARTICLES_PER_DOMAIN_RSS_SEARCH = 25
from feeds.transcript_extractors._common import html_to_text
from services.post_refresh_qc import normalize_german_umlauts
from agents.researcher import keywords_for_language, flatten_keywords
@@ -171,6 +176,11 @@ class RSSParser:
name = feed_config["name"]
url = feed_config["url"]
articles = []
# Google-News-Feeds (Site-Search ODER Volltext-Suche) buendeln Artikel
# vieler echter Publisher. Pro Item steht der echte Publisher im
# <source>-Tag — den nutzen wir als source-Name, sonst zaehlt der
# Faktencheck 25 Artikel als "eine Quelle".
_is_google_news = "news.google.com" in (url or "")
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
@@ -208,19 +218,54 @@ class RSSParser:
if match_count >= min_matches:
published = None
published_dt = None
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).astimezone(TIMEZONE).isoformat()
published_dt = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
published = published_dt.astimezone(TIMEZONE).isoformat()
except (TypeError, ValueError):
pass
# Relevanz-Score: Anteil der gematchten Suchworte (0.0-1.0)
relevance_score = match_count / len(search_words) if search_words else 0.0
# Aktualitaets-Bonus/Malus: frische Artikel sollen den
# Domain-Cap (sortiert nach relevance_score) ueberleben und
# nicht von Monate alten verdraengt werden. Damit faengt die
# Pipeline das aktuelle Bild ein. Nur adhoc-Pfad — research
# nutzt diesen Code nicht.
if published_dt is not None:
age_days = (datetime.now(timezone.utc) - published_dt).days
if age_days <= 3:
relevance_score += 0.35
elif age_days <= 14:
relevance_score += 0.20
elif age_days <= 60:
relevance_score += 0.05
elif age_days > 365:
relevance_score -= 0.30
elif age_days > 180:
relevance_score -= 0.15
# Bei Google-News-Feeds: echten Publisher aus <source>-Tag holen
article_source = name
if _is_google_news:
src_obj = entry.get("source")
src_title = ""
if isinstance(src_obj, dict):
src_title = (src_obj.get("title") or "").strip()
elif src_obj:
src_title = str(getattr(src_obj, "title", "") or "").strip()
if src_title:
article_source = src_title
else:
# Google-News-Titel enden oft mit " - Publishername"
if " - " in title:
article_source = title.rsplit(" - ", 1)[-1].strip() or name
articles.append({
"headline": title,
"headline_de": title if self._is_german(title) else None,
"source": name,
"source": article_source,
"source_url": entry.get("link", ""),
# Die Quell-Domain aus der DB (z.B. "mod.go.jp"), nicht aus
# der URL — relevant für Google-News-RSS-Quellen, deren URLs
@@ -276,10 +321,15 @@ class RSSParser:
for domain, domain_articles in by_domain.items():
# Nach Relevanz sortieren (beste zuerst)
domain_articles.sort(key=lambda a: a.get("relevance_score", 0), reverse=True)
kept = domain_articles[:MAX_ARTICLES_PER_DOMAIN_RSS]
if len(domain_articles) > MAX_ARTICLES_PER_DOMAIN_RSS:
# Dynamische Google-News-Suchfeeds ("google-news-search-<lang>") sind
# der Recall-Treiber und bekommen einen hoeheren Cap als feste Feeds.
cap = (MAX_ARTICLES_PER_DOMAIN_RSS_SEARCH
if domain.startswith("google-news-search-")
else MAX_ARTICLES_PER_DOMAIN_RSS)
kept = domain_articles[:cap]
if len(domain_articles) > cap:
logger.info(
f"Domain-Cap: {domain} von {len(domain_articles)} auf {MAX_ARTICLES_PER_DOMAIN_RSS} Artikel begrenzt"
f"Domain-Cap: {domain} von {len(domain_articles)} auf {cap} Artikel begrenzt"
)
capped.extend(kept)

320
src/feeds/x_parser.py Normale Datei
Datei anzeigen

@@ -0,0 +1,320 @@
"""X (Twitter) Parser: Liest Posts aus konfigurierten X-Accounts via twscrape.
Egress laeuft -- wenn X_PROXY_URL gesetzt -- ueber den HTTP-Proxy am RUTX11
(Mobilfunk-IP). Faellt der Proxy aus, wird direkt ueber die Server-IP
abgerufen (Fallback). Gibt Artikel-Dicts im RSS-/Telegram-kompatiblen Format
zurueck.
"""
import asyncio
import logging
import os
from datetime import datetime, timezone, timedelta
import httpx
from config import (
TIMEZONE, X_ACCOUNTS_DB_PATH, X_PROXY_URL,
X_POST_CAP_PER_ACCOUNT, X_RECENCY_DAYS, X_SCRAPER_ENABLED,
)
logger = logging.getLogger("osint.x")
# Stoppwoerter (gleich wie RSS-/Telegram-Parser)
STOP_WORDS = {
"und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an",
"auf", "fuer", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor",
"ueber", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from",
}
def _normalize_handle(raw: str) -> str:
"""X-Handle aus URL-/@-Form auf den nackten Benutzernamen normalisieren."""
h = (raw or "").strip()
for prefix in ("https://", "http://"):
if h.startswith(prefix):
h = h[len(prefix):]
for prefix in ("www.", "x.com/", "twitter.com/", "nitter.net/"):
if h.startswith(prefix):
h = h[len(prefix):]
h = h.lstrip("@").strip("/")
# Pfad-/Query-Reste abschneiden (z.B. handle/status/123 oder handle?lang=de)
for sep in ("/", "?"):
if sep in h:
h = h.split(sep)[0]
return h
class XParser:
"""Durchsucht konfigurierte X-Accounts nach relevanten Posts."""
async def _resolve_proxy(self) -> tuple[str | None, str | None]:
"""Proxy-Strategie aufloesen.
Returns (proxy_url, egress_ip):
- X_PROXY_URL leer -> (None, None): direkter Abruf ueber Server-IP.
- X_PROXY_URL gesetzt und erreichbar -> (proxy, egress_ip).
- X_PROXY_URL gesetzt aber tot -> (None, None): Fallback direkt + Warnung.
"""
if not X_PROXY_URL:
return None, None
try:
async with httpx.AsyncClient(proxy=X_PROXY_URL, timeout=8.0) as client:
resp = await client.get("https://api.ipify.org")
resp.raise_for_status()
egress_ip = resp.text.strip()
logger.info("X-Egress ueber Proxy %s aktiv (IP: %s)", X_PROXY_URL, egress_ip)
return X_PROXY_URL, egress_ip
except Exception as e:
logger.warning(
"X-Proxy %s nicht erreichbar (%s) -- Fallback auf direkte Server-IP",
X_PROXY_URL, e,
)
return None, None
async def _get_api(self, proxy: str | None):
"""twscrape-API-Objekt erstellen.
Gibt None zurueck wenn der Account-Store fehlt oder keine
nutzbaren Accounts vorhanden sind.
"""
if not os.path.exists(X_ACCOUNTS_DB_PATH):
logger.error("X-Account-Store nicht gefunden: %s", X_ACCOUNTS_DB_PATH)
return None
try:
from twscrape import API
except ImportError:
logger.error("twscrape nicht installiert: pip install twscrape")
return None
try:
api = API(X_ACCOUNTS_DB_PATH, proxy=proxy)
# Account-Pool pruefen -- ohne aktive Accounts liefert twscrape nichts
try:
accounts = await api.pool.get_all()
active = [a for a in accounts if getattr(a, "active", True)]
if not accounts:
logger.error("X-Account-Pool leer -- keine Accounts konfiguriert")
return None
if not active:
logger.error(
"X-Account-Pool: alle %d Accounts inaktiv/gesperrt", len(accounts)
)
return None
logger.info("X-Account-Pool: %d/%d Accounts aktiv", len(active), len(accounts))
except Exception as e:
# Pool-Status nicht ermittelbar -- trotzdem weiterversuchen
logger.debug("X-Account-Pool-Status nicht ermittelbar: %s", e)
return api
except Exception as e:
logger.error("X-API-Initialisierung fehlgeschlagen: %s", e)
return None
async def search_accounts(self, search_term: str, tenant_id: int = None,
keywords: dict | list = None,
account_ids: list[int] = None) -> list[dict]:
"""Liest Posts aus konfigurierten X-Accounts.
Args:
keywords: Sprach-Dict {iso_lang: [keyword,...]} oder flache Liste.
Match nutzt pro Account die "en"-Universalbegriffe + die
Keywords der Account-Sprache (primary_language aus sources).
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-/Telegram-Format).
"""
if not X_SCRAPER_ENABLED:
logger.info("X-Scraper deaktiviert (X_SCRAPER_ENABLED=false)")
return []
from agents.researcher import keywords_for_language
accounts = await self._get_x_accounts(tenant_id, account_ids=account_ids)
if not accounts:
logger.info("Keine X-Accounts konfiguriert")
return []
proxy, _egress_ip = await self._resolve_proxy()
api = await self._get_api(proxy)
if not api:
logger.warning("X-API nicht verfuegbar, ueberspringe X-Pipeline")
return []
# Fallback-Suchwoerter wenn keine Keywords da sind
fallback_words: list[str] | None = None
if not keywords:
fallback_words = [
w for w in search_term.lower().split()
if w not in STOP_WORDS and len(w) >= 3
]
if not fallback_words:
fallback_words = search_term.lower().split()[:2]
cutoff = datetime.now(timezone.utc) - timedelta(days=X_RECENCY_DAYS)
# Accounts parallel abrufen
tasks = []
for acc in accounts:
handle = _normalize_handle(acc["url"] or acc["name"])
acc_lang = acc.get("primary_language")
if keywords:
search_words = [w.lower() for w in keywords_for_language(keywords, acc_lang)]
else:
search_words = fallback_words or []
tasks.append(self._fetch_account(api, handle, search_words, cutoff, acc_lang))
results = await asyncio.gather(*tasks, return_exceptions=True)
all_articles = []
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.warning("X-Account %s: %s", accounts[i]["name"], result)
continue
all_articles.extend(result)
logger.info("X: %d relevante Posts aus %d Accounts", len(all_articles), len(accounts))
return all_articles
async def _get_x_accounts(self, tenant_id: int = None,
account_ids: list[int] = None) -> list[dict]:
"""Laedt X-Accounts aus der sources-Tabelle."""
try:
from database import get_db
db = await get_db()
try:
if account_ids and len(account_ids) > 0:
placeholders = ",".join("?" for _ in account_ids)
cursor = await db.execute(
f"""SELECT id, name, url, category, notes, primary_language FROM sources
WHERE source_type = 'x_account'
AND status = 'active'
AND id IN ({placeholders})""",
tuple(account_ids),
)
else:
cursor = await db.execute(
"""SELECT id, name, url, category, notes, primary_language FROM sources
WHERE source_type = 'x_account'
AND status = 'active'
AND (tenant_id IS NULL OR tenant_id = ?)""",
(tenant_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
finally:
await db.close()
except Exception as e:
logger.error("Fehler beim Laden der X-Accounts: %s", e)
return []
async def _fetch_account(self, api, handle: str, search_words: list[str],
cutoff: datetime, account_lang: str | None = None) -> list[dict]:
"""Letzte Posts eines X-Accounts abrufen und nach Keywords filtern."""
from twscrape import gather
articles: list[dict] = []
if not handle:
return articles
try:
user = await api.user_by_login(handle)
if not user:
logger.warning("X-Account @%s nicht gefunden", handle)
return articles
tweets = await gather(api.user_tweets(user.id, limit=X_POST_CAP_PER_ACCOUNT))
for tw in tweets:
# Reine Retweets ueberspringen (Original wird ohnehin erfasst)
if getattr(tw, "retweetedTweet", None) is not None:
continue
text = getattr(tw, "rawContent", None) or ""
# Quote-Tweet: zitierten Text anhaengen, damit Kontext erhalten bleibt
quoted = getattr(tw, "quotedTweet", None)
if quoted is not None:
q_text = getattr(quoted, "rawContent", "") or ""
if q_text:
text = "%s\n\n[Zitiert] %s" % (text, q_text)
if not text.strip():
continue
# Recency-Fenster
tw_date = getattr(tw, "date", None)
if tw_date is not None:
try:
if tw_date < cutoff:
continue
except TypeError:
pass
# Keyword-Matching (lockerer als RSS: 1 Match reicht,
# da Accounts bereits thematisch vorselektiert sind)
text_lower = text.lower()
match_count = sum(1 for w in search_words if w in text_lower)
if search_words and match_count < 1:
continue
lines = text.strip().split("\n")
headline = (lines[0][:200] if lines else text[:200]).strip()
published = None
if tw_date is not None:
try:
published = tw_date.astimezone(TIMEZONE).isoformat()
except Exception:
published = tw_date.isoformat()
source_url = getattr(tw, "url", None) or \
"https://x.com/%s/status/%s" % (handle, getattr(tw, "id", ""))
tw_lang = getattr(tw, "lang", None)
language = account_lang \
or (tw_lang if tw_lang and tw_lang != "und" else None) \
or ("de" if self._is_german(text) else "en")
relevance_score = (match_count / len(search_words)) if search_words else 0.0
articles.append({
"headline": headline,
"headline_de": headline if self._is_german(headline) else None,
"source": "X: @%s" % handle,
"source_url": source_url,
"content_original": text[:2000],
"content_de": text[:2000] if self._is_german(text) else None,
"language": language,
"published_at": published,
"relevance_score": relevance_score,
})
except Exception as e:
logger.warning("X-Account @%s: %s", handle, e)
return articles
async def validate_account(self, handle: str) -> dict | None:
"""Prueft ob ein X-Account erreichbar ist und gibt Account-Info zurueck."""
handle = _normalize_handle(handle)
if not handle:
return None
proxy, _ = await self._resolve_proxy()
api = await self._get_api(proxy)
if not api:
return None
try:
user = await api.user_by_login(handle)
if not user:
return None
return {
"valid": True,
"name": getattr(user, "displayname", None) or handle,
"username": getattr(user, "username", handle),
"description": getattr(user, "rawDescription", "") or "",
"subscribers": getattr(user, "followersCount", None),
}
except Exception as e:
logger.warning("X-Account-Validierung fehlgeschlagen fuer @%s: %s", handle, e)
return None
def _is_german(self, text: str) -> bool:
"""Einfache Heuristik ob ein Text deutsch ist."""
german_words = {"der", "die", "das", "und", "ist", "von", "mit", "fuer", "auf", "ein",
"eine", "den", "dem", "des", "sich", "wird", "nach", "bei", "auch",
"ueber", "wie", "aus", "hat", "zum", "zur", "als", "noch", "mehr",
"nicht", "aber", "oder", "sind", "vor", "einem", "einer", "wurde"}
words = set(text.lower().split())
return len(words & german_words) >= 2

Datei anzeigen

@@ -246,7 +246,14 @@ async def cleanup_expired():
)
logger.info(f"Lage {incident['id']} archiviert (Aufbewahrung abgelaufen)")
# Verwaiste running-Einträge bereinigen (> 15 Minuten ohne Abschluss)
# Verwaiste running-Einträge bereinigen.
# Pruefen auf Pipeline-Fortschritt: legitime Long-Runner (z.B. Translator
# nach summary fuer jp_demo mit 200+ Artikeln ~20 Min) duerfen nicht
# vorzeitig gekillt werden. Ein Refresh gilt als verwaist, wenn entweder
# (a) seit ORPHAN_IDLE_LIMIT Min kein Pipeline-Step Fortschritt zeigte,
# oder (b) das harte Limit ORPHAN_HARD_LIMIT Min ueberschritten wurde.
ORPHAN_IDLE_LIMIT = 60
ORPHAN_HARD_LIMIT = 120
cursor = await db.execute(
"SELECT id, incident_id, started_at FROM refresh_log WHERE status = 'running'"
)
@@ -258,12 +265,46 @@ async def cleanup_expired():
else:
started = started.astimezone(TIMEZONE)
age_minutes = (now - started).total_seconds() / 60
if age_minutes >= 15:
if age_minutes < ORPHAN_IDLE_LIMIT:
continue
# Letzter Pipeline-Step-Fortschritt (Start ODER Ende)
prog_cursor = await db.execute(
"""SELECT MAX(COALESCE(completed_at, started_at)) AS last_activity
FROM refresh_pipeline_steps WHERE refresh_log_id = ?""",
(orphan["id"],),
)
prog_row = await prog_cursor.fetchone()
last_activity_str = prog_row["last_activity"] if prog_row else None
is_orphan = False
reason = None
if age_minutes >= ORPHAN_HARD_LIMIT:
is_orphan = True
reason = f"Verwaist (>{int(age_minutes)} Min, hartes Limit {ORPHAN_HARD_LIMIT} Min)"
elif last_activity_str:
last_activity = datetime.fromisoformat(last_activity_str)
if last_activity.tzinfo is None:
last_activity = last_activity.replace(tzinfo=TIMEZONE)
else:
last_activity = last_activity.astimezone(TIMEZONE)
idle_minutes = (now - last_activity).total_seconds() / 60
if idle_minutes >= ORPHAN_IDLE_LIMIT:
is_orphan = True
reason = (
f"Verwaist (kein Pipeline-Fortschritt seit {int(idle_minutes)} Min, "
f"gesamt {int(age_minutes)} Min)"
)
else:
is_orphan = True
reason = f"Verwaist (keine Pipeline-Schritte nach {int(age_minutes)} Min)"
if is_orphan:
await db.execute(
"UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = ? WHERE id = ?",
(now.strftime('%Y-%m-%d %H:%M:%S'), f"Verwaist (>{int(age_minutes)} Min ohne Abschluss, automatisch bereinigt)", orphan["id"]),
(now.strftime('%Y-%m-%d %H:%M:%S'), reason, orphan["id"]),
)
logger.warning(f"Verwaisten Refresh #{orphan['id']} für Lage {orphan['incident_id']} bereinigt ({int(age_minutes)} Min)")
logger.warning(f"Verwaisten Refresh #{orphan['id']} fuer Lage {orphan['incident_id']} bereinigt: {reason}")
# Alte Notifications bereinigen (> 7 Tage)
await db.execute("DELETE FROM notifications WHERE created_at < datetime('now', '-7 days')")

Datei anzeigen

@@ -57,6 +57,7 @@ class IncidentCreate(BaseModel):
retention_days: int = Field(default=0, ge=0, le=999)
international_sources: bool = False
include_telegram: bool = False
include_x: bool = False
visibility: str = Field(default="public", pattern="^(public|private)$")
@@ -71,6 +72,7 @@ class IncidentUpdate(BaseModel):
retention_days: Optional[int] = Field(default=None, ge=0, le=999)
international_sources: Optional[bool] = None
include_telegram: Optional[bool] = None
include_x: Optional[bool] = None
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
@@ -102,6 +104,7 @@ class IncidentResponse(BaseModel):
public_mood_updated_at: Optional[str] = None
international_sources: bool = True
include_telegram: bool = False
include_x: bool = False
created_by: int
created_by_username: str = ""
created_at: str
@@ -130,6 +133,7 @@ class IncidentListItem(BaseModel):
visibility: str = "public"
international_sources: bool = True
include_telegram: bool = False
include_x: bool = False
created_by: int
created_by_username: str = ""
created_at: str
@@ -142,8 +146,8 @@ class IncidentListItem(BaseModel):
# Sources (Quellenverwaltung)
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$"
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$"
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$"
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige|x)$"
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
class SourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)

Datei anzeigen

@@ -462,8 +462,12 @@ def _build_export_metadata(
organization_name: str | None,
top_locations: list[str] | None,
snapshot_count: int = 0,
include_branding: bool = True,
) -> dict:
"""Einheitlicher Metadaten-Dict fuer PDF (HTML-Meta-Tags) und DOCX (core_properties)."""
"""Einheitlicher Metadaten-Dict fuer PDF (HTML-Meta-Tags) und DOCX (core_properties).
include_branding=False neutralisiert alle AegisSight-Firmenbezeichnungen (White-Label-Export).
"""
is_research = incident.get("type") == "research"
type_label = "Hintergrundrecherche" if is_research else "Live-Monitoring"
category = "OSINT-Hintergrundrecherche" if is_research else "OSINT-Lagebericht"
@@ -546,23 +550,37 @@ def _build_export_metadata(
comments_lines.append("Orte: " + ", ".join(top_locations[:5]))
comments = "\n".join(comments_lines)
publisher = organization_name or "AegisSight"
identifier = f"urn:aegissight:incident:{incident.get('id', '0')}:{now.strftime('%Y%m%dT%H%M%S')}"
rights = (
"Vertrauliche Lageanalyse — AegisSight Monitor. "
"Weitergabe nur an autorisierte Empfänger."
)
# Branding-abhaengige Felder: bei include_branding=False neutralisiert (White-Label-Export)
if include_branding:
publisher = organization_name or "AegisSight"
author = creator or "AegisSight Monitor"
creator_app = "AegisSight Monitor"
producer = "WeasyPrint + AegisSight Monitor"
urn_ns = "aegissight"
rights = (
"Vertrauliche Lageanalyse — AegisSight Monitor. "
"Weitergabe nur an autorisierte Empfänger."
)
else:
publisher = organization_name or ""
author = creator or "Unbekannt"
creator_app = ""
producer = "WeasyPrint"
urn_ns = "report"
rights = "Vertrauliche Lageanalyse. Weitergabe nur an autorisierte Empfänger."
identifier = f"urn:{urn_ns}:incident:{incident.get('id', '0')}:{now.strftime('%Y%m%dT%H%M%S')}"
return {
"title": title,
"author": creator or "AegisSight Monitor",
"author": author,
"subject": subject,
"keywords": unique_keywords,
"keywords_comma": ", ".join(unique_keywords),
"keywords_semicolon": "; ".join(unique_keywords),
"category": category,
"comments": comments,
"creator_app": "AegisSight Monitor",
"creator_app": creator_app,
"producer": producer,
"language": "de-DE",
"created": created,
"modified": modified,
@@ -634,7 +652,7 @@ def _enrich_pdf_metadata(pdf_bytes: bytes, meta: dict) -> bytes:
# PDF Namespace
xmp["pdf:Keywords"] = meta.get("keywords_comma", "")
xmp["pdf:Producer"] = "WeasyPrint + AegisSight Monitor"
xmp["pdf:Producer"] = meta.get("producer", "WeasyPrint + AegisSight Monitor")
# XMP Namespace
xmp["xmp:CreatorTool"] = meta.get("creator_app", "AegisSight Monitor")
@@ -681,6 +699,7 @@ async def generate_pdf(
organization_name: str | None = None,
top_locations: list[str] | None = None,
snapshot_count: int = 0,
include_branding: bool = True,
) -> bytes:
"""PDF-Report via WeasyPrint generieren."""
# Sections aus scope ableiten wenn nicht explizit angegeben
@@ -713,6 +732,7 @@ async def generate_pdf(
meta = _build_export_metadata(
incident, articles, fact_checks, all_sources, creator, scope, sections,
organization_name, top_locations, snapshot_count=snapshot_count,
include_branding=include_branding,
)
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
@@ -741,6 +761,7 @@ async def generate_pdf(
timeline=_prepare_timeline(articles) if scope == "full" else [],
articles=articles if scope == "full" else [],
meta=meta,
include_branding=include_branding,
)
# Artikel pub_date aufbereiten
@@ -764,6 +785,7 @@ async def generate_docx(
organization_name: str | None = None,
top_locations: list[str] | None = None,
snapshot_count: int = 0,
include_branding: bool = True,
) -> bytes:
"""Word-Report via python-docx generieren."""
doc = Document()
@@ -795,6 +817,7 @@ async def generate_docx(
meta = _build_export_metadata(
incident, articles, fact_checks, all_sources, creator, scope, sections,
organization_name, top_locations, snapshot_count=snapshot_count,
include_branding=include_branding,
)
# Dateimetadaten setzen (sichtbar in Explorer/Finder, DMS-Systemen)
@@ -823,13 +846,15 @@ async def generate_docx(
for _ in range(6):
doc.add_paragraph()
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title_para.add_run("AegisSight Monitor")
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
# Firmenname-Zeile nur im gebrandeten Export
if include_branding:
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title_para.add_run("AegisSight Monitor")
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
doc.add_paragraph()
doc.add_paragraph()
type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
type_para = doc.add_paragraph()
@@ -978,7 +1003,11 @@ async def generate_docx(
doc.add_paragraph()
footer = doc.add_paragraph()
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = footer.add_run(f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}")
if include_branding:
footer_text = f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}"
else:
footer_text = f"Stand: {now.strftime('%d.%m.%Y')}"
run = footer.add_run(footer_text)
run.font.size = Pt(8)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)

Datei anzeigen

@@ -84,7 +84,7 @@ tr:nth-child(even) { background: #f8f9fa; }
<body>
<!-- Deckblatt -->
<div class="cover">
<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">
{% if include_branding %}<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">{% endif %}
<div class="cover-type">{{ incident_type_label }}</div>
<div class="cover-title">{{ incident.title }}</div>
<div class="cover-meta">
@@ -92,7 +92,7 @@ tr:nth-child(even) { background: #f8f9fa; }
<div>Erstellt von: {{ creator }}</div>
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
</div>
<div class="cover-brand">AegisSight Monitor</div>
{% if include_branding %}<div class="cover-brand">AegisSight Monitor</div>{% endif %}
</div>
<!-- Inhaltsverzeichnis -->
@@ -208,7 +208,7 @@ tr:nth-child(even) { background: #f8f9fa; }
{% endif %}
<div class="report-footer">
Erstellt mit AegisSight Monitor &mdash; aegis-sight.de &mdash; {{ report_date }}
{% if include_branding %}Erstellt mit AegisSight Monitor &mdash; aegis-sight.de &mdash; {{ report_date }}{% else %}Stand: {{ report_date }}{% endif %}
</div>
</body>
</html>

Datei anzeigen

@@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"])
INCIDENT_UPDATE_COLUMNS = {
"title", "description", "type", "status", "refresh_mode",
"refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "visibility",
"refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "include_x", "visibility",
}
@@ -89,7 +89,7 @@ async def list_incidents(
query = (
"SELECT id, title, description, type, status, refresh_mode, refresh_interval, "
"refresh_start_time, retention_days, visibility, "
"international_sources, include_telegram, created_by, created_at, updated_at, "
"international_sources, include_telegram, include_x, created_by, created_at, updated_at, "
"CASE WHEN summary IS NOT NULL AND summary != '' THEN 1 ELSE 0 END AS has_summary "
"FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
)
@@ -120,9 +120,9 @@ async def create_incident(
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cursor = await db.execute(
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
refresh_start_time, retention_days, international_sources, include_telegram, visibility,
refresh_start_time, retention_days, international_sources, include_telegram, include_x, visibility,
tenant_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data.title,
data.description,
@@ -133,6 +133,7 @@ async def create_incident(
data.retention_days,
1 if data.international_sources else 0,
1 if data.include_telegram else 0,
1 if data.include_x else 0,
data.visibility,
tenant_id,
current_user["id"],
@@ -385,7 +386,7 @@ async def update_incident(
for field, value in data.model_dump(exclude_none=True).items():
if field not in INCIDENT_UPDATE_COLUMNS:
continue
if field in ("international_sources", "include_telegram"):
if field in ("international_sources", "include_telegram", "include_x"):
updates[field] = 1 if value else 0
else:
updates[field] = value
@@ -506,6 +507,14 @@ async def get_articles_sources_summary(
d = dict(r)
langs = (d.pop("languages") or "de").split(",")
d["languages"] = sorted({(l or "de").strip() for l in langs if l is not None})
# Quellentyp aus dem source-Praefix ableiten (fuer den Typ-Filter der Quellenuebersicht)
src = d.get("source") or ""
if src.startswith("X: "):
d["source_type"] = "x"
elif src.startswith("Telegram: "):
d["source_type"] = "telegram"
else:
d["source_type"] = "web"
sources.append(d)
# Sprach-Verteilung gesamt
cursor = await db.execute(
@@ -1143,6 +1152,8 @@ async def export_incident(
format: str = Query("pdf", pattern="^(pdf|docx)$"),
scope: str = Query("report", pattern="^(summary|report|full)$"),
sections: str = Query(None),
branding: str = Query("on", pattern="^(on|off)$"),
creator: str = Query(None, max_length=120),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -1161,10 +1172,13 @@ async def export_incident(
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
incident = dict(row)
# Ersteller-Name
cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
user_row = await cursor.fetchone()
creator = user_row["email"] if user_row else "Unbekannt"
# Ersteller-Name: manuell uebergebener Wert hat Vorrang, sonst E-Mail des Lage-Erstellers
if creator and creator.strip():
creator = creator.strip()
else:
cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
user_row = await cursor.fetchone()
creator = user_row["email"] if user_row else "Unbekannt"
# Organisation (fuer Dateimetadaten)
organization_name = None
@@ -1259,6 +1273,7 @@ async def export_incident(
organization_name=organization_name,
top_locations=top_locations,
snapshot_count=snapshot_count,
include_branding=(branding == "on"),
)
filename = f"{slug}_{scope_labels_key}_{date_str}.pdf"
return StreamingResponse(
@@ -1273,6 +1288,7 @@ async def export_incident(
organization_name=organization_name,
top_locations=top_locations,
snapshot_count=snapshot_count,
include_branding=(branding == "on"),
)
filename = f"{slug}_{scope_labels_key}_{date_str}.docx"
return StreamingResponse(

Datei anzeigen

@@ -144,6 +144,7 @@ async def get_source_stats(
"rss_feed": {"count": 0, "articles": 0},
"web_source": {"count": 0, "articles": 0},
"telegram_channel": {"count": 0, "articles": 0},
"x_account": {"count": 0, "articles": 0},
"excluded": {"count": 0, "articles": 0},
}
for row in rows:
@@ -637,6 +638,30 @@ async def validate_telegram_channel(
raise HTTPException(status_code=500, detail="Telegram-Validierung fehlgeschlagen")
@router.post("/x/validate")
async def validate_x_account(
data: dict,
current_user: dict = Depends(get_current_user),
):
"""Prueft ob ein X-Account (Twitter) erreichbar ist und gibt Account-Info zurueck."""
handle = data.get("handle", "").strip()
if not handle:
raise HTTPException(status_code=400, detail="handle ist erforderlich")
try:
from feeds.x_parser import XParser
parser = XParser()
result = await parser.validate_account(handle)
if result:
return result
raise HTTPException(status_code=404, detail="X-Account nicht erreichbar oder nicht gefunden")
except HTTPException:
raise
except Exception as e:
logger.error("X-Validierung fehlgeschlagen: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="X-Validierung fehlgeschlagen")
@router.post("/refresh-counts")
async def trigger_refresh_counts(
current_user: dict = Depends(get_current_user),

Datei anzeigen

@@ -36,6 +36,8 @@ _PIPELINE_STEPS_DE = [
"tooltip": "Aus Foren-Quellen (z.B. 5ch, Hatena, Note) wird ein Stimmungsbild der öffentlichen Diskussion extrahiert. Keine Faktenlage, sondern dominante Themen und Bruchlinien."},
{"key": "summary", "label": "Lagebild verfassen", "icon": "file-text",
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text."},
{"key": "translate", "label": "Artikel uebersetzen", "icon": "languages",
"tooltip": "Fremdsprachige Meldungen (z.B. japanisch) werden ins Lagebild-Output uebersetzt. Laeuft nur fuer Quellen-Pools mit nicht-deutschen Sprachen und kann bei vielen neuen Artikeln einige Minuten dauern."},
{"key": "qc", "label": "Qualitätscheck", "icon": "check-circle",
"tooltip": "Eine letzte Kontrollprüfung am Ergebnis: Doppelte Fakten zusammenführen, Karten-Verortung prüfen, bevor du benachrichtigt wirst."},
{"key": "notify", "label": "Benachrichtigen", "icon": "bell",
@@ -59,6 +61,8 @@ _PIPELINE_STEPS_EN = [
"tooltip": "Forum sources (5ch, Hatena, Note, etc.) are summarised into a public-mood overview. Not factual, but dominant themes and fault lines."},
{"key": "summary", "label": "Writing the briefing", "icon": "file-text",
"tooltip": "All verified articles are combined into a coherent briefing with inline citations."},
{"key": "translate", "label": "Translating articles", "icon": "languages",
"tooltip": "Foreign-language articles (e.g. Japanese) are translated into the briefing output language. Runs only when the source pool contains non-target-language items and can take several minutes for large incoming batches."},
{"key": "qc", "label": "Quality check", "icon": "check-circle",
"tooltip": "A final review: consolidate duplicate facts, verify map locations, before you get notified."},
{"key": "notify", "label": "Notifying", "icon": "bell",

Datei anzeigen

@@ -86,6 +86,9 @@ DOMAIN_CATEGORY_MAP = {
"merkur.de": "regional",
# Telegram
"t.me": "telegram",
# X / Twitter
"x.com": "x",
"twitter.com": "x",
}
# Bekannte Feed-Pfade zum Durchprobieren

Datei anzeigen

@@ -1715,6 +1715,39 @@ a.dev-source-pill:hover {
color: var(--text-primary);
}
.source-type-filter-chips {
display: flex;
flex-wrap: wrap;
gap: var(--sp-xs);
margin: var(--sp-sm) 0 var(--sp-xs);
}
.source-type-filter-chip {
font: inherit;
font-size: 11px;
padding: 3px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.source-type-filter-chip:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.source-type-filter-chip.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.source-type-filter-chip.active strong {
color: #fff;
}
.source-overview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));

Datei anzeigen

@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260501h">
<link rel="stylesheet" href="/static/css/style.css?v=20260522c">
<style>
/* Export Modal Radio */
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
@@ -352,6 +352,16 @@
</div>
<form id="new-incident-form">
<div class="modal-body">
<div class="form-group">
<label for="inc-type" data-i18n="modal.field.type">Art der Lage</label>
<select id="inc-type" onchange="toggleTypeDefaults()">
<option value="adhoc" data-i18n="modal.option.type_adhoc">Live-Monitoring : Ereignis beobachten</option>
<option value="research" data-i18n="modal.option.type_research">Recherche : Thema analysieren</option>
</select>
<div class="form-hint" id="type-hint" data-i18n="modal.hint.type_adhoc">
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
</div>
</div>
<div class="form-group">
<label for="inc-title" data-i18n="modal.new_incident.title_field">Titel des Vorfalls</label>
<input type="text" id="inc-title" required aria-required="true" placeholder="z.B. Explosion in Madrid" data-i18n-attr="placeholder:modal.placeholder.title">
@@ -367,16 +377,6 @@
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)" data-i18n-attr="placeholder:modal.placeholder.description"></textarea>
</div>
<div class="form-group">
<label for="inc-type" data-i18n="modal.field.type">Art der Lage</label>
<select id="inc-type" onchange="toggleTypeDefaults()">
<option value="adhoc" data-i18n="modal.option.type_adhoc">Live-Monitoring : Ereignis beobachten</option>
<option value="research" data-i18n="modal.option.type_research">Recherche : Thema analysieren</option>
</select>
<div class="form-hint" id="type-hint" data-i18n="modal.hint.type_adhoc">
Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.
</div>
</div>
<div class="form-group">
<label data-i18n="modal.field.sources">Quellen</label>
<div class="toggle-group">
@@ -392,6 +392,13 @@
<span class="toggle-switch"></span>
<span class="toggle-text"><span data-i18n="modal.toggle.telegram">Telegram-Kanäle einbeziehen</span> <span class="info-icon tooltip-below" data-tooltip="Bezieht OSINT-relevante Telegram-Kanäle als zusätzliche Quelle ein. Kann die Aktualität erhöhen, aber auch unbestätigte Informationen liefern."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="inc-x">
<span class="toggle-switch"></span>
<span class="toggle-text"><span data-i18n="modal.toggle.x">X (Twitter) einbeziehen</span> <span class="info-icon tooltip-below" data-tooltip="Bezieht Posts konfigurierter X-Accounts (Twitter) als zusätzliche Quelle ein. Kann die Aktualität erhöhen, aber auch unbestätigte Informationen liefern."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></span>
</label>
</div> </div>
<div class="form-group">
<label><span data-i18n="modal.new_incident.visibility">Sichtbarkeit</span> <span class="info-icon tooltip-below" data-tooltip="Öffentlich: Alle Nutzer der Organisation sehen diese Lage.&#10;&#10;Privat: Nur für dich sichtbar."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
@@ -413,7 +420,7 @@
<div class="form-group conditional-field" id="refresh-interval-field">
<label for="inc-refresh-value" data-i18n="modal.field.interval">Intervall</label>
<div class="interval-input-group">
<input type="number" id="inc-refresh-value" min="10" value="15">
<input type="number" id="inc-refresh-value" min="30" value="30">
<select id="inc-refresh-unit" onchange="updateIntervalMin()">
<option value="1" selected data-i18n="modal.unit.minutes">Minuten</option>
<option value="60" data-i18n="modal.unit.hours">Stunden</option>
@@ -421,6 +428,7 @@
<option value="10080" data-i18n="modal.unit.weeks">Wochen</option>
</select>
</div>
<div class="form-hint" id="interval-min-hint" style="display:none;"></div>
</div>
<div class="form-group conditional-field" id="refresh-starttime-field">
<label for="inc-refresh-starttime"><span data-i18n="modal.field.start_time">Erste Aktualisierung um</span> <span class="info-icon tooltip-below" data-tooltip="Legt den Startzeitpunkt fest. Danach wird im eingestellten Intervall aktualisiert."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
@@ -484,6 +492,8 @@
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="telegram_channel">Telegram</option>
<option value="x_account">X (Twitter)</option>
<option value="podcast_feed">Podcast</option>
<option value="excluded">Von mir ausgeschlossen</option>
</select>
<label for="sources-filter-category" class="sr-only" data-i18n="sources_modal.filter.category">Kategorie filtern</label>
@@ -623,6 +633,7 @@
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="telegram_channel">Telegram-Kanal</option>
<option value="x_account">X-Account</option>
</select>
</div>
<div class="form-group" id="src-rss-url-group">
@@ -795,12 +806,12 @@
<script src="/static/vendor/leaflet.js"></script>
<script src="/static/vendor/leaflet.markercluster.js"></script>
<script src="/static/js/i18n.js?v=20260513a"></script>
<script src="/static/js/api.js?v=20260423a"></script>
<script src="/static/js/api.js?v=20260522f"></script>
<script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260522a"></script>
<script src="/static/js/components.js?v=20260522d"></script>
<script src="/static/js/layout.js?v=20260513f"></script>
<script src="/static/js/pipeline.js?v=20260513d"></script>
<script src="/static/js/app.js?v=20260522a"></script>
<script src="/static/js/app.js?v=20260522f"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260514e"></script>
@@ -840,6 +851,16 @@
<label class="export-radio"><input type="radio" name="export-format" value="pdf" checked><span>PDF</span></label>
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span data-i18n="export.format.docx">Word (DOCX)</span></label>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;" data-i18n="export.branding">Branding</label>
<label class="export-radio"><input type="radio" name="export-branding" value="on" checked><span data-i18n="export.branding.on">Mit AegisSight-Branding</span></label>
<label class="export-radio"><input type="radio" name="export-branding" value="off"><span data-i18n="export.branding.off">Ohne Firmen-Branding</span></label>
</div>
<div style="margin-bottom:0;">
<label for="export-ersteller" style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Ersteller</label>
<input type="text" id="export-ersteller" maxlength="120" placeholder="Name des Erstellers (optional)" style="width:100%;box-sizing:border-box;">
<div style="font-size:11px;color:var(--text-secondary);margin-top:6px;">Leer lassen, dann wird automatisch der Lage-Ersteller verwendet.</div>
</div>
</div>
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
<button class="btn btn-secondary" onclick="closeModal('modal-export')" data-i18n="common.cancel">Abbrechen</button>

Datei anzeigen

@@ -210,6 +210,9 @@
"export.format": "Format",
"export.format.pdf": "PDF",
"export.format.docx": "Word (DOCX)",
"export.branding": "Branding",
"export.branding.on": "Mit AegisSight-Branding",
"export.branding.off": "Ohne Firmen-Branding",
"export.submit": "Exportieren",
"sources_modal.title": "Quellenverwaltung",
"sources_modal.stats.rss": "RSS-Feeds",

Datei anzeigen

@@ -210,6 +210,9 @@
"export.format": "Format",
"export.format.pdf": "PDF",
"export.format.docx": "Word (DOCX)",
"export.branding": "Branding",
"export.branding.on": "With AegisSight branding",
"export.branding.off": "Without company branding",
"export.submit": "Export",
"sources_modal.title": "Source management",
"sources_modal.stats.rss": "RSS feeds",

Datei anzeigen

@@ -330,7 +330,7 @@ const API = {
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportReport(id, format, scope, sections) {
exportReport(id, format, scope, sections, includeBranding, creator) {
const token = localStorage.getItem('osint_token');
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
if (sections && sections.length > 0) {
@@ -338,6 +338,12 @@ const API = {
} else if (scope) {
url += `&scope=${scope}`;
}
if (includeBranding === false) {
url += `&branding=off`;
}
if (creator) {
url += `&creator=${encodeURIComponent(creator)}`;
}
return fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
});

Datei anzeigen

@@ -578,8 +578,16 @@ const App = {
// Telegram-Kategorien Toggle
const tgCheckbox = document.getElementById('inc-telegram');
if (tgCheckbox) {
tgCheckbox.addEventListener('change', () => updateIntervalMin());
}
{ const xCheckbox = document.getElementById('inc-x');
if (xCheckbox) xCheckbox.addEventListener('change', () => updateIntervalMin()); }
{ const ivInput = document.getElementById('inc-refresh-value');
if (ivInput) ivInput.addEventListener('change', () => {
const u = parseInt(document.getElementById('inc-refresh-unit').value);
const m = (u === 1) ? _getMinIntervalMinutes() : 1;
if (isNaN(parseInt(ivInput.value)) || parseInt(ivInput.value) < m) ivInput.value = m;
}); }
// Feedback
@@ -909,6 +917,26 @@ const App = {
}
},
/** Quellenuebersicht der Lage nach Quellentyp filtern (Web/Telegram/X). */
filterSourceOverview(type, chipEl) {
const content = document.getElementById('source-overview-content');
if (!content) return;
content.querySelectorAll('.source-type-filter-chip').forEach(c => c.classList.remove('active'));
if (chipEl) chipEl.classList.add('active');
// ein offenes Detail-Panel schliessen
const det = content.querySelector('.source-overview-detail');
if (det) det.remove();
content.querySelectorAll('.source-overview-item.active').forEach(it => {
it.classList.remove('active');
it.setAttribute('aria-expanded', 'false');
});
// Quellen-Boxen nach Typ ein-/ausblenden
content.querySelectorAll('.source-overview-item').forEach(it => {
const t = it.dataset.type || 'web';
it.style.display = (!type || t === type) ? '' : 'none';
});
},
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
toggleSourceOverviewDetail(el) {
if (!el) return;
@@ -1816,9 +1844,9 @@ const App = {
// === Event Handlers ===
_getFormData() {
const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
const value = parseInt(document.getElementById('inc-refresh-value').value) || 30;
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
const interval = Math.max(10, Math.min(10080, value * unit));
const interval = Math.max(_getMinIntervalMinutes(), Math.min(10080, value * unit));
return {
title: document.getElementById('inc-title').value.trim(),
description: document.getElementById('inc-description').value.trim() || null,
@@ -1831,6 +1859,7 @@ const App = {
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').checked,
include_x: document.getElementById('inc-x').checked,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
};
},
@@ -2266,12 +2295,14 @@ async handleRefresh() {
{ const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
{ const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
{ const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
{ const _e = document.getElementById('inc-x'); if (_e) _e.checked = !!incident.include_x; }
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
updateVisibilityHint();
updateSourcesHint();
toggleTypeDefaults(true);
toggleRefreshInterval();
updateIntervalMin();
// Modal-Titel und Submit ändern
{ const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = (typeof T === 'function') ? T('modal.new_incident.edit_title', 'Lage bearbeiten') : 'Lage bearbeiten'; }
@@ -2615,6 +2646,9 @@ async handleRefresh() {
return;
}
const format = document.querySelector('input[name="export-format"]:checked').value;
const brandingEl = document.querySelector('input[name="export-branding"]:checked');
const includeBranding = !brandingEl || brandingEl.value === 'on';
const ersteller = (document.getElementById('export-ersteller')?.value || '').trim();
const btn = document.getElementById('export-submit-btn');
const origText = btn.textContent;
@@ -2622,7 +2656,7 @@ async handleRefresh() {
btn.textContent = (typeof T === 'function' ? T('action.creating', 'Wird erstellt...') : 'Wird erstellt...');
try {
const response = await API.exportReport(this.currentIncidentId, format, null, sections);
const response = await API.exportReport(this.currentIncidentId, format, null, sections, includeBranding, ersteller);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler ' + response.status);
@@ -2795,12 +2829,14 @@ async handleRefresh() {
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
const web = stats.by_type.web_source || { count: 0, articles: 0 };
const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
const x = stats.by_type.x_account || { count: 0, articles: 0 };
const excluded = this._myExclusions.length;
bar.innerHTML = `
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.rss', 'RSS-Feeds') : 'RSS-Feeds')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${web.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.web', 'Web-Quellen') : 'Web-Quellen')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${tg.count}</span> Telegram</span>
<span class="sources-stat-item"><span class="sources-stat-value">${x.count}</span> X</span>
<span class="sources-stat-item"><span class="sources-stat-value">${excluded}</span> ${(typeof T === 'function' ? T('sources_modal.stats.excluded', 'Ausgeschlossen') : 'Ausgeschlossen')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles}</span> Artikel gesamt</span>
`;
@@ -3246,6 +3282,31 @@ async handleRefresh() {
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
return;
}
// X (Twitter)-URLs direkt behandeln (kein Discovery noetig)
if (urlVal.match(/^(https?:\/\/)?(x\.com|twitter\.com)\//i)) {
const handle = urlVal
.replace(/^(https?:\/\/)?(x\.com|twitter\.com)\//i, '')
.replace(/\/$/, '')
.split(/[/?]/)[0]
.replace(/^@/, '');
const xUrl = 'x.com/' + handle;
this._discoveredData = {
name: '@' + handle,
domain: xUrl,
source_type: 'x_account',
rss_url: null,
};
document.getElementById('src-name').value = '@' + handle;
document.getElementById('src-type-select').value = 'x_account';
document.getElementById('src-type-display').value = 'X (Twitter)';
document.getElementById('src-domain').value = xUrl;
document.getElementById('src-rss-url-group').style.display = 'none';
document.getElementById('src-discovery-result').style.display = 'block';
const saveBtnX = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtnX) { saveBtnX.disabled = false; saveBtnX.textContent = 'Speichern'; }
return;
}
const url = urlInput.value.trim();
if (!url) {
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
@@ -3365,7 +3426,7 @@ async handleRefresh() {
document.getElementById('src-notes').value = source.notes || '';
document.getElementById('src-domain').value = source.domain || '';
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : source.source_type === 'x_account' ? 'X (Twitter)' : 'Web-Quelle';
const typeSelect = document.getElementById('src-type-select');
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
document.getElementById('src-type-display').value = typeLabel;
@@ -3409,7 +3470,7 @@ async handleRefresh() {
name,
source_type: discovered.source_type || 'web_source',
category: document.getElementById('src-category').value,
url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
url: discovered.rss_url || ((discovered.source_type === 'telegram_channel' || discovered.source_type === 'x_account') ? (document.getElementById('src-domain').value || null) : null),
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
notes: document.getElementById('src-notes').value.trim() || null,
};
@@ -3581,6 +3642,7 @@ function openModal(id) {
document.getElementById('inc-notify-status-change').checked = false;
toggleTypeDefaults();
toggleRefreshInterval();
updateIntervalMin();
}
const modal = document.getElementById(id);
modal._previousFocus = document.activeElement;
@@ -3762,17 +3824,38 @@ function toggleRefreshInterval() {
if (startField) startField.classList.toggle('visible', mode === 'auto');
}
function _getMinIntervalMinutes() {
// Mindest-Intervall (Minuten) je nach Quellen: 30 Basis, 45 bei X oder Telegram, 60 bei beiden. International zaehlt nicht.
const tg = document.getElementById('inc-telegram');
const x = document.getElementById('inc-x');
const tgOn = !!(tg && tg.checked);
const xOn = !!(x && x.checked);
if (tgOn && xOn) return 60;
if (tgOn || xOn) return 45;
return 30;
}
function updateIntervalMin() {
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
const input = document.getElementById('inc-refresh-value');
const minMinutes = _getMinIntervalMinutes();
const hint = document.getElementById('interval-min-hint');
if (unit === 1) {
// Minuten: Minimum 10
input.min = 10;
if (parseInt(input.value) < 10) input.value = 10;
// Minuten: dynamisches Minimum (30 / 45 bei X oder Telegram / 60 bei beiden)
input.min = minMinutes;
if (isNaN(parseInt(input.value)) || parseInt(input.value) < minMinutes) input.value = minMinutes;
if (hint) {
let zusatz = '';
if (minMinutes === 45) zusatz = ' (X oder Telegram aktiv)';
else if (minMinutes === 60) zusatz = ' (X und Telegram aktiv)';
hint.textContent = 'Mindestens ' + minMinutes + ' Minuten' + zusatz;
hint.style.display = '';
}
} else {
// Stunden/Tage/Wochen: Minimum 1
// Stunden/Tage/Wochen: eine Einheit liegt ueber jedem Minuten-Minimum
input.min = 1;
if (parseInt(input.value) < 1) input.value = 1;
if (isNaN(parseInt(input.value)) || parseInt(input.value) < 1) input.value = 1;
if (hint) hint.style.display = 'none';
}
}

Datei anzeigen

@@ -1034,11 +1034,31 @@ const UI = {
html += `<div class="source-lang-chips">${langChips}</div>`;
html += `</div>`;
// Typ-Filter-Chips: immer zeigen, sobald Quellen vorhanden sind. Die Leiste
// zeigt zugleich auf einen Blick, welche Quellentypen der Fall enthaelt.
const typeCounts = { web: 0, telegram: 0, x: 0 };
data.sources.forEach(s => {
const t = s.source_type || 'web';
typeCounts[t] = (typeCounts[t] || 0) + 1;
});
const typeMeta = [
{ key: '', label: 'Alle', count: data.sources.length },
{ key: 'web', label: 'Web', count: typeCounts.web },
{ key: 'telegram', label: 'Telegram', count: typeCounts.telegram },
{ key: 'x', label: 'X', count: typeCounts.x },
];
const chips = typeMeta
.filter(t => t.key === '' || t.count > 0)
.map(t => `<button type="button" class="source-type-filter-chip${t.key === '' ? ' active' : ''}" data-type="${t.key}" onclick="App.filterSourceOverview('${t.key}', this)">${t.label} <strong>${t.count}</strong></button>`)
.join('');
html += `<div class="source-type-filter-chips">${chips}</div>`;
html += '<div class="source-overview-grid">';
data.sources.forEach(s => {
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
const sourceName = this.escape(s.source || 'Unbekannt');
html += `<div class="source-overview-item" data-source="${sourceName}" tabindex="0" role="button" aria-expanded="false" onclick="App.toggleSourceOverviewDetail(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.toggleSourceOverviewDetail(this);}">
const sType = s.source_type || 'web';
html += `<div class="source-overview-item" data-source="${sourceName}" data-type="${sType}" tabindex="0" role="button" aria-expanded="false" onclick="App.toggleSourceOverviewDetail(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.toggleSourceOverviewDetail(this);}">
<span class="source-overview-name">${sourceName}</span>
<span class="source-overview-lang">${langs}</span>
<span class="source-overview-count">${s.article_count}</span>
@@ -1210,6 +1230,10 @@ const UI = {
/**
* Domain-Gruppe rendern (aufklappbar mit Feeds).
*/
_sourceTypeLabel(type) {
return ({ rss_feed: 'RSS', web_source: 'Web', telegram_channel: 'Telegram', x_account: 'X', podcast_feed: 'Podcast', excluded: 'Ausgeschlossen' })[type] || 'Web';
},
renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
@@ -1244,7 +1268,7 @@ const UI = {
realFeeds.forEach((feed, i) => {
const isLast = i === realFeeds.length - 1;
const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
const typeLabel = this._sourceTypeLabel(feed.source_type);
const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
feedRows += `<div class="source-feed-row">
<span class="source-feed-connector">${connector}</span>
@@ -1273,7 +1297,7 @@ const UI = {
|| firstFeed.country_code
|| (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
if (hasInfo) {
const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', podcast_feed: 'Podcast' };
const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', x_account: 'X (Twitter)', podcast_feed: 'Podcast' };
const lines = [];
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
@@ -1314,6 +1338,7 @@ const UI = {
<div class="source-group-info">
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
</div>
${!hasMultiple ? `<span class="source-type-badge type-${feeds[0]?.source_type || ''}">${this._sourceTypeLabel(feeds[0]?.source_type)}</span>` : ''}
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${classificationBadges ? `<span class="source-classification-badges">${classificationBadges}</span>` : ''}
${feedCountBadge}