data-preheating-astro 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.
- package/README.md +40 -0
- package/dist/apis/paginate.js +103 -0
- package/dist/apis/query.js +21 -0
- package/dist/apis/request.js +132 -0
- package/dist/cache/keywordHitCache.js +202 -0
- package/dist/cli.js +460 -0
- package/dist/config/app.config.js +150 -0
- package/dist/config/keywords.js +47 -0
- package/dist/config/modules/bannerSetting.js +121 -0
- package/dist/config/modules/cases.js +74 -0
- package/dist/config/modules/categories.js +99 -0
- package/dist/config/modules/companies.js +42 -0
- package/dist/config/modules/contact.js +56 -0
- package/dist/config/modules/contactSidebar.js +86 -0
- package/dist/config/modules/download.js +56 -0
- package/dist/config/modules/honor.js +72 -0
- package/dist/config/modules/insertCode.js +37 -0
- package/dist/config/modules/keywords.js +29 -0
- package/dist/config/modules/languages.js +33 -0
- package/dist/config/modules/menu.js +33 -0
- package/dist/config/modules/mobileMenu.js +51 -0
- package/dist/config/modules/news.js +74 -0
- package/dist/config/modules/partner.js +56 -0
- package/dist/config/modules/products.js +79 -0
- package/dist/config/modules/robot.js +41 -0
- package/dist/config/modules/seo.js +102 -0
- package/dist/config/modules/singlepages.js +57 -0
- package/dist/config/modules/siteConfiguration.js +128 -0
- package/dist/config/modules/template.js +38 -0
- package/dist/config/modules/types.js +10 -0
- package/dist/config/modules/video.js +75 -0
- package/dist/config/modules/websites.js +40 -0
- package/dist/exporter/buildModuleOutput.js +352 -0
- package/dist/exporter/combo.js +21 -0
- package/dist/exporter/fetchAll.js +29 -0
- package/dist/exporter/keywordRules.js +19 -0
- package/dist/exporter/languages.js +26 -0
- package/dist/exporter/outputUtils.js +48 -0
- package/dist/exporter/strapiExtract.js +59 -0
- package/dist/monitor/progress.js +244 -0
- package/dist/prereq/italkinForm.js +185 -0
- package/dist/tools/exportCountsCsv.js +146 -0
- package/dist/tools/httpTrace.js +152 -0
- package/dist/tools/monitorFormat.js +46 -0
- package/dist/tools/robotsTxt.js +69 -0
- package/dist/transform/images.js +142 -0
- package/dist/transform/objectPath.js +188 -0
- package/dist/transform/pathNormalize.js +51 -0
- package/dist/transform/richtext.js +468 -0
- package/dist/transform/slug.js +38 -0
- package/dist/write/index.js +123 -0
- package/dist/write/output.js +60 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Strapi Content Exporter
|
|
2
|
+
|
|
3
|
+
本项目用于从 Strapi REST API **并发**拉取模块数据,对富文本做“关键词 → 内链”的严格替换处理,并将结果落盘到 `data/` 目录。
|
|
4
|
+
|
|
5
|
+
## 特性
|
|
6
|
+
|
|
7
|
+
- **并发拉取**:完成一个请求就立刻补下一个,保持并发池满载,尽快拉取全量分页数据。
|
|
8
|
+
- **接口级国际化**:每个模块可配置是否启用 i18n;启用时按语言列表分别拉取并输出到 `data/[lang]/...`。
|
|
9
|
+
- **富文本严格内链**:仅处理 HTML 文本节点;对每个关键词做“出现次数 vs 生成链接次数”的严格校验,避免遗漏。
|
|
10
|
+
- **输出结构**:
|
|
11
|
+
- 单语言:`data/shared/[modules]/index.json`
|
|
12
|
+
- 多语言:`data/[lang]/[modules]/index.json`
|
|
13
|
+
- 富文本:`data/**/[modules]/[slug].[field].html`
|
|
14
|
+
|
|
15
|
+
## 环境变量
|
|
16
|
+
|
|
17
|
+
所有环境变量均可不设置,项目会使用默认值;但当存在启用 i18n 的模块时,建议明确设置语言列表。
|
|
18
|
+
|
|
19
|
+
- `EXPORT_BASE_URL`:Strapi 基础地址,例如 `https://example.com`
|
|
20
|
+
- `EXPORT_API_TOKEN`:可选,若接口需要鉴权则使用 `Authorization: Bearer ...`
|
|
21
|
+
- `EXPORT_MAX_CONCURRENCY`:最大并发数,默认 `50`
|
|
22
|
+
- `EXPORT_FAIL_STRATEGY`:失败策略,默认 `fail_fast`;可选 `continue`
|
|
23
|
+
- `EXPORT_LANGS`:多语言列表,例如 `en,zh-Hans`(仅在模块启用 i18n 时使用)
|
|
24
|
+
- `EXPORT_DEFAULT_LANG`:默认语言,默认 `en`
|
|
25
|
+
- `EXPORT_OUTPUT_DIR`:输出目录,默认 `data`
|
|
26
|
+
|
|
27
|
+
## 运行
|
|
28
|
+
|
|
29
|
+
安装依赖后执行:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm i
|
|
33
|
+
npm run build
|
|
34
|
+
npm run start
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 模块配置
|
|
38
|
+
|
|
39
|
+
在 `src/config/modules/` 下为每个 Strapi 接口创建一个模块配置文件,并在 `src/config/app.config.ts` 中引入。
|
|
40
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 并发分页调度(核心)。
|
|
4
|
+
*
|
|
5
|
+
* 目的:
|
|
6
|
+
* - “全量拉取”通常需要分页;为了尽快完成全量拉取,这里实现一个并发调度器:
|
|
7
|
+
* 完成一个请求就立刻补下一个,保持并发池尽量满载。
|
|
8
|
+
*
|
|
9
|
+
* 说明:
|
|
10
|
+
* - 该文件不限制你只能拉取部分数据;它的目标是让“全量分页拉取”更快完成。
|
|
11
|
+
* - 这里不考虑限流与优化策略,保持实现简洁,重点是吞吐。
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.AsyncPool = void 0;
|
|
15
|
+
/**
|
|
16
|
+
* 一个最小的并发任务池。
|
|
17
|
+
* - 支持任务在执行过程中继续 add 新任务(用于分页任务动态补位)。
|
|
18
|
+
*/
|
|
19
|
+
class AsyncPool {
|
|
20
|
+
maxConcurrency;
|
|
21
|
+
failStrategy;
|
|
22
|
+
queue = [];
|
|
23
|
+
running = 0;
|
|
24
|
+
idleResolvers = [];
|
|
25
|
+
idleRejecters = [];
|
|
26
|
+
stopped = false;
|
|
27
|
+
firstError = null;
|
|
28
|
+
errors = [];
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.maxConcurrency = opts.maxConcurrency;
|
|
31
|
+
this.failStrategy = opts.failStrategy;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 添加一个任务。
|
|
35
|
+
*/
|
|
36
|
+
add(task) {
|
|
37
|
+
if (this.stopped)
|
|
38
|
+
return;
|
|
39
|
+
this.queue.push(task);
|
|
40
|
+
this.drain();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 等待池进入空闲(所有任务执行完成,且队列为空)。
|
|
44
|
+
*/
|
|
45
|
+
waitIdle() {
|
|
46
|
+
if (this.firstError && this.failStrategy === "fail_fast") {
|
|
47
|
+
return Promise.reject(this.firstError);
|
|
48
|
+
}
|
|
49
|
+
if (this.queue.length === 0 && this.running === 0) {
|
|
50
|
+
return Promise.resolve({ errors: this.errors });
|
|
51
|
+
}
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
this.idleResolvers.push(() => resolve({ errors: this.errors }));
|
|
54
|
+
this.idleRejecters.push(reject);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
drain() {
|
|
58
|
+
while (!this.stopped && this.running < this.maxConcurrency && this.queue.length > 0) {
|
|
59
|
+
const task = this.queue.shift();
|
|
60
|
+
if (!task)
|
|
61
|
+
break;
|
|
62
|
+
this.running += 1;
|
|
63
|
+
Promise.resolve()
|
|
64
|
+
.then(task)
|
|
65
|
+
.catch((err) => this.onError(err))
|
|
66
|
+
.finally(() => {
|
|
67
|
+
this.running -= 1;
|
|
68
|
+
this.drain();
|
|
69
|
+
this.maybeResolveIdle();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
onError(err) {
|
|
74
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
75
|
+
this.errors.push(e);
|
|
76
|
+
if (!this.firstError) {
|
|
77
|
+
this.firstError = e;
|
|
78
|
+
}
|
|
79
|
+
if (this.failStrategy === "fail_fast") {
|
|
80
|
+
this.stopped = true;
|
|
81
|
+
this.rejectIdle(e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
maybeResolveIdle() {
|
|
85
|
+
if (this.stopped)
|
|
86
|
+
return;
|
|
87
|
+
if (this.queue.length !== 0)
|
|
88
|
+
return;
|
|
89
|
+
if (this.running !== 0)
|
|
90
|
+
return;
|
|
91
|
+
const resolvers = this.idleResolvers;
|
|
92
|
+
this.idleResolvers = [];
|
|
93
|
+
this.idleRejecters = [];
|
|
94
|
+
resolvers.forEach((r) => r());
|
|
95
|
+
}
|
|
96
|
+
rejectIdle(err) {
|
|
97
|
+
const rejecters = this.idleRejecters;
|
|
98
|
+
this.idleResolvers = [];
|
|
99
|
+
this.idleRejecters = [];
|
|
100
|
+
rejecters.forEach((r) => r(err));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
exports.AsyncPool = AsyncPool;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Strapi 查询参数序列化。
|
|
4
|
+
*
|
|
5
|
+
* 说明:
|
|
6
|
+
* - Strapi 参数经常包含深层对象(populate/filters/fields/sort/pagination 等)。
|
|
7
|
+
* - 使用 qs 将 object 序列化为查询字符串,便于模块配置“以对象维护参数”。
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.toQueryString = toQueryString;
|
|
14
|
+
const qs_1 = __importDefault(require("qs"));
|
|
15
|
+
function toQueryString(params) {
|
|
16
|
+
const query = qs_1.default.stringify(params, {
|
|
17
|
+
encodeValuesOnly: true,
|
|
18
|
+
arrayFormat: "indices"
|
|
19
|
+
});
|
|
20
|
+
return query ? `?${query}` : "";
|
|
21
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP 请求封装(用于 Strapi)。
|
|
4
|
+
*
|
|
5
|
+
* 说明:
|
|
6
|
+
* - 这里提供最小封装:超时、JSON 解析、错误包装、可选鉴权。
|
|
7
|
+
* - 不做复杂优化逻辑,保持简单直观。
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.initHttpClient = initHttpClient;
|
|
11
|
+
exports.fetchJson = fetchJson;
|
|
12
|
+
/**
|
|
13
|
+
* 初始化 undici 连接池(底层并发能力)。
|
|
14
|
+
*
|
|
15
|
+
* 说明:
|
|
16
|
+
* - Node 的 fetch 底层使用 undici,并且对同一 origin 的连接数存在默认上限
|
|
17
|
+
* - 当导出并发很高(例如 50)时,默认上限会让大量请求在客户端排队,从而整体变慢
|
|
18
|
+
* - 这里在程序启动时把连接池上限提升到与导出并发一致,尽量避免“假并发”
|
|
19
|
+
*
|
|
20
|
+
* 注意:
|
|
21
|
+
* - 该实现不引入任何依赖;运行时若无法加载 undici,则直接跳过(不影响功能)
|
|
22
|
+
* - 只需要调用一次;重复调用会被忽略
|
|
23
|
+
*/
|
|
24
|
+
let httpClientInited = false;
|
|
25
|
+
let undiciFetch = null;
|
|
26
|
+
let undiciAgent = null;
|
|
27
|
+
function initHttpClient(params) {
|
|
28
|
+
if (httpClientInited)
|
|
29
|
+
return;
|
|
30
|
+
httpClientInited = true;
|
|
31
|
+
// connections 取整并做下限保护,避免传入非法值导致抛错。
|
|
32
|
+
const n = Number.isFinite(params.connections) ? Math.floor(params.connections) : 0;
|
|
33
|
+
const connections = Math.max(1, n);
|
|
34
|
+
try {
|
|
35
|
+
// 使用 require 避免 TypeScript 依赖 undici 类型声明(保持代码最简)。
|
|
36
|
+
const undici = require("undici");
|
|
37
|
+
if (!undici || typeof undici.Agent !== "function" || typeof undici.fetch !== "function") {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// 保存 fetch + agent,后续请求强制走 undici.fetch + dispatcher,确保连接池配置生效。
|
|
41
|
+
undiciFetch = undici.fetch;
|
|
42
|
+
undiciAgent = new undici.Agent({ connections });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// 若运行环境无法加载 undici,则不做任何处理(保持功能正常)。
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 请求 JSON 并返回解析后的对象。
|
|
50
|
+
*/
|
|
51
|
+
async function fetchJson(ctx, pathWithQuery) {
|
|
52
|
+
const url = new URL(pathWithQuery, ctx.baseUrl).toString();
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
let logged = false;
|
|
55
|
+
function emitLog(info) {
|
|
56
|
+
if (!ctx.onHttpLog)
|
|
57
|
+
return;
|
|
58
|
+
logged = true;
|
|
59
|
+
ctx.onHttpLog({
|
|
60
|
+
ts: new Date().toISOString(),
|
|
61
|
+
method: "GET",
|
|
62
|
+
url,
|
|
63
|
+
pathWithQuery,
|
|
64
|
+
ok: info.ok,
|
|
65
|
+
status: info.status,
|
|
66
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
67
|
+
error: info.error
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => controller.abort(), ctx.timeoutMs);
|
|
72
|
+
try {
|
|
73
|
+
const headers = {
|
|
74
|
+
Accept: "application/json"
|
|
75
|
+
};
|
|
76
|
+
if (ctx.apiToken) {
|
|
77
|
+
headers.Authorization = `Bearer ${ctx.apiToken}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 请求发起:
|
|
81
|
+
* - 若已初始化 undici agent,则使用 undici.fetch 并显式传 dispatcher,确保连接池配置生效
|
|
82
|
+
* - 否则回退到全局 fetch(保持兼容)
|
|
83
|
+
*/
|
|
84
|
+
const useUndici = typeof undiciFetch === "function" && undiciAgent;
|
|
85
|
+
const res = useUndici
|
|
86
|
+
? await undiciFetch(url, {
|
|
87
|
+
method: "GET",
|
|
88
|
+
headers,
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
dispatcher: undiciAgent
|
|
91
|
+
})
|
|
92
|
+
: await fetch(url, {
|
|
93
|
+
method: "GET",
|
|
94
|
+
headers,
|
|
95
|
+
signal: controller.signal
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const text = await safeReadText(res);
|
|
99
|
+
// 非 2xx:记录一次请求失败(带 status),避免后续 catch 重复记录。
|
|
100
|
+
emitLog({ ok: false, status: res.status, error: `HTTP ${res.status} ${res.statusText}` });
|
|
101
|
+
throw new Error(`请求失败:${res.status} ${res.statusText} ${text}`);
|
|
102
|
+
}
|
|
103
|
+
const json = (await res.json());
|
|
104
|
+
emitLog({ ok: true, status: res.status });
|
|
105
|
+
return json;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// 异常也要上报(例如超时/网络失败/JSON 解析失败)。
|
|
109
|
+
if (!logged) {
|
|
110
|
+
emitLog({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
111
|
+
}
|
|
112
|
+
throw wrapError(err, `请求异常:${url}`);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function safeReadText(res) {
|
|
119
|
+
try {
|
|
120
|
+
const t = await res.text();
|
|
121
|
+
return t ? `- ${t.slice(0, 500)}` : "";
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function wrapError(err, msg) {
|
|
128
|
+
if (err instanceof Error) {
|
|
129
|
+
return new Error(`${msg} - ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
return new Error(`${msg} - ${String(err)}`);
|
|
132
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 关键词命中缓存(用于富文本内链提速)。
|
|
4
|
+
*
|
|
5
|
+
* 目标:
|
|
6
|
+
* - 每天首次运行:用全量关键词规则做内链,并把“每条富文本实际命中的关键词列表”保存下来
|
|
7
|
+
* - 同一天后续运行:直接使用命中的关键词子集做内链,减少 1-2 千关键词逐个匹配带来的耗时
|
|
8
|
+
*
|
|
9
|
+
* 说明:
|
|
10
|
+
* - 缓存文件写入到项目根目录下的 `.cache/`(不放在 data 目录内)
|
|
11
|
+
* - 实现保持简单:按天失效(不做更复杂的版本/哈希策略)
|
|
12
|
+
*/
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.createKeywordHitStore = createKeywordHitStore;
|
|
18
|
+
exports.getTodayKey = getTodayKey;
|
|
19
|
+
exports.keywordRulesCacheFilePath = keywordRulesCacheFilePath;
|
|
20
|
+
exports.loadKeywordRulesCache = loadKeywordRulesCache;
|
|
21
|
+
exports.saveKeywordRulesCache = saveKeywordRulesCache;
|
|
22
|
+
const node_fs_1 = require("node:fs");
|
|
23
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
const output_1 = require("../write/output");
|
|
25
|
+
/**
|
|
26
|
+
* 创建按文章维度的关键词命中存储器。
|
|
27
|
+
*
|
|
28
|
+
* 目录结构:
|
|
29
|
+
* - .cache/keyword-hits/<lang>/<module>/<file>.json
|
|
30
|
+
*
|
|
31
|
+
* file 命名尽量可读,同时做必要的字符清洗以保证跨平台文件名安全。
|
|
32
|
+
*/
|
|
33
|
+
function createKeywordHitStore(params) {
|
|
34
|
+
const langSafe = safeName(params.lang || "default");
|
|
35
|
+
const baseDir = node_path_1.default.join(params.cacheDirAbs, "keyword-hits", langSafe);
|
|
36
|
+
function entryFilePath(moduleName, slug, fieldPath) {
|
|
37
|
+
const modSafe = safeName(moduleName || "unknown");
|
|
38
|
+
const slugSafe = safeName(slug || "unknown");
|
|
39
|
+
const fieldSafe = safeName(fieldPath || "richtext");
|
|
40
|
+
// 文件名:slug.field.json(保持尽量可读)
|
|
41
|
+
const file = `${slugSafe}.${fieldSafe}.json`;
|
|
42
|
+
return node_path_1.default.join(baseDir, modSafe, file);
|
|
43
|
+
}
|
|
44
|
+
async function readEntry(filePath) {
|
|
45
|
+
try {
|
|
46
|
+
const text = await node_fs_1.promises.readFile(filePath, "utf8");
|
|
47
|
+
const parsed = JSON.parse(text);
|
|
48
|
+
if (parsed.version !== 1)
|
|
49
|
+
return null;
|
|
50
|
+
if (typeof parsed.day !== "string")
|
|
51
|
+
return null;
|
|
52
|
+
if (!Array.isArray(parsed.keywords))
|
|
53
|
+
return null;
|
|
54
|
+
return { version: 1, day: parsed.day, keywords: parsed.keywords };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
async get({ moduleName, slug, fieldPath }) {
|
|
62
|
+
await (0, output_1.ensureDir)(baseDir);
|
|
63
|
+
const p = entryFilePath(moduleName, slug, fieldPath);
|
|
64
|
+
const v = await readEntry(p);
|
|
65
|
+
if (!v)
|
|
66
|
+
return undefined;
|
|
67
|
+
// 按天失效:不是今天直接视为不存在(保持实现简单)。
|
|
68
|
+
if (v.day !== getTodayKey())
|
|
69
|
+
return undefined;
|
|
70
|
+
return v.keywords;
|
|
71
|
+
},
|
|
72
|
+
async setIfAbsent({ moduleName, slug, fieldPath, keywords }) {
|
|
73
|
+
await (0, output_1.ensureDir)(baseDir);
|
|
74
|
+
const p = entryFilePath(moduleName, slug, fieldPath);
|
|
75
|
+
const existing = await readEntry(p);
|
|
76
|
+
if (existing && existing.day === getTodayKey())
|
|
77
|
+
return;
|
|
78
|
+
// 写入前确保模块目录存在
|
|
79
|
+
await (0, output_1.ensureDir)(node_path_1.default.dirname(p));
|
|
80
|
+
const data = {
|
|
81
|
+
version: 1,
|
|
82
|
+
day: getTodayKey(),
|
|
83
|
+
keywords: Array.isArray(keywords) ? keywords : []
|
|
84
|
+
};
|
|
85
|
+
await (0, output_1.writeJsonAtomic)(p, data);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 文件名安全化(尽量简单)。
|
|
91
|
+
*
|
|
92
|
+
* 说明:
|
|
93
|
+
* - 替换掉路径分隔符与特殊字符,避免生成不可用文件名
|
|
94
|
+
* - 不做复杂编码,保持可读性为主
|
|
95
|
+
*/
|
|
96
|
+
function safeName(s) {
|
|
97
|
+
const v = typeof s === "string" ? s.trim() : "";
|
|
98
|
+
const out = v.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/_+/g, "_");
|
|
99
|
+
// 避免超长文件名(保持简单,截断即可)
|
|
100
|
+
return out.length > 120 ? out.slice(0, 120) : out || "unknown";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 获取今天日期(本地时区)的 YYYY-MM-DD 字符串。
|
|
104
|
+
*/
|
|
105
|
+
function getTodayKey() {
|
|
106
|
+
const d = new Date();
|
|
107
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
108
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 构建关键词接口缓存文件路径:.cache/keywords-items.<lang>.json
|
|
112
|
+
*/
|
|
113
|
+
function legacyKeywordApiItemsCacheFilePath(cacheDirAbs, lang) {
|
|
114
|
+
const safeLang = (lang || "default").replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
115
|
+
return node_path_1.default.join(cacheDirAbs, `keywords-items.${safeLang}.json`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 构建关键词规则缓存文件路径:.cache/keyword-rules.<lang>.json
|
|
119
|
+
*/
|
|
120
|
+
function keywordRulesCacheFilePath(cacheDirAbs, lang) {
|
|
121
|
+
const safeLang = (lang || "default").replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
122
|
+
return node_path_1.default.join(cacheDirAbs, `keyword-rules.${safeLang}.json`);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 加载关键词接口缓存;若不存在或非今天则返回 null。
|
|
126
|
+
*
|
|
127
|
+
* 说明:
|
|
128
|
+
* - 用于“同一天后续运行不再请求 keywords 接口”的需求
|
|
129
|
+
*/
|
|
130
|
+
async function loadKeywordRulesCache(params) {
|
|
131
|
+
await (0, output_1.ensureDir)(params.cacheDirAbs);
|
|
132
|
+
const today = getTodayKey();
|
|
133
|
+
// 1) 优先读取新缓存(keyword-rules.*.json)
|
|
134
|
+
const rulesPath = keywordRulesCacheFilePath(params.cacheDirAbs, params.lang);
|
|
135
|
+
try {
|
|
136
|
+
const text = await node_fs_1.promises.readFile(rulesPath, "utf8");
|
|
137
|
+
const parsed = JSON.parse(text);
|
|
138
|
+
if (parsed.version !== 1)
|
|
139
|
+
return null;
|
|
140
|
+
// 用户要求:当天首次正常请求并缓存;当天后续只读缓存,不再请求(因此按“当天”判断)
|
|
141
|
+
if (typeof parsed.day !== "string" || parsed.day !== today)
|
|
142
|
+
return null;
|
|
143
|
+
if (!Array.isArray(parsed.rules))
|
|
144
|
+
return null;
|
|
145
|
+
return parsed.rules;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// 无新缓存:继续尝试旧缓存迁移
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 2) 兼容旧缓存(keywords-items.*.json)并自动迁移。
|
|
152
|
+
*
|
|
153
|
+
* 说明:
|
|
154
|
+
* - 旧文件体积大,迁移后会删除旧文件,避免目录膨胀
|
|
155
|
+
* - 迁移只做“字段裁剪”,不改变规则含义
|
|
156
|
+
*/
|
|
157
|
+
const legacyPath = legacyKeywordApiItemsCacheFilePath(params.cacheDirAbs, params.lang);
|
|
158
|
+
try {
|
|
159
|
+
const text = await node_fs_1.promises.readFile(legacyPath, "utf8");
|
|
160
|
+
const parsed = JSON.parse(text);
|
|
161
|
+
if (parsed?.version !== 1)
|
|
162
|
+
return null;
|
|
163
|
+
// 同上:只在“当天”迁移并使用,避免跨天读取旧规则
|
|
164
|
+
if (typeof parsed?.day !== "string" || parsed.day !== today)
|
|
165
|
+
return null;
|
|
166
|
+
if (!Array.isArray(parsed?.items))
|
|
167
|
+
return null;
|
|
168
|
+
const rules = [];
|
|
169
|
+
for (const raw of parsed.items) {
|
|
170
|
+
const keyword = typeof raw?.keyword === "string" ? raw.keyword.trim() : "";
|
|
171
|
+
const href = typeof raw?.slug === "string" ? raw.slug.trim() : "";
|
|
172
|
+
if (!keyword || !href)
|
|
173
|
+
continue;
|
|
174
|
+
rules.push({ keyword, href });
|
|
175
|
+
}
|
|
176
|
+
// 写入新缓存并删除旧缓存(删除失败不影响流程)。
|
|
177
|
+
await saveKeywordRulesCache({ cacheDirAbs: params.cacheDirAbs, lang: params.lang, rules, day: today });
|
|
178
|
+
try {
|
|
179
|
+
await node_fs_1.promises.rm(legacyPath, { force: true });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// ignore
|
|
183
|
+
}
|
|
184
|
+
return rules;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* 保存关键词规则缓存(覆盖写入)。
|
|
192
|
+
*/
|
|
193
|
+
async function saveKeywordRulesCache(params) {
|
|
194
|
+
await (0, output_1.ensureDir)(params.cacheDirAbs);
|
|
195
|
+
const filePath = keywordRulesCacheFilePath(params.cacheDirAbs, params.lang);
|
|
196
|
+
const data = {
|
|
197
|
+
version: 1,
|
|
198
|
+
day: (params.day || getTodayKey()).trim() || getTodayKey(),
|
|
199
|
+
rules: params.rules
|
|
200
|
+
};
|
|
201
|
+
await (0, output_1.writeJsonAtomic)(filePath, data);
|
|
202
|
+
}
|