Artikel

16. Facebook-Automatisierung

Allgemein

Facebook ist für einen Verlag eher ein Zweitverwertungskanal als ein technischer Fokus. Sinnvoll sind dort:

Buchhinweise
Blogartikel
Neuerscheinungen
Verlagsnotizen
Covermotive
Antworten auf sachliche Kommentare

Für Seitenbeiträge ist Meta Business Suite der sauberere Arbeitskontext. Meta beschreibt dort den Desktop-Ablauf mit „Create post“, Auswahl der Zielplattformen, Text, Foto/Video und Veröffentlichung beziehungsweise Planung. (Facebook)

Die Automatisierung bleibt begrenzt:

Beitrag auf eigener Seite veröffentlichen
Bildbeitrag veröffentlichen
Linkbeitrag veröffentlichen
Kommentare lesen
Antwortvorschläge erzeugen
keine Masseninteraktion

16.1 Seitenkontext

Facebook unterscheidet zwischen persönlichem Profil und Seite.

Für den Verlag sollte nur die Seite verwendet werden.

persönliches Profil: nicht automatisieren
Verlagsseite: geeigneter Kontext
Gruppen: nicht im MVP
Meta Business Suite: bevorzugter Veröffentlichungsweg

Konfiguration:

accounts:
  facebook:
    account_name: "xyz Verlag"
    account_type: "page"
    page_name: "xyz Verlag"
    business_suite_url: "https://business.facebook.com/latest/home"

Session-Datei:

storage/sessions/facebook.json

Login:

python -m src.browser_session login facebook

Session prüfen:

python -m src.browser_session check facebook --headed

Der Adapter muss nach dem Laden prüfen:

Login erforderlich
Sicherheitsprüfung
falscher Account-Kontext
Seite nicht auswählbar
Meta-Business-Suite nicht erreichbar

Beispiel:

def assert_logged_in(page: Page) -> None:
    current_url = page.url.lower()

    if "login" in current_url:
        raise FacebookBlockedError("Facebook login required.")

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

    blocked_fragments = [
        "captcha",
        "security check",
        "sicherheitsüberprüfung",
        "unusual activity",
        "verdächtige aktivität",
    ]

    for blocked_fragment in blocked_fragments:
        if blocked_fragment in body_text:
            raise FacebookBlockedError("Blocked Facebook state detected: " + blocked_fragment)

16.2 Beitrag auf Seite veröffentlichen

Ablauf:

Meta Business Suite öffnen
Login prüfen
Create post öffnen
Seitenkontext prüfen
Text einfügen
Dry-Run oder veröffentlichen
Screenshot speichern
Status zurückgeben

Button-Varianten:

class FacebookButtonClicker:
    def click_first_matching_button(self, page: Page, names: list[str]) -> None:
        for name in names:
            locator = page.get_by_role("button", name=name)

            if locator.count() > 0:
                locator.first.click(timeout=10_000)
                return

        for name in names:
            locator = page.get_by_text(name, exact=True)

            if locator.count() > 0:
                locator.first.click(timeout=10_000)
                return

        raise FacebookPublisherError("No matching Facebook button found.")

Post-Dialog öffnen:

button_clicker.click_first_matching_button(
    page=page,
    names=[
        "Beitrag erstellen",
        "Create post",
        "Post erstellen",
    ],
)

Text einfügen:

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

16.3 Bildbeitrag

Bildbeiträge eignen sich für:

Covermotiv
Zitatkarte
Autorenmotiv
Reihenmotiv
Neuerscheinung

Bildvalidierung:

from pathlib import Path


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

        path = Path(image_path)

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

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

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

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

Upload:

button_clicker.click_first_matching_button(
    page=page,
    names=[
        "Foto hinzufügen",
        "Add photo",
        "Medien hinzufügen",
        "Add media",
    ],
)

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

Bei Upload-Dialog über FileChooser:

with page.expect_file_chooser() as file_chooser_info:
    button_clicker.click_first_matching_button(
        page=page,
        names=[
            "Foto hinzufügen",
            "Add photo",
            "Vom Computer auswählen",
            "Select from computer",
        ],
    )

file_chooser = file_chooser_info.value
file_chooser.set_files(image_path)

16.4 Linkbeitrag

Facebook eignet sich für Linkbeiträge besser als Instagram.

Geeignet:

Blogartikel
Shopseite
Buchdetailseite
Verlagsprogramm

Link wird im MVP an den Text angehängt.

class FacebookPostTextBuilder:
    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

Formale Linkprüfung:

from urllib.parse import urlparse


class FacebookLinkValidator:
    def __init__(self, allowed_domains: list[str]) -> None:
        self.allowed_domains = allowed_domains

    def validate_optional(self, target_url: str | None) -> None:
        if target_url is None:
            return

        parsed_url = urlparse(target_url)

        if parsed_url.scheme != "https":
            raise FacebookPublisherError("Facebook link must use https.")

        if parsed_url.netloc not in self.allowed_domains:
            raise FacebookPublisherError("Facebook link domain is not allowed: " + parsed_url.netloc)

Regeln:

Link nur bei eigenen Beiträgen
kein Link in Kommentarantworten
kein Link bei reinen Zitatkarten
kein aggressiver CTA

16.5 Kommentare lesen

Kommentare werden nur unter eigenen Seitenbeiträgen gelesen.

Ablauf:

Post-URL öffnen
Kommentare sichtbar machen
sichtbare Kommentartexte extrahieren
als InteractionCandidate speichern
keine automatische Antwort veröffentlichen

Einfacher Reader:

class FacebookCommentReader:
    def read_comments_from_post(self, page: Page, post_url: str) -> list[str]:
        page.goto(
            post_url,
            wait_until="domcontentloaded",
            timeout=30_000,
        )

        page.wait_for_timeout(3000)

        possible_comment_selectors = [
            "[aria-label*='Kommentar']",
            "[aria-label*='Comment']",
        ]

        comments: list[str] = []

        for selector in possible_comment_selectors:
            locator = page.locator(selector)
            count = locator.count()
            index = 0

            while index < count:
                text = locator.nth(index).inner_text(timeout=5_000).strip()

                if text != "":
                    comments.append(text)

                index += 1

        return comments

Für das MVP reicht eine Sichtprüfung. Bei unklarem DOM:

Screenshot speichern
Status failed setzen
keine weiteren Klickketten

16.6 Antwortvorschläge

Antwortvorschläge werden erzeugt, aber nicht automatisch veröffentlicht.

Ablauf:

Kommentar laden
Kontext laden
OpenAI erzeugt Antwortvorschlag
Quality-Gate prüft Antwort
Vorschlag in Review-Queue speichern
manuelle Freigabe

Prompt:

Du schreibst eine kurze Antwort auf einen Facebook-Kommentar für einen kleinen deutschen E-Book-Verlag.

Ursprünglicher Beitrag:
{{ post_text }}

Kommentar:
{{ comment_text }}

Kontext:
{{ publisher_context }}

Regeln:
- maximal 500 Zeichen
- sachlich
- höflich
- keine Emojis
- keine private Ansprache
- kein Verkaufslink
- keine Eskalation
- keine erfundenen Zusagen
- bei unklarem Kommentar: keine Antwort erzeugen

Ausgabe:
Wenn eine Antwort sinnvoll ist, gib nur den Antworttext zurück.
Wenn keine Antwort sinnvoll ist, gib exakt zurück:
NO_REPLY

Modell:

from dataclasses import dataclass


@dataclass(frozen=True)
class FacebookReplySuggestion:
    source_post_id: str
    comment_text: str
    suggested_reply: str
    status: str

Service:

class FacebookReplySuggestionService:
    def __init__(
        self,
        openai_client: OpenAiClient,
        prompt_renderer: PromptTemplateRenderer,
        safety_identifier: str,
        model: str,
    ) -> None:
        self.openai_client = openai_client
        self.prompt_renderer = prompt_renderer
        self.safety_identifier = safety_identifier
        self.model = model

    def suggest_reply(
        self,
        source_post_id: str,
        post_text: str,
        comment_text: str,
        publisher_context: str,
    ) -> FacebookReplySuggestion:
        input_text = self.prompt_renderer.render(
            "reply_to_user_comment.txt",
            {
                "post_text": post_text,
                "user_comment": comment_text,
                "context": publisher_context,
            },
        )

        request = OpenAiTextRequest(
            model=self.model,
            instructions="Erzeuge sachliche Antwortvorschläge für Facebook-Kommentare.",
            input_text=input_text,
            safety_identifier=self.safety_identifier,
            max_output_tokens=300,
        )

        result = self.openai_client.create_text(request)
        reply = result.text.strip()

        if reply == "NO_REPLY":
            status = "rejected"
        else:
            status = "suggested"

        return FacebookReplySuggestion(
            source_post_id=source_post_id,
            comment_text=comment_text,
            suggested_reply=reply,
            status=status,
        )

16.7 Beispiel: facebook_publisher.py

Datei:

src/platforms/facebook_publisher.py

from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse

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


class FacebookPublisherError(Exception):
    pass


class FacebookBlockedError(FacebookPublisherError):
    pass


@dataclass(frozen=True)
class FacebookPublishRequest:
    id: str
    text: str
    image_path: str | None
    target_url: str | None
    storage_state_path: Path
    screenshot_dir: Path
    dry_run: bool
    include_link: bool
    business_suite_url: str


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


class FacebookScreenshotService:
    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 FacebookButtonClicker:
    def click_first_matching_button(self, page: Page, names: list[str]) -> None:
        for name in names:
            locator = page.get_by_role("button", name=name)

            if locator.count() > 0:
                locator.first.click(timeout=10_000)
                return

        for name in names:
            locator = page.get_by_text(name, exact=True)

            if locator.count() > 0:
                locator.first.click(timeout=10_000)
                return

        raise FacebookPublisherError("No matching Facebook button found.")


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

        path = Path(image_path)

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

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

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

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


class FacebookLinkValidator:
    def __init__(self) -> None:
        self.allowed_domains = [
            "xyz.de",
            "www.xyz.de",
            "abc.com",
            "www.abc.com",
        ]

    def validate_optional(self, target_url: str | None) -> None:
        if target_url is None:
            return

        parsed_url = urlparse(target_url)

        if parsed_url.scheme != "https":
            raise FacebookPublisherError("Facebook link must use https.")

        if parsed_url.netloc not in self.allowed_domains:
            raise FacebookPublisherError("Facebook link domain is not allowed: " + parsed_url.netloc)


class FacebookPostTextBuilder:
    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


class FacebookTextInserter:
    def insert(self, page: Page, text: str) -> None:
        for character in text:
            if character == "\n":
                page.keyboard.press("Shift+Enter")
            else:
                page.keyboard.insert_text(character)


class FacebookPublisher:
    def __init__(self) -> None:
        self.screenshot_service = FacebookScreenshotService()
        self.button_clicker = FacebookButtonClicker()
        self.image_validator = FacebookImageValidator()
        self.link_validator = FacebookLinkValidator()
        self.text_builder = FacebookPostTextBuilder()
        self.text_inserter = FacebookTextInserter()

    def publish(self, request: FacebookPublishRequest) -> FacebookPublishResult:
        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(
                    request.business_suite_url,
                    wait_until="domcontentloaded",
                    timeout=30_000,
                )

                self._assert_logged_in(page)
                self._open_create_post(page)

                post_text = self.text_builder.build(
                    text=request.text,
                    target_url=request.target_url,
                    include_link=request.include_link,
                )

                self._insert_text(page, 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 FacebookPublishResult(
                        post_id=request.id,
                        status="dry_run",
                        screenshot_path=str(screenshot_path),
                        platform_post_url=None,
                        error_message=None,
                    )

                self._publish(page)

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

                browser.close()

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

                browser.close()

                return FacebookPublishResult(
                    post_id=request.id,
                    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 FacebookPublishResult(
                    post_id=request.id,
                    status="failed",
                    screenshot_path=str(screenshot_path),
                    platform_post_url=None,
                    error_message=str(exception),
                )
            except FacebookPublisherError as exception:
                screenshot_path = self.screenshot_service.save(
                    page=page,
                    screenshot_dir=request.screenshot_dir,
                    post_id=request.id,
                    status="failed",
                )

                browser.close()

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

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

        if request.text.strip() == "":
            raise FacebookPublisherError("Facebook post text must not be empty.")

        if request.business_suite_url.strip() == "":
            raise FacebookPublisherError("Facebook business_suite_url must not be empty.")

        self.image_validator.validate_optional_file(request.image_path)
        self.link_validator.validate_optional(request.target_url)

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

        if "login" in current_url:
            raise FacebookBlockedError("Facebook login required.")

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

        blocked_fragments = [
            "captcha",
            "security check",
            "sicherheitsüberprüfung",
            "unusual activity",
            "verdächtige aktivität",
        ]

        for blocked_fragment in blocked_fragments:
            if blocked_fragment in body_text:
                raise FacebookBlockedError(
                    "Blocked Facebook state detected: "
                    + blocked_fragment
                )

    def _open_create_post(self, page: Page) -> None:
        self.button_clicker.click_first_matching_button(
            page=page,
            names=[
                "Beitrag erstellen",
                "Create post",
                "Post erstellen",
            ],
        )

    def _insert_text(self, page: Page, post_text: str) -> None:
        textboxes = page.get_by_role("textbox")

        if textboxes.count() == 0:
            raise FacebookPublisherError("Facebook post textbox not found.")

        textbox = textboxes.first
        textbox.click(timeout=10_000)
        self.text_inserter.insert(page, post_text)

    def _upload_image(self, page: Page, image_path: str) -> None:
        file_inputs = page.locator("input[type=file]")

        if file_inputs.count() > 0:
            file_inputs.first.set_input_files(image_path)
            page.wait_for_timeout(3000)
            return

        with page.expect_file_chooser() as file_chooser_info:
            self.button_clicker.click_first_matching_button(
                page=page,
                names=[
                    "Foto hinzufügen",
                    "Add photo",
                    "Medien hinzufügen",
                    "Add media",
                    "Vom Computer auswählen",
                    "Select from computer",
                ],
            )

        file_chooser = file_chooser_info.value
        file_chooser.set_files(image_path)

        page.wait_for_timeout(3000)

    def _publish(self, page: Page) -> None:
        self.button_clicker.click_first_matching_button(
            page=page,
            names=[
                "Veröffentlichen",
                "Publish",
                "Posten",
                "Post",
            ],
        )

        page.wait_for_timeout(5000)


class FacebookCommentReader:
    def read_comments_from_post(self, page: Page, post_url: str) -> list[str]:
        page.goto(
            post_url,
            wait_until="domcontentloaded",
            timeout=30_000,
        )

        page.wait_for_timeout(3000)

        possible_comment_selectors = [
            "[aria-label*='Kommentar']",
            "[aria-label*='Comment']",
        ]

        comments: list[str] = []

        for selector in possible_comment_selectors:
            locator = page.locator(selector)
            count = locator.count()
            index = 0

            while index < count:
                text = locator.nth(index).inner_text(timeout=5_000).strip()

                if text != "":
                    comments.append(text)

                index += 1

        return comments

Beispielaufruf

from pathlib import Path

from src.platforms.facebook_publisher import FacebookPublisher
from src.platforms.facebook_publisher import FacebookPublishRequest


def main() -> None:
    root_dir = Path(__file__).resolve().parent.parent

    request = FacebookPublishRequest(
        id="draft_20260512_003",
        text=(
            "Gemeinfreie Literatur ist frei verfügbar, aber nicht automatisch "
            "eine gute digitale Ausgabe. Korrektur, Typografie und Metadaten "
            "bleiben verlegerische Arbeit."
        ),
        image_path=None,
        target_url="https://www.xyz.de",
        storage_state_path=root_dir / "storage" / "sessions" / "facebook.json",
        screenshot_dir=root_dir / "storage" / "screenshots" / "facebook",
        dry_run=True,
        include_link=True,
        business_suite_url="https://business.facebook.com/latest/home",
    )

    publisher = FacebookPublisher()
    result = publisher.publish(request)

    print(result)


if __name__ == "__main__":
    main()

Ergebnis dieses Kapitels

Der Facebook-Adapter kann damit:

Meta Business Suite öffnen
gespeicherte Session verwenden
Login- und Sicherheitszustände erkennen
Beitrag auf einer Seite vorbereiten
Textbeitrag erstellen
Bildbeitrag erstellen
Linkbeitrag erstellen
Dry-Run vor Veröffentlichung ausführen
Beitrag veröffentlichen
Screenshot speichern
Kommentare unter eigenen Beiträgen lesen
Antwortvorschläge vorbereiten

Facebook bleibt im MVP ein Seitenkanal. Persönliche Profile, Gruppen, automatische Kommentarveröffentlichung und Reichweiteninteraktionen werden nicht automatisiert.

0 Kommentare