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.