Skip to main content

auto-linkedin

Open-source LinkedIn company-page publisher that drives linkedin.com with Playwright and your own imported cookies. Prepare content in a folder, configure your account once, publish.

Status: Alpha. v1 supports company-page posting only (personal-feed posts are out of scope). Using browser automation to post to LinkedIn violates LinkedIn's User Agreement — use a scratch page, conservative pacing, and a residential IP that matches where you got the cookies.

Why this project

LinkedIn's official Marketing Developer Platform requires app review and is gated to specific Business partners; personal accounts and most company-page admins can't post programmatically through it. auto-linkedin takes the opposite approach: automate the website you already use, with the session you already have.

Supported post types (web UI, April 2026):

  • text — caption-only post
  • image — single image + optional caption
  • multi_image — 2–9 images in a single share
  • video — one video (MP4/MOV/WMV/FLV/AVI)
  • link — share-modal post with an auto-generated URL preview card (/feed/update/...)
  • article — long-form post via LinkedIn's article editor (/pulse/...), with title + body

link and article are different LinkedIn surfaces with unrelated UIs and permalinks; don't confuse them. link is the regular share modal with a URL embedded in the text. article is the dashboard "Publish an article" editor, with a separate title field and a long body (up to ~110k chars).

Personal-feed posting is intentionally out of scope for v1. Every publish call posts as one of the company pages listed in the account YAML.

Requirements

  • Python 3.11+
  • macOS or Linux (tested on macOS)
  • A real Chrome you can log in from on the same network you'll run the bot on
  • Admin or Content Admin permission on the target company page
  • (Recommended) a residential proxy if you'll run this on a different machine from where you logged in

Install

One command with pipx (recommended):

pipx install auto-li
auto-li init --account demo

Or with uv:

uv tool install auto-li
auto-li init --account demo

Note: the GitHub repo is auto-linkedin but the PyPI distribution is published as auto-li (matches the CLI command). PyPI rejected auto-linkedin as too similar to existing LinkedIn-named packages.

auto-li init installs the patched Chrome channel Patchright needs and scaffolds a working directory in .:

./config/demo.yaml            # account config from the shipped template
./content/example-post/       # sample post descriptor
./sessions/                   # session files (gitignored)
./.gitignore                  # appended with auto-linkedin entries

It is safe to re-run — existing files are preserved.

From source (contributors)

git clone https://github.com/xtea/auto-linkedin
cd auto-linkedin
uv sync
uv run auto-li init --account demo

Configure an account

Edit config/<account>.yaml. The important fields:

  • handle — your personal LinkedIn handle (display only)
  • company_pages — at least one entry. The numeric id comes from the admin URL https://www.linkedin.com/company/<id>/admin/dashboard/. Add display_name exactly as it appears on the page (used to verify the share-modal actor pill before posting).
  • default_company_page — id used when post.yaml omits as_company and --as isn't passed
  • user_agent, viewport, locale, timezone — match the browser you'll log in from. Drift between these and the cookie's origin is the #1 cause of /checkpoint/ challenges.
  • pacing.max_posts_per_day — start at 1–2 for company pages. LinkedIn penalizes high-frequency company posting.

Inspect the configured pages at any time:

auto-li pages --account demo

Authenticate

Two paths; pick whichever you prefer.

Option A — Headed manual login (simplest)

auto-li login --account demo

A Chrome window opens. Log in by hand (handle 2FA yourself). When the home feed appears, the session is saved to sessions/demo.json.

Option B — Import cookies from your real browser

If you already have a logged-in LinkedIn tab in Chrome:

  1. Install Cookie-Editor.

  2. Open linkedin.com, click the extension, Export → Export as JSON → save to li-cookies.json.

  3. Run:

    auto-li import-cookies ./li-cookies.json --account demo
    

The tool rejects the import if li_at or JSESSIONID is missing. Recommended cookies (liap, bcookie, bscookie, lidc, li_rm, lang) are warned about but not required.

Verify

auto-li doctor --account demo

Should print OK: <handle> session is valid.

Publish content

Layout

content/
└── my-post/
    ├── post.yaml
    └── media/
        └── launch.jpg

post.yaml schema

type: image                     # text | image | multi_image | video | link | article
caption: |
  Excited to announce ...
media:
  - ./media/launch.jpg          # paths are relative to this file
as_company: 111873058           # optional; falls back to default_company_page
link_url: null                  # required only for type=link (HTTPS)
title: null                     # required only for type=article (≤ 150 chars)
schedule: 2026-05-08T15:00:00Z  # optional; UTC or with offset

Validation runs before any browser work:

Type Rule
text non-empty caption, no media, no link_url, no title
image exactly 1 image (.jpg / .jpeg / .png / .gif)
multi_image 2–9 images
video exactly 1 video (.mp4 / .mov / .wmv / .flv / .avi)
link HTTPS link_url, no media
article non-empty title (≤ 150 chars) + non-empty body in caption (≤ 110k chars), no media, no link_url
caption (non-article) ≤ 3000 chars, ≤ 30 hashtags

One-shot publish

auto-li publish content/my-post --account demo --dry-run        # safe first run
auto-li publish content/my-post --account demo                  # posts for real
auto-li publish content/my-post --account demo --as 99999999    # override target page

--dry-run walks the full upload flow and stops before clicking Post — useful when patching selectors.

--as <page_id> overrides whatever is in post.yaml and the account's default_company_page. Resolution order: --as flag > post.as_company > default_company_page > error.

Scheduled / queued publish

Drop posts with schedule: set in the future, then run auto-li queue from cron / launchd / systemd-timer:

*/5 * * * * cd /path/to/auto-linkedin && auto-li queue --account demo >> sessions/queue.log 2>&1

The queue stores state in sessions/queue.db (SQLite) with statuses: queued | running | succeeded | failed | paused. Use auto-li list to inspect. Successful jobs record the post's activity URN (urn:li:activity:...) and permalink in the row's shortcode and url columns.

We deliberately don't drive LinkedIn's native "Schedule for later" UI button — driving its date-picker bottom-sheet has worse selector rot than the share modal itself, and it would split scheduled-post state between LinkedIn's servers and our SQLite. The CLI + cron pattern keeps everything inspectable in one place.

How the Playwright flow works

  1. Launch Chrome via Patchright (Chromium with CDP/webdriver leaks patched at the binary level). Vanilla playwright is fingerprinted by LinkedIn's bot-detection stack in 2026 — don't use it.
  2. Load sessions/<account>.json as the Playwright storage_state.
  3. Navigate to https://www.linkedin.com/feed/, confirm by URL + page title that the session is authenticated.

For share-modal posts (text / image / multi_image / video / link):

  1. Open the share modal from https://www.linkedin.com/company/<page_id>/admin/page-posts/published/. This URL auto-scopes the actor to the company — no actor-switcher driving required. The visible "Posting as <Company>" pill is verified before any caption / media work.
  2. Type the caption (with humanized per-character delay), then setInputFiles into the hidden <input type=file> for image / multi_image / video posts. For link posts, the URL is included in the typed text so LinkedIn's auto-preview card renders.
  3. Click Post. Confirm via URN delta on the admin grid (urn:li:activity:...).

For article (long-form):

  1. Open the editor directly at https://www.linkedin.com/article/new?author=urn:li:fsd_company:<page_id>. The author= URN locks the actor to the company; no Create-menu interaction is required.
  2. Verify the company name is reachable in the editor toolbar. Fill the title (<textarea>) and body (Quill contenteditable).
  3. Click Next → Publish in the modal. Confirm by URL transition to /pulse/<slug>-<id>/.

All selectors are in src/auto_linkedin/publisher/selectors.py — when LinkedIn changes the UI, that is the file to patch.

Adding an alternative backend

The Publisher protocol in publisher/base.py is deliberately minimal so you can drop in alternative backends (e.g. an official Marketing API publisher for accounts that have access).

Pacing & safety

Built-in guardrails, tunable in config/<account>.yaml:

  • max_posts_per_day daily cap (enforced by the queue)
  • min/max_step_delay_seconds randomized delays between UI actions
  • pre_run_idle_seconds_* scroll/dwell before the first click
  • Per-character typing delay (15–45 ms) and pre-Post mouse jitter

On a /checkpoint/ redirect or /authwall, the runner pauses the job and records the reason. Re-authenticate with auto-li login and retry.

If the share modal opens with the wrong actor (admin permissions on the page have lapsed since you authenticated), the runner aborts with a WrongActorError rather than posting to the wrong surface.

Known limitations

  • Company pages only in v1. Personal-feed posts are out of scope.
  • No @mention typeahead. Mentions in the caption are passed through as plain text — driving LinkedIn's mention picker is a follow-up.
  • No native scheduler. Schedules are managed via the local SQLite queue + cron; we don't drive LinkedIn's "Schedule for later" UI (which exists for both share posts and articles).
  • Selectors rot. LinkedIn ships UI changes every few weeks. Expect periodic patches to selectors.py.
  • 2FA mid-run. If LinkedIn challenges mid-publish, the tool pauses; manual re-login is required.
  • Shared IP. Using cookies captured from residence A while running the bot on residence B's IP is the single most reliable way to get challenged.
  • ToS risk. Browser-driven automation of LinkedIn is against the User Agreement. Use a page you control, on a residential IP, with conservative pacing.

Non-goals (for now)

  • Web UI / dashboard (CLI + YAML only)
  • Long-running daemon (cron-friendly invocation instead)
  • Engagement automation (reactions, comments, DMs, connection requests)
  • Personal-feed posting (deferred to v2)

Release flow (maintainers)

CI (.github/workflows/ci.yml) runs ruff + mypy + pytest on every push to main and every PR, against Python 3.11 and 3.12.

Releases are tag-driven (.github/workflows/publish.yml). To cut a release:

# 1. Bump version in pyproject.toml (the workflow asserts tag == pyproject version)
$EDITOR pyproject.toml

# 2. Commit, tag, push the tag.
git commit -am "Release v0.1.1"
git tag v0.1.1
git push origin main v0.1.1

The publish workflow then:

  1. Verifies tag version matches pyproject.toml.
  2. Re-runs ruff + mypy + pytest (no red builds shipped).
  3. uv build → wheel + sdist.
  4. uv publish to PyPI using the UV_PUBLISH_TOKEN secret from the default GitHub Environment.
  5. Creates a GitHub Release with the dist artifacts attached.

Manual re-publish: trigger Publish to PyPI from the Actions tab and pass an existing tag via workflow_dispatch.

License

MIT.

Metadata

Release files for auto-li 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for auto-li 0.1.1
File Size Uploaded
auto_li-0.1.1.tar.gz 40.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for auto-li 0.1.1
File Interpreter ABI Platform
auto_li-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 85.1 kB

Release files / auto_li-0.1.1.tar.gz

Download URL auto_li-0.1.1.tar.gz
Size 40.3 kB
Tags Source
SHA-256 checksum
How to use checksums
221f973dcae00c155d0eaa21e6e4b09e48d144ec24c989ba82b751d08310d793
BLAKE2b-256 checksum
How to use checksums
67fbfe848e46c8d00c2171be15d94e98d61ebd348c6c793f3b6e249afbacf9fb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / auto_li-0.1.1-py3-none-any.whl

Download URL auto_li-0.1.1-py3-none-any.whl
Size 44.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
85b14213fcf11ad929a836471ff7ba76c737355223a6139a93cf7479b2edbe51
BLAKE2b-256 checksum
How to use checksums
1408a977777e3e3549628c7e64fccb847845a95d099628e1e0d6f231a00a4243
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page