Artikel

15. Instagram-Automatisierung

Allgemein

Instagram ist für den Verlag vor allem ein Bildkanal. Die Plattform eignet sich weniger für längere fachliche Argumente und stärker für visuelle Formate:

Covermotive
Zitatkarten
Buchästhetik
Autorenmotive
Reihenmotive
kurze literarische Einordnungen

Die Automatisierung sollte hier besonders eng begrenzt sein:

Bild vorbereiten
Caption erzeugen
Hashtags begrenzen
Beitrag im Browser vorbereiten
Screenshot speichern
veröffentlichen oder Dry-Run abbrechen

Instagram unterstützt das Erstellen von Beiträgen über die Desktop-Weboberfläche; der typische Ablauf ist Login, Create-Button, Mediendatei auswählen, Caption ergänzen und teilen. Aktuelle Desktop-Anleitungen beschreiben genau diesen Browser-Workflow. (EvergreenFeed)

15.1 Besonderheiten bei Instagram

Instagram unterscheidet sich von LinkedIn und Facebook in mehreren Punkten.

Bild oder Video steht im Zentrum.
Caption ist sekundär.
Links in Captions sind nicht wie normale Weblinks nutzbar.
Hashtags sind wichtiger, aber schnell störend.
Desktop-UI kann sich ändern.
Upload-Dialoge sind empfindlicher als reine Textfelder.

Für dieses System wird zunächst nur ein einfacher Bildbeitrag unterstützt:

ein Bild
eine Caption
maximal 5 Hashtags
kein automatisches Kommentieren
kein automatisches Liken
kein automatisches Folgen
keine Story-Automatisierung
keine Reels-Automatisierung

Nicht im MVP:

Carousel-Posts
Reels
Stories
DMs
Kommentarautomatisierung
Follower-Interaktionen
Hashtag-Scraping

Die technische Strategie bleibt:

Instagram im Browser öffnen
gespeicherte Session laden
Create-Dialog öffnen
Bild hochladen
Caption einfügen
Dry-Run oder teilen
Screenshot speichern
Status zurückgeben

Playwright unterstützt Datei-Uploads über locator.set_input_files() auf input[type=file]; relative Pfade werden relativ zum aktuellen Arbeitsverzeichnis aufgelöst. (Playwright)

15.2 Bildpflicht

Für Instagram ist ein Bild Pflicht.

Ein Instagram-Post ohne image_path wird abgelehnt.

from pathlib import Path


class InstagramImageValidator:
    def validate_required_file(self, image_path: str | None) -> None:
        if image_path is None:
            raise InstagramPublisherError("Instagram post requires image_path.")

        path = Path(image_path)

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

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

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

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

Empfohlene Bildgrößen für das Verlagssystem:

Quadratisch: 1080 × 1080
Hochformat: 1080 × 1350
Story/Reel nicht im MVP

Für Buchcover oder Zitatkarten ist 1080 × 1350 meist besser, weil das Motiv im Feed mehr Raum bekommt.

Technische Bildprüfung kann später ergänzt werden:

from pathlib import Path
from PIL import Image


class InstagramImageDimensionValidator:
    def validate_dimensions(self, image_path: str) -> None:
        path = Path(image_path)

        with Image.open(path) as image:
            width, height = image.size

        allowed_sizes = [
            (1080, 1080),
            (1080, 1350),
        ]

        if (width, height) not in allowed_sizes:
            raise InstagramPublisherError(
                "Unexpected Instagram image size: "
                + str(width)
                + "x"
                + str(height)
            )

Pillow wäre dafür zusätzliche Abhängigkeit:

pip install pillow

Für das MVP reicht die Dateiprüfung.

15.3 Caption erzeugen

Die Caption wird nicht direkt aus dem Buchtitel erzeugt, sondern aus normalisierten Eingangsdaten.

Geeignete Eingangsdaten:

Buchtitel
Autor
kurze Beschreibung
Zitat
Einordnung
Bildbeschreibung
Tags

Prompt-Datei:

prompts/instagram_caption.txt

Du schreibst eine Instagram-Caption für einen kleinen deutschen E-Book-Verlag.

Thema:
{{ title }}

Ausgangsmaterial:
{{ body }}

Autor:
{{ author }}

Buchtitel:
{{ book_title }}

Bildbeschreibung:
{{ image_description }}

Ziel:
Die Caption soll Bildmotiv, literarischen Kontext und Verlag sachlich verbinden.

Stil:
- knapp
- bildnah
- literarisch, aber nicht kitschig
- keine übertriebene Werbung
- keine Emojis
- keine Floskeln

Regeln:
- maximal 700 Zeichen
- maximal 5 Hashtags
- keine Hashtags im Fließtext
- kein direkter Kaufdruck
- keine erfundenen Fakten
- keine Formulierungen wie „perfekt für gemütliche Abende“
- keine direkte Ansprache mit „Du“

Ausgabe:
Erzeuge nur die Caption.

Caption-Modell:

from dataclasses import dataclass


@dataclass(frozen=True)
class InstagramCaptionRequest:
    title: str
    body: str
    author: str
    book_title: str
    image_description: str
    tags: list[str]

Caption-Builder:

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

    def create_caption(self, caption_request: InstagramCaptionRequest) -> str:
        input_text = self.prompt_renderer.render(
            "instagram_caption.txt",
            {
                "title": caption_request.title,
                "body": caption_request.body,
                "author": caption_request.author,
                "book_title": caption_request.book_title,
                "image_description": caption_request.image_description,
            },
        )

        request = OpenAiTextRequest(
            model=self.model,
            instructions="Erzeuge sachliche Instagram-Captions für einen deutschen E-Book-Verlag.",
            input_text=input_text,
            safety_identifier=self.safety_identifier,
            max_output_tokens=500,
        )

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

        hashtags = self.hashtag_generator.generate(caption_request.tags)

        if len(hashtags) > 0:
            caption = caption.rstrip() + "\n\n" + " ".join(hashtags)

        self._validate(caption)

        return caption

    def _validate(self, caption: str) -> None:
        if caption.strip() == "":
            raise InstagramPublisherError("Instagram caption must not be empty.")

        if len(caption) > 700:
            raise InstagramPublisherError("Instagram caption exceeds internal limit.")

15.4 Bild hochladen

Der Upload erfolgt im Create-Dialog.

Typischer Ablauf:

Instagram öffnen
Create-Button klicken
Dateiinput finden
Bildpfad setzen
Weiter klicken
Caption einfügen
Dry-Run oder teilen

UI-Texte können je nach Sprache abweichen. Deshalb werden mehrere Varianten unterstützt:

class InstagramButtonClicker:
    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 InstagramPublisherError("No matching Instagram button found.")

Create öffnen:

button_clicker.click_first_matching_button(
    page=page,
    names=[
        "Erstellen",
        "Create",
        "Neue Veröffentlichung",
        "New post",
    ],
)

Datei setzen:

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

Wenn Instagram statt sichtbarem Dateiinput einen FileChooser öffnet:

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

file_chooser = file_chooser_info.value
file_chooser.set_files(image_path)

Playwright dokumentiert dafür FileChooser.set_files(); bei direktem input[type=file] ist locator.set_input_files() meist einfacher. (Playwright)

15.5 Beitrag veröffentlichen

Für Instagram sind mehrere „Weiter“-Schritte üblich.

Ablauf im Adapter:

Upload
Weiter
optional Zuschnitt/Originalgröße
Weiter
Caption einfügen
Dry-Run-Screenshot
Teilen
Abschluss-Screenshot

Caption einfügen:

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

Absenden:

button_clicker.click_first_matching_button(
    page=page,
    names=[
        "Teilen",
        "Share",
    ],
)

Nach dem Klick nicht blind wiederholen.

Wenn unklar ist, ob der Post veröffentlicht wurde:
Screenshot speichern
Status failed setzen
manuelle Prüfung

Dry-Run-Regel:

Dry-Run endet nach Caption-Eingabe vor „Teilen“.

15.6 Hashtags begrenzen

Hashtags werden regelbasiert aus Tags erzeugt.

Limit:

maximal 5 Hashtags

Generator:

class InstagramHashtagGenerator:
    def generate(self, tags: list[str]) -> list[str]:
        hashtags: list[str] = []

        for tag in tags:
            hashtag = self._normalize(tag)

            if hashtag == "":
                continue

            if hashtag in hashtags:
                continue

            hashtags.append(hashtag)

            if len(hashtags) >= 5:
                break

        return hashtags

    def _normalize(self, tag: str) -> str:
        cleaned_tag = tag.strip().lower()

        if cleaned_tag == "":
            return ""

        replacements: dict[str, str] = {
            "ä": "ae",
            "ö": "oe",
            "ü": "ue",
            "ß": "ss",
            "-": "",
            " ": "",
        }

        for search, replacement in replacements.items():
            cleaned_tag = cleaned_tag.replace(search, replacement)

        allowed_characters = "abcdefghijklmnopqrstuvwxyz0123456789"
        normalized = ""

        for character in cleaned_tag:
            if character in allowed_characters:
                normalized = normalized + character

        if normalized == "":
            return ""

        return "#" + normalized

Beispiel:

generator = InstagramHashtagGenerator()

hashtags = generator.generate(
    [
        "klassiker",
        "Jane Austen",
        "E-Book",
        "gemeinfreie Literatur",
        "Verlag",
        "Buchmarketing",
    ]
)

print(hashtags)

Ausgabe:

['#klassiker', '#janeausten', '#ebook', '#gemeinfreieliteratur', '#verlag']

Verbotene Hashtags können ergänzt werden:

class InstagramHashtagPolicy:
    def __init__(self) -> None:
        self.blocked_hashtags = [
            "#followforfollow",
            "#likeforlike",
            "#viral",
            "#instagood",
        ]

    def validate(self, hashtags: list[str]) -> None:
        for hashtag in hashtags:
            if hashtag.lower() in self.blocked_hashtags:
                raise InstagramPublisherError("Blocked hashtag: " + hashtag)

15.7 Beispiel: instagram_publisher.py

Datei:

src/platforms/instagram_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 InstagramPublisherError(Exception):
    pass


class InstagramBlockedError(InstagramPublisherError):
    pass


@dataclass(frozen=True)
class InstagramPublishRequest:
    id: str
    caption: str
    image_path: str
    storage_state_path: Path
    screenshot_dir: Path
    dry_run: bool


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


class InstagramScreenshotService:
    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 InstagramImageValidator:
    def validate_required_file(self, image_path: str | None) -> None:
        if image_path is None:
            raise InstagramPublisherError("Instagram post requires image_path.")

        path = Path(image_path)

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

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

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

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


class InstagramButtonClicker:
    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 InstagramPublisherError("No matching Instagram button found.")


class InstagramCaptionValidator:
    def validate(self, caption: str) -> None:
        cleaned_caption = caption.strip()

        if cleaned_caption == "":
            raise InstagramPublisherError("Instagram caption must not be empty.")

        if len(cleaned_caption) > 700:
            raise InstagramPublisherError("Instagram caption exceeds internal limit.")

        blocked_fragments = [
            "jetzt kaufen",
            "unbedingt lesen",
            "follow for follow",
            "like for like",
        ]

        lowered_caption = cleaned_caption.lower()

        for blocked_fragment in blocked_fragments:
            if blocked_fragment in lowered_caption:
                raise InstagramPublisherError("Blocked caption fragment: " + blocked_fragment)


class InstagramTextInserter:
    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 InstagramPublisher:
    def __init__(self) -> None:
        self.screenshot_service = InstagramScreenshotService()
        self.image_validator = InstagramImageValidator()
        self.button_clicker = InstagramButtonClicker()
        self.caption_validator = InstagramCaptionValidator()
        self.text_inserter = InstagramTextInserter()

    def publish(self, request: InstagramPublishRequest) -> InstagramPublishResult:
        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.instagram.com/",
                    wait_until="domcontentloaded",
                    timeout=30_000,
                )

                self._assert_logged_in(page)
                self._open_create_dialog(page)
                self._upload_image(page, request.image_path)
                self._continue_after_upload(page)
                self._insert_caption(page, request.caption)

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

                self._share(page)

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

                browser.close()

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

                browser.close()

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

                browser.close()

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

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

        self.image_validator.validate_required_file(request.image_path)
        self.caption_validator.validate(request.caption)

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

        if "login" in current_url:
            raise InstagramBlockedError("Instagram 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 InstagramBlockedError(
                    "Blocked Instagram state detected: "
                    + blocked_fragment
                )

    def _open_create_dialog(self, page: Page) -> None:
        self.button_clicker.click_first_matching_button(
            page=page,
            names=[
                "Erstellen",
                "Create",
                "Neue Veröffentlichung",
                "New post",
            ],
        )

    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=[
                    "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 _continue_after_upload(self, page: Page) -> None:
        self.button_clicker.click_first_matching_button(
            page=page,
            names=[
                "Weiter",
                "Next",
            ],
        )

        page.wait_for_timeout(1000)

        next_buttons = page.get_by_text("Weiter", exact=True)

        if next_buttons.count() > 0:
            next_buttons.first.click(timeout=10_000)
            page.wait_for_timeout(1000)
            return

        next_buttons_english = page.get_by_text("Next", exact=True)

        if next_buttons_english.count() > 0:
            next_buttons_english.first.click(timeout=10_000)
            page.wait_for_timeout(1000)
            return

    def _insert_caption(self, page: Page, caption: str) -> None:
        caption_boxes = page.get_by_role("textbox")

        if caption_boxes.count() == 0:
            raise InstagramPublisherError("Caption textbox not found.")

        caption_box = caption_boxes.first
        caption_box.click(timeout=10_000)
        self.text_inserter.insert(page, caption)

    def _share(self, page: Page) -> None:
        self.button_clicker.click_first_matching_button(
            page=page,
            names=[
                "Teilen",
                "Share",
            ],
        )

        page.wait_for_timeout(5000)

Beispielaufruf

from pathlib import Path

from src.platforms.instagram_publisher import InstagramPublisher
from src.platforms.instagram_publisher import InstagramPublishRequest


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

    request = InstagramPublishRequest(
        id="draft_20260512_002",
        caption=(
            "Ein Klassiker lebt nicht nur vom Text, sondern auch von der Form, "
            "in der er heute gelesen wird. Eine digitale Ausgabe braucht "
            "Korrektur, Typografie und klare Metadaten.\n\n"
            "#klassiker #ebook #verlag"
        ),
        image_path=str(root_dir / "storage" / "images" / "stolz-und-vorurteil.jpg"),
        storage_state_path=root_dir / "storage" / "sessions" / "instagram.json",
        screenshot_dir=root_dir / "storage" / "screenshots" / "instagram",
        dry_run=True,
    )

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

    print(result)


if __name__ == "__main__":
    main()

Ergebnis dieses Kapitels

Der Instagram-Adapter kann damit:

gespeicherte Session verwenden
Login- und Sicherheitszustände erkennen
Bildpflicht prüfen
Caption validieren
Create-Dialog öffnen
Bilddatei hochladen
Caption einfügen
Hashtags begrenzen
Dry-Run vor dem Teilen abbrechen
Beitrag veröffentlichen
Screenshot speichern
Status zurückgeben

Instagram bleibt im MVP ein reiner Veröffentlichungskanal für eigene Bildbeiträge. Interaktionen, Likes, Follows, DMs, Stories und Reels werden nicht automatisiert.

0 Kommentare