socialmag v0.1 — 4/5 platforms live (Threads, FB, IG, TikTok)

This commit is contained in:
2026-09-10 21:10:55 +08:00
commit a4f33f456e
28 changed files with 2053 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Platform modules.
Each module exposes two coroutines with a fixed shape:
async def post(cdp_client, text, media_path, options) -> dict
-> {"status": "ok" | "error", "detail": "..."} (+ "screenshot" on error)
async def check_login(cdp_client) -> bool
`post` owns its own tab: it opens one, drives the flow, closes it. The queue
guarantees only one of these runs at a time, so there is one browser and one
tab in play (proposal: "sequential per-platform").
"""
import os
import time
import traceback
from ..cdp import CDPError
SCREENSHOT_DIR = "/tmp/socialmag"
def screenshot_path(platform):
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
return os.path.join(
SCREENSHOT_DIR, f"{platform}-{time.strftime('%Y%m%d-%H%M%S')}.png"
)
async def guard(platform, tab, coro):
"""Run one platform flow, turning any failure into a labelled result plus a
screenshot of the page as it actually looked. Never masks the failure."""
try:
await coro
return {"status": "ok", "detail": f"{platform}: posted"}
except CDPError as exc:
shot = None
try:
shot = await tab.screenshot(screenshot_path(platform))
except Exception: # the page may be gone; the original error still wins
pass
result = {"status": "error", "detail": f"{platform}: {exc}"}
if shot:
result["screenshot"] = shot
return result
except Exception as exc: # noqa: BLE001 — surface it, never swallow it
return {
"status": "error",
"detail": f"{platform}: unexpected {type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
}
def require_media(platform, media_path):
"""ig/tiktok cannot post without media; nobody gets a silent no-op."""
if not media_path:
raise CDPError(f"{platform} requires a media file; none was given")
validate_media(media_path)
def validate_media(media_path):
if not media_path:
return
if not os.path.isabs(media_path):
raise CDPError(f"media path must be absolute, got {media_path!r}")
if not os.path.exists(media_path):
raise CDPError(f"media file does not exist: {media_path}")
def require_text(platform, text):
if not text or not text.strip():
raise CDPError(f"{platform} requires non-empty text")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
"""Facebook posting flow.
1. navigate facebook.com
2. JS-click the composer: find the "on your mind" span, walk up to the
enclosing [role=button], .click() it
⚠️ A real mouse click on the span opens Messenger instead.
3. wait for the "Create post" dialog
4. type into the dialog textbox
5. optional: Photo/video -> setFileInputFiles
6. optional: change audience when visibility != the current one
7. click [aria-label="Post"]
8. dialog closes = success
"""
import asyncio
from ..cdp import CDPError, human_delay
from . import guard, require_text, validate_media
from ..selectors import FACEBOOK as S
PLATFORM = "fb"
VALID_VISIBILITY = ("public", "friends")
async def post(cdp_client, text, media_path=None, options=None):
options = options or {}
require_text(PLATFORM, text)
validate_media(media_path)
visibility = options.get("visibility")
if visibility is not None and visibility not in VALID_VISIBILITY:
raise CDPError(
f"fb visibility must be one of {VALID_VISIBILITY}, got {visibility!r}"
)
tab, reused = await cdp_client.get_or_open_tab(S["url"], "facebook.com")
if reused:
# Dismiss any leftover dialog (e.g. unsaved Create Post from a failed run)
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
return await guard(PLATFORM, tab, _flow(tab, text, media_path, visibility))
async def _flow(tab, text, media_path, visibility):
await asyncio.sleep(5)
await tab.click_js(S["composer_trigger_js"])
await tab.wait_for(S["dialog_textbox"], timeout=30.0)
await tab.type_text(S["dialog_textbox"], text)
if media_path:
# TODO: VALIDATE LIVE — whether the hidden file input is already in the
# DOM before the Photo/video button is clicked.
if not await tab.exists(S["file_input"]):
await tab.click_js(S["photo_video_js"])
await tab.wait_for(S["file_input"], visible=False)
await tab.set_file_input(S["file_input"], media_path)
# TODO: VALIDATE LIVE — upload/preview settle time.
await human_delay(2.0, 3.5)
if visibility:
# TODO: VALIDATE LIVE — the whole audience sub-dialog. We only open the
# picker here; the option list itself is unmapped, so we refuse rather
# than click something we haven't seen.
raise CDPError(
"fb visibility switching is not implemented: the audience sub-dialog "
"has not been mapped live yet. Post without `visibility` to use the "
"account's current default."
)
# Try aria-label first, fall back to text-match JS click
if await tab.exists(S["post_button"]):
await tab.click(S["post_button"])
else:
await tab.click_js(S["post_button_js"])
# FB keeps hidden [role="dialog"] elements in the DOM after the composer
# closes. Instead of waiting for all dialogs to vanish, check that the
# composer textbox is gone — that's the real "posted" signal.
await tab.wait_for_gone(S["dialog_textbox"], timeout=60.0)
async def check_login(cdp_client):
tab, reused = await cdp_client.get_or_open_tab(S["url"], "facebook.com")
if reused:
# Dismiss any leftover dialog (e.g. unsaved Create Post from a failed run)
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
await asyncio.sleep(4)
if await tab.exists(S["login_marker"]):
return False
return await tab.eval(
"(() => !![...document.querySelectorAll('span')].find(s => s.innerText"
" && s.innerText.toLowerCase().includes('on your mind')))()"
)
+109
View File
@@ -0,0 +1,109 @@
"""Instagram posting flow (media required).
1. navigate instagram.com
2. click svg[aria-label="New post"]
3. wait for the "Create new post" dialog
4. setFileInputFiles on the dialog's file input (bypasses the picker)
5. crop screen -> Next
6. filter screen -> Next
7. caption screen -> type caption
8. Share
9. dialog closes = success
"""
import asyncio
import json
from ..cdp import CDPError, human_delay
from . import guard, require_media
from ..selectors import INSTAGRAM as S
PLATFORM = "ig"
async def post(cdp_client, text, media_path=None, options=None):
options = options or {}
require_media(PLATFORM, media_path)
tab, reused = await cdp_client.get_or_open_tab(S["url"], "instagram.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
return await guard(PLATFORM, tab, _flow(tab, text or "", media_path))
async def _flow(tab, caption, media_path):
await tab.wait_for(S["new_post_svg"])
await human_delay()
# JS click — walks up to the clickable parent of the SVG icon
await tab.eval(S["new_post_js"])
await human_delay()
await tab.wait_for(S["dialog"])
await tab.wait_for_text(S["create_dialog_text"])
# The file input is present but hidden — visible=False on purpose.
await tab.wait_for(S["file_input"], visible=False)
await tab.set_file_input(S["file_input"], media_path)
# TODO: VALIDATE LIVE — how long IG takes to move from picker to crop, and
# whether the crop step is skipped for already-square media.
await human_delay(2.0, 4.0)
# Crop -> Next, filter -> Next. Same button text on both screens, so we
# click it twice with a settle between; if a screen is skipped the second
# Next simply won't be there and we fail loudly rather than guessing.
for step in ("crop", "filter"):
await _click_next(tab, step)
await human_delay(1.0, 2.0)
await tab.wait_for(S["caption_box"])
if caption:
await tab.type_text(S["caption_box"], caption)
await tab.click_js(S["share_button_js"])
# TODO: VALIDATE LIVE — IG shows "Your post has been shared" before the
# dialog closes; either signal is success, whichever lands first.
await _wait_for_share(tab)
async def _click_next(tab, step):
deadline = asyncio.get_running_loop().time() + 20.0
while asyncio.get_running_loop().time() < deadline:
found = await tab.eval("!!(%s)" % S["next_button_js"])
if found:
await tab.click_js(S["next_button_js"])
return
await asyncio.sleep(0.25)
raise CDPError(f"ig: no 'Next' button on the {step} screen after 20s")
async def _wait_for_share(tab):
deadline = asyncio.get_running_loop().time() + 90.0
while asyncio.get_running_loop().time() < deadline:
if await tab.eval(
"document.body.innerText.includes(%s)" % json.dumps(S["shared_text"])
):
# Click "Done" to dismiss the confirmation dialog
await human_delay(0.5, 1.0)
try:
await tab.click_js(S["done_button_js"])
except Exception:
pass
return
if not await tab.exists(S["dialog"]):
return
await asyncio.sleep(0.5)
raise CDPError("ig: no share confirmation and the dialog stayed open for 90s")
async def check_login(cdp_client):
tab, reused = await cdp_client.get_or_open_tab(S["url"], "instagram.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
await asyncio.sleep(5)
if await tab.exists(S["login_marker"]):
return False
return await tab.exists(S["new_post_svg"])
+119
View File
@@ -0,0 +1,119 @@
"""Threads posting flow (2026-09 inline composer).
1. navigate threads.com
2. click the inline "What's new?" composer
3. type text
4. click Post button
5. text clearing / post appearing = success
"""
import asyncio
from ..cdp import human_delay
from . import guard, require_text, validate_media
from ..selectors import THREADS as S
PLATFORM = "threads"
async def post(cdp_client, text, media_path=None, options=None):
options = options or {}
require_text(PLATFORM, text)
validate_media(media_path)
tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
return await guard(PLATFORM, tab, _flow(tab, text, media_path))
async def _flow(tab, text, media_path):
# Wait for the inline composer to appear
await _wait_for_composer(tab)
await human_delay()
# Click the composer input area
await tab.click_js(S["composer_trigger_js"])
await human_delay(0.5, 1.0)
# Type into whichever input is now focused — could be a contenteditable
# or a textbox that appeared after clicking
textbox = await _find_active_input(tab)
await tab.type_text(textbox, text)
if media_path:
if not await tab.exists(S["file_input"]):
# Look for an attach media button near the composer
await human_delay()
if await tab.exists(S["file_input"]):
await tab.set_file_input(S["file_input"], media_path)
await human_delay(1.5, 3.0)
await human_delay(0.5, 1.0)
await tab.click_js(S["post_button_js"])
# Success = the composer returns to its empty placeholder state
await _wait_for_post_complete(tab)
async def _wait_for_composer(tab, timeout=20.0):
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
has_input = await tab.eval(
"!!(document.querySelector('[placeholder=\"What\\'s new?\"]')"
" || [...document.querySelectorAll('span,div')]"
".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))"
)
if has_input:
return
await asyncio.sleep(0.5)
from ..cdp import CDPError
raise CDPError("threads: composer ('What's new?') not found after 20s")
async def _find_active_input(tab):
"""After clicking the composer, find the actual text input element."""
# Check for contenteditable first (Threads often uses these)
for sel in ('[contenteditable="true"]', '[role="textbox"]', 'textarea',
'[placeholder="What\'s new?"]'):
if await tab.exists(sel):
return sel
from ..cdp import CDPError
raise CDPError("threads: no text input found after clicking composer")
async def _wait_for_post_complete(tab, timeout=30.0):
"""Wait for the post to go through — composer empties or a toast appears."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
# Check if the composer is back to empty placeholder state
empty = await tab.eval(
"(() => {"
" const ce = document.querySelector('[contenteditable=\"true\"]');"
" if (ce && ce.innerText.trim() === '') return true;"
" const tb = document.querySelector('[role=\"textbox\"]');"
" if (tb && tb.innerText.trim() === '') return true;"
" return false;"
"})()"
)
if empty:
return
await asyncio.sleep(0.5)
from ..cdp import CDPError
raise CDPError("threads: composer still has text 30s after clicking Post")
async def check_login(cdp_client):
tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
await asyncio.sleep(4)
if await tab.exists(S["login_marker"]):
return False
return await tab.eval(
"!!(document.querySelector('[placeholder=\"What\\'s new?\"]')"
" || [...document.querySelectorAll('span,div')]"
".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))"
)
+130
View File
@@ -0,0 +1,130 @@
"""TikTok upload flow (media required).
1. navigate tiktokstudio/upload
2. wait for the upload page
3. photos: switch to the Photos tab first
4. setFileInputFiles on the matching input[type=file]
5. wait for processing
6. type the caption
7. click Post/Publish
8. wait for the success confirmation
TikTok is the most bot-sensitive of the five (proposal: ⭐⭐⭐⭐). Delays here
are deliberately longer than elsewhere.
"""
import asyncio
import json
import os
from ..cdp import CDPError, human_delay
from . import guard, require_media
from ..selectors import TIKTOK as S
PLATFORM = "tiktok"
VIDEO_EXTS = {".mp4", ".mov", ".webm", ".avi", ".mkv"}
PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".heic"}
async def post(cdp_client, text, media_path=None, options=None):
options = options or {}
require_media(PLATFORM, media_path)
ext = os.path.splitext(media_path)[1].lower()
if ext in VIDEO_EXTS:
kind = "video"
elif ext in PHOTO_EXTS:
kind = "photo"
else:
raise CDPError(
f"tiktok: unrecognised media extension {ext!r} — "
f"videos {sorted(VIDEO_EXTS)}, photos {sorted(PHOTO_EXTS)}"
)
tab, reused = await cdp_client.get_or_open_tab(S["url"], "tiktok.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
return await guard(PLATFORM, tab, _flow(tab, text or "", media_path, kind))
async def _flow(tab, caption, media_path, kind):
await tab.wait_for_load(timeout=45.0)
await human_delay(1.5, 3.0)
if kind == "photo":
# TODO: VALIDATE LIVE — the Photos tab switch and the input it reveals.
await _wait_for_js(tab, S["photos_tab_js"], "the Photos tab", timeout=30.0)
await tab.click_js(S["photos_tab_js"])
await human_delay(1.0, 2.0)
file_input = S["photo_file_input"]
else:
file_input = S["video_file_input"]
await tab.wait_for(file_input, visible=False, timeout=45.0)
await tab.set_file_input(file_input, media_path)
# Step 5: TikTok transcodes before it will let you post.
# TODO: VALIDATE LIVE — the real "upload finished" marker. Right now we
# wait for the Post button to become enabled, which is the behaviour the
# flow actually depends on.
await _wait_for_js(
tab, S["post_button_js"], "an enabled Post button", timeout=300.0
)
if caption:
await tab.wait_for(S["caption_box"])
# DraftJS: type per-char so its keypress handlers fire.
# TODO: VALIDATE LIVE — whether insertText is accepted here.
await tab.type_text(S["caption_box"], caption, per_char=True)
await human_delay(1.0, 2.0)
await tab.click_js(S["post_button_js"])
# TikTok may show a copyright check dialog — dismiss it
await asyncio.sleep(3)
try:
has_continue = await tab.eval("!!(%s)" % S["continue_post_js"])
if has_continue:
await tab.click_js(S["continue_post_js"])
except Exception:
pass
await _wait_for_success(tab)
async def _wait_for_js(tab, expression, what, timeout=30.0):
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
if await tab.eval("!!(%s)" % expression):
return
await asyncio.sleep(0.5)
raise CDPError(f"tiktok: {what} did not appear within {timeout}s")
async def _wait_for_success(tab, timeout=120.0):
# TODO: VALIDATE LIVE — the confirmation copy. TikTok also redirects to
# /tiktokstudio/content on success, which we accept as the same signal.
deadline = asyncio.get_running_loop().time() + timeout
marker = json.dumps(S["success_text"])
while asyncio.get_running_loop().time() < deadline:
if await tab.eval(f"document.body.innerText.includes({marker})"):
return
if "tiktokstudio/content" in (await tab.url() or ""):
return
await asyncio.sleep(1.0)
raise CDPError(
f"tiktok: no success confirmation within {timeout}s of clicking Post"
)
async def check_login(cdp_client):
tab, reused = await cdp_client.get_or_open_tab(S["url"], "tiktok.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
await asyncio.sleep(5)
if await tab.exists(S["login_marker"]):
return False
return await tab.exists(S["video_file_input"])
+106
View File
@@ -0,0 +1,106 @@
"""YouTube community post flow.
1. navigate youtube.com
2. click button[aria-label="Create"]
3. click "Create post" in the dropdown
4. wait for the redirect to the channel posts page
5. type into #contenteditable-root
6. click Post
7. the editor emptying / the post appearing = success
Community posts require a channel that has them enabled. Video upload
(post_youtube_video in the proposal) is NOT built here — the brief scoped this
module to the community post.
"""
import asyncio
from ..cdp import CDPError, human_delay
from . import guard, require_text, validate_media
from ..selectors import YOUTUBE as S
PLATFORM = "yt"
async def post(cdp_client, text, media_path=None, options=None):
options = options or {}
require_text(PLATFORM, text)
validate_media(media_path)
if media_path:
# Community posts do take images, but that flow is unmapped. Refuse
# rather than drop the media on the floor.
raise CDPError(
"yt: media on community posts is not implemented (the image-attach "
"flow has not been mapped live). Post text-only, or use a platform "
"whose media flow is built."
)
tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
return await guard(PLATFORM, tab, _flow(tab, text))
async def _flow(tab, text):
await tab.wait_for(S["create_button"])
await human_delay()
await tab.click(S["create_button"])
# TODO: VALIDATE LIVE — the dropdown item text and element type.
await _wait_for_js(tab, S["create_post_item_js"], "the 'Create post' menu item")
await tab.click_js(S["create_post_item_js"])
# Step 4: YouTube navigates to the channel's posts page.
await tab.wait_for_load(timeout=30.0)
await tab.wait_for(S["editor"], timeout=30.0)
# #contenteditable-root is a YouTube custom editor: it listens to real key
# events, so type per-char rather than insertText.
# TODO: VALIDATE LIVE — whether insertText alone enables the Post button.
await tab.type_text(S["editor"], text, per_char=True)
if await tab.exists(S["post_button"]):
await tab.click(S["post_button"])
else:
await tab.click_js(S["post_button_js"])
# TODO: VALIDATE LIVE — success marker. The editor going empty is the
# cheapest observable signal; the proposal says "post appears".
await _wait_for_editor_empty(tab)
async def _wait_for_js(tab, expression, what, timeout=20.0):
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
if await tab.eval("!!(%s)" % expression):
return
await asyncio.sleep(0.25)
raise CDPError(f"yt: {what} did not appear within {timeout}s")
async def _wait_for_editor_empty(tab, timeout=30.0):
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
text = await tab.eval(
"(() => { const e = document.querySelector('%s');"
" return e ? e.innerText.trim() : null; })()" % S["editor"]
)
if text == "":
return
await asyncio.sleep(0.5)
raise CDPError("yt: editor still holds text 30s after clicking Post")
async def check_login(cdp_client):
tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
await asyncio.sleep(5)
if await tab.exists(S["login_marker"]):
return False
return await tab.eval(
"!!document.querySelector('#avatar-btn, button#avatar-btn,"
" ytd-topbar-menu-button-renderer #avatar-btn')"
)