@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
@@ -0,0 +1,330 @@
1
+ import { XPuppeteerGetPageHtmlOptions } from "./puppeteer.mjs";
2
+ import { EventBus } from "vtils";
3
+ import { HTMLElement } from "node-html-parser";
4
+ import { VaeObjectSchemaShapeOf, v } from "vtils/vae";
5
+ import { AsyncOrSync, OneOrMore } from "vtils/types";
6
+ export type XCrawlerData = Record<any, any>;
7
+ export type XCrawlerDataFieldSelectSelector = string;
8
+ export type XCrawlerDataFieldSelectFilter = [string, string | RegExp | ((el: HTMLElement) => boolean)];
9
+ export type XCrawlerDataFieldSelectFunction = (el: HTMLElement) => AsyncOrSync<string | HTMLElement | undefined>;
10
+ export type XCrawlerDataFieldSelect = XCrawlerDataFieldSelectSelector | XCrawlerDataFieldSelectFilter | XCrawlerDataFieldSelectFunction;
11
+ export type XCrawlerDataFieldExtractExtract = 'getPlainText' | 'getHTML' | 'getFirstImageUrl' | 'getFirstLinkUrl';
12
+ export type XCrawlerDataFieldExtract<V> = {
13
+ /**
14
+ * 选择元素
15
+ *
16
+ * - 可以是一个选择器字符串;
17
+ * - 可以是一个筛选器数组,[选择器, 筛选条件];
18
+ * - 也可以是一个返回元素的函数;
19
+ * - 也可以是一个返回文本的函数,若此,将跳过 ocr 和 action。
20
+ */
21
+ select?: XCrawlerDataFieldSelect;
22
+ /**
23
+ * 识别选中内容中的图片为文本并替换
24
+ */
25
+ ocr?: (imgUrl: string) => AsyncOrSync<string>;
26
+ /**
27
+ * 对选中元素导出结果文本
28
+ *
29
+ * @default 'getPlainText'
30
+ */
31
+ extract?: XCrawlerDataFieldExtractExtract;
32
+ /**
33
+ * 若有需要,可对结果文本进行转换
34
+ *
35
+ * @param text 结果文本
36
+ */
37
+ transform?: (text: string) => AsyncOrSync<V>;
38
+ };
39
+ export type XCrawlerDataExtract<T extends XCrawlerData> = { [K in keyof T]?: XCrawlerDataFieldSelect | XCrawlerDataFieldExtract<T[K]>; };
40
+ export type XCrawlerEngine = 'headless' | 'headful' | 'request';
41
+ export type XCrawlerContentType = 'html' | 'json';
42
+ /**
43
+ * `XCrawlerNext.element` 选择函数回调的附加信息。
44
+ */
45
+ export interface XCrawlerElementExtra {
46
+ /**
47
+ * 当前元素在结果列表中的索引(从 0 开始)
48
+ */
49
+ index: number;
50
+ /**
51
+ * 当前页面的根元素
52
+ */
53
+ root: HTMLElement;
54
+ }
55
+ /**
56
+ * `XCrawlerNext.url` 提取函数的入参。
57
+ */
58
+ export interface XCrawlerUrlPayload<T extends XCrawlerData> {
59
+ /**
60
+ * 当前已收集的数据
61
+ */
62
+ data: T;
63
+ /**
64
+ * 当前页面元素
65
+ */
66
+ el: HTMLElement;
67
+ }
68
+ /**
69
+ * `XCrawlerNext.nextPageUrl` 分页函数的入参。
70
+ */
71
+ export interface XCrawlerNextPageUrlPayload<T extends XCrawlerData> {
72
+ /**
73
+ * 当前已收集的数据
74
+ */
75
+ data: T;
76
+ /**
77
+ * 当前页面元素
78
+ */
79
+ el: HTMLElement;
80
+ /**
81
+ * 当前页面网址
82
+ */
83
+ url: string;
84
+ }
85
+ export type XCrawlerNext<T extends XCrawlerData> = {
86
+ /**
87
+ * ID,可给其他 next 跳转以实现逻辑复用
88
+ */
89
+ id?: string;
90
+ /**
91
+ * 过滤
92
+ */
93
+ filter?: (data: T) => AsyncOrSync<boolean>;
94
+ /**
95
+ * 内容类型
96
+ */
97
+ contentType?: XCrawlerContentType;
98
+ /**
99
+ * 通过元素
100
+ */
101
+ element?: string | ((el: HTMLElement, extra: XCrawlerElementExtra) => AsyncOrSync<OneOrMore<HTMLElement>>);
102
+ /**
103
+ * 通过字段(json)
104
+ */
105
+ field?: string | ((json: any) => AsyncOrSync<any>);
106
+ /**
107
+ * 通过网址
108
+ */
109
+ url?: keyof T | ((payload: XCrawlerUrlPayload<T>) => AsyncOrSync<OneOrMore<string>>);
110
+ /**
111
+ * 下一页网址(有分页时用)
112
+ *
113
+ * 返回空字符串或跟当前页面网址相同的网址时将跳过
114
+ */
115
+ nextPageUrl?: (payload: XCrawlerNextPageUrlPayload<T>) => AsyncOrSync<string | undefined>;
116
+ /** @inner */
117
+ _url?: string[];
118
+ /**
119
+ * 抓取引擎
120
+ */
121
+ engine?: XCrawlerEngine;
122
+ engineOptions?: XPuppeteerGetPageHtmlOptions;
123
+ /**
124
+ * 数据提取
125
+ */
126
+ extract?: XCrawlerDataExtract<T>;
127
+ /**
128
+ * 下一步
129
+ */
130
+ next?: OneOrMore<string | XCrawlerNext<T>>;
131
+ };
132
+ export type XCrawlerConfig<T extends XCrawlerData> = {
133
+ /**
134
+ * 入口页链接
135
+ */
136
+ entry: string;
137
+ /**
138
+ * 使用代理
139
+ */
140
+ proxy?: string;
141
+ /**
142
+ * 内容类型
143
+ */
144
+ contentType?: XCrawlerContentType;
145
+ /**
146
+ * 抓取引擎
147
+ */
148
+ engine?: XCrawlerEngine;
149
+ engineOptions?: XPuppeteerGetPageHtmlOptions;
150
+ /**
151
+ * 数据格式
152
+ */
153
+ schema: (_: typeof v) => VaeObjectSchemaShapeOf<T>;
154
+ /**
155
+ * 数据提取
156
+ */
157
+ extract?: XCrawlerDataExtract<T>;
158
+ /**
159
+ * 下一步
160
+ */
161
+ next?: OneOrMore<XCrawlerNext<T>>;
162
+ /**
163
+ * 数据监听
164
+ */
165
+ listen?: (data: T) => any;
166
+ };
167
+ /**
168
+ * `XCrawler.next` 递归调用的参数列表。
169
+ *
170
+ * 提取为独立类型,便于在构建下一级参数与递归调用之间复用,
171
+ * 避免直接使用 `Parameters<XCrawler<T>['next']>` 这类不稳定写法。
172
+ */
173
+ export type XCrawlerNextParams<T extends XCrawlerData> = [
174
+ /**
175
+ * 当前页面网址
176
+ */
177
+ pageUrl: string,
178
+ /**
179
+ * 内容类型(html / json)
180
+ */
181
+ contentType: XCrawlerContentType,
182
+ /**
183
+ * 抓取引擎(headless / headful / request)
184
+ */
185
+ engine: XCrawlerEngine,
186
+ /**
187
+ * 当前处理对象:
188
+ * - html 时为页面元素(`HTMLElement`);
189
+ * - json 时为字段值(任意)。
190
+ */
191
+ elOrField: any,
192
+ /**
193
+ * 当前已收集的数据(可能尚未完整)
194
+ */
195
+ data: Partial<T>,
196
+ /**
197
+ * 当前步骤的下一步配置
198
+ */
199
+ next: XCrawlerNext<T>,
200
+ /**
201
+ * 当前元素在结果列表中的索引
202
+ */
203
+ index: number,
204
+ /**
205
+ * 入口页面的根元素(html 时有效)
206
+ */
207
+ rootElement: HTMLElement];
208
+ /**
209
+ * 网页/接口抓取器。
210
+ *
211
+ * 工作流程:
212
+ * 1. `start()` 按 `contentType` 抓取 `entry` 入口:
213
+ * - `html`:解析为 DOM 元素(`HTMLElement`);
214
+ * - `json`:解析为 JSON 值;
215
+ * 2. 依据 `config.next` 逐级深入:
216
+ * - `html`:通过 `element`(选择器/函数)在当前元素下选取一批子元素,
217
+ * 或通过 `url` 跳转到新页面并抓取;
218
+ * - `json`:通过 `field`(字段路径)或 `url` 选取一批字段值;
219
+ * 3. 对每个选中的元素/字段执行 `extract`,把结果写入数据对象;
220
+ * 4. 若仍有 `next`,继续递归处理;否则通过 `newData` 事件(即 `listen`
221
+ * 回调)逐条产出最终数据。
222
+ *
223
+ * 数据流说明:
224
+ * - 每一级递归都会 `cloneDeepFast` 复制上一级数据,避免相互污染;
225
+ * - 同名字段在数组 schema 下会按出现顺序累加,用于收集列表;
226
+ * - 抓取过程中相对网址都会基于当前页网址归一化为绝对网址。
227
+ */
228
+ export declare class XCrawler<T extends XCrawlerData> extends EventBus<{
229
+ newData: (data: T) => any;
230
+ }> {
231
+ private config;
232
+ /** 解析后的数据 schema 映射(字段名 → schema),用于对提取结果做 cast 校验。 */
233
+ private schemaMap;
234
+ /** 实例 ID,用于 headless/headful 引擎复用浏览器实例。 */
235
+ private id;
236
+ /**
237
+ * 创建抓取器。
238
+ *
239
+ * @param config 抓取配置,含入口、schema、提取规则与监听回调
240
+ */
241
+ constructor(config: XCrawlerConfig<T>);
242
+ /**
243
+ * 将相对/绝对网址归一化为绝对网址。
244
+ *
245
+ * 规则:
246
+ * - 去除首尾空白后为空时,返回空字符串;
247
+ * - 已是绝对网址(`isUrl`)时原样返回;
248
+ * - 相对路径以当前页网址为基准拼接:
249
+ * - 以 `.` 或 `/` 开头则直接拼接;
250
+ * - 否则补前导 `/` 再拼接(视为站点根相对路径)。
251
+ */
252
+ private normalizeUrl;
253
+ /**
254
+ * 使用无头浏览器抓取页面 HTML。
255
+ *
256
+ * 统一传入了 `proxy`、`blockAssets`、`instanceId` 等公共配置,
257
+ * 调用方传入的 `options` 会覆盖这些默认值。
258
+ */
259
+ private fetchUrlHeadless;
260
+ /**
261
+ * 使用有头浏览器抓取页面 HTML。
262
+ *
263
+ * 与 `fetchUrlHeadless` 仅 `headful` 取值不同,其余行为一致。
264
+ */
265
+ private fetchUrlHeadful;
266
+ /**
267
+ * 使用普通 HTTP 请求抓取页面文本。
268
+ *
269
+ * 适用场景:目标页面不需要执行 JS、无浏览器指纹校验等。
270
+ */
271
+ private fetchUrlRequest;
272
+ /**
273
+ * 按引擎抓取页面文本。
274
+ *
275
+ * 兜底说明:未知引擎会走 `request` 分支(三元表达式最后一项),
276
+ * 因此调用方传入非法引擎值不会抛错,而是按 request 处理。
277
+ */
278
+ private fetchUrl;
279
+ /**
280
+ * 抓取页面并解析为 DOM 元素(html 内容类型)。
281
+ */
282
+ private fetchUrlAsHTMLElement;
283
+ /**
284
+ * 抓取页面并解析为 JSON 值(json 内容类型)。
285
+ */
286
+ private fetchUrlAsJSON;
287
+ /**
288
+ * 将元素内所有图片识别为文本并替换到原位置。
289
+ *
290
+ * 仅处理带 `src` 的 `img`,识别结果通过 `escape` 转义后写入占位 `div`,
291
+ * 供后续 `getPlainText` 提取时包含 OCR 文本。
292
+ */
293
+ private ocr;
294
+ /**
295
+ * 按 ID 在 `next` 树中查找可复用的下一步配置。
296
+ *
297
+ * 结果被 `memoize` 缓存,同一 ID 只做一次深度遍历。
298
+ */
299
+ private findNextById;
300
+ /**
301
+ * 已访问网址缓存,用于去重,避免重复抓取同一页面。
302
+ */
303
+ private pageUrlCache;
304
+ /**
305
+ * 递归抓取核心方法。
306
+ *
307
+ * 主要分支:
308
+ * - `json` 内容类型:通过 `field` / `url` 取得下一级字段值,再执行
309
+ * `extract`(字符串 select 用 `get` 取路径,其余形式暂未实现,留 TODO);
310
+ * - `html` 内容类型:通过 `element` / `url` 取得下一级元素集合,再执行
311
+ * `extract`(支持 `select` 选择器/筛选器/函数,`extract` 导出方式,
312
+ * `ocr` 图片识别与 `transform` 转换)。
313
+ *
314
+ * 出口:
315
+ * - 无 `next.next` 时,当前 `newData` 即为一条最终数据,通过 `newData`
316
+ * 事件(`listen` 回调)对外抛出;
317
+ * - 有 `next.next` 时,逐个构建下一级参数并递归调用,随后处理
318
+ * `nextPageUrl` 分页。
319
+ */
320
+ next(pageUrl: string, contentType: XCrawlerContentType, engine: XCrawlerEngine, elOrField: any, data: Partial<T>, next: XCrawlerNext<T>, index: number, rootElement: HTMLElement): Promise<void>;
321
+ /**
322
+ * 启动抓取。
323
+ *
324
+ * - 默认 `contentType` 为 `'html'`、`engine` 为 `'request'`;
325
+ * - 抓取入口后开始逐级处理 `config.extract` 与 `config.next`;
326
+ * - 最终数据通过 `newData` 事件(`listen` 回调)逐条抛出,
327
+ * 本方法自身不返回数据,仅用于驱动整个抓取流程完成。
328
+ */
329
+ start(): Promise<void>;
330
+ }
@@ -0,0 +1,313 @@
1
+ import { XPuppeteer } from "./puppeteer.mjs";
2
+ import { xr } from "@jayfong/x-request";
3
+ import { EventBus, castArray, cloneDeepFast, escape, get, isRegExp, isUrl, keysStrict, memoize, omitStrict } from "vtils";
4
+ import { parse } from "node-html-parser";
5
+ import { v } from "vtils/vae";
6
+ // src/crawler.ts
7
+ /**
8
+ * 网页/接口抓取器。
9
+ *
10
+ * 工作流程:
11
+ * 1. `start()` 按 `contentType` 抓取 `entry` 入口:
12
+ * - `html`:解析为 DOM 元素(`HTMLElement`);
13
+ * - `json`:解析为 JSON 值;
14
+ * 2. 依据 `config.next` 逐级深入:
15
+ * - `html`:通过 `element`(选择器/函数)在当前元素下选取一批子元素,
16
+ * 或通过 `url` 跳转到新页面并抓取;
17
+ * - `json`:通过 `field`(字段路径)或 `url` 选取一批字段值;
18
+ * 3. 对每个选中的元素/字段执行 `extract`,把结果写入数据对象;
19
+ * 4. 若仍有 `next`,继续递归处理;否则通过 `newData` 事件(即 `listen`
20
+ * 回调)逐条产出最终数据。
21
+ *
22
+ * 数据流说明:
23
+ * - 每一级递归都会 `cloneDeepFast` 复制上一级数据,避免相互污染;
24
+ * - 同名字段在数组 schema 下会按出现顺序累加,用于收集列表;
25
+ * - 抓取过程中相对网址都会基于当前页网址归一化为绝对网址。
26
+ */
27
+ var XCrawler = class extends EventBus {
28
+ config;
29
+ /** 解析后的数据 schema 映射(字段名 → schema),用于对提取结果做 cast 校验。 */
30
+ schemaMap;
31
+ /** 实例 ID,用于 headless/headful 引擎复用浏览器实例。 */
32
+ id = Date.now().toString(36);
33
+ /**
34
+ * 创建抓取器。
35
+ *
36
+ * @param config 抓取配置,含入口、schema、提取规则与监听回调
37
+ */
38
+ constructor(config) {
39
+ super();
40
+ this.config = config;
41
+ this.schemaMap = config.schema(v);
42
+ if (config.listen) this.on("newData", (newData) => {
43
+ config.listen(newData);
44
+ });
45
+ }
46
+ /**
47
+ * 将相对/绝对网址归一化为绝对网址。
48
+ *
49
+ * 规则:
50
+ * - 去除首尾空白后为空时,返回空字符串;
51
+ * - 已是绝对网址(`isUrl`)时原样返回;
52
+ * - 相对路径以当前页网址为基准拼接:
53
+ * - 以 `.` 或 `/` 开头则直接拼接;
54
+ * - 否则补前导 `/` 再拼接(视为站点根相对路径)。
55
+ */
56
+ normalizeUrl(pageUrl, url) {
57
+ url = url.trim();
58
+ return !url ? "" : isUrl(url) ? url : new URL(url.startsWith(".") || url.startsWith("/") ? url : `/${url}`, pageUrl).toString();
59
+ }
60
+ /**
61
+ * 使用无头浏览器抓取页面 HTML。
62
+ *
63
+ * 统一传入了 `proxy`、`blockAssets`、`instanceId` 等公共配置,
64
+ * 调用方传入的 `options` 会覆盖这些默认值。
65
+ */
66
+ async fetchUrlHeadless(pageUrl, url, options) {
67
+ return await XPuppeteer.getPageHtml(this.normalizeUrl(pageUrl, url), {
68
+ headful: false,
69
+ proxy: this.config.proxy,
70
+ blockAssets: true,
71
+ instanceId: this.id,
72
+ ...options
73
+ });
74
+ }
75
+ /**
76
+ * 使用有头浏览器抓取页面 HTML。
77
+ *
78
+ * 与 `fetchUrlHeadless` 仅 `headful` 取值不同,其余行为一致。
79
+ */
80
+ async fetchUrlHeadful(pageUrl, url, options) {
81
+ return await XPuppeteer.getPageHtml(this.normalizeUrl(pageUrl, url), {
82
+ headful: true,
83
+ proxy: this.config.proxy,
84
+ blockAssets: true,
85
+ instanceId: this.id,
86
+ ...options
87
+ });
88
+ }
89
+ /**
90
+ * 使用普通 HTTP 请求抓取页面文本。
91
+ *
92
+ * 适用场景:目标页面不需要执行 JS、无浏览器指纹校验等。
93
+ */
94
+ async fetchUrlRequest(pageUrl, url) {
95
+ const absoluteUrl = this.normalizeUrl(pageUrl, url);
96
+ const { data: text } = await xr.getText({
97
+ url: absoluteUrl,
98
+ autoReferer: true,
99
+ proxyUrl: this.config.proxy
100
+ });
101
+ return text;
102
+ }
103
+ /**
104
+ * 按引擎抓取页面文本。
105
+ *
106
+ * 兜底说明:未知引擎会走 `request` 分支(三元表达式最后一项),
107
+ * 因此调用方传入非法引擎值不会抛错,而是按 request 处理。
108
+ */
109
+ async fetchUrl(pageUrl, url, engine, options) {
110
+ return engine === "headless" ? await this.fetchUrlHeadless(pageUrl, url, options) : engine === "headful" ? await this.fetchUrlHeadful(pageUrl, url, options) : await this.fetchUrlRequest(pageUrl, url);
111
+ }
112
+ /**
113
+ * 抓取页面并解析为 DOM 元素(html 内容类型)。
114
+ */
115
+ async fetchUrlAsHTMLElement(pageUrl, url, engine, options) {
116
+ const text = await this.fetchUrl(pageUrl, url, engine, options);
117
+ return parse(text);
118
+ }
119
+ /**
120
+ * 抓取页面并解析为 JSON 值(json 内容类型)。
121
+ */
122
+ async fetchUrlAsJSON(pageUrl, url, engine) {
123
+ const text = await this.fetchUrl(pageUrl, url, engine);
124
+ return JSON.parse(text);
125
+ }
126
+ /**
127
+ * 将元素内所有图片识别为文本并替换到原位置。
128
+ *
129
+ * 仅处理带 `src` 的 `img`,识别结果通过 `escape` 转义后写入占位 `div`,
130
+ * 供后续 `getPlainText` 提取时包含 OCR 文本。
131
+ */
132
+ async ocr(pageUrl, el, ocr) {
133
+ const imgDocs = el.querySelectorAll("img");
134
+ for (const imgDoc of imgDocs) {
135
+ const imgUrl = imgDoc.getAttribute("src");
136
+ if (imgUrl) {
137
+ const ocrText = await ocr(this.normalizeUrl(pageUrl, imgUrl));
138
+ imgDoc.replaceWith(`<div>${escape(ocrText)} </div>`);
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * 按 ID 在 `next` 树中查找可复用的下一步配置。
144
+ *
145
+ * 结果被 `memoize` 缓存,同一 ID 只做一次深度遍历。
146
+ */
147
+ findNextById = memoize((id) => {
148
+ const findNextById = (nexts) => {
149
+ for (const next of castArray(nexts)) if (typeof next !== "string") {
150
+ if (next.id === id) return next;
151
+ if (next.next) {
152
+ const _next = findNextById(next.next);
153
+ if (_next) return _next;
154
+ }
155
+ }
156
+ };
157
+ return this.config.next && findNextById(this.config.next);
158
+ });
159
+ /**
160
+ * 已访问网址缓存,用于去重,避免重复抓取同一页面。
161
+ */
162
+ pageUrlCache = /* @__PURE__ */ new Set();
163
+ /**
164
+ * 递归抓取核心方法。
165
+ *
166
+ * 主要分支:
167
+ * - `json` 内容类型:通过 `field` / `url` 取得下一级字段值,再执行
168
+ * `extract`(字符串 select 用 `get` 取路径,其余形式暂未实现,留 TODO);
169
+ * - `html` 内容类型:通过 `element` / `url` 取得下一级元素集合,再执行
170
+ * `extract`(支持 `select` 选择器/筛选器/函数,`extract` 导出方式,
171
+ * `ocr` 图片识别与 `transform` 转换)。
172
+ *
173
+ * 出口:
174
+ * - 无 `next.next` 时,当前 `newData` 即为一条最终数据,通过 `newData`
175
+ * 事件(`listen` 回调)对外抛出;
176
+ * - 有 `next.next` 时,逐个构建下一级参数并递归调用,随后处理
177
+ * `nextPageUrl` 分页。
178
+ */
179
+ async next(pageUrl, contentType, engine, elOrField, data, next, index, rootElement) {
180
+ if (next.filter && !await next.filter(data)) return;
181
+ contentType = next.contentType || contentType;
182
+ engine = next.engine || engine;
183
+ if (contentType === "json") {
184
+ const field = elOrField;
185
+ const nextFields = next.url ? await Promise.all(castArray(typeof next.url === "function" ? await next.url(data) : data[next.url]).map((url) => this.fetchUrlAsJSON(pageUrl, url, engine))) : !next.field || next.field === "." ? [field] : typeof next.field === "string" ? get(field, next.field) : castArray(await next.field(field));
186
+ for (const nextField of nextFields) {
187
+ const newData = cloneDeepFast(data);
188
+ if (next.extract) {
189
+ const fieldKeys = keysStrict(next.extract);
190
+ for (const fieldKey of fieldKeys) {
191
+ const fieldConfig = typeof next.extract[fieldKey] === "string" || typeof next.extract[fieldKey] === "function" || Array.isArray(next.extract[fieldKey]) ? { select: next.extract[fieldKey] } : next.extract[fieldKey];
192
+ let fieldValue = !fieldConfig.select || fieldConfig.select === "." ? nextField : typeof fieldConfig.select === "string" ? get(nextField, fieldConfig.select) : Array.isArray(fieldConfig.select) ? "TODO" : "TODO";
193
+ if (fieldValue != null) {
194
+ fieldValue = fieldConfig.transform ? await fieldConfig.transform(fieldValue) : fieldValue;
195
+ fieldValue = this.schemaMap[fieldKey].cast(fieldValue);
196
+ newData[fieldKey] = fieldValue;
197
+ }
198
+ }
199
+ }
200
+ if (next.next) for (const nextItem of castArray(next.next)) await this.next(pageUrl, contentType, engine, nextField, newData, typeof nextItem === "string" ? this.findNextById(nextItem) : nextItem, 0, rootElement);
201
+ else this.emit("newData", newData);
202
+ }
203
+ } else {
204
+ const el = elOrField;
205
+ const nextEls = next.url ? await Promise.all(next._url.map((fullUrl) => this.fetchUrlAsHTMLElement(pageUrl, fullUrl, engine, next.engineOptions))) : !next.element || next.element === "." ? [el] : typeof next.element === "string" ? el.querySelectorAll(next.element) : castArray(await next.element(el, {
206
+ index,
207
+ root: rootElement
208
+ }));
209
+ const nextParamsList = [];
210
+ let nextElIndex = 0;
211
+ for (const nextEl of nextEls) {
212
+ const newData = cloneDeepFast(data);
213
+ if (next.extract) {
214
+ const fieldKeys = keysStrict(next.extract);
215
+ for (const fieldKey of fieldKeys) {
216
+ const fieldConfig = typeof next.extract[fieldKey] === "string" || typeof next.extract[fieldKey] === "function" || Array.isArray(next.extract[fieldKey]) ? { select: next.extract[fieldKey] } : next.extract[fieldKey];
217
+ const fieldEl = !fieldConfig.select || fieldConfig.select === "." ? nextEl : typeof fieldConfig.select === "string" ? nextEl.querySelector(fieldConfig.select) : Array.isArray(fieldConfig.select) ? nextEl.querySelectorAll(fieldConfig.select[0]).find((item) => {
218
+ const filter = fieldConfig.select[1];
219
+ return typeof filter === "string" ? item.text.includes(filter) : isRegExp(filter) ? filter.test(item.text) : filter(item);
220
+ }) : await fieldConfig.select(nextEl);
221
+ if (fieldEl != null) {
222
+ let fieldText = "";
223
+ if (typeof fieldEl === "string") fieldText = fieldEl.trim();
224
+ else {
225
+ if (fieldConfig.ocr) await this.ocr(pageUrl, fieldEl, fieldConfig.ocr);
226
+ switch (fieldConfig.extract) {
227
+ case "getHTML":
228
+ fieldText = fieldEl.outerHTML;
229
+ break;
230
+ case "getFirstImageUrl":
231
+ fieldText = this.normalizeUrl(pageUrl, fieldEl.getAttribute("src") || fieldEl.querySelector("img")?.getAttribute("src") || "");
232
+ break;
233
+ case "getFirstLinkUrl":
234
+ fieldText = this.normalizeUrl(pageUrl, fieldEl.getAttribute("href") || fieldEl.querySelector("a")?.getAttribute("href") || "");
235
+ break;
236
+ default: fieldText = fieldEl.text.trim();
237
+ }
238
+ }
239
+ let fieldValue = fieldConfig.transform ? await fieldConfig.transform(fieldText) : fieldText;
240
+ const fieldSchema = this.schemaMap[fieldKey];
241
+ if (fieldSchema.options.type !== "array") fieldValue = fieldSchema.cast(fieldValue);
242
+ else fieldValue = fieldSchema.cast([...newData[fieldKey] || [], fieldValue]);
243
+ newData[fieldKey] = fieldValue;
244
+ }
245
+ }
246
+ }
247
+ if (next.next) {
248
+ const nexts = await Promise.all(castArray(next.next).map(async (nextItem) => {
249
+ nextItem = cloneDeepFast(typeof nextItem === "string" ? this.findNextById(nextItem) : nextItem);
250
+ if (nextItem.url) nextItem._url = castArray(typeof nextItem.url === "function" ? await nextItem.url({
251
+ data: newData,
252
+ el: nextEl
253
+ }) : newData[nextItem.url]).map((url) => this.normalizeUrl(pageUrl, url)).filter((fullUrl) => !this.pageUrlCache.has(fullUrl)).map((fullUrl) => {
254
+ this.pageUrlCache.add(fullUrl);
255
+ return fullUrl;
256
+ });
257
+ return nextItem;
258
+ }));
259
+ let i = 0;
260
+ for (const nextItem of nexts) {
261
+ nextParamsList.push([
262
+ nextItem.url && nextItem._url[i] || pageUrl,
263
+ contentType,
264
+ engine,
265
+ nextEl,
266
+ newData,
267
+ nextItem,
268
+ nextElIndex,
269
+ rootElement
270
+ ]);
271
+ i++;
272
+ }
273
+ } else this.emit("newData", newData);
274
+ nextElIndex++;
275
+ }
276
+ for (const nextParams of nextParamsList) {
277
+ await this.next(...nextParams);
278
+ if (next.nextPageUrl) {
279
+ const nextPageUrl = await next.nextPageUrl({
280
+ data: nextParams[4],
281
+ el: nextParams[3],
282
+ url: nextParams[0]
283
+ });
284
+ if (nextPageUrl) {
285
+ const nextPageAbsoluteUrl = this.normalizeUrl(nextParams[0], nextPageUrl);
286
+ if (nextPageAbsoluteUrl !== nextParams[0]) {
287
+ const nextPageElement = await this.fetchUrlAsHTMLElement(nextPageAbsoluteUrl, nextPageAbsoluteUrl, engine, this.config.engineOptions);
288
+ await this.next(nextPageAbsoluteUrl, contentType, engine, nextPageElement, nextParams[4], omitStrict(next, ["url"]), index, rootElement);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ /**
296
+ * 启动抓取。
297
+ *
298
+ * - 默认 `contentType` 为 `'html'`、`engine` 为 `'request'`;
299
+ * - 抓取入口后开始逐级处理 `config.extract` 与 `config.next`;
300
+ * - 最终数据通过 `newData` 事件(`listen` 回调)逐条抛出,
301
+ * 本方法自身不返回数据,仅用于驱动整个抓取流程完成。
302
+ */
303
+ async start() {
304
+ const initialContentType = this.config.contentType || "html";
305
+ const initialEngine = this.config.engine || "request";
306
+ const rootElement = initialContentType === "json" ? await this.fetchUrlAsJSON(this.config.entry, this.config.entry, initialEngine) : await this.fetchUrlAsHTMLElement(this.config.entry, this.config.entry, initialEngine, this.config.engineOptions);
307
+ await this.next(this.config.entry, initialContentType, initialEngine, rootElement, {}, {
308
+ extract: this.config.extract,
309
+ next: this.config.next
310
+ }, 0, rootElement);
311
+ }
312
+ };
313
+ export { XCrawler };