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.
- package/CHANGELOG.md +11 -2
- package/assets/web/assets/index-BRROK0vh.js +1 -1
- package/lib/index.js +33 -11
- package/lib/karin/module/utils/CardParser.js +115 -25
- package/lib/karin/module/utils/ErrorReport.js +34 -21
- package/lib/qqFields.json +0 -43
- package/package.json +1 -1
- package/scripts/smoke-cardparse.cjs +58 -0
- package/src/index.ts +33 -11
- package/src/karin/module/utils/CardParser.ts +105 -23
- package/src/karin/module/utils/ErrorReport.ts +33 -20
- package/src/qqFields.json +2 -44
|
@@ -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
|
-
|
|
241
|
-
|
|
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
|
-
/(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* 上报配置。
|
|
71
|
+
*
|
|
72
|
+
* 面板里**只有「上传错误信息」一个开关**(通用 → 错误上报):地址、令牌、群号、日志行数全部写死,
|
|
73
|
+
* 用户没有可以填错的地方。关掉开关就完全不上传。
|
|
74
|
+
*/
|
|
54
75
|
export function reportConfig (): ReportConfig {
|
|
55
|
-
let
|
|
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
|
-
|
|
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
|
+
]
|