koishi-plugin-xhs-parser 0.1.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 ADDED
@@ -0,0 +1,36 @@
1
+ # koishi-plugin-xhs-parser
2
+
3
+ 小红书 / RedNote 链接解析插件,支持普通链接、`xhslink.com` 短链和聊天平台卡片消息中的链接提取,可选合并转发。
4
+
5
+ ## 功能
6
+
7
+ - 解析 `https://www.xiaohongshu.com/explore/...`
8
+ - 解析 `https://www.xiaohongshu.com/discovery/item/...`
9
+ - 解析 `https://xhslink.com/...`,例如 `http://xhslink.com/m/AixEkyLwpfs`
10
+ - 从 Koishi 卡片元素的 `data` 字段、转义 JSON、普通文本中提取小红书链接
11
+ - 支持图片、视频链接、原文链接和基础互动数据返回
12
+ - 支持 OneBot / Red 适配器的合并转发元素
13
+
14
+ ## 本地测试
15
+
16
+ ```bash
17
+ npm install
18
+ npm test
19
+ npm run build
20
+ ```
21
+
22
+ 也可以直接跑一次真实解析:
23
+
24
+ ```bash
25
+ npm run dev -- "http://xhslink.com/m/AixEkyLwpfs"
26
+ ```
27
+
28
+ ## Koishi 使用
29
+
30
+ 构建后在 Koishi 配置中加载本地插件:
31
+
32
+ ```yaml
33
+ plugins:
34
+ /absolute/path/to/xhs-parser/lib:
35
+ enabled: true
36
+ ```
package/lib/index.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { Context, Schema } from 'koishi';
2
+ export declare const name = "xhs-parser";
3
+ export interface Config {
4
+ enabled: boolean;
5
+ parseMode: ('link' | 'card')[];
6
+ waitTip?: string | null;
7
+ useForward: boolean;
8
+ quote: boolean;
9
+ middleware: boolean;
10
+ parseLimit: number;
11
+ minimumInterval: number;
12
+ userAgent: string;
13
+ cookie?: string;
14
+ timeout: number;
15
+ imageFormat: 'jpeg' | 'png' | 'webp' | 'heic' | 'avif' | 'auto';
16
+ showImages: boolean;
17
+ maxImages: number;
18
+ maxDescLength: number;
19
+ descTruncateSuffix: string;
20
+ showVideo: boolean;
21
+ showStats: boolean;
22
+ showLink: boolean;
23
+ showError: boolean;
24
+ loggerinfo: boolean;
25
+ }
26
+ export declare const Config: Schema<Config>;
27
+ export declare const usage = "\n\u53D1\u9001\u5C0F\u7EA2\u4E66\u94FE\u63A5\u6216\u5E73\u53F0\u5361\u7247\u5373\u53EF\u81EA\u52A8\u89E3\u6790\u3002\n\n\u652F\u6301\u793A\u4F8B\uFF1A\n\n- https://www.xiaohongshu.com/explore/...\n- https://www.xiaohongshu.com/discovery/item/...\n- http://xhslink.com/m/AixEkyLwpfs\n";
28
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js ADDED
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.usage = exports.Config = exports.name = void 0;
4
+ exports.apply = apply;
5
+ const koishi_1 = require("koishi");
6
+ const parser_1 = require("./parser");
7
+ exports.name = 'xhs-parser';
8
+ const logger = new koishi_1.Logger(exports.name);
9
+ exports.Config = koishi_1.Schema.intersect([
10
+ koishi_1.Schema.object({
11
+ enabled: koishi_1.Schema.boolean().default(true).description('开启小红书链接/卡片解析。'),
12
+ parseMode: koishi_1.Schema.array(koishi_1.Schema.union([
13
+ koishi_1.Schema.const('link').description('普通链接'),
14
+ koishi_1.Schema.const('card').description('卡片消息'),
15
+ ])).role('checkbox').default(['link', 'card']).description('选择解析来源。'),
16
+ waitTip: koishi_1.Schema.union([
17
+ koishi_1.Schema.const(null).description('不发送提示'),
18
+ koishi_1.Schema.string().description('解析前发送提示语').default('正在解析小红书链接...'),
19
+ ]).default(null).description('等待提示。'),
20
+ }).description('基础设置'),
21
+ koishi_1.Schema.object({
22
+ useForward: koishi_1.Schema.boolean().default(false).description('开启合并转发。主要适用于 onebot / red 适配器。').experimental(),
23
+ quote: koishi_1.Schema.boolean().default(true).description('普通发送时引用原消息。'),
24
+ middleware: koishi_1.Schema.boolean().default(false).description('以前置中间件模式捕获消息。').experimental(),
25
+ parseLimit: koishi_1.Schema.number().min(1).max(10).step(1).default(3).description('单条消息最多解析的链接数量。'),
26
+ minimumInterval: koishi_1.Schema.number().min(0).max(3600).step(1).default(180).description('同频道同链接去重间隔,单位秒。0 表示不去重。'),
27
+ }).description('发送设置'),
28
+ koishi_1.Schema.object({
29
+ imageFormat: koishi_1.Schema.union([
30
+ koishi_1.Schema.const('jpeg').description('jpeg'),
31
+ koishi_1.Schema.const('png').description('png'),
32
+ koishi_1.Schema.const('webp').description('webp'),
33
+ koishi_1.Schema.const('heic').description('heic'),
34
+ koishi_1.Schema.const('avif').description('avif'),
35
+ koishi_1.Schema.const('auto').description('原图格式'),
36
+ ]).default('jpeg').description('图片返回格式。'),
37
+ showImages: koishi_1.Schema.boolean().default(true).description('返回图片。'),
38
+ maxImages: koishi_1.Schema.number().min(0).max(18).step(1).default(9).description('单个笔记最多发送图片数。'),
39
+ maxDescLength: koishi_1.Schema.number().min(0).max(2000).step(10).default(160).description('描述最大字数。设为 0 时不展示描述。'),
40
+ descTruncateSuffix: koishi_1.Schema.string().default('...(已截断)').description('描述超出最大字数时追加的截断标志。'),
41
+ showVideo: koishi_1.Schema.boolean().default(true).description('返回视频元素。'),
42
+ showStats: koishi_1.Schema.boolean().default(true).description('展示点赞、收藏、评论、分享数据。'),
43
+ showLink: koishi_1.Schema.boolean().default(true).description('展示原文链接。'),
44
+ }).description('内容设置'),
45
+ koishi_1.Schema.object({
46
+ userAgent: koishi_1.Schema.string().default('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0').description('请求小红书页面时使用的 User-Agent。'),
47
+ cookie: koishi_1.Schema.string().role('textarea').default('').description('可选 Cookie。遇到风控或无法读取页面数据时可填写。'),
48
+ timeout: koishi_1.Schema.number().min(3).max(60).step(1).default(15).description('请求超时时间,单位秒。'),
49
+ showError: koishi_1.Schema.boolean().default(false).description('解析失败时向聊天发送错误提示。'),
50
+ loggerinfo: koishi_1.Schema.boolean().default(false).description('输出调试日志。').experimental(),
51
+ }).description('网络与调试'),
52
+ ]);
53
+ exports.usage = `
54
+ 发送小红书链接或平台卡片即可自动解析。
55
+
56
+ 支持示例:
57
+
58
+ - https://www.xiaohongshu.com/explore/...
59
+ - https://www.xiaohongshu.com/discovery/item/...
60
+ - http://xhslink.com/m/AixEkyLwpfs
61
+ `;
62
+ function apply(ctx, config) {
63
+ if (!config.enabled)
64
+ return;
65
+ const recent = new Map();
66
+ ctx.middleware(async (session, next) => {
67
+ const content = session.content || session.stripped?.content || '';
68
+ const isCard = /^<\w+\s/i.test(content) || content.includes('data=');
69
+ if (isCard && !config.parseMode.includes('card'))
70
+ return next();
71
+ if (!isCard && !config.parseMode.includes('link'))
72
+ return next();
73
+ const links = (0, parser_1.extractXhsLinks)(content).slice(0, config.parseLimit);
74
+ if (!links.length)
75
+ return next();
76
+ const targets = links.filter((link) => shouldProcess(recent, session.channelId || session.guildId || 'private', link, config.minimumInterval));
77
+ if (!targets.length)
78
+ return next();
79
+ handleLinks(session, targets, config).catch((error) => {
80
+ logger.warn(error);
81
+ });
82
+ return next();
83
+ }, config.middleware);
84
+ }
85
+ async function handleLinks(session, links, config) {
86
+ let waitTipMessageId;
87
+ if (config.waitTip) {
88
+ const result = await session.send(`${koishi_1.h.quote(session.messageId)}${config.waitTip}`);
89
+ waitTipMessageId = Array.isArray(result) ? result[0] : result;
90
+ }
91
+ try {
92
+ const allMessages = [];
93
+ for (const link of links) {
94
+ if (config.loggerinfo)
95
+ logger.info(`parse ${link}`);
96
+ const note = await (0, parser_1.fetchXhsNote)(link, config);
97
+ allMessages.push(...(0, parser_1.buildXhsMessages)(note, config, session));
98
+ }
99
+ if (!allMessages.length)
100
+ return;
101
+ if (config.useForward && (session.platform === 'onebot' || session.platform === 'red')) {
102
+ await session.send((0, koishi_1.h)('figure', { children: allMessages }));
103
+ return;
104
+ }
105
+ if (config.quote) {
106
+ await session.send((0, koishi_1.h)('message', koishi_1.h.quote(session.messageId), allMessages[0].children));
107
+ for (const message of allMessages.slice(1))
108
+ await session.send(message);
109
+ return;
110
+ }
111
+ for (const message of allMessages)
112
+ await session.send(message);
113
+ }
114
+ catch (error) {
115
+ logger.warn(error);
116
+ if (config.showError)
117
+ await session.send(`小红书解析失败:${error instanceof Error ? error.message : String(error)}`);
118
+ }
119
+ finally {
120
+ if (waitTipMessageId) {
121
+ await session.bot?.deleteMessage?.(session.channelId, waitTipMessageId).catch?.(() => undefined);
122
+ }
123
+ }
124
+ }
125
+ function shouldProcess(recent, channelId, link, seconds) {
126
+ if (seconds <= 0)
127
+ return true;
128
+ const key = `${channelId}:${link}`;
129
+ const now = Date.now();
130
+ const last = recent.get(key);
131
+ if (last && now - last < seconds * 1000)
132
+ return false;
133
+ recent.set(key, now);
134
+ return true;
135
+ }
@@ -0,0 +1,40 @@
1
+ import { h } from 'koishi';
2
+ export interface XhsConfigLike {
3
+ userAgent: string;
4
+ timeout: number;
5
+ imageFormat: 'jpeg' | 'png' | 'webp' | 'heic' | 'avif' | 'auto';
6
+ showVideo: boolean;
7
+ showImages: boolean;
8
+ maxImages: number;
9
+ maxDescLength: number;
10
+ descTruncateSuffix: string;
11
+ showStats: boolean;
12
+ showLink: boolean;
13
+ cookie?: string;
14
+ }
15
+ export interface XhsNote {
16
+ id: string;
17
+ url: string;
18
+ title: string;
19
+ desc: string;
20
+ type: 'video' | 'normal' | 'unknown';
21
+ authorName: string;
22
+ authorId: string;
23
+ likedCount?: string | number;
24
+ collectedCount?: string | number;
25
+ commentCount?: string | number;
26
+ shareCount?: string | number;
27
+ imageUrls: string[];
28
+ videoUrls: string[];
29
+ }
30
+ export declare function extractXhsLinks(content: string): string[];
31
+ export declare function resolveXhsLink(rawUrl: string, config: XhsConfigLike): Promise<string>;
32
+ export declare function fetchXhsNote(rawUrl: string, config: XhsConfigLike): Promise<XhsNote>;
33
+ export declare function buildXhsMessages(note: XhsNote, config: XhsConfigLike, session?: {
34
+ userId?: string;
35
+ username?: string;
36
+ author?: {
37
+ nickname?: string;
38
+ };
39
+ }): h[];
40
+ export declare function extractInitialStateNote(html: string): any | null;
package/lib/parser.js ADDED
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractXhsLinks = extractXhsLinks;
4
+ exports.resolveXhsLink = resolveXhsLink;
5
+ exports.fetchXhsNote = fetchXhsNote;
6
+ exports.buildXhsMessages = buildXhsMessages;
7
+ exports.extractInitialStateNote = extractInitialStateNote;
8
+ const koishi_1 = require("koishi");
9
+ const URL_BOUNDARY = '[^\\s"\'<>\\\\^`{|},。;!?、【】《》]+';
10
+ const LINK_PATTERNS = [
11
+ new RegExp(`https?://www\\.xiaohongshu\\.com/explore/${URL_BOUNDARY}`, 'gi'),
12
+ new RegExp(`https?://www\\.xiaohongshu\\.com/discovery/item/${URL_BOUNDARY}`, 'gi'),
13
+ new RegExp(`https?://xhslink\\.com/${URL_BOUNDARY}`, 'gi'),
14
+ new RegExp(`http://xhslink\\.com/${URL_BOUNDARY}`, 'gi'),
15
+ ];
16
+ const DEFAULT_HEADERS = {
17
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
18
+ referer: 'https://www.xiaohongshu.com/explore',
19
+ };
20
+ function extractXhsLinks(content) {
21
+ const candidates = expandTextCandidates(content);
22
+ const links = [];
23
+ for (const candidate of candidates) {
24
+ const normalized = normalizeText(candidate);
25
+ for (const pattern of LINK_PATTERNS) {
26
+ pattern.lastIndex = 0;
27
+ let match;
28
+ while ((match = pattern.exec(normalized))) {
29
+ links.push(cleanUrl(match[0]));
30
+ }
31
+ }
32
+ }
33
+ return [...new Set(links)];
34
+ }
35
+ async function resolveXhsLink(rawUrl, config) {
36
+ const url = ensureProtocol(rawUrl);
37
+ if (!/xhslink\.com/i.test(url))
38
+ return url;
39
+ const response = await fetchWithTimeout(url, config, { redirect: 'manual' });
40
+ const location = response.headers.get('location');
41
+ if (location)
42
+ return new URL(location, url).toString();
43
+ if (response.status >= 300 && response.status < 400)
44
+ return url;
45
+ const followed = await fetchWithTimeout(url, config, { redirect: 'follow' });
46
+ return followed.url || url;
47
+ }
48
+ async function fetchXhsNote(rawUrl, config) {
49
+ const url = await resolveXhsLink(rawUrl, config);
50
+ const html = await fetchText(url, config);
51
+ const data = extractInitialStateNote(html);
52
+ if (!data) {
53
+ throw new Error('未能从页面中读取小红书笔记数据。可能需要 Cookie,或链接已失效。');
54
+ }
55
+ return buildNote(data, url, config);
56
+ }
57
+ function buildXhsMessages(note, config, session) {
58
+ const messages = [];
59
+ const text = formatNoteText(note, config);
60
+ messages.push((0, koishi_1.h)('message', {
61
+ userId: session?.userId,
62
+ nickname: session?.author?.nickname || session?.username,
63
+ }, koishi_1.h.text(text)));
64
+ if (config.showImages) {
65
+ for (const imageUrl of note.imageUrls.slice(0, config.maxImages)) {
66
+ messages.push((0, koishi_1.h)('message', {
67
+ userId: session?.userId,
68
+ nickname: session?.author?.nickname || session?.username,
69
+ }, koishi_1.h.image(imageUrl)));
70
+ }
71
+ }
72
+ if (config.showVideo) {
73
+ for (const videoUrl of note.videoUrls.slice(0, 1)) {
74
+ messages.push((0, koishi_1.h)('message', {
75
+ userId: session?.userId,
76
+ nickname: session?.author?.nickname || session?.username,
77
+ }, koishi_1.h.video(videoUrl)));
78
+ }
79
+ }
80
+ return messages;
81
+ }
82
+ function extractInitialStateNote(html) {
83
+ const scripts = [...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)]
84
+ .map((match) => match[1].trim())
85
+ .reverse();
86
+ const script = scripts.find((item) => item.startsWith('window.__INITIAL_STATE__='));
87
+ if (!script)
88
+ return null;
89
+ const payload = script
90
+ .replace(/^window\.__INITIAL_STATE__=/, '')
91
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
92
+ const state = parseLooseObject(payload);
93
+ return deepGet(state, ['noteData', 'data', 'noteData'])
94
+ || getFirstNoteFromDetailMap(deepGet(state, ['note', 'noteDetailMap']))
95
+ || null;
96
+ }
97
+ function expandTextCandidates(content) {
98
+ const values = new Set([content]);
99
+ try {
100
+ for (const element of koishi_1.h.parse(content))
101
+ collectElementText(element, values);
102
+ }
103
+ catch {
104
+ // Some adapters deliver partial XML snippets; regex extraction below still handles them.
105
+ }
106
+ for (const match of content.matchAll(/\bdata=(?:"([^"]*)"|'([^']*)')/gi)) {
107
+ values.add(match[1] || match[2] || '');
108
+ }
109
+ for (const value of [...values]) {
110
+ const decoded = decodeHtmlEntities(value);
111
+ values.add(decoded);
112
+ maybeCollectJsonValues(decoded, values);
113
+ }
114
+ return [...values];
115
+ }
116
+ function collectElementText(element, values) {
117
+ if (typeof element === 'string') {
118
+ values.add(element);
119
+ return;
120
+ }
121
+ for (const value of Object.values(element.attrs || {})) {
122
+ if (typeof value === 'string')
123
+ values.add(value);
124
+ }
125
+ for (const child of element.children || []) {
126
+ collectElementText(child, values);
127
+ }
128
+ }
129
+ function maybeCollectJsonValues(text, values) {
130
+ try {
131
+ const json = JSON.parse(text);
132
+ walkJson(json, values);
133
+ }
134
+ catch {
135
+ const unescaped = text.replace(/\\"/g, '"').replace(/\\\//g, '/');
136
+ if (unescaped !== text)
137
+ values.add(unescaped);
138
+ }
139
+ }
140
+ function walkJson(value, values) {
141
+ if (typeof value === 'string') {
142
+ values.add(value);
143
+ return;
144
+ }
145
+ if (Array.isArray(value)) {
146
+ for (const item of value)
147
+ walkJson(item, values);
148
+ return;
149
+ }
150
+ if (value && typeof value === 'object') {
151
+ for (const item of Object.values(value))
152
+ walkJson(item, values);
153
+ }
154
+ }
155
+ function normalizeText(text) {
156
+ let value = decodeHtmlEntities(text).replace(/\\\//g, '/');
157
+ try {
158
+ value = decodeURIComponent(value);
159
+ }
160
+ catch {
161
+ // Keep the original if it is only partly percent-encoded.
162
+ }
163
+ return value;
164
+ }
165
+ function decodeHtmlEntities(text) {
166
+ return text
167
+ .replace(/&quot;/g, '"')
168
+ .replace(/&#34;/g, '"')
169
+ .replace(/&#x22;/gi, '"')
170
+ .replace(/&apos;/g, "'")
171
+ .replace(/&#39;/g, "'")
172
+ .replace(/&#x27;/gi, "'")
173
+ .replace(/&amp;/g, '&')
174
+ .replace(/&lt;/g, '<')
175
+ .replace(/&gt;/g, '>');
176
+ }
177
+ function cleanUrl(url) {
178
+ return ensureProtocol(url)
179
+ .replace(/[),,。;;!?!]+$/g, '')
180
+ .replace(/&amp;/g, '&');
181
+ }
182
+ function ensureProtocol(url) {
183
+ return /^https?:\/\//i.test(url) ? url : `https://${url}`;
184
+ }
185
+ async function fetchText(url, config) {
186
+ const response = await fetchWithTimeout(url, config, { redirect: 'follow' });
187
+ if (!response.ok)
188
+ throw new Error(`请求小红书页面失败:HTTP ${response.status}`);
189
+ return response.text();
190
+ }
191
+ async function fetchWithTimeout(url, config, init) {
192
+ const controller = new AbortController();
193
+ const timer = setTimeout(() => controller.abort(), config.timeout * 1000);
194
+ try {
195
+ return await fetch(url, {
196
+ ...init,
197
+ signal: controller.signal,
198
+ headers: {
199
+ ...DEFAULT_HEADERS,
200
+ 'user-agent': config.userAgent,
201
+ ...(config.cookie ? { cookie: config.cookie } : {}),
202
+ ...(init.headers || {}),
203
+ },
204
+ });
205
+ }
206
+ finally {
207
+ clearTimeout(timer);
208
+ }
209
+ }
210
+ function parseLooseObject(payload) {
211
+ return JSON.parse(payload);
212
+ }
213
+ function getFirstNoteFromDetailMap(map) {
214
+ if (!map || typeof map !== 'object')
215
+ return null;
216
+ const first = Object.values(map)[0];
217
+ return first?.note || null;
218
+ }
219
+ function buildNote(data, url, config) {
220
+ const id = get(data, 'noteId') || extractIdFromUrl(url);
221
+ const type = normalizeType(get(data, 'type'));
222
+ return {
223
+ id,
224
+ url: id ? `https://www.xiaohongshu.com/explore/${id}` : url,
225
+ title: get(data, 'title') || '小红书笔记',
226
+ desc: get(data, 'desc') || '',
227
+ type,
228
+ authorName: get(data, 'user.nickname') || get(data, 'user.nickName') || '未知作者',
229
+ authorId: get(data, 'user.userId') || '',
230
+ likedCount: get(data, 'interactInfo.likedCount'),
231
+ collectedCount: get(data, 'interactInfo.collectedCount'),
232
+ commentCount: get(data, 'interactInfo.commentCount'),
233
+ shareCount: get(data, 'interactInfo.shareCount'),
234
+ imageUrls: extractImageUrls(data, config.imageFormat),
235
+ videoUrls: extractVideoUrls(data),
236
+ };
237
+ }
238
+ function formatNoteText(note, config) {
239
+ const lines = [
240
+ `小红书:${note.title}`,
241
+ `作者:${note.authorName}`,
242
+ ];
243
+ if (note.desc && config.maxDescLength > 0) {
244
+ lines.push(trimText(note.desc, config.maxDescLength, config.descTruncateSuffix));
245
+ }
246
+ if (config.showStats) {
247
+ lines.push(`点赞:${displayCount(note.likedCount)} 收藏:${displayCount(note.collectedCount)} 评论:${displayCount(note.commentCount)} 分享:${displayCount(note.shareCount)}`);
248
+ }
249
+ if (config.showLink)
250
+ lines.push(note.url);
251
+ return lines.join('\n');
252
+ }
253
+ function extractImageUrls(data, format) {
254
+ const images = Array.isArray(data?.imageList) ? data.imageList : [];
255
+ const urls = [];
256
+ for (const image of images) {
257
+ const source = image?.urlDefault || image?.url;
258
+ if (!source || typeof source !== 'string')
259
+ continue;
260
+ const token = extractImageToken(source);
261
+ if (!token)
262
+ continue;
263
+ urls.push(format === 'auto'
264
+ ? `https://sns-img-bd.xhscdn.com/${token}`
265
+ : `https://ci.xiaohongshu.com/${token}?imageView2/format/${format}`);
266
+ }
267
+ return [...new Set(urls)];
268
+ }
269
+ function extractImageToken(url) {
270
+ try {
271
+ const parsed = new URL(url.replace(/\\u002F/g, '/'));
272
+ return parsed.pathname.split('/').slice(3).join('/').split('!')[0];
273
+ }
274
+ catch {
275
+ return url.split('/').slice(5).join('/').split('!')[0];
276
+ }
277
+ }
278
+ function extractVideoUrls(data) {
279
+ const originVideoKey = get(data, 'video.consumer.originVideoKey');
280
+ if (originVideoKey)
281
+ return [`https://sns-video-bd.xhscdn.com/${originVideoKey}`];
282
+ const streams = [
283
+ ...(get(data, 'video.media.stream.h265') || []),
284
+ ...(get(data, 'video.media.stream.h264') || []),
285
+ ].filter(Boolean);
286
+ streams.sort((a, b) => (a.height || 0) - (b.height || 0));
287
+ const best = streams[streams.length - 1];
288
+ return best?.backupUrls?.[0] ? [best.backupUrls[0]] : best?.masterUrl ? [best.masterUrl] : [];
289
+ }
290
+ function normalizeType(type) {
291
+ if (type === 'video')
292
+ return 'video';
293
+ if (type === 'normal')
294
+ return 'normal';
295
+ return 'unknown';
296
+ }
297
+ function get(source, path) {
298
+ return deepGet(source, path.split('.'));
299
+ }
300
+ function deepGet(source, path) {
301
+ let cursor = source;
302
+ for (const key of path) {
303
+ if (cursor == null)
304
+ return undefined;
305
+ cursor = cursor[key];
306
+ }
307
+ return cursor;
308
+ }
309
+ function extractIdFromUrl(url) {
310
+ return url.match(/\/(?:explore|item)\/([^/?#]+)/)?.[1] || '';
311
+ }
312
+ function trimText(text, maxLength, suffix) {
313
+ return text.length > maxLength ? `${text.slice(0, maxLength)}${suffix}` : text;
314
+ }
315
+ function displayCount(value) {
316
+ return value == null || value === '' ? '-' : String(value);
317
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "koishi-plugin-xhs-parser",
3
+ "version": "0.1.0",
4
+ "description": "Parse Xiaohongshu links and cards for Koishi.",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "src"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json",
13
+ "test": "vitest run",
14
+ "dev": "tsx scripts/local-test.ts"
15
+ },
16
+ "keywords": [
17
+ "chatbot",
18
+ "koishi",
19
+ "plugin",
20
+ "xiaohongshu",
21
+ "xhs",
22
+ "rednote"
23
+ ],
24
+ "license": "MIT",
25
+ "peerDependencies": {
26
+ "koishi": "^4.16.8"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.14.10",
30
+ "koishi": "^4.18.8",
31
+ "tsx": "^4.20.3",
32
+ "typescript": "^5.5.4",
33
+ "vitest": "^1.6.0"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { Context, Logger, Schema, h } from 'koishi'
2
+ import { buildXhsMessages, extractXhsLinks, fetchXhsNote } from './parser'
3
+
4
+ export const name = 'xhs-parser'
5
+
6
+ const logger = new Logger(name)
7
+
8
+ export interface Config {
9
+ enabled: boolean
10
+ parseMode: ('link' | 'card')[]
11
+ waitTip?: string | null
12
+ useForward: boolean
13
+ quote: boolean
14
+ middleware: boolean
15
+ parseLimit: number
16
+ minimumInterval: number
17
+ userAgent: string
18
+ cookie?: string
19
+ timeout: number
20
+ imageFormat: 'jpeg' | 'png' | 'webp' | 'heic' | 'avif' | 'auto'
21
+ showImages: boolean
22
+ maxImages: number
23
+ maxDescLength: number
24
+ descTruncateSuffix: string
25
+ showVideo: boolean
26
+ showStats: boolean
27
+ showLink: boolean
28
+ showError: boolean
29
+ loggerinfo: boolean
30
+ }
31
+
32
+ export const Config: Schema<Config> = Schema.intersect([
33
+ Schema.object({
34
+ enabled: Schema.boolean().default(true).description('开启小红书链接/卡片解析。'),
35
+ parseMode: Schema.array(Schema.union([
36
+ Schema.const('link').description('普通链接'),
37
+ Schema.const('card').description('卡片消息'),
38
+ ])).role('checkbox').default(['link', 'card']).description('选择解析来源。'),
39
+ waitTip: Schema.union([
40
+ Schema.const(null).description('不发送提示'),
41
+ Schema.string().description('解析前发送提示语').default('正在解析小红书链接...'),
42
+ ]).default(null).description('等待提示。'),
43
+ }).description('基础设置'),
44
+ Schema.object({
45
+ useForward: Schema.boolean().default(false).description('开启合并转发。主要适用于 onebot / red 适配器。').experimental(),
46
+ quote: Schema.boolean().default(true).description('普通发送时引用原消息。'),
47
+ middleware: Schema.boolean().default(false).description('以前置中间件模式捕获消息。').experimental(),
48
+ parseLimit: Schema.number().min(1).max(10).step(1).default(3).description('单条消息最多解析的链接数量。'),
49
+ minimumInterval: Schema.number().min(0).max(3600).step(1).default(180).description('同频道同链接去重间隔,单位秒。0 表示不去重。'),
50
+ }).description('发送设置'),
51
+ Schema.object({
52
+ imageFormat: Schema.union([
53
+ Schema.const('jpeg').description('jpeg'),
54
+ Schema.const('png').description('png'),
55
+ Schema.const('webp').description('webp'),
56
+ Schema.const('heic').description('heic'),
57
+ Schema.const('avif').description('avif'),
58
+ Schema.const('auto').description('原图格式'),
59
+ ]).default('jpeg').description('图片返回格式。'),
60
+ showImages: Schema.boolean().default(true).description('返回图片。'),
61
+ maxImages: Schema.number().min(0).max(18).step(1).default(9).description('单个笔记最多发送图片数。'),
62
+ maxDescLength: Schema.number().min(0).max(2000).step(10).default(160).description('描述最大字数。设为 0 时不展示描述。'),
63
+ descTruncateSuffix: Schema.string().default('...(已截断)').description('描述超出最大字数时追加的截断标志。'),
64
+ showVideo: Schema.boolean().default(true).description('返回视频元素。'),
65
+ showStats: Schema.boolean().default(true).description('展示点赞、收藏、评论、分享数据。'),
66
+ showLink: Schema.boolean().default(true).description('展示原文链接。'),
67
+ }).description('内容设置'),
68
+ Schema.object({
69
+ userAgent: Schema.string().default('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0').description('请求小红书页面时使用的 User-Agent。'),
70
+ cookie: Schema.string().role('textarea').default('').description('可选 Cookie。遇到风控或无法读取页面数据时可填写。'),
71
+ timeout: Schema.number().min(3).max(60).step(1).default(15).description('请求超时时间,单位秒。'),
72
+ showError: Schema.boolean().default(false).description('解析失败时向聊天发送错误提示。'),
73
+ loggerinfo: Schema.boolean().default(false).description('输出调试日志。').experimental(),
74
+ }).description('网络与调试'),
75
+ ])
76
+
77
+ export const usage = `
78
+ 发送小红书链接或平台卡片即可自动解析。
79
+
80
+ 支持示例:
81
+
82
+ - https://www.xiaohongshu.com/explore/...
83
+ - https://www.xiaohongshu.com/discovery/item/...
84
+ - http://xhslink.com/m/AixEkyLwpfs
85
+ `
86
+
87
+ export function apply(ctx: Context, config: Config) {
88
+ if (!config.enabled) return
89
+
90
+ const recent = new Map<string, number>()
91
+
92
+ ctx.middleware(async (session, next) => {
93
+ const content = session.content || session.stripped?.content || ''
94
+ const isCard = /^<\w+\s/i.test(content) || content.includes('data=')
95
+
96
+ if (isCard && !config.parseMode.includes('card')) return next()
97
+ if (!isCard && !config.parseMode.includes('link')) return next()
98
+
99
+ const links = extractXhsLinks(content).slice(0, config.parseLimit)
100
+ if (!links.length) return next()
101
+
102
+ const targets = links.filter((link) => shouldProcess(recent, session.channelId || session.guildId || 'private', link, config.minimumInterval))
103
+ if (!targets.length) return next()
104
+
105
+ handleLinks(session, targets, config).catch((error) => {
106
+ logger.warn(error)
107
+ })
108
+
109
+ return next()
110
+ }, config.middleware)
111
+ }
112
+
113
+ async function handleLinks(session: any, links: string[], config: Config) {
114
+ let waitTipMessageId: string | undefined
115
+
116
+ if (config.waitTip) {
117
+ const result = await session.send(`${h.quote(session.messageId)}${config.waitTip}`)
118
+ waitTipMessageId = Array.isArray(result) ? result[0] : result
119
+ }
120
+
121
+ try {
122
+ const allMessages: h[] = []
123
+
124
+ for (const link of links) {
125
+ if (config.loggerinfo) logger.info(`parse ${link}`)
126
+ const note = await fetchXhsNote(link, config)
127
+ allMessages.push(...buildXhsMessages(note, config, session))
128
+ }
129
+
130
+ if (!allMessages.length) return
131
+
132
+ if (config.useForward && (session.platform === 'onebot' || session.platform === 'red')) {
133
+ await session.send(h('figure', { children: allMessages }))
134
+ return
135
+ }
136
+
137
+ if (config.quote) {
138
+ await session.send(h('message', h.quote(session.messageId), allMessages[0].children))
139
+ for (const message of allMessages.slice(1)) await session.send(message)
140
+ return
141
+ }
142
+
143
+ for (const message of allMessages) await session.send(message)
144
+ } catch (error) {
145
+ logger.warn(error)
146
+ if (config.showError) await session.send(`小红书解析失败:${error instanceof Error ? error.message : String(error)}`)
147
+ } finally {
148
+ if (waitTipMessageId) {
149
+ await session.bot?.deleteMessage?.(session.channelId, waitTipMessageId).catch?.(() => undefined)
150
+ }
151
+ }
152
+ }
153
+
154
+ function shouldProcess(recent: Map<string, number>, channelId: string, link: string, seconds: number) {
155
+ if (seconds <= 0) return true
156
+
157
+ const key = `${channelId}:${link}`
158
+ const now = Date.now()
159
+ const last = recent.get(key)
160
+ if (last && now - last < seconds * 1000) return false
161
+
162
+ recent.set(key, now)
163
+ return true
164
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,376 @@
1
+ import { h } from 'koishi'
2
+
3
+ export interface XhsConfigLike {
4
+ userAgent: string
5
+ timeout: number
6
+ imageFormat: 'jpeg' | 'png' | 'webp' | 'heic' | 'avif' | 'auto'
7
+ showVideo: boolean
8
+ showImages: boolean
9
+ maxImages: number
10
+ maxDescLength: number
11
+ descTruncateSuffix: string
12
+ showStats: boolean
13
+ showLink: boolean
14
+ cookie?: string
15
+ }
16
+
17
+ export interface XhsNote {
18
+ id: string
19
+ url: string
20
+ title: string
21
+ desc: string
22
+ type: 'video' | 'normal' | 'unknown'
23
+ authorName: string
24
+ authorId: string
25
+ likedCount?: string | number
26
+ collectedCount?: string | number
27
+ commentCount?: string | number
28
+ shareCount?: string | number
29
+ imageUrls: string[]
30
+ videoUrls: string[]
31
+ }
32
+
33
+ const URL_BOUNDARY = '[^\\s"\'<>\\\\^`{|},。;!?、【】《》]+'
34
+ const LINK_PATTERNS = [
35
+ new RegExp(`https?://www\\.xiaohongshu\\.com/explore/${URL_BOUNDARY}`, 'gi'),
36
+ new RegExp(`https?://www\\.xiaohongshu\\.com/discovery/item/${URL_BOUNDARY}`, 'gi'),
37
+ new RegExp(`https?://xhslink\\.com/${URL_BOUNDARY}`, 'gi'),
38
+ new RegExp(`http://xhslink\\.com/${URL_BOUNDARY}`, 'gi'),
39
+ ]
40
+
41
+ const DEFAULT_HEADERS = {
42
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
43
+ referer: 'https://www.xiaohongshu.com/explore',
44
+ }
45
+
46
+ export function extractXhsLinks(content: string): string[] {
47
+ const candidates = expandTextCandidates(content)
48
+ const links: string[] = []
49
+
50
+ for (const candidate of candidates) {
51
+ const normalized = normalizeText(candidate)
52
+ for (const pattern of LINK_PATTERNS) {
53
+ pattern.lastIndex = 0
54
+ let match: RegExpExecArray | null
55
+ while ((match = pattern.exec(normalized))) {
56
+ links.push(cleanUrl(match[0]))
57
+ }
58
+ }
59
+ }
60
+
61
+ return [...new Set(links)]
62
+ }
63
+
64
+ export async function resolveXhsLink(rawUrl: string, config: XhsConfigLike): Promise<string> {
65
+ const url = ensureProtocol(rawUrl)
66
+ if (!/xhslink\.com/i.test(url)) return url
67
+
68
+ const response = await fetchWithTimeout(url, config, { redirect: 'manual' })
69
+ const location = response.headers.get('location')
70
+ if (location) return new URL(location, url).toString()
71
+
72
+ if (response.status >= 300 && response.status < 400) return url
73
+
74
+ const followed = await fetchWithTimeout(url, config, { redirect: 'follow' })
75
+ return followed.url || url
76
+ }
77
+
78
+ export async function fetchXhsNote(rawUrl: string, config: XhsConfigLike): Promise<XhsNote> {
79
+ const url = await resolveXhsLink(rawUrl, config)
80
+ const html = await fetchText(url, config)
81
+ const data = extractInitialStateNote(html)
82
+
83
+ if (!data) {
84
+ throw new Error('未能从页面中读取小红书笔记数据。可能需要 Cookie,或链接已失效。')
85
+ }
86
+
87
+ return buildNote(data, url, config)
88
+ }
89
+
90
+ export function buildXhsMessages(note: XhsNote, config: XhsConfigLike, session?: { userId?: string, username?: string, author?: { nickname?: string } }) {
91
+ const messages: h[] = []
92
+ const text = formatNoteText(note, config)
93
+
94
+ messages.push(h('message', {
95
+ userId: session?.userId,
96
+ nickname: session?.author?.nickname || session?.username,
97
+ }, h.text(text)))
98
+
99
+ if (config.showImages) {
100
+ for (const imageUrl of note.imageUrls.slice(0, config.maxImages)) {
101
+ messages.push(h('message', {
102
+ userId: session?.userId,
103
+ nickname: session?.author?.nickname || session?.username,
104
+ }, h.image(imageUrl)))
105
+ }
106
+ }
107
+
108
+ if (config.showVideo) {
109
+ for (const videoUrl of note.videoUrls.slice(0, 1)) {
110
+ messages.push(h('message', {
111
+ userId: session?.userId,
112
+ nickname: session?.author?.nickname || session?.username,
113
+ }, h.video(videoUrl)))
114
+ }
115
+ }
116
+
117
+ return messages
118
+ }
119
+
120
+ export function extractInitialStateNote(html: string): any | null {
121
+ const scripts = [...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)]
122
+ .map((match) => match[1].trim())
123
+ .reverse()
124
+
125
+ const script = scripts.find((item) => item.startsWith('window.__INITIAL_STATE__='))
126
+ if (!script) return null
127
+
128
+ const payload = script
129
+ .replace(/^window\.__INITIAL_STATE__=/, '')
130
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
131
+
132
+ const state = parseLooseObject(payload)
133
+ return deepGet(state, ['noteData', 'data', 'noteData'])
134
+ || getFirstNoteFromDetailMap(deepGet(state, ['note', 'noteDetailMap']))
135
+ || null
136
+ }
137
+
138
+ function expandTextCandidates(content: string): string[] {
139
+ const values = new Set<string>([content])
140
+
141
+ try {
142
+ for (const element of h.parse(content)) collectElementText(element, values)
143
+ } catch {
144
+ // Some adapters deliver partial XML snippets; regex extraction below still handles them.
145
+ }
146
+
147
+ for (const match of content.matchAll(/\bdata=(?:"([^"]*)"|'([^']*)')/gi)) {
148
+ values.add(match[1] || match[2] || '')
149
+ }
150
+
151
+ for (const value of [...values]) {
152
+ const decoded = decodeHtmlEntities(value)
153
+ values.add(decoded)
154
+ maybeCollectJsonValues(decoded, values)
155
+ }
156
+
157
+ return [...values]
158
+ }
159
+
160
+ function collectElementText(element: h, values: Set<string>) {
161
+ if (typeof element === 'string') {
162
+ values.add(element)
163
+ return
164
+ }
165
+
166
+ for (const value of Object.values(element.attrs || {})) {
167
+ if (typeof value === 'string') values.add(value)
168
+ }
169
+
170
+ for (const child of element.children || []) {
171
+ collectElementText(child as h, values)
172
+ }
173
+ }
174
+
175
+ function maybeCollectJsonValues(text: string, values: Set<string>) {
176
+ try {
177
+ const json = JSON.parse(text)
178
+ walkJson(json, values)
179
+ } catch {
180
+ const unescaped = text.replace(/\\"/g, '"').replace(/\\\//g, '/')
181
+ if (unescaped !== text) values.add(unescaped)
182
+ }
183
+ }
184
+
185
+ function walkJson(value: unknown, values: Set<string>) {
186
+ if (typeof value === 'string') {
187
+ values.add(value)
188
+ return
189
+ }
190
+ if (Array.isArray(value)) {
191
+ for (const item of value) walkJson(item, values)
192
+ return
193
+ }
194
+ if (value && typeof value === 'object') {
195
+ for (const item of Object.values(value)) walkJson(item, values)
196
+ }
197
+ }
198
+
199
+ function normalizeText(text: string) {
200
+ let value = decodeHtmlEntities(text).replace(/\\\//g, '/')
201
+ try {
202
+ value = decodeURIComponent(value)
203
+ } catch {
204
+ // Keep the original if it is only partly percent-encoded.
205
+ }
206
+ return value
207
+ }
208
+
209
+ function decodeHtmlEntities(text: string) {
210
+ return text
211
+ .replace(/&quot;/g, '"')
212
+ .replace(/&#34;/g, '"')
213
+ .replace(/&#x22;/gi, '"')
214
+ .replace(/&apos;/g, "'")
215
+ .replace(/&#39;/g, "'")
216
+ .replace(/&#x27;/gi, "'")
217
+ .replace(/&amp;/g, '&')
218
+ .replace(/&lt;/g, '<')
219
+ .replace(/&gt;/g, '>')
220
+ }
221
+
222
+ function cleanUrl(url: string) {
223
+ return ensureProtocol(url)
224
+ .replace(/[),,。;;!?!]+$/g, '')
225
+ .replace(/&amp;/g, '&')
226
+ }
227
+
228
+ function ensureProtocol(url: string) {
229
+ return /^https?:\/\//i.test(url) ? url : `https://${url}`
230
+ }
231
+
232
+ async function fetchText(url: string, config: XhsConfigLike) {
233
+ const response = await fetchWithTimeout(url, config, { redirect: 'follow' })
234
+ if (!response.ok) throw new Error(`请求小红书页面失败:HTTP ${response.status}`)
235
+ return response.text()
236
+ }
237
+
238
+ async function fetchWithTimeout(url: string, config: XhsConfigLike, init: RequestInit) {
239
+ const controller = new AbortController()
240
+ const timer = setTimeout(() => controller.abort(), config.timeout * 1000)
241
+
242
+ try {
243
+ return await fetch(url, {
244
+ ...init,
245
+ signal: controller.signal,
246
+ headers: {
247
+ ...DEFAULT_HEADERS,
248
+ 'user-agent': config.userAgent,
249
+ ...(config.cookie ? { cookie: config.cookie } : {}),
250
+ ...(init.headers || {}),
251
+ },
252
+ })
253
+ } finally {
254
+ clearTimeout(timer)
255
+ }
256
+ }
257
+
258
+ function parseLooseObject(payload: string) {
259
+ return JSON.parse(payload)
260
+ }
261
+
262
+ function getFirstNoteFromDetailMap(map: unknown) {
263
+ if (!map || typeof map !== 'object') return null
264
+ const first = Object.values(map)[0] as any
265
+ return first?.note || null
266
+ }
267
+
268
+ function buildNote(data: any, url: string, config: XhsConfigLike): XhsNote {
269
+ const id = get(data, 'noteId') || extractIdFromUrl(url)
270
+ const type = normalizeType(get(data, 'type'))
271
+
272
+ return {
273
+ id,
274
+ url: id ? `https://www.xiaohongshu.com/explore/${id}` : url,
275
+ title: get(data, 'title') || '小红书笔记',
276
+ desc: get(data, 'desc') || '',
277
+ type,
278
+ authorName: get(data, 'user.nickname') || get(data, 'user.nickName') || '未知作者',
279
+ authorId: get(data, 'user.userId') || '',
280
+ likedCount: get(data, 'interactInfo.likedCount'),
281
+ collectedCount: get(data, 'interactInfo.collectedCount'),
282
+ commentCount: get(data, 'interactInfo.commentCount'),
283
+ shareCount: get(data, 'interactInfo.shareCount'),
284
+ imageUrls: extractImageUrls(data, config.imageFormat),
285
+ videoUrls: extractVideoUrls(data),
286
+ }
287
+ }
288
+
289
+ function formatNoteText(note: XhsNote, config: XhsConfigLike) {
290
+ const lines = [
291
+ `小红书:${note.title}`,
292
+ `作者:${note.authorName}`,
293
+ ]
294
+
295
+ if (note.desc && config.maxDescLength > 0) {
296
+ lines.push(trimText(note.desc, config.maxDescLength, config.descTruncateSuffix))
297
+ }
298
+ if (config.showStats) {
299
+ lines.push(`点赞:${displayCount(note.likedCount)} 收藏:${displayCount(note.collectedCount)} 评论:${displayCount(note.commentCount)} 分享:${displayCount(note.shareCount)}`)
300
+ }
301
+ if (config.showLink) lines.push(note.url)
302
+
303
+ return lines.join('\n')
304
+ }
305
+
306
+ function extractImageUrls(data: any, format: XhsConfigLike['imageFormat']) {
307
+ const images = Array.isArray(data?.imageList) ? data.imageList : []
308
+ const urls: string[] = []
309
+
310
+ for (const image of images) {
311
+ const source = image?.urlDefault || image?.url
312
+ if (!source || typeof source !== 'string') continue
313
+
314
+ const token = extractImageToken(source)
315
+ if (!token) continue
316
+ urls.push(format === 'auto'
317
+ ? `https://sns-img-bd.xhscdn.com/${token}`
318
+ : `https://ci.xiaohongshu.com/${token}?imageView2/format/${format}`)
319
+ }
320
+
321
+ return [...new Set(urls)]
322
+ }
323
+
324
+ function extractImageToken(url: string) {
325
+ try {
326
+ const parsed = new URL(url.replace(/\\u002F/g, '/'))
327
+ return parsed.pathname.split('/').slice(3).join('/').split('!')[0]
328
+ } catch {
329
+ return url.split('/').slice(5).join('/').split('!')[0]
330
+ }
331
+ }
332
+
333
+ function extractVideoUrls(data: any) {
334
+ const originVideoKey = get(data, 'video.consumer.originVideoKey')
335
+ if (originVideoKey) return [`https://sns-video-bd.xhscdn.com/${originVideoKey}`]
336
+
337
+ const streams = [
338
+ ...(get(data, 'video.media.stream.h265') || []),
339
+ ...(get(data, 'video.media.stream.h264') || []),
340
+ ].filter(Boolean)
341
+
342
+ streams.sort((a, b) => (a.height || 0) - (b.height || 0))
343
+ const best = streams[streams.length - 1]
344
+ return best?.backupUrls?.[0] ? [best.backupUrls[0]] : best?.masterUrl ? [best.masterUrl] : []
345
+ }
346
+
347
+ function normalizeType(type: unknown): XhsNote['type'] {
348
+ if (type === 'video') return 'video'
349
+ if (type === 'normal') return 'normal'
350
+ return 'unknown'
351
+ }
352
+
353
+ function get(source: any, path: string) {
354
+ return deepGet(source, path.split('.'))
355
+ }
356
+
357
+ function deepGet(source: any, path: string[]) {
358
+ let cursor = source
359
+ for (const key of path) {
360
+ if (cursor == null) return undefined
361
+ cursor = cursor[key]
362
+ }
363
+ return cursor
364
+ }
365
+
366
+ function extractIdFromUrl(url: string) {
367
+ return url.match(/\/(?:explore|item)\/([^/?#]+)/)?.[1] || ''
368
+ }
369
+
370
+ function trimText(text: string, maxLength: number, suffix: string) {
371
+ return text.length > maxLength ? `${text.slice(0, maxLength)}${suffix}` : text
372
+ }
373
+
374
+ function displayCount(value: unknown) {
375
+ return value == null || value === '' ? '-' : String(value)
376
+ }