qqmusic-api-enhanced 1.0.9 → 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/module/login_qr_check.d.ts +13 -6
- package/module/login_qr_check.js +49 -22
- package/module/login_qr_check.ts +50 -21
- package/module/login_refresh.js +4 -3
- package/module/login_refresh.ts +4 -3
- package/module/login_status.js +6 -3
- package/module/login_status.ts +6 -3
- package/package.json +1 -1
- package/util/cookie.js +14 -1
- package/util/cookie.ts +13 -1
- package/util/option.js +9 -2
- package/util/option.ts +9 -2
|
@@ -29,14 +29,21 @@ export declare function classifyLoginCode(code: number | undefined): {
|
|
|
29
29
|
reason: LoginFailureReason;
|
|
30
30
|
message: string;
|
|
31
31
|
};
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* 按域名隔离的 Cookie 罐,行为对齐浏览器。
|
|
34
|
+
*
|
|
35
|
+
* 登录网关会在同一次响应里给 `graph.qq.com` 下发 p_skey,再把 `qq.com` 的同名项清掉;
|
|
36
|
+
* 不分域名合并时,清除指令会连真票据一起删掉,后面的 OAuth 就没有 g_tk 可算。
|
|
37
|
+
*/
|
|
33
38
|
export declare class CookieJar {
|
|
34
|
-
private readonly
|
|
39
|
+
private readonly entries;
|
|
35
40
|
constructor(initial?: Record<string, string>);
|
|
36
|
-
apply(setCookies: string[]): void;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
apply(setCookies: string[], requestHost?: string): void;
|
|
42
|
+
/** 目标主机能看到的 Cookie;同名时取域最具体的一项。 */
|
|
43
|
+
private visible;
|
|
44
|
+
get(name: string, host?: string): string;
|
|
45
|
+
toObject(host?: string): Record<string, string>;
|
|
46
|
+
header(host?: string): string;
|
|
40
47
|
}
|
|
41
48
|
/** 解析 ptqrlogin 的 `ptuiCB('码','…','URL','…','提示','昵称')` 回调。 */
|
|
42
49
|
export declare function parsePtqrloginText(text: string): {
|
package/module/login_qr_check.js
CHANGED
|
@@ -7,6 +7,9 @@ const HTTP_TIMEOUT_MS = 12_000;
|
|
|
7
7
|
/** 微信 lp 长轮询最长约 25s;留一点余量,超时按“还在等待”处理。 */
|
|
8
8
|
const WX_LONG_POLL_TIMEOUT_MS = 30_000;
|
|
9
9
|
const MAX_REDIRECT_HOPS = 4;
|
|
10
|
+
const LOGIN_HOST = "ssl.ptlogin2.qq.com";
|
|
11
|
+
const OAUTH_HOST = "graph.qq.com";
|
|
12
|
+
const MUSIC_HOST = "u.y.qq.com";
|
|
10
13
|
/** 业务码 → 失败原因。码表来自各登录入口的公开实现与实测(无效授权码返回 104400)。 */
|
|
11
14
|
export function classifyLoginCode(code) {
|
|
12
15
|
switch (code) {
|
|
@@ -37,41 +40,64 @@ const failure = (stage, message, extra = {}) => ({
|
|
|
37
40
|
detail: extra.detail,
|
|
38
41
|
},
|
|
39
42
|
});
|
|
40
|
-
/**
|
|
43
|
+
/** Cookie 域是否覆盖目标主机:空域是构造时给的初始值,对所有主机生效。 */
|
|
44
|
+
const domainCovers = (domain, host) => !domain || !host || host === domain || host.endsWith(`.${domain}`);
|
|
45
|
+
/**
|
|
46
|
+
* 按域名隔离的 Cookie 罐,行为对齐浏览器。
|
|
47
|
+
*
|
|
48
|
+
* 登录网关会在同一次响应里给 `graph.qq.com` 下发 p_skey,再把 `qq.com` 的同名项清掉;
|
|
49
|
+
* 不分域名合并时,清除指令会连真票据一起删掉,后面的 OAuth 就没有 g_tk 可算。
|
|
50
|
+
*/
|
|
41
51
|
export class CookieJar {
|
|
42
|
-
|
|
52
|
+
entries = new Map();
|
|
43
53
|
constructor(initial = {}) {
|
|
44
|
-
for (const [
|
|
45
|
-
this.
|
|
54
|
+
for (const [name, value] of Object.entries(initial)) {
|
|
55
|
+
this.entries.set(`${name}\0`, { name, value, domain: "" });
|
|
56
|
+
}
|
|
46
57
|
}
|
|
47
|
-
apply(setCookies) {
|
|
58
|
+
apply(setCookies, requestHost = "") {
|
|
48
59
|
for (const line of setCookies) {
|
|
49
60
|
const segments = line.split(";").map((part) => part.trim());
|
|
50
61
|
const pair = segments[0] ?? "";
|
|
51
62
|
const idx = pair.indexOf("=");
|
|
52
63
|
if (idx <= 0)
|
|
53
64
|
continue;
|
|
54
|
-
const
|
|
65
|
+
const name = pair.slice(0, idx).trim();
|
|
55
66
|
const value = pair.slice(idx + 1).trim();
|
|
56
67
|
const attrs = segments.slice(1).map((part) => part.toLowerCase());
|
|
57
68
|
const maxAge = attrs.find((part) => part.startsWith("max-age="));
|
|
58
69
|
const expires = attrs.find((part) => part.startsWith("expires="));
|
|
70
|
+
const declared = attrs.find((part) => part.startsWith("domain="));
|
|
71
|
+
const domain = (declared?.slice(7) ?? requestHost).replace(/^\./, "");
|
|
72
|
+
const key = `${name}\0${domain}`;
|
|
59
73
|
const expired = (maxAge !== undefined && Number(maxAge.slice(8)) <= 0) ||
|
|
60
74
|
(expires !== undefined && Date.parse(expires.slice(8)) < Date.now());
|
|
61
75
|
if (!value || expired)
|
|
62
|
-
this.
|
|
76
|
+
this.entries.delete(key);
|
|
63
77
|
else
|
|
64
|
-
this.
|
|
78
|
+
this.entries.set(key, { name, value, domain });
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
/** 目标主机能看到的 Cookie;同名时取域最具体的一项。 */
|
|
82
|
+
visible(host) {
|
|
83
|
+
const out = new Map();
|
|
84
|
+
for (const entry of this.entries.values()) {
|
|
85
|
+
if (!domainCovers(entry.domain, host))
|
|
86
|
+
continue;
|
|
87
|
+
const current = out.get(entry.name);
|
|
88
|
+
if (!current || entry.domain.length >= current.domain.length)
|
|
89
|
+
out.set(entry.name, entry);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
get(name, host = "") {
|
|
94
|
+
return this.visible(host).get(name)?.value ?? "";
|
|
69
95
|
}
|
|
70
|
-
toObject() {
|
|
71
|
-
return Object.fromEntries(this.
|
|
96
|
+
toObject(host = "") {
|
|
97
|
+
return Object.fromEntries([...this.visible(host)].map(([name, entry]) => [name, entry.value]));
|
|
72
98
|
}
|
|
73
|
-
header() {
|
|
74
|
-
return cookieToString(this.toObject());
|
|
99
|
+
header(host = "") {
|
|
100
|
+
return cookieToString(this.toObject(host));
|
|
75
101
|
}
|
|
76
102
|
}
|
|
77
103
|
const withTimeout = (ms) => AbortSignal.timeout(ms);
|
|
@@ -174,12 +200,12 @@ async function checkQq(query, fetchImpl, cgi) {
|
|
|
174
200
|
const checkRes = await fetchImpl(checkUrl, {
|
|
175
201
|
headers: {
|
|
176
202
|
Referer: "https://xui.ptlogin2.qq.com/",
|
|
177
|
-
Cookie: jar.header(),
|
|
203
|
+
Cookie: jar.header(LOGIN_HOST),
|
|
178
204
|
"User-Agent": BROWSER_UA,
|
|
179
205
|
},
|
|
180
206
|
signal: withTimeout(HTTP_TIMEOUT_MS),
|
|
181
207
|
});
|
|
182
|
-
jar.apply(checkRes.headers.getSetCookie?.() || []);
|
|
208
|
+
jar.apply(checkRes.headers.getSetCookie?.() || [], LOGIN_HOST);
|
|
183
209
|
text = await checkRes.text();
|
|
184
210
|
if (checkRes.status >= 400) {
|
|
185
211
|
return failure("ptqrlogin", `QQ 登录网关返回 HTTP ${checkRes.status}`, {
|
|
@@ -216,12 +242,13 @@ async function checkQq(query, fetchImpl, cgi) {
|
|
|
216
242
|
let hops = 0;
|
|
217
243
|
try {
|
|
218
244
|
while (hopUrl && hops < MAX_REDIRECT_HOPS) {
|
|
245
|
+
const hopHost = new URL(hopUrl).hostname;
|
|
219
246
|
const sigRes = await fetchImpl(hopUrl, {
|
|
220
247
|
redirect: "manual",
|
|
221
|
-
headers: { Cookie: jar.header(), "User-Agent": BROWSER_UA },
|
|
248
|
+
headers: { Cookie: jar.header(hopHost), "User-Agent": BROWSER_UA },
|
|
222
249
|
signal: withTimeout(HTTP_TIMEOUT_MS),
|
|
223
250
|
});
|
|
224
|
-
jar.apply(sigRes.headers.getSetCookie?.() || []);
|
|
251
|
+
jar.apply(sigRes.headers.getSetCookie?.() || [], hopHost);
|
|
225
252
|
const next = sigRes.headers.get("Location");
|
|
226
253
|
hopUrl =
|
|
227
254
|
next && sigRes.status >= 300 && sigRes.status < 400 ? new URL(next, hopUrl).href : undefined;
|
|
@@ -231,7 +258,7 @@ async function checkQq(query, fetchImpl, cgi) {
|
|
|
231
258
|
catch (error) {
|
|
232
259
|
return failure("check_sig", `换取 QQ 登录票据失败: ${String(error)}`);
|
|
233
260
|
}
|
|
234
|
-
const pSkey = jar.get("p_skey");
|
|
261
|
+
const pSkey = jar.get("p_skey", OAUTH_HOST);
|
|
235
262
|
if (!pSkey) {
|
|
236
263
|
return failure("check_sig", "QQ 登录票据缺少 p_skey,登录态未建立", {
|
|
237
264
|
detail: { cookieNames: Object.keys(jar.toObject()).sort().join(",") },
|
|
@@ -261,7 +288,7 @@ async function checkQq(query, fetchImpl, cgi) {
|
|
|
261
288
|
method: "POST",
|
|
262
289
|
headers: {
|
|
263
290
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
264
|
-
Cookie: jar.header(),
|
|
291
|
+
Cookie: jar.header(OAUTH_HOST),
|
|
265
292
|
"User-Agent": BROWSER_UA,
|
|
266
293
|
// 授权接口按浏览器表单提交校验来源:带上与官方页面一致的 Referer / Origin。
|
|
267
294
|
Referer: "https://graph.qq.com/oauth2.0/show",
|
|
@@ -287,8 +314,8 @@ async function checkQq(query, fetchImpl, cgi) {
|
|
|
287
314
|
}
|
|
288
315
|
// 3. 将 OAuth code 换取 QQ 音乐正式凭据(只保留音乐侧凭证,不带 ptlogin 的一堆 Cookie)。
|
|
289
316
|
const loginCgiRes = await cgi("QQConnectLogin.LoginServer", "QQLogin", { code }, {
|
|
290
|
-
cookie: jar.toObject(),
|
|
291
|
-
headers: { Cookie: jar.header() },
|
|
317
|
+
cookie: jar.toObject(MUSIC_HOST),
|
|
318
|
+
headers: { Cookie: jar.header(MUSIC_HOST) },
|
|
292
319
|
});
|
|
293
320
|
return finishCgiLogin(loginCgiRes, guid, "qqlogin", "登录成功");
|
|
294
321
|
}
|
package/module/login_qr_check.ts
CHANGED
|
@@ -48,6 +48,9 @@ const HTTP_TIMEOUT_MS = 12_000;
|
|
|
48
48
|
/** 微信 lp 长轮询最长约 25s;留一点余量,超时按“还在等待”处理。 */
|
|
49
49
|
const WX_LONG_POLL_TIMEOUT_MS = 30_000;
|
|
50
50
|
const MAX_REDIRECT_HOPS = 4;
|
|
51
|
+
const LOGIN_HOST = "ssl.ptlogin2.qq.com";
|
|
52
|
+
const OAUTH_HOST = "graph.qq.com";
|
|
53
|
+
const MUSIC_HOST = "u.y.qq.com";
|
|
51
54
|
|
|
52
55
|
/** 业务码 → 失败原因。码表来自各登录入口的公开实现与实测(无效授权码返回 104400)。 */
|
|
53
56
|
export function classifyLoginCode(code: number | undefined): {
|
|
@@ -88,43 +91,68 @@ const failure = (
|
|
|
88
91
|
},
|
|
89
92
|
});
|
|
90
93
|
|
|
91
|
-
/**
|
|
94
|
+
/** Cookie 域是否覆盖目标主机:空域是构造时给的初始值,对所有主机生效。 */
|
|
95
|
+
const domainCovers = (domain: string, host: string): boolean =>
|
|
96
|
+
!domain || !host || host === domain || host.endsWith(`.${domain}`);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 按域名隔离的 Cookie 罐,行为对齐浏览器。
|
|
100
|
+
*
|
|
101
|
+
* 登录网关会在同一次响应里给 `graph.qq.com` 下发 p_skey,再把 `qq.com` 的同名项清掉;
|
|
102
|
+
* 不分域名合并时,清除指令会连真票据一起删掉,后面的 OAuth 就没有 g_tk 可算。
|
|
103
|
+
*/
|
|
92
104
|
export class CookieJar {
|
|
93
|
-
private readonly
|
|
105
|
+
private readonly entries = new Map<string, { name: string; value: string; domain: string }>();
|
|
94
106
|
|
|
95
107
|
constructor(initial: Record<string, string> = {}) {
|
|
96
|
-
for (const [
|
|
108
|
+
for (const [name, value] of Object.entries(initial)) {
|
|
109
|
+
this.entries.set(`${name}\0`, { name, value, domain: "" });
|
|
110
|
+
}
|
|
97
111
|
}
|
|
98
112
|
|
|
99
|
-
apply(setCookies: string[]): void {
|
|
113
|
+
apply(setCookies: string[], requestHost = ""): void {
|
|
100
114
|
for (const line of setCookies) {
|
|
101
115
|
const segments = line.split(";").map((part) => part.trim());
|
|
102
116
|
const pair = segments[0] ?? "";
|
|
103
117
|
const idx = pair.indexOf("=");
|
|
104
118
|
if (idx <= 0) continue;
|
|
105
|
-
const
|
|
119
|
+
const name = pair.slice(0, idx).trim();
|
|
106
120
|
const value = pair.slice(idx + 1).trim();
|
|
107
121
|
const attrs = segments.slice(1).map((part) => part.toLowerCase());
|
|
108
122
|
const maxAge = attrs.find((part) => part.startsWith("max-age="));
|
|
109
123
|
const expires = attrs.find((part) => part.startsWith("expires="));
|
|
124
|
+
const declared = attrs.find((part) => part.startsWith("domain="));
|
|
125
|
+
const domain = (declared?.slice(7) ?? requestHost).replace(/^\./, "");
|
|
126
|
+
const key = `${name}\0${domain}`;
|
|
110
127
|
const expired =
|
|
111
128
|
(maxAge !== undefined && Number(maxAge.slice(8)) <= 0) ||
|
|
112
129
|
(expires !== undefined && Date.parse(expires.slice(8)) < Date.now());
|
|
113
|
-
if (!value || expired) this.
|
|
114
|
-
else this.
|
|
130
|
+
if (!value || expired) this.entries.delete(key);
|
|
131
|
+
else this.entries.set(key, { name, value, domain });
|
|
115
132
|
}
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
/** 目标主机能看到的 Cookie;同名时取域最具体的一项。 */
|
|
136
|
+
private visible(host: string): Map<string, { value: string; domain: string }> {
|
|
137
|
+
const out = new Map<string, { value: string; domain: string }>();
|
|
138
|
+
for (const entry of this.entries.values()) {
|
|
139
|
+
if (!domainCovers(entry.domain, host)) continue;
|
|
140
|
+
const current = out.get(entry.name);
|
|
141
|
+
if (!current || entry.domain.length >= current.domain.length) out.set(entry.name, entry);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get(name: string, host = ""): string {
|
|
147
|
+
return this.visible(host).get(name)?.value ?? "";
|
|
120
148
|
}
|
|
121
149
|
|
|
122
|
-
toObject(): Record<string, string> {
|
|
123
|
-
return Object.fromEntries(this.
|
|
150
|
+
toObject(host = ""): Record<string, string> {
|
|
151
|
+
return Object.fromEntries([...this.visible(host)].map(([name, entry]) => [name, entry.value]));
|
|
124
152
|
}
|
|
125
153
|
|
|
126
|
-
header(): string {
|
|
127
|
-
return cookieToString(this.toObject());
|
|
154
|
+
header(host = ""): string {
|
|
155
|
+
return cookieToString(this.toObject(host));
|
|
128
156
|
}
|
|
129
157
|
}
|
|
130
158
|
|
|
@@ -235,12 +263,12 @@ async function checkQq(
|
|
|
235
263
|
const checkRes = await fetchImpl(checkUrl, {
|
|
236
264
|
headers: {
|
|
237
265
|
Referer: "https://xui.ptlogin2.qq.com/",
|
|
238
|
-
Cookie: jar.header(),
|
|
266
|
+
Cookie: jar.header(LOGIN_HOST),
|
|
239
267
|
"User-Agent": BROWSER_UA,
|
|
240
268
|
},
|
|
241
269
|
signal: withTimeout(HTTP_TIMEOUT_MS),
|
|
242
270
|
});
|
|
243
|
-
jar.apply(checkRes.headers.getSetCookie?.() || []);
|
|
271
|
+
jar.apply(checkRes.headers.getSetCookie?.() || [], LOGIN_HOST);
|
|
244
272
|
text = await checkRes.text();
|
|
245
273
|
if (checkRes.status >= 400) {
|
|
246
274
|
return failure("ptqrlogin", `QQ 登录网关返回 HTTP ${checkRes.status}`, {
|
|
@@ -278,12 +306,13 @@ async function checkQq(
|
|
|
278
306
|
let hops = 0;
|
|
279
307
|
try {
|
|
280
308
|
while (hopUrl && hops < MAX_REDIRECT_HOPS) {
|
|
309
|
+
const hopHost: string = new URL(hopUrl).hostname;
|
|
281
310
|
const sigRes: Response = await fetchImpl(hopUrl, {
|
|
282
311
|
redirect: "manual",
|
|
283
|
-
headers: { Cookie: jar.header(), "User-Agent": BROWSER_UA },
|
|
312
|
+
headers: { Cookie: jar.header(hopHost), "User-Agent": BROWSER_UA },
|
|
284
313
|
signal: withTimeout(HTTP_TIMEOUT_MS),
|
|
285
314
|
});
|
|
286
|
-
jar.apply(sigRes.headers.getSetCookie?.() || []);
|
|
315
|
+
jar.apply(sigRes.headers.getSetCookie?.() || [], hopHost);
|
|
287
316
|
const next: string | null = sigRes.headers.get("Location");
|
|
288
317
|
hopUrl =
|
|
289
318
|
next && sigRes.status >= 300 && sigRes.status < 400 ? new URL(next, hopUrl).href : undefined;
|
|
@@ -292,7 +321,7 @@ async function checkQq(
|
|
|
292
321
|
} catch (error) {
|
|
293
322
|
return failure("check_sig", `换取 QQ 登录票据失败: ${String(error)}`);
|
|
294
323
|
}
|
|
295
|
-
const pSkey = jar.get("p_skey");
|
|
324
|
+
const pSkey = jar.get("p_skey", OAUTH_HOST);
|
|
296
325
|
if (!pSkey) {
|
|
297
326
|
return failure("check_sig", "QQ 登录票据缺少 p_skey,登录态未建立", {
|
|
298
327
|
detail: { cookieNames: Object.keys(jar.toObject()).sort().join(",") },
|
|
@@ -324,7 +353,7 @@ async function checkQq(
|
|
|
324
353
|
method: "POST",
|
|
325
354
|
headers: {
|
|
326
355
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
327
|
-
Cookie: jar.header(),
|
|
356
|
+
Cookie: jar.header(OAUTH_HOST),
|
|
328
357
|
"User-Agent": BROWSER_UA,
|
|
329
358
|
// 授权接口按浏览器表单提交校验来源:带上与官方页面一致的 Referer / Origin。
|
|
330
359
|
Referer: "https://graph.qq.com/oauth2.0/show",
|
|
@@ -349,8 +378,8 @@ async function checkQq(
|
|
|
349
378
|
|
|
350
379
|
// 3. 将 OAuth code 换取 QQ 音乐正式凭据(只保留音乐侧凭证,不带 ptlogin 的一堆 Cookie)。
|
|
351
380
|
const loginCgiRes = await cgi("QQConnectLogin.LoginServer", "QQLogin", { code }, {
|
|
352
|
-
cookie: jar.toObject(),
|
|
353
|
-
headers: { Cookie: jar.header() },
|
|
381
|
+
cookie: jar.toObject(MUSIC_HOST),
|
|
382
|
+
headers: { Cookie: jar.header(MUSIC_HOST) },
|
|
354
383
|
});
|
|
355
384
|
return finishCgiLogin(loginCgiRes, guid, "qqlogin", "登录成功");
|
|
356
385
|
}
|
package/module/login_refresh.js
CHANGED
|
@@ -59,8 +59,8 @@ export default async function login_refresh(query = {}, req = request) {
|
|
|
59
59
|
return true;
|
|
60
60
|
};
|
|
61
61
|
// 1. QQ 互联网页扫码:用旧 musickey 直接换新 key。签名与非签名各试一次,
|
|
62
|
-
// 不同部署/线路对 musics.fcg
|
|
63
|
-
for (const sign of [true, false]) {
|
|
62
|
+
// 不同部署/线路对 musics.fcg 的签名要求不一致。微信账号不走这条线。
|
|
63
|
+
for (const sign of loginType === "1" ? [] : [true, false]) {
|
|
64
64
|
try {
|
|
65
65
|
const refreshed = await tryRefresh("QQConnectLogin.LoginServer", "QQLogin", { expired_in: 7_776_000, musicid: uin, musickey }, { platform: "android", sign });
|
|
66
66
|
if (refreshed) {
|
|
@@ -88,7 +88,8 @@ export default async function login_refresh(query = {}, req = request) {
|
|
|
88
88
|
refresh_token: query.refresh_token || cookieObj.refresh_token || "",
|
|
89
89
|
refresh_key: query.refresh_key || cookieObj.refresh_key || "",
|
|
90
90
|
access_token: cookieObj.access_token || "",
|
|
91
|
-
|
|
91
|
+
// 微信账号号超过安全整数:number 字段会被四舍五入,只传字符串字段。
|
|
92
|
+
musicid: Number.isSafeInteger(Number(uin)) ? Number(uin) : 0,
|
|
92
93
|
str_musicid: uin,
|
|
93
94
|
musickey,
|
|
94
95
|
loginMode: 2,
|
package/module/login_refresh.ts
CHANGED
|
@@ -93,8 +93,8 @@ export default async function login_refresh(
|
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
// 1. QQ 互联网页扫码:用旧 musickey 直接换新 key。签名与非签名各试一次,
|
|
96
|
-
// 不同部署/线路对 musics.fcg
|
|
97
|
-
for (const sign of [true, false]) {
|
|
96
|
+
// 不同部署/线路对 musics.fcg 的签名要求不一致。微信账号不走这条线。
|
|
97
|
+
for (const sign of loginType === "1" ? [] : [true, false]) {
|
|
98
98
|
try {
|
|
99
99
|
const refreshed = await tryRefresh(
|
|
100
100
|
"QQConnectLogin.LoginServer",
|
|
@@ -130,7 +130,8 @@ export default async function login_refresh(
|
|
|
130
130
|
refresh_token: query.refresh_token || cookieObj.refresh_token || "",
|
|
131
131
|
refresh_key: query.refresh_key || cookieObj.refresh_key || "",
|
|
132
132
|
access_token: cookieObj.access_token || "",
|
|
133
|
-
|
|
133
|
+
// 微信账号号超过安全整数:number 字段会被四舍五入,只传字符串字段。
|
|
134
|
+
musicid: Number.isSafeInteger(Number(uin)) ? Number(uin) : 0,
|
|
134
135
|
str_musicid: uin,
|
|
135
136
|
musickey,
|
|
136
137
|
loginMode: 2,
|
package/module/login_status.js
CHANGED
|
@@ -24,7 +24,10 @@ export default async function login_status(query = {}, req = request) {
|
|
|
24
24
|
const mergedCookies = { ...cookieObj, ...parseSetCookie(cgiRes.cookie || []) };
|
|
25
25
|
const merged = mergeLoginPayloadCookies(mergedCookies, cgiRes.body);
|
|
26
26
|
const userData = data.user || data.userInfo || data;
|
|
27
|
-
|
|
27
|
+
// 现行响应把资料放在 `info`、顶层没有 uin;凭证失效时是不带资料的 `{ code: 1000 }`。
|
|
28
|
+
// 账号号已由登录写进 Cookie,这里只需要确认上游认可这份凭证并给出了资料。
|
|
29
|
+
const profile = (data.info || {});
|
|
30
|
+
const isLogin = Boolean(data.code === 0 && (userData.uin || userData.encryptUin || profile.nick || profile.logo)) ||
|
|
28
31
|
Boolean(data.code === undefined && userData.uin);
|
|
29
32
|
if (userData.uin) {
|
|
30
33
|
uin = String(userData.uin).trim();
|
|
@@ -32,8 +35,8 @@ export default async function login_status(query = {}, req = request) {
|
|
|
32
35
|
else if (!uin && userData.encryptUin) {
|
|
33
36
|
uin = String(userData.encryptUin).trim();
|
|
34
37
|
}
|
|
35
|
-
let nickname = userData.nick || userData.nickname || "";
|
|
36
|
-
let avatarUrl = userData.headurl || userData.avatar;
|
|
38
|
+
let nickname = userData.nick || userData.nickname || profile.nick || "";
|
|
39
|
+
let avatarUrl = userData.headurl || userData.avatar || profile.logo || undefined;
|
|
37
40
|
// 并行获取用户主页资料与 VIP 信息(避免串行多次公网 RPC 导致进房等待过久)
|
|
38
41
|
const needDetail = Boolean(uin && (!nickname || !avatarUrl));
|
|
39
42
|
const needVip = Boolean(isLogin);
|
package/module/login_status.ts
CHANGED
|
@@ -65,8 +65,11 @@ export default async function login_status(
|
|
|
65
65
|
const mergedCookies = { ...cookieObj, ...parseSetCookie(cgiRes.cookie || []) };
|
|
66
66
|
const merged = mergeLoginPayloadCookies(mergedCookies, cgiRes.body);
|
|
67
67
|
const userData = data.user || data.userInfo || data;
|
|
68
|
+
// 现行响应把资料放在 `info`、顶层没有 uin;凭证失效时是不带资料的 `{ code: 1000 }`。
|
|
69
|
+
// 账号号已由登录写进 Cookie,这里只需要确认上游认可这份凭证并给出了资料。
|
|
70
|
+
const profile = (data.info || {}) as any;
|
|
68
71
|
const isLogin =
|
|
69
|
-
Boolean(data.code === 0 && (userData.uin || userData.encryptUin)) ||
|
|
72
|
+
Boolean(data.code === 0 && (userData.uin || userData.encryptUin || profile.nick || profile.logo)) ||
|
|
70
73
|
Boolean(data.code === undefined && userData.uin);
|
|
71
74
|
|
|
72
75
|
if (userData.uin) {
|
|
@@ -75,8 +78,8 @@ export default async function login_status(
|
|
|
75
78
|
uin = String(userData.encryptUin).trim();
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
let nickname = userData.nick || userData.nickname || "";
|
|
79
|
-
let avatarUrl: string | undefined = userData.headurl || userData.avatar;
|
|
81
|
+
let nickname = userData.nick || userData.nickname || profile.nick || "";
|
|
82
|
+
let avatarUrl: string | undefined = userData.headurl || userData.avatar || profile.logo || undefined;
|
|
80
83
|
|
|
81
84
|
// 并行获取用户主页资料与 VIP 信息(避免串行多次公网 RPC 导致进房等待过久)
|
|
82
85
|
const needDetail = Boolean(uin && (!nickname || !avatarUrl));
|
package/package.json
CHANGED
package/util/cookie.js
CHANGED
|
@@ -49,6 +49,19 @@ const readText = (value) => {
|
|
|
49
49
|
return String(value);
|
|
50
50
|
return "";
|
|
51
51
|
};
|
|
52
|
+
/**
|
|
53
|
+
* 读账号号:微信账号是 19 位数字,超过 2^53,JSON 里的 number 已经丢了精度,
|
|
54
|
+
* 只有字符串字段才可信。写回被四舍五入的号,会让后续校验与续期都对不上账号。
|
|
55
|
+
*/
|
|
56
|
+
const readAccountId = (...values) => {
|
|
57
|
+
for (const value of values) {
|
|
58
|
+
if (typeof value === "string" && value.trim() && value.trim() !== "0")
|
|
59
|
+
return value.trim();
|
|
60
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0)
|
|
61
|
+
return String(value);
|
|
62
|
+
}
|
|
63
|
+
return "";
|
|
64
|
+
};
|
|
52
65
|
/**
|
|
53
66
|
* 把登录/续期响应里的凭证字段并入 Cookie。
|
|
54
67
|
*
|
|
@@ -65,7 +78,7 @@ export function mergeLoginPayloadCookies(cookieObj, payload) {
|
|
|
65
78
|
cookieObj.qqmusic_key = musickey;
|
|
66
79
|
cookieObj.musickey = musickey;
|
|
67
80
|
}
|
|
68
|
-
const musicid =
|
|
81
|
+
const musicid = readAccountId(source.str_musicid, source.musicid, source.uin);
|
|
69
82
|
if (musicid) {
|
|
70
83
|
cookieObj.uin = musicid;
|
|
71
84
|
cookieObj.qqmusic_uin = musicid;
|
package/util/cookie.ts
CHANGED
|
@@ -55,6 +55,18 @@ const readText = (value: unknown): string => {
|
|
|
55
55
|
return "";
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* 读账号号:微信账号是 19 位数字,超过 2^53,JSON 里的 number 已经丢了精度,
|
|
60
|
+
* 只有字符串字段才可信。写回被四舍五入的号,会让后续校验与续期都对不上账号。
|
|
61
|
+
*/
|
|
62
|
+
const readAccountId = (...values: unknown[]): string => {
|
|
63
|
+
for (const value of values) {
|
|
64
|
+
if (typeof value === "string" && value.trim() && value.trim() !== "0") return value.trim();
|
|
65
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return String(value);
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
};
|
|
69
|
+
|
|
58
70
|
export interface LoginPayloadMergeResult {
|
|
59
71
|
/** 本次响应是否给出了新的 musickey(决定续期成功与否)。 */
|
|
60
72
|
obtainedMusickey: boolean;
|
|
@@ -83,7 +95,7 @@ export function mergeLoginPayloadCookies(
|
|
|
83
95
|
cookieObj.musickey = musickey;
|
|
84
96
|
}
|
|
85
97
|
|
|
86
|
-
const musicid =
|
|
98
|
+
const musicid = readAccountId(source.str_musicid, source.musicid, source.uin);
|
|
87
99
|
if (musicid) {
|
|
88
100
|
cookieObj.uin = musicid;
|
|
89
101
|
cookieObj.qqmusic_uin = musicid;
|
package/util/option.js
CHANGED
|
@@ -55,6 +55,13 @@ export function createOption(query = {}, defaultPlatform = "web") {
|
|
|
55
55
|
comm: query.comm,
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
+
/** comm 里的 uin:超过安全整数的账号号(微信)只能按字符串传,转 number 会被四舍五入。 */
|
|
59
|
+
const commUin = (uin) => {
|
|
60
|
+
const numeric = Number(uin);
|
|
61
|
+
if (!numeric)
|
|
62
|
+
return 0;
|
|
63
|
+
return Number.isSafeInteger(numeric) ? numeric : uin;
|
|
64
|
+
};
|
|
58
65
|
/**
|
|
59
66
|
* 构建 CGI 请求所需的通用 comm 参数对象.
|
|
60
67
|
*/
|
|
@@ -78,7 +85,7 @@ export function buildComm(options) {
|
|
|
78
85
|
ct: 19,
|
|
79
86
|
cv: 2201,
|
|
80
87
|
guid: options.guid,
|
|
81
|
-
uin:
|
|
88
|
+
uin: commUin(options.uin),
|
|
82
89
|
};
|
|
83
90
|
}
|
|
84
91
|
else {
|
|
@@ -88,7 +95,7 @@ export function buildComm(options) {
|
|
|
88
95
|
cv: 4747474,
|
|
89
96
|
platform: "yqq.json",
|
|
90
97
|
chid: "0",
|
|
91
|
-
uin:
|
|
98
|
+
uin: commUin(options.uin),
|
|
92
99
|
g_tk: gTk,
|
|
93
100
|
g_tk_new_20200303: gTk,
|
|
94
101
|
format: "json",
|
package/util/option.ts
CHANGED
|
@@ -100,6 +100,13 @@ export function createOption(
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** comm 里的 uin:超过安全整数的账号号(微信)只能按字符串传,转 number 会被四舍五入。 */
|
|
104
|
+
const commUin = (uin: string): number | string => {
|
|
105
|
+
const numeric = Number(uin);
|
|
106
|
+
if (!numeric) return 0;
|
|
107
|
+
return Number.isSafeInteger(numeric) ? numeric : uin;
|
|
108
|
+
};
|
|
109
|
+
|
|
103
110
|
/**
|
|
104
111
|
* 构建 CGI 请求所需的通用 comm 参数对象.
|
|
105
112
|
*/
|
|
@@ -123,7 +130,7 @@ export function buildComm(options: NormalizedOptions): Record<string, any> {
|
|
|
123
130
|
ct: 19,
|
|
124
131
|
cv: 2201,
|
|
125
132
|
guid: options.guid,
|
|
126
|
-
uin:
|
|
133
|
+
uin: commUin(options.uin),
|
|
127
134
|
};
|
|
128
135
|
} else {
|
|
129
136
|
// Web 平台
|
|
@@ -132,7 +139,7 @@ export function buildComm(options: NormalizedOptions): Record<string, any> {
|
|
|
132
139
|
cv: 4747474,
|
|
133
140
|
platform: "yqq.json",
|
|
134
141
|
chid: "0",
|
|
135
|
-
uin:
|
|
142
|
+
uin: commUin(options.uin),
|
|
136
143
|
g_tk: gTk,
|
|
137
144
|
g_tk_new_20200303: gTk,
|
|
138
145
|
format: "json",
|