_resolve_wp_tag_ids created a WordPress tag for every keyword the rewriter invented, up to 12 per post. That is where the 3.055 tags for 955 posts came from - 1.683 of them used exactly once, 473 attached to no post at all. The categories are stable now, but the tags would simply grow back. Three changes, all on the write path: A proposed tag has to appear for wordpress_new_tag_min_proposals (3) different articles before it is created. Proposals are counted in the new tag_proposals table, keyed on (name, article), so re-publishing an article does not inflate its own count. Tags that already exist in WordPress are assigned as before - the gate only guards creation. Only the first wordpress_max_tags_per_post (5) tags reach WordPress. The full list still feeds the category rules, which were validated against it. The lookup fallback of reusing the first search hit is gone. It filed "Camping" under the unrelated existing tag "Campingplatz" whenever the exact tag was missing, which quietly produced wrong tags rather than none. If the proposal bookkeeping fails, nothing is creatable that run: existing tags still get assigned and the taxonomy stays put, rather than falling back to creating everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
801 lines
29 KiB
Python
801 lines
29 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
from html import escape
|
|
import logging
|
|
import json
|
|
import mimetypes
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Any
|
|
from html import unescape as _html_unescape
|
|
from urllib.parse import quote_plus, urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
from . import categorize
|
|
from . import repositories
|
|
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,
|
|
) -> 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")
|
|
return json.loads(raw) if raw 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 _selected_tags_from_meta(meta_json: str | None) -> list[str]:
|
|
if not meta_json:
|
|
return []
|
|
try:
|
|
meta = json.loads(meta_json)
|
|
except Exception:
|
|
return []
|
|
if not isinstance(meta, dict):
|
|
return []
|
|
raw_tags = meta.get("generated_tags")
|
|
if not isinstance(raw_tags, list):
|
|
return []
|
|
tags: list[str] = []
|
|
seen: set[str] = set()
|
|
for item in raw_tags:
|
|
value = str(item or "").strip()
|
|
if not value:
|
|
continue
|
|
key = value.casefold()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
tags.append(value)
|
|
if len(tags) >= 12:
|
|
break
|
|
return tags
|
|
|
|
|
|
def _resolve_wp_tag_ids(
|
|
*,
|
|
base_url: str,
|
|
auth_header: str,
|
|
tags: list[str],
|
|
creatable: set[str],
|
|
) -> list[int]:
|
|
"""Map tag names to WordPress tag IDs, creating only established ones.
|
|
|
|
A name matches an existing tag only on an exact (case-insensitive) name.
|
|
The former fallback of taking the first search hit filed "Camping" under
|
|
the unrelated existing tag "Campingplatz" whenever the exact tag was
|
|
missing.
|
|
|
|
`creatable` holds the casefolded names that cleared the proposal threshold
|
|
and may be created in WordPress. Everything else is dropped with a log line
|
|
rather than turned into a new tag - an empty set means this run adds nothing
|
|
to the taxonomy.
|
|
"""
|
|
ids: list[int] = []
|
|
seen: set[int] = set()
|
|
for tag in tags:
|
|
name = tag.strip()
|
|
if not name:
|
|
continue
|
|
try:
|
|
endpoint = f"tags?search={quote_plus(name)}&per_page=20"
|
|
result = _wp_request(base_url=base_url, auth_header=auth_header, method="GET", endpoint=endpoint)
|
|
tag_id: int | None = None
|
|
if isinstance(result, list):
|
|
for row in result:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
row_name = str(row.get("name") or "")
|
|
rid = int(row.get("id", 0) or 0)
|
|
if rid <= 0:
|
|
continue
|
|
if row_name.casefold() == name.casefold():
|
|
tag_id = rid
|
|
break
|
|
if tag_id is None:
|
|
if name.casefold() not in creatable:
|
|
_logger.info(
|
|
"Schlagwort '%s' noch nicht etabliert - wird nicht angelegt", name
|
|
)
|
|
continue
|
|
created = _wp_request(
|
|
base_url=base_url,
|
|
auth_header=auth_header,
|
|
method="POST",
|
|
endpoint="tags",
|
|
payload={"name": name},
|
|
)
|
|
if isinstance(created, dict):
|
|
rid = int(created.get("id", 0) or 0)
|
|
if rid > 0:
|
|
tag_id = rid
|
|
_logger.info("Schlagwort '%s' in WordPress neu angelegt (#%s)", name, rid)
|
|
if tag_id is not None and tag_id > 0 and tag_id not in seen:
|
|
seen.add(tag_id)
|
|
ids.append(tag_id)
|
|
except Exception:
|
|
continue
|
|
return ids
|
|
|
|
|
|
def _creatable_tag_names(tags: list[str], article_id: Any) -> set[str]:
|
|
"""Casefolded names that may be created as new WordPress tags.
|
|
|
|
A proposed tag has to show up for `wordpress_new_tag_min_proposals`
|
|
different articles before it earns a taxonomy entry. Until then it is
|
|
counted and dropped, so one-off inventions stop accumulating.
|
|
|
|
If the bookkeeping itself fails, nothing is creatable: existing tags are
|
|
still assigned, and the taxonomy simply does not grow that run.
|
|
"""
|
|
if not tags:
|
|
return set()
|
|
settings = get_settings()
|
|
threshold = max(1, settings.wordpress_new_tag_min_proposals)
|
|
try:
|
|
aid = int(article_id) if article_id is not None else None
|
|
except (TypeError, ValueError):
|
|
aid = None
|
|
try:
|
|
counts = repositories.record_tag_proposals(tags, aid)
|
|
except Exception as exc:
|
|
_logger.warning("Schlagwort-Zähler nicht verfügbar, lege keine neuen an: %s", exc)
|
|
return set()
|
|
return {name for name, seen in counts.items() if seen >= threshold}
|
|
|
|
|
|
_category_id_cache: dict[str, int | None] = {}
|
|
|
|
|
|
def _resolve_wp_category_id(*, base_url: str, auth_header: str, slug: str) -> int | None:
|
|
"""Look up a WordPress category by slug.
|
|
|
|
Unlike tags, categories are never created on the fly: the set is curated in
|
|
WordPress, so an unknown slug means the rules and the site drifted apart and
|
|
should be fixed there, not silently papered over with a new category.
|
|
"""
|
|
if slug in _category_id_cache:
|
|
return _category_id_cache[slug]
|
|
|
|
try:
|
|
result = _wp_request(
|
|
base_url=base_url,
|
|
auth_header=auth_header,
|
|
method="GET",
|
|
endpoint=f"categories?slug={quote_plus(slug)}&per_page=1",
|
|
)
|
|
except Exception as exc:
|
|
# Do not cache transient failures - the next article should retry.
|
|
_logger.warning("Kategorie-Abfrage für '%s' fehlgeschlagen: %s", slug, exc)
|
|
return None
|
|
|
|
category_id: int | None = None
|
|
if isinstance(result, list):
|
|
for row in result:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
rid = int(row.get("id", 0) or 0)
|
|
if rid > 0:
|
|
category_id = rid
|
|
break
|
|
|
|
if category_id is None:
|
|
_logger.warning("Kategorie mit Slug '%s' existiert nicht in WordPress", slug)
|
|
|
|
_category_id_cache[slug] = category_id
|
|
return category_id
|
|
|
|
|
|
_BLOCKED_IMAGE_EXTS = {".svg", ".gif", ".ico", ".webp"}
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _sanitize_image_url(url: str) -> str:
|
|
"""Decode HTML entities (e.g. & → &) in image URLs from RSS feeds."""
|
|
return _html_unescape(url)
|
|
|
|
|
|
_PLACEHOLDER_PATTERNS = ("some-default.jpg", "default-image", "placeholder", "no-image", "noimage")
|
|
|
|
def _is_usable_image_url(url: str) -> bool:
|
|
"""Return False for URLs that are unlikely to work as WP featured images."""
|
|
if not url or url.startswith("data:"):
|
|
return False
|
|
try:
|
|
path = urlparse(url).path.lower()
|
|
_, ext = path.rsplit(".", 1) if "." in path else ("", "")
|
|
if f".{ext}" in _BLOCKED_IMAGE_EXTS:
|
|
return False
|
|
if any(p in path for p in _PLACEHOLDER_PATTERNS):
|
|
return False
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
|
|
def _download_image_bytes(url: str, referer: str | None = None) -> tuple[bytes, str]:
|
|
url = _sanitize_image_url(url)
|
|
headers = {
|
|
"User-Agent": "Mozilla/5.0 (compatible; rss-news-publisher/1.0)",
|
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
}
|
|
if referer:
|
|
headers["Referer"] = referer
|
|
req = Request(url=url, headers=headers)
|
|
with urlopen(req, timeout=20) as resp:
|
|
raw = resp.read()
|
|
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
|
content_type = content_type.split(";")[0].strip() if content_type else "application/octet-stream"
|
|
if not content_type.lower().startswith("image/"):
|
|
raise RuntimeError(f"Ausgewählte Bild-URL liefert kein Bild ({content_type})")
|
|
return raw, content_type
|
|
|
|
|
|
def _guess_filename(image_url: str, content_type: str) -> str:
|
|
parsed = urlparse(_sanitize_image_url(image_url))
|
|
stem = Path(parsed.path).name or "article-image"
|
|
if "." not in stem:
|
|
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".jpg"
|
|
stem = f"{stem}{ext}"
|
|
# Sanitize to ASCII-safe characters for the HTTP Content-Disposition header
|
|
stem = stem.encode("ascii", errors="ignore").decode("ascii")
|
|
stem = re.sub(r"[^\w.\-]", "_", stem) or "article-image.jpg"
|
|
return stem
|
|
|
|
|
|
def _get_image_meta_for_url(meta_json: str | None, image_url: str) -> dict:
|
|
"""Return the caption/credit dict for a specific image URL from extraction metadata."""
|
|
if not meta_json or not image_url:
|
|
return {}
|
|
try:
|
|
from urllib.parse import urlparse
|
|
meta = json.loads(meta_json)
|
|
image_metadata = (meta.get("extraction") or {}).get("image_metadata") or {}
|
|
# Exact match first
|
|
if image_url in image_metadata:
|
|
return image_metadata[image_url]
|
|
# Fuzzy match: compare without query string (handles ?w=1200 variants)
|
|
base_url = urlparse(image_url)._replace(query="").geturl()
|
|
for key, val in image_metadata.items():
|
|
key_base = urlparse(key)._replace(query="").geturl()
|
|
if key_base == base_url:
|
|
return val
|
|
return {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _build_image_caption(image_meta: dict, source_url: str) -> str:
|
|
"""Build a WP caption string from image metadata and source URL."""
|
|
# caption from figcaption typically already contains the credit text
|
|
caption = (image_meta.get("caption") or "").strip()
|
|
if caption:
|
|
return caption
|
|
return f"Quelle: {source_url}"
|
|
|
|
|
|
def _upload_featured_media(
|
|
*,
|
|
base_url: str,
|
|
auth_header: str,
|
|
image_url: str,
|
|
article_title: str,
|
|
source_url: str,
|
|
image_caption: str = "",
|
|
) -> int:
|
|
image_bytes, content_type = _download_image_bytes(image_url, referer=source_url or None)
|
|
filename = _guess_filename(image_url, content_type)
|
|
|
|
media_url = f"{base_url.rstrip('/')}/wp-json/wp/v2/media"
|
|
media_req = Request(
|
|
url=media_url,
|
|
data=image_bytes,
|
|
method="POST",
|
|
headers={
|
|
"Authorization": auth_header,
|
|
"Content-Type": content_type,
|
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
"Accept": "application/json",
|
|
"User-Agent": "rss-news-publisher/1.0",
|
|
},
|
|
)
|
|
with urlopen(media_req, timeout=30) as resp:
|
|
media_raw = resp.read().decode("utf-8", errors="replace")
|
|
media_payload = json.loads(media_raw) if media_raw else {}
|
|
media_id = int(media_payload.get("id", 0)) if isinstance(media_payload, dict) else 0
|
|
if media_id <= 0:
|
|
raise RuntimeError(f"WordPress Media-Upload fehlgeschlagen: {media_payload}")
|
|
|
|
_wp_request(
|
|
base_url=base_url,
|
|
auth_header=auth_header,
|
|
method="POST",
|
|
endpoint=f"media/{media_id}",
|
|
payload={
|
|
"title": f"{article_title[:120]} - Bild",
|
|
"caption": image_caption or f"Quelle: {source_url}",
|
|
"alt_text": article_title[:200],
|
|
},
|
|
)
|
|
return media_id
|
|
|
|
|
|
def _as_paragraph_html(text: str) -> str:
|
|
chunks = [chunk.strip() for chunk in re.split(r"\n{2,}", text.strip()) if chunk.strip()]
|
|
if not chunks:
|
|
return ""
|
|
lines = []
|
|
for chunk in chunks:
|
|
compact = re.sub(r"\s*\n\s*", " ", chunk)
|
|
lines.append(f"<p>{escape(compact)}</p>")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _as_block_paragraphs(text: str) -> str:
|
|
chunks = [chunk.strip() for chunk in re.split(r"\n{2,}", text.strip()) if chunk.strip()]
|
|
if not chunks:
|
|
return ""
|
|
lines = []
|
|
for chunk in chunks:
|
|
compact = re.sub(r"\s*\n\s*", " ", chunk)
|
|
lines.append(f"<!-- wp:paragraph --><p>{escape(compact)}</p><!-- /wp:paragraph -->")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _strip_html_tags(raw: str) -> str:
|
|
text = re.sub(r"<[^>]+>", " ", raw or "")
|
|
return re.sub(r"\s+", " ", text).strip()
|
|
|
|
|
|
def _html_to_wp_blocks(html: str) -> str:
|
|
src = (html or "").strip()
|
|
if not src:
|
|
return ""
|
|
pattern = re.compile(
|
|
r"<h([2-6])[^>]*>[\s\S]*?</h\1>|<p[^>]*>[\s\S]*?</p>|<ul[^>]*>[\s\S]*?</ul>|<ol[^>]*>[\s\S]*?</ol>",
|
|
re.IGNORECASE,
|
|
)
|
|
blocks: list[str] = []
|
|
for match in pattern.finditer(src):
|
|
block_html = match.group(0).strip()
|
|
if not block_html:
|
|
continue
|
|
tag_match = re.match(r"<([a-z0-9]+)", block_html, re.IGNORECASE)
|
|
tag = (tag_match.group(1).lower() if tag_match else "")
|
|
if tag == "p":
|
|
blocks.append(f"<!-- wp:paragraph -->{block_html}<!-- /wp:paragraph -->")
|
|
elif tag in {"ul", "ol"}:
|
|
ordered = tag == "ol"
|
|
if ordered:
|
|
blocks.append(f'<!-- wp:list {{"ordered":true}} -->{block_html}<!-- /wp:list -->')
|
|
else:
|
|
blocks.append(f"<!-- wp:list -->{block_html}<!-- /wp:list -->")
|
|
elif tag.startswith("h") and len(tag) == 2 and tag[1].isdigit():
|
|
level = int(tag[1])
|
|
blocks.append(f'<!-- wp:heading {{"level":{level}}} -->{block_html}<!-- /wp:heading -->')
|
|
if blocks:
|
|
return "\n".join(blocks)
|
|
return _as_block_paragraphs(_strip_html_tags(src))
|
|
|
|
|
|
def _as_block_heading(level: int, text: str) -> str:
|
|
safe_level = min(6, max(1, int(level)))
|
|
return f'<!-- wp:heading {{"level":{safe_level}}} --><h{safe_level}>{escape(text)}</h{safe_level}><!-- /wp:heading -->'
|
|
|
|
|
|
def _as_block_list(items: list[str]) -> str:
|
|
if not items:
|
|
return ""
|
|
content = "".join(f"<li>{item}</li>" for item in items)
|
|
return f"<!-- wp:list --><ul>{content}</ul><!-- /wp:list -->"
|
|
|
|
|
|
def _sanitize_publish_text(text: str) -> str:
|
|
raw = (text or "").strip()
|
|
if not raw:
|
|
return ""
|
|
lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
|
|
if len(lines) > 3:
|
|
lines = lines[3:]
|
|
merged = "\n".join(lines)
|
|
merged = re.sub(r"\n?\s*Pressekontakt[\s\S]*$", "", merged, flags=re.IGNORECASE).strip()
|
|
return merged
|
|
|
|
|
|
def _build_attribution_block(article: dict[str, Any]) -> str:
|
|
"""Build a WP Gutenberg attribution block for the bottom of the article."""
|
|
from urllib.parse import urlparse
|
|
source_url = (article.get("canonical_url") or article.get("source_url") or "").strip()
|
|
source_name = (article.get("source_name_snapshot") or "").strip()
|
|
author = (article.get("author") or "").strip()
|
|
|
|
# If the feed name is "Google Alerts" (or similar generic names), derive the
|
|
# real source name from the hostname of the canonical URL.
|
|
if not source_name or source_name.lower() in ("google alerts", "google"):
|
|
try:
|
|
hostname = urlparse(source_url).hostname or ""
|
|
source_name = hostname.removeprefix("www.")
|
|
except Exception:
|
|
pass
|
|
|
|
# Get image credit from extraction metadata (uses fuzzy URL match)
|
|
meta_json = article.get("meta_json")
|
|
credit = ""
|
|
try:
|
|
meta = json.loads(meta_json or "{}")
|
|
selected_url = (meta.get("image_review") or {}).get("selected_url") or ""
|
|
if selected_url:
|
|
img_meta = _get_image_meta_for_url(meta_json, selected_url)
|
|
raw_credit = (img_meta.get("credit") or "").strip()
|
|
caption_text = (img_meta.get("caption") or "").strip()
|
|
# If credit is just a bare marker prefix (e.g. "Foto:", "Bild:"),
|
|
# clear it and extract the full credit from the caption text instead.
|
|
_BARE_MARKERS = {"foto", "bild", "credit", "fotograf", "fotografie", "photo", "bildnachweis"}
|
|
if raw_credit.endswith(":") and raw_credit[:-1].strip().lower() in _BARE_MARKERS:
|
|
raw_credit = ""
|
|
if raw_credit:
|
|
credit = raw_credit
|
|
elif caption_text:
|
|
# Extract credit markers like "Foto: IMAGO/…", "© Agentur", "Bild: …"
|
|
import re as _re
|
|
m = _re.search(
|
|
r"(©[^\n]{1,120}|(?:Foto|Bild|Credit|Fotograf|Photo)\s*:[^\n]{1,120})",
|
|
caption_text,
|
|
)
|
|
credit = m.group(1).strip() if m else ""
|
|
except Exception:
|
|
pass
|
|
|
|
parts: list[str] = []
|
|
if source_url:
|
|
label = source_name or source_url
|
|
parts.append(f'Originalartikel: <a href="{source_url}">{escape(label)}</a>')
|
|
if author:
|
|
parts.append(f"Autor: {escape(author)}")
|
|
if credit:
|
|
parts.append(f"Bildnachweis: {escape(credit)}")
|
|
|
|
if not parts:
|
|
return ""
|
|
|
|
inner = " | ".join(parts)
|
|
return (
|
|
"\n<!-- wp:separator {\"className\":\"is-style-wide\"} -->"
|
|
"<hr class=\"wp-block-separator is-style-wide\"/><!-- /wp:separator -->\n"
|
|
f'<!-- wp:paragraph {{\"className\":\"article-attribution\"}} -->'
|
|
f'<p class="article-attribution"><em>{inner}</em></p>'
|
|
"<!-- /wp:paragraph -->"
|
|
)
|
|
|
|
|
|
def _build_post_content(article: dict[str, Any]) -> tuple[str, str | None]:
|
|
summary = (article.get("summary") or "").strip()
|
|
body_text = (article.get("content_rewritten") or article.get("content_raw") or "").strip()
|
|
body_text = _sanitize_publish_text(body_text)
|
|
if not body_text:
|
|
body_text = summary
|
|
|
|
has_html = bool(re.search(r"<[a-zA-Z][^>]*>", body_text))
|
|
body_html = _html_to_wp_blocks(body_text) if has_html else _as_block_paragraphs(body_text)
|
|
if not body_html:
|
|
body_html = "<!-- wp:paragraph --><p>Kein Inhalt verfügbar.</p><!-- /wp:paragraph -->"
|
|
|
|
attribution = _build_attribution_block(article)
|
|
content = (body_html + attribution).strip()
|
|
return content, 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)
|
|
|
|
title = (article.get("title") or "Ohne Titel").strip()
|
|
content, excerpt = _build_post_content(article)
|
|
source_url = article.get("source_url") or ""
|
|
|
|
featured_media_id = None
|
|
selected_image_url = _selected_image_url_from_meta(article.get("meta_json"))
|
|
|
|
# Build candidate list: primary selected URL + fallbacks from image_urls_json
|
|
image_candidates: list[str] = []
|
|
if selected_image_url and _is_usable_image_url(selected_image_url):
|
|
image_candidates.append(selected_image_url)
|
|
try:
|
|
extra_urls = json.loads(article.get("image_urls_json") or "[]")
|
|
for u in extra_urls:
|
|
if u and u not in image_candidates and _is_usable_image_url(u):
|
|
image_candidates.append(u)
|
|
except Exception:
|
|
pass
|
|
|
|
for candidate_url in image_candidates:
|
|
image_meta = _get_image_meta_for_url(article.get("meta_json"), candidate_url)
|
|
image_caption = _build_image_caption(image_meta, source_url)
|
|
try:
|
|
featured_media_id = _upload_featured_media(
|
|
base_url=settings.wordpress_base_url,
|
|
auth_header=auth,
|
|
image_url=candidate_url,
|
|
article_title=title,
|
|
source_url=source_url,
|
|
image_caption=image_caption,
|
|
)
|
|
break # success — stop trying further candidates
|
|
except Exception as img_exc:
|
|
_logger.warning(
|
|
"Bild-Upload fehlgeschlagen, versuche nächste URL: %s — %s", candidate_url, img_exc
|
|
)
|
|
|
|
if not featured_media_id and image_candidates:
|
|
_logger.warning(
|
|
"Alle %d Bild-Kandidaten fehlgeschlagen für Artikel #%s (%s)",
|
|
len(image_candidates), article.get("id"), title[:60],
|
|
)
|
|
|
|
payload = {
|
|
"title": title,
|
|
"content": content,
|
|
"status": settings.wordpress_default_status,
|
|
}
|
|
if excerpt:
|
|
payload["excerpt"] = excerpt
|
|
if featured_media_id:
|
|
payload["featured_media"] = featured_media_id
|
|
scheduled_at = article.get("scheduled_publish_at")
|
|
if scheduled_at:
|
|
payload["date"] = scheduled_at # e.g. "2026-03-24T09:00:00"
|
|
# Use status "future" so WP schedules auto-publishing at the given date.
|
|
# WP ignores date for drafts and shows "Sofort veröffentlichen" instead.
|
|
try:
|
|
from datetime import datetime as _dt
|
|
if _dt.fromisoformat(scheduled_at) > _dt.now():
|
|
payload["status"] = "future"
|
|
except Exception:
|
|
pass
|
|
|
|
wp_post_id = article.get("wp_post_id")
|
|
tag_names = _selected_tags_from_meta(article.get("meta_json"))
|
|
# Only the leading few tags reach WordPress; the full list still feeds the
|
|
# category rules below, which were validated against it.
|
|
wp_tag_names = tag_names[: max(0, settings.wordpress_max_tags_per_post)]
|
|
tag_ids = _resolve_wp_tag_ids(
|
|
base_url=settings.wordpress_base_url,
|
|
auth_header=auth,
|
|
tags=wp_tag_names,
|
|
creatable=_creatable_tag_names(wp_tag_names, article.get("id")),
|
|
)
|
|
if tag_ids:
|
|
payload["tags"] = tag_ids
|
|
|
|
# Without an explicit category WordPress files everything under "Allgemein".
|
|
# Leaving it unset when no rule matches is deliberate: a wrong category is
|
|
# harder to spot later than a handful of posts in the catch-all.
|
|
slug = categorize.category_slug(title, tag_names)
|
|
if slug:
|
|
category_id = _resolve_wp_category_id(
|
|
base_url=settings.wordpress_base_url,
|
|
auth_header=auth,
|
|
slug=slug,
|
|
)
|
|
if category_id:
|
|
payload["categories"] = [category_id]
|
|
else:
|
|
_logger.info(
|
|
"Keine Kategorie-Regel für Artikel #%s (%s) - bleibt in der Standardkategorie",
|
|
article.get("id"), title[:60],
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
if not isinstance(result, dict):
|
|
raise RuntimeError(f"WordPress Antwort im unerwarteten Format: {result}")
|
|
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
|
|
|
|
|
|
def delete_wp_post(wp_post_id: int) -> None:
|
|
"""Permanently delete a WordPress post (moves to trash, then deletes)."""
|
|
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")
|
|
auth = _auth_header(settings.wordpress_username, settings.wordpress_app_password)
|
|
# force=true skips trash
|
|
_wp_request(
|
|
base_url=settings.wordpress_base_url,
|
|
auth_header=auth,
|
|
method="DELETE",
|
|
endpoint=f"posts/{wp_post_id}?force=true",
|
|
)
|
|
|
|
|
|
def sync_db_from_wordpress() -> dict[str, Any]:
|
|
"""Sync scheduled_publish_at and wp_post_url in the DB from WordPress.
|
|
|
|
WordPress is treated as the source of truth for scheduling.
|
|
For each DB article that has a wp_post_id:
|
|
- If WP post exists as 'future': update scheduled_publish_at to WP date.
|
|
- If WP post exists as 'draft': clear scheduled_publish_at (not yet scheduled).
|
|
- If WP post exists as 'publish': mark article as published in DB.
|
|
- If WP post is trashed/deleted (404 or trash status): clear wp_post_id,
|
|
wp_post_url, and scheduled_publish_at so the article can be re-processed.
|
|
Returns a stats dict with counts of each action taken.
|
|
"""
|
|
from .db import get_conn
|
|
|
|
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")
|
|
auth = _auth_header(settings.wordpress_username, settings.wordpress_app_password)
|
|
base_url = settings.wordpress_base_url.rstrip("/")
|
|
|
|
# Fetch all future + draft + published WP posts in one pass (up to 300 per status)
|
|
wp_posts: dict[int, dict] = {}
|
|
for status in ("future", "draft", "publish"):
|
|
for page in range(1, 4): # max 300 per status
|
|
try:
|
|
result = _wp_request(
|
|
base_url=base_url,
|
|
auth_header=auth,
|
|
method="GET",
|
|
endpoint=f"posts?status={status}&per_page=100&page={page}&_fields=id,date,status,link",
|
|
)
|
|
except Exception:
|
|
break
|
|
if not isinstance(result, list) or not result:
|
|
break
|
|
for post in result:
|
|
try:
|
|
wp_posts[int(post["id"])] = post
|
|
except Exception:
|
|
pass
|
|
if len(result) < 100:
|
|
break
|
|
|
|
# Load all DB articles that have a wp_post_id
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT id, wp_post_id, wp_post_url, scheduled_publish_at, status
|
|
FROM articles
|
|
WHERE wp_post_id IS NOT NULL
|
|
AND status NOT IN ('no_image')
|
|
ORDER BY id
|
|
"""
|
|
).fetchall()
|
|
|
|
stats: dict[str, int] = {
|
|
"total_db_articles": len(rows),
|
|
"wp_posts_found": len(wp_posts),
|
|
"slot_updated": 0,
|
|
"slot_cleared_draft": 0,
|
|
"marked_published": 0,
|
|
"wp_reference_cleared": 0,
|
|
"already_in_sync": 0,
|
|
}
|
|
|
|
for row in rows:
|
|
article_id = row["id"]
|
|
wp_post_id = int(row["wp_post_id"])
|
|
wp_post = wp_posts.get(wp_post_id)
|
|
|
|
if wp_post is None:
|
|
# Post not found in future/draft/publish — likely trashed or deleted
|
|
# Clear wp reference so article can be re-processed if needed
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"""UPDATE articles
|
|
SET wp_post_id = NULL, wp_post_url = NULL, scheduled_publish_at = NULL
|
|
WHERE id = ?""",
|
|
(article_id,),
|
|
)
|
|
stats["wp_reference_cleared"] += 1
|
|
continue
|
|
|
|
wp_status = wp_post.get("status", "")
|
|
wp_date = wp_post.get("date", "") # local CET datetime, e.g. "2026-05-05T09:00:00"
|
|
wp_link = wp_post.get("link") or row["wp_post_url"]
|
|
|
|
if wp_status == "publish":
|
|
# Already published in WP — mark as published in DB if not already
|
|
if row["status"] != "published":
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE articles SET status = 'published', wp_post_url = ? WHERE id = ?",
|
|
(wp_link, article_id),
|
|
)
|
|
stats["marked_published"] += 1
|
|
else:
|
|
stats["already_in_sync"] += 1
|
|
|
|
elif wp_status == "future":
|
|
# Scheduled — sync the date into scheduled_publish_at
|
|
current_slot = row["scheduled_publish_at"] or ""
|
|
# WP returns e.g. "2026-05-05T09:00:00" — compare ignoring seconds
|
|
if current_slot[:16] != wp_date[:16]:
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE articles SET scheduled_publish_at = ?, wp_post_url = ? WHERE id = ?",
|
|
(wp_date, wp_link, article_id),
|
|
)
|
|
stats["slot_updated"] += 1
|
|
else:
|
|
stats["already_in_sync"] += 1
|
|
|
|
elif wp_status == "draft":
|
|
# Draft without a schedule — clear scheduled_publish_at if set
|
|
if row["scheduled_publish_at"]:
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE articles SET scheduled_publish_at = NULL WHERE id = ?",
|
|
(article_id,),
|
|
)
|
|
stats["slot_cleared_draft"] += 1
|
|
else:
|
|
stats["already_in_sync"] += 1
|
|
|
|
return stats
|