Spaces:
Running
Running
github-actions[bot]
commited on
Commit
·
e513b2a
1
Parent(s):
91d2485
Update from GitHub Actions
Browse files- .env.example +19 -0
- .gitattributes +2 -0
- Dockerfile +66 -0
- package-lock.json +147 -0
- package.json +29 -0
- public/index.html +331 -0
- src/config.js +133 -0
- src/login.js +86 -0
- src/middlewares/auth.js +44 -0
- src/middlewares/cors.js +41 -0
- src/routes/chat-completions.js +53 -0
- src/routes/health.js +23 -0
- src/routes/models.js +75 -0
- src/utils/ai-studio-injector.js +266 -0
- src/utils/ai-studio-processor.js +264 -0
- src/utils/browser.js +949 -0
- src/utils/common-utils.js +104 -0
- src/utils/logger.js +90 -0
- src/utils/page-pool-monitor.js +57 -0
- src/utils/response-parser.js +189 -0
- src/utils/validation.js +41 -0
- src/web-server.js +129 -0
.env.example
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 服务器配置
|
2 |
+
PORT=3000
|
3 |
+
NODE_ENV=development
|
4 |
+
|
5 |
+
# 浏览器配置
|
6 |
+
HEADLESS=false
|
7 |
+
USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
|
8 |
+
|
9 |
+
# AI Studio 配置
|
10 |
+
AI_STUDIO_URL=https://aistudio.google.com/
|
11 |
+
PAGE_TIMEOUT=30000
|
12 |
+
DEFAULT_MODEL=gemini-pro
|
13 |
+
|
14 |
+
# CORS 配置
|
15 |
+
CORS_ORIGIN=*
|
16 |
+
|
17 |
+
# API 认证配置
|
18 |
+
# 设置一个强密码作为API访问令牌
|
19 |
+
API_TOKEN=your_secret_api_token_here
|
.gitattributes
CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
37 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 使用官方 Node.js 运行时作为基础镜像
|
2 |
+
FROM node:23-alpine
|
3 |
+
|
4 |
+
# 设置工作目录
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# 安装系统依赖
|
8 |
+
RUN apk add --no-cache \
|
9 |
+
# 基本构建工具
|
10 |
+
python3 \
|
11 |
+
make \
|
12 |
+
g++ \
|
13 |
+
# Playwright 依赖
|
14 |
+
chromium \
|
15 |
+
nss \
|
16 |
+
freetype \
|
17 |
+
freetype-dev \
|
18 |
+
harfbuzz \
|
19 |
+
ca-certificates \
|
20 |
+
ttf-freefont \
|
21 |
+
# 其他依赖
|
22 |
+
gcompat
|
23 |
+
|
24 |
+
# 设置 Playwright 的环境变量
|
25 |
+
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin
|
26 |
+
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
27 |
+
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
28 |
+
ENV PLAYWRIGHT_SKIP_BROWSER_VALIDATION=1
|
29 |
+
|
30 |
+
# 复制 package.json 和 package-lock.json(如果存在)
|
31 |
+
COPY package*.json ./
|
32 |
+
|
33 |
+
# 复制应用代码
|
34 |
+
COPY src/ ./src/
|
35 |
+
COPY public/ ./public/
|
36 |
+
# 复制 cookies.json 文件(如果存在)
|
37 |
+
# 使用通配符语法,如果文件不存在则跳过,不会报错
|
38 |
+
COPY cookies.json* ./
|
39 |
+
|
40 |
+
# 安装依赖
|
41 |
+
RUN npm ci --only=production
|
42 |
+
|
43 |
+
# 设置非 root 用户(安全最佳实践)
|
44 |
+
RUN addgroup -g 1001 -S nodejs && \
|
45 |
+
adduser -S nodejs -u 1001
|
46 |
+
|
47 |
+
|
48 |
+
# 更改文件所有权
|
49 |
+
# 创建 screenshots 和 logs 目录,并设置正确的权限
|
50 |
+
RUN mkdir -p /app/screenshots && \
|
51 |
+
mkdir -p /app/logs && \
|
52 |
+
chown -R nodejs:nodejs /app && \
|
53 |
+
chmod -R 777 /app/screenshots && \
|
54 |
+
chmod -R 777 /app/logs
|
55 |
+
|
56 |
+
USER nodejs
|
57 |
+
|
58 |
+
# 暴露端口
|
59 |
+
EXPOSE 7860
|
60 |
+
|
61 |
+
# 设置环境变量
|
62 |
+
ENV NODE_ENV=production
|
63 |
+
ENV PORT=7860
|
64 |
+
|
65 |
+
# 启动应用
|
66 |
+
CMD ["npm", "start"]
|
package-lock.json
ADDED
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "aistudio2api",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"lockfileVersion": 3,
|
5 |
+
"requires": true,
|
6 |
+
"packages": {
|
7 |
+
"": {
|
8 |
+
"name": "aistudio2api",
|
9 |
+
"version": "1.0.0",
|
10 |
+
"license": "MIT",
|
11 |
+
"dependencies": {
|
12 |
+
"dotenv": "^17.0.1",
|
13 |
+
"h3": "^2.0.0-beta.1",
|
14 |
+
"playwright": "^1.53.2"
|
15 |
+
},
|
16 |
+
"devDependencies": {
|
17 |
+
"@types/node": "^20.10.0"
|
18 |
+
}
|
19 |
+
},
|
20 |
+
"node_modules/@types/node": {
|
21 |
+
"version": "20.19.4",
|
22 |
+
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.4.tgz",
|
23 |
+
"integrity": "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==",
|
24 |
+
"dev": true,
|
25 |
+
"license": "MIT",
|
26 |
+
"dependencies": {
|
27 |
+
"undici-types": "~6.21.0"
|
28 |
+
}
|
29 |
+
},
|
30 |
+
"node_modules/cookie-es": {
|
31 |
+
"version": "2.0.0",
|
32 |
+
"resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-2.0.0.tgz",
|
33 |
+
"integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==",
|
34 |
+
"license": "MIT"
|
35 |
+
},
|
36 |
+
"node_modules/dotenv": {
|
37 |
+
"version": "17.0.1",
|
38 |
+
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.0.1.tgz",
|
39 |
+
"integrity": "sha512-GLjkduuAL7IMJg/ZnOPm9AnWKJ82mSE2tzXLaJ/6hD6DhwGfZaXG77oB8qbReyiczNxnbxQKyh0OE5mXq0bAHA==",
|
40 |
+
"license": "BSD-2-Clause",
|
41 |
+
"engines": {
|
42 |
+
"node": ">=12"
|
43 |
+
},
|
44 |
+
"funding": {
|
45 |
+
"url": "https://dotenvx.com"
|
46 |
+
}
|
47 |
+
},
|
48 |
+
"node_modules/fetchdts": {
|
49 |
+
"version": "0.1.5",
|
50 |
+
"resolved": "https://registry.npmmirror.com/fetchdts/-/fetchdts-0.1.5.tgz",
|
51 |
+
"integrity": "sha512-GCxyHdCCUm56atms+sIjOsAENvhebk3HAM1CfzgKCgMRjPUylpkkPmNknsaXe1gDRqM3cJbMhpkXMhCzXSE+Jg==",
|
52 |
+
"license": "MIT"
|
53 |
+
},
|
54 |
+
"node_modules/fsevents": {
|
55 |
+
"version": "2.3.2",
|
56 |
+
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
57 |
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
58 |
+
"hasInstallScript": true,
|
59 |
+
"license": "MIT",
|
60 |
+
"optional": true,
|
61 |
+
"os": [
|
62 |
+
"darwin"
|
63 |
+
],
|
64 |
+
"engines": {
|
65 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
66 |
+
}
|
67 |
+
},
|
68 |
+
"node_modules/h3": {
|
69 |
+
"version": "2.0.0-beta.1",
|
70 |
+
"resolved": "https://registry.npmmirror.com/h3/-/h3-2.0.0-beta.1.tgz",
|
71 |
+
"integrity": "sha512-sAhBVWbCi2AzApSLZhk41Rmiz+gjPhoAEqjJ96VH3OwyS3Co2kHVx3FZk0/OCCrzKWzYx3Pwf5WbEKDQ2plifg==",
|
72 |
+
"license": "MIT",
|
73 |
+
"dependencies": {
|
74 |
+
"cookie-es": "^2.0.0",
|
75 |
+
"fetchdts": "^0.1.5",
|
76 |
+
"rou3": "^0.7.3",
|
77 |
+
"srvx": "^0.8.1"
|
78 |
+
},
|
79 |
+
"engines": {
|
80 |
+
"node": ">=20.11.1"
|
81 |
+
},
|
82 |
+
"peerDependencies": {
|
83 |
+
"crossws": "^0.4.1"
|
84 |
+
},
|
85 |
+
"peerDependenciesMeta": {
|
86 |
+
"crossws": {
|
87 |
+
"optional": true
|
88 |
+
}
|
89 |
+
}
|
90 |
+
},
|
91 |
+
"node_modules/playwright": {
|
92 |
+
"version": "1.53.2",
|
93 |
+
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.53.2.tgz",
|
94 |
+
"integrity": "sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==",
|
95 |
+
"license": "Apache-2.0",
|
96 |
+
"dependencies": {
|
97 |
+
"playwright-core": "1.53.2"
|
98 |
+
},
|
99 |
+
"bin": {
|
100 |
+
"playwright": "cli.js"
|
101 |
+
},
|
102 |
+
"engines": {
|
103 |
+
"node": ">=18"
|
104 |
+
},
|
105 |
+
"optionalDependencies": {
|
106 |
+
"fsevents": "2.3.2"
|
107 |
+
}
|
108 |
+
},
|
109 |
+
"node_modules/playwright-core": {
|
110 |
+
"version": "1.53.2",
|
111 |
+
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.53.2.tgz",
|
112 |
+
"integrity": "sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==",
|
113 |
+
"license": "Apache-2.0",
|
114 |
+
"bin": {
|
115 |
+
"playwright-core": "cli.js"
|
116 |
+
},
|
117 |
+
"engines": {
|
118 |
+
"node": ">=18"
|
119 |
+
}
|
120 |
+
},
|
121 |
+
"node_modules/rou3": {
|
122 |
+
"version": "0.7.3",
|
123 |
+
"resolved": "https://registry.npmmirror.com/rou3/-/rou3-0.7.3.tgz",
|
124 |
+
"integrity": "sha512-KKenF/hB2iIhS1ohj226LT+/8uKCBpSMqeS4V1UPN9vad99uLoyIhrULRRB1skaB40LQHcBlSsAi3sT8MaoDDQ==",
|
125 |
+
"license": "MIT"
|
126 |
+
},
|
127 |
+
"node_modules/srvx": {
|
128 |
+
"version": "0.8.2",
|
129 |
+
"resolved": "https://registry.npmmirror.com/srvx/-/srvx-0.8.2.tgz",
|
130 |
+
"integrity": "sha512-anC1+7B6tryHQd4lFVSDZIfZ1QwJwqm5h1iveKwC1E40PA8nOD50hEt7+AlUoGc9jW3OdmztWBqf4yHCdCPdRQ==",
|
131 |
+
"license": "MIT",
|
132 |
+
"dependencies": {
|
133 |
+
"cookie-es": "^2.0.0"
|
134 |
+
},
|
135 |
+
"engines": {
|
136 |
+
"node": ">=20.16.0"
|
137 |
+
}
|
138 |
+
},
|
139 |
+
"node_modules/undici-types": {
|
140 |
+
"version": "6.21.0",
|
141 |
+
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
142 |
+
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
143 |
+
"dev": true,
|
144 |
+
"license": "MIT"
|
145 |
+
}
|
146 |
+
}
|
147 |
+
}
|
package.json
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "aistudio2api",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"description": "Google AI Studio to OpenAI API proxy using H3 and Playwright",
|
5 |
+
"main": "index.js",
|
6 |
+
"type": "module",
|
7 |
+
"scripts": {
|
8 |
+
"login": "node src/login.js",
|
9 |
+
"dev": "node --watch src/web-server.js",
|
10 |
+
"start": "node src/web-server.js"
|
11 |
+
},
|
12 |
+
"keywords": [
|
13 |
+
"h3",
|
14 |
+
"playwright",
|
15 |
+
"openai",
|
16 |
+
"proxy",
|
17 |
+
"google-ai-studio"
|
18 |
+
],
|
19 |
+
"author": "",
|
20 |
+
"license": "MIT",
|
21 |
+
"dependencies": {
|
22 |
+
"dotenv": "^17.0.1",
|
23 |
+
"h3": "^2.0.0-beta.1",
|
24 |
+
"playwright": "^1.53.2"
|
25 |
+
},
|
26 |
+
"devDependencies": {
|
27 |
+
"@types/node": "^20.10.0"
|
28 |
+
}
|
29 |
+
}
|
public/index.html
ADDED
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="zh-CN">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>AI Studio 2 API - 管理面板</title>
|
7 |
+
<style>
|
8 |
+
* {
|
9 |
+
margin: 0;
|
10 |
+
padding: 0;
|
11 |
+
box-sizing: border-box;
|
12 |
+
}
|
13 |
+
|
14 |
+
body {
|
15 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
16 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
17 |
+
min-height: 100vh;
|
18 |
+
color: #333;
|
19 |
+
}
|
20 |
+
|
21 |
+
.container {
|
22 |
+
max-width: 1200px;
|
23 |
+
margin: 0 auto;
|
24 |
+
padding: 20px;
|
25 |
+
}
|
26 |
+
|
27 |
+
.header {
|
28 |
+
text-align: center;
|
29 |
+
margin-bottom: 40px;
|
30 |
+
color: white;
|
31 |
+
}
|
32 |
+
|
33 |
+
.header h1 {
|
34 |
+
font-size: 2.5rem;
|
35 |
+
margin-bottom: 10px;
|
36 |
+
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
37 |
+
}
|
38 |
+
|
39 |
+
.header p {
|
40 |
+
font-size: 1.2rem;
|
41 |
+
opacity: 0.9;
|
42 |
+
}
|
43 |
+
|
44 |
+
.main-content {
|
45 |
+
display: grid;
|
46 |
+
grid-template-columns: 1fr 1fr;
|
47 |
+
gap: 30px;
|
48 |
+
margin-bottom: 40px;
|
49 |
+
}
|
50 |
+
|
51 |
+
.card {
|
52 |
+
background: white;
|
53 |
+
border-radius: 15px;
|
54 |
+
padding: 25px;
|
55 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
56 |
+
transition: transform 0.3s ease;
|
57 |
+
}
|
58 |
+
|
59 |
+
.card:hover {
|
60 |
+
transform: translateY(-5px);
|
61 |
+
}
|
62 |
+
|
63 |
+
.card h2 {
|
64 |
+
color: #4a5568;
|
65 |
+
margin-bottom: 20px;
|
66 |
+
font-size: 1.5rem;
|
67 |
+
border-bottom: 2px solid #e2e8f0;
|
68 |
+
padding-bottom: 10px;
|
69 |
+
}
|
70 |
+
|
71 |
+
.screenshot-container {
|
72 |
+
text-align: center;
|
73 |
+
}
|
74 |
+
|
75 |
+
.screenshot {
|
76 |
+
max-width: 100%;
|
77 |
+
height: auto;
|
78 |
+
border-radius: 10px;
|
79 |
+
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
80 |
+
margin-bottom: 15px;
|
81 |
+
}
|
82 |
+
|
83 |
+
.config-item {
|
84 |
+
display: flex;
|
85 |
+
justify-content: space-between;
|
86 |
+
align-items: center;
|
87 |
+
padding: 12px 0;
|
88 |
+
border-bottom: 1px solid #e2e8f0;
|
89 |
+
}
|
90 |
+
|
91 |
+
.config-item:last-child {
|
92 |
+
border-bottom: none;
|
93 |
+
}
|
94 |
+
|
95 |
+
.config-label {
|
96 |
+
font-weight: 600;
|
97 |
+
color: #4a5568;
|
98 |
+
}
|
99 |
+
|
100 |
+
.config-value {
|
101 |
+
color: #2d3748;
|
102 |
+
font-family: 'Courier New', monospace;
|
103 |
+
background: #f7fafc;
|
104 |
+
padding: 4px 8px;
|
105 |
+
border-radius: 4px;
|
106 |
+
font-size: 0.9rem;
|
107 |
+
}
|
108 |
+
|
109 |
+
.status-indicator {
|
110 |
+
display: inline-block;
|
111 |
+
width: 10px;
|
112 |
+
height: 10px;
|
113 |
+
border-radius: 50%;
|
114 |
+
margin-right: 8px;
|
115 |
+
}
|
116 |
+
|
117 |
+
.status-online {
|
118 |
+
background-color: #48bb78;
|
119 |
+
animation: pulse 2s infinite;
|
120 |
+
}
|
121 |
+
|
122 |
+
.status-offline {
|
123 |
+
background-color: #f56565;
|
124 |
+
}
|
125 |
+
|
126 |
+
@keyframes pulse {
|
127 |
+
0% { opacity: 1; }
|
128 |
+
50% { opacity: 0.5; }
|
129 |
+
100% { opacity: 1; }
|
130 |
+
}
|
131 |
+
|
132 |
+
.api-endpoints {
|
133 |
+
grid-column: 1 / -1;
|
134 |
+
}
|
135 |
+
|
136 |
+
.endpoint-list {
|
137 |
+
display: grid;
|
138 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
139 |
+
gap: 15px;
|
140 |
+
}
|
141 |
+
|
142 |
+
.endpoint {
|
143 |
+
background: #f8f9fa;
|
144 |
+
padding: 15px;
|
145 |
+
border-radius: 8px;
|
146 |
+
border-left: 4px solid #667eea;
|
147 |
+
}
|
148 |
+
|
149 |
+
.endpoint-method {
|
150 |
+
font-weight: bold;
|
151 |
+
color: #667eea;
|
152 |
+
margin-right: 10px;
|
153 |
+
}
|
154 |
+
|
155 |
+
.endpoint-url {
|
156 |
+
font-family: 'Courier New', monospace;
|
157 |
+
color: #2d3748;
|
158 |
+
}
|
159 |
+
|
160 |
+
.endpoint-desc {
|
161 |
+
font-size: 0.9rem;
|
162 |
+
color: #718096;
|
163 |
+
margin-top: 5px;
|
164 |
+
}
|
165 |
+
|
166 |
+
.footer {
|
167 |
+
text-align: center;
|
168 |
+
color: white;
|
169 |
+
opacity: 0.8;
|
170 |
+
margin-top: 40px;
|
171 |
+
}
|
172 |
+
|
173 |
+
@media (max-width: 768px) {
|
174 |
+
.main-content {
|
175 |
+
grid-template-columns: 1fr;
|
176 |
+
}
|
177 |
+
|
178 |
+
.header h1 {
|
179 |
+
font-size: 2rem;
|
180 |
+
}
|
181 |
+
|
182 |
+
.container {
|
183 |
+
padding: 15px;
|
184 |
+
}
|
185 |
+
}
|
186 |
+
</style>
|
187 |
+
</head>
|
188 |
+
<body>
|
189 |
+
<div class="container">
|
190 |
+
<div class="header">
|
191 |
+
<h1>🤖 AI Studio 2 API</h1>
|
192 |
+
<p>Google AI Studio 到 OpenAI API 格式的转换服务</p>
|
193 |
+
</div>
|
194 |
+
|
195 |
+
<div class="main-content">
|
196 |
+
<div class="card screenshot-container">
|
197 |
+
<h2>📸 AI Studio 状态截图</h2>
|
198 |
+
<img src="/screenshots/aistudio-startup.png" alt="AI Studio 启动截图" class="screenshot" id="screenshot">
|
199 |
+
<p style="color: #718096; font-size: 0.9rem;">最后更新: <span id="lastUpdate">加载中...</span></p>
|
200 |
+
</div>
|
201 |
+
|
202 |
+
<div class="card">
|
203 |
+
<h2>⚙️ 基础配置</h2>
|
204 |
+
<div class="config-item">
|
205 |
+
<span class="config-label">服务状态</span>
|
206 |
+
<span class="config-value">
|
207 |
+
<span class="status-indicator status-online"></span>
|
208 |
+
运行中
|
209 |
+
</span>
|
210 |
+
</div>
|
211 |
+
<div class="config-item">
|
212 |
+
<span class="config-label">服务端口</span>
|
213 |
+
<span class="config-value" id="serverPort">加载中...</span>
|
214 |
+
</div>
|
215 |
+
<div class="config-item">
|
216 |
+
<span class="config-label">AI Studio URL</span>
|
217 |
+
<span class="config-value" id="aiStudioUrl">加载中...</span>
|
218 |
+
</div>
|
219 |
+
<div class="config-item">
|
220 |
+
<span class="config-label">默认模型</span>
|
221 |
+
<span class="config-value" id="defaultModel">加载中...</span>
|
222 |
+
</div>
|
223 |
+
<div class="config-item">
|
224 |
+
<span class="config-label">浏览器模式</span>
|
225 |
+
<span class="config-value" id="browserMode">加载中...</span>
|
226 |
+
</div>
|
227 |
+
<div class="config-item">
|
228 |
+
<span class="config-label">页面超时</span>
|
229 |
+
<span class="config-value" id="pageTimeout">加载中...</span>
|
230 |
+
</div>
|
231 |
+
</div>
|
232 |
+
|
233 |
+
<div class="card api-endpoints">
|
234 |
+
<h2>🔗 API 端点</h2>
|
235 |
+
<div class="endpoint-list">
|
236 |
+
<div class="endpoint">
|
237 |
+
<div>
|
238 |
+
<span class="endpoint-method">GET</span>
|
239 |
+
<span class="endpoint-url">/health</span>
|
240 |
+
</div>
|
241 |
+
<div class="endpoint-desc">健康检查端点</div>
|
242 |
+
</div>
|
243 |
+
<div class="endpoint">
|
244 |
+
<div>
|
245 |
+
<span class="endpoint-method">GET</span>
|
246 |
+
<span class="endpoint-url">/v1/models</span>
|
247 |
+
</div>
|
248 |
+
<div class="endpoint-desc">获取可用模型列表</div>
|
249 |
+
</div>
|
250 |
+
<div class="endpoint">
|
251 |
+
<div>
|
252 |
+
<span class="endpoint-method">GET</span>
|
253 |
+
<span class="endpoint-url">/v1/models/{model}</span>
|
254 |
+
</div>
|
255 |
+
<div class="endpoint-desc">获取特定模型信息</div>
|
256 |
+
</div>
|
257 |
+
<div class="endpoint">
|
258 |
+
<div>
|
259 |
+
<span class="endpoint-method">POST</span>
|
260 |
+
<span class="endpoint-url">/v1/chat/completions</span>
|
261 |
+
</div>
|
262 |
+
<div class="endpoint-desc">聊天完成 API (兼容 OpenAI 格式)</div>
|
263 |
+
</div>
|
264 |
+
</div>
|
265 |
+
</div>
|
266 |
+
</div>
|
267 |
+
|
268 |
+
<div class="footer">
|
269 |
+
<p>© 2024 AI Studio 2 API - 基于 H3 v2 构建</p>
|
270 |
+
</div>
|
271 |
+
</div>
|
272 |
+
|
273 |
+
<script>
|
274 |
+
// 获取配置信息
|
275 |
+
async function loadConfig() {
|
276 |
+
try {
|
277 |
+
// 从健康检查端点获取基本信息
|
278 |
+
const healthResponse = await fetch('/health');
|
279 |
+
const healthData = await healthResponse.json();
|
280 |
+
|
281 |
+
// 更新页面信息
|
282 |
+
document.getElementById('serverPort').textContent = window.location.port || '7860';
|
283 |
+
document.getElementById('aiStudioUrl').textContent = 'https://aistudio.google.com/prompts/new_chat';
|
284 |
+
document.getElementById('defaultModel').textContent = 'gemini-pro';
|
285 |
+
document.getElementById('browserMode').textContent = 'Headless';
|
286 |
+
document.getElementById('pageTimeout').textContent = '30000ms';
|
287 |
+
|
288 |
+
// 更新截图时间戳
|
289 |
+
updateScreenshotTimestamp();
|
290 |
+
|
291 |
+
} catch (error) {
|
292 |
+
console.error('加载配置失败:', error);
|
293 |
+
// 设置默认值
|
294 |
+
document.getElementById('serverPort').textContent = window.location.port || '7860';
|
295 |
+
document.getElementById('aiStudioUrl').textContent = 'https://aistudio.google.com/prompts/new_chat';
|
296 |
+
document.getElementById('defaultModel').textContent = 'gemini-pro';
|
297 |
+
document.getElementById('browserMode').textContent = 'Headless';
|
298 |
+
document.getElementById('pageTimeout').textContent = '30000ms';
|
299 |
+
}
|
300 |
+
}
|
301 |
+
|
302 |
+
// 更新截图时间戳
|
303 |
+
function updateScreenshotTimestamp() {
|
304 |
+
const now = new Date();
|
305 |
+
const timestamp = now.toLocaleString('zh-CN', {
|
306 |
+
year: 'numeric',
|
307 |
+
month: '2-digit',
|
308 |
+
day: '2-digit',
|
309 |
+
hour: '2-digit',
|
310 |
+
minute: '2-digit',
|
311 |
+
second: '2-digit'
|
312 |
+
});
|
313 |
+
document.getElementById('lastUpdate').textContent = timestamp;
|
314 |
+
}
|
315 |
+
|
316 |
+
// 处理截图加载错误
|
317 |
+
document.getElementById('screenshot').onerror = function() {
|
318 |
+
this.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxOCIgZmlsbD0iIzk5OTk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPuaIquWbvuS4jeWPr+eUqDwvdGV4dD48L3N2Zz4=';
|
319 |
+
this.alt = '截图不可用';
|
320 |
+
};
|
321 |
+
|
322 |
+
// 页面加载完成后执行
|
323 |
+
document.addEventListener('DOMContentLoaded', function() {
|
324 |
+
loadConfig();
|
325 |
+
|
326 |
+
// 每30秒刷新一次截图时间戳
|
327 |
+
setInterval(updateScreenshotTimestamp, 30000);
|
328 |
+
});
|
329 |
+
</script>
|
330 |
+
</body>
|
331 |
+
</html>
|
src/config.js
ADDED
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import dotenv from 'dotenv';
|
2 |
+
|
3 |
+
// 加载 .env 文件中的环境变量
|
4 |
+
dotenv.config();
|
5 |
+
|
6 |
+
/**
|
7 |
+
* @typedef {Object} AppConfig
|
8 |
+
* @property {Object} server - 服务器配置
|
9 |
+
* @property {number} server.port - 监听端口
|
10 |
+
* @property {string} server.host - 监听主机
|
11 |
+
* @property {Object} browser - Playwright 浏览器配置
|
12 |
+
* @property {boolean} browser.headless - 是否以无头模式运行
|
13 |
+
* @property {number} browser.timeout - 浏览器操作的全局超时时间
|
14 |
+
* @property {string|undefined} browser.executablePath - Chromium 可执行文件路径
|
15 |
+
* @property {string[]} browser.args - 浏览器启动参数
|
16 |
+
* @property {string} browser.userAgent - 浏览器 User-Agent
|
17 |
+
* @property {string} cookieFile - Cookie 存储文件路径
|
18 |
+
* @property {string|undefined} cookiesFromEnv - 从环境变量读取的 Cookie 字符串
|
19 |
+
* @property {string} screenshotDir - 截图保存目录
|
20 |
+
* @property {Object} aiStudio - AI Studio 相关配置
|
21 |
+
* @property {string} aiStudio.url - AI Studio 的目标 URL
|
22 |
+
* @property {number} aiStudio.responseTimeout - 等待 AI 响应的超时时间
|
23 |
+
* @property {number} aiStudio.pageTimeout - 页面加载的超时时间
|
24 |
+
* @property {Object} api - API 相关配置
|
25 |
+
* @property {string} api.defaultModel - 默认使用的模型 ID
|
26 |
+
* @property {number} api.maxTokens - 支持的最大令牌数 (此为示例,实际限制在网页端)
|
27 |
+
* @property {number} api.temperature - 默认温度
|
28 |
+
* @property {string|undefined} api.token - API 访问令牌
|
29 |
+
* @property {Object.<string, Object>} models - 支持的模型列表
|
30 |
+
* @property {Object} debug - 调试配置
|
31 |
+
* @property {boolean} debug.logRequests - 是否记录请求体
|
32 |
+
* @property {boolean} debug.logResponses - 是否记录响应体
|
33 |
+
* @property {boolean} debug.saveScreenshots - 是否在出错时保存截图
|
34 |
+
*/
|
35 |
+
|
36 |
+
/** @type {AppConfig} */
|
37 |
+
const config = {
|
38 |
+
// 服务器配置
|
39 |
+
server: {
|
40 |
+
port: parseInt(process.env.PORT || '3096', 10),
|
41 |
+
host: process.env.HOST || 'localhost'
|
42 |
+
},
|
43 |
+
|
44 |
+
// Playwright 浏览器配置
|
45 |
+
browser: {
|
46 |
+
headless: (process.env.HEADLESS || 'true').toLowerCase() !== 'false',
|
47 |
+
timeout: parseInt(process.env.TIMEOUT || '30000', 10),
|
48 |
+
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
49 |
+
args: [
|
50 |
+
'--disable-blink-features=AutomationControlled', // 防止被检测为自动化工具
|
51 |
+
'--no-sandbox',
|
52 |
+
'--disable-dev-shm-usage',
|
53 |
+
'--disable-infobars',
|
54 |
+
'--disable-extensions',
|
55 |
+
],
|
56 |
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
57 |
+
},
|
58 |
+
|
59 |
+
// Cookie 配置
|
60 |
+
cookieFile: './cookies.json',
|
61 |
+
cookiesFromEnv: process.env.COOKIES,
|
62 |
+
|
63 |
+
// 截图目录
|
64 |
+
screenshotDir: './screenshots',
|
65 |
+
|
66 |
+
// Google AI Studio 配置
|
67 |
+
aiStudio: {
|
68 |
+
url: process.env.AI_STUDIO_URL || 'https://aistudio.google.com/prompts/new_chat',
|
69 |
+
responseTimeout: parseInt(process.env.RESPONSE_TIMEOUT || '600000', 10), // 10分钟
|
70 |
+
pageTimeout: parseInt(process.env.PAGE_TIMEOUT || '30000', 10) // 30秒
|
71 |
+
},
|
72 |
+
|
73 |
+
// API 兼容层配置
|
74 |
+
api: {
|
75 |
+
defaultModel: process.env.DEFAULT_MODEL || 'gemini-pro',
|
76 |
+
maxTokens: 65536, // 理论值,实际由网页决定
|
77 |
+
temperature: 1,
|
78 |
+
token: process.env.API_TOKEN
|
79 |
+
},
|
80 |
+
|
81 |
+
// 可用模型列表 (遵循 OpenAI API 格式)
|
82 |
+
models: {
|
83 |
+
'gemini-2.5-pro': {
|
84 |
+
displayName: 'Gemini 2.5 Pro',
|
85 |
+
id: 'gemini-2.5-pro',
|
86 |
+
object: 'model',
|
87 |
+
created: 1704067200, // 2024-01-01
|
88 |
+
owned_by: 'google',
|
89 |
+
permission: [],
|
90 |
+
root: 'gemini-2.5-pro',
|
91 |
+
parent: null
|
92 |
+
},
|
93 |
+
'gemini-2.5-flash': {
|
94 |
+
displayName: 'Gemini 2.5 Flash',
|
95 |
+
id: 'gemini-2.5-flash',
|
96 |
+
object: 'model',
|
97 |
+
created: 1704067200,
|
98 |
+
owned_by: 'google',
|
99 |
+
permission: [],
|
100 |
+
root: 'gemini-2.5-flash',
|
101 |
+
parent: null
|
102 |
+
},
|
103 |
+
'gemini-pro': {
|
104 |
+
displayName: 'Gemini Pro',
|
105 |
+
id: 'gemini-pro',
|
106 |
+
object: 'model',
|
107 |
+
created: 1701388800, // 2023-12-01
|
108 |
+
owned_by: 'google',
|
109 |
+
permission: [],
|
110 |
+
root: 'gemini-pro',
|
111 |
+
parent: null
|
112 |
+
},
|
113 |
+
'gemini-flash': {
|
114 |
+
displayName: 'Gemini Flash',
|
115 |
+
id: 'gemini-flash',
|
116 |
+
object: 'model',
|
117 |
+
created: 1701388800,
|
118 |
+
owned_by: 'google',
|
119 |
+
permission: [],
|
120 |
+
root: 'gemini-flash',
|
121 |
+
parent: null
|
122 |
+
}
|
123 |
+
},
|
124 |
+
|
125 |
+
// 调试选项
|
126 |
+
debug: {
|
127 |
+
logRequests: process.env.DEBUG_REQUESTS === 'true',
|
128 |
+
logResponses: process.env.DEBUG_RESPONSES === 'true',
|
129 |
+
saveScreenshots: process.env.SAVE_SCREENSHOTS === 'true'
|
130 |
+
}
|
131 |
+
};
|
132 |
+
|
133 |
+
export default config;
|
src/login.js
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { chromium } from 'playwright';
|
2 |
+
import fs from 'fs';
|
3 |
+
import path from 'path';
|
4 |
+
import { fileURLToPath } from 'url';
|
5 |
+
import config from './config.js';
|
6 |
+
import { loadCookies, waitForUserInput } from './utils/common-utils.js';
|
7 |
+
import { info, error } from './utils/logger.js';
|
8 |
+
import { handleWelcomeModal } from './utils/browser.js';
|
9 |
+
|
10 |
+
/**
|
11 |
+
* 启动一个非无头浏览器,引导用户手动登录 Google AI Studio 并保存 Cookie。
|
12 |
+
*/
|
13 |
+
async function login() {
|
14 |
+
info('启动浏览器以进行手动登录...');
|
15 |
+
const browser = await chromium.launch({
|
16 |
+
headless: false, // 必须为 false 以便用户交互
|
17 |
+
timeout: config.browser.timeout,
|
18 |
+
args: config.browser.args
|
19 |
+
});
|
20 |
+
|
21 |
+
const context = await browser.newContext({
|
22 |
+
userAgent: config.browser.userAgent
|
23 |
+
});
|
24 |
+
|
25 |
+
// 尝试加载现有 Cookie
|
26 |
+
const existingCookies = loadCookies(config.cookieFile, config.cookiesFromEnv);
|
27 |
+
if (existingCookies.length > 0) {
|
28 |
+
await context.addCookies(existingCookies);
|
29 |
+
info(`已加载 ${existingCookies.length} 个现有 Cookie。`);
|
30 |
+
}
|
31 |
+
|
32 |
+
const page = await context.newPage();
|
33 |
+
|
34 |
+
try {
|
35 |
+
info(`导航至 Google AI Studio: ${config.aiStudio.url}`);
|
36 |
+
await page.goto(config.aiStudio.url, { waitUntil: 'networkidle' });
|
37 |
+
await page.waitForTimeout(3000); // 等待页面稳定
|
38 |
+
|
39 |
+
// 尝试处理可能出现的欢迎弹窗
|
40 |
+
await handleWelcomeModal(page);
|
41 |
+
|
42 |
+
info('当前页面 URL:', page.url());
|
43 |
+
info('页面标题:', await page.title());
|
44 |
+
|
45 |
+
info('请在浏览器窗口中完成登录操作。');
|
46 |
+
info('登录成功并跳转到 AI Studio 主界面后,请返回此处按 Enter 键继续...');
|
47 |
+
await waitForUserInput();
|
48 |
+
|
49 |
+
info('用户确认登录完成,正在保存 Cookie...');
|
50 |
+
|
51 |
+
// 再次导航以确保在正确的页面上
|
52 |
+
try {
|
53 |
+
await page.goto(config.aiStudio.url, { waitUntil: 'load', timeout: 15000 });
|
54 |
+
await page.waitForTimeout(5000); // 等待页面完全加载
|
55 |
+
} catch (err) {
|
56 |
+
info('导航到主页时出现小问题,但这通常不影响 Cookie 保存。');
|
57 |
+
}
|
58 |
+
|
59 |
+
const newCookies = await context.cookies();
|
60 |
+
if (newCookies.length > 0) {
|
61 |
+
fs.writeFileSync(config.cookieFile, JSON.stringify(newCookies, null, 2));
|
62 |
+
info(`成功将 ${newCookies.length} 个 Cookie 保存到 ${config.cookieFile}`);
|
63 |
+
info('主要 Cookie 名称:');
|
64 |
+
newCookies.slice(0, 5).forEach(cookie => {
|
65 |
+
info(` - ${cookie.name} (域: ${cookie.domain})`);
|
66 |
+
});
|
67 |
+
} else {
|
68 |
+
error('未能获取到任何 Cookie,请确保已成功登录。');
|
69 |
+
}
|
70 |
+
|
71 |
+
} catch (err) {
|
72 |
+
error('登录过程中发生严重错误:', err);
|
73 |
+
} finally {
|
74 |
+
info('关闭浏览器。');
|
75 |
+
await browser.close();
|
76 |
+
}
|
77 |
+
}
|
78 |
+
|
79 |
+
// 使该文件可以直接通过 `node login.js` 运行
|
80 |
+
const __filename = fileURLToPath(import.meta.url);
|
81 |
+
const scriptPath = path.resolve(process.argv[1]);
|
82 |
+
if (path.resolve(__filename) === scriptPath) {
|
83 |
+
login().catch(err => error('执行登录脚本失败:', err));
|
84 |
+
}
|
85 |
+
|
86 |
+
export default login;
|
src/middlewares/auth.js
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createError } from 'h3';
|
2 |
+
import config from '../config.js';
|
3 |
+
|
4 |
+
/**
|
5 |
+
* API Token 认证中间件。
|
6 |
+
* 验证请求头中的 `Authorization: Bearer <token>` 是否与配置的 API_TOKEN 匹配。
|
7 |
+
* @param {import('h3').H3Event} event - H3 事件对象。
|
8 |
+
* @throws {Error} 如果认证失败,则抛出 H3 错误。
|
9 |
+
*/
|
10 |
+
export function apiTokenAuth(event) {
|
11 |
+
// 如果未配置 API_TOKEN,则跳过认证
|
12 |
+
if (!config.api.token) {
|
13 |
+
// 在开发环境中,这可能是预期的行为,但在生产中应发出警告
|
14 |
+
if (process.env.NODE_ENV === 'production') {
|
15 |
+
console.warn('警告: API_TOKEN 未在生产环境中配置,API 对外开放!');
|
16 |
+
}
|
17 |
+
return;
|
18 |
+
}
|
19 |
+
|
20 |
+
const authHeader = event.node.req.headers.authorization;
|
21 |
+
|
22 |
+
if (!authHeader) {
|
23 |
+
throw createError({
|
24 |
+
statusCode: 401,
|
25 |
+
statusMessage: 'Unauthorized: Missing Authorization header.'
|
26 |
+
});
|
27 |
+
}
|
28 |
+
|
29 |
+
const [authType, token] = authHeader.split(' ');
|
30 |
+
|
31 |
+
if (authType !== 'Bearer' || !token) {
|
32 |
+
throw createError({
|
33 |
+
statusCode: 401,
|
34 |
+
statusMessage: 'Unauthorized: Invalid Authorization header format. Expected: Bearer <token>.'
|
35 |
+
});
|
36 |
+
}
|
37 |
+
|
38 |
+
if (token !== config.api.token) {
|
39 |
+
throw createError({
|
40 |
+
statusCode: 401,
|
41 |
+
statusMessage: 'Unauthorized: Invalid API token.'
|
42 |
+
});
|
43 |
+
}
|
44 |
+
}
|
src/middlewares/cors.js
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* CORS (跨域资源共享) 中间件。
|
3 |
+
* 为所有请求设置通用的 CORS 响应头。
|
4 |
+
* @param {import('h3').H3Event} event - H3 事件对象。
|
5 |
+
* @returns {Response|void} 如果是 OPTIONS 请求,则返回一个 204 响应。
|
6 |
+
*/
|
7 |
+
export function corsMiddleware(event) {
|
8 |
+
const headers = event.node.res.getHeaders();
|
9 |
+
|
10 |
+
if (!headers['access-control-allow-origin']) {
|
11 |
+
event.node.res.setHeader('Access-Control-Allow-Origin', process.env.CORS_ORIGIN || '*');
|
12 |
+
}
|
13 |
+
if (!headers['access-control-allow-methods']) {
|
14 |
+
event.node.res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
15 |
+
}
|
16 |
+
if (!headers['access-control-allow-headers']) {
|
17 |
+
event.node.res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
18 |
+
}
|
19 |
+
if (!headers['access-control-max-age']) {
|
20 |
+
event.node.res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
|
21 |
+
}
|
22 |
+
|
23 |
+
// 处理预检请求 (OPTIONS)
|
24 |
+
if (event.node.req.method === 'OPTIONS') {
|
25 |
+
event.node.res.statusCode = 204;
|
26 |
+
return event.node.res.end();
|
27 |
+
}
|
28 |
+
}
|
29 |
+
|
30 |
+
/**
|
31 |
+
* 为流式响应 (Server-Sent Events) 设置特定的响应头。
|
32 |
+
* @param {import('h3').H3Event} event - H3 事件对象。
|
33 |
+
*/
|
34 |
+
export function setStreamHeaders(event) {
|
35 |
+
event.node.res.setHeader('Content-Type', 'text/event-stream');
|
36 |
+
event.node.res.setHeader('Cache-Control', 'no-cache');
|
37 |
+
event.node.res.setHeader('Connection', 'keep-alive');
|
38 |
+
// CORS 头也需要为流式响应设置
|
39 |
+
event.node.res.setHeader('Access-Control-Allow-Origin', process.env.CORS_ORIGIN || '*');
|
40 |
+
event.node.res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
41 |
+
}
|
src/routes/chat-completions.js
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { readBody, createError } from 'h3';
|
2 |
+
import { validateChatCompletionRequest } from '../utils/validation.js';
|
3 |
+
import { setStreamHeaders } from '../middlewares/cors.js';
|
4 |
+
import { apiTokenAuth } from '../middlewares/auth.js';
|
5 |
+
import { processAIStudioRequest, processAIStudioRequestSync } from '../utils/ai-studio-processor.js';
|
6 |
+
|
7 |
+
/**
|
8 |
+
* 处理 `/v1/chat/completions` 的 POST 请求。
|
9 |
+
* 支持流式和非流式响应。
|
10 |
+
* @param {import('h3').H3Event} event - H3 事件对象。
|
11 |
+
* @returns {Promise<object|ReadableStream>} OpenAI 格式的响应对象或 SSE 流。
|
12 |
+
*/
|
13 |
+
export async function chatCompletionsHandler(event) {
|
14 |
+
try {
|
15 |
+
// 1. 认证
|
16 |
+
apiTokenAuth(event);
|
17 |
+
|
18 |
+
// 2. 读取和验证请求体
|
19 |
+
const body = await readBody(event);
|
20 |
+
const { prompt, stream, model, temperature } = validateChatCompletionRequest(body);
|
21 |
+
|
22 |
+
// 3. 根据 stream 参数决定处理方式
|
23 |
+
if (stream) {
|
24 |
+
// 流式响应
|
25 |
+
setStreamHeaders(event);
|
26 |
+
|
27 |
+
const stream = new ReadableStream({
|
28 |
+
start(controller) {
|
29 |
+
// 将请求处理委托给 AI Studio 处理器
|
30 |
+
processAIStudioRequest(prompt, controller, { model, temperature });
|
31 |
+
}
|
32 |
+
});
|
33 |
+
|
34 |
+
return stream;
|
35 |
+
|
36 |
+
} else {
|
37 |
+
// 非流式响应
|
38 |
+
const result = await processAIStudioRequestSync(prompt, { model, temperature });
|
39 |
+
return result;
|
40 |
+
}
|
41 |
+
|
42 |
+
} catch (error) {
|
43 |
+
console.error('处理聊天请求时出错:', error);
|
44 |
+
// 重新抛出 H3 错误或将标准错误包装成 H3 错误
|
45 |
+
if (error.statusCode) {
|
46 |
+
throw error;
|
47 |
+
}
|
48 |
+
throw createError({
|
49 |
+
statusCode: 500,
|
50 |
+
statusMessage: error.message || 'An internal server error occurred.'
|
51 |
+
});
|
52 |
+
}
|
53 |
+
}
|
src/routes/health.js
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { getPagePoolStatus } from '../utils/browser.js';
|
2 |
+
|
3 |
+
/**
|
4 |
+
* 处理 `/health` 的 GET 请求,提供服务健康状态。
|
5 |
+
* @param {import('h3').H3Event} event - H3 事件对象。
|
6 |
+
* @returns {object} 包含服务状态和页面池信息的健康报告。
|
7 |
+
*/
|
8 |
+
export function healthHandler(event) {
|
9 |
+
const pagePoolStatus = getPagePoolStatus();
|
10 |
+
const utilization = pagePoolStatus.total > 0
|
11 |
+
? Math.round((pagePoolStatus.busy / pagePoolStatus.total) * 100)
|
12 |
+
: 0;
|
13 |
+
|
14 |
+
return {
|
15 |
+
status: 'ok',
|
16 |
+
timestamp: new Date().toISOString(),
|
17 |
+
version: process.env.npm_package_version || '1.0.0',
|
18 |
+
pagePool: {
|
19 |
+
...pagePoolStatus,
|
20 |
+
utilization: `${utilization}%`
|
21 |
+
}
|
22 |
+
};
|
23 |
+
}
|
src/routes/models.js
ADDED
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createError } from 'h3';
|
2 |
+
import config from '../config.js';
|
3 |
+
|
4 |
+
/**
|
5 |
+
* 处理 `/v1/models` 的 GET 请求,返回所有可用模型的列表。
|
6 |
+
* @param {import('h3').H3Event} event - H3 事件对象。
|
7 |
+
* @returns {Promise<object>} OpenAI 格式的模型列表响应。
|
8 |
+
*/
|
9 |
+
export async function modelsHandler(event) {
|
10 |
+
try {
|
11 |
+
const models = Object.values(config.models).map(model => ({
|
12 |
+
id: model.id,
|
13 |
+
object: model.object,
|
14 |
+
created: model.created,
|
15 |
+
owned_by: model.owned_by,
|
16 |
+
permission: model.permission,
|
17 |
+
root: model.root,
|
18 |
+
parent: model.parent
|
19 |
+
}));
|
20 |
+
|
21 |
+
// 按创建时间降序排序
|
22 |
+
models.sort((a, b) => b.created - a.created);
|
23 |
+
|
24 |
+
return {
|
25 |
+
object: 'list',
|
26 |
+
data: models
|
27 |
+
};
|
28 |
+
} catch (error) {
|
29 |
+
console.error('获取模型列表时出错:', error);
|
30 |
+
throw createError({
|
31 |
+
statusCode: 500,
|
32 |
+
statusMessage: 'Failed to retrieve models list.'
|
33 |
+
});
|
34 |
+
}
|
35 |
+
}
|
36 |
+
|
37 |
+
/**
|
38 |
+
* 处理 `/v1/models/:model` 的 GET 请求,返回单个模型的详细信息。
|
39 |
+
* @param {import('h3').H3Event} event - H3 事件对象。
|
40 |
+
* @returns {Promise<object>} OpenAI 格式的单个模型信息。
|
41 |
+
*/
|
42 |
+
export async function modelHandler(event) {
|
43 |
+
try {
|
44 |
+
const modelId = event.context.params?.model;
|
45 |
+
|
46 |
+
if (!modelId) {
|
47 |
+
throw createError({ statusCode: 400, statusMessage: 'Model ID is required.' });
|
48 |
+
}
|
49 |
+
|
50 |
+
const model = config.models[modelId];
|
51 |
+
|
52 |
+
if (!model) {
|
53 |
+
throw createError({ statusCode: 404, statusMessage: `Model '${modelId}' not found.` });
|
54 |
+
}
|
55 |
+
|
56 |
+
return {
|
57 |
+
id: model.id,
|
58 |
+
object: model.object,
|
59 |
+
created: model.created,
|
60 |
+
owned_by: model.owned_by,
|
61 |
+
permission: model.permission,
|
62 |
+
root: model.root,
|
63 |
+
parent: model.parent
|
64 |
+
};
|
65 |
+
} catch (error) {
|
66 |
+
console.error(`获取模型 '${event.context.params?.model}' 信息时出错:`, error);
|
67 |
+
if (error.statusCode) {
|
68 |
+
throw error;
|
69 |
+
}
|
70 |
+
throw createError({
|
71 |
+
statusCode: 500,
|
72 |
+
statusMessage: 'Failed to retrieve model information.'
|
73 |
+
});
|
74 |
+
}
|
75 |
+
}
|
src/utils/ai-studio-injector.js
ADDED
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { handleWelcomeModal, simulateHumanRandomClicks, simulateHumanBehavior } from './browser.js';
|
2 |
+
import config from '../config.js';
|
3 |
+
/**
|
4 |
+
* 封装了与 Google AI Studio 页面交互的所有操作,
|
5 |
+
* 如输入文本、点击按钮、设置模型和温度等。
|
6 |
+
*/
|
7 |
+
export class AIStudioInjector {
|
8 |
+
/**
|
9 |
+
* @param {import('playwright').Page} page Playwright 页面对象
|
10 |
+
* @param {object} [options={}] 配置选项
|
11 |
+
* @param {boolean} [options.enableHumanSimulation=true] 是否启用人类行为模拟
|
12 |
+
*/
|
13 |
+
constructor(page, options = {}) {
|
14 |
+
this.page = page;
|
15 |
+
this.options = {
|
16 |
+
enableHumanSimulation: true,
|
17 |
+
...options
|
18 |
+
};
|
19 |
+
this.modelMapping = Object.fromEntries(
|
20 |
+
Object.entries(config.models).map(([key, model]) => [key, model.displayName])
|
21 |
+
);
|
22 |
+
}
|
23 |
+
/**
|
24 |
+
* 等待页面主要内容加载完成并处理欢迎弹窗。
|
25 |
+
* @returns {Promise<boolean>} 是否加载成功。
|
26 |
+
*/
|
27 |
+
async waitForPageLoad() {
|
28 |
+
try {
|
29 |
+
await this.page.waitForSelector('body', { timeout: 15000 });
|
30 |
+
await handleWelcomeModal(this.page);
|
31 |
+
return true;
|
32 |
+
} catch (error) {
|
33 |
+
console.error('页面加载超时或失败:', error);
|
34 |
+
return false;
|
35 |
+
}
|
36 |
+
}
|
37 |
+
/**
|
38 |
+
* 查找页面上的主输入框。
|
39 |
+
* 增强了选择器和等待逻辑,以提高在动态加载页面上的成功率。
|
40 |
+
* @returns {Promise<import('playwright').Locator|null>} 输入框的定位器。
|
41 |
+
*/
|
42 |
+
async findInputElement() {
|
43 |
+
// 首先,等待一个稳定的父容器出现,这表明输入区域已开始渲染。
|
44 |
+
// 这可以大大减少因时序问题导致的查找失败。
|
45 |
+
try {
|
46 |
+
await this.page.waitForSelector('footer ms-prompt-input-wrapper', { timeout: 10000 });
|
47 |
+
console.log('输入框的父容器已加载。');
|
48 |
+
} catch (error) {
|
49 |
+
console.warn('等待输入框父容器超时,将继续尝试查找...');
|
50 |
+
}
|
51 |
+
|
52 |
+
// 定义一组从最具体到最通用的选择器
|
53 |
+
const selectors = [
|
54 |
+
// --- 新增的、更具体的选择器 (基于您提供的HTML) ---
|
55 |
+
'ms-prompt-input-wrapper textarea[aria-label*="prompt"]', // 结合自定义组件和aria-label
|
56 |
+
'textarea[placeholder="Start typing a prompt"]', // 精确匹配placeholder
|
57 |
+
'textarea[aria-label="Start typing a prompt"]', // 精确匹配aria-label
|
58 |
+
'footer textarea', // 任何在footer里的textarea
|
59 |
+
// --- 原有的选择器作为后备 ---
|
60 |
+
'textarea[placeholder*="prompt"]',
|
61 |
+
'textarea[aria-label*="prompt"]',
|
62 |
+
'div[contenteditable="true"]', // 某些情况下可能是div
|
63 |
+
'textarea', // 最通用的后备
|
64 |
+
];
|
65 |
+
|
66 |
+
for (const selector of selectors) {
|
67 |
+
try {
|
68 |
+
const locator = this.page.locator(selector);
|
69 |
+
const count = await locator.count();
|
70 |
+
|
71 |
+
if (count > 0) {
|
72 |
+
console.log(`选择器 "${selector}" 找到了 ${count} 个元素。正在检查可见性和可用性...`);
|
73 |
+
// 遍历找到的元素,返回第一个可见且可用的
|
74 |
+
for (let i = 0; i < count; i++) {
|
75 |
+
const element = locator.nth(i);
|
76 |
+
if (await element.isVisible() && await element.isEnabled()) {
|
77 |
+
console.log(`成功找到可用输入框: ${selector} (索引 ${i})`);
|
78 |
+
return element;
|
79 |
+
}
|
80 |
+
}
|
81 |
+
console.log(`选择器 "${selector}" 找到的元素均不可见或不可用。`);
|
82 |
+
}
|
83 |
+
} catch (error) {
|
84 |
+
// 忽略单个选择器的错误,继续尝试下一个
|
85 |
+
console.warn(`使用选择器 "${selector}" 查找时出错: ${error.message}`);
|
86 |
+
}
|
87 |
+
}
|
88 |
+
|
89 |
+
console.error('关键错误: 尝试了所有选择器后,仍未找到任何可用的输入元素。');
|
90 |
+
// 在失败时保存截图以供调试
|
91 |
+
if (config.debug.saveScreenshots) {
|
92 |
+
const { saveScreenshot } = await import('./common-utils.js');
|
93 |
+
await saveScreenshot(this.page, config.screenshotDir, 'find-input-failed');
|
94 |
+
}
|
95 |
+
return null;
|
96 |
+
}
|
97 |
+
/**
|
98 |
+
* 查找页面上的发送/运行按钮。
|
99 |
+
* @returns {Promise<import('playwright').Locator|null>} 按钮的定位器。
|
100 |
+
*/
|
101 |
+
async findSendButton() {
|
102 |
+
const selectors = [
|
103 |
+
'button[aria-label="Run"]',
|
104 |
+
'button.run-button',
|
105 |
+
'button[aria-label*="Send"]',
|
106 |
+
'button[data-testid*="send"]',
|
107 |
+
];
|
108 |
+
for (const selector of selectors) {
|
109 |
+
const locator = this.page.locator(selector);
|
110 |
+
if (await locator.count() > 0) {
|
111 |
+
const button = locator.first();
|
112 |
+
const isDisabled = await button.isDisabled();
|
113 |
+
if (!isDisabled) return button;
|
114 |
+
}
|
115 |
+
}
|
116 |
+
console.error('未找到任何可用的发送按钮。');
|
117 |
+
return null;
|
118 |
+
}
|
119 |
+
/**
|
120 |
+
* 在输入框中填入消息。
|
121 |
+
* @param {string} message 要发送的消息。
|
122 |
+
* @returns {Promise<boolean>} 是否填充成功。
|
123 |
+
*/
|
124 |
+
async fillMessage(message) {
|
125 |
+
const inputElement = await this.findInputElement();
|
126 |
+
if (!inputElement) throw new Error('无法找到输入框。');
|
127 |
+
try {
|
128 |
+
if (this.options.enableHumanSimulation) {
|
129 |
+
await simulateHumanRandomClicks(this.page, { referenceElement: inputElement });
|
130 |
+
}
|
131 |
+
await inputElement.fill(message);
|
132 |
+
await this.page.waitForTimeout(200); // 等待UI响应
|
133 |
+
console.log('消息填充完成。');
|
134 |
+
return true;
|
135 |
+
} catch (error) {
|
136 |
+
console.error('填充消息失败:', error);
|
137 |
+
return false;
|
138 |
+
}
|
139 |
+
}
|
140 |
+
/**
|
141 |
+
* 等待发送按钮变为可用状态。
|
142 |
+
* @param {number} [timeout=10000] 超时时间。
|
143 |
+
* @returns {Promise<boolean>} 按钮是否变为可用。
|
144 |
+
*/
|
145 |
+
async waitForSendButtonEnabled(timeout = 10000) {
|
146 |
+
try {
|
147 |
+
const sendButtonLocator = this.page.locator('button[aria-label="Run"]:not([disabled]), button.run-button:not([disabled])');
|
148 |
+
await sendButtonLocator.waitFor({ state: 'visible', timeout });
|
149 |
+
console.log('发送按钮已可用。');
|
150 |
+
return true;
|
151 |
+
} catch (error) {
|
152 |
+
console.warn('等待发送按钮可用超时,将继续尝试。');
|
153 |
+
return false;
|
154 |
+
}
|
155 |
+
}
|
156 |
+
/**
|
157 |
+
* 点击发送按钮发送消息。
|
158 |
+
* @returns {Promise<boolean>} 是否发送成功。
|
159 |
+
*/
|
160 |
+
async sendMessage() {
|
161 |
+
if (this.options.enableHumanSimulation) {
|
162 |
+
await simulateHumanBehavior(this.page, { includeScrolling: false, duration: 1500 });
|
163 |
+
}
|
164 |
+
const sendButton = await this.findSendButton();
|
165 |
+
if (!sendButton) throw new Error('无法找到可用的发送按钮。');
|
166 |
+
try {
|
167 |
+
await sendButton.click();
|
168 |
+
console.log('消息已发送。');
|
169 |
+
return true;
|
170 |
+
} catch (error) {
|
171 |
+
console.error('点击发送按钮失败:', error);
|
172 |
+
try {
|
173 |
+
console.log('尝试使用键盘快捷键 (Ctrl+Enter) 发送...');
|
174 |
+
await this.page.keyboard.press('Control+Enter');
|
175 |
+
console.log('已使用键盘快捷键发送。');
|
176 |
+
return true;
|
177 |
+
} catch (keyboardError) {
|
178 |
+
console.error('键盘快捷键发送也失败:', keyboardError);
|
179 |
+
return false;
|
180 |
+
}
|
181 |
+
}
|
182 |
+
}
|
183 |
+
/**
|
184 |
+
* 设置 AI 模型。
|
185 |
+
* @param {string} modelName 模型 ID (例如 'gemini-pro')。
|
186 |
+
* @returns {Promise<boolean>} 是否设置成功。
|
187 |
+
*/
|
188 |
+
async setModel(modelName) {
|
189 |
+
try {
|
190 |
+
const targetDisplayName = this.modelMapping[modelName] || modelName;
|
191 |
+
const modelSelectorContainerLocator = this.page.locator('ms-model-selector-two-column');
|
192 |
+
if (await modelSelectorContainerLocator.count() === 0) {
|
193 |
+
console.log('未找到模型选择器,跳过模型设置。');
|
194 |
+
return false;
|
195 |
+
}
|
196 |
+
const currentModelText = await modelSelectorContainerLocator.locator('.model-option-content .gmat-body-medium').textContent();
|
197 |
+
if (currentModelText?.trim() === targetDisplayName) {
|
198 |
+
console.log('当前已是目标模型,无需切换。');
|
199 |
+
return true;
|
200 |
+
}
|
201 |
+
await modelSelectorContainerLocator.click({ timeout: 5000 });
|
202 |
+
const dropdownPanelLocator = this.page.locator('.mat-mdc-select-panel');
|
203 |
+
await dropdownPanelLocator.waitFor({ state: 'visible', timeout: 5000 });
|
204 |
+
const targetOption = dropdownPanelLocator.locator('mat-option.model-option', { hasText: targetDisplayName });
|
205 |
+
if (await targetOption.count() > 0) {
|
206 |
+
await targetOption.first().click({ timeout: 5000 });
|
207 |
+
await this.page.waitForTimeout(1000); // 等待选择生效
|
208 |
+
console.log(`模型已成功设置为: ${targetDisplayName}`);
|
209 |
+
return true;
|
210 |
+
} else {
|
211 |
+
console.log(`未在下拉菜单中找到目标模型: ${targetDisplayName}`);
|
212 |
+
await this.page.keyboard.press('Escape'); // 关闭下拉菜单
|
213 |
+
return false;
|
214 |
+
}
|
215 |
+
} catch (error) {
|
216 |
+
console.error('设置模型时发生错误:', error);
|
217 |
+
try { await this.page.keyboard.press('Escape'); } catch (e) {}
|
218 |
+
return false;
|
219 |
+
}
|
220 |
+
}
|
221 |
+
/**
|
222 |
+
* 设置温度参数。
|
223 |
+
* @param {number} temperature 温度值。
|
224 |
+
* @returns {Promise<boolean>} 是否设置成功。
|
225 |
+
*/
|
226 |
+
async setTemperature(temperature) {
|
227 |
+
try {
|
228 |
+
const temperatureContainerLocator = this.page.locator('[data-test-id="temperatureSliderContainer"]');
|
229 |
+
if (await temperatureContainerLocator.count() === 0) {
|
230 |
+
console.log('未找到温度设置容器,跳过。');
|
231 |
+
return false;
|
232 |
+
}
|
233 |
+
const numberInput = temperatureContainerLocator.locator('input[type="number"]');
|
234 |
+
if (await numberInput.count() > 0) {
|
235 |
+
await numberInput.fill(temperature.toString());
|
236 |
+
await numberInput.dispatchEvent('change');
|
237 |
+
console.log(`温度已设置为: ${temperature}`);
|
238 |
+
return true;
|
239 |
+
}
|
240 |
+
console.log('未找到温度数字输入框,跳过设置。');
|
241 |
+
return false;
|
242 |
+
} catch (error) {
|
243 |
+
console.error('设置温度时出错:', error);
|
244 |
+
return false;
|
245 |
+
}
|
246 |
+
}
|
247 |
+
/**
|
248 |
+
* 完整处理流程:加载页面、设置参数、填写并发送消息。
|
249 |
+
* @param {string} message 要发送的消息。
|
250 |
+
* @param {object} [options={}] 请求选项。
|
251 |
+
* @param {string} [options.model] 模型 ID。
|
252 |
+
* @param {number} [options.temperature] 温度值。
|
253 |
+
* @returns {Promise<boolean>} 整个流程是否成功。
|
254 |
+
*/
|
255 |
+
async processMessage(message, options = {}) {
|
256 |
+
console.log('开始处理消息...');
|
257 |
+
if (!await this.waitForPageLoad()) throw new Error('页面加载失败。');
|
258 |
+
if (options.model) await this.setModel(options.model);
|
259 |
+
if (options.temperature !== undefined) await this.setTemperature(options.temperature);
|
260 |
+
if (!await this.fillMessage(message)) throw new Error('消息填写失败。');
|
261 |
+
await this.waitForSendButtonEnabled();
|
262 |
+
if (!await this.sendMessage()) throw new Error('消息发送失败。');
|
263 |
+
console.log('消息发送成功,等待网络拦截获取响应。');
|
264 |
+
return true;
|
265 |
+
}
|
266 |
+
}
|
src/utils/ai-studio-processor.js
ADDED
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { AIStudioInjector } from './ai-studio-injector.js';
|
2 |
+
import { getPageFromPool, releasePageToPool, injectStreamInterceptor, activateStreamInterceptor, deactivateStreamInterceptor, smartNavigate } from './browser.js';
|
3 |
+
import { parseGeminiResponse, createStreamResponse, createErrorResponse, createNonStreamResponse } from './response-parser.js';
|
4 |
+
import config from '../config.js';
|
5 |
+
|
6 |
+
const MAX_RETRIES = 3;
|
7 |
+
const RETRY_DELAY_MS = 1000; // 增加了重试延迟,给页面更多反应时间
|
8 |
+
|
9 |
+
/**
|
10 |
+
* 检查错误是否与页面状态相关,这类错误通常可以通过刷新或重新导航解决。
|
11 |
+
* @param {Error} error - 错误对象。
|
12 |
+
* @returns {boolean} 是否是页面状态错误。
|
13 |
+
*/
|
14 |
+
function isPageStateError(error) {
|
15 |
+
const message = error.message.toLowerCase();
|
16 |
+
const pageErrors = [
|
17 |
+
'timeout',
|
18 |
+
'navigation failed',
|
19 |
+
'page crashed',
|
20 |
+
'target closed',
|
21 |
+
'element not found',
|
22 |
+
'is not visible',
|
23 |
+
'无法找到', // 增强中文错误识别
|
24 |
+
'not find', // 增强英文错误识别
|
25 |
+
'cannot find' // 增强英文错误识别
|
26 |
+
];
|
27 |
+
return pageErrors.some(pattern => message.includes(pattern));
|
28 |
+
}
|
29 |
+
|
30 |
+
/**
|
31 |
+
* 检查错误是否与权限相关,这类错误通常可以通过刷新页面来解决(如果Cookie有效)。
|
32 |
+
* @param {Error} error - 错误对象。
|
33 |
+
* @returns {boolean} 是否是权限错误。
|
34 |
+
*/
|
35 |
+
function isPermissionError(error) {
|
36 |
+
const message = error.message.toLowerCase();
|
37 |
+
const permissionKeywords = [
|
38 |
+
'permission', // 英文关键词
|
39 |
+
'unauthorized', // 英文关键词
|
40 |
+
'无权访问', // 中文关键词 (来自你的日志)
|
41 |
+
'cookie', // 关联关键词
|
42 |
+
'登录' // 关联关键词
|
43 |
+
];
|
44 |
+
return permissionKeywords.some(keyword => message.includes(keyword));
|
45 |
+
}
|
46 |
+
|
47 |
+
|
48 |
+
/**
|
49 |
+
* 重置页面状态,优先刷新,失败则重新导航。
|
50 |
+
* @param {import('playwright').Page} page - Playwright 页面对象。
|
51 |
+
*/
|
52 |
+
async function resetPageState(page) {
|
53 |
+
console.log('页面状态异常或遇到权限问题,开始重置...');
|
54 |
+
try {
|
55 |
+
await page.reload({ waitUntil: 'networkidle', timeout: 20000 });
|
56 |
+
console.log('页面刷新成功。');
|
57 |
+
} catch (reloadError) {
|
58 |
+
console.warn('页面刷新失败,尝试重新导航:', reloadError.message);
|
59 |
+
try {
|
60 |
+
await smartNavigate(page, config.aiStudio.url, { timeout: config.aiStudio.pageTimeout });
|
61 |
+
console.log('重新导航成功。');
|
62 |
+
} catch (gotoError) {
|
63 |
+
console.error('页面重置失败:刷新和重新导航均告失败。', gotoError);
|
64 |
+
throw new Error('Failed to reset page state.');
|
65 |
+
}
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
/**
|
70 |
+
* 核心处理逻辑,封装了重试和页面交互。
|
71 |
+
* @param {string} prompt - 用户提示。
|
72 |
+
* @param {object} options - 请求选项。
|
73 |
+
* @param {function} responseHandler - 处理响应的回调函数。
|
74 |
+
* @returns {Promise<any>} 响应处理函数返回的结果。
|
75 |
+
*/
|
76 |
+
async function coreAIStudioProcessor(prompt, options, responseHandler) {
|
77 |
+
let page = null;
|
78 |
+
let lastError = null;
|
79 |
+
|
80 |
+
try {
|
81 |
+
page = await getPageFromPool();
|
82 |
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
83 |
+
try {
|
84 |
+
console.log(`[尝试 ${attempt}/${MAX_RETRIES}] 开始处理请求...`);
|
85 |
+
// 在第一次尝试或页面URL不正确时,强制导航
|
86 |
+
if (attempt === 1 || !page.url().includes('aistudio.google.com')) {
|
87 |
+
await smartNavigate(page, config.aiStudio.url, { timeout: config.aiStudio.pageTimeout });
|
88 |
+
}
|
89 |
+
|
90 |
+
const result = await responseHandler(page, prompt, options);
|
91 |
+
await releasePageToPool(page);
|
92 |
+
return result;
|
93 |
+
|
94 |
+
} catch (error) {
|
95 |
+
lastError = error;
|
96 |
+
console.warn(`[尝试 ${attempt}/${MAX_RETRIES}] 失败: ${error.message}`);
|
97 |
+
|
98 |
+
// 使用新的、更健壮的错误检查函数
|
99 |
+
if ((isPermissionError(error) || isPageStateError(error)) && attempt < MAX_RETRIES) {
|
100 |
+
console.log(`检测到可重试的错误(权限或页面状态),将在 ${RETRY_DELAY_MS}ms 后重试...`);
|
101 |
+
await resetPageState(page); // 刷新或重新导航页面
|
102 |
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
103 |
+
} else {
|
104 |
+
console.error(`错误不可重试或已达到最大重试次数,将抛出异常。`);
|
105 |
+
throw error; // 抛出错误,终止循环
|
106 |
+
}
|
107 |
+
}
|
108 |
+
}
|
109 |
+
} catch (error) {
|
110 |
+
console.error('处理 AI Studio 请求最终失败:', error);
|
111 |
+
if (page) {
|
112 |
+
// 如果是页面状态错误,标记页面以便从池中移除
|
113 |
+
if (isPageStateError(error)) {
|
114 |
+
page._needsRemoval = true;
|
115 |
+
}
|
116 |
+
await releasePageToPool(page);
|
117 |
+
}
|
118 |
+
throw lastError || error;
|
119 |
+
}
|
120 |
+
}
|
121 |
+
|
122 |
+
/**
|
123 |
+
* 流式处理 AI Studio 请求。
|
124 |
+
* @param {string} prompt - 用户提示。
|
125 |
+
* @param {ReadableStreamDefaultController} controller - 流控制器。
|
126 |
+
* @param {object} options - 请求选项。
|
127 |
+
*/
|
128 |
+
export async function processAIStudioRequest(prompt, controller, options = {}) {
|
129 |
+
const model = options.model || config.api.defaultModel;
|
130 |
+
try {
|
131 |
+
await coreAIStudioProcessor(prompt, options, async (page, p, opts) => {
|
132 |
+
return new Promise(async (resolve, reject) => {
|
133 |
+
let streamEnded = false;
|
134 |
+
let accumulatedResponse = "";
|
135 |
+
const sentContentKeys = new Set();
|
136 |
+
|
137 |
+
const finalize = (err) => {
|
138 |
+
if (streamEnded) return;
|
139 |
+
streamEnded = true;
|
140 |
+
deactivateStreamInterceptor(page).catch(console.error);
|
141 |
+
if (err) {
|
142 |
+
reject(err);
|
143 |
+
} else {
|
144 |
+
resolve();
|
145 |
+
}
|
146 |
+
};
|
147 |
+
|
148 |
+
const onStreamChunk = (chunk) => {
|
149 |
+
if (streamEnded) return;
|
150 |
+
accumulatedResponse += chunk;
|
151 |
+
const parsedData = parseGeminiResponse(accumulatedResponse);
|
152 |
+
|
153 |
+
// 如果解析器检测到权限错误,就用这个错误来拒绝Promise
|
154 |
+
if (parsedData?.permissionError) {
|
155 |
+
return finalize(new Error(parsedData.content));
|
156 |
+
}
|
157 |
+
|
158 |
+
if (Array.isArray(parsedData) && parsedData.length > 0) {
|
159 |
+
for (const item of parsedData) {
|
160 |
+
const contentKey = `${item.type}::${item.content}`;
|
161 |
+
if (item.content?.trim() && !sentContentKeys.has(contentKey)) {
|
162 |
+
controller.enqueue(`data: ${JSON.stringify(createStreamResponse(item.content, item.type, model))}\n\n`);
|
163 |
+
sentContentKeys.add(contentKey);
|
164 |
+
}
|
165 |
+
}
|
166 |
+
}
|
167 |
+
};
|
168 |
+
|
169 |
+
const onStreamEnd = () => {
|
170 |
+
if (streamEnded) return;
|
171 |
+
console.log('流已结束,发送 [DONE] 信号。');
|
172 |
+
controller.enqueue('data: [DONE]\n\n');
|
173 |
+
controller.close();
|
174 |
+
finalize();
|
175 |
+
};
|
176 |
+
|
177 |
+
try {
|
178 |
+
await injectStreamInterceptor(page, onStreamChunk, onStreamEnd);
|
179 |
+
await activateStreamInterceptor(page);
|
180 |
+
const injector = new AIStudioInjector(page);
|
181 |
+
await injector.processMessage(p, opts);
|
182 |
+
|
183 |
+
// 设置一个总超时,以防万一
|
184 |
+
setTimeout(() => {
|
185 |
+
if (!streamEnded) {
|
186 |
+
finalize(new Error('AI Studio 响应超时。'));
|
187 |
+
}
|
188 |
+
}, config.aiStudio.responseTimeout);
|
189 |
+
|
190 |
+
} catch (err) {
|
191 |
+
finalize(err);
|
192 |
+
}
|
193 |
+
});
|
194 |
+
});
|
195 |
+
} catch (error) {
|
196 |
+
// 确保在任何情况下都能正确关闭流
|
197 |
+
if (controller.desiredSize !== null) {
|
198 |
+
try {
|
199 |
+
controller.enqueue(`data: ${JSON.stringify(createErrorResponse(error.message, model))}\n\n`);
|
200 |
+
controller.enqueue('data: [DONE]\n\n');
|
201 |
+
controller.close();
|
202 |
+
} catch (e) {
|
203 |
+
console.error('关闭流控制器时出错:', e);
|
204 |
+
}
|
205 |
+
}
|
206 |
+
}
|
207 |
+
}
|
208 |
+
|
209 |
+
/**
|
210 |
+
* 非流式(同步)处理 AI Studio 请求。
|
211 |
+
* @param {string} prompt - 用户提示。
|
212 |
+
* @param {object} options - 请求选项。
|
213 |
+
* @returns {Promise<object>} OpenAI 格式的响应对象。
|
214 |
+
*/
|
215 |
+
export async function processAIStudioRequestSync(prompt, options = {}) {
|
216 |
+
const model = options.model || config.api.defaultModel;
|
217 |
+
try {
|
218 |
+
return await coreAIStudioProcessor(prompt, options, async (page, p, opts) => {
|
219 |
+
return new Promise(async (resolve, reject) => {
|
220 |
+
let collectedContent = '';
|
221 |
+
const responseListener = async (response) => {
|
222 |
+
if (response.url().includes('GenerateContent')) {
|
223 |
+
try {
|
224 |
+
const text = await response.text();
|
225 |
+
const parsedData = parseGeminiResponse(text);
|
226 |
+
|
227 |
+
if (parsedData?.permissionError) {
|
228 |
+
return reject(new Error(parsedData.content));
|
229 |
+
}
|
230 |
+
|
231 |
+
if (Array.isArray(parsedData)) {
|
232 |
+
collectedContent += parsedData
|
233 |
+
.filter(item => item.type === 'text')
|
234 |
+
.map(item => item.content)
|
235 |
+
.join('');
|
236 |
+
}
|
237 |
+
page.removeListener('response', responseListener);
|
238 |
+
resolve(createNonStreamResponse(collectedContent, model));
|
239 |
+
} catch (err) {
|
240 |
+
reject(err);
|
241 |
+
}
|
242 |
+
}
|
243 |
+
};
|
244 |
+
|
245 |
+
page.on('response', responseListener);
|
246 |
+
|
247 |
+
try {
|
248 |
+
const injector = new AIStudioInjector(page);
|
249 |
+
await injector.processMessage(p, opts);
|
250 |
+
|
251 |
+
setTimeout(() => {
|
252 |
+
page.removeListener('response', responseListener);
|
253 |
+
reject(new Error('AI Studio 响应超时。'));
|
254 |
+
}, config.aiStudio.responseTimeout);
|
255 |
+
} catch (err) {
|
256 |
+
page.removeListener('response', responseListener);
|
257 |
+
reject(err);
|
258 |
+
}
|
259 |
+
});
|
260 |
+
});
|
261 |
+
} catch (error) {
|
262 |
+
return createErrorResponse(error.message, model);
|
263 |
+
}
|
264 |
+
}
|
src/utils/browser.js
ADDED
@@ -0,0 +1,949 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { chromium } from 'playwright'
|
2 |
+
// import { chromium } from 'playwright-extra'
|
3 |
+
// import StealthPlugin from 'puppeteer-extra-plugin-stealth'
|
4 |
+
import config from '../config.js'
|
5 |
+
import { loadCookies } from './common-utils.js'
|
6 |
+
import { info } from './logger.js'
|
7 |
+
|
8 |
+
// 存储浏览器实例
|
9 |
+
let browser = null
|
10 |
+
let context = null
|
11 |
+
|
12 |
+
// 页面池管理
|
13 |
+
class PagePool {
|
14 |
+
constructor(maxSize = 5) {
|
15 |
+
this.maxSize = maxSize
|
16 |
+
this.availablePages = []
|
17 |
+
this.busyPages = new Set()
|
18 |
+
this.totalPages = 0
|
19 |
+
}
|
20 |
+
|
21 |
+
/**
|
22 |
+
* 获取一个可用的页面
|
23 |
+
* @returns {Promise<Page>}
|
24 |
+
*/
|
25 |
+
async getPage() {
|
26 |
+
// 如果有可用页面,直接返回
|
27 |
+
if (this.availablePages.length > 0) {
|
28 |
+
const page = this.availablePages.pop()
|
29 |
+
this.busyPages.add(page)
|
30 |
+
info(`从页面池获取页面,当前忙碌页面数: ${this.busyPages.size}`)
|
31 |
+
return page
|
32 |
+
}
|
33 |
+
|
34 |
+
// 如果没有可用页面且未达到最大数量,创建新页面
|
35 |
+
if (this.totalPages < this.maxSize) {
|
36 |
+
const { context } = await initBrowser()
|
37 |
+
const page = await context.newPage()
|
38 |
+
this.busyPages.add(page)
|
39 |
+
this.totalPages++
|
40 |
+
info(`创建新页面,总页面数: ${this.totalPages},忙碌页面数: ${this.busyPages.size}`)
|
41 |
+
return page
|
42 |
+
}
|
43 |
+
|
44 |
+
// 如果达到最大数量,等待有页面释放
|
45 |
+
info('页面池已满,等待页面释放...')
|
46 |
+
return new Promise((resolve) => {
|
47 |
+
const checkAvailable = () => {
|
48 |
+
if (this.availablePages.length > 0) {
|
49 |
+
const page = this.availablePages.pop()
|
50 |
+
this.busyPages.add(page)
|
51 |
+
info(`等待后获取到页面,当前忙碌页面数: ${this.busyPages.size}`)
|
52 |
+
resolve(page)
|
53 |
+
} else {
|
54 |
+
setTimeout(checkAvailable, 100)
|
55 |
+
}
|
56 |
+
}
|
57 |
+
checkAvailable()
|
58 |
+
})
|
59 |
+
}
|
60 |
+
|
61 |
+
/**
|
62 |
+
* 释放页面回到池中
|
63 |
+
* @param {Page} page
|
64 |
+
*/
|
65 |
+
async releasePage(page) {
|
66 |
+
if (!this.busyPages.has(page)) {
|
67 |
+
info('尝试释放不在忙碌列表中的页面')
|
68 |
+
return
|
69 |
+
}
|
70 |
+
|
71 |
+
// 检查页面是否被标记为需要移除
|
72 |
+
if (page._needsRemoval) {
|
73 |
+
info('页面被标记为需要移除,将从池中移除')
|
74 |
+
await this.removePage(page)
|
75 |
+
return
|
76 |
+
}
|
77 |
+
|
78 |
+
try {
|
79 |
+
// 清理页面状态
|
80 |
+
await this.cleanupPage(page)
|
81 |
+
|
82 |
+
this.busyPages.delete(page)
|
83 |
+
this.availablePages.push(page)
|
84 |
+
info(`页面已释放回池中,可用页面数: ${this.availablePages.length},忙碌页面数: ${this.busyPages.size}`)
|
85 |
+
} catch (error) {
|
86 |
+
info(`清理页面时出错,将关闭该页面: ${error.message}`)
|
87 |
+
await this.removePage(page)
|
88 |
+
}
|
89 |
+
}
|
90 |
+
|
91 |
+
/**
|
92 |
+
* 清理页面状态
|
93 |
+
* @param {Page} page
|
94 |
+
*/
|
95 |
+
async cleanupPage(page) {
|
96 |
+
try {
|
97 |
+
// 移除所有事件监听器
|
98 |
+
page.removeAllListeners()
|
99 |
+
|
100 |
+
// 清理流式拦截器相关的函数和状态
|
101 |
+
try {
|
102 |
+
await page.evaluate(() => {
|
103 |
+
// 清理所有流式拦截器相关的函数
|
104 |
+
const keys = Object.keys(window)
|
105 |
+
keys.forEach(key => {
|
106 |
+
if (key.startsWith('__handleStreamChunk') ||
|
107 |
+
key.startsWith('__onStreamChunk') ||
|
108 |
+
key.startsWith('__onStreamEnd')) {
|
109 |
+
delete window[key]
|
110 |
+
}
|
111 |
+
})
|
112 |
+
|
113 |
+
// 清理拦截器状态
|
114 |
+
if (window.__streamInterceptor) {
|
115 |
+
if (typeof window.__streamInterceptor.deactivate === 'function') {
|
116 |
+
window.__streamInterceptor.deactivate()
|
117 |
+
}
|
118 |
+
delete window.__streamInterceptor
|
119 |
+
}
|
120 |
+
|
121 |
+
// 清理其他相关状态
|
122 |
+
delete window.__handleStreamChunk
|
123 |
+
delete window.__onStreamChunk
|
124 |
+
delete window.__onStreamEnd
|
125 |
+
delete window.__streamCallbacks
|
126 |
+
})
|
127 |
+
} catch (evalError) {
|
128 |
+
// 忽略页面evaluate错误,可能页面已经关闭
|
129 |
+
info(`清理页面状态时出现evaluate错误: ${evalError.message}`)
|
130 |
+
}
|
131 |
+
|
132 |
+
info('页面状态已清理')
|
133 |
+
} catch (error) {
|
134 |
+
throw new Error(`清理页面失败: ${error.message}`)
|
135 |
+
}
|
136 |
+
}
|
137 |
+
|
138 |
+
/**
|
139 |
+
* 从池中移除页面
|
140 |
+
* @param {Page} page
|
141 |
+
*/
|
142 |
+
async removePage(page) {
|
143 |
+
try {
|
144 |
+
this.busyPages.delete(page)
|
145 |
+
const index = this.availablePages.indexOf(page)
|
146 |
+
if (index > -1) {
|
147 |
+
this.availablePages.splice(index, 1)
|
148 |
+
}
|
149 |
+
|
150 |
+
await page.close()
|
151 |
+
this.totalPages--
|
152 |
+
info(`页面已从池中移除,总页面数: ${this.totalPages}`)
|
153 |
+
} catch (error) {
|
154 |
+
info(`关闭页面时出错: ${error.message}`)
|
155 |
+
}
|
156 |
+
}
|
157 |
+
|
158 |
+
/**
|
159 |
+
* 清理所有页面
|
160 |
+
*/
|
161 |
+
async cleanup() {
|
162 |
+
info('开始清理页面池...')
|
163 |
+
|
164 |
+
// 关闭所有忙碌页面
|
165 |
+
for (const page of this.busyPages) {
|
166 |
+
try {
|
167 |
+
await page.close()
|
168 |
+
} catch (error) {
|
169 |
+
info(`关闭忙碌页面时出错: ${error.message}`)
|
170 |
+
}
|
171 |
+
}
|
172 |
+
|
173 |
+
// 关闭所有可用页面
|
174 |
+
for (const page of this.availablePages) {
|
175 |
+
try {
|
176 |
+
await page.close()
|
177 |
+
} catch (error) {
|
178 |
+
info(`关闭可用页面时出错: ${error.message}`)
|
179 |
+
}
|
180 |
+
}
|
181 |
+
|
182 |
+
this.busyPages.clear()
|
183 |
+
this.availablePages = []
|
184 |
+
this.totalPages = 0
|
185 |
+
info('页面池清理完成')
|
186 |
+
}
|
187 |
+
|
188 |
+
/**
|
189 |
+
* 获取池状态信息
|
190 |
+
*/
|
191 |
+
getStatus() {
|
192 |
+
return {
|
193 |
+
total: this.totalPages,
|
194 |
+
available: this.availablePages.length,
|
195 |
+
busy: this.busyPages.size,
|
196 |
+
maxSize: this.maxSize
|
197 |
+
}
|
198 |
+
}
|
199 |
+
}
|
200 |
+
|
201 |
+
// 全局页面池实例
|
202 |
+
let pagePool = null
|
203 |
+
|
204 |
+
/**
|
205 |
+
* 初始化浏览器
|
206 |
+
* @returns {Promise<{browser: any, context: any}>}
|
207 |
+
*/
|
208 |
+
export async function initBrowser() {
|
209 |
+
if (!browser) {
|
210 |
+
// chromium.use(StealthPlugin())
|
211 |
+
browser = await chromium.launch({
|
212 |
+
headless: config.browser.headless, // 从环境变量读取
|
213 |
+
timeout: config.browser.timeout,
|
214 |
+
args: config.browser.args,
|
215 |
+
executablePath: config.browser.executablePath
|
216 |
+
})
|
217 |
+
context = await browser.newContext({
|
218 |
+
userAgent: config.browser.userAgent
|
219 |
+
});
|
220 |
+
|
221 |
+
// 读取并设置cookies(优先使用环境变量)
|
222 |
+
const cookies = loadCookies(config.cookieFile, config.cookiesFromEnv);
|
223 |
+
await context.addCookies(cookies);
|
224 |
+
}
|
225 |
+
return { browser, context }
|
226 |
+
}
|
227 |
+
|
228 |
+
/**
|
229 |
+
* 初始化页面池
|
230 |
+
* @param {number} maxSize - 页面池最大大小
|
231 |
+
* @returns {PagePool}
|
232 |
+
*/
|
233 |
+
export function initPagePool(maxSize = 5) {
|
234 |
+
if (!pagePool) {
|
235 |
+
pagePool = new PagePool(maxSize)
|
236 |
+
info(`页面池已初始化,最大页面数: ${maxSize}`)
|
237 |
+
}
|
238 |
+
return pagePool
|
239 |
+
}
|
240 |
+
|
241 |
+
/**
|
242 |
+
* 获取页面池实例
|
243 |
+
* @returns {PagePool}
|
244 |
+
*/
|
245 |
+
export function getPagePool() {
|
246 |
+
if (!pagePool) {
|
247 |
+
pagePool = new PagePool()
|
248 |
+
info('页面池已自动初始化,使用默认配置')
|
249 |
+
}
|
250 |
+
return pagePool
|
251 |
+
}
|
252 |
+
|
253 |
+
/**
|
254 |
+
* 从页面池获取页面
|
255 |
+
* @returns {Promise<any>}
|
256 |
+
*/
|
257 |
+
export async function getPageFromPool() {
|
258 |
+
const pool = getPagePool()
|
259 |
+
const page = await pool.getPage()
|
260 |
+
|
261 |
+
// 将页面切换到前台
|
262 |
+
await page.bringToFront()
|
263 |
+
|
264 |
+
return page
|
265 |
+
}
|
266 |
+
|
267 |
+
/**
|
268 |
+
* 智能导航到指定URL,如果页面已在目标URL则进行刷新
|
269 |
+
* @param {Page} page - Playwright页面对象
|
270 |
+
* @param {string} targetUrl - 目标URL
|
271 |
+
* @param {Object} options - 导航选项
|
272 |
+
* @returns {Promise<boolean>} 是否进行了实际导航
|
273 |
+
*/
|
274 |
+
export async function smartNavigate(page, targetUrl, options = {}) {
|
275 |
+
const currentUrl = page.url()
|
276 |
+
console.log('当前页面URL:', currentUrl)
|
277 |
+
console.log('目标URL:', targetUrl)
|
278 |
+
|
279 |
+
|
280 |
+
try {
|
281 |
+
if (currentUrl !== targetUrl) {
|
282 |
+
console.log('页面URL不匹配,需要导航...')
|
283 |
+
await page.goto(targetUrl, {
|
284 |
+
waitUntil: 'load',
|
285 |
+
timeout: 30000,
|
286 |
+
...options
|
287 |
+
})
|
288 |
+
console.log('页面导航完成')
|
289 |
+
return true
|
290 |
+
} else {
|
291 |
+
console.log('页面已在目标URL,进行刷新以确保最新状态...')
|
292 |
+
// 确保页面处于活跃状态
|
293 |
+
await page.bringToFront()
|
294 |
+
|
295 |
+
// 刷新页面
|
296 |
+
await page.reload({
|
297 |
+
waitUntil: 'load',
|
298 |
+
timeout: 30000,
|
299 |
+
...options
|
300 |
+
})
|
301 |
+
console.log('页面刷新完成')
|
302 |
+
return true
|
303 |
+
}
|
304 |
+
}
|
305 |
+
catch {
|
306 |
+
|
307 |
+
}
|
308 |
+
return false
|
309 |
+
}
|
310 |
+
|
311 |
+
/**
|
312 |
+
* 释放页面回到池中
|
313 |
+
* @param {any} page
|
314 |
+
*/
|
315 |
+
export async function releasePageToPool(page) {
|
316 |
+
const pool = getPagePool()
|
317 |
+
await pool.releasePage(page)
|
318 |
+
}
|
319 |
+
|
320 |
+
/**
|
321 |
+
* 获取页面池状态
|
322 |
+
* @returns {Object}
|
323 |
+
*/
|
324 |
+
export function getPagePoolStatus() {
|
325 |
+
if (!pagePool) {
|
326 |
+
return { total: 0, available: 0, busy: 0, maxSize: 0 }
|
327 |
+
}
|
328 |
+
return pagePool.getStatus()
|
329 |
+
}
|
330 |
+
|
331 |
+
/**
|
332 |
+
* 清理浏览器资源
|
333 |
+
*/
|
334 |
+
export async function cleanup() {
|
335 |
+
// 先清理页面池
|
336 |
+
if (pagePool) {
|
337 |
+
await pagePool.cleanup()
|
338 |
+
pagePool = null
|
339 |
+
}
|
340 |
+
|
341 |
+
if (context) {
|
342 |
+
await context.close()
|
343 |
+
context = null
|
344 |
+
}
|
345 |
+
if (browser) {
|
346 |
+
await browser.close()
|
347 |
+
browser = null
|
348 |
+
}
|
349 |
+
}
|
350 |
+
|
351 |
+
/**
|
352 |
+
* 模拟人类随机点击行为 - 仅在输入框上方300像素内的安全区域
|
353 |
+
* @param {Page} page - Playwright页面对象
|
354 |
+
* @param {Object} options - 配置选项
|
355 |
+
* @param {number} options.minClicks - 最少点击次数,默认2
|
356 |
+
* @param {number} options.maxClicks - 最多点击次数,默认5
|
357 |
+
* @param {number} options.minDelay - 点击间最小延迟(毫秒),默认500
|
358 |
+
* @param {number} options.maxDelay - 点击间最大延迟(毫秒),默认2000
|
359 |
+
* @param {Element} options.referenceElement - 参考元素(如输入框),在其上方300像素内进行点击
|
360 |
+
* @returns {Promise<void>}
|
361 |
+
*/
|
362 |
+
export async function simulateHumanRandomClicks(page, options = {}) {
|
363 |
+
const {
|
364 |
+
minClicks = 1,
|
365 |
+
maxClicks = 3,
|
366 |
+
minDelay = 300,
|
367 |
+
maxDelay = 500,
|
368 |
+
referenceElement = null
|
369 |
+
} = options
|
370 |
+
|
371 |
+
try {
|
372 |
+
info('开始模拟人类随机点击行为...')
|
373 |
+
|
374 |
+
// 随机确定点击次数
|
375 |
+
const clickCount = Math.floor(Math.random() * (maxClicks - minClicks + 1)) + minClicks
|
376 |
+
info(`将进行 ${clickCount} 次随机点击`)
|
377 |
+
|
378 |
+
let safeArea = null
|
379 |
+
|
380 |
+
// 如果提供���参考元素,获取其位置信息
|
381 |
+
if (referenceElement) {
|
382 |
+
try {
|
383 |
+
const boundingBox = await referenceElement.boundingBox()
|
384 |
+
if (boundingBox) {
|
385 |
+
// 定义输入框上方300像素内的安全区域
|
386 |
+
safeArea = {
|
387 |
+
x: boundingBox.x - 50, // 左边扩展50px
|
388 |
+
y: Math.max(0, boundingBox.y - 200), // 上方300px区域
|
389 |
+
width: boundingBox.width + 50, // 宽度扩展100px
|
390 |
+
height: 300 // 高度300px的安全区域(输入框上方300像素内)
|
391 |
+
}
|
392 |
+
info(`使用输入框附近的安全区域: x=${safeArea.x}, y=${safeArea.y}, w=${safeArea.width}, h=${safeArea.height}`)
|
393 |
+
}
|
394 |
+
} catch (error) {
|
395 |
+
info(`获取参考元素位置失败,使用默认安全区域: ${error.message}`)
|
396 |
+
}
|
397 |
+
}
|
398 |
+
|
399 |
+
// 如果没有安全区域,使用页面中央的安全区域
|
400 |
+
if (!safeArea) {
|
401 |
+
const viewport = page.viewportSize()
|
402 |
+
const width = viewport?.width || 1280
|
403 |
+
const height = viewport?.height || 720
|
404 |
+
|
405 |
+
safeArea = {
|
406 |
+
x: width * 0.3,
|
407 |
+
y: height * 0.3,
|
408 |
+
width: width * 0.4,
|
409 |
+
height: height * 0.2
|
410 |
+
}
|
411 |
+
info(`使用默认安全区域: x=${safeArea.x}, y=${safeArea.y}, w=${safeArea.width}, h=${safeArea.height}`)
|
412 |
+
}
|
413 |
+
|
414 |
+
for (let i = 0; i < clickCount; i++) {
|
415 |
+
try {
|
416 |
+
// 在安全区域内生成随机坐标
|
417 |
+
const x = Math.floor(Math.random() * safeArea.width) + safeArea.x
|
418 |
+
const y = Math.floor(Math.random() * safeArea.height) + safeArea.y
|
419 |
+
|
420 |
+
info(`第 ${i + 1} 次安全点击: (${x}, ${y})`)
|
421 |
+
|
422 |
+
// 检查点击位置是否有可交互元素
|
423 |
+
const elementAtPoint = await page.locator(`*`).first().evaluate((_, coords) => {
|
424 |
+
const element = document.elementFromPoint(coords.x, coords.y)
|
425 |
+
if (!element) return { safe: true }
|
426 |
+
|
427 |
+
const tagName = element.tagName.toLowerCase()
|
428 |
+
const hasHref = element.hasAttribute('href')
|
429 |
+
const hasOnClick = element.hasAttribute('onclick') || element.onclick
|
430 |
+
const isButton = tagName === 'button' || element.type === 'button'
|
431 |
+
const isLink = tagName === 'a' || hasHref
|
432 |
+
const isInput = ['input', 'textarea', 'select'].includes(tagName)
|
433 |
+
|
434 |
+
return {
|
435 |
+
safe: !isButton && !isLink && !hasOnClick && !isInput,
|
436 |
+
tagName,
|
437 |
+
hasHref,
|
438 |
+
hasOnClick,
|
439 |
+
isButton,
|
440 |
+
isLink,
|
441 |
+
isInput
|
442 |
+
}
|
443 |
+
}, { x, y })
|
444 |
+
|
445 |
+
if (elementAtPoint.safe) {
|
446 |
+
// 模拟鼠标移动到目标位置
|
447 |
+
await page.mouse.move(x, y, {
|
448 |
+
steps: Math.floor(Math.random() * 10) + 5 // 5-15步移动,更自然
|
449 |
+
})
|
450 |
+
|
451 |
+
// 随机等待一小段时间
|
452 |
+
await page.waitForTimeout(Math.floor(Math.random() * 200) + 100)
|
453 |
+
|
454 |
+
// 执行点击
|
455 |
+
await page.mouse.click(x, y)
|
456 |
+
info(`安全点击完成: (${x}, ${y})`)
|
457 |
+
} else {
|
458 |
+
info(`跳过不安全的点击位置 (${x}, ${y}): ${elementAtPoint.tagName}`)
|
459 |
+
}
|
460 |
+
|
461 |
+
// 点击间随机延迟
|
462 |
+
if (i < clickCount - 1) {
|
463 |
+
const delay = Math.floor(Math.random() * (maxDelay - minDelay + 1)) + minDelay
|
464 |
+
info(`等待 ${delay}ms 后进行下一次点击`)
|
465 |
+
await page.waitForTimeout(delay)
|
466 |
+
}
|
467 |
+
} catch (clickError) {
|
468 |
+
info(`第 ${i + 1} 次点击出现错误,继续下一次: ${clickError.message}`)
|
469 |
+
}
|
470 |
+
}
|
471 |
+
|
472 |
+
info('安全随机点击模拟完成')
|
473 |
+
} catch (error) {
|
474 |
+
info(`模拟安全随机点击时出现错误: ${error.message}`)
|
475 |
+
}
|
476 |
+
}
|
477 |
+
|
478 |
+
/**
|
479 |
+
* 模拟更复杂的人类行为
|
480 |
+
* @param {Page} page - Playwright页面对象
|
481 |
+
* @param {Object} options - 配置选项
|
482 |
+
* @returns {Promise<void>}
|
483 |
+
*/
|
484 |
+
export async function simulateHumanBehavior(page, options = {}) {
|
485 |
+
const {
|
486 |
+
includeScrolling = true,
|
487 |
+
includeMouseMovement = true,
|
488 |
+
includeRandomClicks = true,
|
489 |
+
duration = 3000 // 总持续时间
|
490 |
+
} = options
|
491 |
+
|
492 |
+
try {
|
493 |
+
info('开始模拟复杂的人类行为...')
|
494 |
+
|
495 |
+
const startTime = Date.now()
|
496 |
+
const viewport = page.viewportSize()
|
497 |
+
const width = viewport?.width || 1280
|
498 |
+
const height = viewport?.height || 720
|
499 |
+
|
500 |
+
while (Date.now() - startTime < duration) {
|
501 |
+
const action = Math.random()
|
502 |
+
|
503 |
+
if (action < 0.3 && includeScrolling) {
|
504 |
+
// 30% 概率进行滚动
|
505 |
+
const scrollDirection = Math.random() > 0.5 ? 'down' : 'up'
|
506 |
+
const scrollAmount = Math.floor(Math.random() * 300) + 100
|
507 |
+
|
508 |
+
info(`模拟滚动: ${scrollDirection}, 距离: ${scrollAmount}px`)
|
509 |
+
await page.mouse.wheel(0, scrollDirection === 'down' ? scrollAmount : -scrollAmount)
|
510 |
+
|
511 |
+
} else if (action < 0.6 && includeMouseMovement) {
|
512 |
+
// 30% 概率进行鼠标移动
|
513 |
+
const x = Math.floor(Math.random() * width)
|
514 |
+
const y = Math.floor(Math.random() * height)
|
515 |
+
|
516 |
+
info(`模拟鼠标移动到: (${x}, ${y})`)
|
517 |
+
await page.mouse.move(x, y, {
|
518 |
+
steps: Math.floor(Math.random() * 15) + 5
|
519 |
+
})
|
520 |
+
|
521 |
+
} else if (action < 0.8 && includeRandomClicks) {
|
522 |
+
// 20% 概率进行点击
|
523 |
+
const x = Math.floor(Math.random() * (width * 0.8)) + width * 0.1
|
524 |
+
const y = Math.floor(Math.random() * (height * 0.8)) + height * 0.1
|
525 |
+
|
526 |
+
info(`模拟随机点击: (${x}, ${y})`)
|
527 |
+
await page.mouse.click(x, y)
|
528 |
+
}
|
529 |
+
|
530 |
+
// 随机等待
|
531 |
+
const waitTime = Math.floor(Math.random() * 800) + 200
|
532 |
+
await page.waitForTimeout(waitTime)
|
533 |
+
}
|
534 |
+
|
535 |
+
info('复杂人类行为模拟完成')
|
536 |
+
} catch (error) {
|
537 |
+
info(`模拟复杂人类行为时出现错误: ${error.message}`)
|
538 |
+
}
|
539 |
+
}
|
540 |
+
|
541 |
+
/**
|
542 |
+
* 处理Google AI Studio可能出现的欢迎模态对话框
|
543 |
+
* @param {Page} page - Playwright页面对象
|
544 |
+
* @param {number} timeout - 等待对话框出现的超时时间(毫秒),默认5000ms
|
545 |
+
* @returns {Promise<boolean>} - 返回是否成功处理了对话框
|
546 |
+
*/
|
547 |
+
export async function handleWelcomeModal(page, timeout = 5000) {
|
548 |
+
try {
|
549 |
+
info('检查是否有欢迎对话框需要关闭...')
|
550 |
+
|
551 |
+
// 对话框和关闭按钮的选择器
|
552 |
+
const dialogSelector = 'mat-dialog-container'
|
553 |
+
const closeButtonSelector = 'button[mat-dialog-close][aria-label="close"]'
|
554 |
+
|
555 |
+
// 检查是否存在对话框
|
556 |
+
const dialog = page.locator(dialogSelector).first()
|
557 |
+
|
558 |
+
if (await dialog.isVisible({ timeout })) {
|
559 |
+
info('发现欢迎对话框,尝试关闭...')
|
560 |
+
|
561 |
+
// 查找并点击关闭按钮
|
562 |
+
const closeButton = page.locator(closeButtonSelector).first()
|
563 |
+
|
564 |
+
if (await closeButton.isVisible({ timeout: 2000 })) {
|
565 |
+
await closeButton.click()
|
566 |
+
info('已通过关闭按钮关闭欢迎对话框')
|
567 |
+
|
568 |
+
// 等待对话框消失
|
569 |
+
await page.waitForTimeout(1000)
|
570 |
+
return true
|
571 |
+
} else {
|
572 |
+
info('未找到关闭按钮,尝试按ESC键关闭对话框')
|
573 |
+
await page.keyboard.press('Escape')
|
574 |
+
await page.waitForTimeout(1000)
|
575 |
+
return true
|
576 |
+
}
|
577 |
+
} else {
|
578 |
+
info('未发现欢迎对话框')
|
579 |
+
return false
|
580 |
+
}
|
581 |
+
} catch (err) {
|
582 |
+
info('处理欢迎对话框时出现错误,继续执行:', err.message)
|
583 |
+
return false
|
584 |
+
}
|
585 |
+
}
|
586 |
+
|
587 |
+
/**
|
588 |
+
* 注入流式拦截脚本到页面中(基于Tampermonkey脚本的思路)
|
589 |
+
* 这种方法避免了Playwright的page.route()阻塞问题
|
590 |
+
* @param {Page} page - Playwright页面对象
|
591 |
+
* @param {Function} onStreamChunk - 流数据块回调函数
|
592 |
+
* @param {Function} onStreamEnd - 流结束回调函数
|
593 |
+
* @returns {Promise<void>}
|
594 |
+
*/
|
595 |
+
export async function injectStreamInterceptor(page, onStreamChunk, onStreamEnd) {
|
596 |
+
try {
|
597 |
+
info('注入流式拦截脚本到页面中...')
|
598 |
+
|
599 |
+
// 清理可能存在的旧函数(清理所有以__onStream开头的函数)
|
600 |
+
try {
|
601 |
+
await page.evaluate(() => {
|
602 |
+
// 清理所有以__onStream开头的函数
|
603 |
+
Object.keys(window).forEach(key => {
|
604 |
+
if (key.startsWith('__onStreamChunk_') || key.startsWith('__onStreamEnd_')) {
|
605 |
+
delete window[key]
|
606 |
+
}
|
607 |
+
})
|
608 |
+
// 也清理旧的固定名称函数
|
609 |
+
delete window.__onStreamChunk
|
610 |
+
delete window.__onStreamEnd
|
611 |
+
})
|
612 |
+
} catch (e) {
|
613 |
+
// 忽略清理错误
|
614 |
+
}
|
615 |
+
|
616 |
+
// 设置回调函数(使用唯一名称避免冲突)
|
617 |
+
const uniqueId = Date.now() + '_' + Math.random().toString(36).substr(2, 9)
|
618 |
+
const chunkFunctionName = `__onStreamChunk_${uniqueId}`
|
619 |
+
const endFunctionName = `__onStreamEnd_${uniqueId}`
|
620 |
+
|
621 |
+
await page.exposeFunction(chunkFunctionName, onStreamChunk)
|
622 |
+
await page.exposeFunction(endFunctionName, onStreamEnd)
|
623 |
+
|
624 |
+
// 直接在页面中注入拦截脚本(不使用addInitScript,确保每次都能重新注入)
|
625 |
+
await page.evaluate(() => {
|
626 |
+
// 如果已经存在拦截器,先清理
|
627 |
+
if (window.__streamInterceptor) {
|
628 |
+
if (typeof window.__streamInterceptor.deactivate === 'function') {
|
629 |
+
window.__streamInterceptor.deactivate()
|
630 |
+
}
|
631 |
+
delete window.__streamInterceptor
|
632 |
+
}
|
633 |
+
|
634 |
+
// 保存原始的XMLHttpRequest方法
|
635 |
+
const originalXhrOpen = window.XMLHttpRequest.prototype.open
|
636 |
+
const originalXhrSend = window.XMLHttpRequest.prototype.send
|
637 |
+
// 支持多种可能的URL模式
|
638 |
+
const TARGET_URL_PATTERNS = [
|
639 |
+
"MakerSuiteService/GenerateContent",
|
640 |
+
"GenerateContent",
|
641 |
+
"streamGenerateContent",
|
642 |
+
"generateContent"
|
643 |
+
]
|
644 |
+
|
645 |
+
// 流结束检测的正则表达式(基于Tampermonkey脚本)
|
646 |
+
const FINAL_BLOCK_SIGNATURE = /\[\s*null\s*,\s*null\s*,\s*null\s*,\s*\[\s*"/
|
647 |
+
const END_OF_STREAM_SIGNAL = "__END_OF_STREAM__"
|
648 |
+
|
649 |
+
let interceptorActive = false
|
650 |
+
|
651 |
+
// 向页面暴露拦截器控制函数
|
652 |
+
window.__streamInterceptor = {
|
653 |
+
activate: () => {
|
654 |
+
if (interceptorActive) return
|
655 |
+
|
656 |
+
console.log('🎯 激活流式拦截器...')
|
657 |
+
interceptorActive = true
|
658 |
+
|
659 |
+
// 重写XMLHttpRequest.open方法
|
660 |
+
window.XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
661 |
+
this._url = url
|
662 |
+
this._method = method
|
663 |
+
console.log(`[XHR] 请求: ${method} ${url}`)
|
664 |
+
return originalXhrOpen.apply(this, [method, url, ...rest])
|
665 |
+
}
|
666 |
+
|
667 |
+
// 重写XMLHttpRequest.send方法
|
668 |
+
window.XMLHttpRequest.prototype.send = function (...args) {
|
669 |
+
// 检查URL是否匹配任何目标模式
|
670 |
+
const urlString = this._url ? this._url.toString() : ''
|
671 |
+
const isTargetRequest = TARGET_URL_PATTERNS.some(pattern => urlString.includes(pattern))
|
672 |
+
|
673 |
+
if (this._url && isTargetRequest) {
|
674 |
+
console.log('🎯 [XHR Stream] 拦截到目标请求:', urlString)
|
675 |
+
console.log('🎯 [XHR Stream] 准备接收流式数据...')
|
676 |
+
|
677 |
+
let lastSentLength = 0
|
678 |
+
let fullResponseText = ""
|
679 |
+
let streamEnded = false
|
680 |
+
let finalizationTimer = null
|
681 |
+
|
682 |
+
const finalizeStream = () => {
|
683 |
+
if (streamEnded) return
|
684 |
+
streamEnded = true
|
685 |
+
console.log('[Stream] 判定流已结束')
|
686 |
+
clearTimeout(finalizationTimer)
|
687 |
+
|
688 |
+
const finalChunk = fullResponseText.slice(lastSentLength)
|
689 |
+
if (finalChunk) {
|
690 |
+
window.__streamInterceptor.sendChunk(finalChunk)
|
691 |
+
}
|
692 |
+
window.__streamInterceptor.sendChunk(END_OF_STREAM_SIGNAL)
|
693 |
+
}
|
694 |
+
|
695 |
+
// 监听progress事件来获取流式数据
|
696 |
+
this.addEventListener('progress', () => {
|
697 |
+
if (streamEnded) return
|
698 |
+
|
699 |
+
try {
|
700 |
+
fullResponseText = this.responseText || ''
|
701 |
+
const newChunk = fullResponseText.slice(lastSentLength)
|
702 |
+
|
703 |
+
if (newChunk) {
|
704 |
+
console.log(`[Stream] 收到数据块,长度: ${newChunk.length},HTTP状态: ${this.status}`)
|
705 |
+
console.log(`[Stream] 数据块内容预览: ${newChunk.substring(0, 100)}...`)
|
706 |
+
|
707 |
+
window.__streamInterceptor.sendChunk(newChunk)
|
708 |
+
lastSentLength = fullResponseText.length
|
709 |
+
|
710 |
+
// 检查是否是错误状态码的响应
|
711 |
+
if (this.status >= 400 && this.readyState === 4) {
|
712 |
+
console.log(`[Stream] progress事件中检测到HTTP错误状态码 ${this.status},立即结束流`)
|
713 |
+
finalizeStream()
|
714 |
+
return
|
715 |
+
}
|
716 |
+
|
717 |
+
// 使用精确的签名来检查流是否结束
|
718 |
+
if (FINAL_BLOCK_SIGNATURE.test(newChunk)) {
|
719 |
+
console.log('[Stream] ✅ 检测到最终 ID 签名块,确认流结束')
|
720 |
+
finalizeStream()
|
721 |
+
}
|
722 |
+
}
|
723 |
+
} catch (error) {
|
724 |
+
console.error('[Stream] 处理progress事件时出错:', error)
|
725 |
+
}
|
726 |
+
})
|
727 |
+
|
728 |
+
// 监听readystatechange事件作为备用方法
|
729 |
+
this.addEventListener('readystatechange', () => {
|
730 |
+
if (streamEnded) return
|
731 |
+
|
732 |
+
console.log(`[Stream] readyState: ${this.readyState}, status: ${this.status}`)
|
733 |
+
|
734 |
+
// readyState 3 = LOADING (部分数据可用)
|
735 |
+
// readyState 4 = DONE (请求完成)
|
736 |
+
if (this.readyState === 3 || this.readyState === 4) {
|
737 |
+
try {
|
738 |
+
const currentText = this.responseText || ''
|
739 |
+
const newChunk = currentText.slice(lastSentLength)
|
740 |
+
|
741 |
+
if (newChunk) {
|
742 |
+
console.log(`[Stream] readyState ${this.readyState} 收到数据块,长度: ${newChunk.length}`)
|
743 |
+
console.log(`[Stream] 数据块内容预览: ${newChunk.substring(0, 200)}...`)
|
744 |
+
|
745 |
+
window.__streamInterceptor.sendChunk(newChunk)
|
746 |
+
lastSentLength = currentText.length
|
747 |
+
fullResponseText = currentText
|
748 |
+
|
749 |
+
// 检查流结束签名
|
750 |
+
if (FINAL_BLOCK_SIGNATURE.test(newChunk)) {
|
751 |
+
console.log('[Stream] ✅ readyState事件中检测到最终签名')
|
752 |
+
finalizeStream()
|
753 |
+
}
|
754 |
+
}
|
755 |
+
|
756 |
+
// 如果请求完成,检查HTTP状态码
|
757 |
+
if (this.readyState === 4 && !streamEnded) {
|
758 |
+
console.log(`[Stream] 请求完成,HTTP状态码: ${this.status}`)
|
759 |
+
|
760 |
+
// 检查是否是错误状态码
|
761 |
+
if (this.status >= 400) {
|
762 |
+
console.log(`[Stream] 检测到HTTP错误状态码 ${this.status},立即处理错误响应`)
|
763 |
+
// 对于错误状态码,立即发送所有响应数据并结束流
|
764 |
+
if (fullResponseText && fullResponseText !== currentText) {
|
765 |
+
const remainingChunk = fullResponseText.slice(lastSentLength)
|
766 |
+
if (remainingChunk) {
|
767 |
+
console.log(`[Stream] 发送剩余错误响应数据: ${remainingChunk}`)
|
768 |
+
window.__streamInterceptor.sendChunk(remainingChunk)
|
769 |
+
}
|
770 |
+
}
|
771 |
+
finalizeStream()
|
772 |
+
} else if (!streamEnded) {
|
773 |
+
// 正常状态码,设置保险计时器
|
774 |
+
console.log('[Stream] 正常请求完成,设置保险计时器')
|
775 |
+
finalizationTimer = setTimeout(finalizeStream, 1000)
|
776 |
+
}
|
777 |
+
}
|
778 |
+
} catch (error) {
|
779 |
+
console.error('[Stream] 处理readystatechange事件时出错:', error)
|
780 |
+
}
|
781 |
+
}
|
782 |
+
})
|
783 |
+
|
784 |
+
this.addEventListener('load', () => {
|
785 |
+
if (streamEnded) return
|
786 |
+
console.log('[Stream] "load" 事件触发,启动最终确认计时器')
|
787 |
+
finalizationTimer = setTimeout(finalizeStream, 1500)
|
788 |
+
})
|
789 |
+
|
790 |
+
this.addEventListener('error', (e) => {
|
791 |
+
console.error('[Stream] XHR 请求出错:', e)
|
792 |
+
if (!streamEnded) finalizeStream()
|
793 |
+
})
|
794 |
+
|
795 |
+
this.addEventListener('abort', () => {
|
796 |
+
console.log('[Stream] XHR 请求被中止')
|
797 |
+
if (!streamEnded) finalizeStream()
|
798 |
+
})
|
799 |
+
}
|
800 |
+
return originalXhrSend.apply(this, args)
|
801 |
+
}
|
802 |
+
},
|
803 |
+
|
804 |
+
deactivate: () => {
|
805 |
+
if (!interceptorActive) return
|
806 |
+
console.log('🔄 停用流式拦截器...')
|
807 |
+
window.XMLHttpRequest.prototype.open = originalXhrOpen
|
808 |
+
window.XMLHttpRequest.prototype.send = originalXhrSend
|
809 |
+
interceptorActive = false
|
810 |
+
},
|
811 |
+
|
812 |
+
sendChunk: (chunk) => {
|
813 |
+
try {
|
814 |
+
console.log(`[Stream] 发送数据块到Playwright,长度: ${chunk.length}`)
|
815 |
+
// 直接调用暴露的函数
|
816 |
+
if (window.__handleStreamChunk) {
|
817 |
+
window.__handleStreamChunk(chunk)
|
818 |
+
} else {
|
819 |
+
console.error('[Stream] __handleStreamChunk 函数不存在')
|
820 |
+
}
|
821 |
+
} catch (error) {
|
822 |
+
console.error('[Stream] 发送数据块时出错:', error)
|
823 |
+
}
|
824 |
+
}
|
825 |
+
}
|
826 |
+
|
827 |
+
// 创建全局回调存储
|
828 |
+
window.__streamCallbacks = {
|
829 |
+
onChunk: null,
|
830 |
+
onEnd: null
|
831 |
+
}
|
832 |
+
|
833 |
+
// 创建处理函数
|
834 |
+
window.__handleStreamChunk = function (chunk) {
|
835 |
+
try {
|
836 |
+
console.log('[Stream] 处理数据块:', chunk.length, '字符')
|
837 |
+
if (chunk === "__END_OF_STREAM__") {
|
838 |
+
if (window.__streamCallbacks.onEnd) {
|
839 |
+
window.__streamCallbacks.onEnd()
|
840 |
+
}
|
841 |
+
} else {
|
842 |
+
if (window.__streamCallbacks.onChunk) {
|
843 |
+
window.__streamCallbacks.onChunk(chunk)
|
844 |
+
}
|
845 |
+
}
|
846 |
+
} catch (error) {
|
847 |
+
console.error('[Stream] 处理数据块时出错:', error)
|
848 |
+
}
|
849 |
+
}
|
850 |
+
|
851 |
+
console.log('[Stream] 拦截器和处理函数已创建完成')
|
852 |
+
})
|
853 |
+
|
854 |
+
// 连接回调函数(分别设置以避免参数问题)
|
855 |
+
await page.evaluate((chunkFuncName) => {
|
856 |
+
window.__streamCallbacks.onChunk = window[chunkFuncName]
|
857 |
+
console.log('[Stream] onChunk回调函数已连接:', chunkFuncName)
|
858 |
+
}, chunkFunctionName)
|
859 |
+
|
860 |
+
await page.evaluate((endFuncName) => {
|
861 |
+
window.__streamCallbacks.onEnd = window[endFuncName]
|
862 |
+
console.log('[Stream] onEnd回调函数已连接:', endFuncName)
|
863 |
+
}, endFunctionName)
|
864 |
+
|
865 |
+
info('流式拦截脚本注入完成')
|
866 |
+
} catch (error) {
|
867 |
+
info(`注入流式拦截脚本时出错: ${error.message}`)
|
868 |
+
throw error
|
869 |
+
}
|
870 |
+
}
|
871 |
+
|
872 |
+
/**
|
873 |
+
* 激活页面中的流式拦截器
|
874 |
+
* @param {Page} page - Playwright页面对象
|
875 |
+
* @returns {Promise<void>}
|
876 |
+
*/
|
877 |
+
export async function activateStreamInterceptor(page) {
|
878 |
+
try {
|
879 |
+
await page.evaluate(() => {
|
880 |
+
if (window.__streamInterceptor) {
|
881 |
+
window.__streamInterceptor.activate()
|
882 |
+
}
|
883 |
+
|
884 |
+
// 重新检查并设置处理函数(页面导航后可能被清除)
|
885 |
+
if (!window.__handleStreamChunk) {
|
886 |
+
console.log('[Stream] 重新创建 __handleStreamChunk 函数')
|
887 |
+
|
888 |
+
// 重新创建处理函数
|
889 |
+
window.__handleStreamChunk = function (chunk) {
|
890 |
+
try {
|
891 |
+
console.log('[Stream] 处理数据块:', chunk.length, '字符')
|
892 |
+
if (chunk === "__END_OF_STREAM__") {
|
893 |
+
if (window.__onStreamEnd) {
|
894 |
+
window.__onStreamEnd()
|
895 |
+
}
|
896 |
+
} else {
|
897 |
+
if (window.__onStreamChunk) {
|
898 |
+
window.__onStreamChunk(chunk)
|
899 |
+
}
|
900 |
+
}
|
901 |
+
} catch (error) {
|
902 |
+
console.error('[Stream] 处理数据块时出错:', error)
|
903 |
+
}
|
904 |
+
}
|
905 |
+
|
906 |
+
console.log('[Stream] __handleStreamChunk 函数已重新创建')
|
907 |
+
}
|
908 |
+
})
|
909 |
+
info('流式拦截器已激活')
|
910 |
+
} catch (error) {
|
911 |
+
info(`激活流式拦截器时出错: ${error.message}`)
|
912 |
+
throw error
|
913 |
+
}
|
914 |
+
}
|
915 |
+
|
916 |
+
/**
|
917 |
+
* 停用页面中的流式拦截器
|
918 |
+
* @param {Page} page - Playwright页面对象
|
919 |
+
* @returns {Promise<void>}
|
920 |
+
*/
|
921 |
+
export async function deactivateStreamInterceptor(page) {
|
922 |
+
try {
|
923 |
+
await page.evaluate(() => {
|
924 |
+
if (window.__streamInterceptor) {
|
925 |
+
window.__streamInterceptor.deactivate()
|
926 |
+
}
|
927 |
+
})
|
928 |
+
info('流式拦截器已停用')
|
929 |
+
} catch (error) {
|
930 |
+
info(`停用流式拦截器时出错: ${error.message}`)
|
931 |
+
}
|
932 |
+
}
|
933 |
+
|
934 |
+
/**
|
935 |
+
* 设置进程退出处理
|
936 |
+
*/
|
937 |
+
export function setupProcessHandlers() {
|
938 |
+
process.on('SIGINT', async () => {
|
939 |
+
console.log('正在清理资源...')
|
940 |
+
await cleanup()
|
941 |
+
process.exit(0)
|
942 |
+
})
|
943 |
+
|
944 |
+
process.on('SIGTERM', async () => {
|
945 |
+
console.log('正在清理资源...')
|
946 |
+
await cleanup()
|
947 |
+
process.exit(0)
|
948 |
+
})
|
949 |
+
}
|
src/utils/common-utils.js
ADDED
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import fs from 'fs';
|
2 |
+
import path from 'path';
|
3 |
+
import readline from 'readline';
|
4 |
+
import { info, error } from './logger.js';
|
5 |
+
|
6 |
+
/**
|
7 |
+
* 创建一个人类可读的时间戳字符串。
|
8 |
+
* @returns {string} 格式为 'YYYY-MM-DD_HH-MM-SS' 的时间戳。
|
9 |
+
*/
|
10 |
+
export function getHumanReadableTimestamp() {
|
11 |
+
return new Date().toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '');
|
12 |
+
}
|
13 |
+
|
14 |
+
/**
|
15 |
+
* 确保指定的目录存在,如果不存在则创建它。
|
16 |
+
* @param {string} dir - 目录路径。
|
17 |
+
*/
|
18 |
+
export function ensureDirectoryExists(dir) {
|
19 |
+
if (!fs.existsSync(dir)) {
|
20 |
+
fs.mkdirSync(dir, { recursive: true });
|
21 |
+
info(`已创建目录: ${dir}`);
|
22 |
+
}
|
23 |
+
}
|
24 |
+
|
25 |
+
/**
|
26 |
+
* 检查 Cookie 是否可用(通过环境变量或文件)。
|
27 |
+
* @param {string} cookieFile - Cookie 文件路径。
|
28 |
+
* @param {string|undefined} cookiesFromEnv - 环境变量中的 Cookie 字符串。
|
29 |
+
* @returns {boolean} Cookie 是否可用。
|
30 |
+
*/
|
31 |
+
export function checkCookieAvailability(cookieFile, cookiesFromEnv) {
|
32 |
+
if (cookiesFromEnv) {
|
33 |
+
try {
|
34 |
+
JSON.parse(cookiesFromEnv);
|
35 |
+
info('发现并验证了环境变量中的 COOKIES。');
|
36 |
+
return true;
|
37 |
+
} catch (err) {
|
38 |
+
error('环境变量 COOKIES 格式无效 (必须是 JSON 数组字符串):', err.message);
|
39 |
+
}
|
40 |
+
}
|
41 |
+
if (fs.existsSync(cookieFile)) {
|
42 |
+
info(`发现 Cookie 文件: ${cookieFile}`);
|
43 |
+
return true;
|
44 |
+
}
|
45 |
+
error(`Cookie 文件不存在: ${cookieFile},且未设置 COOKIES 环境变量。`);
|
46 |
+
info('请先运行 `npm run login` 或设置 COOKIES 环境变量。');
|
47 |
+
return false;
|
48 |
+
}
|
49 |
+
|
50 |
+
/**
|
51 |
+
* 加载 Cookie,优先从环境变量读取,其次从文件读取。
|
52 |
+
* @param {string} cookieFile - Cookie 文件路径。
|
53 |
+
* @param {string|undefined} cookiesFromEnv - 环境变量中的 Cookie 字符串。
|
54 |
+
* @returns {object[]} Cookie 对象数组。
|
55 |
+
*/
|
56 |
+
export function loadCookies(cookieFile, cookiesFromEnv) {
|
57 |
+
try {
|
58 |
+
if (cookiesFromEnv) {
|
59 |
+
info('从环境变量 COOKIES 加载 Cookie...');
|
60 |
+
return JSON.parse(cookiesFromEnv);
|
61 |
+
}
|
62 |
+
if (fs.existsSync(cookieFile)) {
|
63 |
+
info(`从文件加载 Cookie: ${cookieFile}`);
|
64 |
+
return JSON.parse(fs.readFileSync(cookieFile, 'utf8'));
|
65 |
+
}
|
66 |
+
} catch (err) {
|
67 |
+
error('加载 Cookie 失败:', err);
|
68 |
+
throw new Error('无法加载或解析 Cookie。');
|
69 |
+
}
|
70 |
+
return [];
|
71 |
+
}
|
72 |
+
|
73 |
+
/**
|
74 |
+
* 保存页面截图。
|
75 |
+
* @param {import('playwright').Page} page - Playwright 页面对象。
|
76 |
+
* @param {string} screenshotDir - 截图保存目录。
|
77 |
+
* @param {string} [prefix='screenshot'] - 截图文件名前缀。
|
78 |
+
* @returns {Promise<string>} 截图文件的完整路径。
|
79 |
+
*/
|
80 |
+
export async function saveScreenshot(page, screenshotDir, prefix = 'screenshot') {
|
81 |
+
ensureDirectoryExists(screenshotDir);
|
82 |
+
const timestamp = getHumanReadableTimestamp();
|
83 |
+
const screenshotPath = path.join(screenshotDir, `${prefix}_${timestamp}.png`);
|
84 |
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
85 |
+
info(`截图已保存: ${screenshotPath}`);
|
86 |
+
return screenshotPath;
|
87 |
+
}
|
88 |
+
|
89 |
+
/**
|
90 |
+
* 等待用户在控制台按下 Enter 键。
|
91 |
+
* @returns {Promise<void>}
|
92 |
+
*/
|
93 |
+
export function waitForUserInput() {
|
94 |
+
const rl = readline.createInterface({
|
95 |
+
input: process.stdin,
|
96 |
+
output: process.stdout
|
97 |
+
});
|
98 |
+
return new Promise(resolve => {
|
99 |
+
rl.question('', () => {
|
100 |
+
rl.close();
|
101 |
+
resolve();
|
102 |
+
});
|
103 |
+
});
|
104 |
+
}
|
src/utils/logger.js
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import fs from 'fs';
|
2 |
+
import path from 'path';
|
3 |
+
|
4 |
+
const LogLevel = {
|
5 |
+
DEBUG: 'DEBUG',
|
6 |
+
INFO: 'INFO',
|
7 |
+
WARN: 'WARN',
|
8 |
+
ERROR: 'ERROR'
|
9 |
+
};
|
10 |
+
|
11 |
+
const LOG_CONFIG = {
|
12 |
+
logFile: './logs/app.log',
|
13 |
+
logDir: './logs',
|
14 |
+
enableConsole: true,
|
15 |
+
enableFile: true,
|
16 |
+
logLevel: process.env.LOG_LEVEL || LogLevel.INFO
|
17 |
+
};
|
18 |
+
|
19 |
+
/**
|
20 |
+
* 确保日志目录存在。
|
21 |
+
*/
|
22 |
+
function ensureLogDirectory() {
|
23 |
+
if (!fs.existsSync(LOG_CONFIG.logDir)) {
|
24 |
+
fs.mkdirSync(LOG_CONFIG.logDir, { recursive: true });
|
25 |
+
}
|
26 |
+
}
|
27 |
+
|
28 |
+
/**
|
29 |
+
* 格式化日志消息。
|
30 |
+
* @param {any[]} args - 要记录的参数。
|
31 |
+
* @returns {string} 格式化后的消息字符串。
|
32 |
+
*/
|
33 |
+
function formatMessage(...args) {
|
34 |
+
return args.map(arg =>
|
35 |
+
(typeof arg === 'object' && arg !== null) ? JSON.stringify(arg, null, 2) : String(arg)
|
36 |
+
).join(' ');
|
37 |
+
}
|
38 |
+
|
39 |
+
/**
|
40 |
+
* 将日志异步写入文件。
|
41 |
+
* @param {string} level - 日志级别。
|
42 |
+
* @param {string} message - 日志消息。
|
43 |
+
*/
|
44 |
+
async function writeToFile(level, message) {
|
45 |
+
if (!LOG_CONFIG.enableFile) return;
|
46 |
+
ensureLogDirectory();
|
47 |
+
const timestamp = new Date().toISOString();
|
48 |
+
const logEntry = `[${timestamp}] [${level}] ${message}\n`;
|
49 |
+
try {
|
50 |
+
await fs.promises.appendFile(LOG_CONFIG.logFile, logEntry);
|
51 |
+
} catch (error) {
|
52 |
+
console.error('写入日志文件失败:', error);
|
53 |
+
}
|
54 |
+
}
|
55 |
+
|
56 |
+
/**
|
57 |
+
* 将日志输出到控制台。
|
58 |
+
* @param {string} level - 日志级别。
|
59 |
+
* @param {any[]} args - 要记录的参数。
|
60 |
+
*/
|
61 |
+
function writeToConsole(level, ...args) {
|
62 |
+
if (!LOG_CONFIG.enableConsole) return;
|
63 |
+
const consoleMethod = {
|
64 |
+
[LogLevel.DEBUG]: console.debug,
|
65 |
+
[LogLevel.INFO]: console.log,
|
66 |
+
[LogLevel.WARN]: console.warn,
|
67 |
+
[LogLevel.ERROR]: console.error,
|
68 |
+
}[level] || console.log;
|
69 |
+
|
70 |
+
const timestamp = new Date().toLocaleTimeString();
|
71 |
+
consoleMethod(`[${timestamp}] [${level}]`, ...args);
|
72 |
+
}
|
73 |
+
|
74 |
+
/**
|
75 |
+
* 通用日志记录函数。
|
76 |
+
* @param {string} level - 日志级别。
|
77 |
+
* @param {any[]} args - 要记录的参数。
|
78 |
+
*/
|
79 |
+
function log(level, ...args) {
|
80 |
+
const message = formatMessage(...args);
|
81 |
+
writeToConsole(level, ...args);
|
82 |
+
writeToFile(level, message);
|
83 |
+
}
|
84 |
+
|
85 |
+
export const debug = (...args) => log(LogLevel.DEBUG, ...args);
|
86 |
+
export const info = (...args) => log(LogLevel.INFO, ...args);
|
87 |
+
export const warn = (...args) => log(LogLevel.WARN, ...args);
|
88 |
+
export const error = (...args) => log(LogLevel.ERROR, ...args);
|
89 |
+
|
90 |
+
export default { debug, info, warn, error };
|
src/utils/page-pool-monitor.js
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { getPagePoolStatus } from './browser.js';
|
2 |
+
import { info } from './logger.js';
|
3 |
+
|
4 |
+
let monitorIntervalId = null;
|
5 |
+
|
6 |
+
/**
|
7 |
+
* 开始监控页面池状态,并定期打印到控制台。
|
8 |
+
* @param {number} [intervalMs=10000] - 监控间隔(毫秒)。
|
9 |
+
*/
|
10 |
+
export function startPagePoolMonitoring(intervalMs = 10000) {
|
11 |
+
if (monitorIntervalId) {
|
12 |
+
info('页面池监控已在运行中。');
|
13 |
+
return;
|
14 |
+
}
|
15 |
+
|
16 |
+
info(`开始监控页面池状态,间隔: ${intervalMs}ms`);
|
17 |
+
monitorIntervalId = setInterval(() => {
|
18 |
+
const status = getPagePoolStatus();
|
19 |
+
const timestamp = new Date().toLocaleTimeString();
|
20 |
+
const utilization = status.total > 0 ? ((status.busy / status.total) * 100).toFixed(1) : 0;
|
21 |
+
|
22 |
+
info(`[${timestamp}] 页面池状态: 总计=${status.total}, 可用=${status.available}, 忙碌=${status.busy}, 使用率=${utilization}%`);
|
23 |
+
}, intervalMs);
|
24 |
+
}
|
25 |
+
|
26 |
+
/**
|
27 |
+
* 停止页面池监控。
|
28 |
+
*/
|
29 |
+
export function stopPagePoolMonitoring() {
|
30 |
+
if (!monitorIntervalId) {
|
31 |
+
info('页面池监控未在运行。');
|
32 |
+
return;
|
33 |
+
}
|
34 |
+
|
35 |
+
info('停止页面池监控。');
|
36 |
+
clearInterval(monitorIntervalId);
|
37 |
+
monitorIntervalId = null;
|
38 |
+
}
|
39 |
+
|
40 |
+
/**
|
41 |
+
* 打印一次详细的当前页面池状态。
|
42 |
+
*/
|
43 |
+
export function printPagePoolStatus() {
|
44 |
+
const status = getPagePoolStatus();
|
45 |
+
const timestamp = new Date().toLocaleString();
|
46 |
+
const utilization = status.total > 0 ? ((status.busy / status.total) * 100).toFixed(1) : 0;
|
47 |
+
|
48 |
+
console.log(`
|
49 |
+
--- 页面池状态 @ ${timestamp} ---
|
50 |
+
总页面数: ${status.total}
|
51 |
+
可用页面数: ${status.available}
|
52 |
+
忙碌页面数: ${status.busy}
|
53 |
+
最大页面数: ${status.maxSize}
|
54 |
+
使用率: ${utilization}%
|
55 |
+
------------------------------------
|
56 |
+
`);
|
57 |
+
}
|
src/utils/response-parser.js
ADDED
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Gemini 的响应流是一种非标准的、混合了 JSON 数组和特殊分隔符的格式。
|
3 |
+
* 本解析器的目标是从这种复杂的流中稳健地提取出有意义的文本内容和其类型(思考过程或最终文本)。
|
4 |
+
*
|
5 |
+
* 策略:
|
6 |
+
* 1. **权限错误优先**:首先检查表示权限问题的特定错误消息。
|
7 |
+
* 2. **JSON 优先解析**:尝试将响应文本修复为可解析的 JSON 数组。这是最可靠的方法,因为它能访问完整的结构。
|
8 |
+
* a. **结构化类型判断**:递归地在解析后的数据结构中寻找内容块 `[null, "string", ...]`。
|
9 |
+
* - 如果该数组长度 > 2,则初步判断为 `thinking`。
|
10 |
+
* - 如果该数组长度 === 2,则初步判断为 `text`。
|
11 |
+
* b. **状态化流处理**:在解析完整个数据块后,进行一次后处理。一旦第一个 `text` 类型的块出现,其后所有的块都将被强制转换为 `text` 类型,确保输出的纯净性。
|
12 |
+
* 3. **正则回退**:如果 JSON 解析失败,则回退到正则表达式,尽力提取内容。
|
13 |
+
*/
|
14 |
+
/**
|
15 |
+
* 检查并返回权限错误。
|
16 |
+
* @param {string} responseText - 响应文本。
|
17 |
+
* @returns {{permissionError: true, content: string}|null}
|
18 |
+
*/
|
19 |
+
function getPermissionError(responseText) {
|
20 |
+
if (responseText.includes('The caller does not have permission')) {
|
21 |
+
console.warn('检测到权限错误,将触发重试机制。');
|
22 |
+
return {
|
23 |
+
permissionError: true,
|
24 |
+
content: '无权访问 AI Studio。请检查 Cookie 或登录状态。'
|
25 |
+
};
|
26 |
+
}
|
27 |
+
return null;
|
28 |
+
}
|
29 |
+
/**
|
30 |
+
* 递归地在解析后的数据中寻找内容块,并根据结构初步判断类型。
|
31 |
+
* @param {any} data - 要搜索的数据。
|
32 |
+
* @returns {Array<{type: 'thinking' | 'text', content: string}>}
|
33 |
+
*/
|
34 |
+
function findContentRecursively(data) {
|
35 |
+
const results = [];
|
36 |
+
if (!data) {
|
37 |
+
return results;
|
38 |
+
}
|
39 |
+
if (Array.isArray(data)) {
|
40 |
+
if (data.length >= 2 && data[0] === null && typeof data[1] === 'string' && data[1]) {
|
41 |
+
if (data[1] !== 'model') {
|
42 |
+
const type = data.length > 2 ? 'thinking' : 'text';
|
43 |
+
results.push({
|
44 |
+
type: type,
|
45 |
+
content: data[1].replace(/\\n/g, '\n').replace(/\\"/g, '"'),
|
46 |
+
});
|
47 |
+
}
|
48 |
+
}
|
49 |
+
for (const item of data) {
|
50 |
+
results.push(...findContentRecursively(item));
|
51 |
+
}
|
52 |
+
}
|
53 |
+
return results;
|
54 |
+
}
|
55 |
+
/**
|
56 |
+
* 解析 Gemini 响应并返回内容数组。
|
57 |
+
* @param {string} responseText - Gemini API 响应文本。
|
58 |
+
* @returns {Array<{type: 'thinking' | 'text', content: string}>|{permissionError: true, content: string}|null}
|
59 |
+
*/
|
60 |
+
export function parseGeminiResponse(responseText) {
|
61 |
+
const permissionError = getPermissionError(responseText);
|
62 |
+
if (permissionError) return permissionError;
|
63 |
+
try {
|
64 |
+
let parsableText = responseText
|
65 |
+
.trim()
|
66 |
+
.replace(/\n/g, ',') // 用逗号替换换行符
|
67 |
+
.replace(/,+/g, ','); // 处理多个逗号
|
68 |
+
if (parsableText.endsWith(',')) {
|
69 |
+
parsableText = parsableText.slice(0, -1);
|
70 |
+
}
|
71 |
+
if (!parsableText.startsWith('[')) {
|
72 |
+
parsableText = '[' + parsableText;
|
73 |
+
}
|
74 |
+
if (!parsableText.endsWith(']')) {
|
75 |
+
parsableText = parsableText + ']';
|
76 |
+
}
|
77 |
+
parsableText = parsableText.replace(/,]/g, ']').replace(/\[,/g, '[');
|
78 |
+
const data = JSON.parse(parsableText);
|
79 |
+
const contentItems = findContentRecursively(data);
|
80 |
+
if (contentItems.length > 0) {
|
81 |
+
// 后处理阶段:应用 "一旦出现 text,后续皆为 text" 规则。
|
82 |
+
// 这能确保在最终答案开始输出后,流的类型统一,避免混入 "thinking" 片段。
|
83 |
+
let hasSeenText = false;
|
84 |
+
return contentItems.map(item => {
|
85 |
+
if (hasSeenText) {
|
86 |
+
// 如果之前已出现过 'text' 类型的内容,则将当前内容也标记为 'text'。
|
87 |
+
item.type = 'text';
|
88 |
+
} else if (item.type === 'text') {
|
89 |
+
// 这是流中首次出现 'text' 类型的内容,设置标志位。
|
90 |
+
hasSeenText = true;
|
91 |
+
}
|
92 |
+
return item;
|
93 |
+
});
|
94 |
+
}
|
95 |
+
} catch (e) {
|
96 |
+
// JSON 解析失败,将进入下面的正则回退逻辑
|
97 |
+
}
|
98 |
+
// 正则回退逻辑
|
99 |
+
const contentRegex = /\[null,\s*"((?:\\"|[^"])*)"/g;
|
100 |
+
const results = [];
|
101 |
+
let match;
|
102 |
+
while ((match = contentRegex.exec(responseText)) !== null) {
|
103 |
+
try {
|
104 |
+
const content = match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
|
105 |
+
if (content && content !== 'model') {
|
106 |
+
const type = content.trim().startsWith('**') ? 'thinking' : 'text';
|
107 |
+
results.push({ type, content });
|
108 |
+
}
|
109 |
+
} catch (err) { /* 忽略单个匹配的解析错误 */ }
|
110 |
+
}
|
111 |
+
// 对正则提取的结果也应用相同的 "once text, always text" 逻辑
|
112 |
+
if (results.length > 0) {
|
113 |
+
let hasSeenText = false;
|
114 |
+
return results.map(item => {
|
115 |
+
if (hasSeenText) {
|
116 |
+
item.type = 'text';
|
117 |
+
} else if (item.type === 'text') {
|
118 |
+
hasSeenText = true;
|
119 |
+
}
|
120 |
+
return item;
|
121 |
+
});
|
122 |
+
}
|
123 |
+
return null;
|
124 |
+
}
|
125 |
+
/**
|
126 |
+
* 创建 OpenAI 格式的流式响应块 (chunk)。
|
127 |
+
* @param {string} content - 文本内容。
|
128 |
+
* @param {'thinking' | 'text'} type - 内容的类型。
|
129 |
+
* @param {string} [model='gemini-pro'] - 模型名称。
|
130 |
+
* @returns {object}
|
131 |
+
*/
|
132 |
+
export function createStreamResponse(content, type, model = 'gemini-pro') {
|
133 |
+
return {
|
134 |
+
id: `chatcmpl-${Date.now()}`,
|
135 |
+
object: 'chat.completion.chunk',
|
136 |
+
created: Math.floor(Date.now() / 1000),
|
137 |
+
model,
|
138 |
+
choices: [{
|
139 |
+
index: 0,
|
140 |
+
delta: {
|
141 |
+
content: content || '',
|
142 |
+
type: type || 'text',
|
143 |
+
},
|
144 |
+
logprobs: null,
|
145 |
+
finish_reason: null
|
146 |
+
}]
|
147 |
+
};
|
148 |
+
}
|
149 |
+
/**
|
150 |
+
* 创建 OpenAI 格式的错误响应。
|
151 |
+
* @param {string} message - 错误消息。
|
152 |
+
* @param {string} [model='gemini-pro'] - 模型名称。
|
153 |
+
* @returns {object}
|
154 |
+
*/
|
155 |
+
export function createErrorResponse(message, model = 'gemini-pro') {
|
156 |
+
return {
|
157 |
+
object: 'error',
|
158 |
+
message,
|
159 |
+
type: 'invalid_request_error',
|
160 |
+
model,
|
161 |
+
};
|
162 |
+
}
|
163 |
+
/**
|
164 |
+
* 创建 OpenAI 格式的非流式(完整)响应。
|
165 |
+
* @param {string} content - 完整的响应内容。
|
166 |
+
* @param {string} [model='gemini-pro'] - 模型名称。
|
167 |
+
* @returns {object}
|
168 |
+
*/
|
169 |
+
export function createNonStreamResponse(content, model = 'gemini-pro') {
|
170 |
+
return {
|
171 |
+
id: `chatcmpl-${Date.now()}`,
|
172 |
+
object: 'chat.completion',
|
173 |
+
created: Math.floor(Date.now() / 1000),
|
174 |
+
model,
|
175 |
+
choices: [{
|
176 |
+
index: 0,
|
177 |
+
message: {
|
178 |
+
role: 'assistant',
|
179 |
+
content
|
180 |
+
},
|
181 |
+
finish_reason: 'stop'
|
182 |
+
}],
|
183 |
+
usage: {
|
184 |
+
prompt_tokens: 0, // 未实现
|
185 |
+
completion_tokens: 0, // 未实现
|
186 |
+
total_tokens: 0 // 未实现
|
187 |
+
}
|
188 |
+
};
|
189 |
+
}
|
src/utils/validation.js
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createError } from 'h3';
|
2 |
+
import config from "../config.js";
|
3 |
+
|
4 |
+
/**
|
5 |
+
* 验证聊天完成请求体,并将其转换为内部处理格式。
|
6 |
+
* @param {object} body - H3 请求体。
|
7 |
+
* @returns {{prompt: string, stream: boolean, model: string, temperature: number, messages: object[]}} 验证和格式化后的数据。
|
8 |
+
* @throws {Error} 如果验证失败,则抛出 H3 错误。
|
9 |
+
*/
|
10 |
+
export function validateChatCompletionRequest(body) {
|
11 |
+
const {
|
12 |
+
messages,
|
13 |
+
stream = false,
|
14 |
+
model = config.api.defaultModel,
|
15 |
+
temperature = config.api.temperature
|
16 |
+
} = body;
|
17 |
+
|
18 |
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
19 |
+
throw createError({
|
20 |
+
statusCode: 400,
|
21 |
+
statusMessage: 'Invalid request: `messages` must be a non-empty array.'
|
22 |
+
});
|
23 |
+
}
|
24 |
+
|
25 |
+
// 将消息数组转换为单个字符串 prompt,以适应 AI Studio 的单输入框模式
|
26 |
+
const prompt = messages.map(message => {
|
27 |
+
if (!message.role || !message.content) {
|
28 |
+
return '';
|
29 |
+
}
|
30 |
+
// 添加角色标签以提供上下文
|
31 |
+
return `${message.role}: ${message.content}`;
|
32 |
+
}).join('\n\n');
|
33 |
+
|
34 |
+
return {
|
35 |
+
prompt,
|
36 |
+
stream,
|
37 |
+
model,
|
38 |
+
temperature,
|
39 |
+
messages
|
40 |
+
};
|
41 |
+
}
|
src/web-server.js
ADDED
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { H3, serve, serveStatic } from 'h3';
|
2 |
+
import { stat, readFile } from 'node:fs/promises';
|
3 |
+
import { join } from 'node:path';
|
4 |
+
import {
|
5 |
+
setupProcessHandlers,
|
6 |
+
initPagePool,
|
7 |
+
getPageFromPool,
|
8 |
+
releasePageToPool,
|
9 |
+
handleWelcomeModal,
|
10 |
+
smartNavigate
|
11 |
+
} from './utils/browser.js';
|
12 |
+
import { startPagePoolMonitoring, printPagePoolStatus } from './utils/page-pool-monitor.js';
|
13 |
+
import { corsMiddleware } from './middlewares/cors.js';
|
14 |
+
import { healthHandler } from './routes/health.js';
|
15 |
+
import { chatCompletionsHandler } from './routes/chat-completions.js';
|
16 |
+
import { modelsHandler, modelHandler } from './routes/models.js';
|
17 |
+
import { saveScreenshot } from './utils/common-utils.js';
|
18 |
+
import config from './config.js';
|
19 |
+
import { info, error } from './utils/logger.js';
|
20 |
+
|
21 |
+
const app = new H3();
|
22 |
+
const CWD = process.cwd();
|
23 |
+
|
24 |
+
// 设置进程退出处理
|
25 |
+
setupProcessHandlers();
|
26 |
+
|
27 |
+
// 全局 CORS 中间件
|
28 |
+
app.use('*', corsMiddleware);
|
29 |
+
|
30 |
+
// --- API 端点 ---
|
31 |
+
// 健康检查端点
|
32 |
+
app.get('/health', healthHandler);
|
33 |
+
|
34 |
+
// 模型相关端点
|
35 |
+
app.get('/v1/models', modelsHandler);
|
36 |
+
app.get('/v1/models/:model', modelHandler);
|
37 |
+
|
38 |
+
// 主要的聊天完成端点
|
39 |
+
app.post('/v1/chat/completions', chatCompletionsHandler);
|
40 |
+
|
41 |
+
// --- 静态文件服务 (必须在 API 端点之后) ---
|
42 |
+
// 处理 screenshots 目录
|
43 |
+
app.get('/screenshots/**', (event) => {
|
44 |
+
// event.path is like /screenshots/foo.png
|
45 |
+
const assetPath = event.path.substring('/screenshots'.length);
|
46 |
+
const filePath = join(CWD, 'screenshots', assetPath);
|
47 |
+
return serveStatic(event, {
|
48 |
+
getContents: () => readFile(filePath),
|
49 |
+
getMeta: async () => {
|
50 |
+
const stats = await stat(filePath).catch(() => {});
|
51 |
+
if (stats?.isFile()) return { size: stats.size, mtime: stats.mtimeMs };
|
52 |
+
}
|
53 |
+
});
|
54 |
+
});
|
55 |
+
|
56 |
+
// 处理 public 目录 (作为其他所有 GET 请求的兜底)
|
57 |
+
app.get('/**', (event) => {
|
58 |
+
const assetPath = event.path === '/' ? 'index.html' : event.path;
|
59 |
+
const filePath = join(CWD, 'public', assetPath);
|
60 |
+
return serveStatic(event, {
|
61 |
+
getContents: () => readFile(filePath),
|
62 |
+
getMeta: async () => {
|
63 |
+
const stats = await stat(filePath).catch(() => {});
|
64 |
+
if (stats?.isFile()) return { size: stats.size, mtime: stats.mtimeMs };
|
65 |
+
}
|
66 |
+
});
|
67 |
+
});
|
68 |
+
|
69 |
+
|
70 |
+
// 初始化页面池
|
71 |
+
info('🔧 初始化页面池...');
|
72 |
+
initPagePool(5); // 最大5个页面
|
73 |
+
|
74 |
+
// 启动页面池监控(开发环境)
|
75 |
+
if (process.env.NODE_ENV !== 'production') {
|
76 |
+
info('📊 启动页面池监控...');
|
77 |
+
startPagePoolMonitoring(10000); // 每10秒监控一次
|
78 |
+
}
|
79 |
+
|
80 |
+
// 启动服务器
|
81 |
+
const port = config.server.port;
|
82 |
+
const host = config.server.host;
|
83 |
+
serve(app, { port, host });
|
84 |
+
|
85 |
+
info(`🚀 服务器运行在 http://${host}:${port}`);
|
86 |
+
info(`🏠 管理面板: http://${host}:${port}/`);
|
87 |
+
info(`📋 健康检查: http://${host}:${port}/health`);
|
88 |
+
info(`💬 聊天端点: http://${host}:${port}/v1/chat/completions`);
|
89 |
+
info(`🎯 AI Studio URL: ${config.aiStudio.url}`);
|
90 |
+
info(`🌍 环境: ${process.env.NODE_ENV || 'development'}`);
|
91 |
+
|
92 |
+
// 截图AI Studio页面的函数
|
93 |
+
async function captureAIStudioScreenshot() {
|
94 |
+
let page;
|
95 |
+
try {
|
96 |
+
info('📸 开始获取AI Studio页面截图...');
|
97 |
+
page = await getPageFromPool();
|
98 |
+
|
99 |
+
info(`🌐 智能导航到: ${config.aiStudio.url}`);
|
100 |
+
await smartNavigate(page, config.aiStudio.url, {
|
101 |
+
timeout: config.aiStudio.pageTimeout
|
102 |
+
});
|
103 |
+
|
104 |
+
await handleWelcomeModal(page);
|
105 |
+
await page.waitForTimeout(2000);
|
106 |
+
|
107 |
+
const screenshotPath = await saveScreenshot(page, './screenshots', 'aistudio-startup');
|
108 |
+
info(`✅ AI Studio截图已保存: ${screenshotPath}`);
|
109 |
+
|
110 |
+
} catch (err) {
|
111 |
+
error('❌ 截图AI Studio页面时出错:', err.message);
|
112 |
+
} finally {
|
113 |
+
if (page) {
|
114 |
+
await releasePageToPool(page);
|
115 |
+
info('🔄 页面已释放回池中');
|
116 |
+
}
|
117 |
+
}
|
118 |
+
}
|
119 |
+
|
120 |
+
// 打印初始页面池状态
|
121 |
+
setTimeout(() => {
|
122 |
+
info('\n📊 初始页面池状态:');
|
123 |
+
printPagePoolStatus();
|
124 |
+
}, 1000);
|
125 |
+
|
126 |
+
// 启动后截图AI Studio页面
|
127 |
+
setTimeout(() => {
|
128 |
+
captureAIStudioScreenshot();
|
129 |
+
}, 2000);
|