koishi-plugin-kkk 3.12.0 → 3.12.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/CHANGELOG.md +16 -0
- package/lib/build-metadata.json +5 -5
- package/lib/karin/platform/bilibili/bilibili.js +2 -6
- package/lib/karin/platform/bilibili/dynamic-text.js +49 -1
- package/lib/karin/platform/bilibili/push.js +1 -3
- package/package.json +1 -1
- package/scripts/probe-bili-desc.cjs +218 -0
- package/scripts/verify-changelog-range.cjs +20 -1
- package/src/karin/platform/bilibili/bilibili.ts +3 -7
- package/src/karin/platform/bilibili/dynamic-text.ts +50 -1
- package/src/karin/platform/bilibili/push.ts +2 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 3.12.1
|
|
4
|
+
|
|
5
|
+
### 修:B站卡片的简介没了
|
|
6
|
+
|
|
7
|
+
有一批 B 站视频解析出来的卡片,标题下面直接跳到播放数据,**视频简介整块不见了**。
|
|
8
|
+
不是渲染糊了,是压根没画上去。
|
|
9
|
+
|
|
10
|
+
原因是 B 站接口有时会把简介放在「分段简介」里、有时只放在普通简介字段里,
|
|
11
|
+
之前的代码碰上第一种情况以外的数据会算出一份空简介,然后把「空的」当成「有内容」画出来,
|
|
12
|
+
结果整块简介反而被吃掉了。
|
|
13
|
+
|
|
14
|
+
现在两个字段都试,哪个真的有内容就用哪个 —— 单行简介、多行简介、带 @ 和链接的简介
|
|
15
|
+
都能正常显示出来。
|
|
16
|
+
|
|
17
|
+
顺手还修了一处:接口返回的简介字段是空的时候不会再让解析报错。
|
|
18
|
+
|
|
3
19
|
## 3.12.0
|
|
4
20
|
|
|
5
21
|
### 新:提取图片的按钮只能点一次
|
package/lib/build-metadata.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.12.
|
|
3
|
-
"buildTime": "2026-10-
|
|
4
|
-
"buildTimestamp":
|
|
2
|
+
"version": "3.12.1",
|
|
3
|
+
"buildTime": "2026-10-03T17:20:37.408Z",
|
|
4
|
+
"buildTimestamp": 1791048037420,
|
|
5
5
|
"name": "koishi-plugin-kkk",
|
|
6
6
|
"description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
|
|
7
7
|
"homepage": "https://github.com/maimai993/koishi-plugin-kkk#readme",
|
|
8
|
-
"commitHash": "
|
|
9
|
-
"shortCommitHash": "
|
|
8
|
+
"commitHash": "c3384b0563f126fff043bf0fed433cc406fd08a9",
|
|
9
|
+
"shortCommitHash": "c3384b0"
|
|
10
10
|
}
|
|
@@ -344,9 +344,7 @@ class Bilibili extends utils_1.Base {
|
|
|
344
344
|
const img = await (0, utils_1.Render)(this.e, 'bilibili/videoInfo', {
|
|
345
345
|
share_url: 'https://b23.tv/' + infoData.data.data.bvid,
|
|
346
346
|
title: infoData.data.data.title,
|
|
347
|
-
desc: infoData.data.data.desc_v2
|
|
348
|
-
? (0, dynamic_text_1.buildBilibiliVideoDescRichText)(infoData.data.data.desc_v2)
|
|
349
|
-
: (0, dynamic_text_1.buildBilibiliDynamicRichText)(infoData.data.data.desc || '', []),
|
|
347
|
+
desc: (0, dynamic_text_1.buildVideoDescRichText)(infoData.data.data.desc_v2, infoData.data.data.desc),
|
|
350
348
|
stat: infoData.data.data.stat,
|
|
351
349
|
bvid: infoData.data.data.bvid,
|
|
352
350
|
ctime: infoData.data.data.ctime,
|
|
@@ -1187,9 +1185,7 @@ class Bilibili extends utils_1.Base {
|
|
|
1187
1185
|
img = await (0, utils_1.Render)(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', {
|
|
1188
1186
|
image_url: INFODATA.data.data.pic,
|
|
1189
1187
|
text: (0, dynamic_text_1.buildBilibiliDynamicRichText)(INFODATA.data.data.title, []),
|
|
1190
|
-
desc: INFODATA.data.data.desc_v2
|
|
1191
|
-
? (0, dynamic_text_1.buildBilibiliVideoDescRichText)(INFODATA.data.data.desc_v2)
|
|
1192
|
-
: (0, dynamic_text_1.buildBilibiliDynamicRichText)(INFODATA.data.data.desc || '', []),
|
|
1188
|
+
desc: (0, dynamic_text_1.buildVideoDescRichText)(INFODATA.data.data.desc_v2, INFODATA.data.data.desc),
|
|
1193
1189
|
dynamic_text: dynamicText,
|
|
1194
1190
|
dianzan: (0, utils_1.Count)(INFODATA.data.data.stat.like),
|
|
1195
1191
|
pinglun: (0, utils_1.Count)(INFODATA.data.data.stat.reply),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.buildBilibiliRichTextForwardMessage = exports.buildBilibiliArticleRichText = exports.buildBilibiliVideoDescRichText = exports.buildBilibiliDynamicRichText = exports.buildUsernameRichText = exports.getUsernameMetadata = void 0;
|
|
3
|
+
exports.buildBilibiliRichTextForwardMessage = exports.buildBilibiliArticleRichText = exports.buildVideoDescRichText = exports.buildBilibiliVideoDescRichText = exports.buildBilibiliDynamicRichText = exports.buildUsernameRichText = exports.getUsernameMetadata = void 0;
|
|
4
4
|
const richtext_1 = require("../../../richtext");
|
|
5
5
|
const node_karin_1 = require("../../../compat/node-karin.js");
|
|
6
6
|
/**
|
|
@@ -201,9 +201,17 @@ exports.buildBilibiliDynamicRichText = buildBilibiliDynamicRichText;
|
|
|
201
201
|
* desc_v2 结构:
|
|
202
202
|
* - type: 1 → 纯文本(含换行、链接)
|
|
203
203
|
* - type: 2 → @提及(biz_id 为用户ID)
|
|
204
|
+
*
|
|
205
|
+
* **入参可能是 `null`/`undefined`**(接口在某些情况下就是这么给的,amagi 自己的类型里
|
|
206
|
+
* 也写着 `desc_v2: null`),这里必须容错 —— 直接 `for…of` 会抛 `descV2 is not iterable`。
|
|
207
|
+
* 一般不要直接调它,用 {@link buildVideoDescRichText},它会把 `desc` 兜底也算进去。
|
|
208
|
+
*
|
|
209
|
+
* @param descV2 接口给的 desc_v2(可为空)
|
|
204
210
|
*/
|
|
205
211
|
const buildBilibiliVideoDescRichText = (descV2) => {
|
|
206
212
|
const nodes = [];
|
|
213
|
+
if (!descV2)
|
|
214
|
+
return (0, richtext_1.createRichTextDocument)(nodes, { platform: 'bilibili' });
|
|
207
215
|
for (const item of descV2) {
|
|
208
216
|
const rawText = item.raw_text || '';
|
|
209
217
|
if (!rawText)
|
|
@@ -232,6 +240,46 @@ const buildBilibiliVideoDescRichText = (descV2) => {
|
|
|
232
240
|
return (0, richtext_1.createRichTextDocument)(nodes, { platform: 'bilibili' });
|
|
233
241
|
};
|
|
234
242
|
exports.buildBilibiliVideoDescRichText = buildBilibiliVideoDescRichText;
|
|
243
|
+
/**
|
|
244
|
+
* 视频详情的简介 → 富文本(**卡片该用这个,不要自己在调用处写三元**)。
|
|
245
|
+
*
|
|
246
|
+
* ## 为什么要有它
|
|
247
|
+
*
|
|
248
|
+
* 各调用点以前是这么写的:
|
|
249
|
+
*
|
|
250
|
+
* ```ts
|
|
251
|
+
* desc: data.desc_v2?.length
|
|
252
|
+
* ? buildBilibiliVideoDescRichText(data.desc_v2)
|
|
253
|
+
* : buildBilibiliDynamicRichText(data.desc || '', [])
|
|
254
|
+
* ```
|
|
255
|
+
*
|
|
256
|
+
* 看着没问题,但 `desc_v2` **不保证是「非空数组」** —— 接口至少有两种给不出内容的形态:
|
|
257
|
+
*
|
|
258
|
+
* - **`null`**:amagi 自己的类型里就有一支专职写着 `desc_v2: null`;
|
|
259
|
+
* - **`[{ raw_text: '', type: 1 }]`**:数组长度是 1,但里面一个字都没有。
|
|
260
|
+
*
|
|
261
|
+
* 前者 `?.length` 是 `undefined`、后者 `?.length` 是 1,判断结果不同,
|
|
262
|
+
* 但**都不能产生可显示的简介** —— 于是交给模板的都是空文档,
|
|
263
|
+
* 而卡片模板里写的是 `props.data.desc &&`,空文档为假 ⇒ **整块简介不渲染**,
|
|
264
|
+
* 而 `desc` 纯文本里明明有内容。用户反馈的「封面卡片没有简介」就是这个。
|
|
265
|
+
*
|
|
266
|
+
* 这个函数把口径收在一处,按**能不能产出内容**判断,而不是按长度:
|
|
267
|
+
* - `desc_v2` 能产出非空文档 → 用它(带 @ 提及、富文本样式);
|
|
268
|
+
* - 否则 → 用 `desc` 纯文本兜底(**关键:desc_v2 为空时不能把 desc 也一起丢掉**);
|
|
269
|
+
* - 两个都没有 → 返回**合法的空文档**(`nodes: []`),模板判假后自然不显示,
|
|
270
|
+
* 不会抛错、也不会渲染出 `undefined` 字样。
|
|
271
|
+
*
|
|
272
|
+
* @param descV2 接口给的 desc_v2(数组 / null / undefined / 空内容数组都可能)
|
|
273
|
+
* @param desc 接口给的 desc 纯文本(兜底用)
|
|
274
|
+
*/
|
|
275
|
+
const buildVideoDescRichText = (descV2, desc) => {
|
|
276
|
+
const fromV2 = (0, exports.buildBilibiliVideoDescRichText)(descV2);
|
|
277
|
+
/** 真的产出了内容才用它:空数组、全空串、null 都会走到 desc 兜底 */
|
|
278
|
+
if (fromV2.nodes.length > 0)
|
|
279
|
+
return fromV2;
|
|
280
|
+
return (0, exports.buildBilibiliDynamicRichText)(desc || '', []);
|
|
281
|
+
};
|
|
282
|
+
exports.buildVideoDescRichText = buildVideoDescRichText;
|
|
235
283
|
/** 修复图片 URL 协议。 */
|
|
236
284
|
const fixImageUrl = (url) => {
|
|
237
285
|
if (url.startsWith('//')) {
|
|
@@ -420,9 +420,7 @@ class Bilibilipush extends module_1.Base {
|
|
|
420
420
|
img = await (0, module_1.Render)(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', {
|
|
421
421
|
image_url: INFODATA.data.data.pic,
|
|
422
422
|
text: (0, dynamic_text_1.buildBilibiliDynamicRichText)(INFODATA.data.data.title, []),
|
|
423
|
-
desc: INFODATA.data.data.desc_v2
|
|
424
|
-
? (0, dynamic_text_1.buildBilibiliVideoDescRichText)(INFODATA.data.data.desc_v2)
|
|
425
|
-
: (0, dynamic_text_1.buildBilibiliDynamicRichText)(INFODATA.data.data.desc || '', []),
|
|
423
|
+
desc: (0, dynamic_text_1.buildVideoDescRichText)(INFODATA.data.data.desc_v2, INFODATA.data.data.desc),
|
|
426
424
|
dynamic_text: dynamicText,
|
|
427
425
|
dianzan: (0, module_1.Count)(INFODATA.data.data.stat.like),
|
|
428
426
|
pinglun: (0, module_1.Count)(INFODATA.data.data.stat.reply),
|
package/package.json
CHANGED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 探针:B站视频卡片里**简介到底有没有渲染出来**。
|
|
3
|
+
*
|
|
4
|
+
* 用户反馈:「b站的视频解析封面卡片渲染的内容没有简介」。
|
|
5
|
+
*
|
|
6
|
+
* 这一版不用截图(截图要浏览器渲染器,只有宿主里才有),直接用 **SSR 产物 HTML**:
|
|
7
|
+
* `renderTemplateHtml` 是「模板组件 → HTML 字符串」那一步,不需要浏览器。
|
|
8
|
+
* 于是可以精确问三个问题:
|
|
9
|
+
* ① 数据里 `desc` 是空的时候,HTML 长什么样(基线);
|
|
10
|
+
* ② `desc` 是正常富文本时,简介的原文**有没有出现在 HTML 里**;
|
|
11
|
+
* ③ 两次 HTML 的长度差有多大 —— 简介整段文字 + 换行都进去的话,差值是**几千字符级**,
|
|
12
|
+
* 只差几十字符就说明「有内容但没渲染」。
|
|
13
|
+
*
|
|
14
|
+
* 用法:node scripts/probe-bili-desc.cjs
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
const os = require('node:os')
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
|
|
20
|
+
const root = path.join(__dirname, '..')
|
|
21
|
+
const lib = path.join(root, 'lib')
|
|
22
|
+
|
|
23
|
+
const runtime = require(path.join(lib, 'compat/runtime.js'))
|
|
24
|
+
runtime.bindRuntime({
|
|
25
|
+
ctx: { config: { port: 5200 } },
|
|
26
|
+
config: { app: {} },
|
|
27
|
+
dataRoot: fs.mkdtempSync(path.join(os.tmpdir(), 'kkk-probe-bili-desc-'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
require(path.join(lib, 'compat/logger.js')).setLogger({
|
|
31
|
+
debug () {}, info () {}, mark () {},
|
|
32
|
+
warn: (m) => console.log(' [warn] ' + m),
|
|
33
|
+
error: (m) => console.log(' [error] ' + m)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const Config = require(path.join(lib, 'karin/module/utils/Config.js'))
|
|
37
|
+
Config.Config = {
|
|
38
|
+
app: { EmojiReply: false, RenderWaitTime: 10, renderScale: 2, noiseOverlay: 'off' },
|
|
39
|
+
amagi: { cookies: {} },
|
|
40
|
+
bilibili: {},
|
|
41
|
+
douyin: {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const render = require(path.join(lib, 'karin/module/utils/Render/index.js'))
|
|
45
|
+
const rich = require(path.join(lib, 'richtext/parse/index.js'))
|
|
46
|
+
const dynamicText = require(path.join(lib, 'karin/platform/bilibili/dynamic-text.js'))
|
|
47
|
+
|
|
48
|
+
let failed = 0
|
|
49
|
+
const check = (name, ok, detail) => {
|
|
50
|
+
console.log((ok ? ' ✔ ' : ' ✘ ') + name + (detail ? ' → ' + detail : ''))
|
|
51
|
+
if (!ok) failed++
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 一段「像真的」的 B 站简介:多行 + 时间轴 + 链接,跟真实投稿一个形状 */
|
|
55
|
+
const DESC_LINES = [
|
|
56
|
+
'本期视频带大家看一下这个方案的完整实现,代码已经开源,链接放在下面。',
|
|
57
|
+
'',
|
|
58
|
+
'00:00 开场',
|
|
59
|
+
'01:20 环境准备',
|
|
60
|
+
'05:40 核心逻辑',
|
|
61
|
+
'12:00 踩坑记录',
|
|
62
|
+
'',
|
|
63
|
+
'参考资料:https://www.bilibili.com/read/cv12345678',
|
|
64
|
+
'',
|
|
65
|
+
'感谢大家的支持,记得三连 ~'
|
|
66
|
+
]
|
|
67
|
+
const DESC_TEXT = DESC_LINES.join('\n')
|
|
68
|
+
/** 只取一段不含 URL、不含 @ 的普通文字当「海报标记」,避免被链接/表情的处理干扰 */
|
|
69
|
+
const MARKER = '本期视频带大家看一下这个方案的完整实现'
|
|
70
|
+
|
|
71
|
+
const descDoc = dynamicText.buildBilibiliVideoDescRichText([{ raw_text: DESC_TEXT, type: 1 }])
|
|
72
|
+
|
|
73
|
+
const baseData = (desc) => ({
|
|
74
|
+
share_url: 'https://b23.tv/BV1JSan6GEFW',
|
|
75
|
+
title: '【实拍】这个方案到底能不能跑起来?完整实现 + 踩坑记录',
|
|
76
|
+
desc,
|
|
77
|
+
stat: { view: 1234567, danmaku: 8901, reply: 2345, like: 98765, coin: 12345, share: 678, favorite: 23456 },
|
|
78
|
+
bvid: 'BV1JSan6GEFW',
|
|
79
|
+
ctime: 1735689600,
|
|
80
|
+
pic: 'https://i0.hdslb.com/bfs/archive/0000000000000000000000000000000000000000.jpg',
|
|
81
|
+
owner: { mid: 123456, name: '测试UP主', face: 'https://i0.hdslb.com/bfs/face/0000000000000000000000000000000000000000.jpg' }
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/** html 里出现 desc 区域才算「渲染了简介」:找渲染后的可见文本片段 */
|
|
85
|
+
const countOccurrences = (haystack, needle) => haystack.split(needle).length - 1
|
|
86
|
+
|
|
87
|
+
;(async () => {
|
|
88
|
+
try {
|
|
89
|
+
console.log('\n=== 1. desc 构建链路(纯函数,先确认数据没问题) ===')
|
|
90
|
+
{
|
|
91
|
+
const plain = rich.extractRichTextPlainText(descDoc)
|
|
92
|
+
console.log(' 富文本节点数 = ' + descDoc.nodes.length + ' | 纯文本长度 = ' + plain.length)
|
|
93
|
+
check('简介文本完整进了富文本文档', plain.includes(MARKER), JSON.stringify(plain.slice(0, 30)))
|
|
94
|
+
check('换行被转成 lineBreak 节点(多行简介要靠它)', descDoc.nodes.some((n) => n.type === 'lineBreak'),
|
|
95
|
+
'节点类型:' + [...new Set(descDoc.nodes.map((n) => n.type))].join(','))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log('\n=== 2. SSR 对照:带简介 vs 不带简介 ===')
|
|
99
|
+
const htmlWith = await render.renderTemplateHtml('bilibili/videoInfo', baseData(descDoc), false)
|
|
100
|
+
const htmlWithout = await render.renderTemplateHtml(
|
|
101
|
+
'bilibili/videoInfo',
|
|
102
|
+
baseData(rich.createRichTextDocument([], { platform: 'bilibili' })),
|
|
103
|
+
false
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
check('两侧 SSR 都成功(模板与校验通过)', !!htmlWith && !!htmlWithout,
|
|
107
|
+
'with=' + (htmlWith ? htmlWith.length : 'null') + ' without=' + (htmlWithout ? htmlWithout.length : 'null'))
|
|
108
|
+
|
|
109
|
+
if (htmlWith && htmlWithout) {
|
|
110
|
+
const inWith = countOccurrences(htmlWith, MARKER)
|
|
111
|
+
const inWithout = countOccurrences(htmlWithout, MARKER)
|
|
112
|
+
console.log(' 简介首句在 HTML 里出现次数:带简介 = ' + inWith + ' / 不带简介 = ' + inWithout)
|
|
113
|
+
check('带简介 → 简介原文出现在卡片 HTML 里', inWith > 0, '出现 ' + inWith + ' 次')
|
|
114
|
+
check('不带简介 → 简介原文不出现(对照成立)', inWithout === 0, '出现 ' + inWithout + ' 次')
|
|
115
|
+
|
|
116
|
+
/** 时间轴、链接这些也验一下:它们走的是另外的分支(split 换行 / URL 识别) */
|
|
117
|
+
check('时间轴行渲染进去了', htmlWith.includes('05:40 核心逻辑'))
|
|
118
|
+
check('简介里的链接被渲染成可点的 a 标签', /<a[^>]*href="https:\/\/www\.bilibili\.com\/read\/cv12345678"/.test(htmlWith))
|
|
119
|
+
|
|
120
|
+
const delta = htmlWith.length - htmlWithout.length
|
|
121
|
+
console.log(' 两次 HTML 长度:' + htmlWith.length + ' vs ' + htmlWithout.length + '(差 ' + delta + ' 字符)')
|
|
122
|
+
check('长度差是「整段简介」的量级(不是几十字符)', delta > DESC_TEXT.length, '差 ' + delta + ' 字符,简介本体 ' + DESC_TEXT.length + ' 字符')
|
|
123
|
+
|
|
124
|
+
/** 存一份给人工看(了解实际排版) */
|
|
125
|
+
const out = path.join(root, 'bili-desc-probe.html')
|
|
126
|
+
fs.writeFileSync(out, htmlWith, 'utf8')
|
|
127
|
+
console.log(' 已写出 SSR HTML:' + path.relative(root, out) + '(可直接用浏览器打开看排版)')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log('\n=== 3. ✅ 回归:desc_v2 为 null 时简介不能凭空消失 ===')
|
|
131
|
+
{
|
|
132
|
+
/**
|
|
133
|
+
* **本次的真凶**:`data.desc_v2?.length` 只在 desc_v2 是**数组**时才对,
|
|
134
|
+
* 而接口在某些情况下给的是 `desc_v2: null`(amagi 自己的类型里就有这一支:
|
|
135
|
+
* `type Arc = { desc: string; desc_v2: null }`)。
|
|
136
|
+
* 于是判断为假 → 走 desc 兜底那一支……实际并没有:三元的两个分支都指望
|
|
137
|
+
* `desc_v2` 是数组,最后交给模板的是一个**空文档**,
|
|
138
|
+
* 卡片模板里 `props.data.desc &&` 直接为假 ⇒ **整块简介都不渲染**,
|
|
139
|
+
* 而 `desc`(纯文本)明明有内容 —— 用户看到的就是「封面卡片没有简介」。
|
|
140
|
+
*
|
|
141
|
+
* 这里**直接测产物里的 `buildVideoDescRichText`**(新的统一口径),
|
|
142
|
+
* 而不是在探针里重写一遍逻辑 —— 重写的话实现改坏了它也测不出来。
|
|
143
|
+
*/
|
|
144
|
+
const buildDesc = dynamicText.buildVideoDescRichText
|
|
145
|
+
check('产物里有 buildVideoDescRichText(三处调用点都用它)', typeof buildDesc === 'function',
|
|
146
|
+
typeof buildDesc)
|
|
147
|
+
|
|
148
|
+
const plainDesc = '掉宝时间:北京时间 2026年9月29日15:00 -10月15日14:59'
|
|
149
|
+
|
|
150
|
+
/** ① 正常:desc_v2 有内容 */
|
|
151
|
+
const normal = rich.extractRichTextPlainText(buildDesc([{ raw_text: plainDesc, type: 1 }], plainDesc))
|
|
152
|
+
check('desc_v2 正常 → 用 desc_v2', normal.includes('掉宝时间'), JSON.stringify(normal.slice(0, 24)))
|
|
153
|
+
|
|
154
|
+
/** ② 真凶场景:desc_v2 是 null,desc 有内容 */
|
|
155
|
+
const nullPlain = rich.extractRichTextPlainText(buildDesc(null, plainDesc))
|
|
156
|
+
check('desc_v2 = null → 用 desc 兜底(不再丢简介)', nullPlain.includes('掉宝时间'),
|
|
157
|
+
nullPlain ? JSON.stringify(nullPlain.slice(0, 24)) : '(空 —— 简介丢了)')
|
|
158
|
+
|
|
159
|
+
/** ③ desc_v2 缺失(undefined)也一样 */
|
|
160
|
+
check('desc_v2 缺失 → 同样用 desc 兜底',
|
|
161
|
+
rich.extractRichTextPlainText(buildDesc(undefined, plainDesc)).includes('掉宝时间'))
|
|
162
|
+
|
|
163
|
+
/** ④ desc_v2 空数组(接口偶尔给空数组) */
|
|
164
|
+
check('desc_v2 空数组 → 还是用 desc 兜底',
|
|
165
|
+
rich.extractRichTextPlainText(buildDesc([], plainDesc)).includes('掉宝时间'))
|
|
166
|
+
|
|
167
|
+
/** ⑤ desc_v2 是「有元素但元素都是空文本」:没有可用内容,也该退回 desc */
|
|
168
|
+
check('desc_v2 全是空串 → 退回 desc 兜底',
|
|
169
|
+
rich.extractRichTextPlainText(buildDesc([{ raw_text: '', type: 1 }], plainDesc)).includes('掉宝时间'))
|
|
170
|
+
|
|
171
|
+
/** ⑥ 两个都空:不能抛错,且必须是合法的空文档(模板要能吃) */
|
|
172
|
+
let bothEmpty
|
|
173
|
+
try {
|
|
174
|
+
bothEmpty = buildDesc(null, '')
|
|
175
|
+
check('两处都空 → 得到合法的空富文本文档', bothEmpty?.nodes?.length === 0,
|
|
176
|
+
'nodes=' + (bothEmpty?.nodes?.length ?? 'undefined'))
|
|
177
|
+
} catch (error) {
|
|
178
|
+
check('两处都空 → 不抛错', false, error.message)
|
|
179
|
+
}
|
|
180
|
+
/** ⑦ 老函数直接吃 null 也不能崩(别处还有人在调它) */
|
|
181
|
+
let oldCrash = false
|
|
182
|
+
try {
|
|
183
|
+
dynamicText.buildBilibiliVideoDescRichText(null)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
oldCrash = true
|
|
186
|
+
}
|
|
187
|
+
check('buildBilibiliVideoDescRichText(null) 不再抛 descV2 is not iterable', !oldCrash)
|
|
188
|
+
|
|
189
|
+
/** ⑧ **端到端**:拿真凶数据走一遍 SSR,简介必须出现在 HTML 里 */
|
|
190
|
+
if (bothEmpty) {
|
|
191
|
+
const nullV2Html = await render.renderTemplateHtml('bilibili/videoInfo', baseData(buildDesc(null, plainDesc)), false)
|
|
192
|
+
check('desc_v2=null 的数据 → SSR 出来的卡片里真的有简介',
|
|
193
|
+
!!nullV2Html && nullV2Html.includes('掉宝时间'),
|
|
194
|
+
nullV2Html ? ('HTML ' + nullV2Html.length + ' 字符,含简介=' + nullV2Html.includes('掉宝时间')) : 'SSR 失败')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log('\n=== 4. 真实的 desc_v2 形状(从接口拿到的就是它) ===')
|
|
199
|
+
{
|
|
200
|
+
/** 接口里 desc_v2 的常见形状:多段,每段 type=1 纯文本 / type=2 @某人 */
|
|
201
|
+
const mixed = dynamicText.buildBilibiliVideoDescRichText([
|
|
202
|
+
{ raw_text: '第一段简介\n', type: 1 },
|
|
203
|
+
{ raw_text: '@某某UP', type: 2, biz_id: 999 },
|
|
204
|
+
{ raw_text: '\n尾段 https://b23.tv/abc', type: 1 }
|
|
205
|
+
])
|
|
206
|
+
const plain = rich.extractRichTextPlainText(mixed)
|
|
207
|
+
console.log(' 混合 desc_v2 → ' + JSON.stringify(plain))
|
|
208
|
+
check('多段拼接后 @ 与链接都在', plain.includes('@某某UP') && plain.includes('https://b23.tv/abc'))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log('\n' + (failed ? '✘ 有 ' + failed + ' 项没通过' : '✔ 全部通过'))
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error('探针自身出错:', error)
|
|
214
|
+
failed++
|
|
215
|
+
} finally {
|
|
216
|
+
process.exit(failed ? 1 : 0)
|
|
217
|
+
}
|
|
218
|
+
})()
|
|
@@ -14,8 +14,26 @@ const karin = require(path.join(lib, 'compat/node-karin.js'))
|
|
|
14
14
|
const md = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8')
|
|
15
15
|
|
|
16
16
|
console.log('解析出的版本:' + Object.keys(karin.parseChangelog(md)).join(' , '))
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
/** 当前版本号(package.json);CHANGELOG 顶部的 `## x.y.z` 必须是它,否则卡片会整份发出去 */
|
|
19
|
+
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version
|
|
20
|
+
const all = Object.keys(karin.parseChangelog(md))
|
|
21
|
+
if (!all.includes(version)) {
|
|
22
|
+
console.log('❌ CHANGELOG 里没有 `## ' + version + '` 这一段(package.json 的版本号对不上)')
|
|
23
|
+
process.exitCode = 1
|
|
24
|
+
} else {
|
|
25
|
+
console.log('✅ package.json 版本 ' + version + ' 在 CHANGELOG 里有对应段落')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 取当前版本以及它下面 3 个历史版本,逐个验证「上一版 → 这一版」能裁出东西 */
|
|
29
|
+
const cases = []
|
|
30
|
+
for (let i = 0; i < all.length; i++) {
|
|
31
|
+
cases.push([all[i + 1] ?? all[i], all[i]])
|
|
32
|
+
}
|
|
18
33
|
for (const [startVersion, endVersion] of cases) {
|
|
34
|
+
// 最老那一版的下界只能等于它自己(没有更老的版本可当哨兵),range() 此时必然返回整份,
|
|
35
|
+
// 属于工具本身的预期行为,不算失败。
|
|
36
|
+
if (startVersion === endVersion && endVersion === all[all.length - 1]) continue
|
|
19
37
|
const out = karin.range({ data: md, startVersion, endVersion, compare: 'semver' })
|
|
20
38
|
const keys = Object.keys(karin.parseChangelog(out))
|
|
21
39
|
const dumped = out.length >= md.length
|
|
@@ -23,4 +41,5 @@ for (const [startVersion, endVersion] of cases) {
|
|
|
23
41
|
'range(' + startVersion + ' → ' + endVersion + '):命中 [' + keys.join(', ') + '] 长度=' + out.length +
|
|
24
42
|
(dumped ? ' ⚠ 返回了整份日志(没裁出来)' : '')
|
|
25
43
|
)
|
|
44
|
+
if (dumped) process.exitCode = 1
|
|
26
45
|
}
|
|
@@ -83,7 +83,7 @@ import {
|
|
|
83
83
|
buildBilibiliArticleRichText,
|
|
84
84
|
buildBilibiliDynamicRichText,
|
|
85
85
|
buildBilibiliRichTextForwardMessage,
|
|
86
|
-
|
|
86
|
+
buildVideoDescRichText,
|
|
87
87
|
getUsernameMetadata
|
|
88
88
|
} from '@/platform/bilibili/dynamic-text'
|
|
89
89
|
import {
|
|
@@ -390,9 +390,7 @@ export class Bilibili extends Base {
|
|
|
390
390
|
const img = await Render(this.e, 'bilibili/videoInfo', {
|
|
391
391
|
share_url: 'https://b23.tv/' + infoData.data.data.bvid,
|
|
392
392
|
title: infoData.data.data.title,
|
|
393
|
-
desc: infoData.data.data.desc_v2
|
|
394
|
-
? buildBilibiliVideoDescRichText(infoData.data.data.desc_v2)
|
|
395
|
-
: buildBilibiliDynamicRichText(infoData.data.data.desc || '', []),
|
|
393
|
+
desc: buildVideoDescRichText(infoData.data.data.desc_v2, infoData.data.data.desc),
|
|
396
394
|
stat: infoData.data.data.stat,
|
|
397
395
|
bvid: infoData.data.data.bvid,
|
|
398
396
|
ctime: infoData.data.data.ctime,
|
|
@@ -1319,9 +1317,7 @@ export class Bilibili extends Base {
|
|
|
1319
1317
|
img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', {
|
|
1320
1318
|
image_url: INFODATA.data.data.pic,
|
|
1321
1319
|
text: buildBilibiliDynamicRichText(INFODATA.data.data.title, []),
|
|
1322
|
-
desc: INFODATA.data.data.desc_v2
|
|
1323
|
-
? buildBilibiliVideoDescRichText(INFODATA.data.data.desc_v2)
|
|
1324
|
-
: buildBilibiliDynamicRichText(INFODATA.data.data.desc || '', []),
|
|
1320
|
+
desc: buildVideoDescRichText(INFODATA.data.data.desc_v2, INFODATA.data.data.desc),
|
|
1325
1321
|
dynamic_text: dynamicText,
|
|
1326
1322
|
dianzan: Count(INFODATA.data.data.stat.like),
|
|
1327
1323
|
pinglun: Count(INFODATA.data.data.stat.reply),
|
|
@@ -272,9 +272,16 @@ export const buildBilibiliDynamicRichText = (
|
|
|
272
272
|
* desc_v2 结构:
|
|
273
273
|
* - type: 1 → 纯文本(含换行、链接)
|
|
274
274
|
* - type: 2 → @提及(biz_id 为用户ID)
|
|
275
|
+
*
|
|
276
|
+
* **入参可能是 `null`/`undefined`**(接口在某些情况下就是这么给的,amagi 自己的类型里
|
|
277
|
+
* 也写着 `desc_v2: null`),这里必须容错 —— 直接 `for…of` 会抛 `descV2 is not iterable`。
|
|
278
|
+
* 一般不要直接调它,用 {@link buildVideoDescRichText},它会把 `desc` 兜底也算进去。
|
|
279
|
+
*
|
|
280
|
+
* @param descV2 接口给的 desc_v2(可为空)
|
|
275
281
|
*/
|
|
276
|
-
export const buildBilibiliVideoDescRichText = (descV2: Array<{ raw_text?: string; type?: number; biz_id?: number }>): RichTextDocument => {
|
|
282
|
+
export const buildBilibiliVideoDescRichText = (descV2: Array<{ raw_text?: string; type?: number; biz_id?: number }> | null | undefined): RichTextDocument => {
|
|
277
283
|
const nodes: RichTextNode[] = []
|
|
284
|
+
if (!descV2) return createRichTextDocument(nodes, { platform: 'bilibili' })
|
|
278
285
|
for (const item of descV2) {
|
|
279
286
|
const rawText = item.raw_text || ''
|
|
280
287
|
if (!rawText) continue
|
|
@@ -301,6 +308,48 @@ export const buildBilibiliVideoDescRichText = (descV2: Array<{ raw_text?: string
|
|
|
301
308
|
return createRichTextDocument(nodes, { platform: 'bilibili' })
|
|
302
309
|
}
|
|
303
310
|
|
|
311
|
+
/**
|
|
312
|
+
* 视频详情的简介 → 富文本(**卡片该用这个,不要自己在调用处写三元**)。
|
|
313
|
+
*
|
|
314
|
+
* ## 为什么要有它
|
|
315
|
+
*
|
|
316
|
+
* 各调用点以前是这么写的:
|
|
317
|
+
*
|
|
318
|
+
* ```ts
|
|
319
|
+
* desc: data.desc_v2?.length
|
|
320
|
+
* ? buildBilibiliVideoDescRichText(data.desc_v2)
|
|
321
|
+
* : buildBilibiliDynamicRichText(data.desc || '', [])
|
|
322
|
+
* ```
|
|
323
|
+
*
|
|
324
|
+
* 看着没问题,但 `desc_v2` **不保证是「非空数组」** —— 接口至少有两种给不出内容的形态:
|
|
325
|
+
*
|
|
326
|
+
* - **`null`**:amagi 自己的类型里就有一支专职写着 `desc_v2: null`;
|
|
327
|
+
* - **`[{ raw_text: '', type: 1 }]`**:数组长度是 1,但里面一个字都没有。
|
|
328
|
+
*
|
|
329
|
+
* 前者 `?.length` 是 `undefined`、后者 `?.length` 是 1,判断结果不同,
|
|
330
|
+
* 但**都不能产生可显示的简介** —— 于是交给模板的都是空文档,
|
|
331
|
+
* 而卡片模板里写的是 `props.data.desc &&`,空文档为假 ⇒ **整块简介不渲染**,
|
|
332
|
+
* 而 `desc` 纯文本里明明有内容。用户反馈的「封面卡片没有简介」就是这个。
|
|
333
|
+
*
|
|
334
|
+
* 这个函数把口径收在一处,按**能不能产出内容**判断,而不是按长度:
|
|
335
|
+
* - `desc_v2` 能产出非空文档 → 用它(带 @ 提及、富文本样式);
|
|
336
|
+
* - 否则 → 用 `desc` 纯文本兜底(**关键:desc_v2 为空时不能把 desc 也一起丢掉**);
|
|
337
|
+
* - 两个都没有 → 返回**合法的空文档**(`nodes: []`),模板判假后自然不显示,
|
|
338
|
+
* 不会抛错、也不会渲染出 `undefined` 字样。
|
|
339
|
+
*
|
|
340
|
+
* @param descV2 接口给的 desc_v2(数组 / null / undefined / 空内容数组都可能)
|
|
341
|
+
* @param desc 接口给的 desc 纯文本(兜底用)
|
|
342
|
+
*/
|
|
343
|
+
export const buildVideoDescRichText = (
|
|
344
|
+
descV2: Array<{ raw_text?: string; type?: number; biz_id?: number }> | null | undefined,
|
|
345
|
+
desc: string | null | undefined
|
|
346
|
+
): RichTextDocument => {
|
|
347
|
+
const fromV2 = buildBilibiliVideoDescRichText(descV2)
|
|
348
|
+
/** 真的产出了内容才用它:空数组、全空串、null 都会走到 desc 兜底 */
|
|
349
|
+
if (fromV2.nodes.length > 0) return fromV2
|
|
350
|
+
return buildBilibiliDynamicRichText(desc || '', [])
|
|
351
|
+
}
|
|
352
|
+
|
|
304
353
|
/** 修复图片 URL 协议。 */
|
|
305
354
|
const fixImageUrl = (url: string): string => {
|
|
306
355
|
if (url.startsWith('//')) {
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
buildBilibiliArticleRichText,
|
|
41
41
|
buildBilibiliDynamicRichText,
|
|
42
42
|
buildBilibiliRichTextForwardMessage,
|
|
43
|
-
|
|
43
|
+
buildVideoDescRichText,
|
|
44
44
|
getUsernameMetadata
|
|
45
45
|
} from '@/platform/bilibili/dynamic-text'
|
|
46
46
|
import { buildBilibiliLiveSessionId, parseBilibiliLiveStartedAt } from '@/platform/bilibili/live-status'
|
|
@@ -516,9 +516,7 @@ export class Bilibilipush extends Base {
|
|
|
516
516
|
img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', {
|
|
517
517
|
image_url: INFODATA.data.data.pic,
|
|
518
518
|
text: buildBilibiliDynamicRichText(INFODATA.data.data.title, []),
|
|
519
|
-
desc: INFODATA.data.data.desc_v2
|
|
520
|
-
? buildBilibiliVideoDescRichText(INFODATA.data.data.desc_v2)
|
|
521
|
-
: buildBilibiliDynamicRichText(INFODATA.data.data.desc || '', []),
|
|
519
|
+
desc: buildVideoDescRichText(INFODATA.data.data.desc_v2, INFODATA.data.data.desc),
|
|
522
520
|
dynamic_text: dynamicText,
|
|
523
521
|
dianzan: Count(INFODATA.data.data.stat.like),
|
|
524
522
|
pinglun: Count(INFODATA.data.data.stat.reply),
|