aiconductor-cli 1.6.0 → 1.7.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.
- package/README.md +565 -488
- package/bin/aiconductor-cli.js +401 -351
- package/package.json +1 -1
- package/src/api.js +135 -3
- package/src/api.test.js +292 -4
- package/src/commands/content.js +184 -184
- package/src/commands/douyin.js +174 -0
- package/src/commands/html_publish.js +54 -54
- package/src/commands/md_publish.js +175 -175
- package/src/commands/web.js +69 -0
- package/src/commands/web_search.js +99 -99
- package/src/commands/wx.js +116 -116
- package/src/commands/xhs.js +763 -609
- package/src/lib/cli-utils.js +172 -172
- package/src/lib/daren-score.js +91 -0
- package/src/lib/daren-score.test.js +57 -0
- package/AGENTS.md +0 -193
- package/api_docs/aic_web_search.md +0 -84
- package/api_docs/audio_converter.md +0 -51
- package/api_docs/balance_query.md +0 -38
- package/api_docs/bing_search.md +0 -55
- package/api_docs/china_stock_data.md +0 -221
- package/api_docs/delete_published_webpage.md +0 -38
- package/api_docs/doubao_search.md +0 -80
- package/api_docs/douyin_link_to_text_v1.md +0 -50
- package/api_docs/douyin_link_to_text_v2.md +0 -50
- package/api_docs/douyin_user_detail.md +0 -65
- package/api_docs/douyin_user_search.md +0 -58
- package/api_docs/douyin_user_videos.md +0 -49
- package/api_docs/douyin_user_videos_async.md +0 -45
- package/api_docs/douyin_user_videos_async_query.md +0 -41
- package/api_docs/douyin_user_videos_max.md +0 -59
- package/api_docs/douyin_video_comments.md +0 -68
- package/api_docs/douyin_video_info.md +0 -70
- package/api_docs/douyin_video_search.md +0 -60
- package/api_docs/douyin_video_search_fast.md +0 -48
- package/api_docs/fast_ocr.md +0 -51
- package/api_docs/flow_article2podcast_creat.md +0 -41
- package/api_docs/flow_article2podcast_query.md +0 -41
- package/api_docs/flow_wx_history_articles.md +0 -45
- package/api_docs/generate_image.md +0 -68
- package/api_docs/get_duration.md +0 -37
- package/api_docs/hot_list_mixed.md +0 -122
- package/api_docs/html_publish.md +0 -50
- package/api_docs/html_publish_flexible.md +0 -65
- package/api_docs/image_resize.md +0 -58
- package/api_docs/image_to_permalink.md +0 -61
- package/api_docs/link_expander.md +0 -39
- package/api_docs/llm_proxy.md +0 -61
- package/api_docs/markitdown.md +0 -47
- package/api_docs/md_to_docx.md +0 -51
- package/api_docs/md_to_html.md +0 -53
- package/api_docs/md_to_mindmap.md +0 -52
- package/api_docs/md_to_pdf.md +0 -52
- package/api_docs/md_to_png.md +0 -53
- package/api_docs/md_to_txt.md +0 -51
- package/api_docs/md_to_wxhtml.md +0 -64
- package/api_docs/md_to_xhs.md +0 -87
- package/api_docs/md_to_xlsx.md +0 -52
- package/api_docs/portrait_enhancement.md +0 -69
- package/api_docs/published_webpages.md +0 -45
- package/api_docs/r2_uploader.md +0 -51
- package/api_docs/schedulerx_job_create.md +0 -56
- package/api_docs/transaction_query.md +0 -52
- package/api_docs/url_to_markdown.md +0 -50
- package/api_docs/video_download_url.md +0 -57
- package/api_docs/video_download_url_oss.md +0 -55
- package/api_docs/video_to_text.md +0 -48
- package/api_docs/video_to_text_v2.md +0 -49
- package/api_docs/volcengine_face_pretty.md +0 -55
- package/api_docs/volcengine_faceswap.md +0 -55
- package/api_docs/website_screenshot.md +0 -49
- package/api_docs/wx_access_token.md +0 -44
- package/api_docs/wx_article_analysis_data_push.md +0 -43
- package/api_docs/wx_article_data.md +0 -48
- package/api_docs/wx_article_style_rewrite.md +0 -45
- package/api_docs/wx_draft_add.md +0 -56
- package/api_docs/wx_draft_publish.md +0 -47
- package/api_docs/wx_history_articles.md +0 -89
- package/api_docs/wx_image_upload.md +0 -46
- package/api_docs/wx_md_to_html.md +0 -48
- package/api_docs/wx_rss_reader.md +0 -59
- package/api_docs/xhs_hot_list.md +0 -48
- package/api_docs/xhs_note_comments_fast.md +0 -54
- package/api_docs/xhs_note_info_v2.md +0 -45
- package/api_docs/xhs_note_info_v3.md +0 -69
- package/api_docs/xhs_note_publish.md +0 -54
- package/api_docs/xhs_note_search_v2.md +0 -73
- package/api_docs/xhs_note_search_v3.md +0 -54
- package/api_docs/xhs_user_info_v2.md +0 -61
- package/api_docs/xhs_user_notes_v2.md +0 -56
- package/api_docs/xhs_user_notes_v3.md +0 -48
package/src/lib/cli-utils.js
CHANGED
|
@@ -1,172 +1,172 @@
|
|
|
1
|
-
const { resolveApiKey } = require("../api");
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 统一成功输出
|
|
5
|
-
* @param {object} data - API 返回数据
|
|
6
|
-
* @param {"json"|"text"} format
|
|
7
|
-
* @param {function} [textFormatter] - text 模式的自定义格式化函数,接收 data 返回字符串
|
|
8
|
-
*/
|
|
9
|
-
function outputResult(data, format, textFormatter) {
|
|
10
|
-
if (format === "json") {
|
|
11
|
-
process.stdout.write(JSON.stringify({ success: true, data }, null, 2) + "\n");
|
|
12
|
-
} else {
|
|
13
|
-
const text = textFormatter ? textFormatter(data) : JSON.stringify(data, null, 2);
|
|
14
|
-
process.stdout.write(text + "\n");
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* 统一错误输出
|
|
20
|
-
* @param {object} err - 错误对象(需有 message 属性)
|
|
21
|
-
* @param {"json"|"text"} format
|
|
22
|
-
*/
|
|
23
|
-
function outputError(err, format) {
|
|
24
|
-
const msg = err.message || String(err);
|
|
25
|
-
if (format === "json") {
|
|
26
|
-
process.stderr.write(JSON.stringify({ success: false, error: msg }, null, 2) + "\n");
|
|
27
|
-
} else {
|
|
28
|
-
process.stderr.write(`✗ 错误: ${msg}\n`);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 解析 API Key,失败时输出错误并退出进程
|
|
34
|
-
* @param {string} [optionKey] - 命令行传入的 key
|
|
35
|
-
* @param {"json"|"text"} format
|
|
36
|
-
* @returns {string} 解析到的 API Key
|
|
37
|
-
*/
|
|
38
|
-
function resolveKeyOrExit(optionKey, format) {
|
|
39
|
-
const key = resolveApiKey(optionKey);
|
|
40
|
-
if (!key) {
|
|
41
|
-
outputError(
|
|
42
|
-
{ message: "缺少 API Key。请通过 -k 参数、AICONDUCTOR_API_KEY 环境变量或 .env 文件提供。" },
|
|
43
|
-
format
|
|
44
|
-
);
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
47
|
-
return key;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* 统一 API 错误处理(不退出,由调用方决定退出码)
|
|
52
|
-
* @param {object} err - 错误对象
|
|
53
|
-
* @param {"json"|"text"} format
|
|
54
|
-
*/
|
|
55
|
-
function handleApiError(err, format) {
|
|
56
|
-
if (err.type === "http_error") {
|
|
57
|
-
outputError(
|
|
58
|
-
{ message: `API 返回错误 (HTTP ${err.statusCode}): ${err.body?.message || JSON.stringify(err.body)}` },
|
|
59
|
-
format
|
|
60
|
-
);
|
|
61
|
-
} else if (err.type === "network_error") {
|
|
62
|
-
outputError({ message: `网络错误: ${err.message}` }, format);
|
|
63
|
-
} else if (err.type === "validation_error") {
|
|
64
|
-
outputError({ message: `参数错误: ${err.message}` }, format);
|
|
65
|
-
} else if (err.type === "api_error") {
|
|
66
|
-
outputError({ message: err.message }, format);
|
|
67
|
-
} else {
|
|
68
|
-
outputError({ message: `未知错误: ${err.message || err}` }, format);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* 安全解析整数,无效时返回 defaultValue
|
|
74
|
-
* @param {*} value
|
|
75
|
-
* @param {number} defaultValue
|
|
76
|
-
* @returns {number}
|
|
77
|
-
*/
|
|
78
|
-
function safeParseInt(value, defaultValue) {
|
|
79
|
-
if (value === undefined || value === null) return defaultValue;
|
|
80
|
-
const n = parseInt(value, 10);
|
|
81
|
-
return Number.isNaN(n) ? defaultValue : n;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 从文本(分享口令/带链接的句子)中抽出第一个 http(s) 链接。
|
|
86
|
-
*
|
|
87
|
-
* 小红书用户复制的内容形如「标题 https://xhslink.cn/o/xxx 复制内容,然后进入【小红书】查看相关笔记。」
|
|
88
|
-
* 平台侧的 share_text 是**原样透传上游**的,带中文口令必失败(2026-11-05 实测),所以链接必须本地抽。
|
|
89
|
-
* @param {string} text
|
|
90
|
-
* @returns {string|null}
|
|
91
|
-
*/
|
|
92
|
-
function extractUrlFromText(text) {
|
|
93
|
-
if (typeof text !== "string") return null;
|
|
94
|
-
const match = text.match(/https?:\/\/[^\s\u4e00-\u9fa5,。、;:!?()【】《》“”‘’]+/i);
|
|
95
|
-
if (!match) return null;
|
|
96
|
-
// 去掉 URL 尾部的英文标点(中文标点已在字符集里排除)
|
|
97
|
-
return match[0].replace(/[),.;:!?'"\]}>]+$/, "");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const XHS_ID_RE = /^[0-9a-f]{24}$/i;
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 从用户输入里识别小红书笔记 ID(裸 ID / 长链 / 带链接的分享文案)。
|
|
104
|
-
* 短链(xhslink.cn)需先由 resolveXhsShortLink 换成长链。
|
|
105
|
-
* @param {string} text
|
|
106
|
-
* @returns {string|null}
|
|
107
|
-
*/
|
|
108
|
-
function parseXhsNoteId(text) {
|
|
109
|
-
if (typeof text !== "string") return null;
|
|
110
|
-
const raw = text.trim();
|
|
111
|
-
if (XHS_ID_RE.test(raw)) return raw;
|
|
112
|
-
const url = extractUrlFromText(raw);
|
|
113
|
-
if (!url) return null;
|
|
114
|
-
const match = url.match(/(?:explore|discovery\/item|note)\/([0-9a-f]{24})/i);
|
|
115
|
-
return match ? match[1] : null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* 从用户输入里识别小红书用户 ID(裸 ID / 主页链接)。
|
|
120
|
-
* @param {string} text
|
|
121
|
-
* @returns {string|null}
|
|
122
|
-
*/
|
|
123
|
-
function parseXhsUserId(text) {
|
|
124
|
-
if (typeof text !== "string") return null;
|
|
125
|
-
const raw = text.trim();
|
|
126
|
-
if (XHS_ID_RE.test(raw)) return raw;
|
|
127
|
-
const url = extractUrlFromText(raw);
|
|
128
|
-
if (!url) return null;
|
|
129
|
-
const match = url.match(/user\/profile\/([0-9a-f]{24})/i);
|
|
130
|
-
return match ? match[1] : null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* 小红书时间戳格式化:上游给的是**真 UTC epoch**(详情秒、笔记列表毫秒),展示需 +8 小时。
|
|
135
|
-
* 与抖音相反(抖音是「北京时间编码」,不要再加 8)。
|
|
136
|
-
* @param {number|string} value epoch 秒或毫秒
|
|
137
|
-
* @returns {string} `YYYY-MM-DD HH:mm:ss`,无效值返回空串
|
|
138
|
-
*/
|
|
139
|
-
function formatXhsTime(value) {
|
|
140
|
-
const n = Number(value);
|
|
141
|
-
if (!n || Number.isNaN(n)) return "";
|
|
142
|
-
const ms = n > 1e12 ? n : n * 1000;
|
|
143
|
-
return new Date(ms + 8 * 3600 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* 归一化为整数计数:上游同一平台里数字有 int、字符串、以及「1.2万」这类糊糊写法。
|
|
148
|
-
* @param {*} value
|
|
149
|
-
* @returns {number}
|
|
150
|
-
*/
|
|
151
|
-
function toCount(value) {
|
|
152
|
-
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
153
|
-
if (value === null || value === undefined) return 0;
|
|
154
|
-
const text = String(value).replace(/[,\s]/g, "");
|
|
155
|
-
const match = text.match(/^(\d+(?:\.\d+)?)(万|w)?\+?$/i);
|
|
156
|
-
if (!match) return 0;
|
|
157
|
-
const base = Number(match[1]);
|
|
158
|
-
return Math.round(match[2] ? base * 10000 : base);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
module.exports = {
|
|
162
|
-
outputResult,
|
|
163
|
-
outputError,
|
|
164
|
-
resolveKeyOrExit,
|
|
165
|
-
handleApiError,
|
|
166
|
-
safeParseInt,
|
|
167
|
-
extractUrlFromText,
|
|
168
|
-
parseXhsNoteId,
|
|
169
|
-
parseXhsUserId,
|
|
170
|
-
formatXhsTime,
|
|
171
|
-
toCount,
|
|
172
|
-
};
|
|
1
|
+
const { resolveApiKey } = require("../api");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 统一成功输出
|
|
5
|
+
* @param {object} data - API 返回数据
|
|
6
|
+
* @param {"json"|"text"} format
|
|
7
|
+
* @param {function} [textFormatter] - text 模式的自定义格式化函数,接收 data 返回字符串
|
|
8
|
+
*/
|
|
9
|
+
function outputResult(data, format, textFormatter) {
|
|
10
|
+
if (format === "json") {
|
|
11
|
+
process.stdout.write(JSON.stringify({ success: true, data }, null, 2) + "\n");
|
|
12
|
+
} else {
|
|
13
|
+
const text = textFormatter ? textFormatter(data) : JSON.stringify(data, null, 2);
|
|
14
|
+
process.stdout.write(text + "\n");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 统一错误输出
|
|
20
|
+
* @param {object} err - 错误对象(需有 message 属性)
|
|
21
|
+
* @param {"json"|"text"} format
|
|
22
|
+
*/
|
|
23
|
+
function outputError(err, format) {
|
|
24
|
+
const msg = err.message || String(err);
|
|
25
|
+
if (format === "json") {
|
|
26
|
+
process.stderr.write(JSON.stringify({ success: false, error: msg }, null, 2) + "\n");
|
|
27
|
+
} else {
|
|
28
|
+
process.stderr.write(`✗ 错误: ${msg}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 解析 API Key,失败时输出错误并退出进程
|
|
34
|
+
* @param {string} [optionKey] - 命令行传入的 key
|
|
35
|
+
* @param {"json"|"text"} format
|
|
36
|
+
* @returns {string} 解析到的 API Key
|
|
37
|
+
*/
|
|
38
|
+
function resolveKeyOrExit(optionKey, format) {
|
|
39
|
+
const key = resolveApiKey(optionKey);
|
|
40
|
+
if (!key) {
|
|
41
|
+
outputError(
|
|
42
|
+
{ message: "缺少 API Key。请通过 -k 参数、AICONDUCTOR_API_KEY 环境变量或 .env 文件提供。" },
|
|
43
|
+
format
|
|
44
|
+
);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 统一 API 错误处理(不退出,由调用方决定退出码)
|
|
52
|
+
* @param {object} err - 错误对象
|
|
53
|
+
* @param {"json"|"text"} format
|
|
54
|
+
*/
|
|
55
|
+
function handleApiError(err, format) {
|
|
56
|
+
if (err.type === "http_error") {
|
|
57
|
+
outputError(
|
|
58
|
+
{ message: `API 返回错误 (HTTP ${err.statusCode}): ${err.body?.message || JSON.stringify(err.body)}` },
|
|
59
|
+
format
|
|
60
|
+
);
|
|
61
|
+
} else if (err.type === "network_error") {
|
|
62
|
+
outputError({ message: `网络错误: ${err.message}` }, format);
|
|
63
|
+
} else if (err.type === "validation_error") {
|
|
64
|
+
outputError({ message: `参数错误: ${err.message}` }, format);
|
|
65
|
+
} else if (err.type === "api_error") {
|
|
66
|
+
outputError({ message: err.message }, format);
|
|
67
|
+
} else {
|
|
68
|
+
outputError({ message: `未知错误: ${err.message || err}` }, format);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 安全解析整数,无效时返回 defaultValue
|
|
74
|
+
* @param {*} value
|
|
75
|
+
* @param {number} defaultValue
|
|
76
|
+
* @returns {number}
|
|
77
|
+
*/
|
|
78
|
+
function safeParseInt(value, defaultValue) {
|
|
79
|
+
if (value === undefined || value === null) return defaultValue;
|
|
80
|
+
const n = parseInt(value, 10);
|
|
81
|
+
return Number.isNaN(n) ? defaultValue : n;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 从文本(分享口令/带链接的句子)中抽出第一个 http(s) 链接。
|
|
86
|
+
*
|
|
87
|
+
* 小红书用户复制的内容形如「标题 https://xhslink.cn/o/xxx 复制内容,然后进入【小红书】查看相关笔记。」
|
|
88
|
+
* 平台侧的 share_text 是**原样透传上游**的,带中文口令必失败(2026-11-05 实测),所以链接必须本地抽。
|
|
89
|
+
* @param {string} text
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
92
|
+
function extractUrlFromText(text) {
|
|
93
|
+
if (typeof text !== "string") return null;
|
|
94
|
+
const match = text.match(/https?:\/\/[^\s\u4e00-\u9fa5,。、;:!?()【】《》“”‘’]+/i);
|
|
95
|
+
if (!match) return null;
|
|
96
|
+
// 去掉 URL 尾部的英文标点(中文标点已在字符集里排除)
|
|
97
|
+
return match[0].replace(/[),.;:!?'"\]}>]+$/, "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const XHS_ID_RE = /^[0-9a-f]{24}$/i;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 从用户输入里识别小红书笔记 ID(裸 ID / 长链 / 带链接的分享文案)。
|
|
104
|
+
* 短链(xhslink.cn)需先由 resolveXhsShortLink 换成长链。
|
|
105
|
+
* @param {string} text
|
|
106
|
+
* @returns {string|null}
|
|
107
|
+
*/
|
|
108
|
+
function parseXhsNoteId(text) {
|
|
109
|
+
if (typeof text !== "string") return null;
|
|
110
|
+
const raw = text.trim();
|
|
111
|
+
if (XHS_ID_RE.test(raw)) return raw;
|
|
112
|
+
const url = extractUrlFromText(raw);
|
|
113
|
+
if (!url) return null;
|
|
114
|
+
const match = url.match(/(?:explore|discovery\/item|note)\/([0-9a-f]{24})/i);
|
|
115
|
+
return match ? match[1] : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 从用户输入里识别小红书用户 ID(裸 ID / 主页链接)。
|
|
120
|
+
* @param {string} text
|
|
121
|
+
* @returns {string|null}
|
|
122
|
+
*/
|
|
123
|
+
function parseXhsUserId(text) {
|
|
124
|
+
if (typeof text !== "string") return null;
|
|
125
|
+
const raw = text.trim();
|
|
126
|
+
if (XHS_ID_RE.test(raw)) return raw;
|
|
127
|
+
const url = extractUrlFromText(raw);
|
|
128
|
+
if (!url) return null;
|
|
129
|
+
const match = url.match(/user\/profile\/([0-9a-f]{24})/i);
|
|
130
|
+
return match ? match[1] : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 小红书时间戳格式化:上游给的是**真 UTC epoch**(详情秒、笔记列表毫秒),展示需 +8 小时。
|
|
135
|
+
* 与抖音相反(抖音是「北京时间编码」,不要再加 8)。
|
|
136
|
+
* @param {number|string} value epoch 秒或毫秒
|
|
137
|
+
* @returns {string} `YYYY-MM-DD HH:mm:ss`,无效值返回空串
|
|
138
|
+
*/
|
|
139
|
+
function formatXhsTime(value) {
|
|
140
|
+
const n = Number(value);
|
|
141
|
+
if (!n || Number.isNaN(n)) return "";
|
|
142
|
+
const ms = n > 1e12 ? n : n * 1000;
|
|
143
|
+
return new Date(ms + 8 * 3600 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 归一化为整数计数:上游同一平台里数字有 int、字符串、以及「1.2万」这类糊糊写法。
|
|
148
|
+
* @param {*} value
|
|
149
|
+
* @returns {number}
|
|
150
|
+
*/
|
|
151
|
+
function toCount(value) {
|
|
152
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
153
|
+
if (value === null || value === undefined) return 0;
|
|
154
|
+
const text = String(value).replace(/[,\s]/g, "");
|
|
155
|
+
const match = text.match(/^(\d+(?:\.\d+)?)(万|w)?\+?$/i);
|
|
156
|
+
if (!match) return 0;
|
|
157
|
+
const base = Number(match[1]);
|
|
158
|
+
return Math.round(match[2] ? base * 10000 : base);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
outputResult,
|
|
163
|
+
outputError,
|
|
164
|
+
resolveKeyOrExit,
|
|
165
|
+
handleApiError,
|
|
166
|
+
safeParseInt,
|
|
167
|
+
extractUrlFromText,
|
|
168
|
+
parseXhsNoteId,
|
|
169
|
+
parseXhsUserId,
|
|
170
|
+
formatXhsTime,
|
|
171
|
+
toCount,
|
|
172
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小红书达人打分(纯函数,不联网)。
|
|
3
|
+
*
|
|
4
|
+
* 公式与 `backend/api_docs/xhs_pgy_blogger.md` 的「注意事项 2」保持一致,
|
|
5
|
+
* HTTP 用户可依同一份公式自行计算。
|
|
6
|
+
*
|
|
7
|
+
* 三个维度满分 100:粉丝 40 / 互动率 30 / 百赞率 30。
|
|
8
|
+
* 某一段取数失败时,按「已拿到维度的满分之和」归一,并在结果里标 partial。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const FAN_TIERS = [
|
|
12
|
+
[100000, 40],
|
|
13
|
+
[50000, 34],
|
|
14
|
+
[10000, 26],
|
|
15
|
+
[5000, 18],
|
|
16
|
+
[1000, 10],
|
|
17
|
+
];
|
|
18
|
+
const INTERACTION_TIERS = [
|
|
19
|
+
[20, 30],
|
|
20
|
+
[10, 24],
|
|
21
|
+
[5, 16],
|
|
22
|
+
[2, 8],
|
|
23
|
+
];
|
|
24
|
+
const HUNDRED_LIKE_TIERS = [
|
|
25
|
+
[50, 30],
|
|
26
|
+
[20, 24],
|
|
27
|
+
[10, 16],
|
|
28
|
+
[5, 8],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 按阶梯取分:命中第一个 `value >= 门槛` 的档位,否则取兜底分。
|
|
33
|
+
* @param {number} value
|
|
34
|
+
* @param {Array<[number, number]>} tiers
|
|
35
|
+
* @param {number} fallback
|
|
36
|
+
* @returns {number}
|
|
37
|
+
*/
|
|
38
|
+
function pickTier(value, tiers, fallback) {
|
|
39
|
+
for (const [threshold, score] of tiers) {
|
|
40
|
+
if (value >= threshold) return score;
|
|
41
|
+
}
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toFinite(value) {
|
|
46
|
+
// null / undefined / 空串表示「没拿到」,不能当成 0 参与打分
|
|
47
|
+
if (value === null || value === undefined || value === "") return undefined;
|
|
48
|
+
const n = Number(value);
|
|
49
|
+
return Number.isFinite(n) ? n : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 计算达人评分。
|
|
54
|
+
* @param {{fansCount?: number, interactionRate?: number, hundredLikePercent?: number}} metrics
|
|
55
|
+
* @returns {{value: number, tier: string, basis: Array<{item: string, got: number, max: number}>, partial: boolean}}
|
|
56
|
+
*/
|
|
57
|
+
function scoreDaren(metrics = {}) {
|
|
58
|
+
const fansCount = toFinite(metrics.fansCount);
|
|
59
|
+
const interactionRate = toFinite(metrics.interactionRate);
|
|
60
|
+
const hundredLikePercent = toFinite(metrics.hundredLikePercent);
|
|
61
|
+
|
|
62
|
+
const dims = [];
|
|
63
|
+
if (fansCount !== undefined) {
|
|
64
|
+
dims.push({ item: "粉丝数", got: pickTier(fansCount, FAN_TIERS, 4), max: 40 });
|
|
65
|
+
}
|
|
66
|
+
if (interactionRate !== undefined) {
|
|
67
|
+
dims.push({ item: "互动率", got: pickTier(interactionRate, INTERACTION_TIERS, 0), max: 30 });
|
|
68
|
+
}
|
|
69
|
+
if (hundredLikePercent !== undefined) {
|
|
70
|
+
dims.push({ item: "百赞率", got: pickTier(hundredLikePercent, HUNDRED_LIKE_TIERS, 0), max: 30 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!dims.length) {
|
|
74
|
+
return { value: 0, tier: "数据不足", basis: [], partial: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const got = dims.reduce((sum, d) => sum + d.got, 0);
|
|
78
|
+
const max = dims.reduce((sum, d) => sum + d.max, 0);
|
|
79
|
+
const value = Math.round((got / max) * 100);
|
|
80
|
+
const tier = value >= 80 ? "头部" : value >= 55 ? "腰部" : "尾部";
|
|
81
|
+
|
|
82
|
+
return { value, tier, basis: dims, partial: dims.length < 3 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
scoreDaren,
|
|
87
|
+
pickTier,
|
|
88
|
+
FAN_TIERS,
|
|
89
|
+
INTERACTION_TIERS,
|
|
90
|
+
HUNDRED_LIKE_TIERS,
|
|
91
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const { scoreDaren, pickTier } = require("./daren-score");
|
|
5
|
+
|
|
6
|
+
test("scoreDaren scores each tier boundary", () => {
|
|
7
|
+
// 头部:粉丝 10 万 + 互动率 20 + 百赞率 50 = 40+30+30
|
|
8
|
+
assert.equal(scoreDaren({ fansCount: 100000, interactionRate: 20, hundredLikePercent: 50 }).value, 100);
|
|
9
|
+
// 腰部:粉丝 1 万 + 互动率 10 + 百赞率 20 = 26+24+24 = 74
|
|
10
|
+
const waist = scoreDaren({ fansCount: 10000, interactionRate: 10, hundredLikePercent: 20 });
|
|
11
|
+
assert.equal(waist.value, 74);
|
|
12
|
+
assert.equal(waist.tier, "腰部");
|
|
13
|
+
// 尾部:粉丝 100 + 互动率 0 + 百赞率 0 = 4+0+0
|
|
14
|
+
const tail = scoreDaren({ fansCount: 100, interactionRate: 0, hundredLikePercent: 0 });
|
|
15
|
+
assert.equal(tail.value, 4);
|
|
16
|
+
assert.equal(tail.tier, "尾部");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("scoreDaren falls back to the lowest band just below a threshold", () => {
|
|
20
|
+
assert.equal(pickTier(99999, [[100000, 40], [50000, 34]], 4), 34);
|
|
21
|
+
assert.equal(pickTier(999, [[1000, 10]], 4), 4);
|
|
22
|
+
assert.equal(pickTier(1.9, [[2, 8]], 0), 0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("scoreDaren normalises when a segment is missing", () => {
|
|
26
|
+
// 只有粉丝 + 互动率:40 + 30 = 70 / 70 → 100
|
|
27
|
+
const partial = scoreDaren({ fansCount: 200000, interactionRate: 25 });
|
|
28
|
+
assert.equal(partial.value, 100);
|
|
29
|
+
assert.equal(partial.partial, true);
|
|
30
|
+
// 只有粉丝 1 万:26/40 = 65 → 腰部
|
|
31
|
+
const onlyFans = scoreDaren({ fansCount: 10000 });
|
|
32
|
+
assert.equal(onlyFans.value, 65);
|
|
33
|
+
assert.equal(onlyFans.tier, "腰部");
|
|
34
|
+
assert.equal(onlyFans.basis.length, 1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("scoreDaren reports 数据不足 when nothing usable is passed", () => {
|
|
38
|
+
const empty = scoreDaren({});
|
|
39
|
+
assert.equal(empty.value, 0);
|
|
40
|
+
assert.equal(empty.tier, "数据不足");
|
|
41
|
+
assert.equal(empty.partial, true);
|
|
42
|
+
assert.deepEqual(empty.basis, []);
|
|
43
|
+
|
|
44
|
+
// 非数字(如上游给 null/undefined/空串)同样视为缺失
|
|
45
|
+
assert.equal(scoreDaren({ fansCount: null, interactionRate: "abc" }).tier, "数据不足");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("scoreDaren basis keeps got/max per dimension for explainability", () => {
|
|
49
|
+
const result = scoreDaren({ fansCount: 5000, interactionRate: 5, hundredLikePercent: 10 });
|
|
50
|
+
assert.deepEqual(result.basis, [
|
|
51
|
+
{ item: "粉丝数", got: 18, max: 40 },
|
|
52
|
+
{ item: "互动率", got: 16, max: 30 },
|
|
53
|
+
{ item: "百赞率", got: 16, max: 30 },
|
|
54
|
+
]);
|
|
55
|
+
assert.equal(result.value, 50);
|
|
56
|
+
assert.equal(result.tier, "尾部");
|
|
57
|
+
});
|