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.
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.
- 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
One command with pipx (recommended):
pipx install auto-li
auto-li init --account demoOr with uv:
uv tool install auto-li
auto-li init --account demoNote: the GitHub repo is
auto-linkedinbut the PyPI distribution is published asauto-li(matches the CLI command). PyPI rejectedauto-linkedinas 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.
git clone https://github.com/xtea/auto-linkedin
cd auto-linkedin
uv sync
uv run auto-li init --account demoEdit config/<account>.yaml. The important fields:
handle— your personal LinkedIn handle (display only)company_pages— at least one entry. The numericidcomes from the admin URLhttps://www.linkedin.com/company/<id>/admin/dashboard/. Adddisplay_nameexactly as it appears on the page (used to verify the share-modal actor pill before posting).default_company_page— id used whenpost.yamlomitsas_companyand--asisn't passeduser_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 demoTwo paths; pick whichever you prefer.
auto-li login --account demoA Chrome window opens. Log in by hand (handle 2FA yourself). When the home feed appears, the session is saved to sessions/demo.json.
If you already have a logged-in LinkedIn tab in Chrome:
-
Install Cookie-Editor.
-
Open
linkedin.com, click the extension, Export → Export as JSON → save toli-cookies.json. -
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.
auto-li doctor --account demoShould print OK: <handle> session is valid.
content/
└── my-post/
├── post.yaml
└── media/
└── launch.jpg
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 offsetValidation 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 |
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.
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>&1The 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.
- Launch Chrome via Patchright (Chromium with CDP/webdriver leaks patched at the binary level). Vanilla
playwrightis fingerprinted by LinkedIn's bot-detection stack in 2026 — don't use it. - Load
sessions/<account>.jsonas the Playwrightstorage_state. - 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):
- 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. - Type the caption (with humanized per-character delay), then
setInputFilesinto the hidden<input type=file>for image / multi_image / video posts. Forlinkposts, the URL is included in the typed text so LinkedIn's auto-preview card renders. - Click Post. Confirm via URN delta on the admin grid (
urn:li:activity:...).
For article (long-form):
- Open the editor directly at
https://www.linkedin.com/article/new?author=urn:li:fsd_company:<page_id>. Theauthor=URN locks the actor to the company; no Create-menu interaction is required. - Verify the company name is reachable in the editor toolbar. Fill the title (
<textarea>) and body (Quill contenteditable). - 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.
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).
Built-in guardrails, tunable in config/<account>.yaml:
max_posts_per_daydaily cap (enforced by the queue)min/max_step_delay_secondsrandomized delays between UI actionspre_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.
- Company pages only in v1. Personal-feed posts are out of scope.
- No
@mentiontypeahead. 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.
- 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)
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). The runtime __version__ is read from package metadata, so pyproject.toml is the only version source. To cut a release:
# 1. Bump pyproject.toml. Pass patch | minor | major, or an explicit X.Y.Z.
./scripts/bump.py patch # e.g. 0.1.1 -> 0.1.2
# 2. Review the printed diff, then commit + tag + push.
git commit -am 'Release v0.1.2'
git tag v0.1.2
git push origin main v0.1.2The publish workflow then:
- Verifies tag version matches
pyproject.toml. - Re-runs ruff + mypy + pytest (no red builds shipped).
uv build→ wheel + sdist.uv publishto PyPI using theUV_PUBLISH_TOKENsecret from thedefaultGitHub Environment.- 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.
MIT.