GEO Task Worker v2 免安装版 (含嵌入式 Python + 全部依赖)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

254 lines
11 KiB

import asyncio
import base64
import json
import random
import re
import time
class DoubaoAdapter:
def __init__(self, page, cdp):
self.page = page
self.cdp = cdp
self.captured = {}
self.response_bodies = {}
self._finished_event = asyncio.Event()
self.captcha_decision = None
self._popup_blocker_injected = False
async def setup_intercept(self):
self.captured.clear()
self.response_bodies.clear()
self._finished_event = asyncio.Event()
self.captcha_decision = None
def on_request(params):
req = params.get("request", {})
url = req.get("url", "")
if "chat/completion" in url and "doubao.com" in url:
self.captured[params.get("requestId")] = {
"url": url, "postData": req.get("postData", ""), "timestamp": time.time(),
}
async def on_finished(params):
rid = params.get("requestId")
if rid in self.captured:
try:
result = await self.cdp.send("Network.getResponseBody", {"requestId": rid})
body = result.get("body", "")
if result.get("base64Encoded"):
body = base64.b64decode(body).decode("utf-8", errors="replace")
self.response_bodies[rid] = body
self._extract_captcha_decision(body)
if "710022004" in body:
print(f" [DB] SSE 返回验证码拦截, 不标记完成", flush=True)
else:
self._finished_event.set()
except Exception:
pass
self.cdp.on("Network.requestWillBeSent", on_request)
self.cdp.on("Network.loadingFinished", lambda p: asyncio.create_task(on_finished(p)))
def _extract_captcha_decision(self, body):
if "710022004" not in body:
return
for line in body.split("\n"):
line = line.strip()
if not line.startswith("data:") and not line.startswith("{"):
continue
raw = line[5:].strip() if line.startswith("data:") else line
try:
data = json.loads(raw)
if data.get("error_code") == 710022004:
decision = data.get("decision")
if not decision:
extra = data.get("extra")
if isinstance(extra, str):
try: extra = json.loads(extra)
except Exception: extra = {}
if isinstance(extra, dict):
rd = extra.get("decision")
if isinstance(rd, str):
try: decision = json.loads(rd)
except Exception: decision = rd
elif isinstance(rd, dict):
decision = rd
if decision:
self.captcha_decision = decision
return
except Exception:
pass
async def _inject_popup_blocker(self):
"""注入 JS + CSS 从源头拦截所有弹窗/覆盖层"""
await self.page.add_init_script("""() => {
const style = document.createElement('style');
style.textContent = `
[class*="cornerPopup"], [class*="corner-popup"],
[class*="guide-"], [class*="Guide"],
[class*="onboarding"], [class*="Onboarding"],
[class*="tooltip-popup"], [class*="notice-bar"],
[class*="upgrade-banner"], [class*="download-banner"],
.semi-modal-wrap, .semi-modal-mask,
[class*="FloatBanner"], [class*="float-banner"] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
`;
(document.head || document.documentElement).appendChild(style);
const BLOCKED_CLASSES = /cornerPopup|corner-popup|guide-|Guide|onboarding|Onboarding|tooltip-popup|notice-bar|upgrade-banner|download-banner|FloatBanner|float-banner/;
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
const cls = node.className ? node.className.toString() : '';
if (BLOCKED_CLASSES.test(cls)) { node.remove(); continue; }
const cs = getComputedStyle(node);
if ((cs.position === 'fixed' || cs.position === 'absolute') &&
parseInt(cs.zIndex) >= 1000 &&
node.offsetWidth > window.innerWidth * 0.5 &&
node.offsetHeight > window.innerHeight * 0.5) {
const text = (node.innerText || '').substring(0, 200);
if (text.includes('知道了') || text.includes('去设置') ||
text.includes('下载') || text.includes('升级') ||
text.includes('引导') || text.includes('水印')) {
node.remove();
}
}
if (cls.includes('semi-modal-wrap') || cls.includes('semi-modal-mask')) {
node.remove();
document.body.style.overflow = '';
}
}
}
});
observer.observe(document.documentElement, {childList: true, subtree: true});
const origOverflow = Object.getOwnPropertyDescriptor(CSSStyleDeclaration.prototype, 'overflow');
if (origOverflow && origOverflow.set) {
Object.defineProperty(document.body.style, 'overflow', {
set(v) {
if (v === 'hidden' || v === 'clip') return;
origOverflow.set.call(this, v);
},
get() { return origOverflow.get.call(this); },
configurable: true,
});
}
}""")
print(" [doubao] 弹窗拦截已注入")
async def _physical_click(self, locator):
box = await locator.bounding_box()
if not box:
await locator.click()
return
x = box['x'] + box['width'] * random.uniform(0.3, 0.7)
y = box['y'] + box['height'] * random.uniform(0.3, 0.7)
await self.page.mouse.move(x, y, steps=random.randint(5, 12))
await asyncio.sleep(random.uniform(0.05, 0.15))
await self.page.mouse.click(x, y)
async def _physical_type(self, locator, text):
await self._physical_click(locator)
await asyncio.sleep(random.uniform(0.3, 0.6))
for char in text:
await self.page.keyboard.type(char, delay=random.randint(40, 120))
if random.random() > 0.9:
await asyncio.sleep(random.uniform(0.2, 0.4))
async def new_chat(self):
if not self._popup_blocker_injected:
await self._inject_popup_blocker()
self._popup_blocker_injected = True
await self.page.goto("https://www.doubao.com/chat", wait_until="domcontentloaded", timeout=60000)
await self.page.wait_for_selector(
"textarea[placeholder*='发消息']", state="visible", timeout=15000
)
await asyncio.sleep(random.uniform(1, 2))
async def set_deep_thinking(self, enabled):
target = "专家" if enabled else "快速"
for attempt in range(3):
try:
trigger = self.page.locator(
'button[data-slot="dropdown-menu-trigger"]'
).filter(has_text=re.compile(r"快速|专家|办公")).first
await trigger.wait_for(state="visible", timeout=15000)
current = (await trigger.inner_text() or "").split("\n")[0].strip()
if target in current:
return
await self._physical_click(trigger)
await asyncio.sleep(random.uniform(0.5, 1.0))
item = self.page.locator('[role="menuitem"]').filter(has_text=target).first
await item.wait_for(state="visible", timeout=8000)
await self._physical_click(item)
await asyncio.sleep(random.uniform(0.5, 1.0))
return
except Exception as e:
if attempt < 2:
print(f" [WARN] 豆包模式切换重试 {attempt+1}/3: {e}")
await asyncio.sleep(2)
else:
print(f" [WARN] 豆包模式切换失败: {e}")
async def send_question(self, question):
ta = self.page.locator(
"textarea[placeholder*='发消息']:not([aria-hidden='true'])"
).first
await ta.wait_for(state="visible", timeout=5000)
await self._physical_type(ta, question)
await asyncio.sleep(random.uniform(0.3, 0.5))
input_val = await ta.input_value()
if len(input_val) < len(question) * 0.5:
await ta.fill('')
await ta.fill(question)
await asyncio.sleep(0.3)
try:
btn = self.page.locator('button[class*="send-msg-btn"]').first
await btn.wait_for(state="visible", timeout=2000)
if not await btn.is_disabled():
await self._physical_click(btn)
return
except Exception:
pass
await self.page.keyboard.press("Enter")
async def wait_done(self, timeout=180):
try:
await asyncio.wait_for(self._finished_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
print(f" [WARN] 豆包响应超时 ({timeout}s)")
return
await asyncio.sleep(1)
for _ in range(30):
try:
streaming = await self.page.evaluate("""() => {
var el = document.querySelector('div[class*="md-box-root"][data-streaming]');
return el ? el.getAttribute('data-streaming') : null;
}""")
if streaming == "false":
break
except Exception:
pass
await asyncio.sleep(1)
await asyncio.sleep(1)
def get_last_response(self):
if not self.response_bodies:
return None, None
last_rid = max(self.response_bodies.keys(),
key=lambda r: self.captured.get(r, {}).get("timestamp", 0))
return self.response_bodies[last_rid], self.captured[last_rid].get("postData", "")