@taole/dd-playwright 0.0.0-stage → 0.1.1

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,279 @@
1
+ import { chromium } from "playwright";
2
+
3
+ export const DIANDIAN_HOME_URL = "https://y.tuwan.com";
4
+ export const DIANDIAN_USER_INFO_URL =
5
+ "https://yapi.tuwan.com/activityapi/getUserInfo?format=json";
6
+ export const DIANDIAN_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
7
+
8
+ export function cookiesToHeaderString(cookies) {
9
+ if (!Array.isArray(cookies)) return "";
10
+ return cookies
11
+ .filter((c) => c && typeof c.name === "string" && c.value != null)
12
+ .map((c) => `${c.name}=${c.value}`)
13
+ .join("; ");
14
+ }
15
+
16
+ export class DiandianAuthManager {
17
+ /**
18
+ * @param {object} options
19
+ * @param {import('../account-store.mjs').AccountStore} options.accountStore
20
+ * @param {any} [options.chromiumLauncher] 可选自定义 chromium 启动器 (测试用)
21
+ */
22
+ constructor({ accountStore, chromiumLauncher = chromium } = {}) {
23
+ this.accountStore = accountStore;
24
+ this.chromium = chromiumLauncher;
25
+
26
+ this.browser = null;
27
+ this.context = null;
28
+ this.page = null;
29
+ this.startupPromise = null;
30
+ this.timeoutTimer = null;
31
+ this.scanStartedAt = 0;
32
+ }
33
+
34
+ get isScanning() {
35
+ return Boolean(this.page && !this.page.isClosed());
36
+ }
37
+
38
+ async startLogin({ headless = true, forceNew = false } = {}) {
39
+ if (this.isScanning && !forceNew) {
40
+ return { status: "scanning", message: "扫码登录会话已在进行中" };
41
+ }
42
+ if (this.startupPromise) return this.startupPromise;
43
+
44
+ this.startupPromise = (async () => {
45
+ await this.close();
46
+
47
+ const instance = await this.chromium.launch({ headless });
48
+ const ctx = await instance.newContext({
49
+ viewport: { width: 1100, height: 850 },
50
+ userAgent:
51
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
52
+ });
53
+ const tab = await ctx.newPage();
54
+
55
+ this.browser = instance;
56
+ this.context = ctx;
57
+ this.page = tab;
58
+ this.scanStartedAt = Date.now();
59
+
60
+ clearTimeout(this.timeoutTimer);
61
+ this.timeoutTimer = setTimeout(() => {
62
+ this.close().catch(() => {});
63
+ }, DIANDIAN_LOGIN_TIMEOUT_MS);
64
+
65
+ // 导航至点点首页
66
+ await tab
67
+ .goto(DIANDIAN_HOME_URL, {
68
+ waitUntil: "domcontentloaded",
69
+ timeout: 30000,
70
+ })
71
+ .catch(() => {});
72
+
73
+ // 自动触发登录弹窗并主动切换至「扫码登录」模式
74
+ try {
75
+ const loginBtn = await tab
76
+ .waitForSelector(".header-iconbtns-login", { timeout: 12000 })
77
+ .catch(() => null);
78
+ if (loginBtn) {
79
+ await loginBtn.click().catch(() => {});
80
+ }
81
+
82
+ await tab
83
+ .waitForSelector(".login-window-content", { timeout: 10000 })
84
+ .catch(() => null);
85
+ await tab.waitForTimeout(600);
86
+
87
+ await tab
88
+ .evaluate(() => {
89
+ if (window.$) {
90
+ window
91
+ .$('.login-window-other-mobile[data-logintype="qrcode"]')
92
+ .trigger("click");
93
+ window.$(".login-window-other-mobile").trigger("click");
94
+ }
95
+ const qrcodeBtn =
96
+ document.querySelector(
97
+ '.login-window-other-mobile[data-logintype="qrcode"]',
98
+ ) ||
99
+ Array.from(document.querySelectorAll("*")).find(
100
+ (el) =>
101
+ (el.innerText || "").trim() === "扫码登录" &&
102
+ el.children.length === 0,
103
+ );
104
+ if (qrcodeBtn) qrcodeBtn.click();
105
+ })
106
+ .catch(() => {});
107
+
108
+ await tab.waitForTimeout(1000);
109
+ } catch {}
110
+
111
+ return {
112
+ status: "scanning",
113
+ message: "扫码登录页面已就绪,请使用微信或点点 App 扫码登录",
114
+ };
115
+ })();
116
+
117
+ try {
118
+ return await this.startupPromise;
119
+ } finally {
120
+ this.startupPromise = null;
121
+ }
122
+ }
123
+
124
+ async getScreenshot() {
125
+ if (!this.page || this.page.isClosed()) {
126
+ throw new Error("扫码登录会话未启动或已关闭");
127
+ }
128
+
129
+ // 自愈检查:确保在扫码模式
130
+ try {
131
+ await this.page
132
+ .evaluate(() => {
133
+ const text =
134
+ document.querySelector(".login-window-content")?.innerText || "";
135
+ if (
136
+ !text.includes("扫码登录") ||
137
+ text.includes("请输入用户名/手机号") ||
138
+ text.includes("忘记密码")
139
+ ) {
140
+ if (window.$) {
141
+ window
142
+ .$('.login-window-other-mobile[data-logintype="qrcode"]')
143
+ .trigger("click");
144
+ }
145
+ document
146
+ .querySelector(
147
+ '.login-window-other-mobile[data-logintype="qrcode"]',
148
+ )
149
+ ?.click();
150
+ }
151
+ })
152
+ .catch(() => {});
153
+ } catch {}
154
+ // 1. 优先截取纯净的二维码元素(放大高清,便于手机扫码识别)
155
+ try {
156
+ const qrElement =
157
+ (await this.page.$(".login-window-qrcodelogin")) ||
158
+ (await this.page.$(".login-window-content img[src^='data:image']"));
159
+ if (qrElement) {
160
+ const box = await qrElement.boundingBox().catch(() => null);
161
+ if (box && box.width > 50 && box.height > 50) {
162
+ return await qrElement.screenshot({ type: "png", timeout: 8000 });
163
+ }
164
+ }
165
+ } catch {}
166
+
167
+ // 2. 兜底截取登录弹窗主体
168
+ try {
169
+ const modal = await this.page.$(".login-window-content");
170
+ if (modal) {
171
+ const box = await modal.boundingBox().catch(() => null);
172
+ if (box && box.width > 150 && box.height > 150) {
173
+ return await modal.screenshot({ type: "png", timeout: 8000 });
174
+ }
175
+ }
176
+ } catch {}
177
+ return this.page.screenshot({
178
+ type: "png",
179
+ timeout: 10000,
180
+ });
181
+ }
182
+
183
+ async verifyCookie(cookieHeader) {
184
+ if (!cookieHeader || typeof cookieHeader !== "string") return null;
185
+ try {
186
+ const res = await fetch(DIANDIAN_USER_INFO_URL, {
187
+ headers: {
188
+ Cookie: cookieHeader,
189
+ Accept: "application/json",
190
+ "User-Agent":
191
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
192
+ },
193
+ });
194
+ if (!res.ok) return null;
195
+ const json = await res.json();
196
+ if (json && (json.code === 0 || json.error === 0) && json.data) {
197
+ const d = json.data;
198
+ return {
199
+ uid: String(d.uid || d.id || ""),
200
+ nickname: d.nickname || d.username || "点点用户",
201
+ avatar: d.avatar || d.headimgurl || "",
202
+ };
203
+ }
204
+ return null;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ async checkStatus() {
211
+ if (!this.context || !this.page || this.page.isClosed()) {
212
+ return { status: "not_started", message: "未开始扫码登录" };
213
+ }
214
+
215
+ try {
216
+ const cookies = await this.context.cookies([
217
+ "https://y.tuwan.com",
218
+ "https://tuwan.com",
219
+ ]);
220
+
221
+ const passportCookie = cookies.find((c) => c.name === "Tuwan_Passport");
222
+ if (!passportCookie || !passportCookie.value) {
223
+ return { status: "scanning", message: "等待扫码或登录中..." };
224
+ }
225
+
226
+ const storageState = await this.context.storageState();
227
+ const cookieHeader = cookiesToHeaderString(
228
+ storageState.cookies || cookies,
229
+ );
230
+
231
+ const userInfo = await this.verifyCookie(cookieHeader);
232
+ const uid = userInfo?.uid || passportCookie.value.slice(0, 10);
233
+ const nickname = userInfo?.nickname || "点点用户";
234
+ const avatar = userInfo?.avatar || "";
235
+
236
+ let savedAccount = null;
237
+ if (this.accountStore) {
238
+ savedAccount = this.accountStore.saveAccount({
239
+ accountId: uid,
240
+ nickname,
241
+ avatar,
242
+ cookieHeader,
243
+ storageState,
244
+ isActive: true,
245
+ });
246
+ }
247
+
248
+ // 扫码成功后释放浏览器资源
249
+ await this.close();
250
+
251
+ return {
252
+ status: "ready",
253
+ message: "点点账号绑定成功",
254
+ user: { uid, nickname, avatar },
255
+ account: savedAccount,
256
+ };
257
+ } catch (err) {
258
+ return {
259
+ status: "scanning",
260
+ message: `检查登录状态中: ${err.message}`,
261
+ };
262
+ }
263
+ }
264
+
265
+ async close() {
266
+ clearTimeout(this.timeoutTimer);
267
+ this.timeoutTimer = null;
268
+ this.scanStartedAt = 0;
269
+
270
+ const b = this.browser;
271
+ this.browser = null;
272
+ this.context = null;
273
+ this.page = null;
274
+
275
+ if (b) {
276
+ await b.close().catch(() => {});
277
+ }
278
+ }
279
+ }