@sybz-components/portal-dev 1.0.6 → 1.0.10
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 +3 -1
- package/package.json +3 -3
- package/skills/portal-dev/SKILL.md +2 -0
- package/src/macos-chrome.mjs +171 -0
- package/src/portal-dev.mjs +18 -0
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
- Node.js 18+
|
|
8
8
|
- Google Chrome 或 Microsoft Edge
|
|
9
9
|
|
|
10
|
+
macOS 会直接复用当前打开的 Google Chrome,在现有窗口中新建标签页完成登录和本地联调,不会启动独立的 Chrome 实例。Windows / Linux 使用独立浏览器实例作为兼容方案。
|
|
11
|
+
|
|
10
12
|
## 安装
|
|
11
13
|
|
|
12
14
|
全局安装:
|
|
@@ -176,4 +178,4 @@ pnpm portal:skill:install
|
|
|
176
178
|
pnpm portal:release
|
|
177
179
|
```
|
|
178
180
|
|
|
179
|
-
`portal:release`
|
|
181
|
+
`portal:release` 会检查工作区和 npm 登录状态、升级 patch 版本并公开发布到 npm。只有确认新版本能从 npm registry 查询到之后,才会创建版本提交和 `portal-dev-v<版本号>` Git tag,并将当前分支及 tags 推送到 Git 远端。发布失败时会恢复本地版本号,避免留下无法安装的空版本。执行前请先提交当前改动,并确认当前分支和远端正确。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sybz-components/portal-dev",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "成华和石景山门户自动登录与本地前端联调 CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
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": "
|
|
24
|
+
"check": "node --check bin/portal-dev.mjs && node --check src/config-file.mjs && node --check src/configure.mjs && node --check src/macos-chrome.mjs && node --check src/portal-dev.mjs && node --check src/recognize-captcha.mjs",
|
|
25
|
+
"release": "node ./scripts/release.mjs"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@napi-rs/canvas": "^1.0.3",
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile)
|
|
5
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
6
|
+
const escapeAppleScript = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, ' ')
|
|
7
|
+
|
|
8
|
+
const runAppleScript = async (script) => {
|
|
9
|
+
const { stdout } = await execFileAsync('/usr/bin/osascript', ['-e', script])
|
|
10
|
+
return stdout.trim()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const openTab = async (url) => {
|
|
14
|
+
const result = await runAppleScript(`
|
|
15
|
+
tell application "Google Chrome"
|
|
16
|
+
activate
|
|
17
|
+
if (count windows) = 0 then make new window
|
|
18
|
+
set currentWindow to front window
|
|
19
|
+
tell currentWindow
|
|
20
|
+
set newTab to make new tab with properties {URL:"${escapeAppleScript(url)}"}
|
|
21
|
+
set active tab index to (count tabs)
|
|
22
|
+
end tell
|
|
23
|
+
return "tab id " & (id of newTab) & " of window id " & (id of currentWindow)
|
|
24
|
+
end tell
|
|
25
|
+
`)
|
|
26
|
+
const match = result.match(/tab id (\d+) of window id (\d+)/)
|
|
27
|
+
if (!match) throw new Error(`无法识别 Chrome 新标签页:${result}`)
|
|
28
|
+
return { tabId: Number(match[1]), windowId: Number(match[2]) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const execute = (tab, javascript) =>
|
|
32
|
+
runAppleScript(
|
|
33
|
+
`tell application "Google Chrome" to return (execute tab id ${tab.tabId} of window id ${tab.windowId} javascript "${escapeAppleScript(javascript)}") as text`,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const pageState = async (tab) =>
|
|
37
|
+
JSON.parse(
|
|
38
|
+
await execute(
|
|
39
|
+
tab,
|
|
40
|
+
`(() => JSON.stringify({url: location.href, frameUrls: Array.from(document.querySelectorAll('iframe')).map((frame) => frame.src).filter(Boolean)}))()`,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const findTargetUrl = (state, iframeHost, iframePath) =>
|
|
45
|
+
state.frameUrls
|
|
46
|
+
.map((value) => {
|
|
47
|
+
try {
|
|
48
|
+
return new URL(value, state.url)
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
.find((url) => url?.host === iframeHost && url.pathname.startsWith(iframePath) && url.searchParams.has('token'))
|
|
54
|
+
|
|
55
|
+
const clickText = async (tab, text) =>
|
|
56
|
+
(await execute(
|
|
57
|
+
tab,
|
|
58
|
+
`(() => {
|
|
59
|
+
const label = ${JSON.stringify(text)};
|
|
60
|
+
const visible = (element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; };
|
|
61
|
+
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"], div, span')).filter((element) => visible(element) && (element.textContent || '').includes(label)).sort((a, b) => (a.textContent || '').length - (b.textContent || '').length);
|
|
62
|
+
if (!candidates[0]) return 'false'; candidates[0].click(); return 'true';
|
|
63
|
+
})()`,
|
|
64
|
+
)) === 'true'
|
|
65
|
+
|
|
66
|
+
const fillSearch = async (tab, roomName) =>
|
|
67
|
+
(await execute(
|
|
68
|
+
tab,
|
|
69
|
+
`(() => {
|
|
70
|
+
const input = document.querySelector('input[placeholder*="搜索"], input[placeholder*="查找"], input[type="search"]');
|
|
71
|
+
if (!input) return 'false';
|
|
72
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
73
|
+
setter.call(input, ${JSON.stringify(roomName)}); input.dispatchEvent(new Event('input', {bubbles:true})); input.dispatchEvent(new Event('change', {bubbles:true})); return 'true';
|
|
74
|
+
})()`,
|
|
75
|
+
)) === 'true'
|
|
76
|
+
|
|
77
|
+
const fillAndSubmit = async (tab, { username, password, captchaText, custom }) =>
|
|
78
|
+
JSON.parse(
|
|
79
|
+
await execute(
|
|
80
|
+
tab,
|
|
81
|
+
`(() => {
|
|
82
|
+
const values = ${JSON.stringify({ username, password, captchaText })};
|
|
83
|
+
const visible = (element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; };
|
|
84
|
+
const inputs = Array.from(document.querySelectorAll('input')).filter(visible);
|
|
85
|
+
const setValue = (element, value) => { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; setter.call(element, value); element.dispatchEvent(new Event('input', {bubbles:true})); element.dispatchEvent(new Event('change', {bubbles:true})); };
|
|
86
|
+
const attribute = (input, name) => input.getAttribute(name) || '';
|
|
87
|
+
const passwordInput = inputs.find((input) => attribute(input, 'autocomplete') === 'current-password' || /^password$/i.test(attribute(input, 'name')) || input.type === 'password' || /密码/.test(attribute(input, 'placeholder')));
|
|
88
|
+
const captchaInput = inputs.find((input) => /验证码/.test(attribute(input, 'placeholder')) || /captcha|code/i.test(attribute(input, 'name')));
|
|
89
|
+
const usernameInput = inputs.find((input) => attribute(input, 'autocomplete') === 'username' || /^(username|account|login|user|email|phone)$/i.test(attribute(input, 'name')) || /用户名|账号|邮箱|手机/.test(attribute(input, 'placeholder'))) || inputs.find((input) => input !== passwordInput && input !== captchaInput && /^(text|email|tel|number)$/.test(input.type));
|
|
90
|
+
const missing = [!usernameInput && '账号输入框', !passwordInput && '密码输入框', !${custom} && !captchaInput && '验证码输入框'].filter(Boolean);
|
|
91
|
+
if (missing.length) return JSON.stringify({submitted:false, reason:'未找到' + missing.join('、')});
|
|
92
|
+
setValue(usernameInput, values.username); setValue(passwordInput, values.password); if (captchaInput) setValue(captchaInput, values.captchaText);
|
|
93
|
+
const button = Array.from(document.querySelectorAll('button, input[type="submit"], [role="button"], .btn-box .btn')).filter(visible).find((element) => element.type === 'submit' || /登录|Login|Sign in/i.test(element.textContent || element.value || ''));
|
|
94
|
+
if (!button) return JSON.stringify({submitted:false, reason:'未找到登录按钮'}); button.click(); return JSON.stringify({submitted:true});
|
|
95
|
+
})()`,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const login = async (tab, config, recognizeCaptcha, custom) => {
|
|
100
|
+
for (let attempt = 1; attempt <= (custom ? 1 : 3); attempt += 1) {
|
|
101
|
+
let captchaText = ''
|
|
102
|
+
if (!custom) {
|
|
103
|
+
const captchaUrl = await execute(
|
|
104
|
+
tab,
|
|
105
|
+
`(() => { const image = document.querySelector('img.code-img, img[class*="captcha" i], img[alt*="验证码"], img[title*="验证码"]'); return image ? (image.currentSrc || image.src || '') : ''; })()`,
|
|
106
|
+
)
|
|
107
|
+
if (!captchaUrl) throw new Error('未找到图形验证码')
|
|
108
|
+
captchaText = await recognizeCaptcha(captchaUrl)
|
|
109
|
+
console.log(`已识别图形验证码(第 ${attempt}/3 次)`)
|
|
110
|
+
}
|
|
111
|
+
const result = await fillAndSubmit(tab, { ...config, captchaText, custom })
|
|
112
|
+
if (!result.submitted) throw new Error(result.reason || '登录表单字段不完整或未找到登录按钮')
|
|
113
|
+
await sleep(1800)
|
|
114
|
+
const state = await pageState(tab)
|
|
115
|
+
if (custom || !state.url.includes('/passport/login/')) return
|
|
116
|
+
if (attempt < 3)
|
|
117
|
+
await execute(
|
|
118
|
+
tab,
|
|
119
|
+
`(() => { const image = document.querySelector('img.code-img, img[class*="captcha" i]'); if (image) image.click(); return ''; })()`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
throw new Error('自动登录失败,已达到最多重试次数')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const runInExistingChrome = async ({
|
|
126
|
+
config,
|
|
127
|
+
devMode,
|
|
128
|
+
localOrigin,
|
|
129
|
+
localPath,
|
|
130
|
+
iframeHost,
|
|
131
|
+
iframePath,
|
|
132
|
+
roomName,
|
|
133
|
+
portal,
|
|
134
|
+
portalName,
|
|
135
|
+
recognizeCaptcha,
|
|
136
|
+
}) => {
|
|
137
|
+
console.log('正在使用当前打开的 Google Chrome 登录门户。')
|
|
138
|
+
const tab = await openTab(config.loginUrl)
|
|
139
|
+
await sleep(800)
|
|
140
|
+
const state = await pageState(tab)
|
|
141
|
+
if (portal === 'custom') await login(tab, config, recognizeCaptcha, true)
|
|
142
|
+
else if (state.url.includes('/passport/login/') || portal === 'chenghua')
|
|
143
|
+
await login(tab, config, recognizeCaptcha, false)
|
|
144
|
+
|
|
145
|
+
if (!devMode) {
|
|
146
|
+
if (portal === 'chenghua') {
|
|
147
|
+
await execute(tab, `location.href = 'https://www.chenghua-ai.com/chat/pages/application'; ''`)
|
|
148
|
+
}
|
|
149
|
+
console.log(`${portalName}登录流程已完成,按 Ctrl+C 结束。`)
|
|
150
|
+
await new Promise(() => undefined)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let sampleRoomClicked = false
|
|
154
|
+
let searched = false
|
|
155
|
+
for (let index = 0; index < 180; index += 1) {
|
|
156
|
+
const currentState = await pageState(tab)
|
|
157
|
+
const targetUrl = findTargetUrl(currentState, iframeHost, iframePath)
|
|
158
|
+
if (targetUrl) {
|
|
159
|
+
const destination = new URL(`${localPath || targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`, localOrigin)
|
|
160
|
+
await openTab(destination.href)
|
|
161
|
+
console.log(`门户本地调试已就绪:${localOrigin}${targetUrl.pathname}`)
|
|
162
|
+
console.log('Token 未打印、未写入文件。按 Ctrl+C 结束。')
|
|
163
|
+
return new Promise(() => undefined)
|
|
164
|
+
}
|
|
165
|
+
if (!sampleRoomClicked) sampleRoomClicked = await clickText(tab, '智能体样板间')
|
|
166
|
+
else if (!searched) searched = await fillSearch(tab, roomName)
|
|
167
|
+
else (await clickText(tab, roomName)) || (await clickText(tab, '3D智能展厅'))
|
|
168
|
+
await sleep(1000)
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`未找到目标智能体入口:${roomName}`)
|
|
171
|
+
}
|
package/src/portal-dev.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path'
|
|
|
4
4
|
import { chromium } from 'playwright-core'
|
|
5
5
|
import { readPortalConfig } from './config-file.mjs'
|
|
6
6
|
import { recognizeCaptcha } from './recognize-captcha.mjs'
|
|
7
|
+
import { runInExistingChrome } from './macos-chrome.mjs'
|
|
7
8
|
|
|
8
9
|
const sleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))
|
|
9
10
|
const args = process.argv.slice(2)
|
|
@@ -145,6 +146,22 @@ if (devMode && !(await isReady())) {
|
|
|
145
146
|
if (!(await isReady())) throw new Error(`本地开发服务启动超时:${localOrigin}`)
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
if (process.platform === 'darwin') {
|
|
150
|
+
await runInExistingChrome({
|
|
151
|
+
config,
|
|
152
|
+
devMode,
|
|
153
|
+
localOrigin,
|
|
154
|
+
localPath,
|
|
155
|
+
iframeHost,
|
|
156
|
+
iframePath,
|
|
157
|
+
roomName,
|
|
158
|
+
portal,
|
|
159
|
+
portalName:
|
|
160
|
+
portal === 'custom' ? `自定义网站“${portalAccount.name}”` : `${portal === 'chenghua' ? '成华' : '石景山'}门户`,
|
|
161
|
+
recognizeCaptcha,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
148
165
|
const browser = await chromium.launch({ headless: false, executablePath })
|
|
149
166
|
const context = await browser.newContext()
|
|
150
167
|
const page = await context.newPage()
|
|
@@ -192,6 +209,7 @@ const login = async () => {
|
|
|
192
209
|
'input[name="username"]',
|
|
193
210
|
'input[placeholder*="用户名"]',
|
|
194
211
|
'input[placeholder*="账号"]',
|
|
212
|
+
'input[autocomplete="off"]:not([type="password"]):not([placeholder*="验证码"])',
|
|
195
213
|
])
|
|
196
214
|
const passwordInput = await visibleLocator([
|
|
197
215
|
'input[autocomplete="current-password"]',
|