koishi-plugin-kkk 3.7.2 → 3.8.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/CHANGELOG.md +31 -0
- package/lib/build-metadata.json +5 -5
- package/lib/karin/module/utils/amagiClient.js +40 -15
- package/lib/karin/platform/douyin/douyin.js +10 -0
- package/package.json +2 -2
- package/scripts/smoke-amagi-capability.cjs +97 -0
- package/scripts/smoke-comments.cjs +164 -0
- package/src/karin/module/utils/amagiClient.ts +49 -27
- package/src/karin/platform/douyin/douyin.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 3.8.0
|
|
4
|
+
|
|
5
|
+
> 抖音解析修好了。抖音那边改了风控规则,**换 cookie 是没用的** —— 问题出在解析用的那个库太旧;
|
|
6
|
+
> 顺带修好快手、小红书的评论区渲染。
|
|
7
|
+
|
|
8
|
+
### 抖音:解析不了 / 一直提示 Cookie 失效
|
|
9
|
+
|
|
10
|
+
- **现象**:发抖音链接,机器人回「抖音没有返回这条作品的数据(错误码 500:抖音数据获取失败),
|
|
11
|
+
常见原因:Cookie 失效、被风控」,可同一个 cookie 换个插件却能正常解析。
|
|
12
|
+
- **原因**:抖音现在要求每个网页接口都带上设备标识(`uifid`)和一套新的签名头
|
|
13
|
+
(`x-secsdk-web-signature`,由抖音自己的风控 SDK 算出来)。
|
|
14
|
+
插件用的解析库 6.6.0 里根本没有这套东西,请求直接被抖音拦下
|
|
15
|
+
(服务器回的是「Blocked by ArgusSecurityPlugin Uifid Not Found」),
|
|
16
|
+
库再把它包成一句「抖音数据获取失败」—— 看起来就像 cookie 过期,怎么换都没用。
|
|
17
|
+
- **处理**:把解析库升到 7.x(目前是 7.0.0-beta.5;npm 上标着「最新」的 6.6.0 反而是坏的)。
|
|
18
|
+
升级后实测:作品详情、评论区、弹幕都正常。
|
|
19
|
+
- 更新完**重启一次机器人**即可;手动 `npm i` 更新插件时会自动装上对应版本的解析库。
|
|
20
|
+
|
|
21
|
+
### 日志更好排查
|
|
22
|
+
|
|
23
|
+
- 以后再遇到这类失败,日志里会直接写明「**多半是解析库版本过旧**」,
|
|
24
|
+
不用再照着「换 cookie、换网络」排查半天。
|
|
25
|
+
|
|
26
|
+
### 修复快手评论渲染
|
|
27
|
+
|
|
28
|
+
- 快手评论区接口以前未实现,现已实现
|
|
29
|
+
|
|
30
|
+
### 修复小红书评论渲染
|
|
31
|
+
|
|
32
|
+
- 小红书评论区接口变更导致失效,现已重新接好,评论能正常显示了
|
|
33
|
+
|
|
3
34
|
## 3.7.2
|
|
4
35
|
|
|
5
36
|
> 群里发的图片**统一改走 markdown** 了 —— 普通图片消息会被 QQ 压得发糊,markdown 图片不会;
|
package/lib/build-metadata.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.
|
|
3
|
-
"buildTime": "2026-09-
|
|
4
|
-
"buildTimestamp":
|
|
2
|
+
"version": "3.8.0",
|
|
3
|
+
"buildTime": "2026-09-26T03:37:45.450Z",
|
|
4
|
+
"buildTimestamp": 1790393865451,
|
|
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": "d5bd8abb16c68f3f03575a4f8de736512e55e94b",
|
|
9
|
+
"shortCommitHash": "d5bd8ab"
|
|
10
10
|
}
|
|
@@ -34,6 +34,19 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.reloadAmagiConfig = exports.getAmagiClient = exports.xiaohongshuFetcher = exports.kuaishouFetcher = exports.douyinFetcher = exports.bilibiliFetcher = exports.registerAmagiReloadListener = exports.isSoftFailure = exports.softFetch = exports.SOFT_ERROR_CODES = exports.AmagiBase = exports.AmagiError = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* 解析库(`@ikenxuan/amagi`)的版本要求:**必须 ≥ 7**(当前钉在 7.0.0-beta.5)。
|
|
39
|
+
*
|
|
40
|
+
* 线上故障(用户反馈「抖音解析改炸了,同一个 cookie 上游代码能正常解析」):
|
|
41
|
+
* 抖音现在要求 web 接口带上 `uifid` 与 `x-secsdk-web-signature`(Argus 风控 SDK 的签名),
|
|
42
|
+
* 而 npm 上的 **latest 6.6.0 完全没有这套东西**,于是每个抖音接口都被拦成
|
|
43
|
+
* `403 Blocked by ArgusSecurityPlugin Uifid Not Found`。amagi 把这个响应包成
|
|
44
|
+
* `{code:500,message:"抖音数据获取失败"}`,插件再翻译成一句「Cookie 失效 / 被风控」——
|
|
45
|
+
* 用户怎么换 cookie 都没用。
|
|
46
|
+
*
|
|
47
|
+
* secsdk / uifid 的实现只存在于 7.x(上游 karin-plugin-kkk 也是把这份新 amagi 打进包里的),
|
|
48
|
+
* 所以这里**故意钉在 beta 上**:latest 反而是坏的。升级解析库时请一并确认抖音能解析。
|
|
49
|
+
*/
|
|
37
50
|
const amagi_1 = __importStar(require("@ikenxuan/amagi"));
|
|
38
51
|
const node_karin_1 = require("../../../compat/node-karin.js");
|
|
39
52
|
const Config_1 = require("./Config");
|
|
@@ -133,16 +146,28 @@ const isFailureEnvelope = (value) => {
|
|
|
133
146
|
};
|
|
134
147
|
/** 判断是不是 thenable,用来只包装异步方法 */
|
|
135
148
|
const isThenable = (value) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function';
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
const throwOnFailure = (target) => {
|
|
150
|
+
/**
|
|
151
|
+
* **摊平成普通对象,而不是再套一层 Proxy。**
|
|
152
|
+
*
|
|
153
|
+
* amagi 自己的 fetcher 就是「带 `ownKeys` / `getOwnPropertyDescriptor` trap、方法按需物化」
|
|
154
|
+
* 的 Proxy。我们再包一层 Proxy 的话,外面只要做一次 `Object.getOwnPropertyDescriptor`
|
|
155
|
+
* (打桩时最常见的 `client.douyin.fetcher.parseWork = fn` 就会走到)就会进到里层 trap,
|
|
156
|
+
* 触发 V8 的 Proxy 不变量检查并抛:
|
|
157
|
+
*
|
|
158
|
+
* TypeError: 'getOwnPropertyDescriptor' on proxy: trap returned descriptor for property
|
|
159
|
+
* 'parseWork' that is incompatible with the existing property in the proxy target
|
|
160
|
+
*
|
|
161
|
+
* 结果是所有「用打桩跑一遍链路」的冒烟集体炸掉(2026-09-26 升级 amagi 7 时踩到)。
|
|
162
|
+
* 摊平成普通对象既没有这个坑,也顺手把每个方法的包装**缓存**了一次,调用少一层代理。
|
|
163
|
+
*/
|
|
164
|
+
const wrapped = {};
|
|
165
|
+
for (const key of Reflect.ownKeys(target)) {
|
|
166
|
+
const value = target[key];
|
|
143
167
|
if (typeof value === 'function') {
|
|
144
|
-
|
|
145
|
-
|
|
168
|
+
const fn = value.bind(target);
|
|
169
|
+
wrapped[key] = (...args) => {
|
|
170
|
+
const returned = fn(...args);
|
|
146
171
|
// 同步方法原样放行:包成 async 会把返回值套一层 Promise,破坏语义
|
|
147
172
|
if (!isThenable(returned))
|
|
148
173
|
return returned;
|
|
@@ -152,14 +177,14 @@ const throwOnFailure = (target) => new Proxy(target, {
|
|
|
152
177
|
return result;
|
|
153
178
|
});
|
|
154
179
|
};
|
|
180
|
+
continue;
|
|
155
181
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return value;
|
|
182
|
+
wrapped[key] = value && typeof value === 'object' && !Array.isArray(value)
|
|
183
|
+
? throwOnFailure(value)
|
|
184
|
+
: value;
|
|
160
185
|
}
|
|
161
|
-
|
|
162
|
-
|
|
186
|
+
return wrapped;
|
|
187
|
+
};
|
|
163
188
|
class AmagiBase {
|
|
164
189
|
/**
|
|
165
190
|
* 原始 v7 客户端。
|
|
@@ -106,6 +106,16 @@ class DouYin extends utils_1.Base {
|
|
|
106
106
|
const code = raw.code ?? raw.data?.code;
|
|
107
107
|
const message = raw.message ?? raw.msg ?? raw.data?.message ?? raw.data?.msg;
|
|
108
108
|
node_karin_2.logger.error('[抖音] 接口没有返回作品详情: ' + JSON.stringify({ code, message, 顶层键: Object.keys(raw) }).slice(0, 300));
|
|
109
|
+
/**
|
|
110
|
+
* amagi 自己的 500 信封 = 抖音那边没给数据。最常见的成因已经**不是** cookie,
|
|
111
|
+
* 而是解析库版本太旧(新版抖音要 uifid + secsdk 签名,旧库会被 Argus 拦成空响应)。
|
|
112
|
+
* 日志里点出来,免得下一次又照着「换个 cookie」排查半天。
|
|
113
|
+
*/
|
|
114
|
+
if (Number(code) === 500 && /抖音数据获取失败/.test(String(message ?? ''))) {
|
|
115
|
+
node_karin_2.logger.mark('[抖音] 这多半是解析库版本过旧:新版抖音要求 uifid + x-secsdk-web-signature,'
|
|
116
|
+
+ '旧版会被 Argus 风控拦成 403(Blocked by ArgusSecurityPlugin Uifid Not Found)。'
|
|
117
|
+
+ '本插件依赖 @ikenxuan/amagi 需要 7.x(见 package.json)');
|
|
118
|
+
}
|
|
109
119
|
throw new Error('抖音没有返回这条作品的数据'
|
|
110
120
|
+ (code !== undefined ? '(错误码 ' + String(code) + (message ? ':' + String(message) : '') + ')' : '')
|
|
111
121
|
+ ',常见原因:Cookie 失效、被风控,或者这条链接已经失效 / 作品已删除。');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-kkk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@icons-pack/react-simple-icons": "^13.15.1",
|
|
55
|
-
"@ikenxuan/amagi": "
|
|
55
|
+
"@ikenxuan/amagi": "7.0.0-beta.5",
|
|
56
56
|
"@ikenxuan/qrcode": "1.5.0",
|
|
57
57
|
"@outpacelabs/avatars": "0.6.0",
|
|
58
58
|
"@phosphor-icons/react": "^2.1.10",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 冒烟测试:**解析库(@ikenxuan/amagi)得是带 secsdk 的那一版**。
|
|
3
|
+
*
|
|
4
|
+
* 线上事故:抖音从某天起要求 web 接口带 `uifid` + `x-secsdk-web-signature`(Argus 风控),
|
|
5
|
+
* npm 上的 latest 6.6.0 没有这套实现 → 每个抖音接口都被拦成
|
|
6
|
+
* `403 Blocked by ArgusSecurityPlugin Uifid Not Found` → 插件报「Cookie 失效」。
|
|
7
|
+
* 换 cookie、换网络都没用,**只能升解析库**(上游 karin-plugin-kkk 也是把新 amagi 打进包里)。
|
|
8
|
+
*
|
|
9
|
+
* 这个冒烟不联网,只检查「装到的那份库**有**做签名和 uifid 的能力」:
|
|
10
|
+
* 一旦有人把依赖降回 6.x,这里立刻红,不会等到用户来报「抖音解析不了」。
|
|
11
|
+
*
|
|
12
|
+
* 用法:node scripts/smoke-amagi-capability.cjs
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('node:fs')
|
|
15
|
+
const path = require('node:path')
|
|
16
|
+
|
|
17
|
+
const pluginRoot = path.resolve(__dirname, '..')
|
|
18
|
+
|
|
19
|
+
let failures = 0
|
|
20
|
+
const check = (name, ok, detail) => {
|
|
21
|
+
console.log((ok ? ' ✅ ' : ' ❌ ') + name + (detail ? ' —— ' + detail : ''))
|
|
22
|
+
if (!ok) failures++
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 装上那份库的根目录:require.resolve 拿到入口文件,再往上找 package.json */
|
|
26
|
+
const resolveInstalled = () => {
|
|
27
|
+
const entry = require.resolve('@ikenxuan/amagi', { paths: [pluginRoot] })
|
|
28
|
+
let dir = path.dirname(entry)
|
|
29
|
+
for (let i = 0; i < 6; i++) {
|
|
30
|
+
const candidate = path.join(dir, 'package.json')
|
|
31
|
+
if (fs.existsSync(candidate)) {
|
|
32
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'))
|
|
33
|
+
if (pkg.name === '@ikenxuan/amagi') return { dir, pkg }
|
|
34
|
+
}
|
|
35
|
+
dir = path.dirname(dir)
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const versionOf = (text) => String(text ?? '').split('-')[0].split('.').map((n) => Number(n) || 0)
|
|
41
|
+
|
|
42
|
+
const main = () => {
|
|
43
|
+
console.log('— 解析库能力 —')
|
|
44
|
+
const installed = resolveInstalled()
|
|
45
|
+
check('能找到已安装的 @ikenxuan/amagi', !!installed, installed ? installed.dir : '(找不到)')
|
|
46
|
+
if (!installed) return done()
|
|
47
|
+
|
|
48
|
+
const [major] = versionOf(installed.pkg.version)
|
|
49
|
+
check('装的是 7.x(6.x 没有 secsdk,抖音会被 Argus 拦)', major >= 7, '实际 ' + installed.pkg.version)
|
|
50
|
+
|
|
51
|
+
/** secsdk 签名只在 7.x 里导出,是最直接的能力探针 */
|
|
52
|
+
let signing = null
|
|
53
|
+
try {
|
|
54
|
+
signing = require('@ikenxuan/amagi/signing')
|
|
55
|
+
} catch (error) {
|
|
56
|
+
signing = null
|
|
57
|
+
check('导出 ./signing(secsdk 签名,7.x 才有)', false, String(error && error.message).slice(0, 120))
|
|
58
|
+
}
|
|
59
|
+
if (signing) {
|
|
60
|
+
const names = Object.keys(signing)
|
|
61
|
+
check('导出 ./signing(secsdk 签名,7.x 才有)', names.length > 0, names.slice(0, 6).join(', ') + '…')
|
|
62
|
+
check('签名模块里能找到 secsdk / uifid 相关导出',
|
|
63
|
+
names.some((name) => /secsdk|uifid|sign/i.test(name)),
|
|
64
|
+
names.filter((name) => /secsdk|uifid|sign/i.test(name)).slice(0, 6).join(', '))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const amagi = require('@ikenxuan/amagi')
|
|
68
|
+
const Client = amagi.Client || amagi.default
|
|
69
|
+
check('Client 能拿到(v7 的默认导出)', typeof Client === 'function', typeof Client)
|
|
70
|
+
if (typeof Client === 'function') {
|
|
71
|
+
const client = Client({ cookies: {}, request: {}, debug: false })
|
|
72
|
+
check('client.douyin.fetcher.parseWork 在(插件主链路)', typeof client?.douyin?.fetcher?.parseWork === 'function')
|
|
73
|
+
check('client.douyin.login 在(扫码登录走它)', typeof client?.douyin?.login?.qrcode === 'function')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log('')
|
|
77
|
+
console.log('— 仓库里的声明 —')
|
|
78
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pluginRoot, 'package.json'), 'utf8'))
|
|
79
|
+
const pinned = pkg.dependencies?.['@ikenxuan/amagi']
|
|
80
|
+
const [pinnedMajor] = versionOf(pinned)
|
|
81
|
+
check('package.json 钉的版本也是 7.x', pinnedMajor >= 7, '实际 ' + String(pinned))
|
|
82
|
+
|
|
83
|
+
done()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const done = () => {
|
|
87
|
+
console.log('')
|
|
88
|
+
console.log(failures === 0 ? '全部通过' : failures + ' 项失败')
|
|
89
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
main()
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(error)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 冒烟测试:**快手 / 小红书的评论数据能变成模板要的结构**。
|
|
3
|
+
*
|
|
4
|
+
* 这两条评论链路对接的接口都换过(快手换到 H5 photo/comment/list、小红书字段也变过),
|
|
5
|
+
* 换完之后字段名、层级、置顶标记、子评论形状全变,而插件是「取不到就静默少一块」的风格 ——
|
|
6
|
+
* 错了不会报错,只是评论区空着。这里用两大平台**真实的返回形状**喂进去,盯住映射结果:
|
|
7
|
+
* - 字段对不对(昵称 / 点赞 / 子评论数 / ip 归属地 / 图片兜底)
|
|
8
|
+
* - 富文本对不对(表情、@、换行都解析成节点,不是拼 HTML)
|
|
9
|
+
* - 排序与条数限制(快手按点赞、小红书置顶优先,超上限按配置截断)
|
|
10
|
+
*
|
|
11
|
+
* 用法:node scripts/smoke-comments.cjs
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('node:fs')
|
|
14
|
+
const os = require('node:os')
|
|
15
|
+
const path = require('node:path')
|
|
16
|
+
|
|
17
|
+
const pluginRoot = path.resolve(__dirname, '..')
|
|
18
|
+
|
|
19
|
+
let failures = 0
|
|
20
|
+
const check = (name, ok, detail) => {
|
|
21
|
+
console.log((ok ? ' ✅ ' : ' ❌ ') + name + (detail ? ' —— ' + detail : ''))
|
|
22
|
+
if (!ok) failures++
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const noop = () => {}
|
|
26
|
+
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kkk-comments-'))
|
|
27
|
+
const runtime = require(path.join(pluginRoot, 'lib/compat/runtime.js'))
|
|
28
|
+
runtime.bindRuntime({
|
|
29
|
+
ctx: { get: () => undefined, logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }), bots: [], registry: new Map(), on: () => () => {} },
|
|
30
|
+
config: {},
|
|
31
|
+
pluginRoot,
|
|
32
|
+
dataRoot
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const { Config } = require(path.join(pluginRoot, 'lib/karin/module/utils/Config.js'))
|
|
36
|
+
const { kuaishouComments } = require(path.join(pluginRoot, 'lib/karin/platform/kuaishou/comments.js'))
|
|
37
|
+
const { xiaohongshuComments } = require(path.join(pluginRoot, 'lib/karin/platform/xiaohongshu/comments.js'))
|
|
38
|
+
|
|
39
|
+
/** 节点类型序列,方便断言富文本结构 */
|
|
40
|
+
const kinds = (doc) => (doc && Array.isArray(doc.nodes) ? doc.nodes.map((node) => node.type) : [])
|
|
41
|
+
const textOf = (doc) => (doc && Array.isArray(doc.nodes) ? doc.nodes.filter((n) => n.type === 'text').map((n) => n.text).join('') : '')
|
|
42
|
+
|
|
43
|
+
const EMOJIS = [
|
|
44
|
+
{ name: '[微笑]', url: 'https://example.com/smile.png' },
|
|
45
|
+
{ name: '[笑]', url: 'https://example.com/laugh.png' }
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const main = async () => {
|
|
49
|
+
console.log('— 快手评论区 —')
|
|
50
|
+
{
|
|
51
|
+
Config.initCfg()
|
|
52
|
+
const payload = {
|
|
53
|
+
// H5 接口的形状:rootComments 在顶层、字段是 snake_case
|
|
54
|
+
rootComments: [
|
|
55
|
+
{
|
|
56
|
+
comment_id: 111,
|
|
57
|
+
author_name: '点赞少的',
|
|
58
|
+
headurl: 'https://example.com/a.jpg',
|
|
59
|
+
content: '第一条\n第二行',
|
|
60
|
+
likedCount: 3,
|
|
61
|
+
timestamp: 1700000000,
|
|
62
|
+
subCommentCount: 2
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
comment_id: 222,
|
|
66
|
+
author_name: '点赞多的',
|
|
67
|
+
headurl: 'https://example.com/b.jpg',
|
|
68
|
+
content: '[微笑]@小明(123) 你好',
|
|
69
|
+
likedCount: '1.2万',
|
|
70
|
+
timestamp: 1700000001,
|
|
71
|
+
subCommentCount: 0
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
const list = await kuaishouComments(payload, EMOJIS)
|
|
76
|
+
const byNick = (nick) => list.find((item) => item.nickname === nick)
|
|
77
|
+
const many = byNick('点赞多的')
|
|
78
|
+
const few = byNick('点赞少的')
|
|
79
|
+
|
|
80
|
+
check('两条都映射出来了', list.length === 2, '实际 ' + list.length)
|
|
81
|
+
check('cid / aweme_id 是字符串(H5 接口给的是数字)', many?.cid === '222' && many?.aweme_id === '222', String(many?.cid))
|
|
82
|
+
check('昵称与头像映射正确', many?.nickname === '点赞多的' && many?.userimageurl === 'https://example.com/b.jpg')
|
|
83
|
+
check('子评论数映射正确', few?.reply_comment_total === 2, String(few?.reply_comment_total))
|
|
84
|
+
/**
|
|
85
|
+
* 「1.2万」这种点赞数解不出数字时**兜底为 0**(而不是 NaN / 1.2):
|
|
86
|
+
* 快手的 H5 接口只给展示用的字符串,排序会因此把它排到后面,这是已知取舍。
|
|
87
|
+
*/
|
|
88
|
+
check('「1.2万」这种点赞数兜底为 0(不是 NaN)', many?.digg_count === 0 && Number.isFinite(many?.digg_count), String(many?.digg_count))
|
|
89
|
+
check('按点赞排序(兜底为 0 的排后面)', list[0]?.nickname === '点赞少的', list.map((c) => c.nickname).join(','))
|
|
90
|
+
check('正文里的表情解析成 emoji 节点', kinds(many?.text).includes('emoji'), kinds(many?.text).join(','))
|
|
91
|
+
check('@昵称(uid) 解析成 mention 节点',
|
|
92
|
+
kinds(many?.text).includes('mention'),
|
|
93
|
+
JSON.stringify(many?.text?.nodes?.find((node) => node.type === 'mention')))
|
|
94
|
+
check('换行解析成 lineBreak 节点', kinds(few?.text).includes('lineBreak'), kinds(few?.text).join(','))
|
|
95
|
+
check('富文本里没有 HTML 字符串', !JSON.stringify(list).includes('<') && !JSON.stringify(list).includes('<span'))
|
|
96
|
+
|
|
97
|
+
const empty = await kuaishouComments({ rootComments: [] }, EMOJIS)
|
|
98
|
+
check('没有评论时返回空数组(不是报错)', Array.isArray(empty) && empty.length === 0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log('')
|
|
102
|
+
console.log('— 小红书评论区 —')
|
|
103
|
+
{
|
|
104
|
+
const payload = {
|
|
105
|
+
data: {
|
|
106
|
+
comments: [
|
|
107
|
+
{
|
|
108
|
+
id: 'c1',
|
|
109
|
+
note_id: 'n1',
|
|
110
|
+
content: '普通评论',
|
|
111
|
+
user_info: { nickname: '普通用户' },
|
|
112
|
+
create_time: 1700000000,
|
|
113
|
+
like_count: 1,
|
|
114
|
+
liked: false,
|
|
115
|
+
sub_comment_count: 1,
|
|
116
|
+
sub_comments: [
|
|
117
|
+
{ id: 's1', note_id: 'n1', content: '子评论 [笑]', user_info: { nickname: '子用户' }, create_time: 1700000001, like_count: 0, ip_location: '上海', show_tags: [], status: 0 }
|
|
118
|
+
],
|
|
119
|
+
show_tags: [],
|
|
120
|
+
at_users: ['@旧形态'],
|
|
121
|
+
ip_location: '',
|
|
122
|
+
pictures: null,
|
|
123
|
+
status: 0
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'c2',
|
|
127
|
+
note_id: 'n1',
|
|
128
|
+
content: '置顶评论',
|
|
129
|
+
user_info: { nickname: '置顶用户' },
|
|
130
|
+
create_time: 1700000002,
|
|
131
|
+
like_count: 5,
|
|
132
|
+
liked: true,
|
|
133
|
+
sub_comment_count: 0,
|
|
134
|
+
sub_comments: [],
|
|
135
|
+
show_tags: ['user_top'],
|
|
136
|
+
at_users: [{ nickname: '新形态' }, { user_info: { nickname: '另一种形态' } }],
|
|
137
|
+
ip_location: '北京',
|
|
138
|
+
pictures: [{ url: 'https://example.com/p.jpg' }],
|
|
139
|
+
status: 0
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const list = xiaohongshuComments(payload, EMOJIS)
|
|
145
|
+
check('两条都映射出来了', list.length === 2, '实际 ' + list.length)
|
|
146
|
+
check('置顶(user_top)排最前', list[0]?.id === 'c2', list.map((c) => c.id).join(','))
|
|
147
|
+
check('ip 归属地缺失时兜底「未知」', list[1]?.ip_location === '未知', String(list[1]?.ip_location))
|
|
148
|
+
check('pictures 不是数组时兜底成空数组', Array.isArray(list[1]?.pictures) && list[1].pictures.length === 0)
|
|
149
|
+
check('子评论也映射出来了且带表情', list[1]?.sub_comments?.length === 1 && kinds(list[1]?.sub_comments?.[0]?.content).includes('emoji'))
|
|
150
|
+
check('at_users 两种形态(字符串 / 对象)都能归一化',
|
|
151
|
+
list[1]?.at_users?.join(',') === '@旧形态' && list[0]?.at_users?.join(',') === '@新形态,@另一种形态',
|
|
152
|
+
JSON.stringify([list[0]?.at_users, list[1]?.at_users]))
|
|
153
|
+
check('正文富文本带平台标记', list[0]?.content?.platform === 'xiaohongshu' && textOf(list[0]?.content).includes('置顶评论'))
|
|
154
|
+
|
|
155
|
+
const empty = xiaohongshuComments({ data: { comments: [] } }, EMOJIS)
|
|
156
|
+
check('没有评论时返回空数组(不是报错)', Array.isArray(empty) && empty.length === 0)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log('')
|
|
160
|
+
console.log(failures === 0 ? '全部通过' : failures + ' 项失败')
|
|
161
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
main().catch((error) => { console.error(error); process.exit(1) })
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 解析库(`@ikenxuan/amagi`)的版本要求:**必须 ≥ 7**(当前钉在 7.0.0-beta.5)。
|
|
3
|
+
*
|
|
4
|
+
* 线上故障(用户反馈「抖音解析改炸了,同一个 cookie 上游代码能正常解析」):
|
|
5
|
+
* 抖音现在要求 web 接口带上 `uifid` 与 `x-secsdk-web-signature`(Argus 风控 SDK 的签名),
|
|
6
|
+
* 而 npm 上的 **latest 6.6.0 完全没有这套东西**,于是每个抖音接口都被拦成
|
|
7
|
+
* `403 Blocked by ArgusSecurityPlugin Uifid Not Found`。amagi 把这个响应包成
|
|
8
|
+
* `{code:500,message:"抖音数据获取失败"}`,插件再翻译成一句「Cookie 失效 / 被风控」——
|
|
9
|
+
* 用户怎么换 cookie 都没用。
|
|
10
|
+
*
|
|
11
|
+
* secsdk / uifid 的实现只存在于 7.x(上游 karin-plugin-kkk 也是把这份新 amagi 打进包里的),
|
|
12
|
+
* 所以这里**故意钉在 beta 上**:latest 反而是坏的。升级解析库时请一并确认抖音能解析。
|
|
13
|
+
*/
|
|
1
14
|
import Client, {
|
|
2
15
|
type AmagiError as AmagiErrorContract,
|
|
3
16
|
type AmagiFailure,
|
|
@@ -135,36 +148,45 @@ const isFailureEnvelope = (value: unknown): value is AmagiFailure => {
|
|
|
135
148
|
const isThenable = (value: unknown): value is PromiseLike<unknown> =>
|
|
136
149
|
!!value && (typeof value === 'object' || typeof value === 'function') && typeof (value as PromiseLike<unknown>).then === 'function'
|
|
137
150
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
151
|
+
const throwOnFailure = <T extends object>(target: T): T => {
|
|
152
|
+
/**
|
|
153
|
+
* **摊平成普通对象,而不是再套一层 Proxy。**
|
|
154
|
+
*
|
|
155
|
+
* amagi 自己的 fetcher 就是「带 `ownKeys` / `getOwnPropertyDescriptor` trap、方法按需物化」
|
|
156
|
+
* 的 Proxy。我们再包一层 Proxy 的话,外面只要做一次 `Object.getOwnPropertyDescriptor`
|
|
157
|
+
* (打桩时最常见的 `client.douyin.fetcher.parseWork = fn` 就会走到)就会进到里层 trap,
|
|
158
|
+
* 触发 V8 的 Proxy 不变量检查并抛:
|
|
159
|
+
*
|
|
160
|
+
* TypeError: 'getOwnPropertyDescriptor' on proxy: trap returned descriptor for property
|
|
161
|
+
* 'parseWork' that is incompatible with the existing property in the proxy target
|
|
162
|
+
*
|
|
163
|
+
* 结果是所有「用打桩跑一遍链路」的冒烟集体炸掉(2026-09-26 升级 amagi 7 时踩到)。
|
|
164
|
+
* 摊平成普通对象既没有这个坑,也顺手把每个方法的包装**缓存**了一次,调用少一层代理。
|
|
165
|
+
*/
|
|
166
|
+
const wrapped: Record<string | symbol, unknown> = {}
|
|
167
|
+
for (const key of Reflect.ownKeys(target)) {
|
|
168
|
+
const value = (target as any)[key]
|
|
169
|
+
|
|
170
|
+
if (typeof value === 'function') {
|
|
171
|
+
const fn = value.bind(target)
|
|
172
|
+
wrapped[key] = (...args: unknown[]) => {
|
|
173
|
+
const returned = fn(...args)
|
|
174
|
+
// 同步方法原样放行:包成 async 会把返回值套一层 Promise,破坏语义
|
|
175
|
+
if (!isThenable(returned)) return returned
|
|
176
|
+
return returned.then((result: unknown) => {
|
|
177
|
+
if (isFailureEnvelope(result)) throw new AmagiError(result)
|
|
178
|
+
return result
|
|
179
|
+
})
|
|
161
180
|
}
|
|
162
|
-
|
|
163
|
-
return value
|
|
181
|
+
continue
|
|
164
182
|
}
|
|
165
|
-
})
|
|
166
183
|
|
|
167
|
-
|
|
184
|
+
wrapped[key] = value && typeof value === 'object' && !Array.isArray(value)
|
|
185
|
+
? throwOnFailure(value)
|
|
186
|
+
: value
|
|
187
|
+
}
|
|
188
|
+
return wrapped as T
|
|
189
|
+
}
|
|
168
190
|
export class AmagiBase {
|
|
169
191
|
/**
|
|
170
192
|
* 原始 v7 客户端。
|
|
@@ -130,6 +130,16 @@ export class DouYin extends Base {
|
|
|
130
130
|
const code = raw.code ?? raw.data?.code
|
|
131
131
|
const message = raw.message ?? raw.msg ?? raw.data?.message ?? raw.data?.msg
|
|
132
132
|
logger.error('[抖音] 接口没有返回作品详情: ' + JSON.stringify({ code, message, 顶层键: Object.keys(raw) }).slice(0, 300))
|
|
133
|
+
/**
|
|
134
|
+
* amagi 自己的 500 信封 = 抖音那边没给数据。最常见的成因已经**不是** cookie,
|
|
135
|
+
* 而是解析库版本太旧(新版抖音要 uifid + secsdk 签名,旧库会被 Argus 拦成空响应)。
|
|
136
|
+
* 日志里点出来,免得下一次又照着「换个 cookie」排查半天。
|
|
137
|
+
*/
|
|
138
|
+
if (Number(code) === 500 && /抖音数据获取失败/.test(String(message ?? ''))) {
|
|
139
|
+
logger.mark('[抖音] 这多半是解析库版本过旧:新版抖音要求 uifid + x-secsdk-web-signature,'
|
|
140
|
+
+ '旧版会被 Argus 风控拦成 403(Blocked by ArgusSecurityPlugin Uifid Not Found)。'
|
|
141
|
+
+ '本插件依赖 @ikenxuan/amagi 需要 7.x(见 package.json)')
|
|
142
|
+
}
|
|
133
143
|
throw new Error('抖音没有返回这条作品的数据'
|
|
134
144
|
+ (code !== undefined ? '(错误码 ' + String(code) + (message ? ':' + String(message) : '') + ')' : '')
|
|
135
145
|
+ ',常见原因:Cookie 失效、被风控,或者这条链接已经失效 / 作品已删除。')
|