diff --git a/RELEASES.json b/RELEASES.json
index 7dbffef..7153d53 100644
--- a/RELEASES.json
+++ b/RELEASES.json
@@ -1,4 +1,12 @@
[
+ {
+ "version": "2026-05-22T11:09Z",
+ "date": "2026-05-22",
+ "title": "X-Konten direkt im Verwaltungsportal verwalten",
+ "items": [
+ "X-Konten können jetzt zentral über das Verwaltungsportal angelegt und verwaltet werden."
+ ]
+ },
{
"version": "2026-05-22T09:37Z",
"date": "2026-05-22",
diff --git a/src/routers/sources.py b/src/routers/sources.py
index 458893b..55377f9 100644
--- a/src/routers/sources.py
+++ b/src/routers/sources.py
@@ -42,7 +42,7 @@ router = APIRouter(prefix="/api/sources", tags=["sources"])
SOURCE_UPDATE_COLUMNS = {
"name", "url", "domain", "source_type", "category", "status", "notes",
- "language", "bias", "fetch_strategy",
+ "language", "primary_language", "bias", "fetch_strategy",
"political_orientation", "media_type", "reliability",
"state_affiliated", "country_code",
}
@@ -118,11 +118,12 @@ class GlobalSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
- source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
+ source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$")
category: str = Field(default="sonstige")
status: str = Field(default="active", pattern="^(active|inactive)$")
notes: Optional[str] = None
language: Optional[str] = Field(default=None, max_length=100)
+ primary_language: Optional[str] = Field(default=None, max_length=16)
bias: Optional[str] = Field(default=None, max_length=500)
fetch_strategy: Optional[str] = Field(default="default", pattern="^(default|googlebot|paywall|skip)$")
@@ -131,11 +132,12 @@ class GlobalSourceUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
- source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
+ source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$")
category: Optional[str] = None
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
notes: Optional[str] = None
language: Optional[str] = Field(default=None, max_length=100)
+ primary_language: Optional[str] = Field(default=None, max_length=16)
bias: Optional[str] = Field(default=None, max_length=500)
political_orientation: Optional[str] = None
media_type: Optional[str] = None
@@ -230,10 +232,10 @@ async def create_global_source(
)
cursor = await db.execute(
- """INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, bias, fetch_strategy, added_by, tenant_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
+ """INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, primary_language, bias, fetch_strategy, added_by, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes,
- data.language, data.bias, data.fetch_strategy or "default"),
+ data.language, data.primary_language, data.bias, data.fetch_strategy or "default"),
)
src_id = cursor.lastrowid
await db.commit()
diff --git a/src/source_meta.py b/src/source_meta.py
index 9f6faa8..4a9e25f 100644
--- a/src/source_meta.py
+++ b/src/source_meta.py
@@ -38,6 +38,7 @@ SOURCE_CATEGORIES: list[CategoryEntry] = [
{"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"},
{"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"},
{"key": "syrien-nahost", "label": "Syrien / Nahost"},
+ {"key": "x", "label": "X-Recherche"},
]
@@ -47,6 +48,7 @@ SOURCE_TYPES: list[TypeEntry] = [
{"key": "telegram_channel", "label": "Telegram-Kanal"},
{"key": "podcast_feed", "label": "Podcast-Feed"},
{"key": "excluded", "label": "Ausgeschlossen"},
+ {"key": "x_account", "label": "X-Account"},
]
diff --git a/src/static/dashboard.html b/src/static/dashboard.html
index b4813a3..10051c4 100644
--- a/src/static/dashboard.html
+++ b/src/static/dashboard.html
@@ -329,6 +329,7 @@
+
@@ -471,6 +472,36 @@
+
+
+
+
+
+
+
+
+
+
+
X-Accounts (Twitter), die der Monitor als Recherchequelle nutzt. Pro Lage über die Option „X (Twitter) einbeziehen" zuschaltbar.
+
+
+
+
+ | Account |
+ URL |
+ Sprache |
+ Notiz |
+ Artikel |
+ Status |
+ Aktionen |
+
+
+
+
+
+
+
+
@@ -938,8 +969,59 @@
+
+
+
-
+
diff --git a/src/static/js/sources.js b/src/static/js/sources.js
index 7c51850..2b4aa12 100644
--- a/src/static/js/sources.js
+++ b/src/static/js/sources.js
@@ -38,10 +38,152 @@ function setupSourceSubTabs() {
else if (subtab === "tenant-sources") loadTenantSources();
else if (subtab === "source-health") loadHealthData();
else if (subtab === "classification-review") loadClassificationQueue();
+ else if (subtab === "x-accounts") loadXAccounts();
});
});
}
+// --- X-Accounts (Recherche-Accounts für den Monitor) ---
+let xAccountsCache = [];
+let editingXAccountId = null;
+
+function normalizeXHandle(raw) {
+ let h = (raw || "").trim();
+ h = h.replace(/^https?:\/\//i, "").replace(/^www\./i, "");
+ h = h.replace(/^(x\.com\/|twitter\.com\/|nitter\.net\/)/i, "");
+ h = h.replace(/^@/, "").replace(/\/+$/, "");
+ return h.split(/[/?#]/)[0];
+}
+
+async function loadXAccounts() {
+ setupXAccountForm();
+ const tbody = document.getElementById("xAccountTable");
+ tbody.innerHTML = '| Lade... |
';
+ try {
+ const all = await API.get("/api/sources/global");
+ xAccountsCache = (all || []).filter((s) => s.source_type === "x_account");
+ renderXAccounts(xAccountsCache);
+ } catch (err) {
+ tbody.innerHTML = '| Fehler beim Laden |
';
+ showToast("X-Accounts konnten nicht geladen werden", "error");
+ }
+}
+
+function renderXAccounts(list) {
+ const tbody = document.getElementById("xAccountTable");
+ const cnt = document.getElementById("xAccountCount");
+ if (cnt) cnt.textContent = list.length + (list.length === 1 ? " Account" : " Accounts");
+ if (!list.length) {
+ tbody.innerHTML = '| Keine X-Accounts. Mit „+ X-Account hinzufügen" anlegen. |
';
+ return;
+ }
+ tbody.innerHTML = list.map((s) => {
+ const handle = normalizeXHandle(s.url || s.domain || s.name || "");
+ const url = "https://x.com/" + handle;
+ const lang = s.primary_language || s.language || "—";
+ const notes = s.notes ? esc(s.notes) : '—';
+ const status = s.status === "active"
+ ? 'Aktiv'
+ : 'Inaktiv';
+ return ''
+ + '| ' + esc(s.name || ("@" + handle)) + ' | '
+ + '' + esc(handle) + ' | '
+ + '' + esc(lang) + ' | '
+ + '' + notes + ' | '
+ + '' + (s.article_count || 0) + ' | '
+ + '' + status + ' | '
+ + ''
+ + ' '
+ + ''
+ + ' | '
+ + '
';
+ }).join("");
+}
+
+function filterXAccounts() {
+ const q = (document.getElementById("xAccountSearch").value || "").toLowerCase();
+ if (!q) { renderXAccounts(xAccountsCache); return; }
+ renderXAccounts(xAccountsCache.filter((s) =>
+ (s.name || "").toLowerCase().includes(q)
+ || (s.url || "").toLowerCase().includes(q)
+ || (s.notes || "").toLowerCase().includes(q)
+ ));
+}
+
+function openXAccountModal(id) {
+ editingXAccountId = id || null;
+ const errEl = document.getElementById("xAccountError");
+ errEl.style.display = "none";
+ const s = editingXAccountId ? xAccountsCache.find((a) => a.id === editingXAccountId) : null;
+ if (editingXAccountId && !s) return;
+ document.getElementById("xAccountModalTitle").textContent = s ? "X-Account bearbeiten" : "X-Account hinzufügen";
+ document.getElementById("xAccountHandle").value = s ? normalizeXHandle(s.url || s.domain || "") : "";
+ document.getElementById("xAccountName").value = s ? (s.name || "") : "";
+ document.getElementById("xAccountLanguage").value = s ? (s.primary_language || s.language || "en") : "en";
+ document.getElementById("xAccountNotes").value = s ? (s.notes || "") : "";
+ document.getElementById("xAccountStatus").value = s ? (s.status || "active") : "active";
+ openModal("modalXAccount");
+}
+
+function setupXAccountForm() {
+ const form = document.getElementById("xAccountForm");
+ if (!form || form.dataset.wired) return;
+ form.dataset.wired = "1";
+ form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const errEl = document.getElementById("xAccountError");
+ errEl.style.display = "none";
+ const handle = normalizeXHandle(document.getElementById("xAccountHandle").value);
+ if (!handle) {
+ errEl.textContent = "Bitte einen Handle oder eine x.com-URL eingeben.";
+ errEl.style.display = "block";
+ return;
+ }
+ const nameVal = document.getElementById("xAccountName").value.trim();
+ const body = {
+ name: nameVal || ("@" + handle),
+ url: "x.com/" + handle,
+ domain: "x.com/" + handle,
+ source_type: "x_account",
+ category: "x",
+ status: document.getElementById("xAccountStatus").value,
+ notes: document.getElementById("xAccountNotes").value.trim() || null,
+ primary_language: document.getElementById("xAccountLanguage").value || null,
+ };
+ try {
+ if (editingXAccountId) {
+ await API.put("/api/sources/global/" + editingXAccountId, body);
+ } else {
+ await API.post("/api/sources/global", body);
+ }
+ closeModal("modalXAccount");
+ loadXAccounts();
+ showToast("X-Account gespeichert.", "success");
+ } catch (err) {
+ errEl.textContent = err.message || "Speichern fehlgeschlagen";
+ errEl.style.display = "block";
+ }
+ });
+}
+
+function confirmDeleteXAccount(id) {
+ const s = xAccountsCache.find((a) => a.id === id);
+ if (!s) return;
+ showConfirm(
+ "X-Account entfernen",
+ 'Soll der X-Account "' + (s.name || "") + '" als Recherchequelle entfernt werden?',
+ async () => {
+ try {
+ await API.del("/api/sources/global/" + id);
+ loadXAccounts();
+ showToast("X-Account entfernt.", "success");
+ } catch (err) {
+ showToast(err.message || "Löschen fehlgeschlagen", "error");
+ }
+ }
+ );
+}
+
// --- Grundquellen ---
async function loadGlobalSources() {
try {