Back to Blog
2026-03-22

How to Automate Social Media Posting with Python and Playwright

Playwright gives you programmatic browser control over any social media platform — even ones without public APIs. Here's how to build a multi-account posting system that handles Instagram, TikTok, Facebook, and YouTube from a single Python script.

Every major social media platform has an official API. And every major social media API is either deprecated, heavily rate-limited, requires business verification, or blocks the features you actually need (like Reels posting, carousel uploads, or account switching).

Playwright browser automation sidesteps all of that. You control the browser the same way a human does — log in, navigate, fill in fields, upload files, click post. The platform can't distinguish it from manual use. Here's how to build it properly.

Why Playwright Over Selenium

Playwright is the successor to Puppeteer with better multi-browser support and cleaner async APIs:

| Feature | Playwright | Selenium | |---|---|---| | Auto-wait for elements | ✅ Built-in | ❌ Manual waits required | | Multiple browser engines | Chromium, Firefox, WebKit | Chrome, Firefox, Edge | | Network interception | ✅ Easy | ❌ Complex | | Persistent sessions | ✅ Storage state API | ❌ Cookie workarounds | | Mobile emulation | ✅ First-class | ❌ Limited | | Speed | Fast | Slower |

The persistent sessions feature is critical for social media: Playwright saves login cookies to a JSON file, so you log in once and reuse the session forever.

Installation

pip install playwright
playwright install chromium  # download browser binary

# For headed mode debugging
pip install playwright pytest-playwright

Session Management: Log In Once, Post Forever

The foundation of multi-account automation is storing sessions:

import asyncio
from playwright.async_api import async_playwright
import json
from pathlib import Path

SESSIONS_DIR = Path("./sessions")
SESSIONS_DIR.mkdir(exist_ok=True)

async def save_session(account_name: str, platform: str):
    """
    Launch browser, let user log in manually, save session.
    Run this once per account.
    """
    session_file = SESSIONS_DIR / f"{platform}_{account_name}.json"
    
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)  # headed for manual login
        context = await browser.new_context()
        page = await context.new_page()
        
        urls = {
            "instagram": "https://www.instagram.com/",
            "tiktok": "https://www.tiktok.com/",
            "facebook": "https://www.facebook.com/",
            "youtube": "https://studio.youtube.com/"
        }
        
        await page.goto(urls[platform])
        
        print(f"Log into {platform} as {account_name}. Press Enter when done...")
        input()
        
        # Save cookies and localStorage
        storage_state = await context.storage_state()
        session_file.write_text(json.dumps(storage_state))
        print(f"Session saved to {session_file}")
        
        await browser.close()


async def load_session(account_name: str, platform: str, playwright):
    """Load a saved session and return a ready browser context."""
    session_file = SESSIONS_DIR / f"{platform}_{account_name}.json"
    
    if not session_file.exists():
        raise FileNotFoundError(f"No session for {platform}/{account_name}. Run save_session first.")
    
    browser = await playwright.chromium.launch(headless=True)
    context = await browser.new_context(
        storage_state=str(session_file),
        viewport={"width": 1280, "height": 800},
        user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
    )
    
    return browser, context

Instagram Reel Upload

async def post_instagram_reel(
    account_name: str,
    video_path: str,
    caption: str,
    cover_frame_time: float = 1.0
):
    async with async_playwright() as p:
        browser, context = await load_session(account_name, "instagram", p)
        page = await context.new_page()
        
        await page.goto("https://www.instagram.com/")
        await page.wait_for_load_state("networkidle")
        
        # Click the + (Create) button
        create_btn = page.locator('[aria-label="New post"]')
        await create_btn.click()
        
        # Click "Reel" option
        await page.locator('text=Reel').click()
        
        # Upload video file
        async with page.expect_file_chooser() as fc_info:
            await page.locator('text=Select from computer').click()
        file_chooser = await fc_info.value
        await file_chooser.set_files(video_path)
        
        # Wait for upload + processing
        await page.wait_for_selector('[aria-label="Trim"]', timeout=60000)
        
        # Click Next (twice: trim screen, then settings screen)
        await page.locator('text=Next').click()
        await page.wait_for_timeout(1000)
        await page.locator('text=Next').click()
        
        # Fill caption
        caption_box = page.locator('[aria-label="Write a caption..."]')
        await caption_box.click()
        await caption_box.type(caption, delay=30)  # human-like typing speed
        
        # Share
        await page.locator('text=Share').click()
        
        # Wait for confirmation
        await page.wait_for_selector('text=Your reel has been shared', timeout=120000)
        
        print(f"✓ Instagram Reel posted for @{account_name}")
        await browser.close()

TikTok Upload

async def post_tiktok_video(
    account_name: str,
    video_path: str,
    caption: str,
    hashtags: list[str] = None
):
    hashtag_string = " ".join(f"#{tag}" for tag in (hashtags or []))
    full_caption = f"{caption}\n\n{hashtag_string}".strip()
    
    async with async_playwright() as p:
        browser, context = await load_session(account_name, "tiktok", p)
        page = await context.new_page()
        
        await page.goto("https://www.tiktok.com/upload")
        await page.wait_for_load_state("domcontentloaded")
        
        # Upload via file input
        file_input = page.locator('input[type="file"]')
        await file_input.set_files(video_path)
        
        # Wait for video processing
        await page.wait_for_selector('.upload-progress-bar', state="hidden", timeout=120000)
        
        # Fill caption
        caption_input = page.locator('[data-text="true"]').first
        await caption_input.click()
        await caption_input.type(full_caption)
        
        # Post
        await page.locator('button:text("Post")').click()
        
        # Wait for success redirect
        await page.wait_for_url("**/profile**", timeout=60000)
        
        print(f"✓ TikTok posted for @{account_name}")
        await browser.close()

Multi-Account, Multi-Platform Orchestrator

import asyncio
from dataclasses import dataclass
from typing import Optional

@dataclass
class PostJob:
    platform: str
    account: str
    video_path: str
    caption: str
    hashtags: list[str] = None

async def run_post_job(job: PostJob) -> dict:
    """Run a single post job, return result."""
    try:
        if job.platform == "instagram":
            await post_instagram_reel(job.account, job.video_path, job.caption)
        elif job.platform == "tiktok":
            await post_tiktok_video(job.account, job.video_path, job.caption, job.hashtags)
        # add youtube, facebook handlers similarly
        
        return {"platform": job.platform, "account": job.account, "status": "success"}
    except Exception as e:
        return {"platform": job.platform, "account": job.account, 
                "status": "failed", "error": str(e)}


async def broadcast_post(
    video_path: str,
    caption: str,
    hashtags: list[str],
    accounts: list[dict]
):
    """Post to multiple platform/account combos concurrently."""
    
    jobs = [
        PostJob(
            platform=acc["platform"],
            account=acc["account"],
            video_path=video_path,
            caption=caption,
            hashtags=hashtags
        )
        for acc in accounts
    ]
    
    # Run all posts concurrently (each opens its own browser)
    results = await asyncio.gather(*[run_post_job(job) for job in jobs])
    
    # Report
    for result in results:
        status = "✓" if result["status"] == "success" else "✗"
        print(f"{status} {result['platform']} / {result['account']}: {result['status']}")
    
    return results


# Example: post to all accounts
asyncio.run(broadcast_post(
    video_path="./export/final_reel.mp4",
    caption="New video dropping 🔥 Full breakdown in the link",
    hashtags=["ai", "tech", "automation", "python"],
    accounts=[
        {"platform": "instagram", "account": "nepa_ai"},
        {"platform": "tiktok", "account": "billy_kennedy_bmx"},
        {"platform": "instagram", "account": "billy_kennedy_bmx"},
    ]
))

Rate Limiting and Anti-Detection

Platforms monitor for bot-like behavior. Mitigations:

import random
import asyncio

async def human_delay(min_ms: int = 500, max_ms: int = 2000):
    """Random delay to simulate human interaction timing."""
    delay = random.uniform(min_ms, max_ms) / 1000
    await asyncio.sleep(delay)


async def human_type(element, text: str):
    """Type with randomized speed to simulate human typing."""
    for char in text:
        await element.type(char, delay=random.uniform(50, 150))
        if char in '.!?':
            await asyncio.sleep(random.uniform(0.3, 0.8))


# Space out posts across accounts
async def staggered_broadcast(jobs: list[PostJob], stagger_seconds: int = 30):
    """Post to accounts with delay between each to avoid suspicious burst patterns."""
    results = []
    for i, job in enumerate(jobs):
        if i > 0:
            print(f"Waiting {stagger_seconds}s before next post...")
            await asyncio.sleep(stagger_seconds)
        result = await run_post_job(job)
        results.append(result)
    return results

Scheduling

Combine with APScheduler for time-based posting:

from apscheduler.schedulers.asyncio import AsyncIOScheduler

scheduler = AsyncIOScheduler()

# Schedule a post for 9am EST daily
scheduler.add_job(
    broadcast_post,
    'cron',
    hour=9, minute=0,
    timezone='America/New_York',
    kwargs={
        "video_path": "./queue/next_post.mp4",
        "caption": "Daily post",
        "hashtags": ["daily"],
        "accounts": [{"platform": "instagram", "account": "nepa_ai"}]
    }
)

scheduler.start()

The NEPA AI Social Poster Pack is a production-ready version of this system: session management, retry logic, queue management, and multi-account orchestration pre-built and tested across Instagram, TikTok, YouTube, Facebook, and Pinterest.

→ Get the Social Poster Pack at /shop/social-poster-pack

Post everywhere, once, automatically.