14. LinkedIn-Automatisierung
LinkedIn ist für dieses System die wichtigste Plattform, weil dort fachliche Beiträge über Verlag, KI, Automatisierung und digitale Buchproduktion besser funktionieren als reine Buchwerbung.
Die Automatisierung beschränkt sich auf:
Login-Session wiederverwenden
eigene Beiträge vorbereiten
Text posten
Bild posten
Link posten
eigene Beiträge prüfen
Kommentare unter eigenen Beiträgen lesen
Antwortvorschläge erzeugen
Nicht Teil dieses Kapitels:
Auto-Follow
Auto-Unfollow
Massenlikes
Massenkommentare
Kommentarspam unter fremden Beiträgen
CAPTCHA-Umgehung
14.1 Login-Session
Die LinkedIn-Session wird manuell erzeugt und danach wiederverwendet.
Session-Datei:
storage/sessions/linkedin.json
Manueller Login:
python -m src.browser_session login linkedin
Session prüfen:
python -m src.browser_session check linkedin --headed
Im Publisher wird die Session geladen:
context = browser.new_context(
storage_state=str(request.storage_state_path)
)
Wenn LinkedIn wieder zur Login-Seite umleitet, wird nicht automatisch weitergemacht.
def assert_logged_in(page: Page) -> None:
current_url = page.url.lower()
if "login" in current_url:
raise LinkedInBlockedError("LinkedIn login required.")
body_text = page.locator("body").inner_text(timeout=10_000).lower()
blocked_fragments = [
"captcha",
"security check",
"sicherheitsüberprüfung",
"unusual activity",
]
for blocked_fragment in blocked_fragments:
if blocked_fragment in body_text:
raise LinkedInBlockedError("Blocked LinkedIn state detected: " + blocked_fragment)
14.2 Beitrag erstellen
Der Ablauf für einen LinkedIn-Beitrag:
Feed öffnen
Login prüfen
Dialog „Beitrag beginnen“ öffnen
Textbox fokussieren
Text einfügen
optional Bild hochladen
optional Link anhängen
Dry-Run oder Posten
Screenshot speichern
Grundgerüst:
page.goto(
"https://www.linkedin.com/feed/",
wait_until="domcontentloaded",
timeout=30_000,
)
assert_logged_in(page)
page.get_by_role("button", name="Beitrag beginnen").click(timeout=10_000)
Da LinkedIn UI-Texte ändern kann, sollte der Adapter mehrere Button-Bezeichnungen unterstützen:
class LinkedInButtonClicker:
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
raise LinkedInPublisherError("No matching LinkedIn button found.")
Verwendung:
button_clicker.click_first_matching_button(
page=page,
names=[
"Beitrag beginnen",
"Start a post",
],
)
14.3 Text posten
LinkedIn verwendet häufig editierbare Textbereiche. fill() ist nicht immer stabil. Für das MVP ist keyboard.insert_text() oft robuster.
textbox = page.get_by_role("textbox").first
textbox.click(timeout=10_000)
page.keyboard.insert_text(post_text)
Für Zeilenumbrüche kann ein separater Inserter verwendet werden:
class TextInserter:
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)
Verwendung:
textbox = page.get_by_role("textbox").first
textbox.click(timeout=10_000)
text_inserter = TextInserter()
text_inserter.insert(page, request.text)
14.4 Bild posten
Bildpfad prüfen:
class ImageValidator:
def validate_required_file(self, image_path: str) -> None:
path = Path(image_path)
if path.exists() is False:
raise LinkedInPublisherError("Image file does not exist: " + image_path)
if path.is_file() is False:
raise LinkedInPublisherError("Image path is not a file: " + image_path)
allowed_suffixes = [
".jpg",
".jpeg",
".png",
".webp",
]
if path.suffix.lower() not in allowed_suffixes:
raise LinkedInPublisherError("Unsupported image file type: " + image_path)
Upload-Button klicken:
button_clicker.click_first_matching_button(
page=page,
names=[
"Bild hinzufügen",
"Medien hinzufügen",
"Foto hinzufügen",
"Add media",
"Add a photo",
],
)
Datei setzen:
file_input = page.locator("input[type=file]").first
file_input.set_input_files(image_path)
Danach kurz auf sichtbare UI-Reaktion warten:
page.wait_for_timeout(3000)
Das ist eine der wenigen Stellen, an denen ein kurzer fester Wait pragmatisch ist, weil Upload-Dialoge je nach Datei und Verbindung unterschiedlich reagieren.
14.5 Link posten
Links werden für LinkedIn im MVP einfach an den Text angehängt.
class LinkedInPostTextBuilder:
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:
Link nur bei eigenen Beiträgen
kein Link in Kommentaren
kein aggressiver CTA
Beispiel:
Gemeinfreie Texte sind frei verfügbar, aber nicht automatisch verlagsfertig. OCR-Korrektur, Typografie und Metadaten bleiben echte Arbeit.
https://www.xyz.de/...
14.6 Eigene Beiträge prüfen
Für das MVP reicht eine einfache Prüfung der eigenen Profil- oder Feed-Seite.
Ziel:
Seite öffnen
sichtbare Beiträge erfassen
Textauszüge speichern
Screenshots erzeugen
keine Likes oder Kommentare ausführen
Beispiel:
def read_visible_feed_texts(page: Page) -> list[str]:
articles = page.locator("article")
count = articles.count()
texts: list[str] = []
index = 0
while index < count:
article = articles.nth(index)
text = article.inner_text(timeout=5_000).strip()
if text != "":
texts.append(text)
index += 1
return texts
Aufruf:
page.goto(
"https://www.linkedin.com/feed/",
wait_until="domcontentloaded",
timeout=30_000,
)
texts = read_visible_feed_texts(page)
Diese Prüfung ist nicht als vollständiges Scraping gedacht, sondern als einfache Sichtkontrolle.
14.7 Kommentare unter eigenen Beiträgen lesen
Für Kommentare unter eigenen Beiträgen ist ein gespeicherter Beitragslink hilfreich.
Datenmodell:
{
"id": "draft_20260512_001",
"platform": "linkedin",
"status": "published",
"platform_post_url": "https://www.linkedin.com/feed/update/...",
"published_at": "2026-05-12T09:30:00+02:00"
}
Kommentare lesen:
def read_comments_from_post(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
Da LinkedIn-Kommentarbereiche variieren, ist das bewusst nur ein Einstieg. Der produktive Adapter sollte bei Fehlschlag Screenshot und HTML-Auszug speichern, aber nicht blind weiterprobieren.
14.8 Antwortvorschläge erzeugen
Antworten auf Kommentare werden nicht automatisch veröffentlicht.
Ablauf:
Kommentar lesen
Kommentar klassifizieren
OpenAI erzeugt Antwortvorschlag
Quality-Gate prüft Antwort
Antwort landet in Review-Queue
manuelle Freigabe
Prompt:
Du schreibst eine kurze Antwort auf einen LinkedIn-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
- 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
Python-Service:
@dataclass(frozen=True)
class ReplySuggestion:
source_post_id: str
comment_text: str
suggested_reply: str
status: str
class LinkedInReplySuggestionService:
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,
) -> ReplySuggestion:
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 LinkedIn-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 ReplySuggestion(
source_post_id=source_post_id,
comment_text=comment_text,
suggested_reply=reply,
status=status,
)
14.9 Beispiel: linkedin_publisher.py
Datei:
src/platforms/linkedin_publisher.py
from dataclasses import dataclass
from pathlib import Path
from playwright.sync_api import Page
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
class LinkedInPublisherError(Exception):
pass
class LinkedInBlockedError(LinkedInPublisherError):
pass
@dataclass(frozen=True)
class LinkedInPublishRequest:
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
@dataclass(frozen=True)
class LinkedInPublishResult:
post_id: str
status: str
screenshot_path: str
platform_post_url: str | None
error_message: str | None
class LinkedInScreenshotService:
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 LinkedInButtonClicker:
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
raise LinkedInPublisherError("No matching LinkedIn button found.")
class LinkedInPostTextBuilder:
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 LinkedInTextInserter:
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 LinkedInImageValidator:
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 LinkedInPublisherError("Image file does not exist: " + image_path)
if path.is_file() is False:
raise LinkedInPublisherError("Image path is not a file: " + image_path)
allowed_suffixes = [
".jpg",
".jpeg",
".png",
".webp",
]
if path.suffix.lower() not in allowed_suffixes:
raise LinkedInPublisherError("Unsupported image file type: " + image_path)
class LinkedInPublisher:
def __init__(self) -> None:
self.screenshot_service = LinkedInScreenshotService()
self.button_clicker = LinkedInButtonClicker()
self.text_builder = LinkedInPostTextBuilder()
self.text_inserter = LinkedInTextInserter()
self.image_validator = LinkedInImageValidator()
def publish(self, request: LinkedInPublishRequest) -> LinkedInPublishResult:
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)
self._open_post_dialog(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 LinkedInPublishResult(
post_id=request.id,
status="dry_run",
screenshot_path=str(screenshot_path),
platform_post_url=None,
error_message=None,
)
self._submit(page)
screenshot_path = self.screenshot_service.save(
page=page,
screenshot_dir=request.screenshot_dir,
post_id=request.id,
status="published",
)
browser.close()
return LinkedInPublishResult(
post_id=request.id,
status="published",
screenshot_path=str(screenshot_path),
platform_post_url=None,
error_message=None,
)
except LinkedInBlockedError as exception:
screenshot_path = self.screenshot_service.save(
page=page,
screenshot_dir=request.screenshot_dir,
post_id=request.id,
status="blocked",
)
browser.close()
return LinkedInPublishResult(
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 LinkedInPublishResult(
post_id=request.id,
status="failed",
screenshot_path=str(screenshot_path),
platform_post_url=None,
error_message=str(exception),
)
except LinkedInPublisherError as exception:
screenshot_path = self.screenshot_service.save(
page=page,
screenshot_dir=request.screenshot_dir,
post_id=request.id,
status="failed",
)
browser.close()
return LinkedInPublishResult(
post_id=request.id,
status="failed",
screenshot_path=str(screenshot_path),
platform_post_url=None,
error_message=str(exception),
)
def _validate_request(self, request: LinkedInPublishRequest) -> None:
if request.storage_state_path.exists() is False:
raise LinkedInPublisherError(
"Storage state file does not exist: "
+ str(request.storage_state_path)
)
if request.text.strip() == "":
raise LinkedInPublisherError("Post text must not be empty.")
self.image_validator.validate_optional_file(request.image_path)
def _assert_logged_in(self, page: Page) -> None:
current_url = page.url.lower()
if "login" in current_url:
raise LinkedInBlockedError("LinkedIn login required.")
body_text = page.locator("body").inner_text(timeout=10_000).lower()
blocked_fragments = [
"captcha",
"security check",
"sicherheitsüberprüfung",
"unusual activity",
]
for blocked_fragment in blocked_fragments:
if blocked_fragment in body_text:
raise LinkedInBlockedError(
"Blocked LinkedIn state detected: "
+ blocked_fragment
)
def _open_post_dialog(self, page: Page) -> None:
self.button_clicker.click_first_matching_button(
page=page,
names=[
"Beitrag beginnen",
"Start a post",
],
)
def _insert_text(self, page: Page, post_text: str) -> None:
textbox = page.get_by_role("textbox").first
textbox.click(timeout=10_000)
self.text_inserter.insert(page, post_text)
def _upload_image(self, page: Page, image_path: str) -> None:
self.button_clicker.click_first_matching_button(
page=page,
names=[
"Bild hinzufügen",
"Medien hinzufügen",
"Foto hinzufügen",
"Add media",
"Add a photo",
],
)
file_input = page.locator("input[type=file]").first
file_input.set_input_files(image_path)
page.wait_for_timeout(3000)
def _submit(self, page: Page) -> None:
self.button_clicker.click_first_matching_button(
page=page,
names=[
"Posten",
"Post",
],
)
page.wait_for_timeout(5000)
class LinkedInOwnPostReader:
def read_visible_feed_texts(self, page: Page) -> list[str]:
articles = page.locator("article")
count = articles.count()
texts: list[str] = []
index = 0
while index < count:
article = articles.nth(index)
text = article.inner_text(timeout=5_000).strip()
if text != "":
texts.append(text)
index += 1
return texts
class LinkedInCommentReader:
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 aus der generischen Posting-Engine
from pathlib import Path
from src.platforms.linkedin_publisher import LinkedInPublisher
from src.platforms.linkedin_publisher import LinkedInPublishRequest
def main() -> None:
root_dir = Path(__file__).resolve().parent.parent
request = LinkedInPublishRequest(
id="draft_20260512_001",
text="Gemeinfreie Texte sind frei verfügbar, aber nicht automatisch verlagsfertig. OCR-Korrektur, Typografie und Metadaten bleiben echte Arbeit.",
image_path=None,
target_url="https://www.xyz.de",
storage_state_path=root_dir / "storage" / "sessions" / "linkedin.json",
screenshot_dir=root_dir / "storage" / "screenshots" / "linkedin",
dry_run=True,
include_link=True,
)
publisher = LinkedInPublisher()
result = publisher.publish(request)
print(result)
if __name__ == "__main__":
main()
Ergebnis dieses Kapitels
Der LinkedIn-Adapter kann damit:
gespeicherte Login-Session verwenden
LinkedIn-Feed öffnen
Login- oder Sicherheitszustände erkennen
Beitragsdialog öffnen
Text einfügen
Bild hochladen
Link anhängen
Dry-Run ausführen
Beitrag veröffentlichen
Screenshot speichern
eigene sichtbare Beiträge lesen
Kommentarbereiche einfacher Beiträge erfassen
Antwortvorschläge vorbereiten
Die UI-Selektoren bleiben bewusst zentral und austauschbar. Wenn LinkedIn die Oberfläche ändert, scheitert der Adapter kontrolliert und speichert einen Screenshot.
0 Kommentare