Files
socialmag/platforms/facebook.py
T
juxtapoandClaude Opus 5 5419b08264 v0.4: background reads, JS-state login signals, blind sleeps dropped
get_or_open_tab(activate=True) — added, not replaced. Read paths pass
activate=False; post flows keep the foreground for dialogs and file inputs.

check_login moved off DOM signals onto session state: fb c_user, ig/threads
ds_user_id, twitter twid, yt ytcfg.LOGGED_IN. Three platforms were reporting
OUT on a signed-in account because their composer never mounts in a hidden
tab. The fixed sleep(4)/sleep(5) before each check goes with them — every
signal reads present the moment navigate() returns.

Unreadable cookies raise instead of returning False: "cannot tell" is not
"signed out". tiktok untouched and still activated — it reported
visibilityState "visible" from a sibling-activated tab, so it has never been
read from a genuine background.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 01:58:25 +08:00

102 lines
4.0 KiB
Python

"""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", activate=False)
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"])
if await tab.exists(S["login_marker"]):
return False
# JS state, not the composer's "on your mind" span: that string is
# localised, A/B'd, and absent until the feed renders, so a signed-in
# account reads as signed out. The c_user cookie is the session itself.
state = await tab.eval(
"(() => { const m = document.cookie.match(/(?:^|;\\s*)c_user=(\\d+)/);"
" return m ? 'in:' + m[1] : (document.cookie ? 'out' : 'unavailable'); })()"
)
if state.startswith("in:"):
return True
if state == "out":
return False
raise CDPError("fb: no cookies readable on facebook.com — cannot tell signed-in from signed-out")