22 m read

How to Build a LinkedIn Publishing Library — Post, Native Image, and First Comment (Browser Automation)

Field-tested in July 2026 on LinkedIn’s company admin UI (2024+ flow). Everything here is standalone — sample Python, sample REST, sample React — so an implementer or an AI agent can reproduce the behavior from this document alone. Field names like content_uuid and draft_id are illustrative; map them to your own schema.


LinkedIn’s composer lies to you. It renders a clean image preview for a corrupt file. It lets you click Post and quietly does nothing. It lets you type a comment that never saves. Each of these hands you a success signal for an action that did not happen — we call that a Phantom Success — and we hit every variant of it during the field test behind this guide.

What we wanted was simple: a company-page post with a native image, no external URL in the body, and the article link as the first comment on that same post. No Marketing API — there is no stable public API for this exact admin flow. What we ended up with is Playwright on a persistent logged-in session, plus a set of guards that refuse to believe any success signal until the state behind it checks out.

The guards are the guide. The Playwright calls are the easy part.

What you are building

Four phases. The operator touches exactly one of them — the review between draft and publish. Everything else is automation.

StepWhoWhat happens
PrepareBackend (+ optional LLM)Generate or load draft post text; persist draft row
ReviewFrontendOperator edits text; confirms publish
PublishBackend + PlaywrightAttach cover image → fill text → Post → add URL as first comment
RecoverFrontendIf the browser disconnects, poll until DB shows published

Out of scope for this pattern: LinkedIn Marketing API organic posting with the same UX (admin composer, crop step, scoped comment on company feed). The validated approach is Playwright + persistent login session.

Design decisions (and why)

None of these are style preferences. Most rows were forced by a failure we hit during validation — the record at the bottom of this guide lists the exact root causes.

DecisionRationale
Playwright, not API-onlyCompany admin uses Create → Start a post → crop → Post. No stable public API for this exact 2024+ flow.
Link in first comment, not bodyExternal URLs in the post body reduce reach; comment links avoid that penalty.
One persistent browser profile per human loginCompany pages use the same session as the admin’s personal account. Store li_at once; post headless.
Image attach before text fillImage crop/Next remounts Quill and wipes text filled earlier.
Scoped DOM selectorsUnscoped button:has-text('Post') hits Repost on the feed behind the modal.
Verify before commentPost click alone is not success — composer must close. Comment only on card matched by text snippet.
Partial success = HTTP 200Post live + comment failed → SUCCESS + warning, not 502.
Long HTTP timeout + pollPlaywright publish with image often takes 2–5+ minutes.

End-to-end architecture

Layer responsibilities

LayerResponsibility
linkedinlibPure Playwright: session, selectors, post, image, comment. No HTTP.
Publish handlerAuth, load draft, download cover, call post_to_linkedin, update DB
Prepare handlerAuth, optional LLM copy (~700 char validation on generated text), save draft, check_only for poll
Session opsHeaded login once; session_is_healthy before deploy; rsync profile to server
Frontend hookfetchSyncRest (60 min), parse non-JSON errors, poll on disconnect

Prerequisites

pip install playwright
playwright install chromium
playwright install chrome   # recommended for headless posting on macOS
Code language: PHP (php)

Dependencies: Python 3.10+, Playwright 1.58+, a server with enough RAM for Chromium (512 MB+ per context).

Reverse proxy: Set read timeout ≥ 3600 seconds on publish routes. A 60-second gateway timeout produces false “Load failed” in the browser while Playwright still runs.


Session model

One human logs in once, in a headed browser. Every publish after that runs headless and unattended — the Chromium profile directory is the entire artifact.

One login, many targets

LINKEDIN_ACCOUNTS = {
    "personal_admin": {
        "type": "personal",
        "start_url": "https://www.linkedin.com/feed/",
        "posts_url": "https://www.linkedin.com/in/your-handle/recent-activity/all/",
        "posts_published_url": "https://www.linkedin.com/in/your-handle/recent-activity/all/",
        "session_account": "admin",  # owns Chromium profile
    },
    "brand_page": {
        "type": "company",
        "start_url": "https://www.linkedin.com/company/your-brand/",
        # Numeric company ID from admin URL bar — slug-based admin paths often 404
        "posts_url": "https://www.linkedin.com/company/12345678/admin/page-posts/",
        "posts_published_url": "https://www.linkedin.com/company/12345678/admin/page-posts/published/",
        "session_account": "admin",  # same profile as personal_admin
    },
}
Code language: JSON / JSON with Comments (json)

Interactive login (headed, once per operator)

Use bundled Chromium with headless=False — not channel="chrome" (headed Chrome + custom profile can hang on macOS). Reuse the same context options as headless publish (viewport, locale, stealth args).

from playwright.sync_api import sync_playwright

SESSION_DIR = "data/linkedin_sessions/admin"
PLAYWRIGHT_CONTEXT_OPTS = {
    "args": ["--disable-blink-features=AutomationControlled", "--disable-dev-shm-usage"],
    "viewport": {"width": 1920, "height": 1080},
    "device_scale_factor": 2,
    "locale": "en-US",
    "timezone_id": "America/Los_Angeles",
    "color_scheme": "light",
}
STEALTH_INIT = """
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
window.chrome = window.chrome || { runtime: {} };
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
"""

def launch_session_context(p, session_dir):
    return p.chromium.launch_persistent_context(
        session_dir, headless=False, **PLAYWRIGHT_CONTEXT_OPTS
    )

def confirm_feed_login(context, page):
    """Return True when li_at cookie exists or feed nav is visible."""
    cookies = context.cookies()
    if any(c["name"] == "li_at" for c in cookies):
        return True
    try:
        page.wait_for_selector("nav[aria-label='Primary Navigation']", timeout=5_000)
        return True
    except Exception:
        return False

with sync_playwright() as p:
    ctx = launch_session_context(p, SESSION_DIR)
    ctx.add_init_script(STEALTH_INIT)
    page = ctx.pages[0] if ctx.pages else ctx.new_page()
    page.goto("https://www.linkedin.com/login", wait_until="domcontentloaded", timeout=30_000)
    while True:
        answer = input(
            "\nWhen your LinkedIn feed is visible, press ENTER to save (q to quit): "
        ).strip().lower()
        if answer == "q":
            ctx.close()
            raise SystemExit("Aborted — session not saved.")
        if confirm_feed_login(ctx, page):
            break
        print("Login not detected — finish sign-in in the browser, then press ENTER again.")
    ctx.close()
Code language: Python (python)

How cookies are created (and what you store)

You do not export or paste cookies manually. LinkedIn’s session cookie (li_at) is written automatically when an operator completes login in a headed Playwright persistent context. The whole Chromium profile directory is the artifact — typically:

data/linkedin_sessions/{session_owner}/
  Default/
    Cookies          ← SQLite DB; li_at lives here after login
    ...
Code language: PHP (php)

Company page targets reuse the same profile as the personal admin account (session_account in the registry). Run interactive login once for the owner; both personal and company posting read that profile.

Session health checks

Use two filesystem checks before deploy or batch jobs — no browser, no LinkedIn request (headless probes on /feed/ often false-fail anti-bot):

import sqlite3
import time
from contextlib import closing
from pathlib import Path

from errors import LinkedInError  # linkedinlib/errors.py

LINKEDIN_AUTH_COOKIE = "li_at"
SESSION_MAX_AGE_DAYS = 14  # LinkedIn sessions often last 30–60 days; 14 is a safe buffer

def session_has_auth_token(session_dir: str) -> bool:
    """True when li_at exists for linkedin.com in the Chromium Cookies DB."""
    cookies_db = Path(session_dir) / "Default" / "Cookies"
    if not cookies_db.exists():
        return False
    try:
        with closing(sqlite3.connect(f"file:{cookies_db}?mode=ro", uri=True)) as conn:
            row = conn.execute(
                "SELECT 1 FROM cookies WHERE name = ? AND host_key LIKE '%linkedin%' LIMIT 1",
                (LINKEDIN_AUTH_COOKIE,),
            ).fetchone()
            return row is not None
    except sqlite3.OperationalError as exc:
        if "locked" in str(exc).lower():
            return False  # browser still open on this profile
        raise LinkedInError(
            f"Could not read LinkedIn session cookies at {cookies_db}: {exc}"
        ) from exc

def is_session_fresh(session_dir: str, max_age_days: int = SESSION_MAX_AGE_DAYS) -> bool:
    """True when Default/Cookies was modified within max_age_days."""
    cookies_path = Path(session_dir) / "Default" / "Cookies"
    if not cookies_path.exists():
        return False
    age_days = (time.time() - cookies_path.stat().st_mtime) / 86400
    return age_days <= max_age_days

def session_is_healthy(session_dir: str, max_age_days: int = SESSION_MAX_AGE_DAYS) -> bool:
    return session_has_auth_token(session_dir) and is_session_fresh(session_dir, max_age_days)
Code language: Python (python)
CheckWhat it provesWhat it does not prove
session_has_auth_tokenLogin completed (li_at on disk)Token still accepted by LinkedIn
is_session_freshCookies file recently writtenSame — age is a proxy only
Live feed probe (optional)Nav visible on /feed/Triggers anti-bot on headless Chromium — avoid in deploy scripts

post_to_linkedin should fail fast with a clear “run session init” message when li_at is missing — do not start LLM/image work first.

Keeping the session up to date

WhenAction
First setupHeaded interactive login (above) until feed loads; verify session_has_auth_token
Before local dev / manual publishRun session_is_healthy; if false → re-run interactive login
Before deploy to serverHealth check locally; rsync profile only when healthy — if stale, warn and skip rsync (do not block deploy on headed login)
On server (cron / API publish)Headless post only — never open interactive login during deploy, CI, or SSH

Refresh flow when health fails:

  1. On your local machine, run interactive session init (headed browser).
  2. Confirm session_is_healthy(session_dir) returns true.
  3. Rsync data/linkedin_sessions/{owner}/ to the production host (gitignore this dir — never commit cookies).
  4. Re-run headless publish.

Dev-server preflight (recommended): at app startup, call session_is_healthy; if false, prompt the operator to run session init interactively before accepting publish requests. In non-interactive shells, log a warning and skip the browser prompt — do not block deploy on headed login.

Ops CLI (optional wrapper)

A thin shell or Makefile around the Python helpers keeps operators out of the weeds:

# Examples — implement as linkedin_ops.sh or similar
./linkedin_ops.sh session-init admin      # headed login, save profile
./linkedin_ops.sh session-status          # li_at + freshness per account
./linkedin_ops.sh session-health admin 14   # exit 0 only if li_at + Cookies < 14 days
./linkedin_ops.sh post brand_page "TEST — DELETE ME"   # headless smoke test
Code language: PHP (php)

Wire deploy like this:

SESSION_DIR="data/linkedin_sessions/admin"
if ./linkedin_ops.sh session-health admin 14; then
  rsync -az "$SESSION_DIR/" "deploy@your-server:/app/data/linkedin_sessions/admin/"
else
  echo "WARNING: LinkedIn session missing or stale — skipping session sync to server"
  echo "Refresh locally: ./linkedin_ops.sh session-init admin"
  # Deploy continues; headless publish on the server fails until you rsync a fresh profile
fi
Code language: PHP (php)

Never run session-init on the server during unattended deploy — it requires a human at the browser.

Browser launch: headed vs headless (validated behavior)

ModeBrowserNotes
Session initBundled Chromium, headless=Falsechannel=chrome + headed + custom profile can hang on macOS
PublishChrome first (channel="chrome", ignore_default_args=["--enable-automation"]), fallback ChromiumLinkedIn blocks headless bundled Chromium more often

Headless launch pattern:

def _with_stealth(context):
    """Apply the stealth init script to every current and future page, then return it."""
    context.add_init_script(STEALTH_INIT)  # runs before page scripts on each navigation
    return context

def launch_linkedin_context(p, session_dir, headless=True):
    clear_stale_profile_lock(session_dir)  # Chromium SingletonLock
    # base_opts must NOT set `channel` or `ignore_default_args` itself — this
    # function supplies those per-attempt, and a duplicate kwarg raises TypeError.
    base_opts = get_playwright_context_options()  # viewport, locale, launch args
    if not headless:
        return _with_stealth(
            p.chromium.launch_persistent_context(session_dir, headless=False, **base_opts)
        )
    for extra in ({"channel": "chrome", "ignore_default_args": ["--enable-automation"]}, {}):
        try:
            return _with_stealth(
                p.chromium.launch_persistent_context(
                    session_dir, headless=True, **base_opts, **extra
                )
            )
        except Exception:
            if not extra:
                raise LinkedInError("Could not launch LinkedIn browser context")
Code language: Python (python)

Shared context options: 1920×1080 viewport, device_scale_factor=2, locale="en-US", timezone_id="America/Los_Angeles", color_scheme="light", --disable-blink-features=AutomationControlled. The stealth init script is attached via add_init_script (above) so it runs on every page — do not rely on the caller to add it.

Stealth init (this snippet already sets webdriver, languages, plugins, window.chrome, hardwareConcurrency, and deviceMemory; a full implementation also adds a permissions.query override and other fingerprint surfaces):

Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
window.chrome = window.chrome || { runtime: {} };
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
Code language: JavaScript (javascript)

Profile lock: Chromium writes SingletonLock in the session dir. Clear stale locks when the owning PID is gone; fail fast if another browser still holds the profile.


Core Python API

POST_MAX_RETRIES = 3
POST_RETRY_BASE_DELAY_S = 5
POST_MAX_CHARS = 3000

def post_to_linkedin(
    account: str,
    message: str,
    *,
    image_path: str | None = None,
    link: str | None = None,
    headless: bool = True,
) -> dict | None:
    """
    Returns:
        None — full success (post + comment if link provided)
        {"image_skipped": True, "image_skip_reason": str} — posted text-only
        {"comment_failed": True, "comment_error": str} — post live, comment failed
        Both image_skipped and comment_failed may appear in one dict.

    Raises:
        LinkedInError — auth failure, post did not publish, retries exhausted
    """
Code language: Python (python)

Retries apply to transient Playwright/network errors (Timeout, Navigation failed, net::ERR_*, browser closed). LinkedInError is not retried. Backoff: POST_RETRY_BASE_DELAY_S * 2 ** (attempt - 1) → 5 s, 10 s, 20 s.

Retry safety invariant (avoid double-posting). The retry wraps the whole compose→submit→comment sequence. If a transient error escapes after the post is already live (e.g. a Timeout during feed navigation or add_first_comment), a naive retry re-runs submit_post and creates a second post. Two rules keep retries safe:

  1. Every post-submit step must raise only LinkedInErrorverify_post_submitted, the goto-feed navigation, and add_first_comment must catch their own Playwright Timeout/nav errors and re-raise as LinkedInError (which is not retried). A bare transient error must never escape once the composer has closed.
  2. Idempotency guard on retry ≥ 2: before composing again, re-check the published feed for a card matching the post snippet (same matcher used for commenting). If the post is already live, skip compose/submit and jump straight to the comment step. This closes the window structurally, not by discipline alone.

Two char limits: marketing copy generation caps at 700 chars with additional rules (hook first line ≤ 60 chars; last line exactly 3 hashtags; first tag from your brand’s allowed niche set — retry LLM once on validation failure). Platform validate_message allows up to 3000 chars for the post body passed to Playwright.

import re

NICHE_FIRST_HASHTAGS = {"YourNicheA", "YourNicheB"}  # first hashtag on last line must be one of these

def validate_linkedin_post(text: str, max_chars: int = 700) -> tuple[bool, str]:
    cleaned = (text or "").strip()
    if not cleaned:
        return False, "empty post"
    if len(cleaned) > max_chars:
        return False, f"post is {len(cleaned)} chars — max {max_chars}"
    first_line = cleaned.splitlines()[0].strip()
    if len(first_line) > 60:
        return False, f"hook is {len(first_line)} chars — max 60"
    last_line = cleaned.splitlines()[-1].strip()
    tags = re.findall(r"#(\w+)", last_line)
    if len(tags) != 3:
        return False, f"last line must have exactly 3 hashtags, found {len(tags)}"
    if tags[0] not in NICHE_FIRST_HASHTAGS:
        return False, f"first hashtag must be one of: {', '.join('#' + t for t in sorted(NICHE_FIRST_HASHTAGS))}"
    return True, ""
Code language: Python (python)

validate_linkedin_post gates LLM-generated copy (700-char rules) before the review UI. The platform limit is separate: validate_message runs at post_to_linkedin entry and enforces POST_MAX_CHARS (3000) on the operator-approved body actually passed to Playwright — LinkedIn rejects longer bodies.

def validate_message(message: str, max_chars: int = POST_MAX_CHARS) -> None:
    """Raise LinkedInError before browser launch when the body is empty or too long."""
    body = (message or "").strip()
    if not body:
        raise LinkedInError("post message is empty")
    if len(body) > max_chars:
        raise LinkedInError(f"post is {len(body)} chars — max {max_chars}")
Code language: Python (python)

When image_path is not None, validate_linkedin_image_file runs at post_to_linkedin entry (before browser launch) — corrupt paths fail fast. validate_message runs at the same entry point, before any browser or image work.


Company page posting flow (step-by-step)

Validated order for company type:

  1. Launch persistent context; page.set_default_timeout(30_000)
  2. Company preflight: if li_at is not on disk, open personal feed and confirm logged-in nav; if cookie already saved from session-init, skip the feed probe (headless Chromium on /feed/ can false-fail even with valid session)
  3. page.goto(start_url)
  4. Assert not redirected to login gate
  5. Dismiss upsell modal if present (button[data-test-modal-close-btn], etc.) — blocks Create otherwise
  6. Click Create → menu item Start a post (a[data-test-org-menu-item='POSTS'])
  7. Land on /admin/page-posts/published/?share=true with composer open
  8. If image_path:
  9. Try hidden input[type=file] first; else click Add media
  10. set_input_files(path)advance_past_image_editor (Next, up to 90 s)
  11. wait_for_media_upload_settled (up to 120 s, stable Post ~3 s)
  12. On attach failure: continue text-only with image_skipped metadata (do not abort)
  13. fill_post_content(message) — always after image when image was attempted
  14. Human-like pause 1000–2500 ms
  15. submit_post(max_attempts=3) — retry if composer stays open
  16. verify_post_submitted — composer Post button must disappear (max 60 s)
  17. If link: add_first_comment — skip entirely when article URL is missing

Personal profile: open feed → Start a post → same image/text order → Post → comment on personal feed variant.


Selector reference (field-tested lists)

Scope composer actions to .share-creation-state. Never use global button:has-text('Post'). Lists below are abbreviated — keep full fallback arrays in your selectors.py and update when LinkedIn changes the admin DOM.

Composer — image

IMAGE_BTN_SELECTORS = [
    ".share-creation-state button[aria-label='Add media']",
    ".share-creation-state button[aria-label*='Add media']",
    ".share-creation-state button[aria-label*='Add an image']",
    ".share-creation-state button[aria-label*='Add a photo']",
    ".share-box-feed-entry__container button[aria-label*='Add media']",
    "button[data-test-id='share-media-upload']",
    # ... additional composer-scoped fallbacks
]
IMAGE_INPUT_SELECTORS = [
    ".share-creation-state input[type='file']",
    ".share-box-feed-entry__container input[type='file']",
    ".share-box input[type='file']",
    "input[type='file'][accept*='image']",
    "input[type='file'][accept*='.png']",
    "input[type='file'][accept*='.jpg']",
]
IMAGE_EDITOR_NEXT = [
    ".share-creation-state button.share-box-footer__primary-btn",
    "button.share-box-footer__primary-btn[aria-label='Next']",
    "button[aria-label='Next']",
]
Code language: PHP (php)

Composer — submit

SUBMIT_BTN_SELECTORS = [
    ".share-creation-state button.share-actions__primary-action",
    "button.share-actions__primary-action",
    "button[aria-label='Post']",  # last resort — prefer scoped selectors above
    "button[class*='share-actions__primary']",
]
POST_CONFIRM_SELECTORS = [
    ".feed-new-update-pill",
    ".share-box-footer__post-button[disabled]",
    ".artdeco-toast-item--visible",
]
Code language: PHP (php)

Published feed — comment (company admin)

COMMENT_TRIGGER_SELECTORS = [
    "button[aria-label='Comment']",           # prefer first on admin cards
    "div[aria-placeholder*='Comment as']",    # "Comment as Brand…"
    "div[aria-placeholder*='Add a comment']",
    "div[aria-label*='Add a comment'][contenteditable='true']",
    ".comments-comment-texteditor .ql-editor[contenteditable='true']",
    ".comments-comment-box .ql-editor[contenteditable='true']",
    "div[aria-placeholder*='omment']",
]
COMMENT_EDITOR_SELECTORS = [
    ".comments-comment-box__form-container .ql-editor",  # most specific
    ".comments-comment-texteditor .ql-editor[contenteditable='true']",
    ".comments-comment-box .ql-editor[contenteditable='true']",
    "div[aria-placeholder*='Add a comment'][contenteditable='true']",
]
COMMENT_SUBMIT_READY = [
    ".comments-comment-box button:has-text('Comment'):not([disabled])",
    "button.comments-comment-box__submit-button--cr:not([disabled])",
    "button.comments-comment-box__submit-button:not([disabled])",
    "button[aria-label='Post comment']:not([disabled])",
]
Code language: PHP (php)

Do not use unscoped button:has-text('Post') or unscoped Add a photo — they match feed chrome behind the modal.


Image validation (mandatory)

This is the purest Phantom Success in the whole flow. LinkedIn happily renders “Image preview” for an invalid file — ours was a 57-byte .jpg full of garbage bytes — then Post silently no-ops and the composer stays open. No error, no toast, nothing.

from pathlib import Path

def validate_linkedin_image_file(path: str | Path) -> None:
    p = Path(path)
    if not p.exists():
        raise LinkedInError(f"image_path not found: {path}")
    if not p.is_file():
        raise LinkedInError(f"image_path is not a file: {path}")
    if p.suffix.lower() not in {".jpg", ".jpeg", ".png", ".gif"}:
        raise LinkedInError(f"Unsupported image format '{p.suffix}' — use .jpg, .jpeg, .png, .gif")
    size = p.stat().st_size
    if size < 512:
        raise LinkedInError(f"image_path too small ({size} bytes) — likely corrupt or empty")
    header = p.read_bytes()[:12]
    magic_ok = (
        header[:3] == b"\xff\xd8\xff"
        or header[:8] == b"\x89PNG\r\n\x1a\n"
        or header[:6] in (b"GIF87a", b"GIF89a")
    )
    if not magic_ok:
        raise LinkedInError(f"image_path is not a valid JPEG/PNG/GIF (bad magic bytes): {path}")
Code language: Python (python)

Call before set_input_files and after downloading a remote cover to a temp path.

Remote cover download (generic CMS pattern)

from contextlib import contextmanager
import os
import tempfile
import requests

@contextmanager
def cover_image_for_content(content_id: int | None):
    """Yield local path or None. Never raise — text-only fallback."""
    url = get_presigned_cover_url(content_id)  # your CMS/storage layer
    if not url:
        yield None
        return
    with tempfile.TemporaryDirectory() as tmp:
        path = os.path.join(tmp, "cover.png")
        local = None
        try:
            resp = requests.get(url, timeout=30)
            resp.raise_for_status()
            with open(path, "wb") as f:
                f.write(resp.content)
            validate_linkedin_image_file(path)  # raises LinkedInError on bad bytes
            local = path
        except Exception:
            local = None  # download or validation failed → text-only fallback
        # Single yield, OUTSIDE any try/except. A consumer exception re-enters the
        # generator here and propagates normally — it can never be swallowed, and
        # there is no second reachable yield to trigger "generator didn't stop".
        yield local  # inside TemporaryDirectory so file lives through post_to_linkedin
Code language: Python (python)

Post body vs comment body (Quill injection)

EditorValidated injectionWhy
Post bodydocument.execCommand('insertText', false, text) after clicking .ql-editorFast for long copy; works after image attach
Commenteditor.press_sequentially(url, delay=20) or page.keyboard.typeexecCommand fires input only — Quill never enables Comment button

Why the asymmetry (not a mistake — use the right tool per editor): the post composer enables Post as soon as Quill holds any content, which the insertText input event provides. The comment box gates Comment on the React key-event state (keydown/keyup), which execCommand does not synthesize — so only real per-character key events (press_sequentially/keyboard.type) flip the submit button to enabled. Do not “simplify” the comment path back to execCommand.

After typing the URL in a comment, wait ≥ 1500 ms (page.wait_for_timeout(1500)) for link unfurl before clicking submit. Focus editor explicitly after trigger click (800 ms beat).

Comment submit: poll for :not([disabled]) up to 25 s. Fallback: Control+Return then Meta+Return.

Comment verification: poll comment items inside the target post card only every 2 s for up to 20 s — never document.body.innerText (includes the open editor).


Locate the correct post for commenting

def post_body_snippet(message: str, max_len: int = 64) -> str:
    for line in message.splitlines():
        if line.strip():
            return line.strip()[:max_len]
    return message.strip()[:max_len]
Code language: Python (python)
  1. Navigate to posts_published_url (fallback: posts_url) without ?share=true
  2. Poll up to 90 s: first wait 10 s, then 5 s between reloads
  3. On attempt 1 or whenever URL contains share=true: goto(clean_posts_url); else reload
  4. Find smallest feed card whose text contains snippet and has a comment placeholder (Comment as / Add a comment)
  5. Tag card with a data attribute (e.g. data-li-post-target="1") — all comment locators scoped to [data-li-post-target='1']. Any stable attribute name works if scoped consistently.
  6. Never click the first comment box on the page without snippet match

Comment submit wait: 25 s for :not([disabled]). Comment verify poll: 20 s scoped to card.


Compose phase (inside post_to_linkedin)

After browser launch, auth, and open_post_modal, the compose/submit/comment sequence is:

def compose_submit_and_comment(page, account, message, image_path, link, *, attempt=1):
    """Single attempt after the post composer is open.

    Retry safety: on attempt >= 2 the post may already be live from a prior
    attempt that failed *after* submit. Re-check the feed first and skip
    compose/submit when the card already exists — otherwise we double-post.
    """
    if attempt > 1 and post_already_live(page, account, message):
        # Post landed on a previous try; do not compose again — go straight to comment.
        return _comment_only(page, account, message, link)

    human_pause(page)  # 600–1800 ms before interacting

    image_meta = None
    if image_path is not None:
        if not attach_image(page, image_path):
            reason = (
                "Cover image could not be attached — media button/input not found; "
                "posted text-only."
            )
            image_meta = {"image_skipped": True, "image_skip_reason": reason}
        else:
            human_pause(page, 400, 900)  # brief beat after crop/Next

    fill_post_content(page, message)  # AFTER image — crop step remounts Quill

    human_pause(page, 1000, 2500)
    submit_post(page)  # max_attempts=3 by default
    # From here on the post is (or may be) live. Every step below MUST convert its
    # own Playwright Timeout/nav errors to LinkedInError — a bare transient error
    # that escapes now would be retried and create a SECOND post.
    verify_post_submitted(page, message)

    if not link:
        return image_meta

    comment_meta = _add_comment_capture(page, account, message, link)
    if comment_meta:
        if image_meta:
            image_meta.update(comment_meta)
            return image_meta
        return comment_meta

    return image_meta


def _add_comment_capture(page, account, message, link):
    """Return comment-failure metadata, or None on success. Never raises for a
    comment problem — comment failure is partial success, not a retryable error."""
    try:
        add_first_comment(page, account, link, post_message=message)
        return None
    except LinkedInError as exc:  # add_first_comment must wrap Timeouts as LinkedInError
        return {"comment_failed": True, "comment_error": str(exc)}


def _comment_only(page, account, message, link):
    """Post already live from a prior attempt — add the comment only."""
    if not link:
        return None
    return _add_comment_capture(page, account, message, link)
Code language: Python (python)

post_to_linkedin wraps this in session launch, company preflight, navigation, retries (3× with 5/10/20 s backoff, passing the incrementing attempt), and validate_message + validate_linkedin_image_file at entry. post_already_live reuses the snippet matcher from Locate the correct post for commenting against posts_published_url.

Upload settle (before Post)

After Next on crop step, poll until:

  • No visible progress/spinner inside .share-creation-state
  • Post button visible, enabled, not showing “Posting…”
  • State stable for 6 × 500 ms = 3 seconds

REST API contract (validated shapes)

Use two routes on your sync REST allowlist (long timeout, no generic 503 interceptor masking). Both prepare and publish must use the long-timeout fetch on the client — prepare can call an LLM and publish runs Playwright.

All requests require session auth fields (example: session_email, session_id) in the JSON body.

POST /content/linkedin/prepare

Request:

{
  "content_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "session_email": "operator@example.com",
  "session_id": "…",
  "check_only": false,
  "regenerate": false
}
Code language: JSON / JSON with Comments (json)
FieldPurpose
check_only: truePoll / open-flow check — no LLM
regenerate: trueForce new copy when draft exists (400 if already published)

Prepare prerequisites: session auth valid (401 if not); published article URL available in your CMS or content store; linkedin_account configured for the workspace or org. Returns 400 if no LinkedIn account is configured; 400 if prepare LLM/validation fails.

Response (generate):

{
  "status": "SUCCESS",
  "post_text": "Hook line…\n\nBody…\n\n#Tag1 #Tag2 #Tag3",
  "draft_id": 42
}
Code language: JSON / JSON with Comments (json)

Response (check_only, no draft yet):

{ "status": "SUCCESS", "already_saved": false }
Code language: JSON / JSON with Comments (json)

Response (check_only or open-flow, draft exists, not published):

{
  "status": "SUCCESS",
  "post_text": "…",
  "draft_id": 42,
  "social_status": "draft",
  "already_saved": true
}
Code language: JSON / JSON with Comments (json)

Response (poll / already published):

{
  "status": "SUCCESS",
  "post_text": "…",
  "draft_id": 42,
  "social_status": "published",
  "post_url": "https://www.linkedin.com/company/12345678/admin/page-posts/published/",
  "already_saved": true
}
Code language: JSON / JSON with Comments (json)

Poll success condition: already_saved === true && social_status === "published".

Always return "status": "SUCCESS" explicitly — do not rely on a generic serializer that omits status.

POST /content/linkedin/publish

Request:

{
  "draft_id": 42,
  "post_text": "Final text operator approved in UI",
  "session_email": "operator@example.com",
  "session_id": "…"
}
Code language: JSON / JSON with Comments (json)

Success:

{ "status": "SUCCESS", "message": "Posted to LinkedIn successfully." }
Code language: JSON / JSON with Comments (json)

Idempotent (already published):

{
  "status": "SUCCESS",
  "message": "Already published to LinkedIn.",
  "already_published": true,
  "post_url": "https://www.linkedin.com/company/12345678/admin/page-posts/published/"
}
Code language: JSON / JSON with Comments (json)

Partial success (post live, comment failed):

{
  "status": "SUCCESS",
  "message": "Posted to LinkedIn. First comment could not be added — add the article link manually.",
  "warning": "Could not find comment trigger…"
}
Code language: JSON / JSON with Comments (json)

Partial success (text-only, cover skipped):

{
  "status": "SUCCESS",
  "message": "Posted to LinkedIn (text-only — cover image could not be attached).",
  "warning": "Cover image could not be attached — posted text-only."
}
Code language: JSON / JSON with Comments (json)

Both comment failed and image skipped:

{
  "status": "SUCCESS",
  "message": "Posted to LinkedIn (text-only…). First comment could not be added…",
  "warning": "…comment error…",
  "image_warning": "…image skip reason…"
}
Code language: JSON / JSON with Comments (json)

When only image skipped (no comment issue), warning carries the image reason. When both fail, warning = comment, image_warning = image.

Hard failure (post did not publish):

{ "status": "ERROR", "message": "Post submit was clicked but the composer is still open…" }
Code language: JSON / JSON with Comments (json)

HTTP 502 with JSON body — not an unhandled stack trace (frontend must parse JSON via response.text() first).

Missing article URL: Backend logs warning; post_to_linkedin runs with link=None — post + image only, no comment step.

After success: Store posts_published_url from account registry as post_url (feed list URL — not a per-post permalink resolver).

Publish handler (reference)

def publish_handler(draft_id, post_text):
    row = db.get_social_post(draft_id)
    if row.status == "published":
        post_url = row.post_url or LINKEDIN_ACCOUNTS[row.linkedin_account]["posts_published_url"]
        return {
            "status": "SUCCESS",
            "message": "Already published to LinkedIn.",
            "already_published": True,
            "post_url": post_url,
        }, 200

    article_url = db.get_published_article_url(row.content_uuid)
    comment_warning = image_warning = None

    try:
        with cover_image_for_content(row.content_id) as local_path:
            result = post_to_linkedin(
                account=row.linkedin_account,
                message=post_text.strip(),
                link=article_url,  # None → post + image only, no comment step
                image_path=local_path,
                headless=True,
            )
            if isinstance(result, dict):
                if result.get("image_skipped"):
                    image_warning = result.get("image_skip_reason")
                if result.get("comment_failed"):
                    comment_warning = result.get("comment_error")
    except LinkedInError as exc:
        return {"status": "ERROR", "message": str(exc)}, 502
    except Exception as exc:
        return {"status": "ERROR", "message": str(exc)}, 500

    stored_url = LINKEDIN_ACCOUNTS[row.linkedin_account]["posts_published_url"]
    db.mark_published(draft_id, post_url=stored_url)  # always, even on partial success

    if comment_warning and image_warning:
        return {
            "status": "SUCCESS",
            "message": (
                "Posted to LinkedIn (text-only — cover image could not be attached). "
                "First comment could not be added — add the article link manually."
            ),
            "warning": comment_warning,
            "image_warning": image_warning,
        }, 200
    if comment_warning:
        return {
            "status": "SUCCESS",
            "message": "Posted to LinkedIn. First comment could not be added — add the article link manually.",
            "warning": comment_warning,
        }, 200
    if image_warning:
        return {
            "status": "SUCCESS",
            "message": "Posted to LinkedIn (text-only — cover image could not be attached).",
            "warning": image_warning,
        }, 200
    return {"status": "SUCCESS", "message": "Posted to LinkedIn successfully."}, 200
Code language: Python (python)

Frontend (React) — validated patterns

Split across five modules: syncRestConfig.js, syncRestFetch.js, syncRestPaths.js, waitForLinkedInPublished.js, and useLinkedInPublish.js.

Config + long-running fetch

// syncRestConfig.js
export const SYNC_REST_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour

// syncRestFetch.js
import { SYNC_REST_TIMEOUT_MS } from "./syncRestConfig.js";

export async function fetchSyncRest(url, options = {}, timeoutMs = SYNC_REST_TIMEOUT_MS) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } catch (err) {
    if (err?.name === "AbortError") {
      throw new Error(
        "Request timed out after 60 minutes. Check the table — the server may still be processing."
      );
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}
Code language: JavaScript (javascript)

Register both prepare and publish paths on:

  • Client sync-rest allowlist (isSyncRestUrl in syncRestPaths.js) — skip interceptors that mask errors as 503
  • Server/nginx 3600 s read timeout on those path prefixes
// syncRestPaths.js
export const SYNC_REST_PATH_PREFIXES = [
  "/content/linkedin/prepare",
  "/content/linkedin/publish",
  // ... other long-running routes
];

export function isSyncRestUrl(url) { /* prefix match on pathname */ }

export function shouldPollAfterHttpStatus(status) {
  return status === 502 || status === 503 || status === 504 || status === 408;
}
Code language: JavaScript (javascript)

Prepare + publish hooks

Wire session auth to your app (example helpers):

function getSessionEmail() {
  return localStorage.getItem("session_email"); // replace with your auth source
}
function getSessionId() {
  return localStorage.getItem("session_id");
}
Code language: JavaScript (javascript)
// useLinkedInPublish.js
import { fetchSyncRest } from "../utils/syncRestFetch.js";
import { shouldPollAfterHttpStatus } from "../utils/syncRestPaths.js";
import {
  makeLinkedInPublishPollableError,
  waitForLinkedInPublished,
} from "../utils/waitForLinkedInPublished.js";

async function parseRestJson(response) {
  const text = await response.text();
  if (!text) return {};
  try {
    return JSON.parse(text);
  } catch {
    return { message: text.slice(0, 500) };
  }
}

function publishNetworkError(err) {
  const msg = err?.message || String(err);
  if (/load failed|failed to fetch|networkerror|network error/i.test(msg)) {
    return makeLinkedInPublishPollableError(
      "Connection dropped while LinkedIn was publishing — waiting for the server to finish."
    );
  }
  return err instanceof Error ? err : new Error(msg);
}

function shouldPollAfterPublishError(err) {
  return (
    err?.linkedInPublishPollable ||
    err?.name === "TypeError" ||
    err?.name === "AbortError" ||
    /failed to fetch|networkerror|load failed/i.test(String(err?.message || ""))
  );
}

export async function prepareLinkedInPost(contentUuid, { checkOnly = false, regenerate = false } = {}) {
  const response = await fetchSyncRest("/content/linkedin/prepare", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      content_uuid: contentUuid,
      session_email: getSessionEmail(),  // wire to your auth layer
      session_id: getSessionId(),
      check_only: checkOnly,
      regenerate,
    }),
  });
  const data = await parseRestJson(response);
  if (!response.ok || data.status !== "SUCCESS") {
    throw new Error(data.message || "Prepare failed");
  }
  return data;
}

export async function publishLinkedInPost(draftId, postText, { contentUuid } = {}) {
  let response;
  try {
    response = await fetchSyncRest("/content/linkedin/publish", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        draft_id: draftId,
        post_text: postText,
        session_email: getSessionEmail(),
        session_id: getSessionId(),
      }),
    });
  } catch (err) {
    if (contentUuid && shouldPollAfterPublishError(publishNetworkError(err))) {
      return waitForLinkedInPublished(contentUuid);
    }
    throw err;
  }

  const data = await parseRestJson(response);
  if (!response.ok || data.status !== "SUCCESS") {
    if (contentUuid && shouldPollAfterHttpStatus(response.status)) {
      return waitForLinkedInPublished(contentUuid);
    }
    throw new Error(data.message || "Publish failed");
  }
  return data;
}
Code language: JavaScript (javascript)

Reconnect poll module

// waitForLinkedInPublished.js
// NOTE: useLinkedInPublish.js imports back from this module — a circular
// dependency. It is safe under ESM because both sides use the imported symbols
// only at call time (inside functions), so live bindings are resolved by then.
// If you prefer to break the cycle, hoist prepareLinkedInPost into a third module.
import { prepareLinkedInPost } from "../hooks/useLinkedInPublish.js";
import { SYNC_REST_TIMEOUT_MS } from "./syncRestConfig.js";

export const LINKEDIN_PUBLISH_POLL_INTERVAL_MS = 5000;

export function makeLinkedInPublishPollableError(message) {
  const err = new Error(message);
  err.linkedInPublishPollable = true;
  return err;
}

function isPublishedCheck(data) {
  return data?.already_saved === true && data?.social_status === "published";
}

export async function waitForLinkedInPublished(contentUuid, {
  timeoutMs = SYNC_REST_TIMEOUT_MS,
  pollIntervalMs = LINKEDIN_PUBLISH_POLL_INTERVAL_MS,
} = {}) {
  if (!contentUuid) {
    throw makeLinkedInPublishPollableError("Cannot verify LinkedIn publish — missing content_uuid.");
  }
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    // Transient failures (502/503, dropped request) are EXPECTED during the
    // reconnect window — swallow them and keep polling until the deadline.
    // Never let a single failed check abort the poll.
    try {
      const status = await prepareLinkedInPost(contentUuid, { checkOnly: true });
      if (isPublishedCheck(status)) {
        return {
          status: "SUCCESS",
          message: "Posted to LinkedIn successfully.",
          post_url: status.post_url,
        };
      }
    } catch {
      // ignore — server may still be mid-publish; retry on the next interval
    }
    await new Promise((r) => setTimeout(r, pollIntervalMs));
  }
  throw makeLinkedInPublishPollableError(
    "LinkedIn publish is still processing on the server. Check LinkedIn and refresh the table."
  );
}
Code language: JavaScript (javascript)

UI state machine (reference)

StepUI behavior
checkingprepare({ checkOnly: true }) — if already_saved && publishedcopy post URL to clipboard, close modal; if already_saved && draft → jump to review; else → confirm
confirmAsk operator to generate copy
generatingprepare() (LLM) — modal busy, no dismiss
reviewEditable textarea + cover thumbnail + “First comment — auto-posted” preview of article URL
publishingpublish() — modal busy, publishLockRef prevents double submit
doneSuccess modal; if warning or image_warning, show non-blocking notification (prefer warning first)

Gate LinkedIn action in list: require cover_image_url and published article (article_status === 'published' or equivalent CMS flag).

Modal cannot close while checking | generating | publishing. Review shows empty-state copy when article URL not yet available.


Constraints checklist (implementer / agent)

#ConstraintFailure mode
1Image ≥ 512 B + valid magic bytesPost click no-op, composer open
2Fill text after image + NextEmpty Quill, silent post failure
3Scoped Post inside .share-creation-stateClicks Repost
4Wait upload settle (~3 s stable)Intermittent composer stuck
5verify_post_submitted before commentLink on wrong / no post
6Goto clean posts_published_url (no ?share=true)Comment trigger not found
7Match post card by first-line snippetComment on stale top post
8press_sequentially for comment URLComment never saves
9Wait Comment button :not([disabled])Silent no-op click
10Verify comment in card scope onlyFalse positive from editor text
11Link in comment, not bodyReach penalty (product choice)
12Fresh li_at + Cookies file ≤ 14 days (session_is_healthy)Login redirect mid-flow
1360 min client + 3600 s proxy timeoutFalse “Load failed”
14Company admin URL uses numeric ID404
15Headless Chrome preferredChromium blocked more often
16Session init headed Chromium onlymacOS hang with channel=chrome
17Partial success → HTTP 200 + warningOperator thinks post failed
18LinkedInError → JSON 502; unexpected errors → 500Frontend parse errors / false hard-fail
19Never session-init / interactive login during deploy, CI, or SSH — refresh locally firstPipeline hangs waiting for human at browser
20POST_MAX_CHARS 3000 enforced at Playwright entryLinkedIn rejection
21check_only returns already_saved: false when no draftPoll must not treat as published
22Company preflight on personal feed before company URLFalse login-negative on company chrome
23Both warning and image_warning in partial responsesUI misses image-only skip message
24Preflight skips feed probe when li_at on diskFalse auth-negative on headless /feed/
25Prepare requires published article URL + configured LinkedIn account400 before LLM runs
26LLM copy: 700 chars + hook ≤60 + 3-hashtag formulaBad copy reaches review UI
27Comment trigger fail → save PNG and HTML dumpHard to debug admin feed DOM
28Deploy rsyncs session dir only when session-health passes locally; warn-and-skip if staleStale cookies on server; silent publish failures
29Post-submit steps raise only LinkedInError; retry ≥ 2 re-checks feed before composingTransient error after submit → duplicate post
30Reconnect poll swallows transient prepare errors and keeps polling to deadlineOne 502 aborts the poll, false “still processing”

Timeouts reference (recommended values)

LayerTimeoutNotes
Playwright default per locator30 spage.set_default_timeout
Image editor Next90 sadvance_past_image_editor
Media upload settle120 s3 s stable Post-ready
Post verify (composer close)60 sverify_post_submitted
Resolve post card in feed90 s10 s first wait, then 5 s
Comment submit enabled25 swait_for_comment_submit_ready
Comment verify in card20 s2 s poll interval
Client fetchSyncRest60 minAbortController
nginx publish routes3600 sreverse proxy
Reconnect poll interval5 sLINKEDIN_PUBLISH_POLL_INTERVAL_MS

Failure modes and debug playbook

Most of these are the same failure in a different costume: a success signal someone accepted without verifying the state behind it.

SymptomLikely causeFix
Composer stays open after PostCorrupt/ tiny image or empty bodyValidate bytes; fill after attach
“Success” but no postAssumed click = successAdd verify_post_submitted
Comment on wrong postBlind top-of-feed clickSnippet-scoped card
Comment trigger missingStill on ?share=trueForce goto clean feed URL
Comment typed, not savedexecCommand or disabled submitpress_sequentially; wait enabled
UI “Load failed”, post existsClient/proxy timeoutsync REST + poll
502 but post on LinkedInOld handler treated comment fail as errorReturn SUCCESS + warning
Two identical postsTransient error after submit → full-flow retry re-postedPost-submit steps raise only LinkedInError; post_already_live check on retry ≥ 2
Reconnect poll gives up earlyprepare check threw on a transient 502Wrap poll check in try/catch; keep polling to deadline
Image preview but no <img>Invalid file passed extension check onlyMagic byte validation

On failure, write full-page PNG + HTML to tmp/linkedin_debug/ (timestamped). Comment-trigger failures save both artifacts. Inspect Post disabled, aria-disabled, .ql-editor.ql-blank.


Testing strategy

1. Probe (no publish)

Open composer → attach real JPG/PNG (>512 B) → click Next → do not Post. Confirms selectors.

2. E2E test post

post_to_linkedin(
    account="brand_page",
    message="TEST — DELETE ME",
    image_path="/path/to/valid.jpg",
    link="https://example.com/your-article",
    headless=True,
)
Code language: PHP (php)

Pass: no exception; logs show composer closed, snippet matched, comment URL verified. Manual: delete test post on LinkedIn.

Run three consecutive greens before enabling publish in your UI.

3. Unit tests (no browser)

  • validate_linkedin_image_file rejects tiny/corrupt files
  • Comment selector lists include :not([disabled]) on submit buttons
  • cover_image_for_content (or equivalent) validates bytes after HTTP download — never raises; yields None for text-only fallback
  • validate_linkedin_post (or equivalent) enforces 700-char / hook / hashtag formula before review
  • validate_message rejects empty and >3000-char bodies (platform limit, distinct from the 700-char copy rule)
  • Poll predicate: already_saved && social_status === 'published'
  • waitForLinkedInPublished keeps polling when a prepare check throws (mock a 502 mid-loop → still resolves on a later published check)
  • Retry ≥ 2 with the post already live calls the comment path only — submit_post is not invoked twice (mock post_already_live → True)
  • session_is_healthy / session_has_auth_token / is_session_fresh(14) — filesystem checks, no browser
  • REST handler returns HTTP 200 + warning when post succeeded but comment failed (not 502)

Suggested repository layout

your_app/
  linkedinlib/
    accounts.py       # LINKEDIN_ACCOUNTS
    selectors.py      # all selector lists
    session.py        # launch context, li_at check, session_init
    image.py          # validate, attach, advance_past_editor, upload_settle
    post.py           # fill_post_content, submit_post, verify_post_submitted
    comment.py        # resolve card, add_first_comment, verify_comment
    errors.py         # LinkedInError
    publish.py        # post_to_linkedin, retries
  api/
    linkedin_prepare.py
    linkedin_publish.py
  web/
    syncRestConfig.js
    syncRestFetch.js
    syncRestPaths.js
    useLinkedInPublish.js
    waitForLinkedInPublished.js
  data/linkedin_sessions/   # gitignored Chromium profiles (rsync to prod when healthy)
  tools/
    linkedin_ops.sh         # session-init, session-health, session-status, smoke post
  tmp/linkedin_debug/       # gitignored failure artifacts
Code language: PHP (php)

Validation record (July 2026 field test)

The following was verified on a company page admin account:

  • [x] Native image attach (JPG cover, >512 B, valid magic)
  • [x] Post body without URL
  • [x] Article URL as first comment on that post
  • [x] Standalone automation script exit 0 (attach → post → comment)
  • [x] Full UI flow: prepare → review → publish (same backend library)
  • [x] Reconnect poll after long publish

Root causes discovered during validation (all addressed in this guide — note that every one of them presented as “working” before it presented as broken):

  1. 57-byte .jpg with invalid bytes → silent Post failure
  2. Text-before-image → Quill wiped after crop
  3. ?share=true URL → comment UI absent
  4. Missing button[aria-label='Comment'] as first trigger on admin feed

Summary for implementers

  1. Login once (headed Chromium) → verify session_is_healthy → post headless (prefer Chrome channel). Refresh locally and rsync profile before deploy when stale.
  2. Prepare / Publish split with operator review between.
  3. Download + validate cover bytes → attach → fill → Post → verify → comment.
  4. Scope every selector to composer or target post card.
  5. Comment with keyboard events; wait for enabled submit; verify in card scope.
  6. Never trust Post click until composer closes.
  7. Long timeouts + poll on frontend; partial success is still success.
  8. Maintain selectors — LinkedIn changes admin DOM without notice.

Replace route names and DB fields with your own; keep the order, guards, and contracts unchanged.

One honest caveat. The selector lists in this guide are a snapshot — LinkedIn will change the admin DOM, probably without telling anyone, and some of those arrays will go stale. When that happens, the guards are what save you, not the selectors: the run fails loudly with a PNG and an HTML dump instead of publishing nothing and reporting success.

Because that is the actual thesis. Every guard in this guide is the same guard — never accept the success signal, verify the state. Composer closed. Card on the feed. Comment inside the card. Build it that way, and a Phantom Success can’t survive past the next check.

InTheValley

Leave a Reply