@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 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` 会执行检查、升级 patch 版本并发布 npm 包,执行前请确认当前分支和版本状态。
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.6",
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": "pnpm check && npm version patch && npm publish"
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",
@@ -7,6 +7,8 @@ description: 启动前端项目的石景山门户本地联调,自动登录成
7
7
 
8
8
  调用已安装的 `portal-dev` CLI,不要复制或改写登录自动化逻辑。
9
9
 
10
+ macOS 会复用用户当前打开的 Google Chrome,并在现有窗口的标签页中完成登录;不要另外启动 Chrome。Windows / Linux 使用 CLI 内置的浏览器兼容方案。
11
+
10
12
  ## 执行
11
13
 
12
14
  缺少账号配置时先执行:
@@ -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
+ }
@@ -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"]',