Commits vergleichen
31 Commits
002584bdb1
...
main
| Autor | SHA1 | Datum | |
|---|---|---|---|
|
|
1647a6f50a | ||
|
|
c53e260c6c | ||
| c3a0ee4538 | |||
|
|
e20b3de0fa | ||
| aa36a9a38f | |||
|
|
d570e13dc6 | ||
|
|
7777b77abd | ||
| b02578e48b | |||
|
|
952df87afa | ||
| 38ce26f0be | |||
| 7f7b30c1d6 | |||
|
|
d986d611cf | ||
| 7954a78964 | |||
|
|
453c505a7e | ||
| 0b335263c9 | |||
|
|
279df0f56b | ||
| 889044cc3b | |||
|
|
0c34f67194 | ||
| 64f9841240 | |||
|
|
1b8961ca12 | ||
| 773715a38e | |||
|
|
f69fa1b95e | ||
| f1a395bb94 | |||
|
|
a0f4572a01 | ||
| 9598063728 | |||
|
|
cc1f9af273 | ||
| a61e45f752 | |||
| 3f45ae66df | |||
|
|
9c50439785 | ||
| f1200743e6 | |||
| 86b12a156e |
@@ -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",
|
"version": "2026-05-21T17:10Z",
|
||||||
"date": "2026-05-21",
|
"date": "2026-05-21",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ python-multipart
|
|||||||
aiosmtplib
|
aiosmtplib
|
||||||
geonamescache>=2.0
|
geonamescache>=2.0
|
||||||
telethon
|
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)
|
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
|
||||||
Jinja2>=3.1
|
Jinja2>=3.1
|
||||||
weasyprint>=68.0
|
weasyprint>=68.0
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ BISHERIGE QUELLEN:
|
|||||||
AUFTRAG:
|
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
|
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
|
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
|
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
|
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 |---|---|)
|
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
|
||||||
- KEIN Fettdruck (**) verwenden
|
- 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.
|
- 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:
|
REGELN:
|
||||||
- Neutral und sachlich - keine Wertungen oder Spekulationen
|
- Neutral und sachlich - keine Wertungen oder Spekulationen
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ CATEGORY_REPUTATION = {
|
|||||||
"international": 0.75, # CNN, Guardian, NYT, Al Jazeera, France24
|
"international": 0.75, # CNN, Guardian, NYT, Al Jazeera, France24
|
||||||
"regional": 0.65, # regionale Tageszeitungen
|
"regional": 0.65, # regionale Tageszeitungen
|
||||||
"telegram": 0.5, # OSINT-Kanaele — gemischte Qualitaet
|
"telegram": 0.5, # OSINT-Kanaele — gemischte Qualitaet
|
||||||
|
"x": 0.4, # X/Twitter-Accounts, hohes Rauschen
|
||||||
"sonstige": 0.4, # unkategorisiert
|
"sonstige": 0.4, # unkategorisiert
|
||||||
"boulevard": 0.3, # Bild, Sun etc.
|
"boulevard": 0.3, # Bild, Sun etc.
|
||||||
}
|
}
|
||||||
@@ -750,6 +751,7 @@ class AgentOrchestrator:
|
|||||||
# Einschraenkung passiert in get_feeds_with_metadata.
|
# Einschraenkung passiert in get_feeds_with_metadata.
|
||||||
# Hinweis: source_lang_whitelist wird weiter unten geladen.
|
# Hinweis: source_lang_whitelist wird weiter unten geladen.
|
||||||
include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False
|
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"
|
visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
|
||||||
created_by = incident["created_by"] if "created_by" in incident.keys() else None
|
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
|
tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
|
||||||
@@ -932,11 +934,21 @@ class AgentOrchestrator:
|
|||||||
_gnews_langs = list(source_lang_whitelist)
|
_gnews_langs = list(source_lang_whitelist)
|
||||||
else:
|
else:
|
||||||
_gnews_langs = list({output_language_iso, research_language_iso})
|
_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_feeds = build_news_search_feeds(keywords, _gnews_langs)
|
||||||
if _gnews_feeds:
|
_gnews_recent = build_news_search_feeds(keywords, _gnews_langs, recency_days=14)
|
||||||
logger.info(f"Google-News-Suchfeeds ergaenzt: {len(_gnews_feeds)}")
|
_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(
|
articles = await rss_parser.search_feeds_selective(
|
||||||
title, selected_feeds + _gnews_feeds, keywords=keywords,
|
title, selected_feeds + _all_gnews, keywords=keywords,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id)
|
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id)
|
||||||
@@ -1068,20 +1080,67 @@ class AgentOrchestrator:
|
|||||||
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
|
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
|
||||||
return articles, None
|
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)
|
# Pipeline-Schritt 2: Nachrichten sammeln (Start)
|
||||||
await _pipe_start("collect")
|
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()]
|
pipelines = [_rss_pipeline(), _web_search_pipeline(), _podcast_pipeline()]
|
||||||
|
telegram_idx = x_idx = None
|
||||||
if include_telegram:
|
if include_telegram:
|
||||||
|
telegram_idx = len(pipelines)
|
||||||
pipelines.append(_telegram_pipeline())
|
pipelines.append(_telegram_pipeline())
|
||||||
|
if include_x:
|
||||||
|
x_idx = len(pipelines)
|
||||||
|
pipelines.append(_x_pipeline())
|
||||||
|
|
||||||
pipeline_results = await asyncio.gather(*pipelines)
|
pipeline_results = await asyncio.gather(*pipelines)
|
||||||
|
|
||||||
(rss_articles, rss_feed_usage) = pipeline_results[0]
|
(rss_articles, rss_feed_usage) = pipeline_results[0]
|
||||||
(search_results, search_usage, search_parse_failed) = pipeline_results[1]
|
(search_results, search_usage, search_parse_failed) = pipeline_results[1]
|
||||||
(podcast_articles, _podcast_usage) = pipeline_results[2]
|
(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)
|
# Podcast-Artikel in die RSS-Liste einfuegen (gleicher Downstream-Pfad)
|
||||||
if podcast_articles:
|
if podcast_articles:
|
||||||
@@ -1100,7 +1159,7 @@ class AgentOrchestrator:
|
|||||||
self._check_cancelled(incident_id)
|
self._check_cancelled(incident_id)
|
||||||
|
|
||||||
# Alle Ergebnisse zusammenführen
|
# 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)
|
# Pipeline-Schritt 2: Nachrichten sammeln (fertig)
|
||||||
try:
|
try:
|
||||||
_delivering_sources = len({a.get("source", "") for a in all_results if a.get("source")})
|
_delivering_sources = len({a.get("source", "") for a in all_results if a.get("source")})
|
||||||
@@ -1694,6 +1753,7 @@ class AgentOrchestrator:
|
|||||||
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
|
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
|
||||||
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
|
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
|
||||||
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
|
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
|
||||||
|
_translate_step_started = False
|
||||||
try:
|
try:
|
||||||
tr_cursor = await db.execute(
|
tr_cursor = await db.execute(
|
||||||
"""SELECT id, headline, content_original, language
|
"""SELECT id, headline, content_original, language
|
||||||
@@ -1705,7 +1765,10 @@ class AgentOrchestrator:
|
|||||||
(incident_id,),
|
(incident_id,),
|
||||||
)
|
)
|
||||||
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
|
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(
|
logger.info(
|
||||||
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
|
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
|
||||||
incident_id, len(pending_translations),
|
incident_id, len(pending_translations),
|
||||||
@@ -1736,8 +1799,11 @@ class AgentOrchestrator:
|
|||||||
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
|
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
|
||||||
incident_id, len(translations), len(pending_translations),
|
incident_id, len(translations), len(pending_translations),
|
||||||
)
|
)
|
||||||
|
await _pipe_done("translate", count_value=len(translations), count_secondary=len(pending_translations))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Translator-Fehler fuer Incident %d: %s", incident_id, e, exc_info=True)
|
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
|
# Refresh trotz Translator-Fehler weiterlaufen lassen
|
||||||
|
|
||||||
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
|
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def build_news_search_feeds(
|
|||||||
keywords_by_lang: dict | list | None,
|
keywords_by_lang: dict | list | None,
|
||||||
languages: list[str],
|
languages: list[str],
|
||||||
max_keywords: int = 4,
|
max_keywords: int = 4,
|
||||||
|
recency_days: int | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Baut dynamische Google-News-Volltext-Such-Feeds pro Sprache.
|
"""Baut dynamische Google-News-Volltext-Such-Feeds pro Sprache.
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ def build_news_search_feeds(
|
|||||||
keywords_by_lang: Sprach-Dict {iso: [keyword,...]} aus der Keyword-Extraktion.
|
keywords_by_lang: Sprach-Dict {iso: [keyword,...]} aus der Keyword-Extraktion.
|
||||||
languages: ISO-Codes, fuer die ein Suchfeed gebaut werden soll.
|
languages: ISO-Codes, fuer die ein Suchfeed gebaut werden soll.
|
||||||
max_keywords: wie viele (spezifischste) Keywords in die Such-Query gehen.
|
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:
|
Returns:
|
||||||
Liste von Feed-Config-Dicts (kompatibel mit RSSParser._fetch_feed).
|
Liste von Feed-Config-Dicts (kompatibel mit RSSParser._fetch_feed).
|
||||||
@@ -88,28 +92,38 @@ def build_news_search_feeds(
|
|||||||
if not deduped:
|
if not deduped:
|
||||||
continue
|
continue
|
||||||
query = " ".join(deduped)
|
query = " ".join(deduped)
|
||||||
if not query or query in seen_queries:
|
# 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
|
continue
|
||||||
seen_queries.add(query)
|
seen_queries.add(effective_query)
|
||||||
|
|
||||||
hl, gl = locale
|
hl, gl = locale
|
||||||
ceid_lang = hl.split("-")[0]
|
ceid_lang = hl.split("-")[0]
|
||||||
url = (
|
url = (
|
||||||
"https://news.google.com/rss/search?q="
|
"https://news.google.com/rss/search?q="
|
||||||
+ urllib.parse.quote(query)
|
+ urllib.parse.quote(effective_query)
|
||||||
+ f"&hl={hl}&gl={gl}&ceid={gl}:{ceid_lang}"
|
+ 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({
|
feeds.append({
|
||||||
"name": f"Google News Suche ({lang_key}): {query}",
|
"name": name,
|
||||||
"url": url,
|
"url": url,
|
||||||
# Eigene Domain-Gruppe, damit der Domain-Cap die Such-Feeds NICHT mit
|
# Eigene Domain-Gruppe, damit der Domain-Cap die Such-Feeds NICHT mit
|
||||||
# den site:-Google-News-Feeds in einen Topf wirft.
|
# den site:-Google-News-Feeds in einen Topf wirft.
|
||||||
"domain": f"google-news-search-{lang_key}",
|
"domain": domain,
|
||||||
"primary_language": lang_key,
|
"primary_language": lang_key,
|
||||||
"category": "international",
|
"category": "international",
|
||||||
"media_type": "",
|
"media_type": "",
|
||||||
})
|
})
|
||||||
logger.info("Google-News-Suchfeed (%s): q=%r", lang_key, query)
|
logger.info("Google-News-Suchfeed (%s): q=%r", lang_key, effective_query)
|
||||||
return feeds
|
return feeds
|
||||||
|
|
||||||
|
|
||||||
@@ -482,6 +496,24 @@ REGELN:
|
|||||||
Antworte NUR mit einem JSON-Array der Kanal-Nummern, z.B.: [1, 3, 5, 12]"""
|
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:
|
class ResearcherAgent:
|
||||||
"""Führt OSINT-Recherchen über Claude CLI WebSearch durch."""
|
"""Führt OSINT-Recherchen über Claude CLI WebSearch durch."""
|
||||||
|
|
||||||
@@ -1002,3 +1034,62 @@ class ResearcherAgent:
|
|||||||
logger.warning("Telegram-Selektion fehlgeschlagen (%s), nutze alle Kanaele", e)
|
logger.warning("Telegram-Selektion fehlgeschlagen (%s), nutze alle Kanaele", e)
|
||||||
return channels_metadata, None
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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_API_HASH = os.environ.get("TELEGRAM_API_HASH", "")
|
||||||
TELEGRAM_SESSION_PATH = os.environ.get("TELEGRAM_SESSION_PATH", "/home/claude-dev/.telegram/telegram_session")
|
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 (genutzt von services/source_health.py)
|
||||||
HEALTH_CHECK_USER_AGENT = os.environ.get(
|
HEALTH_CHECK_USER_AGENT = os.environ.get(
|
||||||
"HEALTH_CHECK_USER_AGENT",
|
"HEALTH_CHECK_USER_AGENT",
|
||||||
|
|||||||
@@ -403,6 +403,11 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: include_telegram zu incidents hinzugefuegt")
|
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:
|
if "telegram_categories" not in columns:
|
||||||
await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL")
|
await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -218,14 +218,33 @@ class RSSParser:
|
|||||||
|
|
||||||
if match_count >= min_matches:
|
if match_count >= min_matches:
|
||||||
published = None
|
published = None
|
||||||
|
published_dt = None
|
||||||
if hasattr(entry, "published_parsed") and entry.published_parsed:
|
if hasattr(entry, "published_parsed") and entry.published_parsed:
|
||||||
try:
|
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):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Relevanz-Score: Anteil der gematchten Suchworte (0.0-1.0)
|
# Relevanz-Score: Anteil der gematchten Suchworte (0.0-1.0)
|
||||||
relevance_score = match_count / len(search_words) if search_words else 0.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
|
# Bei Google-News-Feeds: echten Publisher aus <source>-Tag holen
|
||||||
article_source = name
|
article_source = name
|
||||||
|
|||||||
320
src/feeds/x_parser.py
Normale Datei
320
src/feeds/x_parser.py
Normale Datei
@@ -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
|
||||||
49
src/main.py
49
src/main.py
@@ -246,7 +246,14 @@ async def cleanup_expired():
|
|||||||
)
|
)
|
||||||
logger.info(f"Lage {incident['id']} archiviert (Aufbewahrung abgelaufen)")
|
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(
|
cursor = await db.execute(
|
||||||
"SELECT id, incident_id, started_at FROM refresh_log WHERE status = 'running'"
|
"SELECT id, incident_id, started_at FROM refresh_log WHERE status = 'running'"
|
||||||
)
|
)
|
||||||
@@ -258,12 +265,46 @@ async def cleanup_expired():
|
|||||||
else:
|
else:
|
||||||
started = started.astimezone(TIMEZONE)
|
started = started.astimezone(TIMEZONE)
|
||||||
age_minutes = (now - started).total_seconds() / 60
|
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(
|
await db.execute(
|
||||||
"UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = ? WHERE id = ?",
|
"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)
|
# Alte Notifications bereinigen (> 7 Tage)
|
||||||
await db.execute("DELETE FROM notifications WHERE created_at < datetime('now', '-7 days')")
|
await db.execute("DELETE FROM notifications WHERE created_at < datetime('now', '-7 days')")
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class IncidentCreate(BaseModel):
|
|||||||
retention_days: int = Field(default=0, ge=0, le=999)
|
retention_days: int = Field(default=0, ge=0, le=999)
|
||||||
international_sources: bool = False
|
international_sources: bool = False
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
|
include_x: bool = False
|
||||||
visibility: str = Field(default="public", pattern="^(public|private)$")
|
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)
|
retention_days: Optional[int] = Field(default=None, ge=0, le=999)
|
||||||
international_sources: Optional[bool] = None
|
international_sources: Optional[bool] = None
|
||||||
include_telegram: Optional[bool] = None
|
include_telegram: Optional[bool] = None
|
||||||
|
include_x: Optional[bool] = None
|
||||||
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
|
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ class IncidentResponse(BaseModel):
|
|||||||
public_mood_updated_at: Optional[str] = None
|
public_mood_updated_at: Optional[str] = None
|
||||||
international_sources: bool = True
|
international_sources: bool = True
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
|
include_x: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
created_by_username: str = ""
|
created_by_username: str = ""
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -130,6 +133,7 @@ class IncidentListItem(BaseModel):
|
|||||||
visibility: str = "public"
|
visibility: str = "public"
|
||||||
international_sources: bool = True
|
international_sources: bool = True
|
||||||
include_telegram: bool = False
|
include_telegram: bool = False
|
||||||
|
include_x: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
created_by_username: str = ""
|
created_by_username: str = ""
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -142,8 +146,8 @@ class IncidentListItem(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Sources (Quellenverwaltung)
|
# Sources (Quellenverwaltung)
|
||||||
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$"
|
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)$"
|
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige|x)$"
|
||||||
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
|
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
|
||||||
class SourceCreate(BaseModel):
|
class SourceCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
|||||||
@@ -462,8 +462,12 @@ def _build_export_metadata(
|
|||||||
organization_name: str | None,
|
organization_name: str | None,
|
||||||
top_locations: list[str] | None,
|
top_locations: list[str] | None,
|
||||||
snapshot_count: int = 0,
|
snapshot_count: int = 0,
|
||||||
|
include_branding: bool = True,
|
||||||
) -> dict:
|
) -> 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"
|
is_research = incident.get("type") == "research"
|
||||||
type_label = "Hintergrundrecherche" if is_research else "Live-Monitoring"
|
type_label = "Hintergrundrecherche" if is_research else "Live-Monitoring"
|
||||||
category = "OSINT-Hintergrundrecherche" if is_research else "OSINT-Lagebericht"
|
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_lines.append("Orte: " + ", ".join(top_locations[:5]))
|
||||||
comments = "\n".join(comments_lines)
|
comments = "\n".join(comments_lines)
|
||||||
|
|
||||||
publisher = organization_name or "AegisSight"
|
# Branding-abhaengige Felder: bei include_branding=False neutralisiert (White-Label-Export)
|
||||||
identifier = f"urn:aegissight:incident:{incident.get('id', '0')}:{now.strftime('%Y%m%dT%H%M%S')}"
|
if include_branding:
|
||||||
rights = (
|
publisher = organization_name or "AegisSight"
|
||||||
"Vertrauliche Lageanalyse — AegisSight Monitor. "
|
author = creator or "AegisSight Monitor"
|
||||||
"Weitergabe nur an autorisierte Empfänger."
|
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 {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
"author": creator or "AegisSight Monitor",
|
"author": author,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"keywords": unique_keywords,
|
"keywords": unique_keywords,
|
||||||
"keywords_comma": ", ".join(unique_keywords),
|
"keywords_comma": ", ".join(unique_keywords),
|
||||||
"keywords_semicolon": "; ".join(unique_keywords),
|
"keywords_semicolon": "; ".join(unique_keywords),
|
||||||
"category": category,
|
"category": category,
|
||||||
"comments": comments,
|
"comments": comments,
|
||||||
"creator_app": "AegisSight Monitor",
|
"creator_app": creator_app,
|
||||||
|
"producer": producer,
|
||||||
"language": "de-DE",
|
"language": "de-DE",
|
||||||
"created": created,
|
"created": created,
|
||||||
"modified": modified,
|
"modified": modified,
|
||||||
@@ -634,7 +652,7 @@ def _enrich_pdf_metadata(pdf_bytes: bytes, meta: dict) -> bytes:
|
|||||||
|
|
||||||
# PDF Namespace
|
# PDF Namespace
|
||||||
xmp["pdf:Keywords"] = meta.get("keywords_comma", "")
|
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 Namespace
|
||||||
xmp["xmp:CreatorTool"] = meta.get("creator_app", "AegisSight Monitor")
|
xmp["xmp:CreatorTool"] = meta.get("creator_app", "AegisSight Monitor")
|
||||||
@@ -681,6 +699,7 @@ async def generate_pdf(
|
|||||||
organization_name: str | None = None,
|
organization_name: str | None = None,
|
||||||
top_locations: list[str] | None = None,
|
top_locations: list[str] | None = None,
|
||||||
snapshot_count: int = 0,
|
snapshot_count: int = 0,
|
||||||
|
include_branding: bool = True,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""PDF-Report via WeasyPrint generieren."""
|
"""PDF-Report via WeasyPrint generieren."""
|
||||||
# Sections aus scope ableiten wenn nicht explizit angegeben
|
# Sections aus scope ableiten wenn nicht explizit angegeben
|
||||||
@@ -713,6 +732,7 @@ async def generate_pdf(
|
|||||||
meta = _build_export_metadata(
|
meta = _build_export_metadata(
|
||||||
incident, articles, fact_checks, all_sources, creator, scope, sections,
|
incident, articles, fact_checks, all_sources, creator, scope, sections,
|
||||||
organization_name, top_locations, snapshot_count=snapshot_count,
|
organization_name, top_locations, snapshot_count=snapshot_count,
|
||||||
|
include_branding=include_branding,
|
||||||
)
|
)
|
||||||
|
|
||||||
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
||||||
@@ -741,6 +761,7 @@ async def generate_pdf(
|
|||||||
timeline=_prepare_timeline(articles) if scope == "full" else [],
|
timeline=_prepare_timeline(articles) if scope == "full" else [],
|
||||||
articles=articles if scope == "full" else [],
|
articles=articles if scope == "full" else [],
|
||||||
meta=meta,
|
meta=meta,
|
||||||
|
include_branding=include_branding,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Artikel pub_date aufbereiten
|
# Artikel pub_date aufbereiten
|
||||||
@@ -764,6 +785,7 @@ async def generate_docx(
|
|||||||
organization_name: str | None = None,
|
organization_name: str | None = None,
|
||||||
top_locations: list[str] | None = None,
|
top_locations: list[str] | None = None,
|
||||||
snapshot_count: int = 0,
|
snapshot_count: int = 0,
|
||||||
|
include_branding: bool = True,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Word-Report via python-docx generieren."""
|
"""Word-Report via python-docx generieren."""
|
||||||
doc = Document()
|
doc = Document()
|
||||||
@@ -795,6 +817,7 @@ async def generate_docx(
|
|||||||
meta = _build_export_metadata(
|
meta = _build_export_metadata(
|
||||||
incident, articles, fact_checks, all_sources, creator, scope, sections,
|
incident, articles, fact_checks, all_sources, creator, scope, sections,
|
||||||
organization_name, top_locations, snapshot_count=snapshot_count,
|
organization_name, top_locations, snapshot_count=snapshot_count,
|
||||||
|
include_branding=include_branding,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dateimetadaten setzen (sichtbar in Explorer/Finder, DMS-Systemen)
|
# Dateimetadaten setzen (sichtbar in Explorer/Finder, DMS-Systemen)
|
||||||
@@ -823,13 +846,15 @@ async def generate_docx(
|
|||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
doc.add_paragraph()
|
doc.add_paragraph()
|
||||||
|
|
||||||
title_para = doc.add_paragraph()
|
# Firmenname-Zeile nur im gebrandeten Export
|
||||||
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
if include_branding:
|
||||||
run = title_para.add_run("AegisSight Monitor")
|
title_para = doc.add_paragraph()
|
||||||
run.font.size = Pt(12)
|
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
|
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_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
|
||||||
type_para = doc.add_paragraph()
|
type_para = doc.add_paragraph()
|
||||||
@@ -978,7 +1003,11 @@ async def generate_docx(
|
|||||||
doc.add_paragraph()
|
doc.add_paragraph()
|
||||||
footer = doc.add_paragraph()
|
footer = doc.add_paragraph()
|
||||||
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
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.size = Pt(8)
|
||||||
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
|
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ tr:nth-child(even) { background: #f8f9fa; }
|
|||||||
<body>
|
<body>
|
||||||
<!-- Deckblatt -->
|
<!-- Deckblatt -->
|
||||||
<div class="cover">
|
<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-type">{{ incident_type_label }}</div>
|
||||||
<div class="cover-title">{{ incident.title }}</div>
|
<div class="cover-title">{{ incident.title }}</div>
|
||||||
<div class="cover-meta">
|
<div class="cover-meta">
|
||||||
@@ -92,7 +92,7 @@ tr:nth-child(even) { background: #f8f9fa; }
|
|||||||
<div>Erstellt von: {{ creator }}</div>
|
<div>Erstellt von: {{ creator }}</div>
|
||||||
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
|
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="cover-brand">AegisSight Monitor</div>
|
{% if include_branding %}<div class="cover-brand">AegisSight Monitor</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inhaltsverzeichnis -->
|
<!-- Inhaltsverzeichnis -->
|
||||||
@@ -208,7 +208,7 @@ tr:nth-child(even) { background: #f8f9fa; }
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="report-footer">
|
<div class="report-footer">
|
||||||
Erstellt mit AegisSight Monitor — aegis-sight.de — {{ report_date }}
|
{% if include_branding %}Erstellt mit AegisSight Monitor — aegis-sight.de — {{ report_date }}{% else %}Stand: {{ report_date }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"])
|
|||||||
|
|
||||||
INCIDENT_UPDATE_COLUMNS = {
|
INCIDENT_UPDATE_COLUMNS = {
|
||||||
"title", "description", "type", "status", "refresh_mode",
|
"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 = (
|
query = (
|
||||||
"SELECT id, title, description, type, status, refresh_mode, refresh_interval, "
|
"SELECT id, title, description, type, status, refresh_mode, refresh_interval, "
|
||||||
"refresh_start_time, retention_days, visibility, "
|
"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 "
|
"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 = ?)"
|
"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')
|
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
|
"""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)
|
tenant_id, created_by, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
data.title,
|
data.title,
|
||||||
data.description,
|
data.description,
|
||||||
@@ -133,6 +133,7 @@ async def create_incident(
|
|||||||
data.retention_days,
|
data.retention_days,
|
||||||
1 if data.international_sources else 0,
|
1 if data.international_sources else 0,
|
||||||
1 if data.include_telegram else 0,
|
1 if data.include_telegram else 0,
|
||||||
|
1 if data.include_x else 0,
|
||||||
data.visibility,
|
data.visibility,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
current_user["id"],
|
current_user["id"],
|
||||||
@@ -385,7 +386,7 @@ async def update_incident(
|
|||||||
for field, value in data.model_dump(exclude_none=True).items():
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
if field not in INCIDENT_UPDATE_COLUMNS:
|
if field not in INCIDENT_UPDATE_COLUMNS:
|
||||||
continue
|
continue
|
||||||
if field in ("international_sources", "include_telegram"):
|
if field in ("international_sources", "include_telegram", "include_x"):
|
||||||
updates[field] = 1 if value else 0
|
updates[field] = 1 if value else 0
|
||||||
else:
|
else:
|
||||||
updates[field] = value
|
updates[field] = value
|
||||||
@@ -506,6 +507,14 @@ async def get_articles_sources_summary(
|
|||||||
d = dict(r)
|
d = dict(r)
|
||||||
langs = (d.pop("languages") or "de").split(",")
|
langs = (d.pop("languages") or "de").split(",")
|
||||||
d["languages"] = sorted({(l or "de").strip() for l in langs if l is not None})
|
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)
|
sources.append(d)
|
||||||
# Sprach-Verteilung gesamt
|
# Sprach-Verteilung gesamt
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -1143,6 +1152,8 @@ async def export_incident(
|
|||||||
format: str = Query("pdf", pattern="^(pdf|docx)$"),
|
format: str = Query("pdf", pattern="^(pdf|docx)$"),
|
||||||
scope: str = Query("report", pattern="^(summary|report|full)$"),
|
scope: str = Query("report", pattern="^(summary|report|full)$"),
|
||||||
sections: str = Query(None),
|
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),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
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)
|
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||||
incident = dict(row)
|
incident = dict(row)
|
||||||
|
|
||||||
# Ersteller-Name
|
# Ersteller-Name: manuell uebergebener Wert hat Vorrang, sonst E-Mail des Lage-Erstellers
|
||||||
cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
|
if creator and creator.strip():
|
||||||
user_row = await cursor.fetchone()
|
creator = creator.strip()
|
||||||
creator = user_row["email"] if user_row else "Unbekannt"
|
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)
|
# Organisation (fuer Dateimetadaten)
|
||||||
organization_name = None
|
organization_name = None
|
||||||
@@ -1259,6 +1273,7 @@ async def export_incident(
|
|||||||
organization_name=organization_name,
|
organization_name=organization_name,
|
||||||
top_locations=top_locations,
|
top_locations=top_locations,
|
||||||
snapshot_count=snapshot_count,
|
snapshot_count=snapshot_count,
|
||||||
|
include_branding=(branding == "on"),
|
||||||
)
|
)
|
||||||
filename = f"{slug}_{scope_labels_key}_{date_str}.pdf"
|
filename = f"{slug}_{scope_labels_key}_{date_str}.pdf"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
@@ -1273,6 +1288,7 @@ async def export_incident(
|
|||||||
organization_name=organization_name,
|
organization_name=organization_name,
|
||||||
top_locations=top_locations,
|
top_locations=top_locations,
|
||||||
snapshot_count=snapshot_count,
|
snapshot_count=snapshot_count,
|
||||||
|
include_branding=(branding == "on"),
|
||||||
)
|
)
|
||||||
filename = f"{slug}_{scope_labels_key}_{date_str}.docx"
|
filename = f"{slug}_{scope_labels_key}_{date_str}.docx"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ async def get_source_stats(
|
|||||||
"rss_feed": {"count": 0, "articles": 0},
|
"rss_feed": {"count": 0, "articles": 0},
|
||||||
"web_source": {"count": 0, "articles": 0},
|
"web_source": {"count": 0, "articles": 0},
|
||||||
"telegram_channel": {"count": 0, "articles": 0},
|
"telegram_channel": {"count": 0, "articles": 0},
|
||||||
|
"x_account": {"count": 0, "articles": 0},
|
||||||
"excluded": {"count": 0, "articles": 0},
|
"excluded": {"count": 0, "articles": 0},
|
||||||
}
|
}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -637,6 +638,30 @@ async def validate_telegram_channel(
|
|||||||
raise HTTPException(status_code=500, detail="Telegram-Validierung fehlgeschlagen")
|
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")
|
@router.post("/refresh-counts")
|
||||||
async def trigger_refresh_counts(
|
async def trigger_refresh_counts(
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
|||||||
@@ -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."},
|
"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",
|
{"key": "summary", "label": "Lagebild verfassen", "icon": "file-text",
|
||||||
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am 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",
|
{"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."},
|
"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",
|
{"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."},
|
"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",
|
{"key": "summary", "label": "Writing the briefing", "icon": "file-text",
|
||||||
"tooltip": "All verified articles are combined into a coherent briefing with inline citations."},
|
"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",
|
{"key": "qc", "label": "Quality check", "icon": "check-circle",
|
||||||
"tooltip": "A final review: consolidate duplicate facts, verify map locations, before you get notified."},
|
"tooltip": "A final review: consolidate duplicate facts, verify map locations, before you get notified."},
|
||||||
{"key": "notify", "label": "Notifying", "icon": "bell",
|
{"key": "notify", "label": "Notifying", "icon": "bell",
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ DOMAIN_CATEGORY_MAP = {
|
|||||||
"merkur.de": "regional",
|
"merkur.de": "regional",
|
||||||
# Telegram
|
# Telegram
|
||||||
"t.me": "telegram",
|
"t.me": "telegram",
|
||||||
|
# X / Twitter
|
||||||
|
"x.com": "x",
|
||||||
|
"twitter.com": "x",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bekannte Feed-Pfade zum Durchprobieren
|
# Bekannte Feed-Pfade zum Durchprobieren
|
||||||
|
|||||||
@@ -1715,6 +1715,39 @@ a.dev-source-pill:hover {
|
|||||||
color: var(--text-primary);
|
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 {
|
.source-overview-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.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>
|
<style>
|
||||||
/* Export Modal Radio */
|
/* 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; }
|
.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>
|
</div>
|
||||||
<form id="new-incident-form">
|
<form id="new-incident-form">
|
||||||
<div class="modal-body">
|
<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">
|
<div class="form-group">
|
||||||
<label for="inc-title" data-i18n="modal.new_incident.title_field">Titel des Vorfalls</label>
|
<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">
|
<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>
|
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)" data-i18n-attr="placeholder:modal.placeholder.description"></textarea>
|
||||||
|
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label data-i18n="modal.field.sources">Quellen</label>
|
<label data-i18n="modal.field.sources">Quellen</label>
|
||||||
<div class="toggle-group">
|
<div class="toggle-group">
|
||||||
@@ -392,6 +392,13 @@
|
|||||||
<span class="toggle-switch"></span>
|
<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>
|
<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>
|
</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> </div>
|
||||||
<div class="form-group">
|
<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. 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>
|
<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. 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">
|
<div class="form-group conditional-field" id="refresh-interval-field">
|
||||||
<label for="inc-refresh-value" data-i18n="modal.field.interval">Intervall</label>
|
<label for="inc-refresh-value" data-i18n="modal.field.interval">Intervall</label>
|
||||||
<div class="interval-input-group">
|
<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()">
|
<select id="inc-refresh-unit" onchange="updateIntervalMin()">
|
||||||
<option value="1" selected data-i18n="modal.unit.minutes">Minuten</option>
|
<option value="1" selected data-i18n="modal.unit.minutes">Minuten</option>
|
||||||
<option value="60" data-i18n="modal.unit.hours">Stunden</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>
|
<option value="10080" data-i18n="modal.unit.weeks">Wochen</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-hint" id="interval-min-hint" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group conditional-field" id="refresh-starttime-field">
|
<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>
|
<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="rss_feed">RSS-Feed</option>
|
||||||
<option value="web_source">Web-Quelle</option>
|
<option value="web_source">Web-Quelle</option>
|
||||||
<option value="telegram_channel">Telegram</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>
|
<option value="excluded">Von mir ausgeschlossen</option>
|
||||||
</select>
|
</select>
|
||||||
<label for="sources-filter-category" class="sr-only" data-i18n="sources_modal.filter.category">Kategorie filtern</label>
|
<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="rss_feed">RSS-Feed</option>
|
||||||
<option value="web_source">Web-Quelle</option>
|
<option value="web_source">Web-Quelle</option>
|
||||||
<option value="telegram_channel">Telegram-Kanal</option>
|
<option value="telegram_channel">Telegram-Kanal</option>
|
||||||
|
<option value="x_account">X-Account</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="src-rss-url-group">
|
<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.js"></script>
|
||||||
<script src="/static/vendor/leaflet.markercluster.js"></script>
|
<script src="/static/vendor/leaflet.markercluster.js"></script>
|
||||||
<script src="/static/js/i18n.js?v=20260513a"></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/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/layout.js?v=20260513f"></script>
|
||||||
<script src="/static/js/pipeline.js?v=20260513d"></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/cluster-data.js?v=20260322f"></script>
|
||||||
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
||||||
<script src="/static/js/chat.js?v=20260514e"></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="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>
|
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span data-i18n="export.format.docx">Word (DOCX)</span></label>
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
|
<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>
|
<button class="btn btn-secondary" onclick="closeModal('modal-export')" data-i18n="common.cancel">Abbrechen</button>
|
||||||
|
|||||||
@@ -210,6 +210,9 @@
|
|||||||
"export.format": "Format",
|
"export.format": "Format",
|
||||||
"export.format.pdf": "PDF",
|
"export.format.pdf": "PDF",
|
||||||
"export.format.docx": "Word (DOCX)",
|
"export.format.docx": "Word (DOCX)",
|
||||||
|
"export.branding": "Branding",
|
||||||
|
"export.branding.on": "Mit AegisSight-Branding",
|
||||||
|
"export.branding.off": "Ohne Firmen-Branding",
|
||||||
"export.submit": "Exportieren",
|
"export.submit": "Exportieren",
|
||||||
"sources_modal.title": "Quellenverwaltung",
|
"sources_modal.title": "Quellenverwaltung",
|
||||||
"sources_modal.stats.rss": "RSS-Feeds",
|
"sources_modal.stats.rss": "RSS-Feeds",
|
||||||
|
|||||||
@@ -210,6 +210,9 @@
|
|||||||
"export.format": "Format",
|
"export.format": "Format",
|
||||||
"export.format.pdf": "PDF",
|
"export.format.pdf": "PDF",
|
||||||
"export.format.docx": "Word (DOCX)",
|
"export.format.docx": "Word (DOCX)",
|
||||||
|
"export.branding": "Branding",
|
||||||
|
"export.branding.on": "With AegisSight branding",
|
||||||
|
"export.branding.off": "Without company branding",
|
||||||
"export.submit": "Export",
|
"export.submit": "Export",
|
||||||
"sources_modal.title": "Source management",
|
"sources_modal.title": "Source management",
|
||||||
"sources_modal.stats.rss": "RSS feeds",
|
"sources_modal.stats.rss": "RSS feeds",
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ const API = {
|
|||||||
resetTutorialState() {
|
resetTutorialState() {
|
||||||
return this._request('DELETE', '/tutorial/state');
|
return this._request('DELETE', '/tutorial/state');
|
||||||
},
|
},
|
||||||
exportReport(id, format, scope, sections) {
|
exportReport(id, format, scope, sections, includeBranding, creator) {
|
||||||
const token = localStorage.getItem('osint_token');
|
const token = localStorage.getItem('osint_token');
|
||||||
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
|
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
|
||||||
if (sections && sections.length > 0) {
|
if (sections && sections.length > 0) {
|
||||||
@@ -338,6 +338,12 @@ const API = {
|
|||||||
} else if (scope) {
|
} else if (scope) {
|
||||||
url += `&scope=${scope}`;
|
url += `&scope=${scope}`;
|
||||||
}
|
}
|
||||||
|
if (includeBranding === false) {
|
||||||
|
url += `&branding=off`;
|
||||||
|
}
|
||||||
|
if (creator) {
|
||||||
|
url += `&creator=${encodeURIComponent(creator)}`;
|
||||||
|
}
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -578,8 +578,16 @@ const App = {
|
|||||||
// Telegram-Kategorien Toggle
|
// Telegram-Kategorien Toggle
|
||||||
const tgCheckbox = document.getElementById('inc-telegram');
|
const tgCheckbox = document.getElementById('inc-telegram');
|
||||||
if (tgCheckbox) {
|
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
|
// 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). */
|
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
|
||||||
toggleSourceOverviewDetail(el) {
|
toggleSourceOverviewDetail(el) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -1816,9 +1844,9 @@ const App = {
|
|||||||
// === Event Handlers ===
|
// === Event Handlers ===
|
||||||
|
|
||||||
_getFormData() {
|
_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 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 {
|
return {
|
||||||
title: document.getElementById('inc-title').value.trim(),
|
title: document.getElementById('inc-title').value.trim(),
|
||||||
description: document.getElementById('inc-description').value.trim() || null,
|
description: document.getElementById('inc-description').value.trim() || null,
|
||||||
@@ -1831,6 +1859,7 @@ const App = {
|
|||||||
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
|
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
|
||||||
international_sources: document.getElementById('inc-international').checked,
|
international_sources: document.getElementById('inc-international').checked,
|
||||||
include_telegram: document.getElementById('inc-telegram').checked,
|
include_telegram: document.getElementById('inc-telegram').checked,
|
||||||
|
include_x: document.getElementById('inc-x').checked,
|
||||||
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
|
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-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-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-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'; }
|
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
|
||||||
updateVisibilityHint();
|
updateVisibilityHint();
|
||||||
updateSourcesHint();
|
updateSourcesHint();
|
||||||
toggleTypeDefaults(true);
|
toggleTypeDefaults(true);
|
||||||
toggleRefreshInterval();
|
toggleRefreshInterval();
|
||||||
|
updateIntervalMin();
|
||||||
|
|
||||||
// Modal-Titel und Submit ändern
|
// 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'; }
|
{ 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;
|
return;
|
||||||
}
|
}
|
||||||
const format = document.querySelector('input[name="export-format"]:checked').value;
|
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 btn = document.getElementById('export-submit-btn');
|
||||||
const origText = btn.textContent;
|
const origText = btn.textContent;
|
||||||
@@ -2622,7 +2656,7 @@ async handleRefresh() {
|
|||||||
btn.textContent = (typeof T === 'function' ? T('action.creating', 'Wird erstellt...') : 'Wird erstellt...');
|
btn.textContent = (typeof T === 'function' ? T('action.creating', 'Wird erstellt...') : 'Wird erstellt...');
|
||||||
|
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
const err = await response.json().catch(() => ({}));
|
const err = await response.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || 'Fehler ' + response.status);
|
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 rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
|
||||||
const web = stats.by_type.web_source || { 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 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;
|
const excluded = this._myExclusions.length;
|
||||||
|
|
||||||
bar.innerHTML = `
|
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">${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">${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">${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">${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>
|
<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'; }
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
|
||||||
return;
|
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();
|
const url = urlInput.value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
|
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
|
||||||
@@ -3365,7 +3426,7 @@ async handleRefresh() {
|
|||||||
document.getElementById('src-notes').value = source.notes || '';
|
document.getElementById('src-notes').value = source.notes || '';
|
||||||
document.getElementById('src-domain').value = source.domain || '';
|
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');
|
const typeSelect = document.getElementById('src-type-select');
|
||||||
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
|
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
|
||||||
document.getElementById('src-type-display').value = typeLabel;
|
document.getElementById('src-type-display').value = typeLabel;
|
||||||
@@ -3409,7 +3470,7 @@ async handleRefresh() {
|
|||||||
name,
|
name,
|
||||||
source_type: discovered.source_type || 'web_source',
|
source_type: discovered.source_type || 'web_source',
|
||||||
category: document.getElementById('src-category').value,
|
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,
|
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
|
||||||
notes: document.getElementById('src-notes').value.trim() || null,
|
notes: document.getElementById('src-notes').value.trim() || null,
|
||||||
};
|
};
|
||||||
@@ -3581,6 +3642,7 @@ function openModal(id) {
|
|||||||
document.getElementById('inc-notify-status-change').checked = false;
|
document.getElementById('inc-notify-status-change').checked = false;
|
||||||
toggleTypeDefaults();
|
toggleTypeDefaults();
|
||||||
toggleRefreshInterval();
|
toggleRefreshInterval();
|
||||||
|
updateIntervalMin();
|
||||||
}
|
}
|
||||||
const modal = document.getElementById(id);
|
const modal = document.getElementById(id);
|
||||||
modal._previousFocus = document.activeElement;
|
modal._previousFocus = document.activeElement;
|
||||||
@@ -3762,17 +3824,38 @@ function toggleRefreshInterval() {
|
|||||||
if (startField) startField.classList.toggle('visible', mode === 'auto');
|
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() {
|
function updateIntervalMin() {
|
||||||
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
|
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
|
||||||
const input = document.getElementById('inc-refresh-value');
|
const input = document.getElementById('inc-refresh-value');
|
||||||
|
const minMinutes = _getMinIntervalMinutes();
|
||||||
|
const hint = document.getElementById('interval-min-hint');
|
||||||
if (unit === 1) {
|
if (unit === 1) {
|
||||||
// Minuten: Minimum 10
|
// Minuten: dynamisches Minimum (30 / 45 bei X oder Telegram / 60 bei beiden)
|
||||||
input.min = 10;
|
input.min = minMinutes;
|
||||||
if (parseInt(input.value) < 10) input.value = 10;
|
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 {
|
} else {
|
||||||
// Stunden/Tage/Wochen: Minimum 1
|
// Stunden/Tage/Wochen: eine Einheit liegt ueber jedem Minuten-Minimum
|
||||||
input.min = 1;
|
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1034,11 +1034,31 @@ const UI = {
|
|||||||
html += `<div class="source-lang-chips">${langChips}</div>`;
|
html += `<div class="source-lang-chips">${langChips}</div>`;
|
||||||
html += `</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">';
|
html += '<div class="source-overview-grid">';
|
||||||
data.sources.forEach(s => {
|
data.sources.forEach(s => {
|
||||||
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
|
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
|
||||||
const sourceName = this.escape(s.source || 'Unbekannt');
|
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-name">${sourceName}</span>
|
||||||
<span class="source-overview-lang">${langs}</span>
|
<span class="source-overview-lang">${langs}</span>
|
||||||
<span class="source-overview-count">${s.article_count}</span>
|
<span class="source-overview-count">${s.article_count}</span>
|
||||||
@@ -1210,6 +1230,10 @@ const UI = {
|
|||||||
/**
|
/**
|
||||||
* Domain-Gruppe rendern (aufklappbar mit Feeds).
|
* 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) {
|
renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
|
||||||
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
|
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
|
||||||
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
|
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
|
||||||
@@ -1244,7 +1268,7 @@ const UI = {
|
|||||||
realFeeds.forEach((feed, i) => {
|
realFeeds.forEach((feed, i) => {
|
||||||
const isLast = i === realFeeds.length - 1;
|
const isLast = i === realFeeds.length - 1;
|
||||||
const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
|
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) : '';
|
const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
|
||||||
feedRows += `<div class="source-feed-row">
|
feedRows += `<div class="source-feed-row">
|
||||||
<span class="source-feed-connector">${connector}</span>
|
<span class="source-feed-connector">${connector}</span>
|
||||||
@@ -1273,7 +1297,7 @@ const UI = {
|
|||||||
|| firstFeed.country_code
|
|| firstFeed.country_code
|
||||||
|| (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
|
|| (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
|
||||||
if (hasInfo) {
|
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 = [];
|
const lines = [];
|
||||||
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
|
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
|
||||||
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
|
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
|
||||||
@@ -1314,6 +1338,7 @@ const UI = {
|
|||||||
<div class="source-group-info">
|
<div class="source-group-info">
|
||||||
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
|
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
|
||||||
</div>
|
</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>
|
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
|
||||||
${classificationBadges ? `<span class="source-classification-badges">${classificationBadges}</span>` : ''}
|
${classificationBadges ? `<span class="source-classification-badges">${classificationBadges}</span>` : ''}
|
||||||
${feedCountBadge}
|
${feedCountBadge}
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren