@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 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,4 @@
1
+ interface:
2
+ display_name: '门户本地调试'
3
+ short_description: '自动登录成华、石景山门户并启动本地联调'
4
+ default_prompt: '使用 $portal-dev 启动当前项目的门户本地调试。'
@@ -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
+ }