Promote develop → main (2026-05-22 11:13 UTC) #12

Zusammengeführt
IntelSight_Admin hat 3 Commits von develop nach main 2026-05-22 13:13:42 +02:00 zusammengeführt
4 geänderte Dateien mit 7 neuen und 235 gelöschten Zeilen
Nur Änderungen aus Commit a27fe44b0b werden angezeigt - Alle Commits anzeigen

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", "primary_language", "bias", "fetch_strategy", "language", "bias", "fetch_strategy",
"political_orientation", "media_type", "reliability", "political_orientation", "media_type", "reliability",
"state_affiliated", "country_code", "state_affiliated", "country_code",
} }
@@ -118,12 +118,11 @@ 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|x_account)$") source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
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)$")
@@ -132,12 +131,11 @@ 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|x_account)$") source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
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
@@ -232,10 +230,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, primary_language, bias, fetch_strategy, added_by, tenant_id) """INSERT INTO sources (name, url, domain, source_type, category, status, notes, 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.primary_language, data.bias, data.fetch_strategy or "default"), data.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,7 +38,6 @@ 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"},
] ]
@@ -48,7 +47,6 @@ 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,7 +329,6 @@
<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 -->
@@ -472,36 +471,6 @@
</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 -->
@@ -969,59 +938,8 @@
</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=20260522x"></script> <script src="/static/js/sources.js?v=20260509d"></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,152 +38,10 @@ 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 {