Open-source LinkedIn company-page publisher that drives linkedin.com with Playwright and imported cookies. (CLI: auto-li)
Project description
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-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.
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 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 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:
-
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.
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
- 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.
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_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.
Known limitations
- 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.
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)
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file auto_li-0.1.0.tar.gz.
File metadata
- Download URL: auto_li-0.1.0.tar.gz
- Upload date:
- Size: 39.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ac016e2c8d42c12ebebafb857d8fc60622a9b7938a12458e89dadcfabc24a8d3
|
|
| MD5 |
5ff84354353bdefdcfda01e4a3d1ec01
|
|
| BLAKE2b-256 |
603e793eee03403acac3cd11df11267b4a3548ec8e0cc5833386d9fa3ce3ed2b
|
File details
Details for the file auto_li-0.1.0-py3-none-any.whl.
File metadata
- Download URL: auto_li-0.1.0-py3-none-any.whl
- Upload date:
- Size: 44.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c9b5ce26d90f9049f4e619b5568962df21184f15b148a71218739ea3d412756e
|
|
| MD5 |
18f671411042a3b285bc8cb1d221da9f
|
|
| BLAKE2b-256 |
4a9b1f01357d7e392eb500e3a898e88efec916966b52f7e9b62f4a7df274cbf4
|