diff --git a/TMWebDriver.py b/TMWebDriver.py index 9669777..8d2a204 100644 --- a/TMWebDriver.py +++ b/TMWebDriver.py @@ -12,7 +12,7 @@ class Session: self.connect_at = time.time() self.disconnect_at = None self.type = info.get('type', 'ws') - self.ws_client = client if self.type == 'ws' else None + self.ws_client = client if self.type in ('ws', 'ext_ws') else None self.http_queue = client if self.type == 'http' else None @property def url(self): return self.info.get('url', '') @@ -22,7 +22,7 @@ class Session: def reconnect(self, client, info): self.info = info self.type = info.get('type', 'ws') - if self.type == 'ws': + if self.type in ('ws', 'ext_ws'): self.ws_client = client self.http_queue = None elif self.type == 'http': @@ -79,7 +79,7 @@ class TMWebDriver: if data.get('type') == 'result': self.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])} elif data.get('type') == 'error': - self.results[data.get('id')] = {'success': False, 'data': data.get('error')} + self.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])} return 'ok' @app.route('/link', method=['GET','POST']) @@ -98,7 +98,7 @@ class TMWebDriver: print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' ')) return json.dumps({'r': result}, ensure_ascii=False) except Exception as e: - return json.dumps({'error': str(e)}, ensure_ascii=False) + return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False) return 'ok' def run(): from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler @@ -128,11 +128,24 @@ class TMWebDriver: session_info = {'url': data.get('url'), 'title': data.get('title', ''), 'connected_at': time.time(), 'type': 'ws'} driver._register_client(session_id, self, session_info) + elif data.get('type') in ['ext_ready', 'tabs_update']: + tabs = data.get('tabs', []) + current_tab_ids = {str(tab['id']) for tab in tabs} + for sid in list(driver.sessions.keys()): + sess = driver.sessions[sid] + if sess.type == 'ext_ws' and sess.ws_client == self and sid not in current_tab_ids: + sess.mark_disconnected() + for tab in tabs: + session_id = str(tab['id']) + session_info = {'url': tab.get('url'), 'title': tab.get('title', ''), 'connected_at': time.time(), 'type': 'ext_ws'} + sess = driver.sessions.get(session_id) + if sess and sess.is_active(): sess.info = session_info + else: driver._register_client(session_id, self, session_info) elif data.get('type') == 'ack': driver.acks[data.get('id','')] = True elif data.get('type') == 'result': driver.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])} elif data.get('type') == 'error': - driver.results[data.get('id')] = {'success': False, 'data': data.get('error')} + driver.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])} except Exception as e: print(f"Error handling message: {e}") if hasattr(self, 'data'): print(self.data) @@ -190,11 +203,13 @@ class TMWebDriver: raise ValueError(f"会话ID {session_id} 未连接") tp = session.type - assert tp in ['ws', 'http'], f"Unsupported session type: {tp}" + assert tp in ['ws', 'http', 'ext_ws'], f"Unsupported session type: {tp}" exec_id = str(uuid.uuid4()) - payload = json.dumps({'id': exec_id, 'code': code}) + payload_dict = {'id': exec_id, 'code': code} + if tp == 'ext_ws': payload_dict['tabId'] = int(session.id) + payload = json.dumps(payload_dict) - if tp == 'ws': session.ws_client.send_message(payload) + if tp in ['ws', 'ext_ws']: session.ws_client.send_message(payload) elif tp == 'http': session.http_queue.put(payload) start_time = time.time() @@ -205,12 +220,12 @@ class TMWebDriver: time.sleep(0.2) if not acked and exec_id in self.acks: acked = True; start_time = time.time() - if tp == 'ws': + if tp in ['ws', 'ext_ws']: if not session.is_active(): hasjump = True if hasjump and session.is_active(): return {'result': f"Session {session_id} reloaded.", "closed":1} if time.time() - start_time > timeout: - if tp == 'ws': + if tp in ['ws', 'ext_ws']: if hasjump: return {'result': f"Session {session_id} reloaded and new page is loading...", 'closed':1} if acked: return {"result": f"No response data in {timeout}s (ACK received, script may still be running)"} return {"result": f"No response data in {timeout}s (no ACK, script may not have been delivered)"} diff --git a/agentmain.py b/agentmain.py index 57dd6ce..fd0e29f 100644 --- a/agentmain.py +++ b/agentmain.py @@ -138,7 +138,7 @@ class GeneraticAgent: finally: if self.stop_sig: print('User aborted the task.') - with self.task_queue.mutex: self.task_queue.queue.clear() + #with self.task_queue.mutex: self.task_queue.queue.clear() self.is_running = self.stop_sig = False self.task_queue.task_done() if self.handler is not None: self.handler.code_stop_signal.append(1) diff --git a/assets/tmwd_cdp_bridge/background.js b/assets/tmwd_cdp_bridge/background.js index dd0d940..00e547d 100644 --- a/assets/tmwd_cdp_bridge/background.js +++ b/assets/tmwd_cdp_bridge/background.js @@ -1,5 +1,19 @@ // background.js - Cookie + CDP Bridge -chrome.runtime.onInstalled.addListener(() => console.log('CDP Bridge installed')); +chrome.runtime.onInstalled.addListener(() => { + console.log('CDP Bridge installed'); + // Strip CSP headers to allow eval/inline scripts + chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [9999], + addRules: [{ + id: 9999, priority: 1, + action: { type: 'modifyHeaders', responseHeaders: [ + { header: 'content-security-policy', operation: 'remove' }, + { header: 'content-security-policy-report-only', operation: 'remove' } + ]}, + condition: { urlFilter: '*', resourceTypes: ['main_frame', 'sub_frame'] } + }] + }); +}); chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.action === 'cookies') { @@ -22,7 +36,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { await chrome.windows.update(tab.windowId, { focused: true }); sendResponse({ ok: true }); } else { - const tabs = await chrome.tabs.query({}); + const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url)); const data = tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })); sendResponse({ ok: true, data }); } @@ -58,7 +72,7 @@ async function handleBatch(msg, sender) { if (c.cmd === 'cookies') { R.push(await handleCookies(c, sender)); } else if (c.cmd === 'tabs') { - const tabs = await chrome.tabs.query({}); + const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url)); R.push({ ok: true, data: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })) }); } else if (c.cmd === 'cdp') { const tabId = c.tabId || msg.tabId || sender.tab?.id; @@ -92,4 +106,219 @@ async function handleCDP(msg, sender) { try { await chrome.debugger.detach({ tabId }); } catch (_) {} return { ok: false, error: e.message }; } -} \ No newline at end of file +} +// Filter out chrome:// and other internal tabs that can't be scripted +const isScriptable = url => url && /^https?:/.test(url); + +// --- Shared page-script builder (used by both executeScript and CDP fallback) --- +function buildPageScript(code) { + return `(async () => { + function smartProcessResult(result) { + if (result === null || result === undefined || typeof result !== 'object') return result; + if (typeof jQuery !== 'undefined' && result instanceof jQuery) { + const elements = []; for (let i = 0; i < result.length; i++) { if (result[i] && result[i].nodeType === 1) elements.push(result[i].outerHTML); } return elements; + } + if (result instanceof NodeList || result instanceof HTMLCollection) { + const elements = []; for (let i = 0; i < result.length; i++) { if (result[i] && result[i].nodeType === 1) elements.push(result[i].outerHTML); } return elements; + } + if (result.nodeType === 1) return result.outerHTML; + if (!Array.isArray(result) && typeof result === 'object' && 'length' in result && typeof result.length === 'number') { + const firstElement = result[0]; + if (firstElement && firstElement.nodeType === 1) { + const elements = []; const length = Math.min(result.length, 100); + for (let i = 0; i < length; i++) { const elem = result[i]; if (elem && elem.nodeType === 1) elements.push(elem.outerHTML); } return elements; + } + } + try { return JSON.parse(JSON.stringify(result, function(key, value) { if (typeof value === 'object' && value !== null) { if (value.nodeType === 1) return value.outerHTML; if (value === window || value === document) return '[Object]'; } return value; })); } catch (e) { return '[无法序列化: ' + e.message + ']'; } + } + try { + const jsCode = ${JSON.stringify(code)}.trim(); + const lines = jsCode.split(/\\r?\\n/).filter(l => l.trim()); + const lastLine = lines.length > 0 ? lines[lines.length - 1].trim() : ''; + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + let r; + if (lastLine.startsWith('return')) { + r = await (new AsyncFunction(jsCode))(); + } else { + try { r = eval(jsCode); if (r instanceof Promise) r = await r; } catch (e) { + if (e instanceof SyntaxError && (/return/i.test(e.message) || /await/i.test(e.message))) { r = await (new AsyncFunction(jsCode))(); } else throw e; + } + } + return { ok: true, data: smartProcessResult(r) }; + } catch (e) { + const errMsg = e.message || String(e); + return { ok: false, error: { name: e.name || 'Error', message: errMsg, stack: e.stack || '' }, + csp: errMsg.includes('Refused to evaluate') || errMsg.includes('unsafe-eval') || errMsg.includes('Content Security Policy') }; + } + })()`; +} + +// --- Minimal CDP script: no smartProcessResult, returnByValue handles serialization --- +function buildCdpScript(code) { + return `(async () => { + try { + const jsCode = ${JSON.stringify(code)}.trim(); + const lines = jsCode.split(/\\r?\\n/).filter(l => l.trim()); + const lastLine = lines.length > 0 ? lines[lines.length - 1].trim() : ''; + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + let r; + if (lastLine.startsWith('return')) { + r = await (new AsyncFunction(jsCode))(); + } else { + try { r = eval(jsCode); if (r instanceof Promise) r = await r; } catch (e) { + if (e instanceof SyntaxError && (/return/i.test(e.message) || /await/i.test(e.message))) { r = await (new AsyncFunction(jsCode))(); } else throw e; + } + } + return { ok: true, data: r }; + } catch (e) { + return { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' } }; + } + })()`; +} + +// --- WebSocket Client for TMWebDriver --- +let ws = null; +const WS_URL = 'ws://127.0.0.1:18765'; + +function scheduleProbe() { + // Use chrome.alarms to survive MV3 service worker suspension + chrome.alarms.create('tmwd-ws-probe', { delayInMinutes: 0.083 }); // ~5s +} + +async function isServerAlive() { + try { + const ctrl = new AbortController(); + setTimeout(() => ctrl.abort(), 2000); + await fetch('http://127.0.0.1:18765', { signal: ctrl.signal }); + return true; // Got HTTP response → port is listening + } catch (e) { + return false; // Network error (connection refused) or timeout → server not alive + } +} + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === 'tmwd-ws-probe') { + if (ws && ws.readyState <= 1) return; // Already connected/connecting + if (await isServerAlive()) { + console.log('[TMWD-WS] Server detected, connecting...'); + connectWS(); + } else { + scheduleProbe(); // Server not up, keep probing + } + } +}); + +function connectWS() { + if (ws && ws.readyState <= 1) return; // CONNECTING or OPEN + ws = null; + console.log('[TMWD-WS] Connecting to', WS_URL); + try { + ws = new WebSocket(WS_URL); + } catch (e) { + console.error('[TMWD-WS] Constructor error:', e); + ws = null; + scheduleProbe(); + return; + } + ws.onopen = async () => { + console.log('[TMWD-WS] Connected!'); + chrome.alarms.clear('tmwd-ws-probe'); + const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url)); + ws.send(JSON.stringify({ + type: 'ext_ready', + tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title })) + })); + console.log('[TMWD-WS] Sent ext_ready with', tabs.length, 'tabs'); + }; + ws.onmessage = async (event) => { + try { + const data = JSON.parse(event.data); + if (data.id && data.code) { + const tabId = data.tabId; + console.log('[TMWD-WS] Exec request', data.id, 'on tab', tabId); + // Send ACK immediately so Python side resets timeout timer + ws.send(JSON.stringify({ type: 'ack', id: data.id })); + if (!tabId) { + ws.send(JSON.stringify({ type: 'error', id: data.id, error: 'No tabId provided' })); + return; + } + try { + const tabsBefore = new Set((await chrome.tabs.query({})).map(t => t.id)); + let res; + try { + const result = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: async (s) => await eval(s), + args: [buildPageScript(data.code)] + }); + res = result[0]?.result; + } catch (e) { + console.log('[TMWD-WS] scripting.executeScript failed:', e.message); + res = { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' }, csp: true }; + } + // CDP fallback for CSP-restricted pages + if (res && !res.ok && res.csp) { + console.log('[TMWD-WS] CDP fallback for tab', tabId); + const wrappedCode = buildCdpScript(data.code); + try { + await chrome.debugger.attach({ tabId }, '1.3'); + const cdpRes = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: wrappedCode, awaitPromise: true, returnByValue: true + }); + await chrome.debugger.detach({ tabId }); + if (cdpRes.exceptionDetails) { + const desc = cdpRes.exceptionDetails.exception?.description || 'CDP Error'; + res = { ok: false, error: { name: 'Error', message: desc, stack: desc } }; + } else { + res = cdpRes.result.value; // Already {ok, data/error} from the wrapper + } + } catch (cdpErr) { + try { await chrome.debugger.detach({ tabId }); } catch (_) {} + res = { ok: false, error: { name: 'Error', message: 'CDP fallback failed: ' + cdpErr.message, stack: '' } }; + } + } + const newTabs = (await chrome.tabs.query({})).filter(t => !tabsBefore.has(t.id)).map(t => ({id: t.id, url: t.url, title: t.title})); + if (res?.ok) { + ws.send(JSON.stringify({ type: 'result', id: data.id, result: res.data, newTabs })); + } else { + ws.send(JSON.stringify({ type: 'error', id: data.id, error: res?.error || 'Unknown error', newTabs })); + } + } catch (e) { + ws.send(JSON.stringify({ type: 'error', id: data.id, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' } })); + } + } + } catch (e) { + console.error('[TMWD-WS] message parse error', e); + } + }; + ws.onclose = () => { + console.log('[TMWD-WS] Disconnected'); + ws = null; + scheduleProbe(); + }; + ws.onerror = (e) => { + console.error('[TMWD-WS] Error:', e); + // onclose will fire after this, which triggers reconnect + }; +} + +// Initial connect + wake-up hooks +connectWS(); +chrome.runtime.onStartup.addListener(() => connectWS()); +chrome.runtime.onInstalled.addListener(() => connectWS()); + +// Sync tab list on changes +async function sendTabsUpdate() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url)); + ws.send(JSON.stringify({ + type: 'tabs_update', + tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title })) + })); +} +chrome.tabs.onUpdated.addListener((_, changeInfo) => { + if (changeInfo.status === 'complete') sendTabsUpdate(); +}); +chrome.tabs.onRemoved.addListener(() => sendTabsUpdate()); +chrome.tabs.onCreated.addListener(() => sendTabsUpdate()); diff --git a/assets/tmwd_cdp_bridge/content.js b/assets/tmwd_cdp_bridge/content.js index 555a7b7..3f16611 100644 --- a/assets/tmwd_cdp_bridge/content.js +++ b/assets/tmwd_cdp_bridge/content.js @@ -1,4 +1,18 @@ +// Remove meta CSP tags +document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').forEach(e => e.remove()); + +// Indicator badge at bottom-right (userscript style) +(function(){ + if(window.self!==window.top)return; + const d=document.createElement('div'); + d.id='ljq-ind'; + d.innerText='ljq_driver: 已连接'; + d.style.cssText='position:fixed;bottom:8px;right:8px;background:#4CAF50;color:white;padding:4px 7px;border-radius:4px;font-size:11px;font-weight:bold;z-index:99999;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,0.2);opacity:0.5;'; + d.addEventListener('click',()=>alert('会话活跃\nURL: '+location.href)); + (document.body||document.documentElement).appendChild(d); +})(); + new MutationObserver(muts => { for (const m of muts) for (const n of m.addedNodes) { if (n.id === TID || (n.querySelector && n.querySelector('#' + TID))) { diff --git a/assets/tmwd_cdp_bridge/manifest.json b/assets/tmwd_cdp_bridge/manifest.json index ce81291..bef6bd7 100644 --- a/assets/tmwd_cdp_bridge/manifest.json +++ b/assets/tmwd_cdp_bridge/manifest.json @@ -7,7 +7,10 @@ "cookies", "tabs", "activeTab", - "debugger" + "debugger", + "scripting", + "alarms", + "declarativeNetRequest" ], "host_permissions": [""], "background": { diff --git a/memory/tmwebdriver_sop.md b/memory/tmwebdriver_sop.md index 9988ac2..32fde29 100644 --- a/memory/tmwebdriver_sop.md +++ b/memory/tmwebdriver_sop.md @@ -5,9 +5,9 @@ - 非Selenium/Playwright,不需调试浏览器或新数据目录 - 支撑 `web_scan`(只读DOM) / `web_execute_js`(执行JS) 等高层工具 -## 通用限制 -- ⚠web_execute_js中**禁止使用await**,会报SyntaxError(非async上下文)(BBS#34验证) - - 需要异步操作时用`.then()`链式调用或回调 +## 通用特性 +- ✅web_execute_js**完美支持顶层await**(v0.4+),可直接`await fetch()`/`await new Promise()`等 + - ⚠使用await时需**显式`return`**才能拿到返回值(底层async包裹,不写return则返回null) ## 限制(isTrusted) - JS dispatch的事件`isTrusted=false`,敏感操作(文件上传/部分按钮)会被浏览器拦截 @@ -53,7 +53,7 @@ fetch('PDF_URL').then(r=>r.blob()).then(b=>{ ⚠TID密钥:首次运行自动生成到`assets/tmwd_cdp_bridge/config.js`(已gitignore),扩展通过manifest引用 调用:MutationObserver监听addedNodes(id=TID),⚠每次必须remove旧→createElement新→设textContent JSON→appendChild ```js -// TID从assets/tmwd_cdp_bridge/config.js读取,示例用'__ljq_ctrl'占位 +// TID从assets/tmwd_cdp_bridge/config.js读取 const old = document.getElementById(TID); if (old) old.remove(); const el = document.createElement('div'); @@ -128,14 +128,16 @@ document.body.appendChild(el); // 响应写回el.textContent ## autofill获取与登录 (需 v0.4+ 脚本支持 await) 检测:web_scan输出input带`data-autofilled="true"`,value显示为受保护提示(非真实值,Chrome安全保护需点击释放) -- ⭐**一键释放与登录**:利用 v0.4 脚本的顶层 `await`,在单次 `web_execute_js` 中连贯完成: - 1. JS获取输入框坐标。 - 2. CDP发送 `Input.dispatchMouseEvent` (mousePressed) 物理点击释放autofill。 - 3. `await new Promise(r => setTimeout(r, 500))` 等待释放。 - 4. 派发 `input`/`change` 事件唤醒前端框架(解禁登录按钮)。 - 5. 触发登录点击。 +- ⚠**前置条件:必须先CDP `Page.bringToFront` 切tab到前台**,Chrome仅在前台tab释放autofill保护值,后台tab物理点击无效 +- ⭐**一键释放与登录**:利用顶层 `await`,在单次 `web_execute_js` 中连贯完成: + 1. CDP batch发送 `Page.bringToFront` 切到前台。 + 2. JS获取输入框坐标。 + 3. CDP发送 `Input.dispatchMouseEvent` (mousePressed) 物理点击释放autofill。 + 4. `await new Promise(r => setTimeout(r, 500))` 等待释放。 + 5. 派发 `input`/`change` 事件唤醒前端框架(解禁登录按钮)。 + 6. 触发登录点击。 - ⚠只需 `mousePressed`,无需 `mouseReleased`。点击一个字段即释放全页。 -- ⚠已淘汰旧版跨 tab 查 tabId 或 Python 轮询的繁琐流程,直接在当前页异步完成。 +- ⚠使用await时需显式`return`返回值,否则async包裹层默认返回null。 ## 验证码/页面视觉截图 - ⭐首选CDP截图:`Page.captureScreenshot`(format:'png')→返回base64,无需前台/后台tab也行,全页高清 diff --git a/simphtml.py b/simphtml.py index ff18f11..74f572a 100644 --- a/simphtml.py +++ b/simphtml.py @@ -866,12 +866,14 @@ def execute_js_rich(script, driver, no_monitor=False): "environment": {"reloaded": reloaded}, "tab_id": driver.default_session_id } - after = driver.get_session_dict() - new_sids = {k: v for k, v in after.items() if k not in before_sids} - if new_sids: - newTabs = [{'id': k, 'url': v} for k, v in new_sids.items()] - rr['environment']['newTabs'] = newTabs - rr['suggestion'] = "页面已刷新,以上新标签页在执行期间连接。" + if response.get('newTabs'): rr['environment']['newTabs'] = response['newTabs'] + else: + after = driver.get_session_dict() + new_sids = {k: v for k, v in after.items() if k not in before_sids} + if new_sids: + newTabs = [{'id': k, 'url': v} for k, v in new_sids.items()] + rr['environment']['newTabs'] = newTabs + rr['suggestion'] = "页面已刷新,以上新标签页在执行期间连接。" if error_msg: rr['error'] = error_msg if no_monitor: return rr if not reloaded: