Artikel

13. Posting-Engine

Allgemein

Die Posting-Engine verarbeitet freigegebene Beiträge und übergibt sie an den passenden Plattformadapter.

Sie erzeugt keine Texte. Sie entscheidet auch nicht über Qualität. Sie veröffentlicht nur Beiträge, deren Status bereits approved ist.

approved post
    ↓
Publisher
    ↓
PlatformAdapter
    ↓
Playwright
    ↓
Screenshot
    ↓
published oder failed

13.1 Gemeinsames Interface

Jede Plattform wird über dasselbe Interface angesprochen.

from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class PublishRequest:
    id: str
    platform: str
    post_type: str
    text: str
    image_path: str | None
    target_url: str | None
    storage_state_path: Path
    screenshot_dir: Path
    dry_run: bool


@dataclass(frozen=True)
class PublishResult:
    post_id: str
    platform: str
    status: str
    screenshot_path: str
    platform_post_url: str | None
    error_message: str | None


class PlatformAdapter:
    def publish(self, request: PublishRequest) -> PublishResult:
        raise NotImplementedError

Statuswerte:

published
failed
blocked
dry_run

13.2 Plattformadapter

Die Posting-Engine kennt nur den Plattformnamen. Die konkrete Bedienung liegt im Adapter.

class PlatformAdapterRegistry:
    def __init__(self) -> None:
        self.adapters: dict[str, PlatformAdapter] = {}

    def register(self, platform: str, adapter: PlatformAdapter) -> None:
        self.adapters[platform] = adapter

    def get(self, platform: str) -> PlatformAdapter:
        adapter = self.adapters.get(platform)

        if adapter is None:
            raise PublisherError("No adapter registered for platform: " + platform)

        return adapter

Beispielregistrierung:

registry = PlatformAdapterRegistry()
registry.register("linkedin", LinkedInAdapter())
registry.register("facebook", FacebookAdapter())
registry.register("instagram", InstagramAdapter())

13.3 Text einfügen

Das Einfügen des Textes erfolgt im Adapter.

Grundmuster:

page.get_by_role("textbox").fill(request.text)

Viele Social-Media-Editoren sind aber keine klassischen Textfelder, sondern contenteditable-Elemente. Dann ist fill() nicht immer zuverlässig.

Robuster:

textbox = page.get_by_role("textbox").first
textbox.click()
page.keyboard.insert_text(request.text)

Falls Zeilenumbrüche nicht sauber übernommen werden:

textbox.click()

for character in request.text:
    if character == "\n":
        page.keyboard.press("Shift+Enter")
    else:
        page.keyboard.insert_text(character)

Für das MVP reicht meistens:

textbox.click()
page.keyboard.insert_text(request.text)

13.4 Bild hochladen

Bild-Upload erfolgt über ein input[type=file].

Beispiel:

file_input = page.locator("input[type=file]").first
file_input.set_input_files(request.image_path)

Vorher prüfen:

from pathlib import Path


class UploadFileValidator:
    def validate(self, image_path: str | None) -> None:
        if image_path is None:
            return

        path = Path(image_path)

        if path.exists() is False:
            raise PublisherError("Image file does not exist: " + image_path)

        if path.is_file() is False:
            raise PublisherError("Image path is not a file: " + image_path)

        allowed_suffixes = [
            ".jpg",
            ".jpeg",
            ".png",
            ".webp",
        ]

        if path.suffix.lower() not in allowed_suffixes:
            raise PublisherError("Unsupported image file type: " + image_path)

Bei Instagram ist ein Bild Pflicht. Bei LinkedIn und Facebook optional.

13.5 Link hinzufügen

Links werden als Teil des Textes angehängt oder in einem eigenen Feld eingefügt, wenn die Plattform das vorsieht.

Für das MVP:

class PostTextBuilder:
    def build(self, text: str, target_url: str | None, include_link: bool) -> str:
        if include_link is False:
            return text

        if target_url is None:
            return text

        return text.rstrip() + "\n\n" + target_url

Regel:

LinkedIn: Link optional anhängen
Facebook: Link optional anhängen
Instagram: Link nicht automatisch in Caption anhängen
Kommentare: nie Link anhängen

13.6 Post absenden

Der Klick auf „Posten“ ist der kritischste Schritt.

Grundregel:

Nach dem Klick nicht blind erneut klicken.

Ablauf:

Text einfügen
Bild hochladen
Dry-Run prüfen
Screenshot vor Absenden optional speichern
Post-Button klicken
auf UI-Reaktion warten
Screenshot speichern
Status setzen

Beispiel:

submit_button = page.get_by_role("button", name="Posten")
submit_button.click(timeout=10_000)

Bei unklarem Status:

nicht erneut klicken
Screenshot speichern
Status failed
manuelle Prüfung

13.7 Screenshot nach Veröffentlichung

Nach jedem Veröffentlichungsversuch wird ein Screenshot gespeichert.

class PublisherScreenshotService:
    def save(self, page: Page, screenshot_dir: Path, post_id: str, status: str) -> Path:
        screenshot_dir.mkdir(parents=True, exist_ok=True)

        screenshot_path = screenshot_dir / (post_id + "_" + status + ".png")

        page.screenshot(
            path=str(screenshot_path),
            full_page=True,
        )

        return screenshot_path

Dateinamen:

storage/screenshots/linkedin/draft_20260512_001_published.png
storage/screenshots/linkedin/draft_20260512_001_failed.png
storage/screenshots/linkedin/draft_20260512_001_dry_run.png

13.8 Ergebnis speichern

Die Engine aktualisiert den Beitrag nach dem Veröffentlichungsversuch.

Bei Erfolg:

{
  "status": "published",
  "published_at": "2026-05-12T10:00:00+02:00",
  "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_published.png",
  "platform_post_url": null,
  "error_message": null
}

Bei Fehler:

{
  "status": "failed",
  "failed_at": "2026-05-12T10:00:00+02:00",
  "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_failed.png",
  "platform_post_url": null,
  "error_message": "Post button not found."
}

Repository-Methode:

class PublishedResultApplier:
    def apply(self, draft: dict[str, object], result: PublishResult) -> dict[str, object]:
        draft["status"] = result.status
        draft["screenshot_path"] = result.screenshot_path
        draft["platform_post_url"] = result.platform_post_url
        draft["error_message"] = result.error_message
        draft["updated_at"] = self._now()

        if result.status == "published":
            draft["published_at"] = self._now()

        if result.status == "failed":
            draft["failed_at"] = self._now()

        if result.status == "blocked":
            draft["blocked_at"] = self._now()

        return draft

    def _now(self) -> str:
        from datetime import datetime
        from zoneinfo import ZoneInfo

        return datetime.now(ZoneInfo("Europe/Berlin")).isoformat()

13.9 Beispiel: publisher.py

Datei:

src/publisher.py

import argparse
import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo

from playwright.sync_api import Page
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright


class PublisherError(Exception):
    pass


class PublisherBlockedError(PublisherError):
    pass


@dataclass(frozen=True)
class PublishRequest:
    id: str
    platform: str
    post_type: str
    text: str
    image_path: str | None
    target_url: str | None
    storage_state_path: Path
    screenshot_dir: Path
    dry_run: bool


@dataclass(frozen=True)
class PublishResult:
    post_id: str
    platform: str
    status: str
    screenshot_path: str
    platform_post_url: str | None
    error_message: str | None


class PlatformAdapter:
    def publish(self, request: PublishRequest) -> PublishResult:
        raise NotImplementedError


class PublisherScreenshotService:
    def save(self, page: Page, screenshot_dir: Path, post_id: str, status: str) -> Path:
        screenshot_dir.mkdir(parents=True, exist_ok=True)

        screenshot_path = screenshot_dir / (post_id + "_" + status + ".png")

        page.screenshot(
            path=str(screenshot_path),
            full_page=True,
        )

        return screenshot_path


class UploadFileValidator:
    def validate(self, image_path: str | None) -> None:
        if image_path is None:
            return

        path = Path(image_path)

        if path.exists() is False:
            raise PublisherError("Image file does not exist: " + image_path)

        if path.is_file() is False:
            raise PublisherError("Image path is not a file: " + image_path)

        allowed_suffixes = [
            ".jpg",
            ".jpeg",
            ".png",
            ".webp",
        ]

        if path.suffix.lower() not in allowed_suffixes:
            raise PublisherError("Unsupported image file type: " + image_path)


class PostTextBuilder:
    def build(self, platform: str, text: str, target_url: str | None) -> str:
        if platform == "instagram":
            return text

        if target_url is None:
            return text

        return text.rstrip() + "\n\n" + target_url


class LinkedInAdapter(PlatformAdapter):
    def __init__(self) -> None:
        self.screenshot_service = PublisherScreenshotService()
        self.upload_file_validator = UploadFileValidator()
        self.post_text_builder = PostTextBuilder()

    def publish(self, request: PublishRequest) -> PublishResult:
        self._validate_request(request)

        with sync_playwright() as playwright:
            browser = playwright.chromium.launch(headless=True)
            context = browser.new_context(storage_state=str(request.storage_state_path))
            page = context.new_page()

            page.set_default_timeout(10_000)
            page.set_default_navigation_timeout(30_000)

            try:
                page.goto(
                    "https://www.linkedin.com/feed/",
                    wait_until="domcontentloaded",
                    timeout=30_000,
                )

                self._assert_logged_in(page)

                page.get_by_role("button", name="Beitrag beginnen").click(timeout=10_000)

                textbox = page.get_by_role("textbox").first
                textbox.click(timeout=10_000)

                post_text = self.post_text_builder.build(
                    platform=request.platform,
                    text=request.text,
                    target_url=request.target_url,
                )

                page.keyboard.insert_text(post_text)

                if request.image_path is not None:
                    self._upload_image(page, request.image_path)

                if request.dry_run is True:
                    screenshot_path = self.screenshot_service.save(
                        page=page,
                        screenshot_dir=request.screenshot_dir,
                        post_id=request.id,
                        status="dry_run",
                    )

                    browser.close()

                    return PublishResult(
                        post_id=request.id,
                        platform=request.platform,
                        status="dry_run",
                        screenshot_path=str(screenshot_path),
                        platform_post_url=None,
                        error_message=None,
                    )

                page.get_by_role("button", name="Posten").click(timeout=10_000)

                page.wait_for_timeout(3000)

                screenshot_path = self.screenshot_service.save(
                    page=page,
                    screenshot_dir=request.screenshot_dir,
                    post_id=request.id,
                    status="published",
                )

                browser.close()

                return PublishResult(
                    post_id=request.id,
                    platform=request.platform,
                    status="published",
                    screenshot_path=str(screenshot_path),
                    platform_post_url=None,
                    error_message=None,
                )
            except PublisherBlockedError as exception:
                screenshot_path = self.screenshot_service.save(
                    page=page,
                    screenshot_dir=request.screenshot_dir,
                    post_id=request.id,
                    status="blocked",
                )

                browser.close()

                return PublishResult(
                    post_id=request.id,
                    platform=request.platform,
                    status="blocked",
                    screenshot_path=str(screenshot_path),
                    platform_post_url=None,
                    error_message=str(exception),
                )
            except PlaywrightTimeoutError as exception:
                screenshot_path = self.screenshot_service.save(
                    page=page,
                    screenshot_dir=request.screenshot_dir,
                    post_id=request.id,
                    status="failed",
                )

                browser.close()

                return PublishResult(
                    post_id=request.id,
                    platform=request.platform,
                    status="failed",
                    screenshot_path=str(screenshot_path),
                    platform_post_url=None,
                    error_message=str(exception),
                )
            except PublisherError as exception:
                screenshot_path = self.screenshot_service.save(
                    page=page,
                    screenshot_dir=request.screenshot_dir,
                    post_id=request.id,
                    status="failed",
                )

                browser.close()

                return PublishResult(
                    post_id=request.id,
                    platform=request.platform,
                    status="failed",
                    screenshot_path=str(screenshot_path),
                    platform_post_url=None,
                    error_message=str(exception),
                )

    def _validate_request(self, request: PublishRequest) -> None:
        if request.storage_state_path.exists() is False:
            raise PublisherError("Storage state file does not exist: " + str(request.storage_state_path))

        if request.text.strip() == "":
            raise PublisherError("Post text must not be empty.")

        self.upload_file_validator.validate(request.image_path)

    def _assert_logged_in(self, page: Page) -> None:
        current_url = page.url.lower()

        if "login" in current_url:
            raise PublisherBlockedError("Login required.")

        body_text = page.locator("body").inner_text(timeout=10_000).lower()

        blocked_fragments = [
            "captcha",
            "sicherheitsüberprüfung",
            "security check",
            "unusual activity",
        ]

        for blocked_fragment in blocked_fragments:
            if blocked_fragment in body_text:
                raise PublisherBlockedError("Blocked platform state detected: " + blocked_fragment)

    def _upload_image(self, page: Page, image_path: str) -> None:
        image_button_names = [
            "Bild hinzufügen",
            "Medien hinzufügen",
            "Foto hinzufügen",
        ]

        clicked = False

        for button_name in image_button_names:
            locator = page.get_by_role("button", name=button_name)

            if locator.count() > 0:
                locator.first.click(timeout=10_000)
                clicked = True
                break

        if clicked is False:
            raise PublisherError("Image upload button not found.")

        file_input = page.locator("input[type=file]").first
        file_input.set_input_files(image_path)


class FacebookAdapter(PlatformAdapter):
    def publish(self, request: PublishRequest) -> PublishResult:
        return PublishResult(
            post_id=request.id,
            platform=request.platform,
            status="failed",
            screenshot_path="",
            platform_post_url=None,
            error_message="FacebookAdapter is not implemented yet.",
        )


class InstagramAdapter(PlatformAdapter):
    def publish(self, request: PublishRequest) -> PublishResult:
        return PublishResult(
            post_id=request.id,
            platform=request.platform,
            status="failed",
            screenshot_path="",
            platform_post_url=None,
            error_message="InstagramAdapter is not implemented yet.",
        )


class PlatformAdapterRegistry:
    def __init__(self) -> None:
        self.adapters: dict[str, PlatformAdapter] = {}

    def register(self, platform: str, adapter: PlatformAdapter) -> None:
        self.adapters[platform] = adapter

    def get(self, platform: str) -> PlatformAdapter:
        adapter = self.adapters.get(platform)

        if adapter is None:
            raise PublisherError("No adapter registered for platform: " + platform)

        return adapter


class ReviewQueueStore:
    def __init__(self, file_path: Path) -> None:
        self.file_path = file_path

    def load(self) -> list[dict[str, Any]]:
        if self.file_path.exists() is False:
            return []

        with self.file_path.open("r", encoding="utf-8") as file:
            payload = json.load(file)

        if isinstance(payload, list) is False:
            raise PublisherError("Post draft JSON must contain a list.")

        drafts: list[dict[str, Any]] = []

        for item in payload:
            if isinstance(item, dict) is False:
                raise PublisherError("Post draft JSON contains non-object item.")

            drafts.append(item)

        return drafts

    def save(self, drafts: list[dict[str, Any]]) -> None:
        self.file_path.parent.mkdir(parents=True, exist_ok=True)

        with self.file_path.open("w", encoding="utf-8") as file:
            json.dump(drafts, file, ensure_ascii=False, indent=2)
            file.write("\n")


class ReviewQueueRepository:
    def __init__(self, store: ReviewQueueStore) -> None:
        self.store = store

    def find_by_id(self, post_id: str) -> dict[str, Any]:
        drafts = self.store.load()

        for draft in drafts:
            if draft.get("id") == post_id:
                return draft

        raise PublisherError("Post not found: " + post_id)

    def update(self, updated_post: dict[str, Any]) -> None:
        drafts = self.store.load()
        updated_drafts: list[dict[str, Any]] = []
        found = False

        updated_id = updated_post.get("id")

        for draft in drafts:
            if draft.get("id") == updated_id:
                updated_drafts.append(updated_post)
                found = True
            else:
                updated_drafts.append(draft)

        if found is False:
            raise PublisherError("Post not found: " + str(updated_id))

        self.store.save(updated_drafts)

    def list_approved(self) -> list[dict[str, Any]]:
        drafts = self.store.load()
        result: list[dict[str, Any]] = []

        for draft in drafts:
            if draft.get("status") == "approved":
                result.append(draft)

        return result


class PublishedResultApplier:
    def apply(self, draft: dict[str, Any], result: PublishResult) -> dict[str, Any]:
        draft["status"] = result.status
        draft["screenshot_path"] = result.screenshot_path
        draft["platform_post_url"] = result.platform_post_url
        draft["error_message"] = result.error_message
        draft["updated_at"] = self._now()

        if result.status == "published":
            draft["published_at"] = self._now()

        if result.status == "failed":
            draft["failed_at"] = self._now()

        if result.status == "blocked":
            draft["blocked_at"] = self._now()

        if result.status == "dry_run":
            draft["dry_run_at"] = self._now()

        return draft

    def _now(self) -> str:
        return datetime.now(ZoneInfo("Europe/Berlin")).isoformat()


class PublishRequestFactory:
    def __init__(self, root_dir: Path, dry_run: bool) -> None:
        self.root_dir = root_dir
        self.dry_run = dry_run

    def create(self, draft: dict[str, Any]) -> PublishRequest:
        post_id = self._read_string(draft, "id")
        platform = self._read_string(draft, "platform")
        post_type = self._read_string(draft, "post_type")
        text = self._read_string(draft, "text")

        image_path = self._read_optional_string(draft, "image_path")
        target_url = self._read_optional_string(draft, "target_url")

        return PublishRequest(
            id=post_id,
            platform=platform,
            post_type=post_type,
            text=text,
            image_path=image_path,
            target_url=target_url,
            storage_state_path=self.root_dir / "storage" / "sessions" / (platform + ".json"),
            screenshot_dir=self.root_dir / "storage" / "screenshots" / platform,
            dry_run=self.dry_run,
        )

    def _read_string(self, payload: dict[str, Any], key: str) -> str:
        value = payload.get(key)

        if isinstance(value, str) is False:
            raise PublisherError("Field must be string: " + key)

        if value.strip() == "":
            raise PublisherError("Field must not be empty: " + key)

        return value

    def _read_optional_string(self, payload: dict[str, Any], key: str) -> str | None:
        value = payload.get(key)

        if value is None:
            return None

        if isinstance(value, str) is False:
            raise PublisherError("Field must be string or null: " + key)

        if value.strip() == "":
            return None

        return value


class PublisherService:
    def __init__(
        self,
        repository: ReviewQueueRepository,
        registry: PlatformAdapterRegistry,
        request_factory: PublishRequestFactory,
        result_applier: PublishedResultApplier,
    ) -> None:
        self.repository = repository
        self.registry = registry
        self.request_factory = request_factory
        self.result_applier = result_applier

    def publish_one(self, post_id: str) -> None:
        draft = self.repository.find_by_id(post_id)

        status = draft.get("status")

        if status != "approved":
            raise PublisherError("Post must have status approved. Current status: " + str(status))

        request = self.request_factory.create(draft)
        adapter = self.registry.get(request.platform)
        result = adapter.publish(request)

        updated_draft = self.result_applier.apply(draft, result)
        self.repository.update(updated_draft)

        print(result.status.upper())
        print(result.post_id)

        if result.error_message is not None:
            print(result.error_message)

    def publish_next(self) -> None:
        approved_posts = self.repository.list_approved()

        if len(approved_posts) == 0:
            print("NO_APPROVED_POSTS")
            return

        first_post = approved_posts[0]
        post_id = first_post.get("id")

        if isinstance(post_id, str) is False:
            raise PublisherError("Approved post id must be a string.")

        self.publish_one(post_id)


def build_registry() -> PlatformAdapterRegistry:
    registry = PlatformAdapterRegistry()
    registry.register("linkedin", LinkedInAdapter())
    registry.register("facebook", FacebookAdapter())
    registry.register("instagram", InstagramAdapter())

    return registry


def build_service(root_dir: Path, dry_run: bool) -> PublisherService:
    store = ReviewQueueStore(root_dir / "data" / "post_drafts.json")
    repository = ReviewQueueRepository(store)
    registry = build_registry()
    request_factory = PublishRequestFactory(root_dir, dry_run)
    result_applier = PublishedResultApplier()

    return PublisherService(
        repository=repository,
        registry=registry,
        request_factory=request_factory,
        result_applier=result_applier,
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)

    publish_parser = subparsers.add_parser("publish")
    publish_parser.add_argument("post_id")
    publish_parser.add_argument("--live", action="store_true")

    next_parser = subparsers.add_parser("publish-next")
    next_parser.add_argument("--live", action="store_true")

    args = parser.parse_args()

    root_dir = Path(__file__).resolve().parent.parent

    dry_run = True

    if args.command == "publish":
        if args.live is True:
            dry_run = False

        service = build_service(root_dir, dry_run)
        service.publish_one(args.post_id)
        return

    if args.command == "publish-next":
        if args.live is True:
            dry_run = False

        service = build_service(root_dir, dry_run)
        service.publish_next()
        return

    raise PublisherError("Unsupported command: " + str(args.command))


if __name__ == "__main__":
    main()

Aufrufe

Dry-Run für einen Beitrag:

python -m src.publisher publish draft_20260512_001

Live-Veröffentlichung:

python -m src.publisher publish draft_20260512_001 --live

Nächsten freigegebenen Beitrag im Dry-Run testen:

python -m src.publisher publish-next

Nächsten freigegebenen Beitrag live veröffentlichen:

python -m src.publisher publish-next --live

Ergebnis dieses Kapitels

Die Posting-Engine kann jetzt:

freigegebene Beiträge laden
Plattformadapter auswählen
Text einfügen
Bilder hochladen
Links anhängen
Dry-Run ausführen
Post absenden
Screenshots speichern
Ergebnis in der Queue speichern
Status auf dry_run, published, failed oder blocked setzen

Die Plattformlogik bleibt in Adapterklassen. Der Publisher selbst bleibt generisch.

0 Kommentare