Skill: webtaken/linkedin-page-publisher (version 1.0.0)

--- name: linkedin-page-publisher description: Publish posts programmatically to a LinkedIn Company Page via the versioned Posts API (/rest/posts), including text posts, single-image posts, multi-image carousels, video posts, and article link previews. Handles the full multi-step media upload flow (initializeUpload → PUT binary → wait for LinkedIn to finish processing → reference URN in the post). Use this skill whenever the user wants to post to LinkedIn, publish to a Company Page, automate LinkedIn content, cross-post to LinkedIn from another system, build a LinkedIn publishing bot or scheduler, or upload images or videos to LinkedIn programmatically — even when they don't say "API" explicitly. Also use it when the user mentions errors from the LinkedIn Posts API, w_organization_social scope, urn:li:organization URNs, UGC posts, or /rest/images and /rest/videos endpoints. --- # LinkedIn Page Publisher Publishes content to a LinkedIn Company Page through LinkedIn's versioned REST API (`/rest/posts`). The skill ships a Python CLI (`scripts/publish.py`) that also works as an importable library, plus a one-time OAuth helper (`scripts/get_token.py`) to obtain the initial access token. ## When to reach for what - **User just wants to post something** → run `scripts/publish.py` directly with the right subcommand (see "Publishing" below). - **User is setting this up for the first time and doesn't have a token yet** → walk them through `references/setup.md`, which covers creating the LinkedIn Developer app, requesting Community Management API access, and running `scripts/get_token.py` to complete the 3-legged OAuth flow. - **User hits a cryptic API error** → consult `references/troubleshooting.md` before guessing. LinkedIn's error messages are often misleading (e.g., "unauthorized" frequently means "wrong scope" or "not a page admin," not "bad token"). - **User wants to extend the skill** (schedule posts, add analytics, wrap it in a web service, etc.) → import from `scripts/lib/` rather than shelling out to the CLI. The library layer is the contract; the CLI is just one consumer of it. ## Environment variables These three cover every use case. Don't invent a config file — env vars compose better with cron, CI, and shell scripts. | Variable | Required | What it is | |---|---|---| | `LINKEDIN_ACCESS_TOKEN` | yes | OAuth 2.0 access token with `w_organization_social` scope. Valid for 60 days; refresh tokens last 365 days. | | `LINKEDIN_ORG_ID` | yes | Numeric organization ID only (e.g. `5515715`), not the full URN. The script prepends `urn:li:organization:`. Find it at `https://www.linkedin.com/company/<slug>/admin/` — the URL shows the numeric ID once you're in the admin view. | | `LINKEDIN_API_VERSION` | no | YYYYMM format, e.g. `202602`. Defaults to `202602` (February 2026). LinkedIn supports each version for a minimum of one year, so bump this deliberately when new features ship. | ## Publishing `scripts/publish.py` exposes five subcommands. Every subcommand accepts `--text` (post commentary, up to 3,000 characters) and `--dry-run` (prints the request body without calling the API — useful for debugging). ### Text-only post ```bash python scripts/publish.py text --text "Announcing our Q1 roadmap. Three big bets this year: ..." ``` ### Single image ```bash python scripts/publish.py image path/to/photo.jpg \ --text "At the AI builders meetup in Lima last night" \ --alt "Twelve people seated around a conference table with laptops open" ``` `--alt` is required. Accessible alt text is also what LinkedIn's ranking signals care about, so don't skip it. ### Multi-image carousel (2–20 images) ```bash python scripts/publish.py multi-image img1.jpg img2.jpg img3.jpg \ --text "Recap from OpenClaw's monthly hackathon" \ --alt "Photo of attendees" "Photo of winning demo" "Group photo at the end" ``` Pass one `--alt` per image, in order. If the count doesn't match, the CLI errors out before hitting the API. ### Video ```bash python scripts/publish.py video path/to/demo.mp4 \ --text "Demo of our new agent flow" \ --title "Agent flow walkthrough" ``` Video uploads take longer. The script auto-detects file size and uses single-part upload under 200 MB or multipart upload above. It polls LinkedIn's video status endpoint until the asset reaches `AVAILABLE` before creating the post — posting against a still-processing video produces a post with a broken player. ### Article link preview ```bash python scripts/publish.py article https://example.com/my-blog-post \ --text "Wrote up how we built this — thoughts welcome" ``` LinkedIn scrapes the URL's OpenGraph metadata to render the preview card. If the target page's OG tags are missing or wrong, the preview will look bad — that's a site issue, not an API issue. The CLI prints the article URN in the response so the user can verify. ## Using the library from other Python code ```python from scripts.lib.client import LinkedInClient from scripts.lib.posts import post_text, post_image, post_video, post_article, post_multi_image client = LinkedInClient() # reads env vars # Text post_text(client, "Hello, page followers!") # Image post_image(client, "photo.jpg", text="At the meetup", alt="Group photo") # Video (handles small and multipart automatically) post_video(client, "demo.mp4", text="Quick demo", title="Demo") # Article post_article(client, "https://example.com/post", text="Worth a read") ``` All post functions return the post URN (e.g. `urn:li:share:7045020441609936898`) on success and raise on failure. Don't swallow exceptions — the error messages carry the LinkedIn response body, which is the only useful debugging signal. ## Why the upload flow has so many steps LinkedIn's media upload is a three-step handshake: 1. **Register** — `POST /rest/images?action=initializeUpload` (or `/rest/videos?action=initializeUpload`). LinkedIn returns an upload URL (or several, for multipart) and a pre-assigned URN (`urn:li:image:...` or `urn:li:video:...`). 2. **Upload** — `PUT` the binary bytes to the returned upload URL(s). No auth header on these PUT calls — the URL itself is pre-signed. 3. **Reference** — create the post with the URN in `content.media.id`. For videos, there's an implicit fourth step: LinkedIn processes the video asynchronously. If you create the post immediately after the PUT, the video may not be ready and the post will be broken. The library polls `GET /rest/videos/{urn}` until `status == AVAILABLE` before returning the URN. Multipart video uploads also need a `finalizeUpload` call with the ETags from each part — the library handles this. This is why the skill bundles upload helpers rather than expecting callers to reimplement them — the edge cases (async processing, multipart, alt text on multi-image) are where naive implementations break. ## Rate limits and scope - **Personal token limit**: roughly 100 calls/day/member. Respect this when building schedulers. - **Scope required**: `w_organization_social`. The authenticating user must be an admin of the Company Page — being an employee is not enough. - **Post character limit**: 3,000. The API returns HTTP 422 if exceeded. The library checks locally before calling so the failure is cheaper. - **Access token lifetime**: 60 days. Refresh tokens last 365 days and can be used to mint new access tokens without re-prompting the user. `get_token.py` saves both. ## What LinkedIn's API cannot do (as of 2026) Don't promise the user these — they require manual work in LinkedIn's web UI: - **Long-form articles** (the Medium-style ones with a title, cover, and body) — web UI only. - **Newsletters** — web UI only. - **Document posts / PDF carousels** — no API support. - **Polls** — no API support. - **@mentions of people or companies in post text** — no API support. The text will publish, but the mention won't be a link. - **Native scheduling** — the API posts immediately. Build scheduling with cron or a queue. If the user asks for any of the above, say so upfront rather than trying to hack around it. ## Debugging etiquette When the user reports an error, ask for: 1. The exact command they ran (redacting the token). 2. The full response body LinkedIn returned — the `serviceErrorCode` and the `message` fields carry the real signal. 3. Whether the token still works for a simple `GET /rest/posts?author=urn:li:organization:<id>&q=author`. If this 401s, the token is the problem, not the post. Then check `references/troubleshooting.md` against the specific error code before guessing.

path: references/troubleshooting.md
content: # Troubleshooting

LinkedIn's error messages are famously misleading — "unauthorized" rarely means "bad token." Before guessing, always look at the response body's `serviceErrorCode` and `message` fields, then match against this table.

## Authentication and authorization

### `401 Unauthorized` with `"message": "Not enough permissions"`

The token is valid but lacks `w_organization_social`. Either:
- Community Management API hasn't been approved for your app yet (see `setup.md` step 2), or
- The OAuth flow didn't include `w_organization_social` in the `scope` parameter. Re-run `get_token.py` — it requests that scope explicitly.

### `401 Unauthorized` with `"code": "INVALID_CREDENTIAL"`

Token expired (60-day lifetime) or revoked. Use the refresh token to mint a new one — see `setup.md` for the snippet.

### `403 Forbidden` with `"message": "Not authorized to perform this action"`

The authenticated user is not an admin of the target Company Page. Being a content admin or an employee is not enough — you need the full admin role. Check `https://www.linkedin.com/company/<id>/admin/settings/manage-admins/`.

### `403 Forbidden` when calling a versioned endpoint

Missing the `LinkedIn-Version` or `X-Restli-Protocol-Version` header. The client in this skill always sends them, so if you see this, you're probably calling LinkedIn directly instead of through `LinkedInClient`.

## Content and shape errors

### `400 Bad Request` with `"message": "Invalid content format"`

The `content` field shape doesn't match the expected schema for the post kind. Common culprits:
- Using `content.media.id` (singular) for a carousel — use `content.multiImage.images[].id`.
- Passing a person URN as `author` on an org-scoped post. The token and the author must be consistent.
- Including a `media` block with a URN that's still processing (video) — see below.

### `422 Unprocessable Entity` with a length message

Post commentary exceeded 3,000 characters. The library checks locally before calling, so if you see this, you're bypassing `_envelope`. Trim and retry.

### Post publishes but the image/video is broken in the feed

The media URN wasn't ready when the post was created. For images this is rare but possible; for videos it's common if you skip the poll.

Fix: for videos, always pass `poll=True` to `upload_video` (the default). If you're calling the API directly, `GET /rest/videos/{urn}` until `status == AVAILABLE` before creating the post.

### Article link preview is blank or says "Preview not available"

LinkedIn couldn't scrape the URL's OpenGraph metadata. Check:
- `<meta property="og:title">`, `og:description`, `og:image` are present on the target page.
- The target page is publicly reachable (not behind auth, no aggressive bot-blocking).
- The `og:image` URL is absolute and returns `image/*` content.

You can override `title` and `description` in `post_article`, but `og:image` specifically must come from the target page.

## Media upload errors

### `initializeUpload` returns `400` with `"fileSizeBytes is required"`

Videos always need `fileSizeBytes` in the request. The helper in `lib/media.py` reads this from the file on disk; if you're calling directly, pass the exact `os.path.getsize(path)`.

### Multipart video finalize fails with `"ETag mismatch"`

One of the chunk uploads didn't return an ETag, or the ETags were recorded out of order. The order of `uploadedPartIds` in `finalizeUpload` must match the order of `uploadInstructions` from `initializeUpload`.

### Upload PUT returns `403` with an XML error body

The upload URL expired (15-minute lifetime) or was used twice. Re-run `initializeUpload` to get a fresh one — they're not reusable.

### Image rejected with "pixel count exceeds limit"

LinkedIn caps images at ~36 megapixels. Resize before uploading. Width × height, not file size.

## Rate limiting

### `429 Too Many Requests`

The per-member daily quota is roughly 100 calls. If you're scheduling, queue the posts and spread them out. The response includes a `Retry-After` header — honor it.

## Dev-only: fake/stale tokens

LinkedIn's OAuth tool at https://www.linkedin.com/developers/tools/oauth/token-generator issues tokens that are quick to obtain but miss some scopes. If you grabbed a token there and it behaves oddly, re-run `get_token.py` to get a full 3-legged token instead.

## Last-resort debugging

When nothing in this list matches, gather:

1. The exact request body (from `--dry-run` or by logging the call).
2. The full response body, not just the status code.
3. A fresh `curl` that reproduces the issue, so you can share it without leaking app secrets.

Then compare the request shape against the current LinkedIn docs at:
- Posts: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api
- Images: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api
- Videos: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api

LinkedIn ships monthly versions; if the docs for your `LINKEDIN_API_VERSION` say something different from what the client expects, the version header is likely out of sync with the docs you're reading.


path: references/setup.md
content: # Setup guide

Read this if the user is setting up LinkedIn publishing for the first time, or if they're seeing errors that look like "unauthorized," "permission denied," or "Community Management API access not granted." Most first-run pain comes from one of the steps below being skipped.

## 1. Create a LinkedIn Developer app

1. Go to https://www.linkedin.com/developers/apps
2. Click **Create app**.
3. Fill in:
   - **App name** — anything descriptive.
   - **LinkedIn Page** — the Company Page you want to post to. This is the critical step; the app must be associated with the target page, and the user creating the app must be an admin of that page.
   - **App logo** — required; LinkedIn rejects apps without one.
4. Accept the legal agreement and create the app.

## 2. Request Community Management API access

The `w_organization_social` scope (needed to create posts on a Company Page) lives inside the **Community Management API** product. It is not available in the free tier.

1. In the app dashboard, go to the **Products** tab.
2. Request **Community Management API**.
3. LinkedIn will ask for:
   - A description of what you're building.
   - Screenshots or a demo video.
   - A privacy policy URL.
   - Sometimes a call with a LinkedIn rep.
4. Approval typically takes a few days to a couple of weeks. Plan accordingly. During review, the app can still authenticate but `POST /rest/posts` will 403.

If you just want to test without approval, you can use `w_member_social` to post to a personal profile instead. But this skill is scoped to Company Pages, so that path needs changes to both `client.py` (use `urn:li:person:...`) and the CLI (accept a person URN instead of an org ID).

## 3. Configure Auth settings

In the app's **Auth** tab:

1. Add `http://localhost:8765/callback` to **Authorized redirect URLs for your app**. This matches the redirect URI in `scripts/get_token.py`.
2. Note the **Client ID** and **Client Secret** — you'll need them in a moment.
3. Under **OAuth 2.0 scopes**, verify that `w_organization_social` (and ideally `r_organization_social` for read access) appear. If they don't, the Community Management API request hasn't gone through yet.

## 4. Find the Company Page's numeric ID

The URN format used by the API is `urn:li:organization:<id>`, where `<id>` is the numeric ID — not the vanity slug.

1. Go to the Company Page's admin view: `https://www.linkedin.com/company/<slug>/admin/`.
2. Once you're in the admin view, the URL changes to include the numeric ID, e.g. `https://www.linkedin.com/company/5515715/admin/`.
3. That `5515715` is what you export as `LINKEDIN_ORG_ID`.

If you don't see an admin view link on the Company Page, the authenticated user is not an admin and the posts will 403 no matter what scope the token has.

## 5. Run the OAuth helper

```bash
export LINKEDIN_CLIENT_ID=<client id from step 3>
export LINKEDIN_CLIENT_SECRET=<client secret from step 3>
python scripts/get_token.py
```

The helper opens a browser, you sign in as the Company Page admin, approve the scopes, and the script prints the access token and refresh token to stdout. Copy them into your shell profile or `.env`:

```bash
export LINKEDIN_ACCESS_TOKEN=AQV...
export LINKEDIN_REFRESH_TOKEN=AQW...
export LINKEDIN_ORG_ID=5515715
```

## 6. Verify with a dry run

```bash
python scripts/publish.py text --text "Test" --dry-run
```

This prints the post body that would be sent. If it renders correctly and the org URN looks right, you're set. Then try a real text post to confirm end-to-end.

## Refreshing the access token later

Access tokens last 60 days. Refresh tokens last 365. To mint a new access token without prompting the user again:

```python
import requests
resp = requests.post(
    "https://www.linkedin.com/oauth/v2/accessToken",
    data={
        "grant_type": "refresh_token",
        "refresh_token": os.environ["LINKEDIN_REFRESH_TOKEN"],
        "client_id": os.environ["LINKEDIN_CLIENT_ID"],
        "client_secret": os.environ["LINKEDIN_CLIENT_SECRET"],
    },
)
new_access_token = resp.json()["access_token"]
```

For a long-running deployment, wire this into a cron or a startup hook that refreshes if the current token is within, say, 5 days of expiry.


path: scripts/get_token.py
content: #!/usr/bin/env python3
"""One-time OAuth 2.0 helper to get an access token for a LinkedIn Company Page.

Runs a tiny local HTTP server to catch the redirect, exchanges the code, and
prints both the access token and the refresh token. You only run this once
per environment — after that, the access token lives in env vars and the
refresh token renews it for the next year.

Usage:
    export LINKEDIN_CLIENT_ID=<your app's client id>
    export LINKEDIN_CLIENT_SECRET=<your app's client secret>
    python scripts/get_token.py

Setup prerequisites: see references/setup.md. TL;DR — you need a LinkedIn
Developer app with "Community Management API" access approved, and the
redirect URI `http://localhost:8765/callback` added under Auth settings.
"""

from __future__ import annotations

import http.server
import os
import secrets
import sys
import urllib.parse
import webbrowser
from threading import Event, Thread

import requests


REDIRECT_URI = "http://localhost:8765/callback"
SCOPES = ["w_organization_social", "r_organization_social", "rw_organization_admin"]
AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization"
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"


class _CallbackHandler(http.server.BaseHTTPRequestHandler):
    # Class-level stash so main() can read the result after the server stops.
    result: dict[str, str] = {}
    done: Event = Event()

    def log_message(self, format: str, *args) -> None:  # noqa: ARG002
        pass  # Suppress default noisy logging.

    def do_GET(self) -> None:  # noqa: N802
        parsed = urllib.parse.urlparse(self.path)
        params = urllib.parse.parse_qs(parsed.query)
        if parsed.path != "/callback":
            self.send_response(404)
            self.end_headers()
            return

        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()

        if "error" in params:
            msg = params.get("error_description", params["error"])[0]
            self.wfile.write(f"<h1>OAuth error</h1><pre>{msg}</pre>".encode())
            _CallbackHandler.result["error"] = msg
        elif "code" in params and "state" in params:
            _CallbackHandler.result["code"] = params["code"][0]
            _CallbackHandler.result["state"] = params["state"][0]
            self.wfile.write(
                b"<h1>You can close this tab.</h1>"
                b"<p>Token exchange is happening in your terminal.</p>"
            )
        else:
            self.wfile.write(b"<h1>Unexpected callback params.</h1>")
            _CallbackHandler.result["error"] = "missing code/state"

        _CallbackHandler.done.set()


def _require_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        print(f"error: {name} is not set.", file=sys.stderr)
        sys.exit(2)
    return value


def main() -> int:
    client_id = _require_env("LINKEDIN_CLIENT_ID")
    client_secret = _require_env("LINKEDIN_CLIENT_SECRET")

    state = secrets.token_urlsafe(16)
    auth_params = {
        "response_type": "code",
        "client_id": client_id,
        "redirect_uri": REDIRECT_URI,
        "state": state,
        "scope": " ".join(SCOPES),
    }
    auth_url = f"{AUTH_URL}?{urllib.parse.urlencode(auth_params)}"

    print("Opening browser for LinkedIn sign-in…")
    print(f"If it doesn't open automatically, visit:\n  {auth_url}\n")

    server = http.server.HTTPServer(("127.0.0.1", 8765), _CallbackHandler)
    Thread(target=server.serve_forever, daemon=True).start()
    webbrowser.open(auth_url)

    try:
        if not _CallbackHandler.done.wait(timeout=300):
            print("timed out waiting for LinkedIn redirect.", file=sys.stderr)
            return 1
    except KeyboardInterrupt:
        print("cancelled.")
        return 1
    finally:
        server.shutdown()

    if "error" in _CallbackHandler.result:
        print(f"OAuth error: {_CallbackHandler.result['error']}", file=sys.stderr)
        return 1
    if _CallbackHandler.result.get("state") != state:
        print("state mismatch — possible CSRF. Aborting.", file=sys.stderr)
        return 1

    code = _CallbackHandler.result["code"]
    print("Got authorization code, exchanging for tokens…")

    resp = requests.post(
        TOKEN_URL,
        data={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": REDIRECT_URI,
            "client_id": client_id,
            "client_secret": client_secret,
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=30,
    )
    if resp.status_code != 200:
        print(f"token exchange failed ({resp.status_code}): {resp.text}", file=sys.stderr)
        return 1

    body = resp.json()
    print("\nSuccess. Save these — LinkedIn will not show them again.\n")
    print(f"LINKEDIN_ACCESS_TOKEN={body['access_token']}")
    if "refresh_token" in body:
        print(f"LINKEDIN_REFRESH_TOKEN={body['refresh_token']}")
    print(f"\nAccess token expires in {body.get('expires_in', '?')} seconds.")
    print("Add these to your shell profile or .env file.\n")
    print("Next step: find your Company Page's numeric ID at")
    print("  https://www.linkedin.com/company/<slug>/admin/  (visible in the URL)")
    print("and export it as LINKEDIN_ORG_ID.")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/publish.py
content: #!/usr/bin/env python3
"""LinkedIn Page Publisher CLI.

Usage:
    python scripts/publish.py text --text "Hello, page"
    python scripts/publish.py image photo.jpg --text "..." --alt "..."
    python scripts/publish.py multi-image a.jpg b.jpg --text "..." --alt "first" "second"
    python scripts/publish.py video demo.mp4 --text "..." --title "Demo"
    python scripts/publish.py article https://example.com --text "..."

Env vars required: LINKEDIN_ACCESS_TOKEN, LINKEDIN_ORG_ID.
Optional: LINKEDIN_API_VERSION (default 202602).

--dry-run prints the computed post body without hitting the API. Media
uploads still happen in non-dry mode — there's no clean way to dry-run
multi-step flows.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

# Make `from lib.X import Y` work whether the user runs this from the skill
# root or from anywhere else.
sys.path.insert(0, str(Path(__file__).resolve().parent))

from lib.client import LinkedInClient, LinkedInAPIError  # noqa: E402
from lib.posts import (  # noqa: E402
    post_text,
    post_image,
    post_multi_image,
    post_video,
    post_article,
    _envelope,
)


def _build_parser() -> argparse.ArgumentParser:
    # `--dry-run` is declared once in this parent parser and attached to each
    # subcommand. It is NOT attached to the top-level parser — argparse's
    # subparser would overwrite the top-level value with its own default,
    # silently turning dry-runs into live API calls. Position-after-subcommand
    # (`publish.py text --dry-run ...`) is the only reliable pattern here.
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument(
        "--dry-run",
        action="store_true",
        help="Print the request body without calling the API. Media is NOT uploaded in dry-run.",
    )

    parser = argparse.ArgumentParser(
        prog="publish.py",
        description="Publish content to a LinkedIn Company Page.",
    )

    subparsers = parser.add_subparsers(dest="kind", required=True)

    p_text = subparsers.add_parser("text", parents=[common], help="Text-only post.")
    p_text.add_argument("--text", required=True)

    p_image = subparsers.add_parser("image", parents=[common], help="Single-image post.")
    p_image.add_argument("image_path")
    p_image.add_argument("--text", required=True)
    p_image.add_argument("--alt", required=True, help="Alt text for accessibility.")

    p_multi = subparsers.add_parser("multi-image", parents=[common], help="Carousel of 2–20 images.")
    p_multi.add_argument("image_paths", nargs="+")
    p_multi.add_argument("--text", required=True)
    p_multi.add_argument(
        "--alt",
        nargs="+",
        required=True,
        help="One alt text string per image, in order.",
    )

    p_video = subparsers.add_parser("video", parents=[common], help="Video post.")
    p_video.add_argument("video_path")
    p_video.add_argument("--text", required=True)
    p_video.add_argument("--title", required=True, help="Video title shown under the post.")

    p_article = subparsers.add_parser("article", parents=[common], help="Post with an article link preview.")
    p_article.add_argument("url")
    p_article.add_argument("--text", required=True)
    p_article.add_argument("--title", help="Override OpenGraph title.")
    p_article.add_argument("--description", help="Override OpenGraph description.")

    return parser


def _dry_run(client: LinkedInClient, args: argparse.Namespace) -> None:
    """Print what the request body would look like, without uploading media."""
    if args.kind == "text":
        body = _envelope(client, args.text)
    elif args.kind == "image":
        body = _envelope(client, args.text, {
            "media": {"id": "urn:li:image:<not uploaded>", "altText": args.alt},
        })
    elif args.kind == "multi-image":
        if len(args.alt) != len(args.image_paths):
            raise SystemExit(
                f"--alt count ({len(args.alt)}) must match image count ({len(args.image_paths)})."
            )
        body = _envelope(client, args.text, {
            "multiImage": {
                "images": [
                    {"id": f"urn:li:image:<not uploaded #{i}>", "altText": alt}
                    for i, alt in enumerate(args.alt)
                ]
            }
        })
    elif args.kind == "video":
        body = _envelope(client, args.text, {
            "media": {"id": "urn:li:video:<not uploaded>", "title": args.title},
        })
    elif args.kind == "article":
        article = {"source": args.url}
        if args.title:
            article["title"] = args.title
        if args.description:
            article["description"] = args.description
        body = _envelope(client, args.text, {"article": article})
    else:
        raise SystemExit(f"Unknown kind: {args.kind}")

    print(json.dumps(body, indent=2, ensure_ascii=False))


def _live(client: LinkedInClient, args: argparse.Namespace) -> str:
    if args.kind == "text":
        return post_text(client, args.text)
    if args.kind == "image":
        return post_image(client, args.image_path, text=args.text, alt=args.alt)
    if args.kind == "multi-image":
        return post_multi_image(
            client, args.image_paths, text=args.text, alts=args.alt
        )
    if args.kind == "video":
        return post_video(client, args.video_path, text=args.text, title=args.title)
    if args.kind == "article":
        return post_article(
            client,
            args.url,
            text=args.text,
            title=args.title,
            description=args.description,
        )
    raise SystemExit(f"Unknown kind: {args.kind}")


def main(argv: list[str] | None = None) -> int:
    args = _build_parser().parse_args(argv)
    try:
        client = LinkedInClient()
    except RuntimeError as e:
        print(f"configuration error: {e}", file=sys.stderr)
        return 2

    try:
        if args.dry_run:
            _dry_run(client, args)
            return 0
        post_urn = _live(client, args)
    except LinkedInAPIError as e:
        print(f"LinkedIn API error ({e.status}): {e.body}", file=sys.stderr)
        return 1
    except FileNotFoundError as e:
        print(f"file not found: {e}", file=sys.stderr)
        return 2
    except (ValueError, RuntimeError, TimeoutError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 1

    print(f"Published: {post_urn}")
    print(f"  URL: https://www.linkedin.com/feed/update/{post_urn}/")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/lib/client.py
content: """LinkedIn REST API client.

A thin wrapper around `requests` that enforces the headers LinkedIn's versioned
API requires on every call:

- Authorization: Bearer <token>
- LinkedIn-Version: YYYYMM
- X-Restli-Protocol-Version: 2.0.0

Keeping this in one place means we never forget one of them, which is the
single most common cause of opaque 400s from LinkedIn.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Any

import requests


DEFAULT_API_VERSION = "202602"  # February 2026. Bump deliberately.
API_BASE = "https://api.linkedin.com/rest"
MAX_COMMENTARY_LENGTH = 3000


class LinkedInAPIError(Exception):
    """Raised when LinkedIn returns a non-2xx response.

    The `body` attribute holds the parsed response body (dict if JSON, str
    otherwise). LinkedIn's real error signal lives in `serviceErrorCode` and
    `message` fields inside that body — check them before guessing.
    """

    def __init__(self, status: int, body: Any, url: str):
        self.status = status
        self.body = body
        self.url = url
        super().__init__(f"LinkedIn API {status} at {url}: {body}")


@dataclass
class LinkedInClient:
    """Wraps credentials and gives you `get`, `post`, and `put` helpers.

    Reads from env vars by default; override via constructor for tests or
    multi-tenant use.
    """

    access_token: str | None = None
    org_id: str | None = None
    api_version: str | None = None
    timeout: float = 60.0

    def __post_init__(self) -> None:
        self.access_token = self.access_token or os.environ.get("LINKEDIN_ACCESS_TOKEN")
        self.org_id = self.org_id or os.environ.get("LINKEDIN_ORG_ID")
        self.api_version = (
            self.api_version
            or os.environ.get("LINKEDIN_API_VERSION")
            or DEFAULT_API_VERSION
        )

        if not self.access_token:
            raise RuntimeError(
                "LINKEDIN_ACCESS_TOKEN is not set. Run scripts/get_token.py or "
                "export the token before calling this script."
            )
        if not self.org_id:
            raise RuntimeError(
                "LINKEDIN_ORG_ID is not set. Set it to the numeric organization "
                "ID of the Company Page (not the full URN)."
            )
        if not self.api_version.isdigit() or len(self.api_version) != 6:
            raise RuntimeError(
                f"LINKEDIN_API_VERSION must be YYYYMM (e.g. 202602); got {self.api_version!r}."
            )

    # --- URNs -------------------------------------------------------------

    @property
    def author_urn(self) -> str:
        """The organization URN used in the `author` field of every post."""
        return f"urn:li:organization:{self.org_id}"

    # --- Headers ----------------------------------------------------------

    def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
        headers = {
            "Authorization": f"Bearer {self.access_token}",
            "LinkedIn-Version": self.api_version,
            "X-Restli-Protocol-Version": "2.0.0",
            "Content-Type": "application/json",
        }
        if extra:
            headers.update(extra)
        return headers

    # --- Verbs ------------------------------------------------------------

    def post(self, path: str, body: dict[str, Any]) -> requests.Response:
        """POST to /rest/<path>. Returns the raw Response so callers can
        read response headers too (post IDs come back in `x-restli-id`)."""
        url = f"{API_BASE}/{path.lstrip('/')}"
        resp = requests.post(url, headers=self._headers(), json=body, timeout=self.timeout)
        self._check(resp)
        return resp

    def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
        url = f"{API_BASE}/{path.lstrip('/')}"
        resp = requests.get(url, headers=self._headers(), params=params, timeout=self.timeout)
        self._check(resp)
        return resp.json() if resp.content else {}

    def put_upload(self, upload_url: str, data: bytes, extra_headers: dict[str, str] | None = None) -> requests.Response:
        """PUT raw bytes to an upload URL returned by initializeUpload.

        These uploads do NOT use the standard LinkedIn auth headers — the URL
        itself is pre-signed. We only forward `extra_headers` (mostly used for
        multipart uploads that need specific Content-Type per part).
        """
        headers = extra_headers or {}
        resp = requests.put(upload_url, headers=headers, data=data, timeout=max(self.timeout, 300))
        # Upload URLs return a variety of 2xx codes; 200 and 201 are both fine.
        if not (200 <= resp.status_code < 300):
            raise LinkedInAPIError(resp.status_code, resp.text, upload_url)
        return resp

    # --- Error handling ---------------------------------------------------

    @staticmethod
    def _check(resp: requests.Response) -> None:
        if 200 <= resp.status_code < 300:
            return
        try:
            body: Any = resp.json()
        except ValueError:
            body = resp.text
        raise LinkedInAPIError(resp.status_code, body, resp.url)


path: scripts/lib/__init__.py
content: """LinkedIn Page Publisher — library layer.

Import from here to build your own publishing workflows:

    from scripts.lib.client import LinkedInClient
    from scripts.lib.posts import post_text, post_image, post_video, post_article
"""


path: scripts/lib/media.py
content: """Image and video upload helpers.

LinkedIn's media upload is a three-step handshake:

1. `initializeUpload` — register the upload. LinkedIn returns an upload URL
   (or several, for multipart video) and a pre-assigned URN.
2. `PUT` the binary — upload the actual bytes to the returned URL(s).
3. (videos only) `finalizeUpload` for multipart, then poll status until the
   video reaches `AVAILABLE`.

Only then is the URN safe to reference in a post. Skipping the poll is the
single most common cause of "the post published but the video is broken"
complaints — LinkedIn happily accepts the post but the video isn't ready.
"""

from __future__ import annotations

import mimetypes
import os
import time
from pathlib import Path

from .client import LinkedInClient


# LinkedIn's own thresholds from its docs.
IMAGE_MAX_PIXELS = 36_152_320
VIDEO_MULTIPART_THRESHOLD_BYTES = 200 * 1024 * 1024  # 200 MB
VIDEO_CHUNK_SIZE_BYTES = 4 * 1024 * 1024  # 4 MB chunks when multipart

# How long to wait for LinkedIn to process a video before giving up.
VIDEO_POLL_INTERVAL_SECONDS = 5
VIDEO_POLL_TIMEOUT_SECONDS = 600  # 10 minutes


# --- Images ------------------------------------------------------------------


def upload_image(client: LinkedInClient, image_path: str | os.PathLike) -> str:
    """Upload an image and return its URN (e.g. `urn:li:image:D4E10AQ...`).

    The returned URN goes in `content.media.id` for single-image posts or in
    `content.multiImage.images[].id` for carousels.
    """
    path = Path(image_path)
    if not path.is_file():
        raise FileNotFoundError(f"Image not found: {path}")

    # Step 1: initialize.
    resp = client.post(
        "images?action=initializeUpload",
        body={"initializeUploadRequest": {"owner": client.author_urn}},
    )
    value = resp.json()["value"]
    upload_url = value["uploadUrl"]
    image_urn = value["image"]

    # Step 2: upload the bytes.
    with path.open("rb") as fh:
        data = fh.read()
    content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
    client.put_upload(upload_url, data, extra_headers={"Content-Type": content_type})

    # Images are processed fast enough that we don't poll — the post endpoint
    # accepts them almost immediately. If you see "image not visible" issues,
    # add a small sleep or poll `GET /rest/images/{urn}` for status.
    return image_urn


# --- Videos ------------------------------------------------------------------


def upload_video(
    client: LinkedInClient,
    video_path: str | os.PathLike,
    *,
    poll: bool = True,
) -> str:
    """Upload a video and return its URN (e.g. `urn:li:video:C5F10AQ...`).

    Automatically chooses single-part vs multipart based on file size.
    Polls until the video finishes processing (pass `poll=False` to skip —
    but don't create the post until processing is done or the video will
    appear broken).
    """
    path = Path(video_path)
    if not path.is_file():
        raise FileNotFoundError(f"Video not found: {path}")

    size = path.stat().st_size
    if size > VIDEO_MULTIPART_THRESHOLD_BYTES:
        video_urn = _upload_video_multipart(client, path, size)
    else:
        video_urn = _upload_video_single(client, path, size)

    if poll:
        _wait_for_video_available(client, video_urn)
    return video_urn


def _upload_video_single(client: LinkedInClient, path: Path, size: int) -> str:
    """Single-URL upload for videos under the multipart threshold."""
    resp = client.post(
        "videos?action=initializeUpload",
        body={
            "initializeUploadRequest": {
                "owner": client.author_urn,
                "fileSizeBytes": size,
                "uploadCaptions": False,
                "uploadThumbnail": False,
            }
        },
    )
    value = resp.json()["value"]
    video_urn = value["video"]

    # For single-part videos, `uploadInstructions` is a one-element list.
    instructions = value["uploadInstructions"]
    if len(instructions) != 1:
        # LinkedIn changed its mind about file size — fall through to multipart.
        return _upload_video_multipart(client, path, size, pre_initialized=value)

    upload_url = instructions[0]["uploadUrl"]
    with path.open("rb") as fh:
        upload_resp = client.put_upload(upload_url, fh.read())

    etag = upload_resp.headers.get("etag") or upload_resp.headers.get("ETag")
    if not etag:
        raise RuntimeError(
            "Single-part video upload returned no ETag header; cannot finalize. "
            "Retry with a freshly initialized upload."
        )
    _finalize_video(client, video_urn, [etag])
    return video_urn


def _upload_video_multipart(
    client: LinkedInClient,
    path: Path,
    size: int,
    *,
    pre_initialized: dict | None = None,
) -> str:
    """Chunked upload for videos over the multipart threshold."""
    if pre_initialized is None:
        resp = client.post(
            "videos?action=initializeUpload",
            body={
                "initializeUploadRequest": {
                    "owner": client.author_urn,
                    "fileSizeBytes": size,
                    "uploadCaptions": False,
                    "uploadThumbnail": False,
                }
            },
        )
        value = resp.json()["value"]
    else:
        value = pre_initialized

    video_urn = value["video"]
    instructions = value["uploadInstructions"]

    etags: list[str] = []
    with path.open("rb") as fh:
        for instruction in instructions:
            first = instruction["firstByte"]
            last = instruction["lastByte"]
            fh.seek(first)
            chunk = fh.read(last - first + 1)
            resp = client.put_upload(instruction["uploadUrl"], chunk)
            etag = resp.headers.get("etag") or resp.headers.get("ETag")
            if not etag:
                raise RuntimeError(
                    f"Upload chunk at bytes {first}-{last} returned no ETag; "
                    "cannot finalize multipart upload."
                )
            etags.append(etag)

    _finalize_video(client, video_urn, etags)
    return video_urn


def _finalize_video(client: LinkedInClient, video_urn: str, etags: list[str]) -> None:
    """Call finalizeUpload with the per-part ETags."""
    client.post(
        "videos?action=finalizeUpload",
        body={
            "finalizeUploadRequest": {
                "video": video_urn,
                "uploadToken": "",
                "uploadedPartIds": etags,
            }
        },
    )


def _wait_for_video_available(client: LinkedInClient, video_urn: str) -> None:
    """Poll `GET /rest/videos/{urn}` until status == AVAILABLE or timeout."""
    deadline = time.monotonic() + VIDEO_POLL_TIMEOUT_SECONDS
    encoded = video_urn.replace(":", "%3A")
    while time.monotonic() < deadline:
        result = client.get(f"videos/{encoded}")
        status = result.get("status")
        if status == "AVAILABLE":
            return
        if status == "PROCESSING_FAILED":
            raise RuntimeError(f"LinkedIn reported processing failure for {video_urn}")
        time.sleep(VIDEO_POLL_INTERVAL_SECONDS)
    raise TimeoutError(
        f"Video {video_urn} did not reach AVAILABLE within "
        f"{VIDEO_POLL_TIMEOUT_SECONDS}s. You can retry polling later — the "
        "upload itself succeeded."
    )


path: scripts/lib/posts.py
content: """Post body builders and publish helpers.

One function per post type. Each calls `POST /rest/posts` and returns the
post URN from the `x-restli-id` response header (e.g.
`urn:li:share:7045020441609936898`).

Every post shares the same envelope: `author`, `commentary`, `visibility`,
`distribution`, `lifecycleState`, `isReshareDisabledByAuthor`. The content
type is carried in the optional `content` field.
"""

from __future__ import annotations

from typing import Any

from .client import LinkedInClient, MAX_COMMENTARY_LENGTH
from .media import upload_image, upload_video


def _envelope(client: LinkedInClient, commentary: str, content: dict[str, Any] | None = None) -> dict[str, Any]:
    """Build the shared post body. Every post type starts from this."""
    if len(commentary) > MAX_COMMENTARY_LENGTH:
        raise ValueError(
            f"Commentary is {len(commentary)} characters; LinkedIn rejects anything "
            f"over {MAX_COMMENTARY_LENGTH}. Trim before calling."
        )

    body: dict[str, Any] = {
        "author": client.author_urn,
        "commentary": commentary,
        "visibility": "PUBLIC",
        "distribution": {
            "feedDistribution": "MAIN_FEED",
            "targetEntities": [],
            "thirdPartyDistributionChannels": [],
        },
        "lifecycleState": "PUBLISHED",
        "isReshareDisabledByAuthor": False,
    }
    if content is not None:
        body["content"] = content
    return body


def _publish(client: LinkedInClient, body: dict[str, Any]) -> str:
    """POST to /rest/posts and return the post URN from response headers."""
    resp = client.post("posts", body=body)
    post_urn = resp.headers.get("x-restli-id")
    if not post_urn:
        # Shouldn't happen for 201 responses, but fall back to the body just in case.
        payload = resp.json() if resp.content else {}
        post_urn = payload.get("id", "<unknown>")
    return post_urn


# --- Public API -------------------------------------------------------------


def post_text(client: LinkedInClient, text: str) -> str:
    """Publish a text-only post. Returns the post URN."""
    return _publish(client, _envelope(client, text))


def post_image(client: LinkedInClient, image_path: str, *, text: str, alt: str) -> str:
    """Upload an image and publish it as a single-image post."""
    if not alt:
        raise ValueError("alt text is required for accessibility and ranking.")
    image_urn = upload_image(client, image_path)
    content = {"media": {"id": image_urn, "altText": alt}}
    return _publish(client, _envelope(client, text, content))


def post_multi_image(
    client: LinkedInClient,
    image_paths: list[str],
    *,
    text: str,
    alts: list[str],
) -> str:
    """Upload multiple images and publish them as a carousel (2–20 images)."""
    if not 2 <= len(image_paths) <= 20:
        raise ValueError(
            f"Multi-image posts need between 2 and 20 images; got {len(image_paths)}."
        )
    if len(alts) != len(image_paths):
        raise ValueError(
            f"Alt text count ({len(alts)}) must match image count ({len(image_paths)})."
        )

    images = []
    for path, alt in zip(image_paths, alts, strict=True):
        if not alt:
            raise ValueError(f"Missing alt text for image {path}.")
        urn = upload_image(client, path)
        images.append({"id": urn, "altText": alt})

    content = {"multiImage": {"images": images}}
    return _publish(client, _envelope(client, text, content))


def post_video(
    client: LinkedInClient,
    video_path: str,
    *,
    text: str,
    title: str,
) -> str:
    """Upload a video and publish it. Waits for LinkedIn to finish processing
    before creating the post — a post created against a still-processing video
    will show a broken player to viewers."""
    video_urn = upload_video(client, video_path, poll=True)
    content = {"media": {"id": video_urn, "title": title}}
    return _publish(client, _envelope(client, text, content))


def post_article(
    client: LinkedInClient,
    url: str,
    *,
    text: str,
    title: str | None = None,
    description: str | None = None,
) -> str:
    """Publish a post with an article link preview.

    LinkedIn scrapes the URL's OpenGraph metadata for the preview card. You
    can override `title` and `description` explicitly, but LinkedIn may still
    prefer the OG tags. If the preview looks wrong, the fix is usually on the
    target page's `<meta property="og:...">` tags, not here.
    """
    article: dict[str, Any] = {"source": url}
    if title is not None:
        article["title"] = title
    if description is not None:
        article["description"] = description
    content = {"article": article}
    return _publish(client, _envelope(client, text, content))


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)