"""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")