Publishes one technology article to LinkedIn each morning, at a randomised time.
Post copy is generated by a locally hosted language model. Images are scraped from the source article. No content leaves the machine except the post itself.
Five technology RSS feeds are the input. One LinkedIn post is the output, published once daily between 08:00 and 08:55 at a time selected at random each morning, so the schedule is not externally predictable.
Post copy is written by llama3.1:8b running locally through Ollama. The accompanying image is the source article's own Open Graph image. No article text, generated draft, or credential is transmitted to a hosted model.
- Ollama, running, with the model available:
ollama pull llama3.1:8b - A LinkedIn Developer application with both the Share on LinkedIn and Sign In with LinkedIn using OpenID Connect products enabled
- Python 3, and macOS for
launchdscheduling
pip3 install -r requirements.txt
cp .env.template .env # add LinkedIn client ID and secretAuthorise once through a browser:
python3 auth.pyThis writes .tokens.json. Both that file and .env are gitignored.
Verify end to end before automating:
python3 post.pyThen install the scheduler:
chmod +x setup_launchd.sh && ./setup_launchd.shThis registers two agents: the scheduler at five-minute intervals, and a daily token check at 08:05.
cron_setup.shis a superseded approach using fixed times viacrontab.setup_launchd.shreplaces it and removes the agents it supersedes.launchdis preferred becausecrondoes not fire missed runs after sleep, which on a laptop results in skipped mornings.
launchd cannot express "at a random time within a window", so the randomisation lives one level above it.
The scheduler runs every five minutes and does nothing until the clock passes a target minute, selected once per day at random and stored in .schedule.json alongside the date. A stale date triggers a new selection.
A slot that fires successfully is marked posted, making the day's remaining runs no-ops. A non-zero exit leaves the slot unposted but is deliberately not retried: the failure conditions here — expired token, unavailable model — persist, so retrying would only multiply the notifications.
Retrieval and ranking. Five feeds — Ars Technica, The Verge, TechCrunch, Wired, MIT Technology Review. Entries are scored on how many of fifteen preferred topics appear in the title or summary, and anything already in .posted_urls.json is excluded.
Generation. The prompt fixes the structure: technical substance, then broader implication ending in a question, then hashtags and the source. Emoji, first-person voice, and promotional openers are prohibited. Three attempts, 90-second timeout.
Image acquisition. og:image, twitter:image, and og:image:secure_url are examined in order. Failing all three, a text card is generated with Pillow; a missing image never blocks publication.
Upload and publication. The asset is registered, the bytes uploaded to the returned URL, and a UGC post created referencing it. The article URL is then appended to .posted_urls.json.
The Open Graph URL comes from arbitrary third-party HTML, so a crafted page could direct the bot at an internal service or a host on the local network. Every candidate is resolved to an IP and rejected if private, loopback, link-local, reserved, multicast, or unspecified. Content types are limited to JPEG, PNG, and GIF.
LinkedIn access tokens last roughly 60 days, and re-authorisation on expiry is unavoidable. check_token.py runs daily at 08:05 and raises a macOS notification at seven days, escalating at one day and once expired. Re-authorise with python3 auth.py.
launchctl list | grep linkedinbot # confirm both agents are loaded
python3 scheduler.py # inspect today's scheduled time
python3 check_token.py # check remaining token validity
tail -f logs/$(date +%F).log # today's run in detailLogs are written per day into logs/, which is gitignored.
| Symptom | Cause |
|---|---|
Ollama is not running |
Start ollama serve and confirm the model is available |
| Publication stopped without visible errors | Token expired — run python3 check_token.py |
| Scheduler runs but nothing publishes | The current day's slot is already marked posted |
401 from LinkedIn |
Token expired, or the application lacks the Share on LinkedIn product |
| All posts are text-only | Image scraping is failing or being rejected; the log states the reason |
| An article published twice | .posted_urls.json was deleted — it is the sole deduplication record |
linkedin-bot/
├── post.py Retrieval, ranking, generation, image, upload, publication
├── scheduler.py Daily time selection; invokes post.py once
├── auth.py One-time OAuth browser flow → .tokens.json
├── check_token.py Daily expiry notification
├── setup_launchd.sh Installs both agents — preferred
├── cron_setup.sh Superseded fixed-time configuration
└── .env.template LinkedIn client ID and secret
Excluded from version control: .env, .tokens.json, .posted_urls.json, .schedule.json, logs/.
| Setting | Location |
|---|---|
| Feed list | RSS_FEEDS in post.py |
| Topic preferences | PREFERRED_TOPICS in post.py |
| Tone, length, and structure | The prompt in generate_post() |
| Model selection | The model field in the Ollama request |
| Posting window, or additional windows | WINDOWS in scheduler.py — a list of tuples; a second entry produces a second daily post |
| chakri192 | Author |
| aider | AI pair programmer |