Commits vergleichen

...

3 Commits

Autor SHA1 Nachricht Datum
6c623a8ae5 Promote develop → main (2026-05-22 11:09 UTC) 2026-05-22 13:09:25 +02:00
240222cb2a Release-Notes: X-Konten direkt im Verwaltungsportal verwalten 2026-05-22 13:09:23 +02:00
claude-dev
bd476edb13 feat(sources): X-Account-Verwaltung im Verwaltungsportal
Neuer Sub-Tab "X-Accounts" unter Quellen: die als Recherchequelle
eingebundenen X-Accounts anzeigen, hinzufuegen, bearbeiten und entfernen.
Schreibt source_type=x_account in die geteilte sources-Tabelle, von wo
der Monitor sie pro Lage nutzt.

- x_account im source_type-Pattern von GlobalSourceCreate/Update
- primary_language in Create/Update plus INSERT (Keyword-Matching)
- x_account-Typ und x-Kategorie in source_meta.py
- Sub-Tab, Tabelle und Modal in dashboard.html, Logik in sources.js

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:06:07 +00:00
5 geänderte Dateien mit 243 neuen und 7 gelöschten Zeilen

Datei anzeigen

@@ -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", "version": "2026-05-22T09:37Z",
"date": "2026-05-22", "date": "2026-05-22",

Datei anzeigen

@@ -42,7 +42,7 @@ router = APIRouter(prefix="/api/sources", tags=["sources"])
SOURCE_UPDATE_COLUMNS = { SOURCE_UPDATE_COLUMNS = {
"name", "url", "domain", "source_type", "category", "status", "notes", "name", "url", "domain", "source_type", "category", "status", "notes",
"language", "bias", "fetch_strategy", "language", "primary_language", "bias", "fetch_strategy",
"political_orientation", "media_type", "reliability", "political_orientation", "media_type", "reliability",
"state_affiliated", "country_code", "state_affiliated", "country_code",
} }
@@ -118,11 +118,12 @@ class GlobalSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None url: Optional[str] = None
domain: 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") category: str = Field(default="sonstige")
status: str = Field(default="active", pattern="^(active|inactive)$") status: str = Field(default="active", pattern="^(active|inactive)$")
notes: Optional[str] = None notes: Optional[str] = None
language: Optional[str] = Field(default=None, max_length=100) 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) bias: Optional[str] = Field(default=None, max_length=500)
fetch_strategy: Optional[str] = Field(default="default", pattern="^(default|googlebot|paywall|skip)$") 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) name: Optional[str] = Field(default=None, max_length=200)
url: Optional[str] = None url: Optional[str] = None
domain: 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 category: Optional[str] = None
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$") status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
notes: Optional[str] = None notes: Optional[str] = None
language: Optional[str] = Field(default=None, max_length=100) 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) bias: Optional[str] = Field(default=None, max_length=500)
political_orientation: Optional[str] = None political_orientation: Optional[str] = None
media_type: Optional[str] = None media_type: Optional[str] = None
@@ -230,10 +232,10 @@ async def create_global_source(
) )
cursor = await db.execute( cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, bias, fetch_strategy, added_by, tenant_id) """INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, primary_language, bias, fetch_strategy, added_by, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes, (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 src_id = cursor.lastrowid
await db.commit() await db.commit()

Datei anzeigen

@@ -38,6 +38,7 @@ SOURCE_CATEGORIES: list[CategoryEntry] = [
{"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"}, {"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"},
{"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"}, {"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"},
{"key": "syrien-nahost", "label": "Syrien / Nahost"}, {"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": "telegram_channel", "label": "Telegram-Kanal"},
{"key": "podcast_feed", "label": "Podcast-Feed"}, {"key": "podcast_feed", "label": "Podcast-Feed"},
{"key": "excluded", "label": "Ausgeschlossen"}, {"key": "excluded", "label": "Ausgeschlossen"},
{"key": "x_account", "label": "X-Account"},
] ]

Datei anzeigen

@@ -329,6 +329,7 @@
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button> <button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button> <button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
<button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button> <button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
<button class="nav-tab" data-subtab="x-accounts">X-Accounts</button>
</div> </div>
<!-- Grundquellen --> <!-- Grundquellen -->
@@ -471,6 +472,36 @@
</div> </div>
</div> </div>
<!-- X-Accounts (Sub-Tab) -->
<div class="section" id="sub-x-accounts">
<div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<input type="text" class="search-input" id="xAccountSearch" placeholder="X-Account suchen..." oninput="filterXAccounts()">
<span class="text-secondary" id="xAccountCount"></span>
</div>
<button class="btn btn-primary" id="newXAccountBtn" onclick="openXAccountModal()">+ X-Account hinzufügen</button>
</div>
<div class="card">
<p class="text-secondary" style="padding:0 4px 12px;">X-Accounts (Twitter), die der Monitor als Recherchequelle nutzt. Pro Lage über die Option „X (Twitter) einbeziehen" zuschaltbar.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Account</th>
<th>URL</th>
<th>Sprache</th>
<th>Notiz</th>
<th>Artikel</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="xAccountTable"></tbody>
</table>
</div>
</div>
</div>
</div> <!-- /sec-sources --> </div> <!-- /sec-sources -->
<!-- Audit-Log Section --> <!-- Audit-Log Section -->
@@ -938,8 +969,59 @@
</div> </div>
</div> </div>
<!-- Modal: X-Account -->
<div class="modal-overlay" id="modalXAccount">
<div class="modal">
<div class="modal-header">
<h3 id="xAccountModalTitle">X-Account hinzufügen</h3>
<button class="modal-close" onclick="closeModal('modalXAccount')">&times;</button>
</div>
<form id="xAccountForm">
<div class="modal-body">
<div class="form-group">
<label for="xAccountHandle">X-Handle oder URL</label>
<input type="text" id="xAccountHandle" required placeholder="z.B. bellingcat oder https://x.com/bellingcat">
<small class="text-secondary">Account-Name mit oder ohne @, oder die volle x.com-URL.</small>
</div>
<div class="form-group">
<label for="xAccountName">Anzeigename</label>
<input type="text" id="xAccountName" placeholder="optional, z.B. Bellingcat">
</div>
<div class="form-group">
<label for="xAccountLanguage">Sprache</label>
<select id="xAccountLanguage">
<option value="en">Englisch</option>
<option value="de">Deutsch</option>
<option value="fr">Französisch</option>
<option value="ru">Russisch</option>
<option value="uk">Ukrainisch</option>
<option value="">Unbestimmt</option>
</select>
<small class="text-secondary">Steuert das Keyword-Matching im Monitor.</small>
</div>
<div class="form-group">
<label for="xAccountNotes">Notiz</label>
<textarea id="xAccountNotes" rows="2" placeholder="Kurzbeschreibung, hilft der KI bei der Account-Auswahl pro Lage"></textarea>
</div>
<div class="form-group">
<label for="xAccountStatus">Status</label>
<select id="xAccountStatus">
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<div id="xAccountError" class="error-msg" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modalXAccount')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<script src="/static/js/app.js?v=20260522a"></script> <script src="/static/js/app.js?v=20260522a"></script>
<script src="/static/js/sources.js?v=20260509d"></script> <script src="/static/js/sources.js?v=20260522x"></script>
<script src="/static/js/source-health.js?v=20260509l"></script> <script src="/static/js/source-health.js?v=20260509l"></script>
<script src="/static/js/audit.js?v=20260509d"></script> <script src="/static/js/audit.js?v=20260509d"></script>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div> <div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>

Datei anzeigen

@@ -38,10 +38,152 @@ function setupSourceSubTabs() {
else if (subtab === "tenant-sources") loadTenantSources(); else if (subtab === "tenant-sources") loadTenantSources();
else if (subtab === "source-health") loadHealthData(); else if (subtab === "source-health") loadHealthData();
else if (subtab === "classification-review") loadClassificationQueue(); 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 = '<tr><td colspan="7" class="text-muted">Lade...</td></tr>';
try {
const all = await API.get("/api/sources/global");
xAccountsCache = (all || []).filter((s) => s.source_type === "x_account");
renderXAccounts(xAccountsCache);
} catch (err) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Fehler beim Laden</td></tr>';
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 = '<tr><td colspan="7" class="text-muted">Keine X-Accounts. Mit „+ X-Account hinzufügen" anlegen.</td></tr>';
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) : '<span class="text-muted">—</span>';
const status = s.status === "active"
? '<span style="color:var(--success,#2e7d32);">Aktiv</span>'
: '<span class="text-muted">Inaktiv</span>';
return '<tr>'
+ '<td><strong>' + esc(s.name || ("@" + handle)) + '</strong></td>'
+ '<td><a href="' + esc(url) + '" target="_blank" rel="noopener">' + esc(handle) + '</a></td>'
+ '<td>' + esc(lang) + '</td>'
+ '<td>' + notes + '</td>'
+ '<td>' + (s.article_count || 0) + '</td>'
+ '<td>' + status + '</td>'
+ '<td>'
+ '<button class="btn btn-secondary btn-small" onclick="openXAccountModal(' + s.id + ')">Bearbeiten</button> '
+ '<button class="btn btn-danger btn-small" onclick="confirmDeleteXAccount(' + s.id + ')">Löschen</button>'
+ '</td>'
+ '</tr>';
}).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 --- // --- Grundquellen ---
async function loadGlobalSources() { async function loadGlobalSources() {
try { try {