koishi-plugin-kkk 3.2.0 → 3.2.2

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.
@@ -236,15 +236,57 @@ export const ocrImageText = async (imageUrl: string): Promise<string> => {
236
236
  * 从 OCR 文本里认 UP 主名。
237
237
  * B站个人卡片的排版是「昵称 / UP主 / 粉丝数…」,所以拿「UP主」上一行最稳。
238
238
  */
239
- export const extractUpName = (text: string): string => {
240
- const lines = String(text ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
241
- if (!lines.length) return ''
239
+ export const extractUpName = (text: string): string => extractUpNames(text)[0] ?? ''
240
+
241
+ /** 明显不是昵称的标签词 */
242
+ const NICK_LABELS = new Set(['up', 'up主', 'upzhu', 'v', '粉丝', '关注', '获赞', '播放', '点赞', '弹幕', '投币', '收藏', '转发', '评论', '分享', '投稿', '作品', '简介', '主页', '更多', '展开', '未知', '半身像'])
243
+
244
+ /**
245
+ * 从 OCR 文本里认**所有可能**的 UP 主名(按可能性排序)。
246
+ *
247
+ * 为什么要多个:B站个人卡片实测长这样(OCR 把结构压扁了、顺序也不一定)——
248
+ *
249
+ * 雾小霜暗区突围 1,052 半身像 UP主 4993粉丝 1,087 *未知" 5.3万播放1806点赞 11弹幕
250
+ *
251
+ * 真名是**第一行的「雾小霜暗区突围」**,而「UP主」前一行是「半身像」(封面上的字)。
252
+ * 只取「UP主前一行」这条老规则就会拿「半身像」去搜,标题又对不上,于是六个候选一个都不敢选。
253
+ * 现在两条都当候选(首行 + UP主前后行),搜索端**任一命中**就算作者对上,
254
+ * 谁真的搜得到就用谁 —— 不再赌某一种排版。
255
+ */
256
+ export const extractUpNames = (text: string): string[] => {
257
+ const raw = String(text ?? '')
258
+ // OCR 有时整段只有一行(空格分隔),这时按空白切
259
+ const byLine = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
260
+ const tokens = byLine.length >= 2 ? byLine : raw.split(/[\s\u3000|/]+/).map((item) => item.trim()).filter(Boolean)
242
261
  const isStat = (line: string) =>
243
- /(粉丝|播放|点赞|弹幕|投币|收藏|关注|转发|评论|分享)/.test(line) || /^\d+(\.\d+)?[万亿]?$/.test(line)
244
- const idx = lines.findIndex((line) => /^(up主|UP主|up|UP)$/.test(line))
245
- if (idx > 0) return lines[idx - 1]
246
- if (idx === 0 && lines[1]) return lines[1]
247
- return lines.find((line) => !isStat(line)) ?? ''
262
+ /(粉丝|播放|点赞|弹幕|投币|收藏|关注|转发|评论|分享|投稿)/.test(line) ||
263
+ /^[\d,.]+(\.\d+)?[万亿]?$/.test(line) ||
264
+ /^[**·.]+$/.test(line)
265
+ const clean = (line: string): string => line
266
+ .replace(/^[**·\s]+/, '')
267
+ .replace(/[**·\s]+$/, '')
268
+ .replace(/["“”'']/g, '')
269
+ .trim()
270
+ const candidates: string[] = []
271
+ const push = (line?: string) => {
272
+ if (!line) return
273
+ const value = clean(line)
274
+ if (value.length < 2 || value.length > 20) return
275
+ if (isStat(value)) return
276
+ if (NICK_LABELS.has(value.toLowerCase())) return
277
+ if (/^[\d,.万]+$/.test(value)) return
278
+ if (!candidates.includes(value)) candidates.push(value)
279
+ }
280
+ // ① UP主 前后各一行/一个词
281
+ const idx = tokens.findIndex((token) => /^(up主|up|upzhu)$/i.test(clean(token)))
282
+ if (idx > 0) push(tokens[idx - 1])
283
+ if (idx >= 0) push(tokens[idx + 1])
284
+ // ② 第一行(B站卡片昵称就在最上面)
285
+ push(tokens[0])
286
+ // ③ 任何一行像「粉丝数」这种统计的**前面**一行
287
+ const statIdx = tokens.findIndex((token) => /粉丝|关注/.test(token))
288
+ if (statIdx > 0) push(tokens[statIdx - 1])
289
+ return candidates
248
290
  }
249
291
 
250
292
  /* ------------------------------------------------------------------ *
@@ -305,9 +347,9 @@ const getWbiKeys = async (): Promise<{ imgKey: string; subKey: string; buvid3: s
305
347
  export const searchBiliVideos = async (
306
348
  keyword: string,
307
349
  title = '',
308
- author = '',
350
+ author: string | string[] = '',
309
351
  limit = 8
310
- ): Promise<Array<{ bvid: string; title: string; author: string; score: number; titleMatch: boolean; authorMatch: boolean }>> => {
352
+ ): Promise<Array<{ bvid: string; title: string; author: string; score: number; titleMatch: boolean; authorMatch: boolean; matchedAuthor: string }>> => {
311
353
  try {
312
354
  const { imgKey, subKey, buvid3 } = await getWbiKeys()
313
355
  const mixinKey = MIXIN_KEY_ENC_TAB.map((n) => (imgKey + subKey)[n]).join('').slice(0, 32)
@@ -329,7 +371,13 @@ export const searchBiliVideos = async (
329
371
  }
330
372
  const raw = Array.isArray(json?.data?.result) ? json.data.result : []
331
373
  const titleKey = normalizeText(title || keyword)
332
- const authorKey = normalizeText(author)
374
+ /**
375
+ * 作者可以有**多个候选**(卡片摘要里的那个 + OCR 认出来的那几个),
376
+ * 结果只要命中任意一个就算作者对上了,用命中的那个算分。
377
+ */
378
+ const authorKeys = (Array.isArray(author) ? author : [author])
379
+ .map((item) => normalizeText(item))
380
+ .filter((item, index, list) => item.length >= 2 && list.indexOf(item) === index)
333
381
  return raw
334
382
  .filter((item: any) => item?.bvid)
335
383
  .map((item: any, index: number) => {
@@ -340,9 +388,15 @@ export const searchBiliVideos = async (
340
388
  let score = 0
341
389
  let titleMatch = false
342
390
  let authorMatch = false
343
- if (authorKey && a) {
344
- if (a === authorKey) { score += 120; authorMatch = true }
345
- else if (a.includes(authorKey) || authorKey.includes(a)) { score += 70; authorMatch = true }
391
+ let matchedAuthor = ''
392
+ for (const authorKey of authorKeys) {
393
+ if (!a) break
394
+ if (a === authorKey) { score += 120; authorMatch = true; matchedAuthor = authorKey; break }
395
+ if (a.includes(authorKey) || authorKey.includes(a)) {
396
+ // 模糊命中:分少一点,并且只取最好的那次
397
+ if (!authorMatch) { score += 70; matchedAuthor = authorKey }
398
+ authorMatch = true
399
+ }
346
400
  }
347
401
  if (titleKey && t) {
348
402
  if (t === titleKey) { score += 100; titleMatch = true }
@@ -353,7 +407,7 @@ export const searchBiliVideos = async (
353
407
  score += Math.round(sim * 40)
354
408
  }
355
409
  }
356
- return { bvid: String(item.bvid), title: itemTitle, author: itemAuthor, score: score - index, titleMatch, authorMatch }
410
+ return { bvid: String(item.bvid), title: itemTitle, author: itemAuthor, score: score - index, titleMatch, authorMatch, matchedAuthor }
357
411
  })
358
412
  .sort((left: any, right: any) => right.score - left.score)
359
413
  .slice(0, Math.max(1, Math.min(20, limit)))
@@ -367,7 +421,11 @@ export const searchBiliVideos = async (
367
421
  * 抖音搜索(直接用 amagi 的 search 端点)
368
422
  * ------------------------------------------------------------------ */
369
423
 
370
- export const searchDouyinWorks = async (keyword: string, limit = 8): Promise<Array<{ aweme_id: string; desc: string; author: string; score: number }>> => {
424
+ export const searchDouyinWorks = async (
425
+ keyword: string,
426
+ limit = 8,
427
+ authors: string[] = []
428
+ ): Promise<Array<{ aweme_id: string; desc: string; author: string; score: number }>> => {
371
429
  try {
372
430
  // 这版接口库的抖音 fetcher 不一定有 search(实测 6.6.0 上没有),没有就干脆跳过
373
431
  const fetcher: any = douyinFetcher as any
@@ -379,16 +437,25 @@ export const searchDouyinWorks = async (keyword: string, limit = 8): Promise<Arr
379
437
  const list: any[] =
380
438
  res?.data?.data?.aweme_list ?? res?.data?.aweme_list ?? res?.aweme_list ?? []
381
439
  const titleKey = normalizeText(keyword)
440
+ const authorKeys = authors.map((item) => normalizeText(item)).filter((item) => item.length >= 2)
382
441
  return list
383
442
  .filter((item: any) => item?.aweme_id)
384
443
  .map((item: any, index: number) => {
385
444
  const desc = String(item.desc ?? '')
386
445
  const sim = titleSimilarity(normalizeText(desc), titleKey)
446
+ // 作者对上一样加分:抖音搜索噪声大,光靠标题常常分不出是哪一条
447
+ const authorKey = normalizeText(String(item.author?.nickname ?? ''))
448
+ let authorScore = 0
449
+ for (const key of authorKeys) {
450
+ if (!authorKey) break
451
+ if (authorKey === key) { authorScore = 60; break }
452
+ if (authorKey.includes(key) || key.includes(authorKey)) authorScore = Math.max(authorScore, 35)
453
+ }
387
454
  return {
388
455
  aweme_id: String(item.aweme_id),
389
456
  desc,
390
457
  author: String(item.author?.nickname ?? ''),
391
- score: Math.round(sim * 100) - index
458
+ score: Math.round(sim * 100) + authorScore - index
392
459
  }
393
460
  })
394
461
  .sort((left, right) => right.score - left.score)
@@ -443,7 +510,17 @@ export const resolveCardToUrl = async (
443
510
 
444
511
  // ② OCR 封面拿文字线索
445
512
  const ocrText = await ocrImageText(card.cover)
446
- const upName = card.author || extractUpName(ocrText)
513
+ /**
514
+ * UP 主名可以有好几个来源:卡片摘要里的 author、OCR 里「UP主」前后行、OCR 首行。
515
+ *
516
+ * 实测踩过的坑:卡片摘要给的是「半身像」(封面上的字),OCR 首行才是真昵称
517
+ * 「雾小霜暗区突围」—— 只认一个来源时,作者永远匹配不上,六个候选一个都不敢选。
518
+ * 这里全部当候选,谁匹配上算谁的。
519
+ */
520
+ const upNames = [card.author, ...extractUpNames(ocrText)]
521
+ .map((item) => String(item ?? '').trim())
522
+ .filter((item, index, list) => item.length >= 2 && list.indexOf(item) === index)
523
+ const upName = upNames[0] ?? ''
447
524
  const keyword = card.title || upName || ocrText.replace(/\s+/g, ' ').slice(0, 40)
448
525
  if (!keyword) {
449
526
  logger.mark('[卡片解析] OCR 没有给出可用关键词')
@@ -456,19 +533,22 @@ export const resolveCardToUrl = async (
456
533
  const looksBili = /bilibili|哔哩|B站|UP主/i.test(hint)
457
534
 
458
535
  // ③ 先按最可能的平台搜,命中就返回
459
- const tryBili = async (): Promise<{ url?: string; candidates: CardCandidate[] }> => {
460
- const hits = await searchBiliVideos(keyword, card.title, upName, 8)
536
+ const tryBili = async (): Promise<{ url?: string; candidates: CardCandidate[]; matchedAuthor?: string }> => {
537
+ const hits = await searchBiliVideos(keyword, card.title, upNames, 8)
461
538
  const candidates = hits.slice(0, 6).map((item) => ({
462
539
  platform: 'bilibili' as const, id: item.bvid, title: item.title, author: item.author, score: item.score
463
540
  }))
464
541
  const strict = hits.filter((item) => item.authorMatch || item.titleMatch)
465
542
  // 只有「标题和作者都命中」才敢自动继续,否则交给用户挑
466
543
  const best = (strict.length ? strict : hits)[0]
467
- if (best && best.titleMatch && best.authorMatch) return { url: 'https://www.bilibili.com/video/' + best.bvid, candidates }
544
+ if (best && best.titleMatch && best.authorMatch) {
545
+ logger.mark('[卡片解析] 作者命中「' + (best.matchedAuthor || '') + '」:' + best.author + ',标题: ' + best.title)
546
+ return { url: 'https://www.bilibili.com/video/' + best.bvid, candidates, matchedAuthor: best.author }
547
+ }
468
548
  return { candidates }
469
549
  }
470
550
  const tryDouyin = async (): Promise<{ url?: string; candidates: CardCandidate[] }> => {
471
- const hits = await searchDouyinWorks(keyword || card.title, 8)
551
+ const hits = await searchDouyinWorks(keyword || card.title, 8, upNames)
472
552
  const candidates = hits.slice(0, 6).map((item) => ({
473
553
  platform: 'douyin' as const, id: item.aweme_id, title: item.desc, author: item.author, score: item.score
474
554
  }))
@@ -486,7 +566,9 @@ export const resolveCardToUrl = async (
486
566
  candidates.push(...hit.candidates)
487
567
  if (hit.url) {
488
568
  logger.mark('[卡片解析] 定位成功: ' + hit.url)
489
- return { url: hit.url, platform: hit.url.includes('bilibili') ? 'bilibili' : 'douyin', card, candidates, ocrText, upName }
569
+ // 提示语里报「真正匹配上的那个 UP 名」,而不是卡片摘要里那个不准的
570
+ const matched = (hit as any).matchedAuthor || upName
571
+ return { url: hit.url, platform: hit.url.includes('bilibili') ? 'bilibili' : 'douyin', card, candidates, ocrText, upName: matched }
490
572
  }
491
573
  }
492
574
  }
@@ -21,13 +21,29 @@ import { readQqOptions } from '../../../qqOptions'
21
21
  import { Root } from '../../root'
22
22
  import { getBuildMetadata } from './build-metadata'
23
23
 
24
- /** 默认收集站(面板里可改) */
25
- export const DEFAULT_REPORT_URL = 'https://err.tangbot.xyz'
26
- /** 默认反馈群(面板里可改) */
27
- export const DEFAULT_REPORT_GROUP = '1050229473'
24
+ /**
25
+ * 收集站地址:**写死,不开放配置**。
26
+ * 自建的人改这一行重新构建即可 —— 面板里只留「是否上传」一个开关,少一个能填错的地方。
27
+ */
28
+ export const REPORT_URL = 'https://err.tangbot.xyz'
29
+ /**
30
+ * 上报令牌:**写死**。
31
+ * 服务端 \`KKK_ERR_TOKEN\` 的默认值就是它,所以两边开箱即用、什么都不用配。
32
+ * 它写在开源插件里,等于公开 —— 服务端那边另有 IP 限流兜着,挡的是随机刷接口的流量。
33
+ */
34
+ export const REPORT_TOKEN = 'kkk-err-9f3c1d7a5b2e4c80'
35
+ /** 反馈群:写死(QQ 客户端点群链接就能进) */
36
+ export const REPORT_GROUP = '1050229473'
37
+ /** 上报多少行日志:写死(本次请求的日志 + 宿主日志文件尾部各取这么多行) */
38
+ export const REPORT_LOG_LINES = 200
28
39
 
29
- /** 上传超时(毫秒):超了就放弃,不拖住错误卡片 */
30
- const UPLOAD_TIMEOUT_MS = 8000
40
+ /**
41
+ * 上传超时(毫秒):超了就放弃,不拖住错误卡片。
42
+ *
43
+ * 上报是**在渲染错误卡片之前**做的(编号要印在卡片上),所以站点挂掉时这 5 秒会加到
44
+ * 报错送达上 —— 正常情况一次上传 50 毫秒左右,只有连不上站点才会等到超时。
45
+ */
46
+ const UPLOAD_TIMEOUT_MS = 5000
31
47
  /** 上报日志单行最长字符数 */
32
48
  const LOG_LINE_LIMIT = 2000
33
49
  /** 整份上报最多多大(压缩前),超了继续砍日志 */
@@ -50,24 +66,21 @@ interface ReportConfig {
50
66
  group: string
51
67
  }
52
68
 
53
- /** 读面板里的「错误上报」配置(缺省值来自 qqFields.json) */
69
+ /**
70
+ * 上报配置。
71
+ *
72
+ * 面板里**只有「上传错误信息」一个开关**(通用 → 错误上报):地址、令牌、群号、日志行数全部写死,
73
+ * 用户没有可以填错的地方。关掉开关就完全不上传。
74
+ */
54
75
  export function reportConfig (): ReportConfig {
55
- let options: Record<string, any> = {}
76
+ let enabled = true
56
77
  try {
57
- options = readQqOptions((tryGetRuntime()?.config ?? {}) as any)
78
+ const options = readQqOptions((tryGetRuntime()?.config ?? {}) as any)
79
+ enabled = options.errorReportUpload !== false
58
80
  } catch {
59
- options = (tryGetRuntime()?.config ?? {}) as any
60
- }
61
- const url = String(options.errorReportUrl ?? '').trim() || DEFAULT_REPORT_URL
62
- const logLines = Number(options.errorReportLogLines)
63
- return {
64
- enabled: options.errorReportUpload !== false,
65
- // 末尾斜杠统一去掉,拼 /api/v1/reports 时不会出现双斜杠
66
- url: url.replace(/\/+$/, ''),
67
- token: String(options.errorReportToken ?? '').trim(),
68
- logLines: Number.isFinite(logLines) ? Math.min(1000, Math.max(0, Math.trunc(logLines))) : 200,
69
- group: String(options.errorReportGroup ?? '').trim() || DEFAULT_REPORT_GROUP
81
+ enabled = (tryGetRuntime()?.config as any)?.errorReportUpload !== false
70
82
  }
83
+ return { enabled, url: REPORT_URL, token: REPORT_TOKEN, logLines: REPORT_LOG_LINES, group: REPORT_GROUP }
71
84
  }
72
85
 
73
86
  /** 反馈群链接:QQ 官方的加群链接格式,面板里只填群号 */
package/src/qqFields.json CHANGED
@@ -224,47 +224,5 @@
224
224
  "default": true,
225
225
  "label": "上传错误信息",
226
226
  "description": "插件出错时,自动把**错误信息、运行环境(Koishi 版本、装了哪些插件、适配器)、最近的日志**上传到收集站,站长在网页上就能查到,不用你截图、也不用翻日志文件。**默认开启**。上传失败不影响报错本身(错误卡片照常发)。不想要就在这里关掉。"
227
- },
228
- {
229
- "key": "errorReportUrl",
230
- "group": "通用",
231
- "renderIn": "app",
232
- "section": "错误上报",
233
- "type": "string",
234
- "default": "https://err.tangbot.xyz",
235
- "label": "上报服务器地址",
236
- "description": "错误收集站的地址,**默认 https://err.tangbot.xyz**。自建的话填自己的域名(不要带结尾斜杠)。上报内容会 POST 到「地址 + /api/v1/reports」。"
237
- },
238
- {
239
- "key": "errorReportToken",
240
- "group": "通用",
241
- "renderIn": "app",
242
- "section": "错误上报",
243
- "type": "string",
244
- "default": "",
245
- "secret": true,
246
- "label": "上报令牌",
247
- "description": "收集站要求的令牌,要和服务器端的 KKK_ERR_TOKEN 一致;填错会返回 401,日志里会写「上传失败 HTTP 401」。服务器启动时会打印这个令牌(没设就随机生成一个)。"
248
- },
249
- {
250
- "key": "errorReportLogLines",
251
- "group": "通用",
252
- "renderIn": "app",
253
- "section": "错误上报",
254
- "type": "number",
255
- "default": 200,
256
- "min": 0,
257
- "max": 1000,
258
- "label": "上报日志行数",
259
- "description": "上报时带多少行日志:本次请求捕获的日志 + 宿主日志文件的尾部(宿主那份最多 300 行)。0 = 完全不上传日志。单行超长的(比如 OneBot 把 4.6MB 的 base64 塞进报错)会自动折叠,不会把上报撑爆。"
260
- },
261
- {
262
- "key": "errorReportGroup",
263
- "group": "通用",
264
- "renderIn": "app",
265
- "section": "错误上报",
266
- "type": "string",
267
- "default": "1050229473",
268
- "label": "问题反馈群号",
269
- "description": "错误消息里带上这个群号,用户拿着上报编号进群提问。QQ 官方机器人会把它渲染成**可点的加群链接**(https://qm.qq.com/q/群号),个人号(NapCat 等)直接给纯文本链接,QQ 客户端同样会变成可点。"
270
- }]
227
+ }
228
+ ]