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 return normalizeImageUrl( image.url || image.picurl || image.picUrl || image.imageurl || image.imageUrl ) }) .filter(Boolean) return [...new Set(urls)] } const getCommentId = (item: Record): string => optionalString(item.CmId ?? item.commentId ?? item.commentid ?? item.id) const getRootCommentId = (item: Record): 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) return optionalString(root) } const getParentCommentId = (item: Record): 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): Array> => { const replies = [ ...(Array.isArray(item.RepliedComments) ? item.RepliedComments : []), ...(Array.isArray(item.SubComments) ? item.SubComments : []), ...(Array.isArray(item.SubCmListV1) ? item.SubCmListV1 : []) ] const seen = new Set() 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(), depth = 0 ): QqCommentItem | null => { if (!raw || typeof raw !== 'object' || depth > MAX_REPLY_DEPTH) return null const item = raw as Record 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() 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) : {} 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()): Set => { 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 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() for (const { item } of candidates) { if (!normalizedById.has(item.commentId)) normalizedById.set(item.commentId, item) } const explicitReplyIds = new Set() 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() 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)) .filter(Boolean) ) const orphanReplies = items .map((raw) => { if (!raw || typeof raw !== 'object') return null const source = raw as Record 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 } }