From 5419b08264c2ca7a9accd19c8183344b41428c62 Mon Sep 17 00:00:00 2001 From: Muhammad Aizat Date: Sun, 13 Sep 2026 01:58:25 +0800 Subject: [PATCH] v0.4: background reads, JS-state login signals, blind sleeps dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cdp.py | 19 ++++++++++++++----- platforms/facebook.py | 17 ++++++++++++----- platforms/instagram.py | 16 +++++++++++++--- platforms/threads.py | 19 +++++++++++++------ platforms/tiktok.py | 7 +++++++ platforms/twitter.py | 20 ++++++++++++++------ platforms/youtube.py | 17 ++++++++++++----- 7 files changed, 85 insertions(+), 30 deletions(-) diff --git a/cdp.py b/cdp.py index 212295d..6c0642b 100644 --- a/cdp.py +++ b/cdp.py @@ -22,6 +22,7 @@ import base64 import json import random import urllib.error +import urllib.parse import urllib.request import websockets @@ -50,9 +51,10 @@ def _http_get_json(url, timeout=5.0): with urllib.request.urlopen(url, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.URLError as exc: + port = urllib.parse.urlsplit(url).port raise CDPError( f"CDP endpoint unreachable at {url}: {exc}. " - "Is Brave running with --remote-debugging-port=9222?" + f"Is Brave running with --remote-debugging-port={port}?" ) from exc except json.JSONDecodeError as exc: raise CDPError(f"CDP endpoint {url} returned non-JSON: {exc}") from exc @@ -174,16 +176,23 @@ class CDPClient: return await self._attach(info["targetId"]) raise CDPError(f"no open tab whose URL contains {url_contains!r}") - async def get_or_open_tab(self, url, url_contains): + async def get_or_open_tab(self, url, url_contains, activate=True): """Reuse a warm existing tab if one matches, otherwise open a fresh one. Returns (tab, reused: bool). Caller should skip tab.close() when reused. - A reused tab is brought to the front — see Tab.activate() for why a - backgrounded tab cannot be trusted to have rendered. + A reused tab is brought to the front by default — see Tab.activate() for + why a backgrounded tab cannot be trusted to have rendered. + + `activate=False` is for callers that read a **JS-state** signal (a cookie, + a page global) or the network, neither of which needs a rendered page. + Measured 2026-09-12: X, Instagram and Threads all answer correctly with + `document.visibilityState == "hidden"`. It is not for the post flows — + those drive dialogs and file inputs, which need the foreground. """ try: tab = await self.attach_existing(url_contains) - await tab.activate() + if activate: + await tab.activate() return tab, True except CDPError: tab = await self.open_tab(url) diff --git a/platforms/facebook.py b/platforms/facebook.py index 9ca0765..3d6b1da 100644 --- a/platforms/facebook.py +++ b/platforms/facebook.py @@ -80,15 +80,22 @@ async def _flow(tab, text, media_path, visibility): async def check_login(cdp_client): - tab, reused = await cdp_client.get_or_open_tab(S["url"], "facebook.com") + 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"]) - 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')))()" + # 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") diff --git a/platforms/instagram.py b/platforms/instagram.py index 2baf93a..aa59a69 100644 --- a/platforms/instagram.py +++ b/platforms/instagram.py @@ -99,11 +99,21 @@ async def _wait_for_share(tab): async def check_login(cdp_client): - tab, reused = await cdp_client.get_or_open_tab(S["url"], "instagram.com") + tab, reused = await cdp_client.get_or_open_tab(S["url"], "instagram.com", activate=False) 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"]) + # JS state rather than the new-post SVG. The icon does survive a background + # tab today (measured 2026-09-12), but it is an icon — it moves when the nav + # is redesigned. The session cookie is the session. + state = await tab.eval( + "(() => { const m = document.cookie.match(/(?:^|;\\s*)ds_user_id=(\\d+)/);" + " return m ? 'in:' + m[1] : (document.cookie ? 'out' : 'unavailable'); })()" + ) + if state.startswith("in:"): + return True + if state == "out": + return False + raise CDPError("ig: no cookies readable on instagram.com — cannot tell signed-in from signed-out") diff --git a/platforms/threads.py b/platforms/threads.py index 76f7b22..bb2ad94 100644 --- a/platforms/threads.py +++ b/platforms/threads.py @@ -103,18 +103,25 @@ async def _wait_for_post_complete(tab, timeout=30.0): async def check_login(cdp_client): - tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com") + tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com", activate=False) 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?'))" + # JS state, not the "What's new?" composer: that composer never mounts in a + # backgrounded tab, so a signed-in account read as signed out (measured + # 2026-09-12 — DOM signal False while the session cookie was right there). + # Threads runs on the Instagram session, hence ds_user_id. + state = await tab.eval( + "(() => { const m = document.cookie.match(/(?:^|;\\s*)ds_user_id=(\\d+)/);" + " return m ? 'in:' + m[1] : (document.cookie ? 'out' : 'unavailable'); })()" ) + if state.startswith("in:"): + return True + if state == "out": + return False + raise CDPError("threads: no cookies readable on threads.com — cannot tell signed-in from signed-out") async def get_notifications(cdp_client, count=10): diff --git a/platforms/tiktok.py b/platforms/tiktok.py index eb0a4b0..2c667b0 100644 --- a/platforms/tiktok.py +++ b/platforms/tiktok.py @@ -120,6 +120,13 @@ async def _wait_for_success(tab, timeout=120.0): async def check_login(cdp_client): + # The only platform still activated for a login check. TikTok could not be + # backgrounded on 2026-09-12: a sibling tab in the *same* window was + # activated (Browser.getWindowForTarget agreed on windowId) and TikTok still + # reported visibilityState "visible" — own window, or it overrides the + # property to keep video playing. Its DOM signal has never lied, but it has + # never been read from a genuine background either. Three states, not two: + # this stays foreground until there is evidence, not until it looks fine. tab, reused = await cdp_client.get_or_open_tab(S["url"], "tiktok.com") if reused: await tab.eval("window.onbeforeunload = null") diff --git a/platforms/twitter.py b/platforms/twitter.py index 6dc5c1a..6f9277b 100644 --- a/platforms/twitter.py +++ b/platforms/twitter.py @@ -103,19 +103,27 @@ async def _wait_for_post_complete(tab, timeout=30.0): async def check_login(cdp_client): - tab, reused = await cdp_client.get_or_open_tab(S["url"], "x.com") + tab, reused = await cdp_client.get_or_open_tab(S["url"], "x.com", activate=False) if reused: await tab.eval("window.onbeforeunload = null") await tab.navigate(S["url"]) - await asyncio.sleep(4) # If we see login markers, not logged in if await tab.exists(S["login_marker"]): return False - # If we see the home timeline composer, we're in - return await tab.eval( - "!!(document.querySelector('[data-testid=\"tweetTextarea_0\"]')" - " || document.querySelector('[data-testid=\"SideNav_NewTweet_Button\"]'))" + # JS state, not the composer. The DOM signal does mount in a background tab, + # but only once React has painted — dropping the old sleep(4) made it read + # OUT on a signed-in account the moment five tabs navigated at once + # (measured 2026-09-12). `twid` carries the user id and is set at login, + # same shape as fb's c_user and ig's ds_user_id. + state = await tab.eval( + "(() => { const m = document.cookie.match(/(?:^|;\\s*)twid=([^;]+)/);" + " return m ? 'in:' + m[1] : (document.cookie ? 'out' : 'unavailable'); })()" ) + if state.startswith("in:"): + return True + if state == "out": + return False + raise CDPError("twitter: no cookies readable on x.com — cannot tell signed-in from signed-out") # ── Scrape tweets from a page ──────────────────────────────────────────────── diff --git a/platforms/youtube.py b/platforms/youtube.py index a548fe5..5455657 100644 --- a/platforms/youtube.py +++ b/platforms/youtube.py @@ -93,14 +93,21 @@ async def _wait_for_editor_empty(tab, timeout=30.0): async def check_login(cdp_client): - tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com") + tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com", activate=False) 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')" + # JS state, not the masthead DOM: #avatar-btn needs a fully rendered + # foreground tab and reads absent for a signed-in account otherwise. + # ytcfg is set by the page's own bootstrap and does not depend on render. + state = await tab.eval( + "(() => { try { return String(ytcfg.get('LOGGED_IN')); }" + " catch (e) { return 'unavailable'; } })()" ) + if state == "true": + return True + if state == "false": + return False + raise CDPError("yt: ytcfg.LOGGED_IN unavailable — cannot tell signed-in from signed-out")