@jayfong/x-utils 1.0.0-202510061015 → 3.3.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 (113) hide show
  1. package/lib/case.d.mts +184 -0
  2. package/lib/case.mjs +120 -0
  3. package/lib/chinese.d.mts +39 -0
  4. package/lib/chinese.mjs +52 -0
  5. package/lib/cli.d.mts +64 -0
  6. package/lib/cli.mjs +59 -0
  7. package/lib/clipboard.d.mts +50 -0
  8. package/lib/clipboard.mjs +122 -0
  9. package/lib/cmd.d.mts +38 -0
  10. package/lib/cmd.mjs +52 -0
  11. package/lib/crawler.d.mts +330 -0
  12. package/lib/crawler.mjs +313 -0
  13. package/lib/csv.d.mts +67 -0
  14. package/lib/csv.mjs +76 -0
  15. package/lib/data_set.d.mts +76 -0
  16. package/lib/data_set.mjs +104 -0
  17. package/lib/fs.d.mts +49 -0
  18. package/lib/fs.mjs +65 -0
  19. package/lib/json.d.mts +50 -0
  20. package/lib/json.mjs +74 -0
  21. package/lib/puppeteer.d.mts +88 -0
  22. package/lib/puppeteer.mjs +219 -0
  23. package/lib/runner.d.mts +52 -0
  24. package/lib/runner.mjs +50 -0
  25. package/lib/task.d.mts +37 -0
  26. package/lib/task.mjs +43 -0
  27. package/lib/text.d.mts +84 -0
  28. package/lib/text.mjs +141 -0
  29. package/lib/yaml.d.mts +51 -0
  30. package/lib/yaml.mjs +64 -0
  31. package/package.json +50 -164
  32. package/_cjs/ai.js +0 -80
  33. package/_cjs/alipan.js +0 -173
  34. package/_cjs/cli.js +0 -51
  35. package/_cjs/clipboard.js +0 -65
  36. package/_cjs/cmd.js +0 -42
  37. package/_cjs/compressor.js +0 -47
  38. package/_cjs/crawler.js +0 -341
  39. package/_cjs/csv.js +0 -97
  40. package/_cjs/data_set.js +0 -115
  41. package/_cjs/env.js +0 -82
  42. package/_cjs/fs.js +0 -86
  43. package/_cjs/html.js +0 -62
  44. package/_cjs/http_server.js +0 -79
  45. package/_cjs/json.js +0 -49
  46. package/_cjs/ksb.js +0 -218
  47. package/_cjs/number.js +0 -39
  48. package/_cjs/ocr.js +0 -74
  49. package/_cjs/package.json +0 -3
  50. package/_cjs/path.js +0 -35
  51. package/_cjs/pinyin.js +0 -37
  52. package/_cjs/puppeteer.js +0 -271
  53. package/_cjs/runner.js +0 -88
  54. package/_cjs/task.js +0 -41
  55. package/_cjs/text.js +0 -136
  56. package/_cjs/unicode/lib_unicode.js +0 -58643
  57. package/_cjs/unicode/unicode.js +0 -441
  58. package/_cjs/upload.js +0 -101
  59. package/_cjs/yaml.js +0 -52
  60. package/ai.d.ts +0 -26
  61. package/ai.js +0 -60
  62. package/alipan.d.ts +0 -209
  63. package/alipan.js +0 -153
  64. package/cli.d.ts +0 -14
  65. package/cli.js +0 -21
  66. package/clipboard.d.ts +0 -6
  67. package/clipboard.js +0 -35
  68. package/cmd.d.ts +0 -5
  69. package/cmd.js +0 -22
  70. package/compressor.d.ts +0 -5
  71. package/compressor.js +0 -17
  72. package/crawler.d.ts +0 -162
  73. package/crawler.js +0 -332
  74. package/csv.d.ts +0 -14
  75. package/csv.js +0 -67
  76. package/data_set.d.ts +0 -22
  77. package/data_set.js +0 -85
  78. package/env.d.ts +0 -6
  79. package/env.js +0 -52
  80. package/fs.d.ts +0 -23
  81. package/fs.js +0 -56
  82. package/html.d.ts +0 -7
  83. package/html.js +0 -45
  84. package/http_server.d.ts +0 -13
  85. package/http_server.js +0 -49
  86. package/json.d.ts +0 -6
  87. package/json.js +0 -19
  88. package/ksb.d.ts +0 -37
  89. package/ksb.js +0 -198
  90. package/number.d.ts +0 -3
  91. package/number.js +0 -9
  92. package/ocr.d.ts +0 -17
  93. package/ocr.js +0 -54
  94. package/path.d.ts +0 -2
  95. package/path.js +0 -5
  96. package/pinyin.d.ts +0 -8
  97. package/pinyin.js +0 -17
  98. package/puppeteer.d.ts +0 -38
  99. package/puppeteer.js +0 -241
  100. package/runner.d.ts +0 -13
  101. package/runner.js +0 -58
  102. package/task.d.ts +0 -6
  103. package/task.js +0 -21
  104. package/text.d.ts +0 -63
  105. package/text.js +0 -119
  106. package/unicode/lib_unicode.d.ts +0 -4
  107. package/unicode/lib_unicode.js +0 -58623
  108. package/unicode/unicode.d.ts +0 -347
  109. package/unicode/unicode.js +0 -426
  110. package/upload.d.ts +0 -34
  111. package/upload.js +0 -71
  112. package/yaml.d.ts +0 -6
  113. package/yaml.js +0 -22
@@ -0,0 +1,313 @@
1
+ import { XPuppeteer } from "./puppeteer.mjs";
2
+ import { xr } from "@jayfong/x-request";
3
+ import { parse } from "node-html-parser";
4
+ import { EventBus, castArray, cloneDeepFast, escape, get, isRegExp, isUrl, keysStrict, memoize, omitStrict } from "vtils";
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 };
package/lib/csv.d.mts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * CSV 解析/序列化的配置项。
3
+ *
4
+ * 这些字段用于在 CSV 与对象之间做更强的类型转换:
5
+ * - `arrayFieldNames`:按分隔符拆分/组装数组字段;
6
+ * - `objectFieldNames`:将 JSON 字符串恢复为对象,或写出为字符串;
7
+ * - `booleanFieldNames`:将 `1/0` 这类值恢复为布尔值;
8
+ * - `dateFieldNames`:将日期字符串转换为 `Date` 对象,写出时按指定格式格式化。
9
+ */
10
+ export interface XCsvParseOptions {
11
+ /** 需要按 `arrayDelimiter` 拆分为数组的字段名列表。 */
12
+ arrayFieldNames?: string[];
13
+ /** 数组字段的分隔符;仅在 `arrayFieldNames` 生效时使用。 */
14
+ arrayDelimiter?: string;
15
+ /** 需要 JSON 解析为对象的字段名列表。 */
16
+ objectFieldNames?: string[];
17
+ /** 需要转换为布尔值的字段名列表;CSV 中常见 `1/0` 形式。 */
18
+ booleanFieldNames?: string[];
19
+ /** 需要转换为 `Date` 的字段名列表。 */
20
+ dateFieldNames?: string[];
21
+ }
22
+ /**
23
+ * CSV 序列化配置与解析配置相同,因此直接复用同一组字段定义。
24
+ */
25
+ export interface XCsvStringifyOptions extends XCsvParseOptions {}
26
+ /**
27
+ * CSV 数据的通用读写工具。
28
+ *
29
+ * 设计目标:
30
+ * - 让对象字段在 CSV 与 JavaScript/TypeScript 之间可互转;
31
+ * - 在常见场景中自动处理数组、对象、布尔值和日期;
32
+ * - 提供与文件系统统一的 `read` / `write` 能力,方便配置和报表场景使用。
33
+ */
34
+ export declare class XCsv {
35
+ /**
36
+ * 解析 CSV 文本为对象数组。
37
+ *
38
+ * 当启用 `arrayFieldNames` / `objectFieldNames` / `booleanFieldNames` /
39
+ * `dateFieldNames` 这些选项时,会对相应字段执行类型转换;否则直接返回
40
+ * 原始解析结果。
41
+ */
42
+ static parse<T>(value: string, options?: XCsvParseOptions): Promise<T[]>;
43
+ /**
44
+ * 将对象数组序列化为 CSV 文本。
45
+ *
46
+ * 对于数组、对象、布尔和日期字段,会在输出前做统一转换:
47
+ * - 数组字段以 `arrayDelimiter` 拼接;
48
+ * - 对象字段使用 `JSON.stringify`;
49
+ * - 布尔字段输出 `1/0`;
50
+ * - 日期字段按 `yyyy-mm-dd hh:ii:ss` 输出。
51
+ */
52
+ static stringify(value: any[], options?: XCsvStringifyOptions): Promise<string>;
53
+ /**
54
+ * 读取 CSV 文件并立即解析为对象数组。
55
+ *
56
+ * 传入的 `options` 会应用到 `parse` 中,因此开发者可以用同一套规则在
57
+ * 文件读取和内存对象转换两种场景中保持行为一致。
58
+ */
59
+ static read<T>(file: string, options?: XCsvParseOptions): Promise<T[]>;
60
+ /**
61
+ * 将对象数组写入 CSV 文件。
62
+ *
63
+ * 这里会先走 `stringify`,再将结果写到目标文件。调用方可传入相同的
64
+ * `XCsvStringifyOptions` 以控制数组、对象、布尔和日期字段输出格式。
65
+ */
66
+ static write(file: string, value: any, options?: XCsvStringifyOptions): Promise<void>;
67
+ }
package/lib/csv.mjs ADDED
@@ -0,0 +1,76 @@
1
+ import { mapValues } from "vtils";
2
+ import * as fs from "node:fs";
3
+ import * as csv from "csv";
4
+ import { formatDate } from "vtils/date";
5
+ // src/csv.ts
6
+ /**
7
+ * CSV 数据的通用读写工具。
8
+ *
9
+ * 设计目标:
10
+ * - 让对象字段在 CSV 与 JavaScript/TypeScript 之间可互转;
11
+ * - 在常见场景中自动处理数组、对象、布尔值和日期;
12
+ * - 提供与文件系统统一的 `read` / `write` 能力,方便配置和报表场景使用。
13
+ */
14
+ var XCsv = class XCsv {
15
+ /**
16
+ * 解析 CSV 文本为对象数组。
17
+ *
18
+ * 当启用 `arrayFieldNames` / `objectFieldNames` / `booleanFieldNames` /
19
+ * `dateFieldNames` 这些选项时,会对相应字段执行类型转换;否则直接返回
20
+ * 原始解析结果。
21
+ */
22
+ static async parse(value, options = {}) {
23
+ const res = [];
24
+ for await (const item of csv.parse(value, { columns: true })) res.push(options.objectFieldNames?.length || options.booleanFieldNames?.length || options.dateFieldNames?.length || options.arrayFieldNames?.length && options.arrayDelimiter ? mapValues(item, (v, k) => {
25
+ if (options.arrayFieldNames.includes(k)) return v.split(options.arrayDelimiter).filter(Boolean);
26
+ if (options.objectFieldNames?.includes(k)) return v ? JSON.parse(v) : {};
27
+ if (options.booleanFieldNames?.includes(k)) {
28
+ if (v === "" || v == null) return;
29
+ return v === true || v === "1" || v === 1 || v === "true";
30
+ }
31
+ if (options.dateFieldNames?.includes(k)) return v ? new Date(v) : void 0;
32
+ return v;
33
+ }) : item);
34
+ return res;
35
+ }
36
+ /**
37
+ * 将对象数组序列化为 CSV 文本。
38
+ *
39
+ * 对于数组、对象、布尔和日期字段,会在输出前做统一转换:
40
+ * - 数组字段以 `arrayDelimiter` 拼接;
41
+ * - 对象字段使用 `JSON.stringify`;
42
+ * - 布尔字段输出 `1/0`;
43
+ * - 日期字段按 `yyyy-mm-dd hh:ii:ss` 输出。
44
+ */
45
+ static async stringify(value, options = {}) {
46
+ let res = "";
47
+ for await (const line of csv.stringify(options.objectFieldNames?.length || options.booleanFieldNames?.length || options.dateFieldNames?.length || options.arrayFieldNames?.length && options.arrayDelimiter ? value.map((item) => mapValues(item, (v, k) => {
48
+ if (options.arrayFieldNames.includes(k)) return v ? v.join(options.arrayDelimiter) : "";
49
+ if (options.objectFieldNames?.includes(k)) return JSON.stringify(v || {});
50
+ if (options.booleanFieldNames?.includes(k)) return v ? "1" : "0";
51
+ if (options.dateFieldNames?.includes(k)) return v ? formatDate(v, "yyyy-mm-dd hh:ii:ss") : v;
52
+ return v;
53
+ })) : value, { header: true })) res += line;
54
+ return res;
55
+ }
56
+ /**
57
+ * 读取 CSV 文件并立即解析为对象数组。
58
+ *
59
+ * 传入的 `options` 会应用到 `parse` 中,因此开发者可以用同一套规则在
60
+ * 文件读取和内存对象转换两种场景中保持行为一致。
61
+ */
62
+ static async read(file, options = {}) {
63
+ const value = await fs.promises.readFile(file, "utf8");
64
+ return XCsv.parse(value, options);
65
+ }
66
+ /**
67
+ * 将对象数组写入 CSV 文件。
68
+ *
69
+ * 这里会先走 `stringify`,再将结果写到目标文件。调用方可传入相同的
70
+ * `XCsvStringifyOptions` 以控制数组、对象、布尔和日期字段输出格式。
71
+ */
72
+ static async write(file, value, options = {}) {
73
+ await fs.promises.writeFile(file, await XCsv.stringify(value, options));
74
+ }
75
+ };
76
+ export { XCsv };
@@ -0,0 +1,76 @@
1
+ import { VaeObjectSchemaShapeOf, v } from "vtils/vae";
2
+ import { OmitStrict } from "vtils/types";
3
+ /**
4
+ * 基于本地文件(JSON / YAML / CSV)的轻量数据集存储。
5
+ *
6
+ * `XDataSet` 将一组可通过 vae 校验的对象持久化为单个文件,并提供以下能力:
7
+ * - 以 `dataKey` 字段作为主键,内存中以 `Map` 形式维护记录,便于快速定位与去重;
8
+ * - 通过 `dataSchema` 在读写时对数据做类型转换与校验;
9
+ * - 根据文件扩展名自动选择 JSON、YAML 或 CSV 读写方式;
10
+ * - 可选地按固定间隔自动落盘,并在进程退出前做最后一次写入。
11
+ */
12
+ export interface XDataSetOptions<T extends Record<string, any>, K extends keyof T> {
13
+ /** 数据文件路径;扩展名决定存储格式:`.csv`、`.yaml`/`.yml`,其余按 JSON 处理。 */
14
+ storeFile: string;
15
+ /** 记录结构的 vae schema,入参为 `v`,返回各字段 schema;用于读写时的类型转换与校验。 */
16
+ dataSchema: (_: typeof v) => VaeObjectSchemaShapeOf<T>;
17
+ /** 作为记录主键的字段名,`currentMap` 以该字段值作为键。 */
18
+ dataKey: K;
19
+ /** CSV 存储时数组字段的分隔符。 */
20
+ csvArrayDelimiter?: string;
21
+ /** 自动写入间隔(毫秒),仅在未关闭自动写入时生效。 @default 5000 */
22
+ autoWriteInterval?: number;
23
+ /** 是否关闭自动写入。 @default false */
24
+ noAutoWrite?: boolean;
25
+ }
26
+ /**
27
+ * 数据集实例,负责将记录数组映射到 `storeFile` 并做类型转换。
28
+ *
29
+ * @template T 数据集中的单条记录类型。
30
+ * @template K `T` 中作为主键的字段名。
31
+ */
32
+ export declare class XDataSet<T extends Record<string, any>, K extends keyof T> {
33
+ private options;
34
+ /** 存储格式,由 `storeFile` 扩展名推导得出。 */
35
+ private storeType;
36
+ /** 记录结构的 vae schema 形状(字段名 → 字段 schema)。 */
37
+ private dataShapeSchema;
38
+ /** 由 `dataShapeSchema` 组合得到的对象 schema,用于整体转换与校验。 */
39
+ private dataSchema;
40
+ /** 根据 schema 推导出的 CSV 解析/序列化配置。 */
41
+ private csvOptions;
42
+ /** 以 `dataKey` 字段值为键的记录映射,值为去掉主键后的记录。 */
43
+ currentMap: Record<T[K], OmitStrict<T, K>>;
44
+ /**
45
+ * 将 `currentMap` 还原为记录数组。
46
+ *
47
+ * 每条记录会按 schema 定义的字段顺序重组,并执行一次 schema 转换,
48
+ * 保证返回结构与 `dataSchema` 声明一致。
49
+ */
50
+ get current(): T[];
51
+ /**
52
+ * @param options 数据集配置,详见 {@link XDataSetOptions}。
53
+ *
54
+ * 构造函数会先根据 `storeFile` 扩展名确定存储格式,再基于 `dataSchema`
55
+ * 生成 CSV 所需的字段类型转换配置。若未设置 `noAutoWrite`,则启动定时器
56
+ * 周期性调用 {@link write},并在进程退出时做最后一次写入。
57
+ */
58
+ constructor(options: XDataSetOptions<T, K>);
59
+ /**
60
+ * 从 `storeFile` 读取数据集。
61
+ *
62
+ * - 文件不存在或读取/解析失败时返回空数组(不会抛错);
63
+ * - 读取成功后对每条记录执行 schema 转换,并重建 `currentMap`。
64
+ *
65
+ * @returns 读取并转换后的记录数组。
66
+ */
67
+ read(): Promise<T[]>;
68
+ /**
69
+ * 将数据集写入 `storeFile`。
70
+ *
71
+ * 写入前会确保父目录存在;未显式传入 `dataSet` 时使用 `current` 作为数据源。
72
+ *
73
+ * @param dataSet 要写入的记录数组,默认取 `current`。
74
+ */
75
+ write(dataSet?: T[]): Promise<void>;
76
+ }
@@ -0,0 +1,104 @@
1
+ import { XCsv } from "./csv.mjs";
2
+ import { XJson } from "./json.mjs";
3
+ import { XYaml } from "./yaml.mjs";
4
+ import { keyBy, keysStrict, pickStrict } from "vtils";
5
+ import { v } from "vtils/vae";
6
+ import { asyncExitHook } from "exit-hook";
7
+ import * as path from "node:path";
8
+ import fs from "fs-extra";
9
+ // src/data_set.ts
10
+ /**
11
+ * 数据集实例,负责将记录数组映射到 `storeFile` 并做类型转换。
12
+ *
13
+ * @template T 数据集中的单条记录类型。
14
+ * @template K `T` 中作为主键的字段名。
15
+ */
16
+ var XDataSet = class {
17
+ options;
18
+ /** 存储格式,由 `storeFile` 扩展名推导得出。 */
19
+ storeType;
20
+ /** 记录结构的 vae schema 形状(字段名 → 字段 schema)。 */
21
+ dataShapeSchema;
22
+ /** 由 `dataShapeSchema` 组合得到的对象 schema,用于整体转换与校验。 */
23
+ dataSchema;
24
+ /** 根据 schema 推导出的 CSV 解析/序列化配置。 */
25
+ csvOptions;
26
+ /** 以 `dataKey` 字段值为键的记录映射,值为去掉主键后的记录。 */
27
+ currentMap;
28
+ /**
29
+ * 将 `currentMap` 还原为记录数组。
30
+ *
31
+ * 每条记录会按 schema 定义的字段顺序重组,并执行一次 schema 转换,
32
+ * 保证返回结构与 `dataSchema` 声明一致。
33
+ */
34
+ get current() {
35
+ const orderedDataFieldNames = keysStrict(this.dataShapeSchema);
36
+ return keysStrict(this.currentMap).map((dataKeyValue) => {
37
+ const data = {
38
+ ...this.currentMap[dataKeyValue],
39
+ [this.options.dataKey]: dataKeyValue
40
+ };
41
+ return this.dataSchema.cast(pickStrict(data, orderedDataFieldNames));
42
+ });
43
+ }
44
+ /**
45
+ * @param options 数据集配置,详见 {@link XDataSetOptions}。
46
+ *
47
+ * 构造函数会先根据 `storeFile` 扩展名确定存储格式,再基于 `dataSchema`
48
+ * 生成 CSV 所需的字段类型转换配置。若未设置 `noAutoWrite`,则启动定时器
49
+ * 周期性调用 {@link write},并在进程退出时做最后一次写入。
50
+ */
51
+ constructor(options) {
52
+ this.options = options;
53
+ this.storeType = options.storeFile.endsWith(".csv") ? "csv" : options.storeFile.endsWith(".yaml") || options.storeFile.endsWith(".yml") ? "yaml" : "json";
54
+ this.dataShapeSchema = options.dataSchema(v);
55
+ this.dataSchema = v.object(this.dataShapeSchema);
56
+ this.csvOptions = {
57
+ arrayDelimiter: options.csvArrayDelimiter,
58
+ arrayFieldNames: keysStrict(this.dataShapeSchema).filter((key) => this.dataShapeSchema[key].options.type === "array"),
59
+ objectFieldNames: keysStrict(this.dataShapeSchema).filter((key) => this.dataShapeSchema[key].options.type === "object"),
60
+ booleanFieldNames: keysStrict(this.dataShapeSchema).filter((key) => this.dataShapeSchema[key].options.type === "boolean"),
61
+ dateFieldNames: keysStrict(this.dataShapeSchema).filter((key) => this.dataShapeSchema[key].options.type === "date")
62
+ };
63
+ if (!options.noAutoWrite) {
64
+ const timer = setInterval(() => {
65
+ this.write();
66
+ }, options.autoWriteInterval || 5e3);
67
+ asyncExitHook(async () => {
68
+ clearInterval(timer);
69
+ await this.write();
70
+ }, { wait: 1e4 });
71
+ }
72
+ }
73
+ /**
74
+ * 从 `storeFile` 读取数据集。
75
+ *
76
+ * - 文件不存在或读取/解析失败时返回空数组(不会抛错);
77
+ * - 读取成功后对每条记录执行 schema 转换,并重建 `currentMap`。
78
+ *
79
+ * @returns 读取并转换后的记录数组。
80
+ */
81
+ async read() {
82
+ let dataSet = [];
83
+ try {
84
+ dataSet = await (this.storeType === "csv" ? XCsv.read(this.options.storeFile, this.csvOptions) : this.storeType === "yaml" ? XYaml.read(this.options.storeFile) : XJson.read(this.options.storeFile));
85
+ } catch {}
86
+ dataSet = dataSet.map((data) => this.dataSchema.cast(data));
87
+ this.currentMap = keyBy(dataSet, (data) => data[this.options.dataKey]);
88
+ return dataSet;
89
+ }
90
+ /**
91
+ * 将数据集写入 `storeFile`。
92
+ *
93
+ * 写入前会确保父目录存在;未显式传入 `dataSet` 时使用 `current` 作为数据源。
94
+ *
95
+ * @param dataSet 要写入的记录数组,默认取 `current`。
96
+ */
97
+ async write(dataSet = this.current) {
98
+ console.log(`💥 写入 ${this.options.storeFile}`);
99
+ await fs.ensureDir(path.dirname(this.options.storeFile));
100
+ await (this.storeType === "csv" ? XCsv.write(this.options.storeFile, dataSet, this.csvOptions) : this.storeType === "yaml" ? XYaml.write(this.options.storeFile, dataSet) : XJson.write(this.options.storeFile, dataSet));
101
+ console.log(`✅ 写入 ${this.options.storeFile}`);
102
+ }
103
+ };
104
+ export { XDataSet };
package/lib/fs.d.mts ADDED
@@ -0,0 +1,49 @@
1
+ import fs from "fs-extra";
2
+ /**
3
+ * 额外的文件类型辅助方法:
4
+ * - `getMimeType`:将扩展名 / 文件名 / MIME 字符串统一转换为 MIME 类型;
5
+ * - `getExtension`:将 MIME 字符串 / 文件名统一转换为扩展名。
6
+ *
7
+ * 这些方法会被合并到 `XFs` 中,与 `fs-extra` 的 API 一起导出。
8
+ */
9
+ declare const XFsExtra: {
10
+ /**
11
+ * 获取 MIME 类型。
12
+ *
13
+ * 入参支持以下形式:
14
+ * - 扩展名:`json`、`png`
15
+ * - 文件名:`file.txt`、`archive.tar.gz`
16
+ * - MIME 字符串:`application/json`
17
+ *
18
+ * 处理规则:
19
+ * 1. 去除首尾空白后,如果为空,返回通用兜底值 `application/octet-stream`;
20
+ * 2. 如果已是 MIME 形式(包含 `/`),直接返回原值;
21
+ * 3. 否则交给 `mime.getType` 推断;
22
+ * 4. 推断失败时,返回 `application/octet-stream`。
23
+ */
24
+ getMimeType(extOrName: string): string;
25
+ /**
26
+ * 获取扩展名(不带 `.`)。
27
+ *
28
+ * 入参支持以下形式:
29
+ * - MIME 字符串:`image/png`、`text/plain`
30
+ * - 文件名:`file.txt`、`archive.tar.gz`
31
+ * - 扩展名:`json`
32
+ *
33
+ * 处理规则:
34
+ * 1. 去除首尾空白后,如果为空,返回空字符串;
35
+ * 2. 如果是 MIME 形式(包含 `/`),优先通过 `mime.getExtension` 推断,
36
+ * 若推断失败则退化为取 `/` 右侧片段;
37
+ * 3. 如果是文件名(包含 `.`),返回最后一段后缀;
38
+ * 4. 否则按扩展名/MIME 简写尝试 `mime.getExtension`,失败返回空字符串。
39
+ */
40
+ getExtension(typeOrName: string): string;
41
+ };
42
+ /**
43
+ * `XFs` = `fs-extra` + 轻量文件类型工具。
44
+ *
45
+ * 这样调用方既可以直接使用完整的文件系统 API(如 `readFile`、`writeFile`),
46
+ * 也可以在同一对象上使用 `getMimeType` 和 `getExtension`。
47
+ */
48
+ export declare const XFs: typeof fs & typeof XFsExtra;
49
+ export {};