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>
This commit is contained in:
@@ -22,6 +22,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
@@ -50,9 +51,10 @@ def _http_get_json(url, timeout=5.0):
|
|||||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
|
port = urllib.parse.urlsplit(url).port
|
||||||
raise CDPError(
|
raise CDPError(
|
||||||
f"CDP endpoint unreachable at {url}: {exc}. "
|
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
|
) from exc
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise CDPError(f"CDP endpoint {url} returned non-JSON: {exc}") from 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"])
|
return await self._attach(info["targetId"])
|
||||||
raise CDPError(f"no open tab whose URL contains {url_contains!r}")
|
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.
|
"""Reuse a warm existing tab if one matches, otherwise open a fresh one.
|
||||||
Returns (tab, reused: bool). Caller should skip tab.close() when reused.
|
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
|
A reused tab is brought to the front by default — see Tab.activate() for
|
||||||
backgrounded tab cannot be trusted to have rendered.
|
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:
|
try:
|
||||||
tab = await self.attach_existing(url_contains)
|
tab = await self.attach_existing(url_contains)
|
||||||
await tab.activate()
|
if activate:
|
||||||
|
await tab.activate()
|
||||||
return tab, True
|
return tab, True
|
||||||
except CDPError:
|
except CDPError:
|
||||||
tab = await self.open_tab(url)
|
tab = await self.open_tab(url)
|
||||||
|
|||||||
+12
-5
@@ -80,15 +80,22 @@ async def _flow(tab, text, media_path, visibility):
|
|||||||
|
|
||||||
|
|
||||||
async def check_login(cdp_client):
|
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:
|
if reused:
|
||||||
# Dismiss any leftover dialog (e.g. unsaved Create Post from a failed run)
|
# Dismiss any leftover dialog (e.g. unsaved Create Post from a failed run)
|
||||||
await tab.eval("window.onbeforeunload = null")
|
await tab.eval("window.onbeforeunload = null")
|
||||||
await tab.navigate(S["url"])
|
await tab.navigate(S["url"])
|
||||||
await asyncio.sleep(4)
|
|
||||||
if await tab.exists(S["login_marker"]):
|
if await tab.exists(S["login_marker"]):
|
||||||
return False
|
return False
|
||||||
return await tab.eval(
|
# JS state, not the composer's "on your mind" span: that string is
|
||||||
"(() => !![...document.querySelectorAll('span')].find(s => s.innerText"
|
# localised, A/B'd, and absent until the feed renders, so a signed-in
|
||||||
" && s.innerText.toLowerCase().includes('on your mind')))()"
|
# 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")
|
||||||
|
|||||||
+13
-3
@@ -99,11 +99,21 @@ async def _wait_for_share(tab):
|
|||||||
|
|
||||||
|
|
||||||
async def check_login(cdp_client):
|
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:
|
if reused:
|
||||||
await tab.eval("window.onbeforeunload = null")
|
await tab.eval("window.onbeforeunload = null")
|
||||||
await tab.navigate(S["url"])
|
await tab.navigate(S["url"])
|
||||||
await asyncio.sleep(5)
|
|
||||||
if await tab.exists(S["login_marker"]):
|
if await tab.exists(S["login_marker"]):
|
||||||
return False
|
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")
|
||||||
|
|||||||
+13
-6
@@ -103,18 +103,25 @@ async def _wait_for_post_complete(tab, timeout=30.0):
|
|||||||
|
|
||||||
|
|
||||||
async def check_login(cdp_client):
|
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:
|
if reused:
|
||||||
await tab.eval("window.onbeforeunload = null")
|
await tab.eval("window.onbeforeunload = null")
|
||||||
await tab.navigate(S["url"])
|
await tab.navigate(S["url"])
|
||||||
await asyncio.sleep(4)
|
|
||||||
if await tab.exists(S["login_marker"]):
|
if await tab.exists(S["login_marker"]):
|
||||||
return False
|
return False
|
||||||
return await tab.eval(
|
# JS state, not the "What's new?" composer: that composer never mounts in a
|
||||||
"!!(document.querySelector('[placeholder=\"What\\'s new?\"]')"
|
# backgrounded tab, so a signed-in account read as signed out (measured
|
||||||
" || [...document.querySelectorAll('span,div')]"
|
# 2026-09-12 — DOM signal False while the session cookie was right there).
|
||||||
".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))"
|
# 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):
|
async def get_notifications(cdp_client, count=10):
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ async def _wait_for_success(tab, timeout=120.0):
|
|||||||
|
|
||||||
|
|
||||||
async def check_login(cdp_client):
|
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")
|
tab, reused = await cdp_client.get_or_open_tab(S["url"], "tiktok.com")
|
||||||
if reused:
|
if reused:
|
||||||
await tab.eval("window.onbeforeunload = null")
|
await tab.eval("window.onbeforeunload = null")
|
||||||
|
|||||||
+14
-6
@@ -103,19 +103,27 @@ async def _wait_for_post_complete(tab, timeout=30.0):
|
|||||||
|
|
||||||
|
|
||||||
async def check_login(cdp_client):
|
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:
|
if reused:
|
||||||
await tab.eval("window.onbeforeunload = null")
|
await tab.eval("window.onbeforeunload = null")
|
||||||
await tab.navigate(S["url"])
|
await tab.navigate(S["url"])
|
||||||
await asyncio.sleep(4)
|
|
||||||
# If we see login markers, not logged in
|
# If we see login markers, not logged in
|
||||||
if await tab.exists(S["login_marker"]):
|
if await tab.exists(S["login_marker"]):
|
||||||
return False
|
return False
|
||||||
# If we see the home timeline composer, we're in
|
# JS state, not the composer. The DOM signal does mount in a background tab,
|
||||||
return await tab.eval(
|
# but only once React has painted — dropping the old sleep(4) made it read
|
||||||
"!!(document.querySelector('[data-testid=\"tweetTextarea_0\"]')"
|
# OUT on a signed-in account the moment five tabs navigated at once
|
||||||
" || document.querySelector('[data-testid=\"SideNav_NewTweet_Button\"]'))"
|
# (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 ────────────────────────────────────────────────
|
# ── Scrape tweets from a page ────────────────────────────────────────────────
|
||||||
|
|||||||
+12
-5
@@ -93,14 +93,21 @@ async def _wait_for_editor_empty(tab, timeout=30.0):
|
|||||||
|
|
||||||
|
|
||||||
async def check_login(cdp_client):
|
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:
|
if reused:
|
||||||
await tab.eval("window.onbeforeunload = null")
|
await tab.eval("window.onbeforeunload = null")
|
||||||
await tab.navigate(S["url"])
|
await tab.navigate(S["url"])
|
||||||
await asyncio.sleep(5)
|
|
||||||
if await tab.exists(S["login_marker"]):
|
if await tab.exists(S["login_marker"]):
|
||||||
return False
|
return False
|
||||||
return await tab.eval(
|
# JS state, not the masthead DOM: #avatar-btn needs a fully rendered
|
||||||
"!!document.querySelector('#avatar-btn, button#avatar-btn,"
|
# foreground tab and reads absent for a signed-in account otherwise.
|
||||||
" ytd-topbar-menu-button-renderer #avatar-btn')"
|
# 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")
|
||||||
|
|||||||
Reference in New Issue
Block a user