magicrew-auth 0.3.7 → 0.3.13

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.
@@ -0,0 +1,335 @@
1
+ // 纯协议完成「第一次 AI 工作」—— 不开浏览器、不点页面
2
+ //
3
+ // ═══════════════════════════════════════════════════════════════════════
4
+ // 为什么能做到
5
+ // ═══════════════════════════════════════════════════════════════════════
6
+ //
7
+ // 2026-10-27 用 CDP 抓了网页发消息的全过程,发现:
8
+ //
9
+ // · 消息走 Socket.IO(Engine.IO v3),wss://<host>/socket.io/?EIO=3&transport=websocket
10
+ // · **鉴权在消息体里**(context.authorization = JWT),不在握手头 —— 所以不用带 Cookie
11
+ // · 不需要先发 `40` CONNECT,直接 `42/im,<ns>["login",…]` 就能被受理
12
+ // · <ns> 是客户端自己生成的唯一数字(浏览器用 Date.now()),随便给不重复的就行
13
+ //
14
+ // 帧格式(实测):
15
+ //
16
+ // 42/im,<ns>["login",{context:{…}}] → 43/im,<ns>[{code:1000,…user}]
17
+ // 42/im,<ns>["chat", {context:{…},data:{…}}] → 43/im,<ns>[{code:1000,…seq}]
18
+ //
19
+ // ═══════════════════════════════════════════════════════════════════════
20
+ // 那三个 id 从哪来(这是最容易卡住的地方)
21
+ // ═══════════════════════════════════════════════════════════════════════
22
+ //
23
+ // conversation_id ← topic.chat_conversation_id
24
+ // message.topic_id ← topic.chat_topic_id
25
+ // (⚠ 不是 project.current_topic_id —— 那个传过去回 3016「话题未找到」)
26
+ //
27
+ // 话题可以直接建:POST /api/v1/super-agent/topics { topic_name, project_id }
28
+ // 返回体里就带着 chat_topic_id / chat_conversation_id,拿来即用。
29
+ //
30
+ // ═══════════════════════════════════════════════════════════════════════
31
+ // 模型
32
+ // ═══════════════════════════════════════════════════════════════════════
33
+ //
34
+ // chat 帧里必须写死一个模型。查 /api/v1/products/model-points 拿每个模型的
35
+ // input_points / output_points,按两者之和升序取最小的 —— 目前是 qwen3.5-flash
36
+ // (20 / 200),也正是网页的默认模型。没有免费档。
37
+
38
+ import { fetchModels, fetchModelPoints, SITES } from "./api.mjs"
39
+
40
+ /** 建话题用的名字池 —— 随机挑一个,别每次都是「测试」那种一眼假的 */
41
+ const TOPIC_NAMES = [
42
+ "你好",
43
+ "介绍一下你自己",
44
+ "今天适合做什么",
45
+ "帮我理一下思路",
46
+ "随便聊聊",
47
+ "写一句鼓励的话",
48
+ "解释一下什么是 AI",
49
+ "推荐一本值得读的书",
50
+ ]
51
+
52
+ const PICK_TEXT = "你好,介绍一下你自己"
53
+
54
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
55
+
56
+ function pickName() {
57
+ return TOPIC_NAMES[Math.floor(Math.random() * TOPIC_NAMES.length)]
58
+ }
59
+
60
+ /** 极简 CDP 无关:这是 WebSocket 客户端,只收发文本帧 */
61
+ async function openSocket(host, origin) {
62
+ const url = `wss://${host}/socket.io/?EIO=3&transport=websocket&timestamp=${Date.now()}`
63
+ const ws = new WebSocket(url, { headers: { Origin: origin } })
64
+ const queue = []
65
+ let notify = null
66
+
67
+ ws.addEventListener("message", (ev) => {
68
+ const d = String(ev.data || "")
69
+ if (d === "2") { try { ws.send("3") } catch {} return } // Engine.IO ping → pong
70
+ queue.push(d)
71
+ if (notify) { const n = notify; notify = null; n() }
72
+ })
73
+
74
+ await new Promise((res, rej) => {
75
+ const t = setTimeout(() => rej(new Error("WebSocket 连接超时")), 15_000)
76
+ ws.addEventListener("open", () => { clearTimeout(t); res() }, { once: true })
77
+ ws.addEventListener("error", () => { clearTimeout(t); rej(new Error("WebSocket 连接失败")) }, { once: true })
78
+ })
79
+
80
+ return {
81
+ ws,
82
+ /** 等下一条满足条件的帧;超时返回 null */
83
+ async next(pred, timeoutMs) {
84
+ const deadline = Date.now() + timeoutMs
85
+ while (Date.now() < deadline) {
86
+ for (let i = 0; i < queue.length; i++) {
87
+ if (pred(queue[i])) return queue.splice(i, 1)[0]
88
+ }
89
+ await new Promise((res) => {
90
+ notify = res
91
+ setTimeout(() => { if (notify === res) { notify = null; res() } }, 500)
92
+ })
93
+ }
94
+ return null
95
+ },
96
+ /** 把队列里所有帧读掉(用于等 AI 回复) */
97
+ drain() {
98
+ const all = queue.slice()
99
+ queue.length = 0
100
+ return all
101
+ },
102
+ close() { try { ws.close() } catch {} },
103
+ }
104
+ }
105
+
106
+ /** 按投入+产出积分之和挑最便宜的模型 */
107
+ export async function pickCheapestModel(jwt, org, host = SITES.cn.host) {
108
+ const models = await fetchModels(jwt, org, host).catch(() => [])
109
+ if (!models.length) return null
110
+ const byId = new Map(models.map((m) => [m.id, m]))
111
+ let points = {}
112
+ try {
113
+ points = await fetchModelPoints(jwt, org, models.map((m) => m.id), host)
114
+ } catch {}
115
+
116
+ const scored = models
117
+ .map((m) => {
118
+ const p = points[m.id] || {}
119
+ const cost = Number(p.inputPoints ?? 0) + Number(p.outputPoints ?? 0)
120
+ return { id: m.id, name: m.name, cost, known: p.inputPoints != null }
121
+ })
122
+ // 有计费信息的优先;都没有就按名字兜底
123
+ .sort((a, b) => (b.known - a.known) || (a.cost - b.cost))
124
+
125
+ const best = scored[0]
126
+ if (!best) return null
127
+ const icon = models.find((m) => m.id === best.id)?.icon || ""
128
+ return { model_id: best.id, model_name: byId.get(best.id)?.name || best.id, model_icon: icon || "" }
129
+ }
130
+
131
+ /** 找一个能挂话题的项目;没有就返回空串 */
132
+ export async function findProjectId(jwt, org, host, workspaceId) {
133
+ const res = await fetch(`${host}/api/v1/super-agent/projects/participated`, {
134
+ method: "POST",
135
+ headers: {
136
+ authorization: `Bearer ${jwt}`,
137
+ "content-type": "application/json",
138
+ accept: "application/json",
139
+ origin: host.includes("magicrew") ? "https://www.magicrew.ai" : "https://www.letsmagic.cn",
140
+ "organization-code": org,
141
+ },
142
+ body: JSON.stringify({ workspace_id: workspaceId, page: 1, page_size: 20, show_collaboration: 1, include_special: 0 }),
143
+ })
144
+ const body = await res.json().catch(() => ({}))
145
+ const list = body?.data?.list ?? body?.data?.items ?? []
146
+ return String(list[0]?.id ?? "")
147
+ }
148
+
149
+ /** 建一个项目 —— 新注册的号只有工作区、没有项目,不建就发不了消息 */
150
+ export async function createProject(jwt, org, host, workspaceId, name = pickName()) {
151
+ const res = await fetch(`${host}/api/v1/super-agent/projects`, {
152
+ method: "POST",
153
+ headers: {
154
+ authorization: `Bearer ${jwt}`,
155
+ "content-type": "application/json",
156
+ accept: "application/json",
157
+ origin: host.includes("magicrew") ? "https://www.magicrew.ai" : "https://www.letsmagic.cn",
158
+ "organization-code": org,
159
+ },
160
+ body: JSON.stringify({ workspace_id: workspaceId, project_name: name }),
161
+ })
162
+ const body = await res.json().catch(() => ({}))
163
+ const d = body?.data?.project ?? body?.data ?? {}
164
+ if (body?.code !== 1000 || !d.id) {
165
+ throw new Error(`建项目失败:${body?.message ?? "无返回"}(code ${body?.code})`)
166
+ }
167
+ return String(d.id)
168
+ }
169
+
170
+ /** 建一个话题,返回 chat_topic_id / chat_conversation_id */
171
+ export async function createTopic(jwt, org, host, projectId, name = pickName()) {
172
+ const res = await fetch(`${host}/api/v1/super-agent/topics`, {
173
+ method: "POST",
174
+ headers: {
175
+ authorization: `Bearer ${jwt}`,
176
+ "content-type": "application/json",
177
+ accept: "application/json",
178
+ origin: host.includes("magicrew") ? "https://www.magicrew.ai" : "https://www.letsmagic.cn",
179
+ "organization-code": org,
180
+ },
181
+ body: JSON.stringify({ topic_name: name, project_id: projectId }),
182
+ })
183
+ const body = await res.json().catch(() => ({}))
184
+ const d = body?.data ?? {}
185
+ if (body?.code !== 1000 || !d.chat_topic_id || !d.chat_conversation_id) {
186
+ throw new Error(`建话题失败:${body?.message ?? "无返回"}(code ${body?.code})`)
187
+ }
188
+ return {
189
+ name,
190
+ topicId: String(d.chat_topic_id),
191
+ conversationId: String(d.chat_conversation_id),
192
+ id: String(d.id ?? ""),
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 发一条消息并等 AI 回复完。
198
+ *
199
+ * @returns {{ok:boolean, messageId?:string, replies?:number, reason?:string}}
200
+ */
201
+ export async function sendChat(jwt, org, host, { topicId, conversationId, text = PICK_TEXT, model }) {
202
+ const origin = host.includes("magicrew") ? "https://www.magicrew.ai" : "https://www.letsmagic.cn"
203
+ const sock = await openSocket(host.replace(/^https?:\/\//, ""), origin)
204
+ const ns = String(Date.now())
205
+ const appMsgId = `${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 14)}`
206
+ const ctx = () => ({
207
+ timestamp: Date.now(),
208
+ authorization: jwt,
209
+ organization_code: org,
210
+ language: "zh_CN",
211
+ signature: "",
212
+ })
213
+
214
+ try {
215
+ // ① 登录
216
+ sock.ws.send(
217
+ `42/im,${ns}` +
218
+ JSON.stringify([
219
+ "login",
220
+ { context: ctx(), data: { message: { type: "text", text: { content: "登录" }, app_message_id: "WEB-" + appMsgId }, conversation_id: "" } },
221
+ ]),
222
+ )
223
+ const loginAck = await sock.next((d) => d.startsWith(`43/im,${ns}`), 20_000)
224
+ if (!loginAck) return { ok: false, reason: "登录无响应" }
225
+ const la = JSON.parse(loginAck.slice(`43/im,${ns}`.length))?.[0] || {}
226
+ if (la.code !== 1000) return { ok: false, reason: `登录失败:${la.message}(code ${la.code})` }
227
+ const senderId = String(la.data?.user?.user_id ?? "")
228
+
229
+ // ② 发消息
230
+ const richText = JSON.stringify({
231
+ type: "doc",
232
+ content: [
233
+ {
234
+ type: "paragraph",
235
+ attrs: { suggestion: "", wechatMediaState: null, wechatInstruction: false },
236
+ content: [{ type: "text", text }],
237
+ },
238
+ ],
239
+ })
240
+ const m = model || { model_id: "qwen3.5-flash", model_name: "Qwen3.5 Flash", model_icon: "" }
241
+ sock.ws.send(
242
+ `42/im,${ns}` +
243
+ JSON.stringify([
244
+ "chat",
245
+ {
246
+ context: ctx(),
247
+ data: {
248
+ message: {
249
+ type: "rich_text",
250
+ rich_text: {
251
+ content: richText,
252
+ instructs: [{ value: "normal" }],
253
+ extra: {
254
+ super_agent: {
255
+ mentions: [],
256
+ chat_mode: "normal",
257
+ topic_pattern: "general",
258
+ model: m,
259
+ enable_web_search: false, // 首任务不需要联网,关掉省时间也省钱
260
+ image_model: { model_id: "doubao-seedream-5-0-pro", model_name: "Seedream 5.0 Pro", model_icon: "" },
261
+ dynamic_params: { message_version: "v2" },
262
+ },
263
+ },
264
+ },
265
+ send_timestamp: Date.now(),
266
+ send_time: Date.now(),
267
+ sender_id: senderId,
268
+ app_message_id: appMsgId,
269
+ message_id: appMsgId,
270
+ topic_id: topicId,
271
+ },
272
+ conversation_id: conversationId,
273
+ },
274
+ },
275
+ ]),
276
+ )
277
+ const chatAck = await sock.next((d) => d.startsWith(`43/im,${ns}`), 25_000)
278
+ if (!chatAck) return { ok: false, reason: "发送无响应" }
279
+ const ca = JSON.parse(chatAck.slice(`43/im,${ns}`.length))?.[0] || {}
280
+ if (ca.code !== 1000) return { ok: false, reason: `发送失败:${ca.message}(code ${ca.code})` }
281
+
282
+ // ③ 等 AI 真的回一条(看到 super_magic_message 就算数),最多 90 秒
283
+ const deadline = Date.now() + 90_000
284
+ let replies = 0
285
+ while (Date.now() < deadline) {
286
+ const f = await sock.next((d) => d.includes(`42/im,["chat"`) || d.includes("super_magic_message") || d.includes("end_conversation_input"), 5_000)
287
+ if (f && f.includes("super_magic_message")) replies++
288
+ if (replies >= 1) break
289
+ }
290
+ return { ok: true, messageId: String(ca.data?.seq?.seq_id ?? ""), replies }
291
+ } finally {
292
+ sock.close()
293
+ }
294
+ }
295
+
296
+ /**
297
+ * 完整链路:找项目 → 建话题(随机名)→ 挑最便宜的模型 → 发一条消息。
298
+ * 不改任何账号状态,失败只返回原因。
299
+ */
300
+ export async function runChatTask({ jwt, org, host = SITES.cn.host, workspaceId, text, onProgress = () => {} }) {
301
+ onProgress("挑选模型…")
302
+ const model = await pickCheapestModel(jwt, org, host).catch(() => null)
303
+
304
+ onProgress("定位项目…")
305
+ let projectId = await findProjectId(jwt, org, host, workspaceId)
306
+ if (!projectId) {
307
+ // 新注册的号只有工作区、没有项目 —— 不建就发不了消息。
308
+ // 实测 POST /super-agent/projects {workspace_id, project_name} 就能建。
309
+ onProgress("新建一个项目…")
310
+ projectId = await createProject(jwt, org, host, workspaceId).catch(() => "")
311
+ if (!projectId) {
312
+ return { ok: false, reason: "建项目失败 —— 这个账号可能还没进过工作台,先在网页里打开一次" }
313
+ }
314
+ }
315
+
316
+ onProgress("建一个话题…")
317
+ const topic = await createTopic(jwt, org, host, projectId, pickName())
318
+
319
+ onProgress("发送消息…")
320
+ const r = await sendChat(jwt, org, host, {
321
+ topicId: topic.topicId,
322
+ conversationId: topic.conversationId,
323
+ text: text || PICK_TEXT,
324
+ model,
325
+ })
326
+
327
+ return {
328
+ ...r,
329
+ topicName: topic.name,
330
+ topicId: topic.topicId,
331
+ model: model?.model_name || "(默认)",
332
+ }
333
+ }
334
+
335
+ export { TOPIC_NAMES, PICK_TEXT }
package/src/clipboard.mjs CHANGED
@@ -1,108 +1,108 @@
1
- // 剪贴板 —— 浏览器登录的降级通道
2
- //
3
- // 场景:本机没有 Chrome / Edge,或者自己拉起来的那个没读出凭据。
4
- // 这时让用户在官方登录页的 Console 里粘一行,把 jwt + org 送进剪贴板,
5
- // 插件读回来 —— 不用他去 DevTools 里翻两个字段。
6
- //
7
- // ⚠ authorize() 里不能 await 读剪贴板(起 powershell 要几百毫秒,它必须立刻返回);
8
- // 把 promise 递到 callback 才落地。
9
-
10
- import { execFile as execFileCb } from "node:child_process"
11
- import { promisify } from "node:util"
12
-
13
- const execFile = promisify(execFileCb)
14
-
15
- // ═══ 浏览器登录:为什么只能「开页面 + 接剪贴板」═══════════════════════
16
- //
17
- // 想要的那种「点一下 → 跳官网 → 用 Google / 邮箱 / 手机登 → 插件自己拿到凭证」
18
- // magicrew **没有这条路**,不是没找到实现方法,是接口不存在:
19
- //
20
- // ① 它的登录类型只有 13 种,全是「拿凭据换 token」的:
21
- // phone_password / phone_captcha / email / DingTalk / DingTalkAvoid /
22
- // wecom / Lark / LarkRedirect / wechat_official_account / wechat_app /
23
- // dingtalk_app / redirect / apple_login / google_login / feishu_oauth
24
- // (dtyq/magic `frontend/magic-web/src/types/login.ts` 的 LoginType 枚举,
25
- // 注释写明「登录方式(与API强绑定,禁止修改)」)
26
- // —— 没有设备码、没有插件授权、没有一次性 ticket。
27
- //
28
- // ② WorkBuddy 那种做法在它这里是空的:它没有
29
- // POST /v2/plugin/auth/state (开登录会话)
30
- // GET /v2/plugin/auth/token (轮询拿 token)
31
- // 这套 `/plugin/auth/*` 协议。
32
- //
33
- // ③ 也试过 `/api/v1/auth/status`、`/api/v1/auth/environment`、
34
- // `/api/v1/aggregation/login-data` —— status 要 authorization(3103),
35
- // environment 回 `{"login_code":null}` 且没有配套的兑现接口。
36
- //
37
- // ④ 网页登录成功后 token 只在 www.magicrew.ai 的 localStorage 里,
38
- // 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址。
39
- //
40
- // ⇒ 所以能做的最短路径是:**用系统浏览器打开官方登录页(官方登录方式一样不少),
41
- // 登录完粘一行 Console 命令把两个值送进剪贴板,插件自己读回来。**
42
- // 用户不用在 magpie 里输手机号密码,也不用去 DevTools 里翻两个字段。
43
- //
44
- // 剪贴板读法(三平台):
45
- // win32 powershell Get-Clipboard -Raw
46
- // darwin pbpaste
47
- // linux xclip -selection clipboard -o
48
- const CLIP_HINT = [
49
- "浏览器已打开官方登录页 —— 用你平时的方式登进去(Google / 邮箱 / 手机号都行)。",
50
- "登录完成后回到那个页面按 F12 → Console,粘这一行回车:",
51
- `copy(JSON.stringify({jwt:(()=>{try{return JSON.parse(localStorage["MAGIC:user:authorization"]).value}catch{return localStorage["MAGIC:user:authorization"]}})(),org:(()=>{try{return JSON.parse(localStorage["MAGIC:user:organizationCode"]).value}catch{return localStorage["MAGIC:user:organizationCode"]}})()}))`,
52
- "插件会自己从剪贴板读,读到就完成 —— 不用回来点任何按钮。",
53
- ].join("\n")
54
-
55
- async function clipboardText() {
56
- try {
57
- const { stdout } = await execFile(CLIP_CMD[process.platform]?.[0] ?? "true", CLIP_CMD[process.platform]?.slice(1) ?? [], {
58
- timeout: 6000,
59
- windowsHide: true,
60
- maxBuffer: 1 << 20,
61
- })
62
- return String(stdout ?? "")
63
- } catch {
64
- return ""
65
- }
66
- }
67
-
68
- const CLIP_CMD = {
69
- win32: ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"],
70
- darwin: ["pbpaste"],
71
- linux: ["xclip", "-selection", "clipboard", "-o"],
72
- }
73
-
74
- /** 剪贴板里的内容 → { jwt, org },认不出返回 null */
75
- function parseCreds(txt) {
76
- const s = String(txt || "").trim()
77
- if (!s) return null
78
- // ① Console 那行给的 JSON
79
- try {
80
- const o = JSON.parse(s)
81
- if (typeof o?.jwt === "string" && o.jwt.startsWith("eyJ")) {
82
- return { jwt: o.jwt, org: String(o?.org || "").trim() }
83
- }
84
- } catch {}
85
- // ② 只粘了 JWT(组织码空着,后面会报错让人补)
86
- if (s.startsWith("eyJ") && s.split(".").length === 3) return { jwt: s, org: "" }
87
- return null
88
- }
89
-
90
- /**
91
- * 盯着剪贴板,直到出现能认的凭证。
92
- * @param skip 打开登录页之前剪贴板里就有的内容 —— 不能拿它当结果
93
- */
94
- async function pollClipboard({ skip = "", timeout = 300_000, interval = 1500 } = {}) {
95
- const deadline = Date.now() + timeout
96
- while (Date.now() < deadline) {
97
- const txt = (await clipboardText()).trim()
98
- if (txt && txt !== skip) {
99
- const got = parseCreds(txt)
100
- if (got) return got
101
- }
102
- await new Promise((r) => setTimeout(r, interval))
103
- }
104
- return null
105
- }
106
-
1
+ // 剪贴板 —— 浏览器登录的降级通道
2
+ //
3
+ // 场景:本机没有 Chrome / Edge,或者自己拉起来的那个没读出凭据。
4
+ // 这时让用户在官方登录页的 Console 里粘一行,把 jwt + org 送进剪贴板,
5
+ // 插件读回来 —— 不用他去 DevTools 里翻两个字段。
6
+ //
7
+ // ⚠ authorize() 里不能 await 读剪贴板(起 powershell 要几百毫秒,它必须立刻返回);
8
+ // 把 promise 递到 callback 才落地。
9
+
10
+ import { execFile as execFileCb } from "node:child_process"
11
+ import { promisify } from "node:util"
12
+
13
+ const execFile = promisify(execFileCb)
14
+
15
+ // ═══ 浏览器登录:为什么只能「开页面 + 接剪贴板」═══════════════════════
16
+ //
17
+ // 想要的那种「点一下 → 跳官网 → 用 Google / 邮箱 / 手机登 → 插件自己拿到凭证」
18
+ // magicrew **没有这条路**,不是没找到实现方法,是接口不存在:
19
+ //
20
+ // ① 它的登录类型只有 13 种,全是「拿凭据换 token」的:
21
+ // phone_password / phone_captcha / email / DingTalk / DingTalkAvoid /
22
+ // wecom / Lark / LarkRedirect / wechat_official_account / wechat_app /
23
+ // dingtalk_app / redirect / apple_login / google_login / feishu_oauth
24
+ // (dtyq/magic `frontend/magic-web/src/types/login.ts` 的 LoginType 枚举,
25
+ // 注释写明「登录方式(与API强绑定,禁止修改)」)
26
+ // —— 没有设备码、没有插件授权、没有一次性 ticket。
27
+ //
28
+ // ② WorkBuddy 那种做法在它这里是空的:它没有
29
+ // POST /v2/plugin/auth/state (开登录会话)
30
+ // GET /v2/plugin/auth/token (轮询拿 token)
31
+ // 这套 `/plugin/auth/*` 协议。
32
+ //
33
+ // ③ 也试过 `/api/v1/auth/status`、`/api/v1/auth/environment`、
34
+ // `/api/v1/aggregation/login-data` —— status 要 authorization(3103),
35
+ // environment 回 `{"login_code":null}` 且没有配套的兑现接口。
36
+ //
37
+ // ④ 网页登录成功后 token 只在 www.magicrew.ai 的 localStorage 里,
38
+ // 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址。
39
+ //
40
+ // ⇒ 所以能做的最短路径是:**用系统浏览器打开官方登录页(官方登录方式一样不少),
41
+ // 登录完粘一行 Console 命令把两个值送进剪贴板,插件自己读回来。**
42
+ // 用户不用在 magpie 里输手机号密码,也不用去 DevTools 里翻两个字段。
43
+ //
44
+ // 剪贴板读法(三平台):
45
+ // win32 powershell Get-Clipboard -Raw
46
+ // darwin pbpaste
47
+ // linux xclip -selection clipboard -o
48
+ const CLIP_HINT = [
49
+ "浏览器已打开官方登录页 —— 用你平时的方式登进去(Google / 邮箱 / 手机号都行)。",
50
+ "登录完成后回到那个页面按 F12 → Console,粘这一行回车:",
51
+ `copy(JSON.stringify({jwt:(()=>{try{return JSON.parse(localStorage["MAGIC:user:authorization"]).value}catch{return localStorage["MAGIC:user:authorization"]}})(),org:(()=>{try{return JSON.parse(localStorage["MAGIC:user:organizationCode"]).value}catch{return localStorage["MAGIC:user:organizationCode"]}})()}))`,
52
+ "插件会自己从剪贴板读,读到就完成 —— 不用回来点任何按钮。",
53
+ ].join("\n")
54
+
55
+ async function clipboardText() {
56
+ try {
57
+ const { stdout } = await execFile(CLIP_CMD[process.platform]?.[0] ?? "true", CLIP_CMD[process.platform]?.slice(1) ?? [], {
58
+ timeout: 6000,
59
+ windowsHide: true,
60
+ maxBuffer: 1 << 20,
61
+ })
62
+ return String(stdout ?? "")
63
+ } catch {
64
+ return ""
65
+ }
66
+ }
67
+
68
+ const CLIP_CMD = {
69
+ win32: ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"],
70
+ darwin: ["pbpaste"],
71
+ linux: ["xclip", "-selection", "clipboard", "-o"],
72
+ }
73
+
74
+ /** 剪贴板里的内容 → { jwt, org },认不出返回 null */
75
+ function parseCreds(txt) {
76
+ const s = String(txt || "").trim()
77
+ if (!s) return null
78
+ // ① Console 那行给的 JSON
79
+ try {
80
+ const o = JSON.parse(s)
81
+ if (typeof o?.jwt === "string" && o.jwt.startsWith("eyJ")) {
82
+ return { jwt: o.jwt, org: String(o?.org || "").trim() }
83
+ }
84
+ } catch {}
85
+ // ② 只粘了 JWT(组织码空着,后面会报错让人补)
86
+ if (s.startsWith("eyJ") && s.split(".").length === 3) return { jwt: s, org: "" }
87
+ return null
88
+ }
89
+
90
+ /**
91
+ * 盯着剪贴板,直到出现能认的凭证。
92
+ * @param skip 打开登录页之前剪贴板里就有的内容 —— 不能拿它当结果
93
+ */
94
+ async function pollClipboard({ skip = "", timeout = 300_000, interval = 1500 } = {}) {
95
+ const deadline = Date.now() + timeout
96
+ while (Date.now() < deadline) {
97
+ const txt = (await clipboardText()).trim()
98
+ if (txt && txt !== skip) {
99
+ const got = parseCreds(txt)
100
+ if (got) return got
101
+ }
102
+ await new Promise((r) => setTimeout(r, interval))
103
+ }
104
+ return null
105
+ }
106
+
107
107
 
108
108
  export { CLIP_HINT, clipboardText, parseCreds, pollClipboard }