Files
BYW-VoiceHub/server/utils/qqComment.ts
T

270 lines
8.3 KiB
TypeScript

const MAX_REPLY_DEPTH = 8
export const buildQqCommentRequestParam = ({
topid,
cursor,
page,
pageSize,
type
}: {
topid: string
cursor: string
page: number
pageSize: number
type: 'hot' | 'latest'
}) => ({
BizType: 1,
BizId: topid,
LastCommentSeqNo: cursor,
PageSize: pageSize,
PageNum: page,
PicEnable: 1,
...(type === 'hot'
? { HotType: 1, WithAirborne: 0 }
: { HashTagID: '', SelfSeeEnable: 1, AudioEnable: 1 })
})
const optionalString = (value: unknown): string => {
if (typeof value !== 'string') return ''
return value.trim()
}
// QQ 评论正文的换行以字面量 \n 两个字符返回,需还原为真实换行
const unescapeLiteralNewlines = (text: string) => text.replace(/\\r\\n|\\n|\\r/g, '\n')
const normalizeImageUrl = (value: unknown): string => {
const text = optionalString(value).replace(/&/gi, '&')
if (!text) return ''
if (text.startsWith('//')) return `https:${text}`
if (text.startsWith('http://')) return `https://${text.slice(7)}`
return /^https:\/\//i.test(text) ? text : ''
}
const collectImageUrls = (value: unknown): string[] => {
const values = Array.isArray(value) ? value : [value]
const urls = values
.map((item) => {
if (typeof item === 'string') return normalizeImageUrl(item)
if (!item || typeof item !== 'object') return ''
const image = item as Record<string, unknown>
return normalizeImageUrl(
image.url || image.picurl || image.picUrl || image.imageurl || image.imageUrl
)
})
.filter(Boolean)
return [...new Set(urls)]
}
const getCommentId = (item: Record<string, unknown>): string =>
optionalString(item.CmId ?? item.commentId ?? item.commentid ?? item.id)
const getRootCommentId = (item: Record<string, unknown>): string => {
const root =
item.RootCmId ??
item.RootCommentId ??
item.rootCommentId ??
item.rootcommentid ??
item.rootid ??
item.RootComment ??
item.rootComment
if (root && typeof root === 'object') return getCommentId(root as Record<string, unknown>)
return optionalString(root)
}
const getParentCommentId = (item: Record<string, unknown>): string =>
(() => {
const parent =
item.ParentCmId ??
item.ParentCommentId ??
item.parentCommentId ??
item.parentcommentid ??
item.parentid ??
item.ParentComment
if (parent && typeof parent === 'object') return getCommentId(parent)
return optionalString(parent)
})()
const getNestedReplies = (item: Record<string, unknown>): Array<Record<string, unknown>> => {
const replies = [
...(Array.isArray(item.RepliedComments) ? item.RepliedComments : []),
...(Array.isArray(item.SubComments) ? item.SubComments : []),
...(Array.isArray(item.SubCmListV1) ? item.SubCmListV1 : [])
]
const seen = new Set<string>()
return replies.filter((reply) => {
if (!reply || typeof reply !== 'object') return false
const id = getCommentId(reply)
if (id && seen.has(id)) return false
if (id) seen.add(id)
return true
})
}
const normalizeTimestamp = (value: unknown): number => {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) return 0
return timestamp >= 1_000_000_000_000 ? timestamp : timestamp * 1000
}
export interface QqCommentItem {
commentId: string
content: string
time: number
likedCount: number
liked: boolean
replyCount: number
images: string[]
emojis: string[]
replies: QqCommentItem[]
user: {
nickname: string
avatarUrl: string
location: string
}
}
export const normalizeQqComment = (
raw: unknown,
parentIds = new Set<string>(),
depth = 0
): QqCommentItem | null => {
if (!raw || typeof raw !== 'object' || depth > MAX_REPLY_DEPTH) return null
const item = raw as Record<string, unknown>
const commentId = getCommentId(item)
const content = unescapeLiteralNewlines(
optionalString(item.Content ?? item.content ?? item.middlecommentcontent ?? item.commentcontent)
)
if (!commentId || !content || parentIds.has(commentId)) return null
const nextParentIds = new Set(parentIds)
nextParentIds.add(commentId)
const replyIds = new Set<string>()
const replies = getNestedReplies(item)
.map((reply) => normalizeQqComment(reply, nextParentIds, depth + 1))
.filter((reply): reply is QqCommentItem => {
if (!reply || replyIds.has(reply.commentId)) return false
replyIds.add(reply.commentId)
return true
})
const userSource = item.userinfo || item.user
const user =
userSource && typeof userSource === 'object'
? (userSource as Record<string, unknown>)
: {}
return {
commentId,
content,
time: normalizeTimestamp(item.PubTime ?? item.time),
likedCount: Number(item.PraiseNum ?? item.praisenum ?? item.likedCount ?? 0) || 0,
liked: Boolean(item.IsPraised ?? item.liked),
replyCount: Number(item.ReplyCnt ?? item.replyCount ?? replies.length) || replies.length,
images: collectImageUrls(item.Pic ?? item.pic ?? item.images),
emojis: collectImageUrls(item.EmoPic ?? item.emoPic),
replies,
user: {
nickname:
optionalString(item.Nick ?? item.nick ?? item.nickname ?? user.nickname) || 'QQ 音乐用户',
avatarUrl: normalizeImageUrl(item.Avatar ?? item.avatar ?? item.avatarurl ?? user.avatarUrl),
location: optionalString(item.Location ?? item.location ?? user.location)
}
}
}
const collectReplyIds = (items: QqCommentItem[], result = new Set<string>()): Set<string> => {
for (const item of items) {
for (const reply of item.replies) {
result.add(reply.commentId)
collectReplyIds(reply.replies, result)
}
}
return result
}
export const normalizeQqCommentList = (rawItems: unknown): QqCommentItem[] => {
if (!Array.isArray(rawItems)) return []
const candidates = rawItems
.map((raw) => {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const item = normalizeQqComment(source)
if (!item) return null
return {
item,
rootId: getRootCommentId(source),
parentId: getParentCommentId(source)
}
})
.filter((candidate): candidate is { item: QqCommentItem; rootId: string; parentId: string } =>
Boolean(candidate)
)
const normalizedById = new Map<string, QqCommentItem>()
for (const { item } of candidates) {
if (!normalizedById.has(item.commentId)) normalizedById.set(item.commentId, item)
}
const explicitReplyIds = new Set<string>()
for (const { item, rootId, parentId } of candidates) {
const targetId = rootId || parentId
if (!targetId || targetId === item.commentId) continue
explicitReplyIds.add(item.commentId)
const target = normalizedById.get(targetId)
if (!target || target.commentId === item.commentId) continue
if (collectReplyIds([item]).has(target.commentId)) continue
const existingReplyIds = collectReplyIds([target])
if (!existingReplyIds.has(item.commentId)) {
target.replies.push(item)
target.replyCount = Math.max(target.replyCount, target.replies.length)
}
}
const normalized = [...normalizedById.values()]
const nestedReplyIds = collectReplyIds(normalized)
const rootIds = new Set<string>()
return normalized.filter((item) => {
if (
explicitReplyIds.has(item.commentId) ||
nestedReplyIds.has(item.commentId) ||
rootIds.has(item.commentId)
) {
return false
}
rootIds.add(item.commentId)
return true
})
}
export const normalizeQqCommentPage = (rawItems: unknown) => {
const items = Array.isArray(rawItems) ? rawItems : []
const ids = new Set(
items
.filter((item) => item && typeof item === 'object')
.map((item) => getCommentId(item as Record<string, unknown>))
.filter(Boolean)
)
const orphanReplies = items
.map((raw) => {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const rootCommentId = getRootCommentId(source) || getParentCommentId(source)
const commentId = getCommentId(source)
if (!rootCommentId || rootCommentId === commentId || ids.has(rootCommentId)) return null
const item = normalizeQqComment(source)
return item ? { ...item, rootCommentId } : null
})
.filter((item): item is QqCommentItem & { rootCommentId: string } => Boolean(item))
return {
comments: normalizeQqCommentList(items),
orphanReplies
}
}