@sybz-components/portal-dev 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/bin/portal-dev.mjs +57 -0
- package/package.json +32 -0
- package/skills/portal-dev/SKILL.md +34 -0
- package/skills/portal-dev/agents/openai.yaml +4 -0
- package/src/config-file.mjs +39 -0
- package/src/configure.mjs +69 -0
- package/src/portal-dev.mjs +241 -0
- package/src/recognize-captcha.mjs +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# @sybz-components/portal-dev
|
|
2
|
+
|
|
3
|
+
成华、石景山门户自动登录与本地前端联调 CLI。支持自动启动项目开发服务、识别图形验证码、登录门户,以及携带门户 Token 打开本地页面。
|
|
4
|
+
|
|
5
|
+
## 环境要求
|
|
6
|
+
|
|
7
|
+
- Node.js 18+
|
|
8
|
+
- Google Chrome 或 Microsoft Edge
|
|
9
|
+
- 目标项目已安装依赖,并提供 `dev` script
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
全局安装:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add -g @sybz-components/portal-dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 第一次使用(推荐)
|
|
20
|
+
|
|
21
|
+
全局安装后,在任意目录运行:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
portal-dev config
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
根据中文提示选择门户、输入账号和密码即可。密码输入时会显示为 `*`。账号配置属于当前电脑用户,与具体项目无关,只需配置一次。
|
|
28
|
+
|
|
29
|
+
默认配置文件位置:
|
|
30
|
+
|
|
31
|
+
- macOS / Linux:`~/.config/sybz-components/portal-dev.json`
|
|
32
|
+
- Windows:`%APPDATA%\sybz-components\portal-dev.json`
|
|
33
|
+
|
|
34
|
+
程序会自动创建目录,并尽可能将配置文件权限设为仅当前用户可读写。
|
|
35
|
+
|
|
36
|
+
配置完成后直接运行:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
portal-dev
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
需要分别配置两个门户时:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
portal-dev config --portal sjs
|
|
46
|
+
portal-dev config --portal chenghua
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 使用
|
|
50
|
+
|
|
51
|
+
石景山门户会启动本地开发服务、进入智能体样板间,并携带门户 Token 打开本地页面:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
portal-dev
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
成华门户完成自动登录后进入智能体广场:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
portal-dev --portal chenghua
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
不在项目目录时可以指定项目路径:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
portal-dev --portal sjs --project .
|
|
67
|
+
portal-dev --portal chenghua --project .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
查看命令帮助:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
portal-dev --help
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 安装 Codex Skill
|
|
77
|
+
|
|
78
|
+
安装包后执行:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
portal-dev skill install
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Skill 默认安装到 `~/.codex/skills/portal-dev`。之后可以在 Codex 中直接说:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
使用 portal-dev 启动当前项目的石景山门户联调
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
或者:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
使用 portal-dev 登录成华门户
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 安全说明
|
|
97
|
+
|
|
98
|
+
- CLI 不会在终端打印用户名、密码或门户 Token。
|
|
99
|
+
- 门户 Token 只用于当前浏览器会话,不写入文件。
|
|
100
|
+
- 用户级配置文件不位于项目目录,不会随项目提交到 Git;仍不要把它复制进源码或 npm 包。
|
|
101
|
+
- `portal-dev config` 输入密码时不会回显明文,也不会在命令历史中留下密码。
|
|
102
|
+
|
|
103
|
+
## 本仓库开发
|
|
104
|
+
|
|
105
|
+
在 `sybz-components` 仓库根目录执行:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pnpm --dir packages/portal-dev check
|
|
109
|
+
pnpm portal:skill:install
|
|
110
|
+
pnpm portal:release
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`portal:release` 会执行检查、升级 patch 版本并发布 npm 包,执行前请确认当前分支和版本状态。
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { dirname, resolve } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
8
|
+
const args = process.argv.slice(2)
|
|
9
|
+
|
|
10
|
+
if (args[0] === 'skill' && args[1] === 'install') {
|
|
11
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE
|
|
12
|
+
if (!homeDir) throw new Error('无法识别用户目录,请设置 HOME 或 USERPROFILE')
|
|
13
|
+
const sourceDir = resolve(packageDir, 'skills/portal-dev')
|
|
14
|
+
const targetRoot = args[2] ? resolve(args[2]) : resolve(homeDir, '.codex/skills')
|
|
15
|
+
const targetDir = resolve(targetRoot, 'portal-dev')
|
|
16
|
+
if (!existsSync(sourceDir)) throw new Error(`Skill 源目录不存在:${sourceDir}`)
|
|
17
|
+
mkdirSync(targetRoot, { recursive: true })
|
|
18
|
+
rmSync(targetDir, { recursive: true, force: true })
|
|
19
|
+
cpSync(sourceDir, targetDir, { recursive: true })
|
|
20
|
+
console.log(`portal-dev Skill 已安装到 ${targetDir}`)
|
|
21
|
+
process.exit(0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (args[0] === 'config') {
|
|
25
|
+
const child = spawn(process.execPath, [resolve(packageDir, 'src/configure.mjs'), ...args.slice(1)], {
|
|
26
|
+
cwd: process.cwd(),
|
|
27
|
+
env: process.env,
|
|
28
|
+
stdio: 'inherit',
|
|
29
|
+
})
|
|
30
|
+
child.on('error', (error) => {
|
|
31
|
+
throw error
|
|
32
|
+
})
|
|
33
|
+
child.on('exit', (code, signal) => (signal ? process.kill(process.pid, signal) : process.exit(code ?? 0)))
|
|
34
|
+
} else if (args.includes('--help') || args.includes('-h')) {
|
|
35
|
+
console.log(`用法:
|
|
36
|
+
portal-dev
|
|
37
|
+
portal-dev config [--portal sjs|chenghua]
|
|
38
|
+
portal-dev --portal sjs [--project <目录>]
|
|
39
|
+
portal-dev --portal chenghua [--project <目录>]
|
|
40
|
+
portal-dev skill install [Codex skills 目录]
|
|
41
|
+
|
|
42
|
+
首次使用只需运行一次 portal-dev config。
|
|
43
|
+
之后在项目目录运行 portal-dev 即可,默认使用石景山门户。`)
|
|
44
|
+
process.exit(0)
|
|
45
|
+
} else {
|
|
46
|
+
const child = spawn(process.execPath, [resolve(packageDir, 'src/portal-dev.mjs'), ...args], {
|
|
47
|
+
cwd: process.cwd(),
|
|
48
|
+
env: process.env,
|
|
49
|
+
stdio: 'inherit',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal))
|
|
53
|
+
child.on('error', (error) => {
|
|
54
|
+
throw error
|
|
55
|
+
})
|
|
56
|
+
child.on('exit', (code, signal) => (signal ? process.kill(process.pid, signal) : process.exit(code ?? 0)))
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sybz-components/portal-dev",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "成华和石景山门户自动登录与本地前端联调 CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"portal-dev": "./bin/portal-dev.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"skills",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"skill:install": "node ./bin/portal-dev.mjs skill install",
|
|
24
|
+
"check": "node --check bin/portal-dev.mjs && node --check src/config-file.mjs && node --check src/configure.mjs && node --check src/portal-dev.mjs && node --check src/recognize-captcha.mjs",
|
|
25
|
+
"release": "pnpm check && npm version patch && npm publish"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@napi-rs/canvas": "^1.0.3",
|
|
29
|
+
"playwright-core": "^1.62.0",
|
|
30
|
+
"tesseract.js": "^7.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: portal-dev
|
|
3
|
+
description: 启动前端项目的石景山门户本地联调,或自动登录成华门户。用户提到 dev:portal、门户联调、成华登录、石景山登录或从任意项目启动门户调试时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 门户本地调试
|
|
7
|
+
|
|
8
|
+
调用已安装的 `portal-dev` CLI,不要复制或改写登录自动化逻辑。
|
|
9
|
+
|
|
10
|
+
## 执行
|
|
11
|
+
|
|
12
|
+
缺少账号配置时先执行:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
portal-dev config --portal sjs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
石景山门户联调:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
portal-dev --portal sjs --project <project-directory>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
成华门户登录:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
portal-dev --portal chenghua --project <project-directory>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
保持进程运行并告诉用户当前状态;只有用户明确要求停止时才终止。不得输出用户名、密码或门户 Token。
|
|
31
|
+
|
|
32
|
+
## 配置
|
|
33
|
+
|
|
34
|
+
账号密码只从当前用户的专属配置文件读取,`portal-dev config` 会交互式创建该文件。缺少凭据时,引导用户在本机终端运行 `portal-dev config`,不索要或展示具体密码。
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const getPortalConfigPath = () => {
|
|
6
|
+
const configRoot =
|
|
7
|
+
process.platform === 'win32' ? resolve(homedir(), 'AppData', 'Roaming') : resolve(homedir(), '.config')
|
|
8
|
+
return resolve(configRoot, 'sybz-components', 'portal-dev.json')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const readPortalConfig = () => {
|
|
12
|
+
const configPath = getPortalConfigPath()
|
|
13
|
+
if (!existsSync(configPath)) return { version: 1, profiles: {} }
|
|
14
|
+
try {
|
|
15
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
16
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('根节点必须是对象')
|
|
17
|
+
return { ...config, profiles: config.profiles && typeof config.profiles === 'object' ? config.profiles : {} }
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new Error(`门户配置文件格式错误:${configPath}\n${error.message}`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const writePortalProfile = (portal, profile) => {
|
|
24
|
+
const configPath = getPortalConfigPath()
|
|
25
|
+
const config = readPortalConfig()
|
|
26
|
+
const nextConfig = {
|
|
27
|
+
...config,
|
|
28
|
+
version: 1,
|
|
29
|
+
profiles: { ...config.profiles, [portal]: profile },
|
|
30
|
+
}
|
|
31
|
+
mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 })
|
|
32
|
+
writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, { mode: 0o600 })
|
|
33
|
+
try {
|
|
34
|
+
chmodSync(configPath, 0o600)
|
|
35
|
+
} catch {
|
|
36
|
+
// Windows 等不支持 POSIX 权限的环境由系统用户目录权限保护。
|
|
37
|
+
}
|
|
38
|
+
return configPath
|
|
39
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises'
|
|
2
|
+
import { writePortalProfile } from './config-file.mjs'
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2)
|
|
5
|
+
const readArg = (name) => {
|
|
6
|
+
const index = args.indexOf(name)
|
|
7
|
+
return index >= 0 ? args[index + 1] : undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const askPassword = (message) => {
|
|
11
|
+
if (!process.stdin.isTTY) throw new Error('当前终端不支持安全密码输入,请在本机终端中运行 portal-dev config')
|
|
12
|
+
|
|
13
|
+
return new Promise((resolvePassword, reject) => {
|
|
14
|
+
let password = ''
|
|
15
|
+
process.stdout.write(message)
|
|
16
|
+
process.stdin.setRawMode(true)
|
|
17
|
+
process.stdin.resume()
|
|
18
|
+
process.stdin.setEncoding('utf8')
|
|
19
|
+
|
|
20
|
+
const cleanup = () => {
|
|
21
|
+
process.stdin.setRawMode(false)
|
|
22
|
+
process.stdin.pause()
|
|
23
|
+
process.stdin.removeListener('data', onData)
|
|
24
|
+
}
|
|
25
|
+
const onData = (input) => {
|
|
26
|
+
for (const character of input) {
|
|
27
|
+
if (character === '\u0003') {
|
|
28
|
+
cleanup()
|
|
29
|
+
process.stdout.write('\n')
|
|
30
|
+
reject(new Error('已取消配置'))
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
if (character === '\r' || character === '\n') {
|
|
34
|
+
cleanup()
|
|
35
|
+
process.stdout.write('\n')
|
|
36
|
+
resolvePassword(password)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (character === '\u007f' || character === '\b') {
|
|
40
|
+
if (password) {
|
|
41
|
+
password = password.slice(0, -1)
|
|
42
|
+
process.stdout.write('\b \b')
|
|
43
|
+
}
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
password += character
|
|
47
|
+
process.stdout.write('*')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
process.stdin.on('data', onData)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
56
|
+
const portalArg = readArg('--portal')
|
|
57
|
+
const portalAnswer = portalArg || (await rl.question('请选择门户(1=石景山,2=成华,直接回车默认石景山):'))
|
|
58
|
+
const portal = portalAnswer === '2' || portalAnswer === 'chenghua' ? 'chenghua' : 'sjs'
|
|
59
|
+
const portalName = portal === 'chenghua' ? '成华' : '石景山'
|
|
60
|
+
const username = (await rl.question(`请输入${portalName}门户账号:`)).trim()
|
|
61
|
+
rl.close()
|
|
62
|
+
if (!username) throw new Error('账号不能为空')
|
|
63
|
+
const password = await askPassword(`请输入${portalName}门户密码(输入内容会隐藏):`)
|
|
64
|
+
if (!password) throw new Error('密码不能为空')
|
|
65
|
+
|
|
66
|
+
const configPath = writePortalProfile(portal, { username, password })
|
|
67
|
+
|
|
68
|
+
console.log(`配置完成:${configPath}`)
|
|
69
|
+
console.log(`现在进入前端项目目录,运行:${portal === 'sjs' ? 'portal-dev' : 'portal-dev --portal chenghua'}`)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { chromium } from 'playwright-core'
|
|
5
|
+
import { readPortalConfig } from './config-file.mjs'
|
|
6
|
+
import { recognizeCaptcha } from './recognize-captcha.mjs'
|
|
7
|
+
|
|
8
|
+
const sleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))
|
|
9
|
+
const args = process.argv.slice(2)
|
|
10
|
+
const readArg = (name) => {
|
|
11
|
+
const index = args.indexOf(name)
|
|
12
|
+
return index >= 0 ? args[index + 1] : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const portal = readArg('--portal') || 'sjs'
|
|
16
|
+
if (!['sjs', 'chenghua'].includes(portal)) throw new Error(`不支持的门户:${portal},可选值为 sjs、chenghua`)
|
|
17
|
+
const loginOnly = args.includes('--login-only') || portal === 'chenghua'
|
|
18
|
+
|
|
19
|
+
const findProject = () => {
|
|
20
|
+
const explicit = readArg('--project')
|
|
21
|
+
if (explicit) return resolve(explicit)
|
|
22
|
+
let current = process.cwd()
|
|
23
|
+
while (true) {
|
|
24
|
+
if (existsSync(resolve(current, 'package.json'))) return current
|
|
25
|
+
const parent = dirname(current)
|
|
26
|
+
if (parent === current) break
|
|
27
|
+
current = parent
|
|
28
|
+
}
|
|
29
|
+
throw new Error('未找到前端项目,请先进入项目目录,或使用 --project <路径> 指定')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const projectDir = findProject()
|
|
33
|
+
if (!existsSync(resolve(projectDir, 'package.json')))
|
|
34
|
+
throw new Error(`目标项目不存在或缺少 package.json:${projectDir}`)
|
|
35
|
+
const portalProfile = readPortalConfig().profiles[portal] || {}
|
|
36
|
+
|
|
37
|
+
const configs = {
|
|
38
|
+
sjs: {
|
|
39
|
+
loginUrl: 'http://115.190.54.111:1880/passport/login/userLogin',
|
|
40
|
+
username: portalProfile.username,
|
|
41
|
+
password: portalProfile.password,
|
|
42
|
+
},
|
|
43
|
+
chenghua: {
|
|
44
|
+
loginUrl: 'https://www.chenghua-ai.com/passport/login/userLogin',
|
|
45
|
+
username: portalProfile.username,
|
|
46
|
+
password: portalProfile.password,
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
const config = configs[portal]
|
|
50
|
+
if (!config.username || !config.password) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`尚未配置${portal === 'chenghua' ? '成华' : '石景山'}门户账号,请先运行 portal-dev config --portal ${portal}`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const localOrigin = 'http://localhost:5173'
|
|
57
|
+
const iframeHost = 'hia.sjsdoubao.com:31118'
|
|
58
|
+
const iframePath = '/exhibition-hall'
|
|
59
|
+
const roomName = '3D智能展厅智能体'
|
|
60
|
+
const chromeCandidates = {
|
|
61
|
+
darwin: [
|
|
62
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
63
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
64
|
+
],
|
|
65
|
+
win32: [
|
|
66
|
+
`${process.env.PROGRAMFILES || ''}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
67
|
+
`${process.env['PROGRAMFILES(X86)'] || ''}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
68
|
+
`${process.env.LOCALAPPDATA || ''}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
69
|
+
],
|
|
70
|
+
linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'],
|
|
71
|
+
}
|
|
72
|
+
const executablePath = chromeCandidates[process.platform]?.find(existsSync)
|
|
73
|
+
if (!executablePath) throw new Error('未找到 Chrome 或 Edge,请先安装浏览器')
|
|
74
|
+
|
|
75
|
+
const localUrl = new URL(localOrigin)
|
|
76
|
+
const isReady = async () => {
|
|
77
|
+
try {
|
|
78
|
+
return (await fetch(localOrigin, { redirect: 'manual' })).status < 500
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let devServer
|
|
85
|
+
if (!loginOnly && !(await isReady())) {
|
|
86
|
+
const packageJson = JSON.parse(readFileSync(resolve(projectDir, 'package.json'), 'utf8'))
|
|
87
|
+
if (!packageJson.scripts?.dev) throw new Error(`目标项目没有 dev script:${projectDir}`)
|
|
88
|
+
const runner = existsSync(resolve(projectDir, 'bun.lock'))
|
|
89
|
+
? 'bun'
|
|
90
|
+
: existsSync(resolve(projectDir, 'pnpm-lock.yaml'))
|
|
91
|
+
? 'pnpm'
|
|
92
|
+
: 'npm'
|
|
93
|
+
console.log(`正在启动项目开发服务:${projectDir}`)
|
|
94
|
+
devServer = spawn(runner, ['run', 'dev', '--', '--host', localUrl.hostname, '--port', localUrl.port || '80'], {
|
|
95
|
+
cwd: projectDir,
|
|
96
|
+
env: process.env,
|
|
97
|
+
stdio: 'inherit',
|
|
98
|
+
})
|
|
99
|
+
for (let index = 0; index < 120 && !(await isReady()); index += 1) await sleep(500)
|
|
100
|
+
if (!(await isReady())) throw new Error(`本地开发服务启动超时:${localOrigin}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const browser = await chromium.launch({ headless: false, executablePath })
|
|
104
|
+
const context = await browser.newContext()
|
|
105
|
+
const page = await context.newPage()
|
|
106
|
+
await page.goto(config.loginUrl, { waitUntil: 'domcontentloaded' })
|
|
107
|
+
|
|
108
|
+
const visibleLocator = async (selectors) => {
|
|
109
|
+
for (const frame of page.frames()) {
|
|
110
|
+
for (const selector of selectors) {
|
|
111
|
+
const locator = frame.locator(selector).filter({ visible: true }).first()
|
|
112
|
+
if (await locator.count().catch(() => 0)) return locator
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const clickText = async (text) => {
|
|
119
|
+
for (const frame of page.frames()) {
|
|
120
|
+
const locator = frame.getByText(text, { exact: false }).filter({ visible: true }).first()
|
|
121
|
+
if (await locator.count().catch(() => 0)) {
|
|
122
|
+
await locator.click()
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const login = async () => {
|
|
130
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
131
|
+
let captcha
|
|
132
|
+
try {
|
|
133
|
+
captcha = await visibleLocator([
|
|
134
|
+
'img.code-img',
|
|
135
|
+
'img[class*="captcha" i]',
|
|
136
|
+
'img[alt*="验证码"]',
|
|
137
|
+
'img[title*="验证码"]',
|
|
138
|
+
])
|
|
139
|
+
if (!captcha) throw new Error('未找到图形验证码')
|
|
140
|
+
const captchaUrl = await captcha.evaluate((image) => image.currentSrc || image.src || '')
|
|
141
|
+
const captchaText = await recognizeCaptcha(
|
|
142
|
+
captchaUrl && !captchaUrl.startsWith('blob:') ? captchaUrl : await captcha.screenshot(),
|
|
143
|
+
)
|
|
144
|
+
console.log(`已识别图形验证码(第 ${attempt}/3 次)`)
|
|
145
|
+
const usernameInput = await visibleLocator([
|
|
146
|
+
'input[autocomplete="username"]',
|
|
147
|
+
'input[name="username"]',
|
|
148
|
+
'input[placeholder*="用户名"]',
|
|
149
|
+
'input[placeholder*="账号"]',
|
|
150
|
+
])
|
|
151
|
+
const passwordInput = await visibleLocator([
|
|
152
|
+
'input[autocomplete="current-password"]',
|
|
153
|
+
'input[name="password"]',
|
|
154
|
+
'input[type="password"]',
|
|
155
|
+
'input[placeholder*="密码"]',
|
|
156
|
+
])
|
|
157
|
+
const captchaInput = await visibleLocator([
|
|
158
|
+
'input[placeholder*="图形验证码"]',
|
|
159
|
+
'input[placeholder*="验证码"]',
|
|
160
|
+
'input[name*="captcha" i]',
|
|
161
|
+
'input[name*="code" i]',
|
|
162
|
+
])
|
|
163
|
+
if (!usernameInput || !passwordInput || !captchaInput) throw new Error('登录表单字段不完整')
|
|
164
|
+
await usernameInput.fill(config.username)
|
|
165
|
+
await passwordInput.fill(config.password)
|
|
166
|
+
await captchaInput.fill(captchaText)
|
|
167
|
+
const button = await visibleLocator([
|
|
168
|
+
'.btn-box .btn',
|
|
169
|
+
'button:has-text("登录")',
|
|
170
|
+
'[role="button"]:has-text("登录")',
|
|
171
|
+
])
|
|
172
|
+
if (!button) throw new Error('未找到登录按钮')
|
|
173
|
+
await button.click()
|
|
174
|
+
await sleep(1800)
|
|
175
|
+
const stillLoginForm = Boolean(await visibleLocator(['input[type="password"]', 'input[placeholder*="验证码"]']))
|
|
176
|
+
if (!page.url().includes('/passport/login/') && !stillLoginForm) return
|
|
177
|
+
throw new Error('登录未成功')
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error(`第 ${attempt} 次登录失败:${error instanceof Error ? error.message : error}`)
|
|
180
|
+
if (attempt < 3) await captcha?.click().catch(() => undefined)
|
|
181
|
+
await sleep(800)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
throw new Error('自动登录失败,已达到最多重试次数')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (page.url().includes('/passport/login/') || portal === 'chenghua') await login()
|
|
188
|
+
if (loginOnly) {
|
|
189
|
+
await page.goto('https://www.chenghua-ai.com/chat/pages/application', { waitUntil: 'domcontentloaded' })
|
|
190
|
+
console.log('成华门户登录已完成,按 Ctrl+C 结束。')
|
|
191
|
+
await new Promise(() => undefined)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let sampleRoomClicked = false
|
|
195
|
+
let searched = false
|
|
196
|
+
let targetUrl
|
|
197
|
+
for (let index = 0; index < 180; index += 1) {
|
|
198
|
+
for (const frame of page.frames()) {
|
|
199
|
+
try {
|
|
200
|
+
const candidate = new URL(frame.url())
|
|
201
|
+
if (
|
|
202
|
+
candidate.host === iframeHost &&
|
|
203
|
+
candidate.pathname.startsWith(iframePath) &&
|
|
204
|
+
candidate.searchParams.has('token')
|
|
205
|
+
)
|
|
206
|
+
targetUrl = candidate
|
|
207
|
+
} catch {
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (targetUrl) break
|
|
212
|
+
if (!sampleRoomClicked) sampleRoomClicked = await clickText('智能体样板间')
|
|
213
|
+
else if (!searched) {
|
|
214
|
+
const search = await visibleLocator([
|
|
215
|
+
'input[placeholder*="搜索"]',
|
|
216
|
+
'input[placeholder*="查找"]',
|
|
217
|
+
'input[type="search"]',
|
|
218
|
+
])
|
|
219
|
+
if (search) {
|
|
220
|
+
await search.fill(roomName)
|
|
221
|
+
searched = true
|
|
222
|
+
await sleep(800)
|
|
223
|
+
}
|
|
224
|
+
} else (await clickText(roomName)) || (await clickText('3D智能展厅'))
|
|
225
|
+
await sleep(1000)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!targetUrl) throw new Error(`未找到目标智能体入口:${roomName}`)
|
|
229
|
+
const destination = new URL(`${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`, localOrigin)
|
|
230
|
+
await context.newPage().then((localPage) => localPage.goto(destination.href))
|
|
231
|
+
console.log(`门户本地调试已就绪:${localOrigin}${targetUrl.pathname}`)
|
|
232
|
+
console.log('Token 未打印、未写入文件。按 Ctrl+C 结束。')
|
|
233
|
+
|
|
234
|
+
const shutdown = async () => {
|
|
235
|
+
devServer?.kill('SIGTERM')
|
|
236
|
+
await browser.close().catch(() => undefined)
|
|
237
|
+
process.exit(0)
|
|
238
|
+
}
|
|
239
|
+
process.on('SIGINT', shutdown)
|
|
240
|
+
process.on('SIGTERM', shutdown)
|
|
241
|
+
await new Promise(() => undefined)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createCanvas, loadImage } from '@napi-rs/canvas'
|
|
2
|
+
import { createWorker, PSM } from 'tesseract.js'
|
|
3
|
+
|
|
4
|
+
const CAPTCHA_PATTERN = /^[A-Z0-9]{4}$/
|
|
5
|
+
|
|
6
|
+
export async function recognizeCaptcha(source) {
|
|
7
|
+
const image = await loadImage(source)
|
|
8
|
+
const { width, height } = image
|
|
9
|
+
const input = createCanvas(width, height)
|
|
10
|
+
const context = input.getContext('2d')
|
|
11
|
+
context.drawImage(image, 0, 0, width, height)
|
|
12
|
+
const pixels = context.getImageData(0, 0, width, height)
|
|
13
|
+
const mask = new Uint8Array(width * height)
|
|
14
|
+
for (let index = 0; index < mask.length; index += 1) {
|
|
15
|
+
const offset = index * 4
|
|
16
|
+
const brightness = pixels.data[offset] * 0.299 + pixels.data[offset + 1] * 0.587 + pixels.data[offset + 2] * 0.114
|
|
17
|
+
mask[index] = brightness > 150 ? 1 : 0
|
|
18
|
+
}
|
|
19
|
+
const opened = dilate(erode(mask, width, height), width, height)
|
|
20
|
+
const scale = 4
|
|
21
|
+
const padding = 12
|
|
22
|
+
const output = createCanvas(width * scale + padding * 2, height * scale + padding * 2)
|
|
23
|
+
const outputContext = output.getContext('2d')
|
|
24
|
+
outputContext.fillStyle = '#fff'
|
|
25
|
+
outputContext.fillRect(0, 0, output.width, output.height)
|
|
26
|
+
outputContext.fillStyle = '#000'
|
|
27
|
+
for (let y = 0; y < height; y += 1) {
|
|
28
|
+
for (let x = 0; x < width; x += 1) {
|
|
29
|
+
if (opened[y * width + x]) outputContext.fillRect(padding + x * scale, padding + y * scale, scale, scale)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const worker = await createWorker('eng')
|
|
34
|
+
try {
|
|
35
|
+
await worker.setParameters({
|
|
36
|
+
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
|
|
37
|
+
tessedit_pageseg_mode: PSM.SINGLE_WORD,
|
|
38
|
+
})
|
|
39
|
+
const { data } = await worker.recognize(output.toBuffer('image/png'))
|
|
40
|
+
const result = data.text.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
|
41
|
+
if (!CAPTCHA_PATTERN.test(result)) throw new Error(`验证码识别结果无效:${JSON.stringify(data.text)}`)
|
|
42
|
+
return result
|
|
43
|
+
} finally {
|
|
44
|
+
await worker.terminate()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const erode = (mask, width, height) => {
|
|
49
|
+
const result = new Uint8Array(mask.length)
|
|
50
|
+
for (let y = 1; y < height - 1; y += 1) {
|
|
51
|
+
for (let x = 1; x < width - 1; x += 1) {
|
|
52
|
+
const index = y * width + x
|
|
53
|
+
result[index] =
|
|
54
|
+
mask[index] && mask[index - 1] && mask[index + 1] && mask[index - width] && mask[index + width] ? 1 : 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dilate = (mask, width, height) => {
|
|
61
|
+
const result = new Uint8Array(mask.length)
|
|
62
|
+
for (let y = 1; y < height - 1; y += 1) {
|
|
63
|
+
for (let x = 1; x < width - 1; x += 1) {
|
|
64
|
+
const index = y * width + x
|
|
65
|
+
result[index] =
|
|
66
|
+
mask[index] || mask[index - 1] || mask[index + 1] || mask[index - width] || mask[index + width] ? 1 : 0
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result
|
|
70
|
+
}
|