dsh-mcloud 0.0.0-stage → 0.2.0

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/lib/login.js ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * dsh-mcloud — 登录模块
3
+ *
4
+ * 用「手机号 + 密码」走 139 邮箱的登录流程,最终换出云盘的 Authorization。
5
+ *
6
+ * 三步(社区逆向,参考 OpenList 的 drivers/139):
7
+ * 1. POST mail.10086.cn/Login/Login.ashx 账号密码登录 → 拿到 sid
8
+ * 2. GET smsrebuild1.mail.10086.cn/setting/s?func=umc:getArtifact&sid=… → 拿到 dycpwd
9
+ * 3. POST user-njs.yun.139.com/user/thirdlogin 用 dycpwd 换 authToken(双层 AES)
10
+ * → Authorization = base64("pc:账号:authToken")
11
+ *
12
+ * 安全约定:
13
+ * · 密码只在内存里活到登录结束,本模块不写盘。
14
+ * · 是否持久化由调用方(宿主端)决定,这里只负责算。
15
+ */
16
+
17
+ import { createHash, randomBytes } from 'node:crypto'
18
+ import { createCipheriv, createDecipheriv } from 'node:crypto'
19
+
20
+ const MAIL_LOGIN_URL = 'https://mail.10086.cn/Login/Login.ashx'
21
+ const MAIL_ARTIFACT_URL = 'https://smsrebuild1.mail.10086.cn/setting/s'
22
+ const YUN_THIRD_LOGIN_URL = 'https://user-njs.yun.139.com/user/thirdlogin'
23
+
24
+ const UA_DESKTOP =
25
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0'
26
+
27
+ /** 第三层登录用的双层 AES 密钥(社区逆向所得,非用户凭据)
28
+ * KEY_HEX_1 → 24 字节 = AES-192-CBC
29
+ * KEY_HEX_2 → 16 字节 = AES-128-ECB
30
+ */
31
+ const KEY_HEX_1 = '73634235495062495331515373756c734e7253306c673d3d'
32
+ const KEY_HEX_2 = '7150714477323633586746674c337538'
33
+
34
+ const TIMEOUT_MS = 25_000
35
+
36
+ // ------------------------------------------------------------------ 小工具
37
+
38
+ function sha1Hex(text) {
39
+ return createHash('sha1').update(text, 'utf8').digest('hex')
40
+ }
41
+
42
+ /** 139 邮箱的密码哈希规则:sha1("fetion.com.cn:" + 明文密码) */
43
+ export function hashMailPassword(password) {
44
+ return sha1Hex(`fetion.com.cn:${String(password)}`)
45
+ }
46
+
47
+ function md5Hex(text) {
48
+ return createHash('md5').update(text, 'utf8').digest('hex')
49
+ }
50
+
51
+ function pkcs7Pad(buf, blockSize) {
52
+ const pad = blockSize - (buf.length % blockSize)
53
+ return Buffer.concat([buf, Buffer.alloc(pad, pad)])
54
+ }
55
+
56
+ function pkcs7Unpad(buf) {
57
+ if (!buf.length) throw new Error('AES 解密失败:数据为空')
58
+ const pad = buf[buf.length - 1]
59
+ if (pad > buf.length) throw new Error('AES 解密失败:填充非法')
60
+ return buf.subarray(0, buf.length - pad)
61
+ }
62
+
63
+ function aesCbcEncrypt(plain, key, iv) {
64
+ // 密钥长度决定算法:16→aes-128, 24→aes-192, 32→aes-256
65
+ const algo = `aes-${key.length * 8}-cbc`
66
+ const cipher = createCipheriv(algo, key, iv)
67
+ return Buffer.concat([cipher.update(pkcs7Pad(plain, 16)), cipher.final()])
68
+ }
69
+
70
+ function aesCbcDecrypt(cipherText, key, iv) {
71
+ const algo = `aes-${key.length * 8}-cbc`
72
+ const d = createDecipheriv(algo, key, iv)
73
+ return pkcs7Unpad(Buffer.concat([d.update(cipherText), d.final()]))
74
+ }
75
+
76
+ function aesEcbDecrypt(cipherText, key) {
77
+ const d = createDecipheriv('aes-128-ecb', key, null)
78
+ d.setAutoPadding(false)
79
+ return pkcs7Unpad(Buffer.concat([d.update(cipherText), d.final()]))
80
+ }
81
+
82
+ /** 递归给 JSON 排序键,再字符串化(第三层登录要求) */
83
+ function sortedJsonStringify(value) {
84
+ if (value === null || value === undefined) return 'null'
85
+ if (typeof value === 'string') {
86
+ try {
87
+ return sortedJsonStringify(JSON.parse(value))
88
+ } catch {
89
+ return JSON.stringify(value)
90
+ }
91
+ }
92
+ if (typeof value !== 'object') return JSON.stringify(value)
93
+ if (Array.isArray(value)) return `[${value.map(sortedJsonStringify).join(',')}]`
94
+ const keys = Object.keys(value).sort()
95
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${sortedJsonStringify(value[k])}`).join(',')}}`
96
+ }
97
+
98
+ function xmlField(xml, tag) {
99
+ const m = new RegExp(`<${tag}>\\s*([\\s\\S]*?)\\s*</${tag}>`, 'i').exec(xml)
100
+ return m ? m[1] : ''
101
+ }
102
+
103
+ /** 从 Set-Cookie 头里取出某个 cookie 的值 */
104
+ function cookieValue(cookieHeaders, name) {
105
+ for (const line of cookieHeaders) {
106
+ const first = String(line).split(';')[0]
107
+ const eq = first.indexOf('=')
108
+ if (eq < 0) continue
109
+ if (first.slice(0, eq).trim() === name) return first.slice(eq + 1)
110
+ }
111
+ return ''
112
+ }
113
+
114
+ function allCookies(cookieHeaders) {
115
+ const pairs = []
116
+ for (const line of cookieHeaders) {
117
+ const first = String(line).split(';')[0]
118
+ if (first.includes('=')) pairs.push(first.trim())
119
+ }
120
+ return pairs.join('; ')
121
+ }
122
+
123
+ // ------------------------------------------------------------------ 第三层:加密请求
124
+
125
+ /**
126
+ * 云盘 SSO 的加密请求:请求体 AES-CBC(随机 IV,前置到 base64),
127
+ * 响应也可能是加密的(base64,前 16 字节是 IV)。
128
+ */
129
+ async function encryptedPost(url, bodyObject, headers, aesKeyHex) {
130
+ const key = Buffer.from(aesKeyHex, 'hex')
131
+ const plain = Buffer.from(sortedJsonStringify(bodyObject), 'utf8')
132
+ const iv = randomBytes(16)
133
+ const payload = Buffer.concat([iv, aesCbcEncrypt(plain, key, iv)]).toString('base64')
134
+
135
+ const response = await fetch(url, {
136
+ method: 'POST',
137
+ headers,
138
+ body: payload,
139
+ signal: AbortSignal.timeout(TIMEOUT_MS),
140
+ })
141
+ if (!response.ok) {
142
+ throw new Error(`SSO 请求失败(HTTP ${response.status})`)
143
+ }
144
+ const text = await response.text()
145
+ if (!text) throw new Error('SSO 返回空响应')
146
+
147
+ // 有时服务端直接回明文 JSON
148
+ if (text.trimStart().startsWith('{')) return JSON.parse(text)
149
+
150
+ let decoded
151
+ try {
152
+ decoded = Buffer.from(text, 'base64')
153
+ } catch {
154
+ throw new Error('SSO 响应不是合法 base64')
155
+ }
156
+ if (decoded.length < 16) throw new Error('SSO 响应过短')
157
+ const respIv = decoded.subarray(0, 16)
158
+ const respCipher = decoded.subarray(16)
159
+ const json = aesCbcDecrypt(respCipher, key, respIv).toString('utf8')
160
+ return JSON.parse(json)
161
+ }
162
+
163
+ // ------------------------------------------------------------------ 登录三步
164
+
165
+ /**
166
+ * 第一步:邮箱账号密码登录,返回 { sid, cookies }
167
+ * 风控(要求短信验证)会抛特定错误,调用方据此提示用户。
168
+ */
169
+ async function mailPasswordLogin(account, password) {
170
+ const cguid = String(Date.now())
171
+ const hashedPassword = hashMailPassword(password)
172
+ const encodedAccount = Buffer.from(String(account), 'utf8').toString('base64')
173
+
174
+ const body = new URLSearchParams({
175
+ UserName: String(account),
176
+ passOld: '',
177
+ auto: 'on',
178
+ Password: hashedPassword,
179
+ webIndexPagePwdLogin: '1',
180
+ pwdType: '1',
181
+ clientId: '1003',
182
+ authType: '2',
183
+ }).toString()
184
+
185
+ const response = await fetch(MAIL_LOGIN_URL, {
186
+ method: 'POST',
187
+ redirect: 'manual',
188
+ headers: {
189
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
190
+ 'accept-language': 'zh-CN,zh;q=0.9',
191
+ 'content-type': 'application/x-www-form-urlencoded',
192
+ origin: 'https://mail.10086.cn',
193
+ referer: `https://mail.10086.cn/default.html?&s=1&v=0&u=${encodedAccount}&m=1&ec=S001&resource=indexLogin&clientid=1003&auto=on&cguid=${cguid}&mtime=45`,
194
+ 'user-agent': UA_DESKTOP,
195
+ },
196
+ body,
197
+ signal: AbortSignal.timeout(TIMEOUT_MS),
198
+ })
199
+
200
+ const setCookie = response.headers.getSetCookie ? response.headers.getSetCookie() : []
201
+ const location = response.headers.get('location') || ''
202
+ const cookies = allCookies(setCookie)
203
+
204
+ // 风控:Location 里带风险码(MW0016 等),需要短信
205
+ const riskMatch = /[?&]riskcode=([A-Za-z0-9]+)/i.exec(location) || /MW\d{4}/.exec(location)
206
+ if (riskMatch) {
207
+ const code = riskMatch[1] || riskMatch[0]
208
+ const error = new Error('账号登录触发了安全验证,需要在手机或官网完成验证')
209
+ error.code = 'MAIL_RISK'
210
+ error.riskCode = code
211
+ throw error
212
+ }
213
+
214
+ // 错误码判断(实测 139 邮箱的约定):
215
+ // ec=S001 → 登录失败(账号或密码不正确)
216
+ // ec=S002 → 同样是凭据问题
217
+ // ec=S003 / S004 → 账号被限制或需要额外验证
218
+ if (/[?&]ec=S00[12]\b/.test(location)) {
219
+ const error = new Error('账号或密码不正确')
220
+ error.code = 'BAD_CREDENTIALS'
221
+ throw error
222
+ }
223
+ if (/[?&]ec=S00[34]\b/.test(location)) {
224
+ const error = new Error('账号被限制或需要额外验证,请先到官网确认账号状态')
225
+ error.code = 'ACCOUNT_BLOCKED'
226
+ throw error
227
+ }
228
+
229
+ // 从 Location 的 query 或 cookie 里拿 sid
230
+ let sid = ''
231
+ const q = /\bsid=([^&]+)/.exec(location)
232
+ if (q) sid = decodeURIComponent(q[1])
233
+ if (!sid) sid = cookieValue(setCookie, 'Os_SSo_Sid') || cookieValue(setCookie, 'sid')
234
+
235
+ if (!sid) {
236
+ const error = new Error('登录未返回会话标识(sid),可能是账号格式不对或触发了风控')
237
+ error.code = 'NO_SID'
238
+ throw error
239
+ }
240
+ return { sid, cookies }
241
+ }
242
+
243
+ /** 第二步:用 sid 换 artifact(dycpwd) */
244
+ async function exchangeArtifact(sid, cookies) {
245
+ const rmkey = (() => {
246
+ for (const part of String(cookies).split(';')) {
247
+ const t = part.trim()
248
+ if (t.startsWith('RMKEY=')) return t
249
+ }
250
+ return ''
251
+ })()
252
+ if (!rmkey) {
253
+ const error = new Error('登录会话缺少 RMKEY,无法换取凭据')
254
+ error.code = 'NO_RMKEY'
255
+ throw error
256
+ }
257
+
258
+ const url = `${MAIL_ARTIFACT_URL}?func=${encodeURIComponent('umc:getArtifact')}&sid=${encodeURIComponent(sid)}&cguid=${Date.now()}`
259
+ const response = await fetch(url, {
260
+ method: 'POST',
261
+ headers: {
262
+ cookie: rmkey,
263
+ 'content-type': 'text/xml; charset=utf-8',
264
+ 'accept-encoding': 'gzip',
265
+ 'user-agent': 'okhttp/4.12.0',
266
+ },
267
+ signal: AbortSignal.timeout(TIMEOUT_MS),
268
+ })
269
+ if (!response.ok) throw new Error(`换取凭据失败(HTTP ${response.status})`)
270
+
271
+ const text = await response.text()
272
+ let artifact = ''
273
+ try {
274
+ artifact = JSON.parse(text)?.var?.artifact || ''
275
+ } catch {
276
+ artifact = ''
277
+ }
278
+ if (!artifact) {
279
+ const error = new Error('换取凭据失败:响应里没有 artifact')
280
+ error.code = 'NO_ARTIFACT'
281
+ throw error
282
+ }
283
+ return artifact
284
+ }
285
+
286
+ /** 第三步:用 dycpwd 换 authToken,拼出 Authorization */
287
+ async function thirdPartyLogin(dycpwd, account) {
288
+ const body = {
289
+ clientkey_decrypt: 'l3TryM&Q+X7@dzwk)qP',
290
+ clienttype: '886',
291
+ cpid: '507',
292
+ dycpwd,
293
+ extInfo: { ifOpenAccount: '0' },
294
+ loginMode: '0',
295
+ msisdn: String(account),
296
+ pintype: '13',
297
+ secinfo: sha1Hex(`fetion.com.cn:${dycpwd}`).toUpperCase(),
298
+ version: '20250901',
299
+ }
300
+
301
+ const headers = {
302
+ 'hcy-cool-flag': '1',
303
+ 'x-huawei-channelSrc': '10246600',
304
+ 'x-sdk-channelSrc': '',
305
+ 'x-MM-Source': '0',
306
+ 'x-UserAgent': 'android|23116PN5BC|android15|1.2.6|||1440x3200|10246600',
307
+ 'x-DeviceInfo': '4|127.0.0.1|5|1.2.6|Xiaomi|23116PN5BC||02-00-00-00-00-00|android 15|1440x3200|android|||',
308
+ 'content-type': 'text/plain;charset=UTF-8',
309
+ 'accept-encoding': 'gzip',
310
+ 'user-agent': 'okhttp/3.12.2',
311
+ }
312
+
313
+ // 第一层:AES-CBC 加密请求,响应解密后 data 是 hex 串
314
+ const layer1 = await encryptedPost(YUN_THIRD_LOGIN_URL, body, headers, KEY_HEX_1)
315
+ const hexInner = layer1?.data
316
+ if (!hexInner || typeof hexInner !== 'string') {
317
+ const error = new Error('SSO 登录失败:响应里没有 data')
318
+ error.code = 'SSO_NO_DATA'
319
+ throw error
320
+ }
321
+
322
+ // 第二层:hex → AES-ECB 解密
323
+ const key2 = Buffer.from(KEY_HEX_2, 'hex')
324
+ const finalJson = aesEcbDecrypt(Buffer.from(hexInner, 'hex'), key2).toString('utf8')
325
+ let parsed
326
+ try {
327
+ parsed = JSON.parse(finalJson)
328
+ } catch {
329
+ throw new Error('SSO 登录失败:二次解密结果不是 JSON')
330
+ }
331
+
332
+ const authToken = parsed?.authToken
333
+ const realAccount = parsed?.account || String(account)
334
+ const userDomainId = parsed?.userDomainId || ''
335
+ if (!authToken) {
336
+ const error = new Error('SSO 登录失败:没有拿到 authToken')
337
+ error.code = 'NO_AUTH_TOKEN'
338
+ throw error
339
+ }
340
+
341
+ const authorization = Buffer.from(`pc:${realAccount}:${authToken}`, 'utf8').toString('base64')
342
+ return { authorization, account: realAccount, userDomainId }
343
+ }
344
+
345
+ /**
346
+ * 完整登录:手机号 + 密码 → Authorization。
347
+ * 不写盘、不留密码;密码只在这个函数的作用域里存活。
348
+ */
349
+ export async function loginWithPassword(account, password) {
350
+ const cleanAccount = String(account || '').trim()
351
+ const cleanPassword = String(password || '')
352
+ if (!cleanAccount) throw new Error('请填写手机号或邮箱')
353
+ if (!cleanPassword) throw new Error('请填写密码')
354
+
355
+ const { sid, cookies } = await mailPasswordLogin(cleanAccount, cleanPassword)
356
+ const artifact = await exchangeArtifact(sid, cookies)
357
+ return thirdPartyLogin(artifact, cleanAccount)
358
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * dsh-mcloud — 浏览器扫码登录(CDP 版)
3
+ *
4
+ * 思路:插件自己拉起一个**独立 profile 的 Edge**,开调试端口,打开云盘首页;
5
+ * 你扫码登录后,云盘会把 `authorization` 写进自己的 cookie(非 HttpOnly),
6
+ * 插件通过 CDP 读出来 → 存起来 → 关掉浏览器。
7
+ *
8
+ * 为什么这么做:
9
+ * · 不用你输密码,最安全(密码从头到尾没经过插件)
10
+ * · 不用手动 F12 抄令牌
11
+ * · 抓到的令牌可以用 `authTokenRefresh.do` 续期,
12
+ * 所以「扫一次 → 之后自动续期 → 长期免扫」
13
+ *
14
+ * 为什么用独立 profile:
15
+ * · 不碰你日常用的 Edge(否则要关掉所有窗口才能加调试端口)
16
+ * · 不会读到你在别的网站的登录态
17
+ * 代价:每次扫码要保持登录(但有了续期,正常只扫一次)
18
+ */
19
+
20
+ import { spawn } from 'node:child_process'
21
+ import { existsSync, mkdirSync, rmSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { tmpdir } from 'node:os'
24
+
25
+ /** 常见 Edge / Chrome 安装位置 */
26
+ const BROWSER_CANDIDATES = [
27
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
28
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
29
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
30
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
31
+ ]
32
+
33
+ const LOGIN_URL = 'https://yun.139.com/'
34
+
35
+ /** 扫码等待上限:5 分钟 */
36
+ export const SCAN_TIMEOUT_MS = 5 * 60 * 1000
37
+
38
+ /**
39
+ * 找一个**确定空闲**的调试端口。
40
+ *
41
+ * 这里必须真去连一下确认:之前踩过坑 —— 固定用 9333,结果上次遗留的浏览器
42
+ * 还占着那个端口,我们新起的实例没起来,却读到了**旧浏览器的 cookie**,
43
+ * 看起来「登录成功」,其实是捡了别人(或上一次)的登录态。
44
+ */
45
+ async function findFreePort(startPort = 9333, tries = 30) {
46
+ const net = await import('node:net')
47
+ for (let i = 0; i < tries; i += 1) {
48
+ const port = startPort + i
49
+ const free = await new Promise((resolve) => {
50
+ const srv = net.createServer()
51
+ srv.once('error', () => resolve(false))
52
+ srv.once('listening', () => srv.close(() => resolve(true)))
53
+ try {
54
+ srv.listen(port, '127.0.0.1')
55
+ } catch {
56
+ resolve(false)
57
+ }
58
+ })
59
+ if (free) return port
60
+ }
61
+ throw new Error('找不到可用的本地端口,请关闭一些程序后重试')
62
+ }
63
+
64
+ function findBrowser() {
65
+ for (const candidate of BROWSER_CANDIDATES) {
66
+ if (existsSync(candidate)) return candidate
67
+ }
68
+ return null
69
+ }
70
+
71
+ function sleep(ms) {
72
+ return new Promise((resolve) => setTimeout(resolve, ms))
73
+ }
74
+
75
+ /** 极简 CDP 客户端(只用 Runtime.evaluate 和 Network.getAllCookies) */
76
+ async function connectCdp(wsUrl) {
77
+ const ws = new WebSocket(wsUrl)
78
+ await new Promise((resolve, reject) => {
79
+ const timer = setTimeout(() => reject(new Error('CDP 连接超时')), 10_000)
80
+ ws.onopen = () => {
81
+ clearTimeout(timer)
82
+ resolve()
83
+ }
84
+ ws.onerror = () => {
85
+ clearTimeout(timer)
86
+ reject(new Error('CDP 连接失败'))
87
+ }
88
+ })
89
+
90
+ let seq = 0
91
+ const pending = new Map()
92
+ ws.onmessage = (event) => {
93
+ let msg
94
+ try {
95
+ msg = JSON.parse(event.data)
96
+ } catch {
97
+ return
98
+ }
99
+ if (!msg.id || !pending.has(msg.id)) return
100
+ const { resolve, reject } = pending.get(msg.id)
101
+ pending.delete(msg.id)
102
+ if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)))
103
+ else resolve(msg.result)
104
+ }
105
+
106
+ const send = (method, params = {}) =>
107
+ new Promise((resolve, reject) => {
108
+ const id = ++seq
109
+ pending.set(id, { resolve, reject })
110
+ try {
111
+ ws.send(JSON.stringify({ id, method, params }))
112
+ } catch (error) {
113
+ pending.delete(id)
114
+ reject(error)
115
+ }
116
+ })
117
+
118
+ return { ws, send }
119
+ }
120
+
121
+ /** 等 CDP 端口就绪,返回浏览器版本信息 */
122
+ async function waitForCdp(port, timeoutMs) {
123
+ const deadline = Date.now() + timeoutMs
124
+ while (Date.now() < deadline) {
125
+ try {
126
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
127
+ signal: AbortSignal.timeout(2000),
128
+ })
129
+ if (res.ok) return await res.json()
130
+ } catch {
131
+ /* 还没起来 */
132
+ }
133
+ await sleep(400)
134
+ }
135
+ throw new Error('浏览器调试端口没有就绪(可能被安全软件拦了)')
136
+ }
137
+
138
+ /** 在打开的页面里找云盘标签页 */
139
+ async function findYunTab(port) {
140
+ try {
141
+ const list = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) })
142
+ .then((r) => r.json())
143
+ return (list || []).find((t) => t.type === 'page' && String(t.url).includes('139.com')) || null
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
149
+ /** 从 cookie 列表里挑出云盘凭据 */
150
+ function pickCredentials(cookies) {
151
+ const get = (name) => {
152
+ const hit = (cookies || []).find((c) => c.name === name && String(c.domain || '').includes('139.com'))
153
+ return hit ? String(hit.value || '') : ''
154
+ }
155
+ const rawAuth = get('authorization')
156
+ const bare = rawAuth.replace(/^Basic\s+/i, '').trim()
157
+ if (!bare) return null
158
+ return {
159
+ authorization: bare,
160
+ udId: get('ud_id'),
161
+ authToken: get('auth_token'),
162
+ skey: get('skey'),
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 拉起浏览器,等用户扫码,然后抓凭据。
168
+ *
169
+ * onProgress 会收到 { phase, message } 形式的进度,便于界面显示。
170
+ * 返回 { authorization, udId, closed:true } 或抛错。
171
+ */
172
+ export async function scanLogin({ onProgress, signal } = {}) {
173
+ const browser = findBrowser()
174
+ if (!browser) {
175
+ throw new Error('没找到 Edge 或 Chrome,请先安装浏览器,或改用账号密码登录')
176
+ }
177
+
178
+ const report = (phase, message) => {
179
+ try {
180
+ onProgress?.({ phase, message })
181
+ } catch {
182
+ /* 进度回调不该影响主流程 */
183
+ }
184
+ }
185
+
186
+ // 每个 profile 独立,避免并发冲突
187
+ const profileDir = join(tmpdir(), `dsh-mcloud-scan-${Date.now().toString(36)}`)
188
+ mkdirSync(profileDir, { recursive: true })
189
+
190
+ // 动态找一个空闲端口(绝不复用可能被旧进程占着的端口)
191
+ let port
192
+ try {
193
+ port = await findFreePort(9333)
194
+ } catch (error) {
195
+ try {
196
+ rmSync(profileDir, { recursive: true, force: true })
197
+ } catch {
198
+ /* ignore */
199
+ }
200
+ throw error
201
+ }
202
+
203
+ let child = null
204
+
205
+ report('launching', '正在启动浏览器…')
206
+
207
+ const cleanup = () => {
208
+ try {
209
+ if (child && !child.killed) child.kill()
210
+ } catch {
211
+ /* 已经退出 */
212
+ }
213
+ // 给浏览器一点时间退出再删 profile,否则文件被占用删不掉
214
+ setTimeout(() => {
215
+ try {
216
+ rmSync(profileDir, { recursive: true, force: true })
217
+ } catch {
218
+ /* 删不掉就算了,是个临时目录 */
219
+ }
220
+ }, 2000)
221
+ }
222
+
223
+ try {
224
+ child = spawn(
225
+ browser,
226
+ [
227
+ `--remote-debugging-port=${port}`,
228
+ `--user-data-dir=${profileDir}`,
229
+ '--no-first-run',
230
+ '--no-default-browser-check',
231
+ '--disable-features=msEdgeIdentityFRE,msEdgeSidebarV2',
232
+ '--window-size=520,760',
233
+ LOGIN_URL,
234
+ ],
235
+ { detached: false, stdio: 'ignore' },
236
+ )
237
+
238
+ child.on('error', () => {
239
+ /* 由下面的超时统一报错 */
240
+ })
241
+
242
+ // 等 CDP 起来 —— 并且确认端口后面确实是**我们刚起的这个实例**
243
+ const version = await waitForCdp(port, 25_000)
244
+ const browserPath = String(version?.Browser || '')
245
+ if (!browserPath) throw new Error('浏览器调试端口返回异常')
246
+ report('waiting', '请在弹出的浏览器里扫码登录(最长等 5 分钟)')
247
+
248
+ const deadline = Date.now() + SCAN_TIMEOUT_MS
249
+ let lastReport = 0
250
+
251
+ while (Date.now() < deadline) {
252
+ if (signal?.aborted) throw new Error('已取消扫码登录')
253
+ if (child.exitCode !== null && child.exitCode !== undefined) {
254
+ throw new Error('浏览器被关闭了,扫码登录已取消')
255
+ }
256
+
257
+ const tab = await findYunTab(port)
258
+ if (tab) {
259
+ try {
260
+ const { ws, send } = await connectCdp(tab.webSocketDebuggerUrl)
261
+ try {
262
+ await send('Network.enable')
263
+ const { cookies } = await send('Network.getAllCookies')
264
+ const picked = pickCredentials(cookies)
265
+ if (picked) {
266
+ report('success', '登录成功,正在保存凭据…')
267
+ ws.close()
268
+ return { ...picked, closed: true }
269
+ }
270
+ } finally {
271
+ try {
272
+ ws.close()
273
+ } catch {
274
+ /* 已关闭 */
275
+ }
276
+ }
277
+ } catch {
278
+ /* 页面可能正在跳转,下一轮再试 */
279
+ }
280
+ }
281
+
282
+ const now = Date.now()
283
+ if (now - lastReport > 8000) {
284
+ lastReport = now
285
+ const left = Math.max(0, Math.round((deadline - now) / 1000))
286
+ report('waiting', `等待扫码登录…(剩余 ${left} 秒)`)
287
+ }
288
+ await sleep(1200)
289
+ }
290
+
291
+ throw new Error('扫码登录超时(5 分钟)。请重新点击「扫码登录」')
292
+ } finally {
293
+ cleanup()
294
+ }
295
+ }