@jayfong/x-utils 1.0.0-202510061015 → 3.4.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.
Files changed (143) hide show
  1. package/lib/ai.d.mts +103 -0
  2. package/lib/ai.mjs +75 -0
  3. package/lib/cache.d.mts +464 -0
  4. package/lib/cache.mjs +531 -0
  5. package/lib/captcha.d.mts +259 -0
  6. package/lib/captcha.mjs +174 -0
  7. package/lib/case.d.mts +184 -0
  8. package/lib/case.mjs +120 -0
  9. package/lib/chinese.d.mts +57 -0
  10. package/lib/chinese.mjs +79 -0
  11. package/lib/cli.d.mts +52 -0
  12. package/lib/cli.mjs +62 -0
  13. package/lib/clipboard.d.mts +50 -0
  14. package/lib/clipboard.mjs +122 -0
  15. package/lib/cmd.d.mts +38 -0
  16. package/lib/cmd.mjs +52 -0
  17. package/lib/crawler.d.mts +330 -0
  18. package/lib/crawler.mjs +313 -0
  19. package/lib/crypto.d.mts +158 -0
  20. package/lib/crypto.mjs +213 -0
  21. package/lib/csv.d.mts +67 -0
  22. package/lib/csv.mjs +76 -0
  23. package/lib/data_set.d.mts +76 -0
  24. package/lib/data_set.mjs +104 -0
  25. package/lib/db_value.d.mts +213 -0
  26. package/lib/db_value.mjs +208 -0
  27. package/lib/dispose.d.mts +98 -0
  28. package/lib/dispose.mjs +107 -0
  29. package/lib/file_store.d.mts +167 -0
  30. package/lib/file_store.mjs +120 -0
  31. package/lib/fs.d.mts +111 -0
  32. package/lib/fs.mjs +127 -0
  33. package/lib/html.d.mts +103 -0
  34. package/lib/html.mjs +112 -0
  35. package/lib/json.d.mts +50 -0
  36. package/lib/json.mjs +74 -0
  37. package/lib/jwt.d.mts +128 -0
  38. package/lib/jwt.mjs +104 -0
  39. package/lib/mail.d.mts +125 -0
  40. package/lib/mail.mjs +108 -0
  41. package/lib/pay.d.mts +394 -0
  42. package/lib/pay.mjs +343 -0
  43. package/lib/puppeteer.d.mts +88 -0
  44. package/lib/puppeteer.mjs +219 -0
  45. package/lib/random.d.mts +71 -0
  46. package/lib/random.mjs +92 -0
  47. package/lib/redis.d.mts +32 -0
  48. package/lib/redis.mjs +24 -0
  49. package/lib/runner.d.mts +80 -0
  50. package/lib/runner.mjs +66 -0
  51. package/lib/sensitive_words.d.mts +82 -0
  52. package/lib/sensitive_words.mjs +73 -0
  53. package/lib/sms.d.mts +133 -0
  54. package/lib/sms.mjs +100 -0
  55. package/lib/task.d.mts +37 -0
  56. package/lib/task.mjs +43 -0
  57. package/lib/text.d.mts +84 -0
  58. package/lib/text.mjs +141 -0
  59. package/lib/yaml.d.mts +51 -0
  60. package/lib/yaml.mjs +64 -0
  61. package/package.json +82 -164
  62. package/_cjs/ai.js +0 -80
  63. package/_cjs/alipan.js +0 -173
  64. package/_cjs/cli.js +0 -51
  65. package/_cjs/clipboard.js +0 -65
  66. package/_cjs/cmd.js +0 -42
  67. package/_cjs/compressor.js +0 -47
  68. package/_cjs/crawler.js +0 -341
  69. package/_cjs/csv.js +0 -97
  70. package/_cjs/data_set.js +0 -115
  71. package/_cjs/env.js +0 -82
  72. package/_cjs/fs.js +0 -86
  73. package/_cjs/html.js +0 -62
  74. package/_cjs/http_server.js +0 -79
  75. package/_cjs/json.js +0 -49
  76. package/_cjs/ksb.js +0 -218
  77. package/_cjs/number.js +0 -39
  78. package/_cjs/ocr.js +0 -74
  79. package/_cjs/package.json +0 -3
  80. package/_cjs/path.js +0 -35
  81. package/_cjs/pinyin.js +0 -37
  82. package/_cjs/puppeteer.js +0 -271
  83. package/_cjs/runner.js +0 -88
  84. package/_cjs/task.js +0 -41
  85. package/_cjs/text.js +0 -136
  86. package/_cjs/unicode/lib_unicode.js +0 -58643
  87. package/_cjs/unicode/unicode.js +0 -441
  88. package/_cjs/upload.js +0 -101
  89. package/_cjs/yaml.js +0 -52
  90. package/ai.d.ts +0 -26
  91. package/ai.js +0 -60
  92. package/alipan.d.ts +0 -209
  93. package/alipan.js +0 -153
  94. package/cli.d.ts +0 -14
  95. package/cli.js +0 -21
  96. package/clipboard.d.ts +0 -6
  97. package/clipboard.js +0 -35
  98. package/cmd.d.ts +0 -5
  99. package/cmd.js +0 -22
  100. package/compressor.d.ts +0 -5
  101. package/compressor.js +0 -17
  102. package/crawler.d.ts +0 -162
  103. package/crawler.js +0 -332
  104. package/csv.d.ts +0 -14
  105. package/csv.js +0 -67
  106. package/data_set.d.ts +0 -22
  107. package/data_set.js +0 -85
  108. package/env.d.ts +0 -6
  109. package/env.js +0 -52
  110. package/fs.d.ts +0 -23
  111. package/fs.js +0 -56
  112. package/html.d.ts +0 -7
  113. package/html.js +0 -45
  114. package/http_server.d.ts +0 -13
  115. package/http_server.js +0 -49
  116. package/json.d.ts +0 -6
  117. package/json.js +0 -19
  118. package/ksb.d.ts +0 -37
  119. package/ksb.js +0 -198
  120. package/number.d.ts +0 -3
  121. package/number.js +0 -9
  122. package/ocr.d.ts +0 -17
  123. package/ocr.js +0 -54
  124. package/path.d.ts +0 -2
  125. package/path.js +0 -5
  126. package/pinyin.d.ts +0 -8
  127. package/pinyin.js +0 -17
  128. package/puppeteer.d.ts +0 -38
  129. package/puppeteer.js +0 -241
  130. package/runner.d.ts +0 -13
  131. package/runner.js +0 -58
  132. package/task.d.ts +0 -6
  133. package/task.js +0 -21
  134. package/text.d.ts +0 -63
  135. package/text.js +0 -119
  136. package/unicode/lib_unicode.d.ts +0 -4
  137. package/unicode/lib_unicode.js +0 -58623
  138. package/unicode/unicode.d.ts +0 -347
  139. package/unicode/unicode.js +0 -426
  140. package/upload.d.ts +0 -34
  141. package/upload.js +0 -71
  142. package/yaml.d.ts +0 -6
  143. package/yaml.js +0 -22
package/lib/pay.mjs ADDED
@@ -0,0 +1,343 @@
1
+ import { xr } from "@jayfong/x-request";
2
+ import crypto from "node:crypto";
3
+ import { AlipaySdk } from "alipay-sdk";
4
+ // src/pay.ts
5
+ /**
6
+ * 支付工具。
7
+ *
8
+ * 封装支付宝(网页支付)与微信支付(JSAPI 支付)的下单、回调验签与
9
+ * 订单查询能力:
10
+ * - 支付宝:`prepareAlipay` 生成支付链接、`getOrderInfo` 查询订单;
11
+ * - 微信支付:`prepareWepay` 生成 JSSDK 支付参数、`getOrderInfo` 查询订单;
12
+ * - 回调:`verifyNotifyData` 统一校验并解析两个渠道的回调数据。
13
+ *
14
+ * 支付宝 SDK 按需惰性创建,只有首次使用支付宝能力时才初始化。
15
+ * 注意:微信支付回调目前只做 AES-GCM 解密,未实现签名验签,
16
+ * 业务方如需严格校验身份,请自行补充。
17
+ *
18
+ * @example
19
+ * const pay = new XPay({
20
+ * alipay: {
21
+ * appId: '2021000000000000',
22
+ * privateKey: '应用私钥',
23
+ * publicKey: '支付宝公钥',
24
+ * },
25
+ * wepay: {
26
+ * appId: 'wx1234567890',
27
+ * merchantId: '1234567890',
28
+ * privateKey: '商户 API 证书私钥',
29
+ * certificateSerialNumber: '证书序列号',
30
+ * secretKey: '32 字节 APIv3 密钥',
31
+ * },
32
+ * });
33
+ *
34
+ * const { payUrl } = await pay.prepareAlipay({
35
+ * goodsName: '测试商品',
36
+ * tradeNumber: 'T20260101001',
37
+ * totalMoney: 0.01,
38
+ * notifyUrl: 'https://example.com/notify/alipay',
39
+ * });
40
+ * // 跳转到 payUrl 完成支付
41
+ */
42
+ var XPay = class {
43
+ options;
44
+ alipaySdk;
45
+ constructor(options) {
46
+ this.options = options;
47
+ }
48
+ /**
49
+ * 支付宝网页支付下单,生成支付链接。
50
+ *
51
+ * 调用支付宝 `alipay.trade.page.pay` 接口生成 GET 形式的支付地址,
52
+ * 用户跳转到该地址即可在支付宝收银台完成支付。订单金额会格式化为
53
+ * 两位小数字符串,并通过 `passback_params` 标记渠道为 `alipay`,
54
+ * 以便回调时识别。
55
+ *
56
+ * @param options 下单参数,见 {@link XPayPrepareAlipayOptions}。
57
+ * @returns 支付地址,见 {@link XPayPrepareAlipayResult}。
58
+ *
59
+ * @example
60
+ * const { payUrl } = await pay.prepareAlipay({
61
+ * goodsName: '测试商品',
62
+ * tradeNumber: 'T20260101001',
63
+ * totalMoney: 0.01,
64
+ * notifyUrl: 'https://example.com/notify/alipay',
65
+ * });
66
+ */
67
+ async prepareAlipay(options) {
68
+ return { payUrl: this.getAlipaySdk().pageExecute("alipay.trade.page.pay", "GET", {
69
+ notifyUrl: options.notifyUrl,
70
+ ...options.returnUrl ? { returnUrl: options.returnUrl } : {},
71
+ bizContent: {
72
+ outTradeNo: options.tradeNumber,
73
+ productCode: "FAST_INSTANT_TRADE_PAY",
74
+ totalAmount: options.totalMoney.toFixed(2),
75
+ subject: options.goodsName,
76
+ goodsType: "0",
77
+ passbackParams: "alipay"
78
+ }
79
+ }) };
80
+ }
81
+ /**
82
+ * 微信支付 JSAPI 下单,生成 JSSDK 支付参数。
83
+ *
84
+ * 调用微信支付 `v3/pay/transactions/jsapi` 接口获取 `prepay_id`,
85
+ * 再按 JSSDK 规则签名得到支付参数,可直接传给前端
86
+ * `wx.requestPayment`。金额由元转换为分(乘以 100 后四舍五入),
87
+ * 避免浮点误差;下单请求会通过 `attach` 字段标记渠道为 `wepay`。
88
+ *
89
+ * @param options 下单参数,见 {@link XPayPrepareWepayOptions}。
90
+ * @returns 支付参数,见 {@link XPayPrepareWepayResult}。
91
+ *
92
+ * @example
93
+ * const { payParams } = await pay.prepareWepay({
94
+ * goodsName: '测试商品',
95
+ * tradeNumber: 'T20260101002',
96
+ * totalMoney: 0.01,
97
+ * notifyUrl: 'https://example.com/notify/wepay',
98
+ * openid: 'oABC123',
99
+ * });
100
+ */
101
+ async prepareWepay(options) {
102
+ const wepay = this.getWepayConfig();
103
+ const res = await this.wepayRequest("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi", {
104
+ appid: wepay.appId,
105
+ mchid: wepay.merchantId,
106
+ description: options.goodsName,
107
+ out_trade_no: options.tradeNumber,
108
+ attach: "wepay",
109
+ notify_url: options.notifyUrl,
110
+ amount: {
111
+ total: Math.round(options.totalMoney * 100),
112
+ currency: "CNY"
113
+ },
114
+ payer: { openid: options.openid }
115
+ });
116
+ if (!res || !res.prepay_id) throw new Error(res?.message || "微信支付下单失败,未返回 prepay_id");
117
+ const params = {
118
+ appId: wepay.appId,
119
+ timestamp: String(Math.round(Date.now() / 1e3)),
120
+ nonceStr: this.generateNonceStr(),
121
+ package: `prepay_id=${res.prepay_id}`,
122
+ signType: "RSA",
123
+ paySign: ""
124
+ };
125
+ params.paySign = this.wepaySign([
126
+ params.appId,
127
+ params.timestamp,
128
+ params.nonceStr,
129
+ params.package
130
+ ]);
131
+ return { payParams: params };
132
+ }
133
+ /**
134
+ * 校验并解析支付回调通知数据。
135
+ *
136
+ * 先判断数据是否为对象,再按渠道标记识别:
137
+ * - 支付宝:当 `passback_params` 为 `alipay` 时,调用支付宝 SDK 验签,
138
+ * 验签通过后返回支付宝交易号;
139
+ * - 微信支付:当存在 `resource` 字段时,先 AES-GCM 解密,再校验
140
+ * `attach` 是否为 `wepay`,通过后返回微信支付订单号。
141
+ *
142
+ * 任何一步失败(数据非法、未配置渠道、验签失败、解密失败)都会
143
+ * 返回 `false`,不会抛出异常。
144
+ *
145
+ * @param data 支付平台 POST 过来的回调数据(支付宝为表单 JSON,
146
+ * 微信支付为通知报文 JSON)。
147
+ * @returns 校验通过时返回渠道订单号,否则返回 `false`。
148
+ *
149
+ * @example
150
+ * const res = pay.verifyNotifyData(req.body);
151
+ * if (res) {
152
+ * // 校验通过,res.channelOrderNo 为渠道订单号
153
+ * }
154
+ */
155
+ verifyNotifyData(data) {
156
+ try {
157
+ if (!data || typeof data !== "object") return false;
158
+ if (data.passback_params === "alipay") {
159
+ if (!this.getAlipaySdk().checkNotifySign(data)) return false;
160
+ return { channelOrderNo: data.trade_no };
161
+ }
162
+ if (data.resource) {
163
+ const params = JSON.parse(this.wepayDecrypt(data.resource));
164
+ if (params.attach === "wepay" && params.transaction_id) return { channelOrderNo: params.transaction_id };
165
+ return false;
166
+ }
167
+ return false;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+ /**
173
+ * 校验并解析支付回调通知数据,失败时抛出错误。
174
+ *
175
+ * 行为与 {@link verifyNotifyData} 一致,区别在于校验失败时抛出
176
+ * `Error`,便于在需要强制校验的流程中使用。
177
+ *
178
+ * @param data 支付平台 POST 过来的回调数据。
179
+ * @param message 校验失败时抛出的错误信息。
180
+ * @returns 校验通过时的渠道订单号。
181
+ *
182
+ * @example
183
+ * const res = pay.verifyNotifyDataOrFail(req.body, '回调验签失败');
184
+ */
185
+ verifyNotifyDataOrFail(data, message) {
186
+ const res = this.verifyNotifyData(data);
187
+ if (!res) throw new Error(message);
188
+ return res;
189
+ }
190
+ /**
191
+ * 查询订单状态。
192
+ *
193
+ * 按渠道分别调用微信支付订单查询接口或支付宝 `alipay.trade.query`:
194
+ * - 优先使用 `orderNo`(商家订单号)查询,未提供时使用
195
+ * `channelOrderNo`(渠道订单号);
196
+ * - 微信支付 `trade_state` 为 `SUCCESS` 时视为成功;
197
+ * - 支付宝 `trade_status` 为 `TRADE_SUCCESS` 或 `TRADE_FINISHED` 时
198
+ * 视为成功,其余状态视为失败。
199
+ *
200
+ * @param payload 查询条件,见 {@link XPayGetOrderInfoPayload}。
201
+ * @returns 订单信息,见 {@link XPayGetOrderInfoResult}。
202
+ *
203
+ * @example
204
+ * await pay.getOrderInfo({ channel: 'alipay', orderNo: 'T20260101001' });
205
+ * // { channelOrderNo: '2026...', orderNo: 'T20260101001', status: 'success' }
206
+ */
207
+ async getOrderInfo(payload) {
208
+ if (payload.channel === "wepay") {
209
+ const wepay = this.getWepayConfig();
210
+ const res = await this.wepayRequest(payload.orderNo ? `https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/${payload.orderNo}?mchid=${wepay.merchantId}` : `https://api.mch.weixin.qq.com/v3/pay/transactions/id/${payload.channelOrderNo}?mchid=${wepay.merchantId}`);
211
+ return {
212
+ channelOrderNo: res.transaction_id,
213
+ orderNo: res.out_trade_no,
214
+ status: res.trade_state === "SUCCESS" ? "success" : "fail"
215
+ };
216
+ }
217
+ const res = await this.getAlipaySdk().exec("alipay.trade.query", { biz_content: payload.orderNo ? { out_trade_no: payload.orderNo } : { trade_no: payload.channelOrderNo } });
218
+ return {
219
+ channelOrderNo: res.tradeNo,
220
+ orderNo: res.outTradeNo,
221
+ status: res.tradeStatus === "TRADE_SUCCESS" || res.tradeStatus === "TRADE_FINISHED" ? "success" : "fail"
222
+ };
223
+ }
224
+ /**
225
+ * 获取(并按需创建)支付宝 SDK 实例。
226
+ *
227
+ * 首次调用时才初始化 SDK,后续复用同一实例;未配置支付宝时抛出
228
+ * 明确错误。
229
+ */
230
+ getAlipaySdk() {
231
+ const alipay = this.getAlipayConfig();
232
+ if (!this.alipaySdk) this.alipaySdk = new AlipaySdk({
233
+ appId: alipay.appId,
234
+ privateKey: alipay.privateKey,
235
+ alipayPublicKey: alipay.publicKey
236
+ });
237
+ return this.alipaySdk;
238
+ }
239
+ /**
240
+ * 获取支付宝配置,未配置时抛出错误。
241
+ */
242
+ getAlipayConfig() {
243
+ if (!this.options.alipay) throw new Error("未配置支付宝(alipay),无法使用支付宝相关能力");
244
+ return this.options.alipay;
245
+ }
246
+ /**
247
+ * 获取微信支付配置,未配置时抛出错误。
248
+ */
249
+ getWepayConfig() {
250
+ if (!this.options.wepay) throw new Error("未配置微信支付(wepay),无法使用微信支付相关能力");
251
+ return this.options.wepay;
252
+ }
253
+ /**
254
+ * 生成随机字符串,用于微信支付请求的 `nonce_str` 与支付参数。
255
+ *
256
+ * 使用密码学安全的随机字节生成 12 位十六进制字符串。
257
+ */
258
+ generateNonceStr() {
259
+ return crypto.randomBytes(6).toString("hex");
260
+ }
261
+ /**
262
+ * 微信支付 RSA 签名。
263
+ *
264
+ * 将每个字段拼接换行符(`\n`)后使用 `RSA-SHA256` 私钥签名,返回
265
+ * base64 编码的签名;同一方法同时用于请求头签名与 JSSDK 支付参数
266
+ * 签名。
267
+ *
268
+ * @param data 待签名字段,按顺序依次拼接,每个字段后追加换行符。
269
+ * @returns base64 编码的签名串。
270
+ */
271
+ wepaySign(data) {
272
+ return crypto.createSign("RSA-SHA256").update(data.map((v) => `${v}\n`).join("")).sign(this.getWepayConfig().privateKey, "base64");
273
+ }
274
+ /**
275
+ * 微信支付回调数据解密。
276
+ *
277
+ * 使用 APIv3 密钥对 `resource` 做 AES-256-GCM 解密:密文末尾 16
278
+ * 字节为认证标签,前面为加密数据,`nonce` 作为 IV,
279
+ * `associated_data` 作为附加认证数据。
280
+ *
281
+ * @param resource 回调数据中的 `resource` 字段。
282
+ * @returns 解密后的 JSON 字符串。
283
+ */
284
+ wepayDecrypt(resource) {
285
+ const ciphertext = Buffer.from(resource.ciphertext, "base64");
286
+ const authTag = ciphertext.subarray(ciphertext.length - 16);
287
+ const encryptedData = ciphertext.subarray(0, ciphertext.length - 16);
288
+ const decipher = crypto.createDecipheriv("aes-256-gcm", Buffer.from(this.getWepayConfig().secretKey, "utf8"), Buffer.from(resource.nonce, "utf8"));
289
+ decipher.setAuthTag(authTag);
290
+ decipher.setAAD(Buffer.from(resource.associated_data ?? "", "utf8"));
291
+ return Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString("utf8");
292
+ }
293
+ /**
294
+ * 发起微信支付 APIv3 请求。
295
+ *
296
+ * 根据 URL 计算请求路径,按 `方法\nURL\n时间戳\n随机串\n请求体\n`
297
+ * 构造签名并生成 `Authorization` 请求头;GET 请求体为空字符串,
298
+ * POST 请求体为 `data` 的 JSON 字符串。
299
+ *
300
+ * @param url 完整请求地址。
301
+ * @param data 请求体,缺省表示 GET 请求。
302
+ * @returns 响应数据。
303
+ */
304
+ async wepayRequest(url, data) {
305
+ const wepay = this.getWepayConfig();
306
+ const path = url.replace(/^https?:\/\/[^/]+/, "");
307
+ const method = data == null ? "GET" : "POST";
308
+ const body = method === "POST" ? JSON.stringify(data) : "";
309
+ const params = {
310
+ method,
311
+ url: path,
312
+ time: String(Math.round(Date.now() / 1e3)),
313
+ rand: this.generateNonceStr(),
314
+ body,
315
+ signature: ""
316
+ };
317
+ params.signature = this.wepaySign([
318
+ params.method,
319
+ params.url,
320
+ params.time,
321
+ params.rand,
322
+ params.body
323
+ ]);
324
+ const Authorization = [
325
+ `WECHATPAY2-SHA256-RSA2048 `,
326
+ `mchid="${wepay.merchantId}",`,
327
+ `nonce_str="${params.rand}",`,
328
+ `signature="${params.signature}",`,
329
+ `timestamp="${params.time}",`,
330
+ `serial_no="${wepay.certificateSerialNumber}"`
331
+ ].join("");
332
+ const { data: res } = method === "POST" ? await xr.postJson({
333
+ url,
334
+ headers: { Authorization },
335
+ data
336
+ }) : await xr.getJson({
337
+ url,
338
+ headers: { Authorization }
339
+ });
340
+ return res;
341
+ }
342
+ };
343
+ export { XPay };
@@ -0,0 +1,88 @@
1
+ import { Browser, ElementHandle, Frame, Page } from "puppeteer";
2
+ export type XPuppeteerBrowser = Browser;
3
+ export type XPuppeteerPage = Page;
4
+ export interface XPuppeteerOpenBrowserOptions {
5
+ /** 是否以有头模式启动浏览器;默认 `false`(无头)。 */
6
+ headful?: boolean;
7
+ /** 代理地址,例如 `http://127.0.0.1:7890`。 */
8
+ proxy?: string;
9
+ /** 浏览器用户数据目录。 */
10
+ dataDir?: string;
11
+ }
12
+ export interface XPuppeteerOpenPageOptions {
13
+ /**
14
+ * 当目标 URL 已在现有页面中打开时的匹配规则。
15
+ *
16
+ * - `string`:匹配 `pathname + search` 是否包含该片段;
17
+ * - `RegExp`:匹配 `pathname + search`。
18
+ */
19
+ match?: string | RegExp;
20
+ /** 是否拦截图片/字体/样式资源,以提升打开速度。 */
21
+ blockAssets?: boolean;
22
+ /** `goto` 时使用的 referer;未传则使用目标 URL。 */
23
+ referer?: string;
24
+ }
25
+ export interface XPuppeteerGetPageHtmlOptions extends XPuppeteerOpenBrowserOptions, XPuppeteerOpenPageOptions {
26
+ /** 复用浏览器实例的标识;传入后会命中 memoized 实例。 */
27
+ instanceId?: string | number;
28
+ /** 是否连接系统已启动的调试浏览器(默认 `false`)。 */
29
+ useSystemBrowser?: boolean;
30
+ /**
31
+ * 页面打开后的扩展逻辑钩子,例如等待某个 selector 出现再取 HTML。
32
+ */
33
+ afterOpenPage?: (payload: {
34
+ /** 当前 `XPuppeteer` 实例。 */
35
+ browser: XPuppeteer;
36
+ /** 已打开的页面实例。 */
37
+ page: XPuppeteerPage;
38
+ }) => any;
39
+ }
40
+ export declare class XPuppeteer {
41
+ browser: Browser;
42
+ /**
43
+ * 连接系统中已开启远程调试端口(9222)的浏览器。
44
+ */
45
+ connectSystemBrowser(): Promise<XPuppeteerBrowser>;
46
+ /**
47
+ * 启动新的浏览器实例并绑定退出钩子。
48
+ */
49
+ openBrowser(options?: XPuppeteerOpenBrowserOptions): Promise<XPuppeteerBrowser>;
50
+ /**
51
+ * 打开 URL 或直接渲染 HTML。
52
+ *
53
+ * 当入参为 URL 时,会优先尝试复用已打开且匹配的页面。
54
+ */
55
+ openPage(urlOrHtml: string, options?: XPuppeteerOpenPageOptions): Promise<XPuppeteerPage>;
56
+ input(page: Page, selector: string, textOrHtml: string, frameIndex?: number): Promise<void>;
57
+ /**
58
+ * 在当前激活元素上执行“全选”。
59
+ */
60
+ selectAll(page: Page | Frame): Promise<void>;
61
+ /**
62
+ * 通过键盘事件执行粘贴(Shift + Insert)。
63
+ */
64
+ paste(page: Page): Promise<void>;
65
+ queryByText<T extends Node = HTMLDivElement>(page: Page, text: string | RegExp | ((el: T) => boolean)): Promise<ElementHandle<T>>;
66
+ /**
67
+ * 断开与浏览器的连接(不关闭浏览器进程)。
68
+ */
69
+ disconnect(): Promise<void>;
70
+ /**
71
+ * 关闭当前浏览器实例。
72
+ */
73
+ close(): Promise<void>;
74
+ /**
75
+ * 创建并打开浏览器,返回 `XPuppeteer` 实例。
76
+ */
77
+ static openBrowser(options?: XPuppeteerOpenBrowserOptions): Promise<XPuppeteer>;
78
+ /**
79
+ * 便捷方法:打开页面并返回 `Page`。
80
+ *
81
+ * 返回的 `page.close()` 会被替换为关闭整个浏览器实例。
82
+ */
83
+ static openPage(urlOrHtml: string, options?: XPuppeteerGetPageHtmlOptions): Promise<XPuppeteerPage>;
84
+ static getPageHtmlXp: ((options: XPuppeteerGetPageHtmlOptions) => Promise<XPuppeteer>) & {
85
+ cache: import("vtils").AsyncMemoizeCacheMap;
86
+ };
87
+ static getPageHtml(url: string, options?: XPuppeteerGetPageHtmlOptions): Promise<string>;
88
+ }
@@ -0,0 +1,219 @@
1
+ import { XClipboard } from "./clipboard.mjs";
2
+ import { XDispose } from "./dispose.mjs";
3
+ import { xr } from "@jayfong/x-request";
4
+ import { asyncMemoize, isFunction, isRegExp } from "vtils";
5
+ import puppeteer from "puppeteer";
6
+ // src/puppeteer.ts
7
+ var XPuppeteer = class XPuppeteer {
8
+ browser;
9
+ /**
10
+ * 连接系统中已开启远程调试端口(9222)的浏览器。
11
+ */
12
+ async connectSystemBrowser() {
13
+ const { data: { webSocketDebuggerUrl } } = await xr.getJson({ url: "http://localhost:9222/json/version" });
14
+ const browser = await puppeteer.connect({
15
+ browserWSEndpoint: webSocketDebuggerUrl,
16
+ defaultViewport: {
17
+ width: 0,
18
+ height: 0
19
+ }
20
+ });
21
+ this.browser = browser;
22
+ XDispose.exit.add(() => this.disconnect());
23
+ return browser;
24
+ }
25
+ /**
26
+ * 启动新的浏览器实例并绑定退出钩子。
27
+ */
28
+ async openBrowser(options = {}) {
29
+ const browser = await puppeteer.launch({
30
+ headless: !options.headful,
31
+ defaultViewport: {
32
+ width: 1440,
33
+ height: 900
34
+ },
35
+ userDataDir: options.dataDir,
36
+ args: options.proxy ? [`--proxy-server=${options.proxy}`] : void 0
37
+ });
38
+ this.browser = browser;
39
+ XDispose.exit.add(() => this.close());
40
+ return browser;
41
+ }
42
+ /**
43
+ * 打开 URL 或直接渲染 HTML。
44
+ *
45
+ * 当入参为 URL 时,会优先尝试复用已打开且匹配的页面。
46
+ */
47
+ async openPage(urlOrHtml, options = {}) {
48
+ const isUrl = /^https?:\/\//i.test(urlOrHtml);
49
+ const pages = await this.browser.pages();
50
+ if (isUrl) {
51
+ const inputUrl = new URL(urlOrHtml);
52
+ const foundPage = pages.find((item) => {
53
+ const pageUrl = item.url();
54
+ if (!/^https?:\/\//i.test(pageUrl)) return false;
55
+ const url = new URL(pageUrl);
56
+ return inputUrl.origin === url.origin && (pageUrl === urlOrHtml || !!options.match && (typeof options.match === "string" ? `${url.pathname}${url.search}`.includes(options.match) : options.match.test(`${url.pathname}${url.search}`)));
57
+ });
58
+ if (foundPage) return foundPage;
59
+ }
60
+ const page = await this.browser.newPage();
61
+ await page.setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36");
62
+ if (options.blockAssets) {
63
+ await page.setRequestInterception(true);
64
+ page.on("request", (req) => {
65
+ const resourceType = req.resourceType();
66
+ if (resourceType === "stylesheet" || resourceType === "font" || resourceType === "image") req.abort();
67
+ else req.continue();
68
+ });
69
+ }
70
+ if (isUrl) await page.goto(urlOrHtml, { referer: options.referer || urlOrHtml });
71
+ else await page.setContent(urlOrHtml);
72
+ return page;
73
+ }
74
+ async input(page, selector, textOrHtml, frameIndex) {
75
+ const mainPage = page;
76
+ const keyboard = page.keyboard;
77
+ let queryRoot = page;
78
+ if (frameIndex != null) {
79
+ const frame = page.frames()[frameIndex];
80
+ if (!frame) throw new Error(`Frame 不存在: ${frameIndex}`);
81
+ queryRoot = frame;
82
+ }
83
+ const el = await queryRoot.$(selector);
84
+ if (!el) throw new Error(`未找到输入元素: ${selector}`);
85
+ if (await el.evaluate((el) => {
86
+ return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SPAN";
87
+ })) await XClipboard.writeText(textOrHtml);
88
+ else await XClipboard.writeHtml(textOrHtml);
89
+ await el.click();
90
+ await this.selectAll(queryRoot);
91
+ await keyboard.press("Backspace");
92
+ await this.paste(mainPage);
93
+ }
94
+ /**
95
+ * 在当前激活元素上执行“全选”。
96
+ */
97
+ async selectAll(page) {
98
+ await page.evaluate(() => {
99
+ const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
100
+ document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {
101
+ bubbles: true,
102
+ cancelable: true,
103
+ key: isMac ? "Meta" : "Control",
104
+ code: isMac ? "MetaLeft" : "ControlLeft",
105
+ location: window.KeyboardEvent.DOM_KEY_LOCATION_LEFT,
106
+ getModifierState: (keyArg) => keyArg === (isMac ? "Meta" : "Control"),
107
+ ctrlKey: !isMac,
108
+ metaKey: isMac,
109
+ charCode: 0,
110
+ keyCode: isMac ? 93 : 17,
111
+ which: isMac ? 93 : 17
112
+ }));
113
+ const preventableEvent = new KeyboardEvent("keydown", {
114
+ bubbles: true,
115
+ cancelable: true,
116
+ key: "a",
117
+ code: "KeyA",
118
+ location: window.KeyboardEvent.DOM_KEY_LOCATION_STANDARD,
119
+ getModifierState: (keyArg) => keyArg === (isMac ? "Meta" : "Control"),
120
+ ctrlKey: !isMac,
121
+ metaKey: isMac,
122
+ charCode: 0,
123
+ keyCode: 65,
124
+ which: 65
125
+ });
126
+ if (!(!document.activeElement.dispatchEvent(preventableEvent) || preventableEvent.defaultPrevented)) document.execCommand("selectall", false, null);
127
+ document.activeElement.dispatchEvent(new KeyboardEvent("keyup", {
128
+ bubbles: true,
129
+ cancelable: true,
130
+ key: isMac ? "Meta" : "Control",
131
+ code: isMac ? "MetaLeft" : "ControlLeft",
132
+ location: window.KeyboardEvent.DOM_KEY_LOCATION_LEFT,
133
+ getModifierState: () => false,
134
+ charCode: 0,
135
+ keyCode: isMac ? 93 : 17,
136
+ which: isMac ? 93 : 17
137
+ }));
138
+ });
139
+ }
140
+ /**
141
+ * 通过键盘事件执行粘贴(Shift + Insert)。
142
+ */
143
+ async paste(page) {
144
+ await page.keyboard.down("Shift");
145
+ await page.keyboard.press("Insert");
146
+ await page.keyboard.up("Shift");
147
+ }
148
+ async queryByText(page, text) {
149
+ const el = await page.evaluateHandle((text, isRegExp, isFunction) => {
150
+ const textRe = isRegExp ? (() => {
151
+ const m = text.match(/^\/(.+)\/([^/]*)$/);
152
+ return new RegExp(m[1], m[2] || void 0);
153
+ })() : void 0;
154
+ const textFn = isFunction ? eval(`(() => {return ${text}})()`) : void 0;
155
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode: (node) => {
156
+ return (isRegExp ? textRe.test(node.textContent) : isFunction ? textFn(node) : node.textContent.includes(text)) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
157
+ } });
158
+ walker.nextNode();
159
+ return walker.currentNode.parentElement;
160
+ }, String(text), isRegExp(text), isFunction(text));
161
+ return el;
162
+ }
163
+ /**
164
+ * 断开与浏览器的连接(不关闭浏览器进程)。
165
+ */
166
+ async disconnect() {
167
+ await this.browser.disconnect();
168
+ }
169
+ /**
170
+ * 关闭当前浏览器实例。
171
+ */
172
+ async close() {
173
+ await this.browser.close();
174
+ }
175
+ /**
176
+ * 创建并打开浏览器,返回 `XPuppeteer` 实例。
177
+ */
178
+ static async openBrowser(options = {}) {
179
+ const xp = new XPuppeteer();
180
+ await xp.openBrowser(options);
181
+ return xp;
182
+ }
183
+ /**
184
+ * 便捷方法:打开页面并返回 `Page`。
185
+ *
186
+ * 返回的 `page.close()` 会被替换为关闭整个浏览器实例。
187
+ */
188
+ static async openPage(urlOrHtml, options = {}) {
189
+ const xp = new XPuppeteer();
190
+ await xp.openBrowser(options);
191
+ const page = await xp.openPage(urlOrHtml, options);
192
+ page.close = () => xp.close();
193
+ return page;
194
+ }
195
+ static getPageHtmlXp = asyncMemoize(async (options) => {
196
+ const xp = new XPuppeteer();
197
+ await (options.useSystemBrowser ? xp.connectSystemBrowser() : xp.openBrowser(options));
198
+ return xp;
199
+ }, { cacheKey: (options) => options.instanceId });
200
+ static async getPageHtml(url, options = {}) {
201
+ const hasInstanceId = !!options.instanceId;
202
+ options.instanceId ||= Date.now();
203
+ const xp = await XPuppeteer.getPageHtmlXp(options);
204
+ try {
205
+ const page = await xp.openPage(url, options);
206
+ if (options.afterOpenPage) await options.afterOpenPage({
207
+ browser: xp,
208
+ page
209
+ });
210
+ return await page.content();
211
+ } finally {
212
+ if (!hasInstanceId) {
213
+ if (options.useSystemBrowser) await xp.disconnect();
214
+ else await xp.close();
215
+ }
216
+ }
217
+ }
218
+ };
219
+ export { XPuppeteer };