hsmm commited on
Commit
f4f3994
·
verified ·
1 Parent(s): 0d40527

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +23 -0
  2. README.md +23 -10
  3. main.py +356 -0
  4. requirements.txt +5 -0
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM mcr.microsoft.com/playwright/python:v1.57.0-noble
2
+
3
+ # Hugging Face Docker Spaces runs as UID 1000. Create a matching user to avoid permission issues.
4
+ RUN useradd -m -u 1000 user
5
+
6
+ ENV HOME=/home/user \
7
+ PATH=/home/user/.local/bin:$PATH \
8
+ PYTHONUNBUFFERED=1
9
+
10
+ WORKDIR $HOME/app
11
+
12
+ COPY --chown=user requirements.txt ./requirements.txt
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ COPY --chown=user . .
16
+
17
+ # Optional: if you upgrade the Space with persistent disk, /data is mounted at runtime.
18
+ RUN mkdir -p /data && chmod 777 /data
19
+
20
+ USER user
21
+
22
+ EXPOSE 7860
23
+ CMD ["bash","-lc","uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860}"]
README.md CHANGED
@@ -1,10 +1,23 @@
1
- ---
2
- title: Playwright
3
- emoji: 🏢
4
- colorFrom: red
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Doubao Web Worker (Playwright) for Dify
2
+
3
+ This Docker app exposes a minimal HTTP API for Dify workflow HTTP Request node:
4
+
5
+ - `POST /api/ask` -> returns plain text answer
6
+ - `GET /api/health`
7
+
8
+ If login/captcha is required, `/api/ask` returns HTTP 409 and a message containing:
9
+ - `/auth/qr.png` (login screenshot with QR code)
10
+ - `/auth` (helper page to refresh screenshot and optionally submit SMS code)
11
+
12
+ ## Required env vars (Hugging Face Space -> Settings -> Variables/Secrets)
13
+
14
+ - `WORKER_API_KEY` (Secret): required. Dify sends it via `X-Api-Key` header.
15
+ - `PUBLIC_BASE_URL` (Variable): optional but recommended, e.g. `https://YOUR_SPACE.hf.space`.
16
+ - `PROFILE_DIR` (Variable): recommended `/data/doubao_profile` if you have a persistent disk.
17
+ - `DOUBAO_CHAT_URL` (Variable): optional; default `https://www.doubao.com/chat/`.
18
+
19
+ ## Notes
20
+
21
+ - Persistent session is the key to reduce verification frequency: keep cookies/localStorage in `PROFILE_DIR`.
22
+ - This worker serializes requests with a lock because the browser profile is stateful.
23
+ - Selectors may change. Adjust `INPUT_SELECTOR`, `SEND_BTN_SELECTOR`, `ANSWER_SELECTOR` if needed.
main.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ from fastapi import FastAPI, Header, HTTPException
10
+ from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
11
+ from pydantic import BaseModel
12
+ from playwright.async_api import async_playwright, TimeoutError as PWTimeoutError
13
+
14
+ # -------------------------
15
+ # Runtime configuration
16
+ # -------------------------
17
+ WORKER_API_KEY = os.getenv("WORKER_API_KEY", "")
18
+ PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").rstrip("/") # e.g. https://YOUR_SPACE.hf.space
19
+ PROFILE_DIR = os.getenv("PROFILE_DIR", "/data/doubao_profile")
20
+ STATE_FILE = os.path.join(PROFILE_DIR, "worker_state.json")
21
+
22
+ # Chat entry URL. You can keep it as a specific chat id if you prefer stability.
23
+ DOUBAO_CHAT_URL = os.getenv("DOUBAO_CHAT_URL", "https://www.doubao.com/chat/")
24
+
25
+ # Manual auth rate-limit: allow at most once per N hours
26
+ AUTH_LIMIT_HOURS = int(os.getenv("AUTH_LIMIT_HOURS", "24"))
27
+
28
+ # Where to store the latest auth screenshot (QR / login screen)
29
+ AUTH_SCREENSHOT = os.path.join(PROFILE_DIR, "auth.png")
30
+
31
+ # Basic selectors (may need adjustment if Doubao updates UI)
32
+ INPUT_SELECTOR = os.getenv("INPUT_SELECTOR", "textarea")
33
+ SEND_BTN_SELECTOR = os.getenv("SEND_BTN_SELECTOR", "#flow-end-msg-send")
34
+ ANSWER_SELECTOR = os.getenv("ANSWER_SELECTOR", ".container-ncFTrL")
35
+
36
+ # -------------------------
37
+ # FastAPI app
38
+ # -------------------------
39
+ app = FastAPI(title="Doubao Web Worker (Playwright)", version="1.0.0")
40
+
41
+ class AskRequest(BaseModel):
42
+ question: str
43
+
44
+ @dataclass
45
+ class Runtime:
46
+ pw: Optional[object] = None
47
+ ctx: Optional[object] = None
48
+ page: Optional[object] = None
49
+ lock: asyncio.Lock = asyncio.Lock()
50
+
51
+ rt = Runtime()
52
+
53
+ def _now_ts() -> int:
54
+ return int(time.time())
55
+
56
+ def _read_state() -> dict:
57
+ try:
58
+ with open(STATE_FILE, "r", encoding="utf-8") as f:
59
+ return json.load(f)
60
+ except Exception:
61
+ return {}
62
+
63
+ def _write_state(state: dict) -> None:
64
+ os.makedirs(PROFILE_DIR, exist_ok=True)
65
+ tmp = STATE_FILE + ".tmp"
66
+ with open(tmp, "w", encoding="utf-8") as f:
67
+ json.dump(state, f, ensure_ascii=False, indent=2)
68
+ os.replace(tmp, STATE_FILE)
69
+
70
+ def _require_key(x_api_key: Optional[str]) -> None:
71
+ if not WORKER_API_KEY:
72
+ raise HTTPException(status_code=500, detail="WORKER_API_KEY is not set on the worker.")
73
+ if not x_api_key or x_api_key != WORKER_API_KEY:
74
+ raise HTTPException(status_code=401, detail="Invalid X-Api-Key.")
75
+
76
+ async def _ensure_browser() -> None:
77
+ if rt.ctx and rt.page:
78
+ return
79
+ os.makedirs(PROFILE_DIR, exist_ok=True)
80
+
81
+ rt.pw = await async_playwright().start()
82
+ # Persistent context keeps cookies/localStorage/etc (crucial for reducing auth frequency)
83
+ rt.ctx = await rt.pw.chromium.launch_persistent_context(
84
+ user_data_dir=PROFILE_DIR,
85
+ headless=True,
86
+ args=[
87
+ "--no-sandbox",
88
+ "--disable-dev-shm-usage",
89
+ ],
90
+ viewport={"width": 1280, "height": 900},
91
+ locale="zh-CN",
92
+ )
93
+ # Reuse first page if exists
94
+ if rt.ctx.pages:
95
+ rt.page = rt.ctx.pages[0]
96
+ else:
97
+ rt.page = await rt.ctx.new_page()
98
+ rt.page.set_default_timeout(30_000)
99
+
100
+ async def _goto_chat() -> None:
101
+ assert rt.page is not None
102
+ await rt.page.goto(DOUBAO_CHAT_URL, wait_until="domcontentloaded")
103
+ # give SPA a moment to hydrate
104
+ await rt.page.wait_for_timeout(1200)
105
+
106
+ async def _is_chat_ready() -> bool:
107
+ assert rt.page is not None
108
+ try:
109
+ send_btn = rt.page.locator(SEND_BTN_SELECTOR)
110
+ if await send_btn.count() > 0 and await send_btn.first.is_visible():
111
+ # also require an input
112
+ inp = rt.page.locator(f"{INPUT_SELECTOR}:visible")
113
+ return (await inp.count()) > 0
114
+ except Exception:
115
+ pass
116
+ return False
117
+
118
+ async def _prepare_human_auth() -> str:
119
+ """
120
+ Prepare login screen and save a screenshot to AUTH_SCREENSHOT.
121
+ Returns a user-facing instruction string.
122
+ """
123
+ assert rt.page is not None
124
+ os.makedirs(PROFILE_DIR, exist_ok=True)
125
+
126
+ state = _read_state()
127
+ last = state.get("last_human_auth_ts")
128
+ if last and (_now_ts() - int(last)) < AUTH_LIMIT_HOURS * 3600:
129
+ # Daily (or N-hour) limit reached
130
+ return (
131
+ "NEED_HUMAN_AUTH\n"
132
+ f"已触发登录/验证码,但在过去 {AUTH_LIMIT_HOURS} 小时内已进行过一次人工验证;为满足“每天最多一次人工验证码”约束,当前拒绝继续自动触发。\n"
133
+ "建议:先检查 Space 是否重启导致 Cookie 丢失;或升级持久化磁盘并将 PROFILE_DIR 指向 /data。\n"
134
+ )
135
+
136
+ # Record that we are consuming today's manual auth quota
137
+ state["last_human_auth_ts"] = _now_ts()
138
+ _write_state(state)
139
+
140
+ await _goto_chat()
141
+
142
+ # Best-effort: click "登录" if it exists (UI may vary; screenshot will still help)
143
+ try:
144
+ login_btn = rt.page.get_by_role("button", name=re.compile(r"登录|Log\s*in", re.I))
145
+ if await login_btn.count() > 0:
146
+ await login_btn.first.click()
147
+ await rt.page.wait_for_timeout(800)
148
+ except Exception:
149
+ pass
150
+
151
+ # Save screenshot for QR / login status
152
+ try:
153
+ await rt.page.screenshot(path=AUTH_SCREENSHOT, full_page=True)
154
+ except Exception:
155
+ pass
156
+
157
+ base = PUBLIC_BASE_URL or "<WORKER_BASE_URL>"
158
+ return (
159
+ "NEED_HUMAN_AUTH\n"
160
+ "需要你手工完成一次登录(扫码或短信)。\n"
161
+ f"1) 打开登录截图:{base}/auth/qr.png\n"
162
+ f"2) 打开辅助页面(可填短信验证码/刷新截图):{base}/auth\n"
163
+ "登录成功后,回到 Dify 重新运行同一个问题。\n"
164
+ )
165
+
166
+ async def _send_and_read_answer(question: str) -> str:
167
+ assert rt.page is not None
168
+
169
+ # Locate input (best-effort). Doubao is SPA; selectors may change.
170
+ inp = rt.page.locator(f"{INPUT_SELECTOR}:visible").last
171
+ await inp.click()
172
+ await inp.fill(question)
173
+
174
+ # Count answers before sending
175
+ ans = rt.page.locator(ANSWER_SELECTOR)
176
+ before = await ans.count()
177
+
178
+ # Send
179
+ send_btn = rt.page.locator(SEND_BTN_SELECTOR).first
180
+ await send_btn.click()
181
+
182
+ # Wait for at least one new answer block
183
+ try:
184
+ await rt.page.wait_for_function(
185
+ "(sel, n) => document.querySelectorAll(sel).length > n",
186
+ ANSWER_SELECTOR,
187
+ before,
188
+ timeout=180_000,
189
+ )
190
+ except PWTimeoutError:
191
+ # Fallback: return last visible text if any
192
+ if await ans.count() > 0:
193
+ return (await ans.last.inner_text()).strip()
194
+ raise
195
+
196
+ # Stabilize: poll last answer until it stops changing (streaming-like UIs)
197
+ last_text = ""
198
+ stable_rounds = 0
199
+ for _ in range(60):
200
+ txt = (await ans.last.inner_text()).strip()
201
+ if txt == last_text and txt:
202
+ stable_rounds += 1
203
+ else:
204
+ stable_rounds = 0
205
+ last_text = txt
206
+ if stable_rounds >= 3:
207
+ break
208
+ await rt.page.wait_for_timeout(500)
209
+
210
+ return last_text or "(空响应:已发送但未抓取到文本,请检查选择器或页面结构)"
211
+
212
+ @app.get("/api/health")
213
+ async def health():
214
+ return {"ok": True}
215
+
216
+ @app.post("/api/ask", response_class=PlainTextResponse)
217
+ async def ask(req: AskRequest, x_api_key: Optional[str] = Header(default=None, convert_underscores=False)):
218
+ _require_key(x_api_key)
219
+
220
+ q = (req.question or "").strip()
221
+ if not q:
222
+ raise HTTPException(status_code=400, detail="question is required")
223
+
224
+ async with rt.lock:
225
+ await _ensure_browser()
226
+ await _goto_chat()
227
+
228
+ if not await _is_chat_ready():
229
+ msg = await _prepare_human_auth()
230
+ return PlainTextResponse(msg, status_code=409)
231
+
232
+ try:
233
+ ans = await _send_and_read_answer(q)
234
+ return PlainTextResponse(ans, status_code=200)
235
+ except Exception as e:
236
+ # Screenshot helps debug selector/UI changes
237
+ try:
238
+ os.makedirs(PROFILE_DIR, exist_ok=True)
239
+ await rt.page.screenshot(path=os.path.join(PROFILE_DIR, "last_error.png"), full_page=True)
240
+ except Exception:
241
+ pass
242
+ raise HTTPException(status_code=500, detail=f"Ask failed: {type(e).__name__}: {e}")
243
+
244
+ @app.get("/auth/qr.png")
245
+ async def auth_qr_png(token: Optional[str] = None):
246
+ # Optional lightweight protection (re-use WORKER_API_KEY)
247
+ if WORKER_API_KEY and token != WORKER_API_KEY:
248
+ raise HTTPException(status_code=401, detail="token required")
249
+ if not os.path.exists(AUTH_SCREENSHOT):
250
+ raise HTTPException(status_code=404, detail="auth screenshot not ready. Call /api/ask once to generate it.")
251
+ return FileResponse(AUTH_SCREENSHOT, media_type="image/png")
252
+
253
+ @app.get("/auth", response_class=HTMLResponse)
254
+ async def auth_page(token: Optional[str] = None):
255
+ # Optional lightweight protection (re-use WORKER_API_KEY)
256
+ if WORKER_API_KEY and token != WORKER_API_KEY:
257
+ raise HTTPException(status_code=401, detail="token required")
258
+
259
+ base = PUBLIC_BASE_URL or ""
260
+ # A minimal helper page: auto-refresh screenshot and allow SMS code input.
261
+ # Note: SMS login selectors may need adjustment; QR scan is usually the most robust.
262
+ html = f"""
263
+ <!doctype html>
264
+ <html lang="zh-CN">
265
+ <head>
266
+ <meta charset="utf-8" />
267
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
268
+ <title>Doubao 登录辅助</title>
269
+ <style>
270
+ body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; margin: 24px; }}
271
+ img {{ max-width: 100%; border: 1px solid #ddd; }}
272
+ code {{ background:#f6f8fa; padding:2px 4px; border-radius:4px; }}
273
+ .row {{ display:flex; gap:24px; flex-wrap:wrap; }}
274
+ .card {{ border:1px solid #eee; border-radius:8px; padding:16px; max-width:900px; }}
275
+ input {{ padding:8px; width:260px; }}
276
+ button {{ padding:8px 12px; }}
277
+ </style>
278
+ </head>
279
+ <body>
280
+ <h2>豆包登录辅助(扫码/短信)</h2>
281
+ <p>1) 优先使用扫码:用手机直接扫下方截图里的二维码。</p>
282
+ <p>2) 如果走短信:先在豆包页面点“短信登录/获取验证码”,再把验证码填到下面提交(需要页面结构匹配)。</p>
283
+ <p>3) 登录完成后,回到 Dify 重新运行。</p>
284
+
285
+ <div class="row">
286
+ <div class="card">
287
+ <h3>实时截图</h3>
288
+ <p><code>/auth/qr.png?token=***</code>(页面每 2 秒刷新)</p>
289
+ <img id="shot" src="{base}/auth/qr.png?token={WORKER_API_KEY}&t={_now_ts()}" alt="auth screenshot"/>
290
+ </div>
291
+
292
+ <div class="card">
293
+ <h3>短信验证码提交(可选)</h3>
294
+ <form method="post" action="{base}/auth/sms?token={WORKER_API_KEY}">
295
+ <p><label>手机号(可选):<br/><input name="phone" placeholder="手机号"/></label></p>
296
+ <p><label>验证码:<br/><input name="code" placeholder="短信验证码" required/></label></p>
297
+ <p><button type="submit">提交验证码</button></p>
298
+ </form>
299
+ <p>如提交失败,请改用扫码,或调整 Worker 的短信登录选择器。</p>
300
+ </div>
301
+ </div>
302
+
303
+ <script>
304
+ setInterval(() => {{
305
+ const img = document.getElementById('shot');
306
+ img.src = "{base}/auth/qr.png?token={WORKER_API_KEY}&t=" + Date.now();
307
+ }}, 2000);
308
+ </script>
309
+ </body>
310
+ </html>
311
+ """
312
+ return HTMLResponse(html)
313
+
314
+ @app.post("/auth/sms", response_class=PlainTextResponse)
315
+ async def auth_sms(token: Optional[str] = None, phone: Optional[str] = None, code: str = ""):
316
+ # Optional lightweight protection (re-use WORKER_API_KEY)
317
+ if WORKER_API_KEY and token != WORKER_API_KEY:
318
+ raise HTTPException(status_code=401, detail="token required")
319
+
320
+ code = (code or "").strip()
321
+ phone = (phone or "").strip()
322
+ if not code:
323
+ raise HTTPException(status_code=400, detail="code is required")
324
+
325
+ async with rt.lock:
326
+ await _ensure_browser()
327
+ await _goto_chat()
328
+
329
+ # Best-effort: find phone/code inputs and fill them.
330
+ # If Doubao UI differs, prefer QR scan or adjust these selectors.
331
+ try:
332
+ if phone:
333
+ phone_box = rt.page.get_by_placeholder(re.compile(r"手机号|手机", re.I))
334
+ if await phone_box.count() > 0:
335
+ await phone_box.first.click()
336
+ await phone_box.first.fill(phone)
337
+
338
+ code_box = rt.page.get_by_placeholder(re.compile(r"验证码", re.I))
339
+ if await code_box.count() > 0:
340
+ await code_box.first.click()
341
+ await code_box.first.fill(code)
342
+
343
+ # Click a confirm/login button
344
+ ok_btn = rt.page.get_by_role("button", name=re.compile(r"登录|确定|确认|提交", re.I))
345
+ if await ok_btn.count() > 0:
346
+ await ok_btn.first.click()
347
+
348
+ await rt.page.wait_for_timeout(1500)
349
+ await rt.page.screenshot(path=AUTH_SCREENSHOT, full_page=True)
350
+ return PlainTextResponse("SMS_SUBMITTED. 如果仍未登录,请优先扫码,或检查/调整短信选择器。", status_code=200)
351
+ except Exception as e:
352
+ try:
353
+ await rt.page.screenshot(path=AUTH_SCREENSHOT, full_page=True)
354
+ except Exception:
355
+ pass
356
+ raise HTTPException(status_code=500, detail=f"SMS submit failed: {type(e).__name__}: {e}")
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.30.6
3
+ playwright==1.57.0
4
+ pydantic==2.9.2
5
+ python-multipart==0.0.12