diff --git a/backend/app/admin_ui.py b/backend/app/admin_ui.py
index f276b8c..8d8e879 100644
--- a/backend/app/admin_ui.py
+++ b/backend/app/admin_ui.py
@@ -15,6 +15,7 @@ from .auth import create_session_token, verify_credentials, verify_session_token
from .config import get_settings
from .ingestion import run_ingestion
from .policy import evaluate_source_policy
+from .publisher import enqueue_publish, run_publisher
from .relevance import article_age_days, article_relevance
from .repositories import (
FeedCreate,
@@ -25,6 +26,7 @@ from .repositories import (
get_feed_by_id,
list_articles,
list_feeds,
+ list_publish_jobs,
list_runs,
list_sources,
set_article_image_decision,
@@ -273,6 +275,7 @@ def admin_dashboard(request: Request):
source_policy = {s["id"]: evaluate_source_policy(s) for s in sources}
feeds = list_feeds()
runs = list_runs(limit=30)
+ publish_jobs = list_publish_jobs(limit=30)
status_filter = request.query_params.get("status_filter")
if status_filter in {"new", "rewrite", "review", "approved", "published", "error"}:
articles = list_articles(limit=100, status_filter=status_filter)
@@ -308,6 +311,7 @@ def admin_dashboard(request: Request):
"source_policy": source_policy,
"feeds": feeds,
"runs": runs,
+ "publish_jobs": publish_jobs,
"articles": articles,
"status_options": ["new", "rewrite", "review", "approved", "published", "error"],
"allowed_transitions": ALLOWED_TRANSITIONS,
@@ -358,6 +362,8 @@ def admin_article_detail(request: Request, article_id: int):
"feed": feed,
"checklist": checklist,
"allowed_transitions": ALLOWED_TRANSITIONS.get(article.get("status"), ()),
+ "flash_msg": request.query_params.get("msg", ""),
+ "flash_type": request.query_params.get("type", "success"),
},
)
@@ -379,6 +385,32 @@ def admin_article_image_decision(
return RedirectResponse(url=f"/admin/articles/{article_id}", status_code=303)
+@router.post("/admin/articles/{article_id}/publish-enqueue")
+def admin_enqueue_publish(request: Request, article_id: int, max_attempts: str = Form("3")):
+ user = _admin_user(request)
+ if not user:
+ return RedirectResponse(url="/admin/login", status_code=303)
+ try:
+ job_id = enqueue_publish(article_id=article_id, max_attempts=max(1, int(max_attempts)))
+ except Exception as exc:
+ return _dashboard_redirect(msg=f"Publish Queue Fehler fuer Artikel #{article_id}: {exc}", msg_type="error")
+ return RedirectResponse(url=f"/admin/articles/{article_id}?msg=Publish-Job%20#{job_id}%20erstellt&type=success", status_code=303)
+
+
+@router.post("/admin/publisher/run")
+def admin_run_publisher(request: Request, max_jobs: str = Form("10")):
+ user = _admin_user(request)
+ if not user:
+ return RedirectResponse(url="/admin/login", status_code=303)
+ try:
+ stats = run_publisher(max_jobs=max(1, int(max_jobs)))
+ except Exception as exc:
+ return _dashboard_redirect(msg=f"Publisher Fehler: {exc}", msg_type="error")
+ return _dashboard_redirect(
+ msg=f"Publisher: processed={stats.processed}, success={stats.success}, failed={stats.failed}, requeued={stats.requeued}"
+ )
+
+
@router.get("/admin/images/proxy")
def admin_image_proxy(request: Request, url: str):
if not _is_http_image_url(url):
diff --git a/backend/app/config.py b/backend/app/config.py
index f32b8c4..40deedb 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -23,6 +23,11 @@ class Settings(BaseSettings):
app_db_path: str = "backend/data/rss_news.db"
+ wordpress_base_url: str | None = None
+ wordpress_username: str | None = None
+ wordpress_app_password: str | None = None
+ wordpress_default_status: str = "draft"
+
@lru_cache(maxsize=1)
def get_settings() -> Settings:
diff --git a/backend/app/db.py b/backend/app/db.py
index 27bbc10..d2ebfd5 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -68,6 +68,21 @@ def init_db() -> None:
details TEXT
);
+ CREATE TABLE IF NOT EXISTS publish_jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ article_id INTEGER NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ max_attempts INTEGER NOT NULL DEFAULT 3,
+ error_message TEXT,
+ wp_post_id INTEGER,
+ wp_post_url TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ started_at TEXT,
+ finished_at TEXT,
+ FOREIGN KEY(article_id) REFERENCES articles(id) ON DELETE CASCADE
+ );
+
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER,
@@ -89,6 +104,11 @@ def init_db() -> None:
legal_checked INTEGER NOT NULL DEFAULT 0,
legal_checked_at TEXT,
legal_note TEXT,
+ wp_post_id INTEGER,
+ wp_post_url TEXT,
+ publish_attempts INTEGER NOT NULL DEFAULT 0,
+ publish_last_error TEXT,
+ published_to_wp_at TEXT,
word_count INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'rewrite', 'review', 'approved', 'published', 'error')),
meta_json TEXT,
@@ -110,6 +130,7 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_feeds_source_id ON feeds(source_id);
CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at);
CREATE INDEX IF NOT EXISTS idx_articles_published_at ON articles(published_at);
+ CREATE INDEX IF NOT EXISTS idx_publish_jobs_status_created_at ON publish_jobs(status, created_at);
CREATE TRIGGER IF NOT EXISTS trg_sources_updated_at
AFTER UPDATE ON sources
@@ -148,11 +169,40 @@ def init_db() -> None:
"legal_checked": "ALTER TABLE articles ADD COLUMN legal_checked INTEGER NOT NULL DEFAULT 0",
"legal_checked_at": "ALTER TABLE articles ADD COLUMN legal_checked_at TEXT",
"legal_note": "ALTER TABLE articles ADD COLUMN legal_note TEXT",
+ "wp_post_id": "ALTER TABLE articles ADD COLUMN wp_post_id INTEGER",
+ "wp_post_url": "ALTER TABLE articles ADD COLUMN wp_post_url TEXT",
+ "publish_attempts": "ALTER TABLE articles ADD COLUMN publish_attempts INTEGER NOT NULL DEFAULT 0",
+ "publish_last_error": "ALTER TABLE articles ADD COLUMN publish_last_error TEXT",
+ "published_to_wp_at": "ALTER TABLE articles ADD COLUMN published_to_wp_at TEXT",
}
for column, ddl in migration_columns.items():
if column not in existing_columns:
conn.execute(ddl)
+ table_rows = conn.execute(
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'publish_jobs'"
+ ).fetchall()
+ if not table_rows:
+ conn.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS publish_jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ article_id INTEGER NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ max_attempts INTEGER NOT NULL DEFAULT 3,
+ error_message TEXT,
+ wp_post_id INTEGER,
+ wp_post_url TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ started_at TEXT,
+ finished_at TEXT,
+ FOREIGN KEY(article_id) REFERENCES articles(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS idx_publish_jobs_status_created_at ON publish_jobs(status, created_at);
+ """
+ )
+
def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
return [dict(r) for r in rows]
diff --git a/backend/app/ingestion.py b/backend/app/ingestion.py
index 8a7696a..872a1b0 100644
--- a/backend/app/ingestion.py
+++ b/backend/app/ingestion.py
@@ -289,6 +289,11 @@ def run_ingestion(feed_id: int | None = None) -> IngestionStats:
legal_checked=False,
legal_checked_at=None,
legal_note=None,
+ wp_post_id=None,
+ wp_post_url=None,
+ publish_attempts=0,
+ publish_last_error=None,
+ published_to_wp_at=None,
word_count=len((final_content_raw or "").split()),
status="new",
meta_json=json.dumps({"attribution": attribution, "extraction": extraction_meta}, ensure_ascii=False),
diff --git a/backend/app/main.py b/backend/app/main.py
index 177c312..c0a0143 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -16,6 +16,7 @@ from .config import get_settings
from .db import init_db
from .ingestion import run_ingestion
from .policy import evaluate_source_policy, is_source_allowed
+from .publisher import enqueue_publish, run_publisher
from .relevance import article_age_days, article_relevance
from .repositories import (
ArticleUpsert,
@@ -30,6 +31,7 @@ from .repositories import (
get_feed_by_id,
get_run_by_id,
get_source_by_id,
+ list_publish_jobs,
list_articles as repo_list_articles,
list_feeds as repo_list_feeds,
list_runs,
@@ -111,6 +113,11 @@ class ArticleUpsertRequest(BaseModel):
legal_checked: bool = False
legal_checked_at: str | None = None
legal_note: str | None = None
+ wp_post_id: int | None = None
+ wp_post_url: str | None = None
+ publish_attempts: int = 0
+ publish_last_error: str | None = None
+ published_to_wp_at: str | None = None
word_count: int = 0
status: str = Field(default="new", pattern="^(new|rewrite|review|approved|published|error)$")
meta_json: str | None = None
@@ -135,6 +142,15 @@ class ArticleLegalReviewRequest(BaseModel):
note: str | None = None
+class PublisherEnqueueRequest(BaseModel):
+ article_id: int
+ max_attempts: int = 3
+
+
+class PublisherRunRequest(BaseModel):
+ max_jobs: int = 10
+
+
ALLOWED_ARTICLE_TRANSITIONS: dict[str, set[str]] = {
"new": {"review", "rewrite", "error"},
"rewrite": {"review", "error"},
@@ -446,6 +462,11 @@ def api_upsert_article(payload: ArticleUpsertRequest, username: str = Depends(re
legal_checked=payload.legal_checked,
legal_checked_at=payload.legal_checked_at,
legal_note=payload.legal_note,
+ wp_post_id=payload.wp_post_id,
+ wp_post_url=payload.wp_post_url,
+ publish_attempts=payload.publish_attempts,
+ publish_last_error=payload.publish_last_error,
+ published_to_wp_at=payload.published_to_wp_at,
word_count=payload.word_count,
status=payload.status,
meta_json=payload.meta_json,
@@ -495,6 +516,35 @@ def api_article_legal_review(article_id: int, payload: ArticleLegalReviewRequest
}
+@app.get("/api/publisher/jobs")
+def api_publisher_jobs(limit: int = 100, username: str = Depends(require_auth)) -> dict:
+ return {"ok": True, "items": list_publish_jobs(limit=limit), "requested_by": username}
+
+
+@app.post("/api/publisher/enqueue")
+def api_publisher_enqueue(payload: PublisherEnqueueRequest, username: str = Depends(require_auth)) -> dict:
+ article = get_article_by_id(payload.article_id)
+ if not article:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
+ job_id = enqueue_publish(article_id=payload.article_id, max_attempts=payload.max_attempts)
+ return {"ok": True, "job_id": job_id, "article_id": payload.article_id, "requested_by": username}
+
+
+@app.post("/api/publisher/run")
+def api_publisher_run(payload: PublisherRunRequest, username: str = Depends(require_auth)) -> dict:
+ stats = run_publisher(max_jobs=payload.max_jobs)
+ return {
+ "ok": True,
+ "requested_by": username,
+ "stats": {
+ "processed": stats.processed,
+ "success": stats.success,
+ "failed": stats.failed,
+ "requeued": stats.requeued,
+ },
+ }
+
+
@app.post("/api/articles/{article_id}/review")
def api_article_review(article_id: int, payload: ArticleReviewRequest, username: str = Depends(require_auth)) -> dict:
article = get_article_by_id(article_id)
diff --git a/backend/app/publisher.py b/backend/app/publisher.py
new file mode 100644
index 0000000..06cc8f2
--- /dev/null
+++ b/backend/app/publisher.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .repositories import (
+ claim_next_publish_job,
+ complete_publish_job,
+ create_publish_job,
+ fail_publish_job,
+ get_article_by_id,
+ mark_article_publish_result,
+ PublishJobCreate,
+)
+from .wordpress import publish_article_draft, selected_image_exists
+
+
+@dataclass(frozen=True)
+class PublisherStats:
+ processed: int
+ success: int
+ failed: int
+ requeued: int
+
+
+def enqueue_publish(article_id: int, max_attempts: int = 3) -> int:
+ return create_publish_job(PublishJobCreate(article_id=article_id, max_attempts=max_attempts))
+
+
+def _can_publish(article: dict) -> tuple[bool, str | None]:
+ if article.get("status") not in {"approved", "published"}:
+ return False, "Artikelstatus muss 'approved' sein"
+ if int(article.get("legal_checked", 0)) != 1:
+ return False, "Rechtsfreigabe fehlt"
+ if not selected_image_exists(article):
+ return False, "Hauptbild nicht gesetzt"
+ return True, None
+
+
+def run_publisher(max_jobs: int = 10) -> PublisherStats:
+ processed = 0
+ success = 0
+ failed = 0
+ requeued = 0
+
+ for _ in range(max(1, max_jobs)):
+ job = claim_next_publish_job()
+ if not job:
+ break
+ processed += 1
+ job_id = int(job["id"])
+ article_id = int(job["article_id"])
+
+ article = get_article_by_id(article_id)
+ if not article:
+ fail_publish_job(job_id, "Artikel nicht gefunden", requeue=False)
+ failed += 1
+ continue
+
+ allowed, reason = _can_publish(article)
+ if not allowed:
+ fail_publish_job(job_id, reason or "Publish-Bedingungen nicht erfüllt", requeue=False)
+ mark_article_publish_result(
+ article_id,
+ wp_post_id=article.get("wp_post_id"),
+ wp_post_url=article.get("wp_post_url"),
+ error=reason or "blocked",
+ increment_attempts=True,
+ set_published_status=False,
+ )
+ failed += 1
+ continue
+
+ try:
+ wp_post_id, wp_post_url = publish_article_draft(article)
+ complete_publish_job(job_id, wp_post_id=wp_post_id, wp_post_url=wp_post_url)
+ mark_article_publish_result(
+ article_id,
+ wp_post_id=wp_post_id,
+ wp_post_url=wp_post_url,
+ error=None,
+ increment_attempts=True,
+ set_published_status=True,
+ )
+ success += 1
+ except Exception as exc:
+ attempts = int(job.get("attempts", 1))
+ max_attempts = int(job.get("max_attempts", 3))
+ should_requeue = attempts < max_attempts
+ fail_publish_job(job_id, str(exc), requeue=should_requeue)
+ mark_article_publish_result(
+ article_id,
+ wp_post_id=article.get("wp_post_id"),
+ wp_post_url=article.get("wp_post_url"),
+ error=str(exc),
+ increment_attempts=True,
+ set_published_status=False,
+ )
+ if should_requeue:
+ requeued += 1
+ else:
+ failed += 1
+
+ return PublisherStats(processed=processed, success=success, failed=failed, requeued=requeued)
diff --git a/backend/app/repositories.py b/backend/app/repositories.py
index 164fc79..ca25821 100644
--- a/backend/app/repositories.py
+++ b/backend/app/repositories.py
@@ -56,11 +56,22 @@ class ArticleUpsert:
legal_checked: bool
legal_checked_at: str | None
legal_note: str | None
+ wp_post_id: int | None
+ wp_post_url: str | None
+ publish_attempts: int
+ publish_last_error: str | None
+ published_to_wp_at: str | None
word_count: int
status: str
meta_json: str | None
+@dataclass(frozen=True)
+class PublishJobCreate:
+ article_id: int
+ max_attempts: int = 3
+
+
def create_source(payload: SourceCreate) -> int:
with get_conn() as conn:
cur = conn.execute(
@@ -235,6 +246,7 @@ def get_article_by_id(article_id: int) -> dict[str, Any] | None:
a.summary, a.content_raw, a.content_rewritten, a.image_urls_json, a.press_contact,
a.source_name_snapshot, a.source_terms_url_snapshot, a.source_license_name_snapshot,
a.legal_checked, a.legal_checked_at, a.legal_note,
+ a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at,
a.word_count, a.status, a.meta_json, a.created_at, a.updated_at
FROM articles a
WHERE a.id = ?
@@ -375,6 +387,147 @@ def set_article_image_decision(article_id: int, image_url: str, action: str, act
return True
+def create_publish_job(payload: PublishJobCreate) -> int:
+ with get_conn() as conn:
+ existing = conn.execute(
+ """
+ SELECT id FROM publish_jobs
+ WHERE article_id = ? AND status IN ('queued', 'running')
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (payload.article_id,),
+ ).fetchone()
+ if existing:
+ return int(existing["id"])
+
+ cur = conn.execute(
+ """
+ INSERT INTO publish_jobs (article_id, status, attempts, max_attempts)
+ VALUES (?, 'queued', 0, ?)
+ """,
+ (payload.article_id, max(1, payload.max_attempts)),
+ )
+ return int(cur.lastrowid)
+
+
+def list_publish_jobs(limit: int = 100) -> list[dict[str, Any]]:
+ safe_limit = max(1, min(limit, 500))
+ with get_conn() as conn:
+ rows = conn.execute(
+ """
+ SELECT j.id, j.article_id, j.status, j.attempts, j.max_attempts, j.error_message, j.wp_post_id, j.wp_post_url,
+ j.created_at, j.started_at, j.finished_at, a.title AS article_title
+ FROM publish_jobs j
+ LEFT JOIN articles a ON a.id = j.article_id
+ ORDER BY j.id DESC
+ LIMIT ?
+ """,
+ (safe_limit,),
+ ).fetchall()
+ return rows_to_dicts(rows)
+
+
+def claim_next_publish_job() -> dict[str, Any] | None:
+ with get_conn() as conn:
+ row = conn.execute(
+ """
+ SELECT id, article_id, status, attempts, max_attempts, error_message, wp_post_id, wp_post_url
+ FROM publish_jobs
+ WHERE status = 'queued' AND attempts < max_attempts
+ ORDER BY id ASC
+ LIMIT 1
+ """
+ ).fetchone()
+ if not row:
+ return None
+ job_id = int(row["id"])
+ conn.execute(
+ """
+ UPDATE publish_jobs
+ SET status = 'running',
+ attempts = attempts + 1,
+ started_at = datetime('now'),
+ finished_at = NULL
+ WHERE id = ?
+ """,
+ (job_id,),
+ )
+ claimed = conn.execute(
+ """
+ SELECT id, article_id, status, attempts, max_attempts, error_message, wp_post_id, wp_post_url
+ FROM publish_jobs
+ WHERE id = ?
+ """,
+ (job_id,),
+ ).fetchone()
+ return dict(claimed) if claimed else None
+
+
+def complete_publish_job(job_id: int, wp_post_id: int | None, wp_post_url: str | None) -> None:
+ with get_conn() as conn:
+ conn.execute(
+ """
+ UPDATE publish_jobs
+ SET status = 'success',
+ wp_post_id = ?,
+ wp_post_url = ?,
+ error_message = NULL,
+ finished_at = datetime('now')
+ WHERE id = ?
+ """,
+ (wp_post_id, wp_post_url, job_id),
+ )
+
+
+def fail_publish_job(job_id: int, error_message: str, requeue: bool) -> None:
+ next_status = "queued" if requeue else "failed"
+ with get_conn() as conn:
+ conn.execute(
+ """
+ UPDATE publish_jobs
+ SET status = ?,
+ error_message = ?,
+ finished_at = datetime('now')
+ WHERE id = ?
+ """,
+ (next_status, error_message[:2000], job_id),
+ )
+
+
+def mark_article_publish_result(
+ article_id: int,
+ *,
+ wp_post_id: int | None,
+ wp_post_url: str | None,
+ error: str | None,
+ increment_attempts: bool,
+ set_published_status: bool,
+) -> None:
+ with get_conn() as conn:
+ conn.execute(
+ """
+ UPDATE articles
+ SET wp_post_id = ?,
+ wp_post_url = ?,
+ publish_attempts = CASE WHEN ? THEN publish_attempts + 1 ELSE publish_attempts END,
+ publish_last_error = ?,
+ published_to_wp_at = CASE WHEN ? IS NOT NULL THEN datetime('now') ELSE published_to_wp_at END,
+ status = CASE WHEN ? THEN 'published' ELSE status END
+ WHERE id = ?
+ """,
+ (
+ wp_post_id,
+ wp_post_url,
+ 1 if increment_attempts else 0,
+ error[:2000] if error else None,
+ wp_post_id,
+ 1 if set_published_status else 0,
+ article_id,
+ ),
+ )
+
+
def _resolve_existing_article_id(payload: ArticleUpsert) -> int | None:
with get_conn() as conn:
# 1) strongest key: source_url
@@ -417,8 +570,9 @@ def upsert_article(payload: ArticleUpsert) -> int:
summary, content_raw, content_rewritten, image_urls_json, press_contact,
source_name_snapshot, source_terms_url_snapshot, source_license_name_snapshot,
legal_checked, legal_checked_at, legal_note,
+ wp_post_id, wp_post_url, publish_attempts, publish_last_error, published_to_wp_at,
word_count, status, meta_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload.feed_id,
@@ -440,6 +594,11 @@ def upsert_article(payload: ArticleUpsert) -> int:
1 if payload.legal_checked else 0,
payload.legal_checked_at,
payload.legal_note,
+ payload.wp_post_id,
+ payload.wp_post_url,
+ payload.publish_attempts,
+ payload.publish_last_error,
+ payload.published_to_wp_at,
payload.word_count,
payload.status,
payload.meta_json,
@@ -469,6 +628,11 @@ def upsert_article(payload: ArticleUpsert) -> int:
legal_checked = ?,
legal_checked_at = ?,
legal_note = ?,
+ wp_post_id = ?,
+ wp_post_url = ?,
+ publish_attempts = ?,
+ publish_last_error = ?,
+ published_to_wp_at = ?,
word_count = ?,
status = ?,
meta_json = ?
@@ -494,6 +658,11 @@ def upsert_article(payload: ArticleUpsert) -> int:
1 if payload.legal_checked else 0,
payload.legal_checked_at,
payload.legal_note,
+ payload.wp_post_id,
+ payload.wp_post_url,
+ payload.publish_attempts,
+ payload.publish_last_error,
+ payload.published_to_wp_at,
payload.word_count,
payload.status,
payload.meta_json,
@@ -515,7 +684,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
SELECT a.id, a.feed_id, a.source_article_id, a.source_hash, a.title, a.source_url, a.canonical_url, a.published_at, a.author,
a.summary, a.content_raw, a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name,
a.image_urls_json, a.press_contact, a.source_name_snapshot, a.source_terms_url_snapshot,
- a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note
+ a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note,
+ a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at
FROM articles a
LEFT JOIN feeds f ON f.id = a.feed_id
WHERE a.status = ?
@@ -530,7 +700,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
SELECT a.id, a.feed_id, a.source_article_id, a.source_hash, a.title, a.source_url, a.canonical_url, a.published_at, a.author,
a.summary, a.content_raw, a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name,
a.image_urls_json, a.press_contact, a.source_name_snapshot, a.source_terms_url_snapshot,
- a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note
+ a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note,
+ a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at
FROM articles a
LEFT JOIN feeds f ON f.id = a.feed_id
ORDER BY a.id DESC
diff --git a/backend/app/wordpress.py b/backend/app/wordpress.py
new file mode 100644
index 0000000..adb4d9c
--- /dev/null
+++ b/backend/app/wordpress.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import base64
+import json
+from typing import Any
+from urllib.request import Request, urlopen
+
+from .config import get_settings
+
+
+def _auth_header(username: str, app_password: str) -> str:
+ token = base64.b64encode(f"{username}:{app_password}".encode("utf-8")).decode("ascii")
+ return f"Basic {token}"
+
+
+def _wp_request(
+ *,
+ base_url: str,
+ auth_header: str,
+ method: str,
+ endpoint: str,
+ payload: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ url = f"{base_url.rstrip('/')}/wp-json/wp/v2/{endpoint.lstrip('/')}"
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
+ req = Request(
+ url=url,
+ data=data,
+ method=method,
+ headers={
+ "Authorization": auth_header,
+ "Content-Type": "application/json; charset=utf-8",
+ "Accept": "application/json",
+ "User-Agent": "rss-news-publisher/1.0",
+ },
+ )
+ with urlopen(req, timeout=20) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ parsed = json.loads(raw) if raw else {}
+ return parsed if isinstance(parsed, dict) else {}
+
+
+def _selected_image_url_from_meta(meta_json: str | None) -> str | None:
+ if not meta_json:
+ return None
+ try:
+ meta = json.loads(meta_json)
+ except Exception:
+ return None
+ if not isinstance(meta, dict):
+ return None
+ image_review = meta.get("image_review")
+ if not isinstance(image_review, dict):
+ return None
+ selected = image_review.get("selected_url")
+ return selected if isinstance(selected, str) and selected.strip() else None
+
+
+def publish_article_draft(article: dict[str, Any]) -> tuple[int, str | None]:
+ settings = get_settings()
+ if not settings.wordpress_base_url or not settings.wordpress_username or not settings.wordpress_app_password:
+ raise RuntimeError("WordPress Konfiguration fehlt (base_url, username, app_password)")
+
+ auth = _auth_header(settings.wordpress_username, settings.wordpress_app_password)
+
+ source_url = article.get("source_url") or ""
+ canonical_url = article.get("canonical_url") or source_url
+ title = (article.get("title") or "Ohne Titel").strip()
+ body = (article.get("content_rewritten") or article.get("content_raw") or "").strip()
+ if not body:
+ body = article.get("summary") or ""
+
+ footer = "\n\n
\nQuelle: "
+ footer += f"{source_url}
"
+ if canonical_url and canonical_url != source_url:
+ footer += f"\nCanonical: {canonical_url}
"
+ content = f"{body}{footer}"
+
+ payload = {
+ "title": title,
+ "content": content,
+ "status": settings.wordpress_default_status,
+ }
+
+ wp_post_id = article.get("wp_post_id")
+ if wp_post_id:
+ result = _wp_request(
+ base_url=settings.wordpress_base_url,
+ auth_header=auth,
+ method="POST",
+ endpoint=f"posts/{int(wp_post_id)}",
+ payload=payload,
+ )
+ else:
+ result = _wp_request(
+ base_url=settings.wordpress_base_url,
+ auth_header=auth,
+ method="POST",
+ endpoint="posts",
+ payload=payload,
+ )
+
+ post_id = int(result.get("id", 0))
+ if post_id <= 0:
+ raise RuntimeError(f"WordPress Antwort ohne Post-ID: {result}")
+ post_url = result.get("link")
+ return post_id, post_url if isinstance(post_url, str) else None
+
+
+def selected_image_exists(article: dict[str, Any]) -> bool:
+ return _selected_image_url_from_meta(article.get("meta_json")) is not None
diff --git a/backend/templates/admin_article_detail.html b/backend/templates/admin_article_detail.html
index 86fb7af..a5943ef 100644
--- a/backend/templates/admin_article_detail.html
+++ b/backend/templates/admin_article_detail.html
@@ -21,6 +21,12 @@
+ {% if flash_msg %}
+
+ {% endif %}
+
{{ article.title }}
@@ -39,6 +45,16 @@
{% if article.summary %}
Summary: {{ article.summary }}
{% endif %}
+
WordPress Post:
+ {% if article.wp_post_url %}
+ #{{ article.wp_post_id }}
+ {% elif article.wp_post_id %}
+ #{{ article.wp_post_id }}
+ {% else %}
+ -
+ {% endif %}
+
+
Publish Attempts: {{ article.publish_attempts or 0 }} | Letzter Fehler: {{ article.publish_last_error or "-" }}
@@ -184,6 +200,15 @@
+
+
+ WordPress Publish Queue
+ Voraussetzungen: Status `approved`, Rechtsfreigabe aktiv, Hauptbild gesetzt.
+
+