diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3481f57..9535376 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -30,7 +30,8 @@ image-use --backend web ├── click Send once and confirm a new message (never replay an uncertain send) ├── poll the page: wait until streaming stops AND a new asset is stable └── fetch the asset bytes in-page (credentials:'include') → base64 → save - (the signed estuary/content URL is authorized by the browser's own cookies) + (the signed estuary/content URL is authorized by the browser's own cookies; + the 2026-09 redesign serves a blob: URL instead, which only the page can read) ``` No tokens leave the browser. Each run's chat lands inside the `imagegen` Project (auto-created) instead of the top-level history; pass `--project ""` to opt out, and the conversation is deleted afterwards by default (`--keep-conversation` to keep it). diff --git a/docs/how-it-works.zh-CN.md b/docs/how-it-works.zh-CN.md index 1c14e1d..62daa5d 100644 --- a/docs/how-it-works.zh-CN.md +++ b/docs/how-it-works.zh-CN.md @@ -28,7 +28,8 @@ image-use --backend web ├── 用真实键盘输入打提示词 (ProseMirror/React 输入框不认纯 DOM 的 `fill`) ├── 轮询页面:等流结束 且 新的 资源稳定 └── 在页面内 fetch 资源字节 (credentials:'include') → base64 → 存盘 - (签名的 estuary/content URL 由浏览器自己的 cookie 授权) + (签名的 estuary/content URL 由浏览器自己的 cookie 授权; + 2026-09 改版后图片是 blob: URL,只有页面自己能读) ``` token 不出浏览器。每次出图落在 `imagegen` 项目里(自动创建),且**默认出图后删除该对话**不留历史(`--keep-conversation` 可保留);传 `--project ""` 可退回普通顶层对话。 diff --git a/image-use b/image-use index 018e44c..f16bf7d 100755 --- a/image-use +++ b/image-use @@ -212,6 +212,45 @@ WEB_PROJECT_URL = "https://chatgpt.com/g/{gizmo_id}/project" DEFAULT_PROJECT = "imagegen" # Image assets render as whose src points at one of these backend paths. WEB_IMG_SRC_RE = r"estuary/content|files/download|oaiusercontent" + +# chatgpt.com DOM selectors, shared by the page-side JS and the chrome-use +# commands. ChatGPT rolls UI redesigns out gradually (issue #41, 2026-09), so +# each list keeps the older layout's selector FIRST and adds the redesigned +# one after it — whichever the account is served, one of them matches. +# old: #prompt-textarea, button[data-testid=send-button], +# [data-message-author-role=user|assistant], estuary/content src +# new: a bare div.ProseMirror[contenteditable] in the composer
, a +# plain (localized) button[type=submit], turns as div[data-turn-key] +# holding [data-user-message-bubble] + h4[data-conversation-role= +# assistant], and results as blob: in +# [data-testid=generated-image-preview] +WEB_COMPOSER_SEL = '#prompt-textarea, form div.ProseMirror[contenteditable="true"]' +# Send, looked up INSIDE the composer form. The redesigned button has no testid +# and a localized label ("Send prompt", "发送", …), so match its type — but +# never the voice/dictation controls that share the submit-button slot. +WEB_SEND_SEL = ('button[data-testid="send-button"], button#composer-submit-button, ' + 'button[type="submit"]:not([aria-label*="voice" i])' + ':not([aria-label*="dictat" i]):not([aria-label*="语音"])') +# The Send click target: WEB_SEND_SEL scoped to the form holding the composer, +# so a submit button in some other form (a dialog, a search box) never matches. +WEB_SEND_CLICK_SEL = ", ".join( + f'form:has(div.ProseMirror[contenteditable="true"]) {s.strip()}' + for s in ('button[data-testid="send-button"]', "button#composer-submit-button", + 'button[type="submit"]:not([aria-label*="voice" i])' + ':not([aria-label*="dictat" i]):not([aria-label*="语音"])')) +# The stop-generating control; the label is localized in the redesign. +WEB_STOP_SEL = ('button[data-testid="stop-button"], button[aria-label*="Stop" i], ' + 'button[aria-label*="停止"], button[aria-label*="中止"]') +# A user's message: the old role marker or the redesigned bubble. +WEB_USER_MSG_SEL = '[data-message-author-role="user"], [data-user-message-bubble]' +# The redesigned thread's turn container, and the reply content inside one that +# proves the assistant has actually answered (its h4 role heading renders +# before the reply does, so the heading alone proves nothing). +WEB_TURN_SEL = "[data-turn-key]" +WEB_ASSISTANT_BODY_SEL = '[data-markdown-copy], [data-testid="generated-image-gallery"]' +# A generated result in the redesigned UI. Its src is a blob: URL (not matched +# by WEB_IMG_SRC_RE), which only the page itself can fetch. +WEB_GEN_IMG_SEL = '[data-testid="generated-image-preview"] img' # Composer model the web backend forces before generating. The "Pro" reasoning # tier has NO built-in image generator — it answers an image request by running # Code Interpreter (Python/PIL, a downloadable file) or flat-out refusing ("image @@ -1379,22 +1418,35 @@ def _ab_eval(ab: str, js: str, session: str, timeout: float) -> Any: # so don't be tempted to scope tighter than
. _JS_STATE = r"""(() => { const seen = new Set(%s); - const stop = !!document.querySelector( - 'button[data-testid="stop-button"], button[aria-label*="Stop" i]' - ); const re = new RegExp(%s); + const SEL = %s; + const stop = !!document.querySelector(SEL.stop); // The generated image renders in
but OUTSIDE any message bubble (it's // an image-generation card, not an assistant text turn — so we can't scope to // the assistant role). An img2img reference, however, is echoed INSIDE the // user bubble with a fresh estuary/content src not in the pre-submit baseline. // So detect fresh main images but exclude any that belong to a user message. const userSrcs = new Set(); - document.querySelectorAll('[data-message-author-role="user"] img') + document.querySelectorAll(SEL.user.split(',').map(s => s + ' img').join(',')) .forEach(i => { const s = i.currentSrc || i.src; if (s) userSrcs.add(s); }); + // Redesign (#41): the result is a blob: inside the generated-image + // preview; count it once it has actually decoded (a blob placeholder that + // hasn't loaded yet must not be taken as final). const fresh = [...document.querySelectorAll('main img')] + .filter(i => re.test(i.currentSrc || i.src) || + (i.matches(SEL.genImg) && i.complete && i.naturalWidth > 0)) .map(i => i.currentSrc || i.src) - .filter(s => re.test(s) && !seen.has(s) && !userSrcs.has(s)); - const a = document.querySelectorAll('[data-message-author-role="assistant"]'); + .filter(s => s && !seen.has(s) && !userSrcs.has(s)); + let a = [...document.querySelectorAll('[data-message-author-role="assistant"]')]; + if (!a.length) { + // Redesign: each turn container holds the user bubble AND the reply; the + // reply exists once it has rendered content (markdown or an image gallery). + const turns = [...document.querySelectorAll('main ' + SEL.turn)]; + const t = turns[turns.length - 1]; + const body = t && t.querySelector('[data-conversation-role="assistant"]') && + t.querySelector(SEL.body); + if (body) a = [body]; + } const lastA = a[a.length - 1]; const dlg = [...document.querySelectorAll('[role="dialog"]')] .map(d => d.textContent || '').join(' '); @@ -1406,26 +1458,64 @@ _JS_STATE = r"""(() => { _JS_BASELINE = r"""(() => { const re = new RegExp(%s); + const genImg = %s; const srcs = [...document.querySelectorAll('main img')] - .map(i => i.currentSrc || i.src).filter(s => re.test(s)); + .filter(i => re.test(i.currentSrc || i.src) || i.matches(genImg)) + .map(i => i.currentSrc || i.src).filter(Boolean); return JSON.stringify(srcs); })()""" + +def _js_state_selectors() -> str: + """The selector bundle _JS_STATE reads, as a JS object literal.""" + return json.dumps({"stop": WEB_STOP_SEL, "user": WEB_USER_MSG_SEL, + "genImg": WEB_GEN_IMG_SEL, "turn": WEB_TURN_SEL, + "body": WEB_ASSISTANT_BODY_SEL}, ensure_ascii=False) + # JS: fetch the asset bytes from inside the authenticated page and hand them # back base64-encoded along with the content type. credentials:'include' reuses # the browser's session cookies, so the signed/asset URL resolves. _JS_FETCH = r"""(async () => { - try { - const r = await fetch(%s, {credentials: 'include'}); - if (!r.ok) return JSON.stringify({ok: false, status: r.status}); - const buf = new Uint8Array(await (await r.blob()).arrayBuffer()); + const src = %s; + const pack = async (blob, type) => { + const buf = new Uint8Array(await blob.arrayBuffer()); let bin = ''; const CH = 0x8000; for (let i = 0; i < buf.length; i += CH) bin += String.fromCharCode.apply(null, buf.subarray(i, i + CH)); - return JSON.stringify({ok: true, type: r.headers.get('content-type'), + return JSON.stringify({ok: true, type: type || blob.type, bytes: buf.length, b64: btoa(bin)}); - } catch (e) { return JSON.stringify({ok: false, error: String(e)}); } + }; + // Last resort for a blob: src (redesign, #41) that ChatGPT revoked between + // detection and download: the decoded is still on the page, so + // re-encode its pixels losslessly. Same-origin blob, so the canvas isn't + // tainted. + const fromImg = async () => { + const img = [...document.querySelectorAll('img')] + .find(i => (i.currentSrc || i.src) === src && i.complete && i.naturalWidth > 0); + if (!img) return null; + const c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + c.getContext('2d').drawImage(img, 0, 0); + const b = await new Promise(res => c.toBlob(res, 'image/png')); + return b ? pack(b, 'image/png') : null; + }; + try { + // blob: URLs only resolve inside the page that minted them, which is why + // the bytes are fetched here and handed back base64 — never from outside. + const r = await fetch(src, src.startsWith('blob:') ? {} : {credentials: 'include'}); + if (!r.ok) { + const alt = src.startsWith('blob:') ? await fromImg() : null; + return alt || JSON.stringify({ok: false, status: r.status}); + } + return await pack(await r.blob(), r.headers.get('content-type')); + } catch (e) { + try { + const alt = src.startsWith('blob:') ? await fromImg() : null; + if (alt) return alt; + } catch (e2) {} + return JSON.stringify({ok: false, error: String(e)}); + } })()""" # JS: resolve a ChatGPT Project (a "snorlax" gizmo, id g-p-…) by exact display @@ -1476,7 +1566,7 @@ _JS_COMPOSER = r"""(() => { const dlg = [...document.querySelectorAll('[role="dialog"]')] .map(d => d.textContent || '').join(' '); return JSON.stringify({ - composer: !!document.querySelector('#prompt-textarea'), + composer: !!document.querySelector(%s), limited: /too many requests|requests too quickly/i.test(dlg), }); })()""" @@ -1488,14 +1578,14 @@ _RATE_LIMIT_MSG = ( def _wait_composer(ab: str, session: str, remaining, tries: int = 15) -> bool: - """Poll until the chat composer (#prompt-textarea) is on the page. + """Poll until the chat composer (WEB_COMPOSER_SEL) is on the page. Raises WebRateLimited if the page shows the "Too many requests" dialog — waiting out the poll budget (and then other profile candidates) would just burn ~45s each on an account-level block.""" for _ in range(tries): try: - st = _ab_eval(ab, _JS_COMPOSER, session=session, + st = _ab_eval(ab, _JS_COMPOSER % json.dumps(WEB_COMPOSER_SEL), session=session, timeout=min(20.0, remaining())) if isinstance(st, dict): if st.get("limited"): @@ -2130,7 +2220,8 @@ def _resolve_ref_path(ref: str) -> tuple[str, str | None]: # spacing) or textContent (which loses paragraph breaks). A ProseMirror trailing # BR is an empty-paragraph placeholder, not an extra newline. _JS_WEB_COMPOSER_STATE = r"""(() => { - const composer = document.querySelector('#prompt-textarea'); + const SEL = %s; + const composer = document.querySelector(SEL.composer); const form = composer && composer.closest('form'); const read = n => { if (n.nodeType === 3) return n.textContent; @@ -2147,7 +2238,17 @@ _JS_WEB_COMPOSER_STATE = r"""(() => { } const images = form ? [...form.querySelectorAll('img')] .filter(i => /blob:|estuary\/content|oaiusercontent/.test(i.src || '')) : []; - const send = form && form.querySelector('button[data-testid="send-button"]'); + const send = form && form.querySelector(SEL.send); + // A user message per turn. The redesign renders the whole thread twice, so + // key each message by its turn container and count distinct turns; a marker + // nested inside another marker (old role div around a bubble) is the same + // message, not a second one. + const turns = new Set(); + document.querySelectorAll(SEL.user).forEach(m => { + if (m.parentElement && m.parentElement.closest(SEL.user)) return; + const t = m.closest(SEL.turn); + turns.add(t ? 'k:' + t.getAttribute('data-turn-key') : m); + }); return JSON.stringify({ exists: !!composer, text, attachments: images.length, @@ -2156,14 +2257,21 @@ _JS_WEB_COMPOSER_STATE = r"""(() => { ready: images.filter(i => !i.src.startsWith('blob:')).length, busy: !!(form && form.querySelector('[role="progressbar"], [aria-busy="true"]')), send_ready: !!send && !send.disabled && send.getAttribute('aria-disabled') !== 'true', - user_turns: document.querySelectorAll('[data-message-author-role="user"]').length, + user_turns: turns.size, }); })()""" +def _js_composer_selectors() -> str: + """The selector bundle _JS_WEB_COMPOSER_STATE reads, as a JS object literal.""" + return json.dumps({"composer": WEB_COMPOSER_SEL, "send": WEB_SEND_SEL, + "user": WEB_USER_MSG_SEL, "turn": WEB_TURN_SEL}, + ensure_ascii=False) + + def _web_composer_state(ab, session, remaining) -> dict: """Read logical editor text and upload/send state, requiring a live composer.""" - state = _ab_eval(ab, _JS_WEB_COMPOSER_STATE, session=session, + state = _ab_eval(ab, _JS_WEB_COMPOSER_STATE % _js_composer_selectors(), session=session, timeout=min(15.0, remaining())) if not isinstance(state, dict) or not state.get("exists"): raise GatewayError("ChatGPT composer is unavailable; submission cannot be verified") @@ -2253,7 +2361,7 @@ _DRAFT_HINT = ("Clear the ChatGPT composer at https://chatgpt.com/ (a leftover d def _clear_web_composer(ab, session, remaining) -> None: """Best-effort: empty the composer so our own text is not left as a draft.""" try: - _ab(ab, "fill", "#prompt-textarea", "--stdin", input_text="", + _ab(ab, "fill", WEB_COMPOSER_SEL, "--stdin", input_text="", session=session, timeout=min(20.0, remaining())) except GatewayError: pass @@ -2275,7 +2383,7 @@ def _submit_web_prompt(ab, session, user_text, n_refs, remaining, emit) -> None: if initial.get("text"): raise GatewayError("ChatGPT composer is not empty; prompt not submitted. " + _DRAFT_HINT) emit("pasting prompt") - _ab(ab, "fill", "#prompt-textarea", "--stdin", input_text=user_text, + _ab(ab, "fill", WEB_COMPOSER_SEL, "--stdin", input_text=user_text, session=session, timeout=min(60.0, remaining())) # Everything below until the click is a "nothing was sent" failure. ChatGPT # keeps composer drafts across chats, so leaving our text in the box would @@ -2309,7 +2417,7 @@ def _submit_web_prompt(ab, session, user_text, n_refs, remaining, emit) -> None: emit(f"verified prompt ({len(user_text)} characters) and {n_refs} reference image(s)") send_error = None try: - _ab(ab, "click", 'button[data-testid="send-button"]', + _ab(ab, "click", WEB_SEND_CLICK_SEL, session=session, timeout=min(20.0, remaining())) except GatewayError as error: send_error = error @@ -2360,7 +2468,8 @@ def _generate_in_browser(ab, session, user_text, args, deadline, remaining, emit # Capture the baseline set of image srcs already on the page so we never # mistake a prior image — INCLUDING a just-uploaded reference, whose src is # also an estuary/content URL — for the new one. - baseline = _ab_eval(ab, _JS_BASELINE % json.dumps(WEB_IMG_SRC_RE), + baseline = _ab_eval(ab, _JS_BASELINE % (json.dumps(WEB_IMG_SRC_RE), + json.dumps(WEB_GEN_IMG_SEL)), session=session, timeout=min(20.0, remaining())) if not isinstance(baseline, list): baseline = [] @@ -2369,7 +2478,8 @@ def _generate_in_browser(ab, session, user_text, args, deadline, remaining, emit # Poll until the stream stops AND a brand-new image asset is present and # stable across two reads (guards against grabbing a mid-render partial). - state_js = _JS_STATE % (json.dumps(baseline), json.dumps(WEB_IMG_SRC_RE)) + state_js = _JS_STATE % (json.dumps(baseline), json.dumps(WEB_IMG_SRC_RE), + _js_state_selectors()) last_phase = "" final_src: str | None = None stable_src: str | None = None diff --git a/test_image_use.py b/test_image_use.py index 6d25542..c1d6013 100644 --- a/test_image_use.py +++ b/test_image_use.py @@ -10,6 +10,8 @@ """ import argparse +import base64 +import html.parser import importlib.machinery import importlib.util import inspect @@ -2512,9 +2514,9 @@ def test_long_multiline_prompt_uses_stdin_and_one_click(self): _composer_state(prompt, attachments=5), _composer_state(user_turns=1)], prompt) self.assertEqual(len(self.calls), 2) - self.assertEqual(self.calls[0][0], ("ab", "fill", "#prompt-textarea", "--stdin")) + self.assertEqual(self.calls[0][0], ("ab", "fill", cig.WEB_COMPOSER_SEL, "--stdin")) self.assertEqual(self.calls[0][1]["input_text"], prompt) - self.assertEqual(self.calls[1][0][1:], ("click", 'button[data-testid="send-button"]')) + self.assertEqual(self.calls[1][0][1:], ("click", cig.WEB_SEND_CLICK_SEL)) def test_changed_text_attachments_or_early_submission_prevents_send(self): """Stop before Send if prompt text, reference state, or user turns change.""" @@ -3478,5 +3480,318 @@ def test_version_matches_cli(self): self.assertEqual(m.group(1), cig.__version__) +# ---------- chatgpt.com DOM fixtures (issue #41) ---------- +# +# The web backend's page-side JS is only as good as its selectors, and ChatGPT +# rolls redesigns out per account. These tests hold the selector constants to +# DOM snapshots of BOTH layouts in tests/fixtures/, using a tiny stdlib CSS +# matcher (descendant combinator, tag/#id/.class, [attr], = *= ^= with the `i` +# flag, :not() and :has()) — enough for the selectors the CLI uses, no deps. + +_FIXTURES = Path(__file__).resolve().parent / "tests" / "fixtures" +_VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", + "meta", "source", "track", "wbr"} + + +class _Node: + def __init__(self, tag, attrs, parent): + self.tag, self.attrs, self.parent, self.children = tag, attrs, parent, [] + + def iter(self): + for c in self.children: + yield c + yield from c.iter() + + def closest(self, sel): + n = self + while n is not None and n.tag != "#root": + if _matches(n, sel): + return n + n = n.parent + return None + + +class _TreeBuilder(html.parser.HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.root = _Node("#root", {}, None) + self.cur = self.root + + def handle_starttag(self, tag, attrs): + node = _Node(tag, {k: (v if v is not None else "") for k, v in attrs}, self.cur) + self.cur.children.append(node) + if tag not in _VOID: + self.cur = node + + def handle_endtag(self, tag): + n = self.cur + while n is not None and n.tag != tag: + n = n.parent + if n is not None and n.parent is not None: + self.cur = n.parent + + +def _parse(markup): + b = _TreeBuilder() + b.feed(markup) + return b.root + + +def _split_top(s, sep): + """Split on `sep` outside (), [] and quotes.""" + out, depth, quote, buf = [], 0, None, "" + for ch in s: + if quote: + quote = None if ch == quote else quote + elif ch in "\"'": + quote = ch + elif ch in "([": + depth += 1 + elif ch in ")]": + depth -= 1 + elif depth == 0 and (ch == sep or (sep == " " and ch.isspace())): + if buf.strip(): + out.append(buf.strip()) + buf = "" + continue + buf += ch + if buf.strip(): + out.append(buf.strip()) + return out + + +_ATTR_RE = re.compile(r'\[\s*([\w-]+)\s*(?:([*^]?=)\s*(?:"([^"]*)"|\'([^\']*)\'|([^\]\s]+))\s*(i)?\s*)?\]') + + +def _match_compound(node, comp): + if node.tag == "#root": + return False + i = 0 + m = re.match(r"[a-zA-Z][\w-]*", comp) + if m: + if node.tag != m.group(0).lower(): + return False + i = m.end() + while i < len(comp): + ch = comp[i] + if ch in "#.": + m = re.match(r"[\w-]+", comp[i + 1:]) + name = m.group(0) + if ch == "#" and node.attrs.get("id") != name: + return False + if ch == "." and name not in node.attrs.get("class", "").split(): + return False + i += 1 + m.end() + elif ch == "[": + m = _ATTR_RE.match(comp, i) + name, op = m.group(1), m.group(2) + val = next((g for g in m.group(3, 4, 5) if g is not None), None) + have = node.attrs.get(name) + if have is None: + return False + if op: + a, b = (have.lower(), val.lower()) if m.group(6) else (have, val) + if (op == "=" and a != b) or (op == "*=" and b not in a) \ + or (op == "^=" and not a.startswith(b)): + return False + i = m.end() + elif comp.startswith(":not(", i) or comp.startswith(":has(", i): + depth, j = 0, i + 4 + while True: + depth += comp[j] == "(" + depth -= comp[j] == ")" + if depth == 0: + break + j += 1 + inner = comp[i + 5:j] + if comp.startswith(":not(", i) and _matches(node, inner): + return False + if comp.startswith(":has(", i) and not any(_matches(d, inner) for d in node.iter()): + return False + i = j + 1 + else: + raise ValueError(f"unsupported selector syntax at {comp[i:]!r}") + return True + + +def _match_complex(node, parts): + if not _match_compound(node, parts[-1]): + return False + rest, anc = parts[:-1], node.parent + while rest and anc is not None: + if _match_compound(anc, rest[-1]) and _match_complex(anc, rest): + return True + anc = anc.parent + return not rest + + +def _matches(node, selector): + return any(_match_complex(node, _split_top(c, " ")) for c in _split_top(selector, ",")) + + +def _select(root, selector): + return [n for n in root.iter() if _matches(n, selector)] + + +def _fixture(name): + return _parse((_FIXTURES / name).read_text(encoding="utf-8")) + + +class SelectorEngineSelfTest(unittest.TestCase): + """The mini matcher must agree with CSS on the constructs the CLI uses.""" + + def test_constructs(self): + root = _parse('
' + '' + '' + '
') + self.assertEqual(len(_select(root, 'form div.ProseMirror[contenteditable="true"]')), 1) + self.assertEqual(len(_select(root, 'button[aria-label*="voice" i]')), 1) + self.assertEqual(len(_select(root, 'button[aria-label*="voice"]')), 0) + self.assertEqual( + [n.attrs["aria-label"] for n in _select( + root, 'form:has(div.ProseMirror) button[type="submit"]:not([aria-label*="voice" i])')], + ["Send"]) + + +class ChatGPTDomSelectors(unittest.TestCase): + """Hold the web backend's selectors to both chatgpt.com layouts (issue #41).""" + + LAYOUTS = ("chatgpt-thread-legacy.html", "chatgpt-thread-redesign.html") + + def _user_turns(self, root): + """Mirror of the distinct-turn count in _JS_WEB_COMPOSER_STATE.""" + seen = set() + for m in _select(root, cig.WEB_USER_MSG_SEL): + if m.parent is not None and m.parent.closest(cig.WEB_USER_MSG_SEL): + continue + t = m.closest(cig.WEB_TURN_SEL) + seen.add(("k", t.attrs["data-turn-key"]) if t else id(m)) + return len(seen) + + def test_composer_and_send_resolve_in_both_layouts(self): + for name in self.LAYOUTS: + with self.subTest(layout=name): + root = _fixture(name) + composers = _select(root, cig.WEB_COMPOSER_SEL) + self.assertEqual(len(composers), 1) + form = composers[0].closest("form") + self.assertIsNotNone(form) + sends = _select(form, cig.WEB_SEND_SEL) + self.assertEqual(len(sends), 1) + self.assertEqual(sends[0].attrs.get("type"), "submit") + # The page-wide click selector hits that same button, only it. + self.assertEqual(_select(root, cig.WEB_SEND_CLICK_SEL), sends) + + def test_redesign_has_none_of_the_legacy_markers(self): + """The reason #41 broke: every legacy hook is gone from the redesign.""" + root = _fixture("chatgpt-thread-redesign.html") + for sel in ("#prompt-textarea", 'button[data-testid="send-button"]', + "[data-message-author-role]"): + self.assertEqual(_select(root, sel), [], sel) + + def test_one_user_turn_even_though_the_redesign_renders_the_thread_twice(self): + for name in self.LAYOUTS: + with self.subTest(layout=name): + self.assertEqual(self._user_turns(_fixture(name)), 1) + root = _fixture("chatgpt-thread-redesign.html") + self.assertEqual(len(_select(root, "[data-user-message-bubble]")), 2) + + def test_nested_user_markers_count_once(self): + root = _parse('
' + '
hi
') + self.assertEqual(self._user_turns(root), 1) + + def test_generated_image_is_found_and_user_images_are_not(self): + re_src = re.compile(cig.WEB_IMG_SRC_RE) + for name in self.LAYOUTS: + with self.subTest(layout=name): + root = _fixture(name) + user = {i.attrs["src"] for i in _select( + root, ", ".join(s.strip() + " img" for s in cig.WEB_USER_MSG_SEL.split(",")))} + fresh = [i.attrs["src"] for i in _select(root, "main img") + if (re_src.search(i.attrs.get("src", "")) or _matches(i, cig.WEB_GEN_IMG_SEL)) + and i.attrs["src"] not in user] + self.assertTrue(fresh) + if name.endswith("redesign.html"): + # blob: src — the regex alone would never have matched it. + self.assertTrue(all(s.startswith("blob:https://chatgpt.com/") for s in fresh)) + self.assertFalse(any(re_src.search(s) for s in fresh)) + else: + self.assertTrue(all("file_00000000000000000000000000000002" in s for s in fresh)) + + def test_redesign_reply_detected_only_once_it_has_content(self): + root = _fixture("chatgpt-thread-redesign.html") + turn = _select(root, "main " + cig.WEB_TURN_SEL)[-1] + self.assertTrue(_select(turn, '[data-conversation-role="assistant"]')) + self.assertTrue(_select(turn, cig.WEB_ASSISTANT_BODY_SEL)) + pending = _parse('
x
' + '

ChatGPT

') + self.assertEqual(_select(pending, cig.WEB_ASSISTANT_BODY_SEL), []) + + def test_send_never_matches_voice_or_dictation(self): + root = _parse('
' + '' + '' + '
') + self.assertEqual(_select(root, cig.WEB_SEND_SEL), []) + self.assertEqual(_select(root, cig.WEB_SEND_CLICK_SEL), []) + + def test_stop_button_matches_localized_labels(self): + for label in ("Stop streaming", "停止生成", "中止"): + root = _parse(f'') + self.assertEqual(len(_select(root, cig.WEB_STOP_SEL)), 1, label) + + def test_page_js_templates_are_fully_formatted(self): + """Every selector bundle lands in the JS; no stray %s is left behind.""" + filled = [ + cig._JS_STATE % (json.dumps([]), json.dumps(cig.WEB_IMG_SRC_RE), + cig._js_state_selectors()), + cig._JS_BASELINE % (json.dumps(cig.WEB_IMG_SRC_RE), json.dumps(cig.WEB_GEN_IMG_SEL)), + cig._JS_WEB_COMPOSER_STATE % cig._js_composer_selectors(), + cig._JS_COMPOSER % json.dumps(cig.WEB_COMPOSER_SEL), + cig._JS_FETCH % json.dumps("blob:https://chatgpt.com/x"), + ] + for js in filled: + self.assertNotIn("%s", js) + self.assertIn(json.dumps(cig.WEB_GEN_IMG_SEL), filled[0]) + self.assertIn(json.dumps(cig.WEB_SEND_SEL, ensure_ascii=False), filled[2]) + + def test_blob_src_is_fetched_in_page_with_a_decoded_img_fallback(self): + """A blob: URL only resolves inside the page, so the download runs there + and can fall back to re-encoding the already-decoded .""" + js = cig._JS_FETCH % json.dumps("blob:https://chatgpt.com/x") + self.assertIn("startsWith('blob:')", js) + self.assertIn("toBlob", js) + self.assertIn("credentials: 'include'", js) + + +class WebGenerateBlobDownload(unittest.TestCase): + """End-to-end over simulated Chrome: a blob: result is detected and saved.""" + + def test_blob_result_is_downloaded_through_the_page(self): + png = b"\x89PNG\r\n\x1a\n" + b"\0" * 32 + blob = "blob:https://chatgpt.com/00000000-0000-0000-0000-000000000001" + evals = [] + + def fake_eval(ab, js, session, timeout): + evals.append(js) + if js.startswith(cig._JS_BASELINE[:40]): + return [] + if "credentials" in js and "fetch(src" in js: + return {"ok": True, "type": "image/png", "b64": base64.b64encode(png).decode()} + return {"stop": False, "last": blob, "assistant": True, "limited": False, "atext": ""} + + args = argparse.Namespace(timeout=60) + with unittest.mock.patch.object(cig, "_ab_eval", side_effect=fake_eval), \ + unittest.mock.patch.object(cig, "_submit_web_prompt"), \ + unittest.mock.patch.object(cig.time, "sleep"): + data, meta = cig._generate_in_browser( + "ab", "s", "prompt", args, time.monotonic() + 60, lambda: 60, lambda _: None) + self.assertEqual(data, png) + self.assertEqual(meta["content_type"], "image/png") + self.assertIn(json.dumps(blob), evals[-1]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/fixtures/chatgpt-thread-legacy.html b/tests/fixtures/chatgpt-thread-legacy.html new file mode 100644 index 0000000..944f124 --- /dev/null +++ b/tests/fixtures/chatgpt-thread-legacy.html @@ -0,0 +1,10 @@ + + + +

You said:

a simple flat illustration of a red apple on white background

ChatGPT said:

Generated image: apple
ChatGPT can make mistakes. Check important info.


+ diff --git a/tests/fixtures/chatgpt-thread-redesign.html b/tests/fixtures/chatgpt-thread-redesign.html new file mode 100644 index 0000000..11fb3ec --- /dev/null +++ b/tests/fixtures/chatgpt-thread-redesign.html @@ -0,0 +1,58 @@ + + + +
+
+
+
+
+
a simple flat illustration of a red apple on white background
+
+

ChatGPT 说:

+
+ +
+
+
+ +
+
+ +
+ +
+

a simple flat illustration of a red apple on white background

+
+ + +
+
+
+