Artikel

14. LinkedIn-Automatisierung

Allgemein

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