16. Facebook-Automatisierung
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