DEV Community

Kai Thorne
Kai Thorne

Posted on

I Wrote a Python Script That Cross-Posts My Dev.to Articles to 3 Platforms Automatically — Here's the Code

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:

  1. Watches for new Dev.to articles (via RSS + API)
  2. Converts markdown to each platform's format
  3. Posts via each platform's API
  4. Sets canonical URLs back to Dev.to (for SEO)
  5. 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)
Enter fullscreen mode Exit fullscreen mode

Step 1: Get Your API Keys

Dev.to API Key

Settings → Account → DEV API Key → Generate
Enter fullscreen mode Exit fullscreen mode

Free, instant, no approval needed.

Medium Integration Token

Settings → Security → Integration Token → Generate
Enter fullscreen mode Exit fullscreen mode

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_social scope (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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
"
Enter fullscreen mode Exit fullscreen mode

Output:

┌────────────┬──────────┬────────┬──────────┐
│    day     │ articles │ medium │ linkedin │
├────────────┼──────────┼────────┼──────────┤
│ 2026-06-10 │    3     │   3    │    2     │
│ 2026-06-09 │    2     │   2    │    2     │
│ 2026-06-08 │    1     │   1    │    1     │
└────────────┴──────────┴────────┴──────────┘
Enter fullscreen mode Exit fullscreen mode

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:


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)