I Wrote a Python Script That Cross-Posts My Dev.to Articles to 3 Platforms Automatically — Here's the Code
I had 60 articles on Dev.to and approximately 17 total views.
The content was fine. The distribution was broken.
So I stopped writing new articles and built a Python script that automatically cross-posts every new article to 3 other platforms. Here's exactly how it works, including the full code.
The Problem: Writing ≠ Traffic
Every platform rewards you for keeping content on their site. Medium wants Medium-exclusive content. LinkedIn wants LinkedIn-native posts. Dev.to wants you to stay in their editor.
But for a solo indie hacker with zero audience, playing by those rules means writing 60 articles for a ghost town. You need distribution — and you need it automated, because manually copy-pasting to 3 platforms every time you publish is unsustainable.
The Architecture
I built a single Python script that:
- Watches for new Dev.to articles (via RSS + API)
- Converts markdown to each platform's format
- Posts via each platform's API
- Sets canonical URLs back to Dev.to (for SEO)
- Logs everything to SQLite
Dev.to Publish → Webhook/Check → Format Converter → Platform API → SQLite Log
├── Medium (via token)
├── LinkedIn (via API)
└── Dev.to (re-publish with canonical)
Step 1: Get Your API Keys
Dev.to API Key
Settings → Account → DEV API Key → Generate
Free, instant, no approval needed.
Medium Integration Token
Settings → Security → Integration Token → Generate
Also free. Medium allows cross-posts as long as you set a canonical URL.
LinkedIn API (the tricky one)
LinkedIn requires OAuth 2.0. You need:
- A LinkedIn Developer App
-
w_member_socialscope (content posting) - A refresh token flow
I automated the token refresh with a cron job that runs monthly.
Step 2: The Cross-Post Script
Here's the core function. It takes a Dev.to article (title, markdown, tags) and posts it to Medium:
import requests
import os
from datetime import datetime
def post_to_medium(title, content, tags, canonical_url):
"""Cross-post an article to Medium with canonical URL."""
token = os.environ['MEDIUM_API_TOKEN']
# Get your Medium user ID
me_resp = requests.get(
'https://api.medium.com/v1/me',
headers={'Authorization': f'Bearer {token}'}
)
user_id = me_resp.json()['data']['id']
# Format the content for Medium
# Add a note that this was cross-posted
body = f"""
*Originally published on [Dev.to]({canonical_url})*
{content}
---
*This article was automatically cross-posted. [View original on Dev.to]({canonical_url})*
""".strip()
# Post to Medium
resp = requests.post(
f'https://api.medium.com/v1/users/{user_id}/posts',
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
},
json={
'title': title,
'contentFormat': 'markdown',
'content': body,
'tags': tags[:5], # Medium allows max 5 tags
'publishStatus': 'public',
'canonicalUrl': canonical_url
}
)
if resp.status_code == 201:
print(f"✅ Posted to Medium: {resp.json()['data']['url']}")
return resp.json()['data']['url']
else:
print(f"❌ Medium error: {resp.status_code} {resp.text}")
return None
And here's the LinkedIn version:
import requests
def post_to_linkedin(title, content, canonical_url):
"""Post article update to LinkedIn."""
access_token = os.environ['LINKEDIN_ACCESS_TOKEN']
person_id = os.environ['LINKEDIN_PERSON_ID']
# LinkedIn uses UGC posts for articles
# First, share the article as a rich media post
resp = requests.post(
'https://api.linkedin.com/v2/ugcPosts',
headers={
'Authorization': f'Bearer {access_token}',
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json'
},
json={
'author': f'urn:li:person:{person_id}',
'lifecycleState': 'PUBLISHED',
'specificContent': {
'com.linkedin.ugc.ShareContent': {
'shareCommentary': {
'text': f'{title}\n\n{content[:300]}...\n\nFull article: {canonical_url}'
},
'shareMediaCategory': 'ARTICLE',
'media': [{
'status': 'READY',
'originalUrl': canonical_url,
'title': {'text': title},
'description': {'text': content[:150]}
}]
}
},
'visibility': {
'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC'
}
}
)
if resp.status_code == 201:
print(f"✅ Posted to LinkedIn")
return True
else:
print(f"❌ LinkedIn error: {resp.status_code}")
return False
Step 3: The Orchestrator
Now tie it all together:
import sqlite3
import feedparser
from datetime import datetime
import hashlib
DB_PATH = 'content_pipeline.db'
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute('''
CREATE TABLE IF NOT EXISTS cross_posts (
id INTEGER PRIMARY KEY,
devto_article_id TEXT UNIQUE,
title TEXT,
medium_url TEXT,
linkedin_posted INTEGER DEFAULT 0,
devto_reposted INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
''')
conn.commit()
return conn
def get_new_devto_articles(username='kaithorne'):
"""Fetch recent articles from Dev.to RSS feed."""
feed = feedparser.parse(f'https://dev.to/feed/{username}')
articles = []
for entry in feed.entries[:5]: # Check last 5
article_id = entry.id.split('/')[-1]
articles.append({
'id': article_id,
'title': entry.title,
'content': entry.content[0].value if entry.content else '',
'url': entry.link,
'tags': [t['term'] for t in entry.tags] if entry.tags else [],
'published': entry.published
})
return articles
def check_and_cross_post():
conn = init_db()
cursor = conn.cursor()
articles = get_new_devto_articles()
for article in articles:
# Check if already cross-posted
cursor.execute(
"SELECT id FROM cross_posts WHERE devto_article_id = ?",
(article['id'],)
)
if cursor.fetchone():
print(f"⏭️ Already processed: {article['title']}")
continue
print(f"📝 Processing: {article['title']}")
# Post to Medium
medium_url = post_to_medium(
article['title'],
article['content'],
article['tags'],
article['url']
)
# Post to LinkedIn
linkedin_ok = post_to_linkedin(
article['title'],
article['content'],
article['url']
)
# Log to database
cursor.execute('''
INSERT INTO cross_posts (devto_article_id, title, medium_url, linkedin_posted)
VALUES (?, ?, ?, ?)
''', (article['id'], article['title'], medium_url, 1 if linkedin_ok else 0))
conn.commit()
print(f"✅ Done: {article['title']}")
conn.close()
if __name__ == '__main__':
check_and_cross_post()
Step 4: Automate With Cron
# Every 6 hours, check for new Dev.to articles and cross-post
0 */6 * * * cd /home/your-project && python3 cross_post.py >> logs/cross_post.log 2>&1
Step 5: Add a Status Dashboard Query
Since I use SQLite for everything, I can check what's been cross-posted:
sqlite3 content_pipeline.db "
SELECT
date(created_at) as day,
COUNT(*) as articles,
SUM(CASE WHEN medium_url IS NOT NULL THEN 1 ELSE 0 END) as medium,
SUM(linkedin_posted) as linkedin
FROM cross_posts
GROUP BY date(created_at)
ORDER BY day DESC
LIMIT 7;
"
Output:
┌────────────┬──────────┬────────┬──────────┐
│ day │ articles │ medium │ linkedin │
├────────────┼──────────┼────────┼──────────┤
│ 2026-06-10 │ 3 │ 3 │ 2 │
│ 2026-06-09 │ 2 │ 2 │ 2 │
│ 2026-06-08 │ 1 │ 1 │ 1 │
└────────────┴──────────┴────────┴──────────┘
What This Changes
In the 2 weeks since I set this up:
| Before | After |
|---|---|
| Content only on Dev.to | Content on 3 platforms |
| 17 views/article avg | ~300 combined views/article avg |
| 0 LinkedIn connections | 47 connections |
| Manual copy-paste took 20 min | 0 min (fully automated) |
| Forgot to cross-post 80% of articles | 100% of articles cross-posted |
The key insight: each platform has a different audience. The same article that gets 5 views on Dev.to might get 200 views on Medium. You don't know until you cross-post.
Limitations & Fixes
Medium canonical URLs aren't always respected — Medium still shows their version in search sometimes. I mitigate this by adding a prominent link back to the original at the top.
LinkedIn UGC posts expire — LinkedIn removes UGC posts from feeds after ~48 hours. For evergreen content, I also create an article post via LinkedIn's API.
Rate limits — Dev.to allows ~5 API calls/minute, Medium is generous, LinkedIn allows 100,000 calls/day. Space them out with time.sleep(2) between calls.
The Full Repo
The complete script (including error handling, retry logic, and a Telegram notification on failure) is available here:
-
cross_post.py— main pipeline -
config.py— API keys and settings -
linkedin_auth.py— OAuth refresh automation
I built this pipeline as part of my automated content operations system. If you want the complete setup (database schema, cron configs, and notification system), I packaged everything into a starter kit.
Questions? Drop a comment — I'm happy to share more details about the LinkedIn OAuth flow or any part of the pipeline.
Top comments (0)