Promote develop → main (2026-05-22 11:09 UTC)
This commit was merged in pull request #11.
Dieser Commit ist enthalten in:
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')">×</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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren