koishi-plugin-kkk 3.7.2 → 3.9.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.
@@ -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
- * 递归代理一个 fetcher 对象,把失败信封转成 `throw AmagiError`。返回类型与入参同形 ——
138
- * 「只保留成功分支」是**类型层**由 {@link ThrowingClient} 声明的,这里只管运行时行为。
139
- */
140
- const throwOnFailure = (target) => new Proxy(target, {
141
- get(obj, prop) {
142
- const value = obj[prop];
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
- return (...args) => {
145
- const returned = value.apply(obj, args);
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
- if (value && typeof value === 'object' && !Array.isArray(value)) {
157
- return throwOnFailure(value);
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 失效、被风控,或者这条链接已经失效 / 作品已删除。');
@@ -38,7 +38,6 @@ require(".//platform/bilibili/riskControl");
38
38
  const node_karin_1 = __importStar(require("../compat/node-karin.js"));
39
39
  const root_1 = require("../compat/root.js");
40
40
  const module_1 = require(".//module");
41
- const db_1 = require(".//module/db");
42
41
  const semver_1 = require("./module/utils/semver");
43
42
  // ----------------- VERSION CHECK -----------------
44
43
  const requireVersion = typeof __REQUIRE_KARIN_VERSION__ !== 'undefined' ? __REQUIRE_KARIN_VERSION__ : '1.1145.14';
@@ -90,9 +89,8 @@ if (process.env.NODE_ENV !== 'development' && (0, semver_1.isSemverGreater)(requ
90
89
  });
91
90
  }
92
91
  // ----------------- DATABASE INIT -----------------
93
- await (0, db_1.initAllDatabases)().catch((err) => {
94
- node_karin_1.logger.error(`[koishi-plugin-kkk] 数据库初始化失败: ${err.message}`);
95
- });
92
+ // 数据库走 Koishi 原生服务(ctx.database),由插件入口在 apply 阶段调用
93
+ // bootstrapDatabases(ctx) 引导,这里不再初始化任何自有的 sqlite 文件。
96
94
  // ------------------- MAIN INIT -------------------
97
95
  (0, node_karin_1.mkdirSync)(`${root_1.karinPathBase}/${module_1.Root.pluginName}/data`);
98
96
  (0, node_karin_1.mkdirSync)(module_1.Common.tempDri.images);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.7.2",
3
+ "version": "3.9.0",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -43,6 +43,7 @@
43
43
  "postinstall": "node scripts/patch-deps.mjs",
44
44
  "patch-deps": "node scripts/patch-deps.mjs",
45
45
  "smoke": "node scripts/smoke-parse.cjs",
46
+ "smoke:db": "node scripts/smoke-db-koishi.cjs",
46
47
  "placeholders": "node scripts/make-demo-placeholders.mjs",
47
48
  "smoke:render": "node scripts/smoke-render.cjs",
48
49
  "check-types": "tsc -p tsconfig.json"
@@ -52,7 +53,7 @@
52
53
  },
53
54
  "dependencies": {
54
55
  "@icons-pack/react-simple-icons": "^13.15.1",
55
- "@ikenxuan/amagi": "6.6.0",
56
+ "@ikenxuan/amagi": "7.0.0-beta.5",
56
57
  "@ikenxuan/qrcode": "1.5.0",
57
58
  "@outpacelabs/avatars": "0.6.0",
58
59
  "@phosphor-icons/react": "^2.1.10",
package/scripts/build.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * - node-karin[/sub] → 兼容层实现(这样产物自带兼容层,不依赖 node_modules 里的转发包)
10
10
  */
11
11
  import { execFileSync } from 'node:child_process'
12
- import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'
12
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
15
 
@@ -27,10 +27,20 @@ if (configFile.error) {
27
27
  const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, root)
28
28
  const options = { ...parsed.options, noCheck: true, incremental: false }
29
29
 
30
- if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true })
30
+ /**
31
+ * 记录本次 emit 写出的文件,用于构建后清理残留。
32
+ *
33
+ * 以前是 emit 前直接 rmSync 掉整个 lib(一次删几百个文件),现在改成事后比对清理:
34
+ * 只删「上次产物里有、这次没被重写」的文件,正常情况下是 0 个。
35
+ */
36
+ const emitted = new Set()
37
+ const recordAndWrite = (fileName, text, writeByteOrderMark) => {
38
+ emitted.add(path.resolve(fileName))
39
+ ts.sys.writeFile(fileName, text, writeByteOrderMark)
40
+ }
31
41
 
32
42
  const program = ts.createProgram(parsed.fileNames, options)
33
- const emitResult = program.emit()
43
+ const emitResult = program.emit(undefined, recordAndWrite)
34
44
 
35
45
  const diagnostics = ts.getPreEmitDiagnostics(program).filter((item) => item.category === ts.DiagnosticCategory.Error)
36
46
  if (diagnostics.length) {
@@ -49,10 +59,32 @@ if (emitResult.emitSkipped) {
49
59
  process.exit(1)
50
60
  }
51
61
 
62
+ /** 递归收集目录下的文件(只用于找残留产物) */
63
+ const walkFiles = (dir, out = []) => {
64
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
65
+ const full = path.join(dir, entry.name)
66
+ if (entry.isDirectory()) walkFiles(full, out)
67
+ else if (entry.isFile()) out.push(full)
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** 清掉这次没被重写的旧产物(比如删除源文件后 lib 里留下的孤儿 .js) */
73
+ const cleanStaleArtifacts = () => {
74
+ const stale = walkFiles(outDir).filter((file) => {
75
+ if (!/\.(js|cjs|mjs|d\.ts|map)$/.test(file)) return false
76
+ return !emitted.has(path.resolve(file))
77
+ })
78
+ for (const file of stale) {
79
+ rmSync(file, { force: true })
80
+ console.log(' 清理残留 ' + path.relative(outDir, file).split(path.sep).join('/'))
81
+ }
82
+ return stale.length
83
+ }
84
+
52
85
  /** 顺序敏感:子路径必须排在 node-karin 之前 */
53
86
  const ALIASES = [
54
87
  { prefix: 'node-karin/root', target: 'compat/root', exact: true },
55
- { prefix: 'node-karin/sqlite3', target: 'compat/sqlite3', exact: true },
56
88
  { prefix: 'node-karin/axios', target: 'compat/axios', exact: true },
57
89
  { prefix: 'node-karin/yaml', target: 'compat/yaml', exact: true },
58
90
  { prefix: 'node-karin/express', target: 'compat/express', exact: true },
@@ -161,5 +193,6 @@ if (offenders.length) {
161
193
  for (const item of offenders) console.error(' - ' + item)
162
194
  process.exit(1)
163
195
  }
164
- console.log('构建完成:输出到 ' + path.relative(root, outDir) + ',重写别名文件 ' + rewritten + ' 个')
196
+ const stale = cleanStaleArtifacts()
197
+ console.log('构建完成:输出到 ' + path.relative(root, outDir) + ',重写别名文件 ' + rewritten + ' 个,清理残留 ' + stale + ' 个')
165
198
  console.log('构建信息:v' + buildMetadata.version + ' · ' + (buildMetadata.shortCommitHash || '无 commit') + ' · ' + buildMetadata.buildTime)
@@ -5,12 +5,17 @@
5
5
  * 不带 src、scripts、测试数据。依赖不复制(那会多出 400+ MB),而是在副本里建一个
6
6
  * node_modules junction 指回开发目录的依赖,Node 的模块解析照常生效。
7
7
  *
8
+ * 同步策略是「镜像」而不是「先删再拷」:
9
+ * - 覆盖:源文件比目标新(或大小不同)才复制
10
+ * - 清理:目标里源已经没有的文件逐个删掉(比如删掉的旧模块),避免残留文件被 require 到
11
+ * - 依赖 junction:路径不对(比如项目从 D 盘迁到 E 盘后指向了不存在的旧路径)就重建
12
+ *
8
13
  * 用法:
9
14
  * node scripts/deploy.mjs # 部署到 <工作区>/node_modules/koishi-plugin-kkk
10
15
  * node scripts/deploy.mjs --with-deps # 连依赖一起复制(自包含,可整体拷走,约 450 MB)
11
16
  * node scripts/deploy.mjs --target <dir> # 指定目标目录
12
17
  */
13
- import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, symlinkSync } from 'node:fs'
18
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, unlinkSync } from 'node:fs'
14
19
  import path from 'node:path'
15
20
  import { fileURLToPath } from 'node:url'
16
21
 
@@ -32,47 +37,91 @@ if (!existsSync(path.join(root, 'lib', 'index.js'))) {
32
37
  process.exit(1)
33
38
  }
34
39
 
35
- // 清掉旧的部署(可能是 junction、目录或文件)
36
- if (existsSync(target) || lstatSync(target, { throwIfNoEntry: false })) {
37
- rmSync(target, { recursive: true, force: true })
40
+ const stats = { copied: 0, removed: 0, bytes: 0 }
41
+
42
+ /** 收集目录里的相对文件路径(用 / 分隔) */
43
+ function walk (dir, base = dir, out = new Map()) {
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const full = path.join(dir, entry.name)
46
+ if (entry.isDirectory()) walk(full, base, out)
47
+ else if (entry.isFile()) out.set(path.relative(base, full).split(path.sep).join('/'), full)
48
+ }
49
+ return out
50
+ }
51
+
52
+ /** 目录镜像:该覆盖的覆盖,该删的删 */
53
+ function mirrorDir (from, to) {
54
+ const src = walk(from)
55
+ const dst = existsSync(to)
56
+ ? walk(to)
57
+ : (mkdirSync(to, { recursive: true }), new Map())
58
+
59
+ for (const [rel, srcFile] of src) {
60
+ const dstFile = path.join(to, rel.split('/').join(path.sep))
61
+ const s = statSync(srcFile)
62
+ const d = existsSync(dstFile) ? statSync(dstFile) : null
63
+ if (d && d.size === s.size && d.mtimeMs >= s.mtimeMs) continue
64
+ mkdirSync(path.dirname(dstFile), { recursive: true })
65
+ cpSync(srcFile, dstFile)
66
+ stats.copied++
67
+ stats.bytes += s.size
68
+ }
69
+
70
+ for (const [rel, dstFile] of dst) {
71
+ if (src.has(rel)) continue
72
+ rmSync(dstFile, { force: true })
73
+ stats.removed++
74
+ console.log(' 删除残留 ' + rel)
75
+ }
38
76
  }
77
+
39
78
  mkdirSync(target, { recursive: true })
40
79
 
41
- let copied = 0
42
- let bytes = 0
43
80
  for (const entry of ENTRIES) {
44
81
  const from = path.join(root, entry)
45
82
  if (!existsSync(from)) continue
46
83
  const to = path.join(target, entry)
47
- cpSync(from, to, { recursive: true })
48
- const size = statSync(from).isDirectory()
49
- ? readdirSync(from, { recursive: true }).reduce((sum, item) => {
50
- const file = path.join(from, item)
51
- return sum + (statSync(file).isFile() ? statSync(file).size : 0)
52
- }, 0)
53
- : statSync(from).size
54
- bytes += size
55
- copied++
56
- console.log(' 复制 ' + entry + '(' + (size / 1024 / 1024).toFixed(2) + ' MB)')
84
+ if (statSync(from).isDirectory()) {
85
+ mirrorDir(from, to)
86
+ } else {
87
+ const s = statSync(from)
88
+ const d = existsSync(to) ? statSync(to) : null
89
+ if (d && d.size === s.size && d.mtimeMs >= s.mtimeMs) continue
90
+ cpSync(from, to)
91
+ stats.copied++
92
+ stats.bytes += s.size
93
+ }
57
94
  }
58
95
 
96
+ // 依赖:--with-deps 时整份复制,否则用 junction 指回开发目录
97
+ const depsLink = path.join(target, 'node_modules')
98
+ const deps = path.join(root, 'node_modules')
59
99
  if (withDeps) {
60
- const from = path.join(root, 'node_modules')
61
- if (existsSync(from)) {
100
+ if (existsSync(deps)) {
62
101
  console.log(' 复制 node_modules(依赖,比较慢)…')
63
- cpSync(from, path.join(target, 'node_modules'), { recursive: true })
102
+ cpSync(deps, depsLink, { recursive: true })
64
103
  }
65
- } else {
66
- // 依赖不复制,用 junction 指回开发目录;Node 解析依赖时会正常走到这里
67
- const deps = path.join(root, 'node_modules')
68
- if (existsSync(deps)) {
69
- symlinkSync(deps, path.join(target, 'node_modules'), 'junction')
70
- console.log(' 依赖:junction → ' + deps)
104
+ } else if (existsSync(deps)) {
105
+ // junction 可能指向一个已经不存在的路径(项目换过盘符/目录),这时必须重建,
106
+ // 否则运行时解析插件私有依赖会直接失败
107
+ let current = null
108
+ if (lstatSync(depsLink, { throwIfNoEntry: false })) {
109
+ try { current = realpathSync(depsLink) } catch { current = null }
110
+ }
111
+ if (current && path.resolve(current) === path.resolve(deps)) {
112
+ console.log(' 依赖:junction 已正确 → ' + deps)
71
113
  } else {
72
- console.warn(' 警告:开发目录没有 node_modules,副本运行时会缺依赖')
114
+ if (existsSync(depsLink) || lstatSync(depsLink, { throwIfNoEntry: false })) {
115
+ try { rmSync(depsLink, { recursive: true, force: true }) } catch { unlinkSync(depsLink) }
116
+ }
117
+ symlinkSync(deps, depsLink, 'junction')
118
+ console.log(' 依赖:junction' + (current ? '(修复,原来指向 ' + current + ')' : '') + ' → ' + deps)
73
119
  }
120
+ } else {
121
+ console.warn(' 警告:开发目录没有 node_modules,副本运行时会缺依赖')
74
122
  }
75
123
 
76
124
  console.log('\n部署完成 → ' + target)
77
- console.log(' 文件 ' + copied + ' 项,约 ' + (bytes / 1024 / 1024).toFixed(2) + ' MB' + (withDeps ? '(另有依赖副本)' : ',依赖走 junction'))
125
+ console.log(' 更新 ' + stats.copied + ' 个文件,约 ' + (stats.bytes / 1024 / 1024).toFixed(2) + ' MB;清理残留 ' + stats.removed + ' 个'
126
+ + (withDeps ? ';另有依赖副本' : ';依赖走 junction'))
78
127
  console.log(' 宿主重启(koishi start)后生效')
@@ -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('&lt;') && !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) })