// ==UserScript== // @name 知乎楼中楼上下文|完整对话链 // @namespace https://tampermonkey.net/ // @version 0.5.1 // @description 鼠标悬停知乎深层楼中楼回复,自动追溯并展示完整评论父链,快速看懂当前回复的前因后果。 // @author KIDGG // @license MIT // @match https://www.zhihu.com/* // @match https://zhuanlan.zhihu.com/* // @grant GM_addStyle // @grant unsafeWindow // @run-at document-start // ==/UserScript== (function () { 'use strict'; const PAGE = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const VERSION = '0.5.1'; const DEBUG_KEY = 'tm-zhihu-context-debug'; const DEBUG = localStorage.getItem(DEBUG_KEY) === '1'; const MODAL_ID = 'tm-zhihu-context-popover-v051'; /* * =============================== * v0.4 稳定参数 * =============================== */ const HOVER_OPEN_DELAY = 110; const HOVER_CLOSE_DELAY = 220; const MAX_ANCESTORS = 15; const MAX_MATCH_COMMENTS = 1500; const MAX_PAGING_REQUESTS = 50; const MAX_CHAIN_DEPTH = 12; /* * =============================== * 仅展示层参数 * =============================== */ const AUTO_COLLAPSE_CHAIN_LENGTH = 6; const KEEP_CHAIN_HEAD = 2; const KEEP_CHAIN_TAIL = 3; /* * =============================== * 评论缓存 * =============================== */ const commentById = new Map(); const commentsByRoot = new Map(); const pagingNextByRoot = new Map(); const pagingStartByRoot = new Map(); /* * 只缓存已经唯一确认的: * * DOM -> comment ID */ const elementMatchCache = new WeakMap(); /* * =============================== * 运行状态 * =============================== */ let activeHost = null; let activeComment = null; let activeAnchor = null; let hoverOpenTimer = 0; let hoverCloseTimer = 0; let popoverFollowRaf = 0; let lastMouseTarget = null; let lastMatchDebug = null; let domMatchCount = 0; /* * =============================== * 基础工具 * =============================== */ function log(...args) { if (DEBUG) { console.log( `[ZhihuContext v${VERSION}]`, ...args ); } } function setDebugEnabled( enabled ) { try { localStorage.setItem( DEBUG_KEY, enabled ? '1' : '0' ); } catch (_) {} } function sleep(ms) { return new Promise( resolve => setTimeout( resolve, ms ) ); } function normalizeText( value = '' ) { return String(value) .replace( /\u200b|\u200c|\u200d|\ufeff/g, '' ) .replace( /\s+/g, ' ' ) .trim(); } function normalizeForMatch( value = '' ) { return normalizeText(value) .replace( /\s+/g, '' ) .replace( /[“”‘’"'`]/g, '' ) .toLowerCase(); } function escapeHtml( value = '' ) { return String(value) .replaceAll( '&', '&' ) .replaceAll( '<', '<' ) .replaceAll( '>', '>' ) .replaceAll( '"', '"' ) .replaceAll( "'", ''' ); } function htmlToText( value = '' ) { const raw = String( value ?? '' ); if ( !raw.includes('<') ) { return normalizeText( raw ); } try { const div = document.createElement( 'div' ); div.innerHTML = raw; return normalizeText( div.textContent || '' ); } catch (_) { return normalizeText( raw.replace( /<[^>]+>/g, ' ' ) ); } } function toId( value ) { if ( value == null ) { return ''; } if ( typeof value === 'object' ) { value = value.id ?? value.comment_id ?? value.commentId ?? value.value ?? ''; } const raw = String(value) .trim(); if ( !raw || raw === '0' || raw === 'null' || raw === 'undefined' ) { return ''; } const match = raw.match( /\d{4,}/ ); return match ? match[0] : ''; } function getAuthorName( obj ) { return normalizeText( obj?.author?.name || obj?.member?.name || obj?.member?.uname || obj?.user?.name || obj?.creator?.name || obj?.author_name || '' ); } function getReplyTargetName( obj ) { return normalizeText( obj?.reply_to_author?.name || obj?.replyToAuthor?.name || obj?.reply_author?.name || obj?.replyAuthor?.name || obj?.parent_author?.name || obj?.parentAuthor?.name || '' ); } function getCommentContent( obj ) { return htmlToText( obj?.content ?? obj?.content_text ?? obj?.contentText ?? obj?.text ?? obj?.message ?? obj?.body ?? '' ); } function stripLeadingReplyPrefix( text = '' ) { return normalizeText( String(text) .replace( /^回复\s*@?\s*[^::]{1,60}[::]\s*/, '' ) ); } /* * =============================== * 评论对象识别 * =============================== */ function looksLikeCommentObject( obj ) { if ( !obj || typeof obj !== 'object' || Array.isArray(obj) ) { return false; } const type = String( obj.type || obj.object_type || obj.objectType || '' ) .toLowerCase(); const id = toId( obj.id ?? obj.comment_id ?? obj.commentId ); const content = getCommentContent( obj ); const commentKeys = [ 'reply_comment_id', 'replyCommentId', 'parent_comment_id', 'parentCommentId', 'root_comment_id', 'rootCommentId', 'child_comments', 'childComments', 'reply_to_author', 'replyToAuthor', 'child_comment_count', 'childCommentCount' ]; return ( !!id && !!content && ( type.includes( 'comment' ) || commentKeys.some( key => key in obj ) ) ); } function normalizeComment( raw, fallback = {} ) { if ( !raw || typeof raw !== 'object' ) { return null; } const id = toId( raw.id ?? raw.comment_id ?? raw.commentId ?? fallback.id ); if (!id) { return null; } const explicitRoot = toId( raw.root_comment_id ?? raw.rootCommentId ?? raw.root_id ?? raw.rootId ?? raw.root_comment?.id ?? raw.rootComment?.id ?? fallback.root ); const explicitParent = toId( raw.reply_comment_id ?? raw.replyCommentId ?? raw.parent_comment_id ?? raw.parentCommentId ?? raw.reply_to_comment_id ?? raw.replyToCommentId ?? raw.parent?.id ?? raw.reply_comment?.id ?? raw.replyComment?.id ?? fallback.parent ); const childComments = raw.child_comments ?? raw.childComments; const isRootByShape = Array.isArray( childComments ) || Number( raw.child_comment_count ?? raw.childCommentCount ?? 0 ) > 0; const root = explicitRoot || ( isRootByShape ? id : toId( fallback.root ) ) || id; let parent = explicitParent || toId( fallback.parent ); if (!parent) { parent = id === root ? id : root; } const author = getAuthorName( raw ) || normalizeText( fallback.author || '' ); const replyTargetName = getReplyTargetName( raw ) || normalizeText( fallback.replyTargetName || '' ); let content = getCommentContent( raw ) || normalizeText( fallback.content || '' ); if ( replyTargetName ) { content = stripLeadingReplyPrefix( content ); } return { ...fallback, ...raw, id, root, parent, author, replyTargetName, content, _normalizedZhihuComment: true }; } function mergeComment( base, extra ) { if (!base) { return extra || null; } if (!extra) { return base; } const merged = { ...base, ...extra }; for ( const key of [ 'id', 'root', 'parent', 'author', 'replyTargetName', 'content' ] ) { if ( !merged[key] && base[key] ) { merged[key] = base[key]; } } return merged; } /* * =============================== * 缓存 * =============================== */ function getRootEntry( rootId ) { const root = toId( rootId ); if (!root) { return null; } if ( !commentsByRoot.has( root ) ) { commentsByRoot.set( root, { rootComment: null, comments: new Map() } ); } return commentsByRoot.get( root ); } function cacheComment( raw, fallback = {} ) { const normalized = raw?._normalizedZhihuComment ? raw : normalizeComment( raw, fallback ); if (!normalized) { return null; } const old = commentById.get( normalized.id ); const merged = mergeComment( old, normalized ); commentById.set( merged.id, merged ); const rootEntry = getRootEntry( merged.root ); if (rootEntry) { rootEntry.comments.set( merged.id, mergeComment( rootEntry.comments.get( merged.id ), merged ) ); if ( merged.id === merged.root ) { rootEntry.rootComment = mergeComment( rootEntry.rootComment, merged ); } } return merged; } function cacheJsonComments( payload, context = {} ) { if ( !payload || typeof payload !== 'object' ) { return 0; } const queue = [ { value: payload, depth: 0, fallback: context } ]; const visited = new Set(); let cached = 0; let steps = 0; while ( queue.length && steps < 6000 ) { const { value, depth, fallback } = queue.shift(); steps += 1; if ( !value || typeof value !== 'object' ) { continue; } if ( visited.has( value ) ) { continue; } visited.add( value ); if ( looksLikeCommentObject( value ) ) { const comment = cacheComment( value, fallback ); if (comment) { cached += 1; const children = value.child_comments ?? value.childComments; if ( Array.isArray( children ) ) { for ( const child of children ) { queue.push( { value: child, depth: depth + 1, fallback: { root: comment.root || comment.id, parent: comment.id } } ); } } } } if ( depth >= 8 ) { continue; } if ( Array.isArray( value ) ) { for ( const child of value.slice( 0, 300 ) ) { if ( child && typeof child === 'object' ) { queue.push( { value: child, depth: depth + 1, fallback } ); } } continue; } for ( const [ key, child ] of Object.entries( value ) ) { if ( !child || typeof child !== 'object' ) { continue; } if ( /^(author|member|user|creator|badge|avatar|relationship)$/i .test( key ) ) { continue; } queue.push( { value: child, depth: depth + 1, fallback } ); } } return cached; } /* * =============================== * 知乎接口 * =============================== */ function isZhihuCommentApiUrl( rawUrl ) { const url = String( rawUrl || '' ); return ( /\/api\/v\d+\//i .test(url) && /comment/i .test(url) ); } function extractRootIdFromUrl( rawUrl ) { try { const url = new URL( String( rawUrl ), location.origin ); for ( const key of [ 'root_comment_id', 'rootCommentId', 'root_id', 'root' ] ) { const id = toId( url.searchParams.get( key ) ); if (id) { return id; } } const match = url.pathname.match( /comments?\/(\d+)\/(?:child_comments?|children)/i ) || url.pathname.match( /comment(?:_v\d+)?\/comment\/(\d+)\/child_comment/i ); return match ? toId( match[1] ) : ''; } catch (_) { return ''; } } function rememberPaging( payload, requestUrl = '' ) { if ( !payload || typeof payload !== 'object' ) { return; } const paging = payload.paging || payload.data?.paging || payload.pagination || payload.data ?.pagination; const next = paging?.next || paging?.next_url || paging?.nextUrl || ''; if (!next) { return; } const roots = new Set(); const rootFromUrl = extractRootIdFromUrl( requestUrl ); if ( rootFromUrl ) { roots.add( rootFromUrl ); } let data = []; if ( Array.isArray( payload.data ) ) { data = payload.data; } else if ( Array.isArray( payload?.data?.data ) ) { data = payload.data.data; } for ( const item of data.slice( 0, 100 ) ) { if ( !looksLikeCommentObject( item ) ) { continue; } const comment = normalizeComment( item ); if ( comment?.root ) { roots.add( comment.root ); } } for ( const root of roots ) { pagingNextByRoot.set( root, String(next) ); if ( !pagingStartByRoot.has( root ) ) { pagingStartByRoot.set( root, String(next) ); } } } function processApiJson( json, requestUrl = '' ) { const rootHint = extractRootIdFromUrl( requestUrl ); const count = cacheJsonComments( json, rootHint ? { root: rootHint } : {} ); rememberPaging( json, requestUrl ); if ( count > 0 ) { log( '缓存 API 评论:', count, requestUrl ); } } /* * =============================== * 网络监听 * =============================== */ function installNetworkHooks() { if ( PAGE .__tmZhihuContextNetworkV051 ) { return; } PAGE .__tmZhihuContextNetworkV051 = true; /* * fetch */ try { const rawFetch = PAGE.fetch; if ( typeof rawFetch === 'function' ) { PAGE.fetch = async function ( ...args ) { const response = await rawFetch.apply( this, args ); try { const input = args[0]; const url = typeof input === 'string' ? input : input?.url || ''; if ( isZhihuCommentApiUrl( url ) ) { response .clone() .json() .then( json => { processApiJson( json, url ); } ) .catch( () => {} ); } } catch (_) {} return response; }; } } catch (err) { console.warn( `[ZhihuContext v${VERSION}] fetch hook failed:`, err ); } /* * XHR */ try { const XHR = PAGE.XMLHttpRequest; if ( XHR?.prototype ) { const rawOpen = XHR.prototype.open; const rawSend = XHR.prototype.send; XHR.prototype.open = function ( method, url, ...rest ) { this .__tmZhihuContextUrlV051 = url; return rawOpen.call( this, method, url, ...rest ); }; XHR.prototype.send = function ( ...args ) { this.addEventListener( 'load', function () { try { const url = String( this.responseURL || this .__tmZhihuContextUrlV051 || '' ); if ( !isZhihuCommentApiUrl( url ) ) { return; } if ( this.responseType && this.responseType !== '' && this.responseType !== 'text' ) { return; } processApiJson( JSON.parse( this.responseText ), url ); } catch (_) {} } ); return rawSend.apply( this, args ); }; } } catch (err) { console.warn( `[ZhihuContext v${VERSION}] XHR hook failed:`, err ); } } /* * ========================================== * * DOM → Comment * * 下面保持 v0.4 已验证的 * “最小唯一 DOM”匹配逻辑 * * ========================================== */ function getElementText( element ) { if ( !(element instanceof Element) ) { return ''; } try { return normalizeText( element.innerText || element.textContent || '' ); } catch (_) { return normalizeText( element.textContent || '' ); } } function getAncestorCandidates( target ) { const result = []; let current = target instanceof Element ? target : target?.parentElement || null; for ( let depth = 0; current && depth < MAX_ANCESTORS; depth += 1 ) { if ( current.id === MODAL_ID || current.closest?.( `#${MODAL_ID}` ) ) { break; } const text = getElementText( current ); if ( text && text.length >= 2 && text.length <= 6000 ) { result.push( { element: current, depth, text, matchText: normalizeForMatch( text ) } ); } if ( current === document.body || current === document.documentElement ) { break; } current = current.parentElement; } return result; } function testCommentAgainstCandidate( candidate, comment ) { if ( !candidate?.matchText || !comment ) { return null; } const candidateText = candidate.matchText; const content = normalizeForMatch( comment.content || '' ); if ( !content || content.length < 2 ) { return null; } /* * 必须完整包含正文。 */ if ( !candidateText.includes( content ) ) { return null; } const author = normalizeForMatch( comment.author || '' ); const replyTarget = normalizeForMatch( comment .replyTargetName || '' ); const authorMatched = !!author && candidateText.includes( author ); const targetMatched = !!replyTarget && candidateText.includes( replyTarget ); /* * 极短回复必须匹配作者。 */ if ( content.length <= 6 && author && !authorMatched ) { return null; } /* * 很短的回复如果存在回复对象, * 也要求目标出现在 DOM。 */ if ( content.length <= 10 && replyTarget && !targetMatched ) { return null; } const candidateLength = Math.max( 1, candidateText.length ); const contentLength = Math.max( 1, content.length ); const ratio = candidateLength / contentLength; let score = 100; if ( authorMatched ) { score += 28; } if ( targetMatched ) { score += 18; } if ( comment.parent && comment.root && comment.parent !== comment.root ) { score += 6; } if ( ratio <= 1.8 ) { score += 32; } else if ( ratio <= 3 ) { score += 26; } else if ( ratio <= 5 ) { score += 18; } else if ( ratio <= 8 ) { score += 10; } else if ( ratio <= 15 ) { score += 2; } else if ( ratio > 25 ) { score -= 35; } else if ( ratio > 18 ) { score -= 15; } if ( candidateLength > 1200 ) { score -= 12; } if ( candidateLength > 1800 ) { score -= 25; } if ( candidateLength > 3000 ) { score -= 45; } return { comment, score, ratio, authorMatched, targetMatched }; } function getCandidateMatches( candidate ) { const result = []; const comments = Array.from( commentById.values() ) .slice( -MAX_MATCH_COMMENTS ); for ( const comment of comments ) { const match = testCommentAgainstCandidate( candidate, comment ); if (match) { result.push( match ); } } return result; } function chooseUniqueMatch( matches ) { if ( !Array.isArray( matches ) || !matches.length ) { return null; } if ( matches.length === 1 ) { return matches[0]; } const authorMatches = matches.filter( item => item.authorMatched ); if ( authorMatches.length === 1 ) { return authorMatches[0]; } const base = authorMatches.length ? authorMatches : matches; const targetMatches = base.filter( item => item.targetMatched ); if ( targetMatches.length === 1 ) { return targetMatches[0]; } /* * 无法唯一判断,不猜。 */ return null; } function findBestCommentMatch( target ) { if ( !(target instanceof Element) ) { return null; } /* * 优先检查已经唯一确认的节点。 */ let cachedNode = target; for ( let i = 0; cachedNode && i < 4; i += 1 ) { const cached = elementMatchCache.get( cachedNode ); if ( cached?.exact === true && cached?.commentId && commentById.has( cached.commentId ) ) { return { element: cachedNode, comment: commentById.get( cached.commentId ), score: cached.score || 100, source: 'exact-element-cache' }; } cachedNode = cachedNode.parentElement; } const candidates = getAncestorCandidates( target ); if ( !candidates.length || !commentById.size ) { return null; } /* * 从最小 DOM 开始向上。 */ for ( const candidate of candidates ) { if ( candidate.matchText .length > 3500 ) { break; } const matches = getCandidateMatches( candidate ); if ( !matches.length ) { continue; } const unique = chooseUniqueMatch( matches ); /* * 一个 DOM 同时包含多条评论, * 说明它是评论列表容器。 */ if (!unique) { log( '跳过歧义 DOM:', { depth: candidate.depth, matchCount: matches.length, ids: matches .slice( 0, 20 ) .map( item => item.comment.id ), text: candidate.text .slice( 0, 180 ) } ); continue; } if ( unique.score < 90 ) { continue; } const comment = unique.comment; /* * 只有唯一确认后才缓存。 */ elementMatchCache.set( candidate.element, { commentId: comment.id, score: unique.score, exact: true } ); domMatchCount += 1; lastMatchDebug = { version: VERSION, result: 'unique-match', id: comment.id, root: comment.root, parent: comment.parent, author: comment.author, replyTarget: comment .replyTargetName, content: String( comment.content || '' ).slice( 0, 140 ), score: Math.round( unique.score * 10 ) / 10, ratio: Math.round( unique.ratio * 10 ) / 10, authorMatched: unique.authorMatched, targetMatched: unique.targetMatched, candidateDepth: candidate.depth, candidateLength: candidate.matchText .length, candidateMatches: matches.length, elementTag: candidate.element .tagName, elementClass: String( candidate.element .className || '' ).slice( 0, 180 ), elementText: candidate.text .slice( 0, 220 ) }; log( '唯一定位评论:', lastMatchDebug ); return { element: candidate.element, comment, score: unique.score, source: 'v0.4-stable-unique' }; } lastMatchDebug = { version: VERSION, result: 'no-unique-match' }; return null; } function isNestedReply( comment ) { return !!( comment?.id && comment?.root && comment?.parent && comment.id !== comment.root && comment.parent !== comment.root ); } /* * =============================== * API 主动补全 * =============================== */ async function safeFetchJson( url ) { try { const absolute = new URL( url, 'https://www.zhihu.com' ) .toString(); const response = await fetch( absolute, { credentials: 'include', headers: { accept: 'application/json, text/plain, */*' } } ); if ( !response.ok ) { return null; } const json = await response.json(); processApiJson( json, absolute ); return json; } catch (err) { log( '补全接口失败:', url, err ); return null; } } async function fetchCommentDirect( commentId ) { const id = toId( commentId ); if (!id) { return null; } if ( commentById.has( id ) ) { return commentById.get( id ); } const urls = [ `/api/v4/comments/${id}`, `/api/v4/comment_v5/comment/${id}` ]; for ( const url of urls ) { const json = await safeFetchJson( url ); if (!json) { continue; } const cached = commentById.get( id ); if (cached) { return cached; } if ( looksLikeCommentObject( json ) ) { const direct = cacheComment( json ); if (direct) { return direct; } } } return null; } async function fetchRootChildren( rootId ) { const root = toId( rootId ); if (!root) { return; } const urls = [ `/api/v4/comments/${root}/child_comments?limit=20&offset=0`, `/api/v4/comment_v5/comment/${root}/child_comment?limit=20&offset=0` ]; for ( const url of urls ) { await safeFetchJson( url ); const entry = commentsByRoot.get( root ); if ( entry ?.comments ?.size > 1 ) { break; } } } async function fetchUntilCommentFound( rootId, targetId ) { const root = toId( rootId ); const target = toId( targetId ); if ( !root || !target ) { return null; } if ( commentById.has( target ) ) { return commentById.get( target ); } let next = pagingNextByRoot.get( root ) || pagingStartByRoot.get( root ) || ''; /* * 每次寻找祖先都使用自己的 visitedUrls。 */ const visitedUrls = new Set(); let count = 0; while ( next && count < MAX_PAGING_REQUESTS && !visitedUrls.has( next ) ) { visitedUrls.add( next ); count += 1; const json = await safeFetchJson( next ); if (!json) { break; } if ( commentById.has( target ) ) { return commentById.get( target ); } const paging = json.paging || json.data?.paging || json.pagination || json.data ?.pagination; if ( paging?.is_end === true || paging?.isEnd === true ) { break; } next = paging?.next || paging?.next_url || paging?.nextUrl || ''; if (next) { pagingNextByRoot.set( root, next ); } await sleep( 90 ); } return ( commentById.get( target ) || null ); } async function ensureCommentLoaded( commentId, rootId ) { const id = toId( commentId ); const root = toId( rootId ); if (!id) { return null; } if ( commentById.has( id ) ) { return commentById.get( id ); } let comment = await fetchCommentDirect( id ); if (comment) { return comment; } if (root) { await fetchRootChildren( root ); if ( commentById.has( id ) ) { return commentById.get( id ); } comment = await fetchUntilCommentFound( root, id ); if (comment) { return comment; } } return null; } /* * ========================================== * * 完整父链 * * 保持 v0.4 逻辑 * * ========================================== */ async function resolveFullChain( current ) { const latest = commentById.get( current.id ) || current; const rootId = toId( latest.root ); const trace = { current: 'DOM唯一匹配', root: '', ancestorsLoaded: 0, stoppedReason: '' }; const reverseChain = [ latest ]; const visitedIds = new Set( [ latest.id ] ); let cursor = latest; for ( let depth = 0; depth < MAX_CHAIN_DEPTH; depth += 1 ) { const cursorId = toId( cursor.id ); const parentId = toId( cursor.parent ); if (!parentId) { trace.stoppedReason = '当前节点缺少 parent'; break; } if ( parentId === cursorId ) { trace.stoppedReason = '已到达 root'; break; } if ( visitedIds.has( parentId ) ) { trace.stoppedReason = `检测到循环 parent=${parentId}`; break; } let parent = commentById.get( parentId ) || null; if (!parent) { parent = await ensureCommentLoaded( parentId, rootId ); } if (!parent) { trace.stoppedReason = `未获取到祖先评论 ${parentId}`; break; } reverseChain.push( parent ); visitedIds.add( parent.id ); trace.ancestorsLoaded += 1; if ( parent.id === rootId ) { trace.root = '祖先链'; break; } cursor = parent; } let chain = reverseChain.reverse(); /* * 如果链中途断了, * 仍然尝试补真正 root。 */ if ( rootId && chain[0]?.id !== rootId ) { let rootComment = commentById.get( rootId ) || commentsByRoot .get( rootId ) ?.rootComment || null; if ( !rootComment ) { rootComment = await ensureCommentLoaded( rootId, rootId ); } if ( rootComment && !chain.some( item => item.id === rootComment.id ) ) { chain.unshift( rootComment ); trace.root = trace.root || '补全接口'; } } /* * 去重 */ const deduped = []; const dedupeIds = new Set(); for ( const item of chain ) { if ( !item?.id || dedupeIds.has( item.id ) ) { continue; } dedupeIds.add( item.id ); deduped.push( item ); } if ( deduped.length >= MAX_CHAIN_DEPTH + 1 ) { trace.stoppedReason = trace.stoppedReason || `达到最大追溯深度 ${MAX_CHAIN_DEPTH}`; } return { chain: deduped, currentComment: latest, trace }; } /* * ========================================== * * 以下只属于展示层 * * 不参与 DOM 匹配 * 不参与 parent/root 判断 * * ========================================== */ GM_addStyle(` #${MODAL_ID} { position: fixed; width: min( 430px, calc(100vw - 24px) ); max-height: min( 72vh, 660px ); overflow: auto; visibility: hidden; background: #fff; border: 1px solid rgba( 0, 0, 0, .09 ); border-radius: 14px; box-shadow: 0 16px 48px rgba( 0, 0, 0, .17 ); z-index: 2147483647; padding: 12px; color: #121212; font-size: 13px; line-height: 1.65; overscroll-behavior: contain; } #${MODAL_ID} .tm-zc-head { display: flex; align-items: center; justify-content: space-between; min-height: 26px; padding: 0 2px 0 3px; margin-bottom: 8px; } #${MODAL_ID} .tm-zc-head-left { display: flex; align-items: center; gap: 7px; } #${MODAL_ID} .tm-zc-title { color: #444; font-size: 12px; font-weight: 600; } #${MODAL_ID} .tm-zc-count { display: inline-flex; align-items: center; height: 19px; padding: 0 7px; border-radius: 99px; color: #8590a6; background: #f5f6f7; font-size: 10px; line-height: 19px; } #${MODAL_ID} .tm-zc-close { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; cursor: pointer; color: #8590a6; font-size: 18px; line-height: 1; user-select: none; } #${MODAL_ID} .tm-zc-close:hover { color: #444; background: #f3f4f5; } #${MODAL_ID} .tm-zc-content { display: flex; flex-direction: column; gap: 9px; } #${MODAL_ID} .tm-zc-loading, #${MODAL_ID} .tm-zc-note, #${MODAL_ID} .tm-zc-error, #${MODAL_ID} .tm-zc-debug { padding: 8px 10px; border-radius: 8px; } #${MODAL_ID} .tm-zc-loading, #${MODAL_ID} .tm-zc-note { color: #646464; background: #f7f8f9; } #${MODAL_ID} .tm-zc-error { color: #b42318; background: #fff1f0; } #${MODAL_ID} .tm-zc-debug { color: #8b5e00; background: #fff7e6; font-size: 11px; word-break: break-word; } #${MODAL_ID} .tm-zc-tree { display: flex; flex-direction: column; gap: 8px; } #${MODAL_ID} .tm-zc-node { position: relative; } /* * 树线 */ #${MODAL_ID} .tm-zc-node.has-indent::before { content: ''; position: absolute; left: -11px; top: -8px; bottom: 50%; width: 1px; background: #dde2e7; } #${MODAL_ID} .tm-zc-node.has-indent::after { content: ''; position: absolute; left: -11px; top: 50%; width: 8px; height: 1px; background: #dde2e7; } #${MODAL_ID} .tm-zc-block { border: 1px solid #e7e9eb; border-radius: 10px; padding: 9px 11px; background: #fafafa; } /* * 起点 */ #${MODAL_ID} .role-root { border-color: #ead8a4; background: #fffaf0; } /* * 中间上文 */ #${MODAL_ID} .role-middle { border-color: #e2e6ea; background: #f8f9fa; } /* * 当前回复直接回复的那一条 */ #${MODAL_ID} .role-parent { border-color: #c6ced7; background: #f1f4f7; } /* * 当前回复最突出 */ #${MODAL_ID} .role-current { border-color: #8ec5ea; background: #eef8ff; box-shadow: 0 0 0 1px rgba( 23, 81, 153, .04 ); } #${MODAL_ID} .tm-zc-label { margin-bottom: 3px; color: #a0a7b1; font-size: 10px; line-height: 1.4; } #${MODAL_ID} .role-current .tm-zc-label { color: #4d85b7; font-weight: 600; } #${MODAL_ID} .tm-zc-user { color: #121212; font-weight: 650; } #${MODAL_ID} .role-current .tm-zc-user { color: #175199; } #${MODAL_ID} .tm-zc-message { color: #292929; word-break: break-word; } #${MODAL_ID} .tm-zc-message.clamped { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; } #${MODAL_ID} .tm-zc-expand { margin-top: 5px; padding: 0; border: 0; background: transparent; color: #175199; cursor: pointer; font-size: 11px; } #${MODAL_ID} .tm-zc-expand:hover { text-decoration: underline; } #${MODAL_ID} .tm-zc-reply-target { color: #444; font-weight: 600; } #${MODAL_ID} .role-current .tm-zc-reply-target { color: #175199; } #${MODAL_ID} .tm-zc-meta { margin-top: 5px; color: #a3a8ae; font-size: 10px; word-break: break-all; } /* * 长链折叠 */ #${MODAL_ID} .tm-zc-hidden-chain { display: none; } #${MODAL_ID} .tm-zc-chain-fold { position: relative; margin: 1px 0; padding-left: 22px; } #${MODAL_ID} .tm-zc-chain-fold::before { content: ''; position: absolute; left: 7px; top: -8px; bottom: -8px; width: 1px; background: #dde2e7; } #${MODAL_ID} .tm-zc-chain-fold-button { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 0; border-radius: 7px; color: #8590a6; background: #f5f6f7; cursor: pointer; font-size: 10px; line-height: 1.4; } #${MODAL_ID} .tm-zc-chain-fold-button:hover { color: #175199; background: #eef4fa; } `); /* * =============================== * 浮窗基础 * =============================== */ function clearHoverOpenTimer() { if ( !hoverOpenTimer ) { return; } clearTimeout( hoverOpenTimer ); hoverOpenTimer = 0; } function clearHoverCloseTimer() { if ( !hoverCloseTimer ) { return; } clearTimeout( hoverCloseTimer ); hoverCloseTimer = 0; } function getAnchorElement( anchor ) { if ( anchor?.host ?.isConnected ) { return anchor.host; } if ( anchor?.isConnected ) { return anchor; } return null; } function positionPopover( modal, anchor ) { if (!modal) { return; } const anchorElement = getAnchorElement( anchor ); if ( !anchorElement ) { return; } const rect = anchorElement .getBoundingClientRect(); const margin = 12; const gap = 12; const width = modal.offsetWidth || 430; const height = modal.offsetHeight || 260; const spaceAbove = rect.top - margin; const spaceBelow = window.innerHeight - rect.bottom - margin; const spaceRight = window.innerWidth - rect.right - margin; let left = rect.left; let top = rect.bottom + gap; /* * 和 v0.4 一样: * * 上 * 下 * 右 * 左 */ if ( spaceAbove >= height ) { top = rect.top - height - gap; } else if ( spaceBelow >= height ) { top = rect.bottom + gap; } else if ( spaceRight >= width ) { left = rect.right + gap; top = rect.top; } else { left = rect.left - width - gap; top = rect.top; } left = Math.max( margin, Math.min( left, window.innerWidth - width - margin ) ); top = Math.max( margin, Math.min( top, window.innerHeight - height - margin ) ); modal.style.left = `${left}px`; modal.style.top = `${top}px`; modal.style.visibility = 'visible'; } function startPopoverFollow() { if ( popoverFollowRaf ) { cancelAnimationFrame( popoverFollowRaf ); } const tick = () => { const modal = document.getElementById( MODAL_ID ); const anchorElement = getAnchorElement( activeAnchor ); if ( !modal || !anchorElement ) { popoverFollowRaf = 0; return; } positionPopover( modal, activeAnchor ); popoverFollowRaf = requestAnimationFrame( tick ); }; popoverFollowRaf = requestAnimationFrame( tick ); } function removeModal() { clearHoverOpenTimer(); clearHoverCloseTimer(); if ( popoverFollowRaf ) { cancelAnimationFrame( popoverFollowRaf ); popoverFollowRaf = 0; } document .getElementById( MODAL_ID ) ?.remove(); activeHost = null; activeComment = null; activeAnchor = null; } function schedulePopoverClose( delay = HOVER_CLOSE_DELAY ) { clearHoverCloseTimer(); hoverCloseTimer = setTimeout( () => { hoverCloseTimer = 0; const modal = document.getElementById( MODAL_ID ); if ( activeHost ?.matches ?.(':hover') || modal ?.matches ?.(':hover') ) { return; } removeModal(); }, delay ); } function attachModalHoverHandlers( modal ) { if ( !modal || modal.dataset .tmHoverBound === '1' ) { return; } modal.addEventListener( 'mouseenter', clearHoverCloseTimer ); modal.addEventListener( 'mouseleave', () => schedulePopoverClose() ); modal.dataset .tmHoverBound = '1'; } /* * =============================== * 浮窗 HTML * =============================== */ function buildModalShell( bodyHtml, chainLength = 0 ) { return `
对话上下文 ${ chainLength > 0 ? ` ${chainLength} 条 ` : '' }
×
${bodyHtml}
`; } function bindModalContentEvents( modal ) { modal .querySelector( '.tm-zc-close' ) ?.addEventListener( 'click', removeModal ); /* * 展开 root 长文本 */ for ( const button of modal.querySelectorAll( '[data-expand-node="1"]' ) ) { button.addEventListener( 'click', event => { const btn = event.currentTarget; const message = btn ?.previousElementSibling; message ?.classList .remove( 'clamped' ); btn?.remove(); positionPopover( modal, activeAnchor ); } ); } /* * 展开被折叠的中间链 */ modal .querySelector( '[data-expand-chain="1"]' ) ?.addEventListener( 'click', event => { const button = event.currentTarget; const fold = button.closest( '.tm-zc-chain-fold' ); for ( const hidden of modal.querySelectorAll( '.tm-zc-hidden-chain' ) ) { hidden.classList.remove( 'tm-zc-hidden-chain' ); } fold?.remove(); positionPopover( modal, activeAnchor ); } ); attachModalHoverHandlers( modal ); } function showModalSkeleton( anchor ) { document .getElementById( MODAL_ID ) ?.remove(); activeAnchor = anchor; const modal = document.createElement( 'div' ); modal.id = MODAL_ID; modal.innerHTML = buildModalShell( `
正在追溯完整对话链…
` ); bindModalContentEvents( modal ); document.body.appendChild( modal ); positionPopover( modal, activeAnchor ); startPopoverFollow(); } function updateModal( bodyHtml, chainLength = 0 ) { const modal = document.getElementById( MODAL_ID ); if (!modal) { return; } modal.innerHTML = buildModalShell( bodyHtml, chainLength ); bindModalContentEvents( modal ); positionPopover( modal, activeAnchor ); } /* * =============================== * 内容渲染 * =============================== */ function buildMessageHtml( comment ) { const target = normalizeText( comment ?.replyTargetName || '' ); const content = escapeHtml( comment?.content || '[无文本]' ); if (!target) { return content; } return ( `回复 ` + `` + `@${escapeHtml(target)}` + `:` + content ); } function getNodeRole( index, total ) { if ( index === 0 ) { return 'root'; } if ( index === total - 1 ) { return 'current'; } if ( index === total - 2 ) { return 'parent'; } return 'middle'; } /* * 标签只做视觉弱化, * 不影响上下文逻辑。 */ function getNodeLabel( index, total ) { if ( index === 0 ) { return '起点'; } if ( index === total - 1 ) { return '当前回复'; } if ( index === total - 2 ) { return '正在回复'; } return '上文'; } function buildChainBlock( comment, index, total, hidden = false ) { const role = getNodeRole( index, total ); const label = getNodeLabel( index, total ); const author = escapeHtml( comment.author || '未知用户' ); /* * 起点长文本默认三行。 */ const clamp = index === 0 ? ' clamped' : ''; const expand = index === 0 ? ` ` : ''; const meta = DEBUG ? `
${escapeHtml( `id=${comment.id} | root=${comment.root} | parent=${comment.parent}` )}
` : ''; /* * 纯视觉: * 最多缩进 6 层。 */ const indent = Math.min( index, 6 ) * 14; const indentClass = index > 0 ? ' has-indent' : ''; const hiddenClass = hidden ? ' tm-zc-hidden-chain' : ''; return `
${escapeHtml(label)}
${author} :${buildMessageHtml(comment)}
${expand} ${meta}
`; } function buildFoldButton( hiddenCount ) { return `
`; } /* * 长链只在渲染完成后折叠。 * * 不影响 parent/root 获取。 */ function buildChainHtml( chain ) { const total = chain.length; if ( total <= AUTO_COLLAPSE_CHAIN_LENGTH ) { return chain .map( ( comment, index ) => buildChainBlock( comment, index, total, false ) ) .join(''); } /* * 例如: * * 0 root * 1 第一条 * * [中间折叠] * * 倒数第三 * parent * current */ const hiddenStart = KEEP_CHAIN_HEAD; const hiddenEnd = total - KEEP_CHAIN_TAIL; const hiddenCount = Math.max( 0, hiddenEnd - hiddenStart ); if ( hiddenCount <= 0 ) { return chain .map( ( comment, index ) => buildChainBlock( comment, index, total, false ) ) .join(''); } const pieces = []; for ( let index = 0; index < total; index += 1 ) { if ( index === hiddenStart ) { pieces.push( buildFoldButton( hiddenCount ) ); } const hidden = index >= hiddenStart && index < hiddenEnd; pieces.push( buildChainBlock( chain[index], index, total, hidden ) ); } return pieces.join(''); } function buildDebugBlock( context ) { if (!DEBUG) { return ''; } const trace = context?.trace || {}; const parts = [ `链长度=${ context ?.chain ?.length || 0 }`, trace.current ? `当前=${trace.current}` : '', trace.root ? `root=${trace.root}` : '', `祖先=${ trace .ancestorsLoaded || 0 }`, trace.stoppedReason ? `停止=${trace.stoppedReason}` : '', lastMatchDebug ?.score != null ? `DOM=${lastMatchDebug.score}` : '' ] .filter( Boolean ); return `
${escapeHtml( parts.join( ' | ' ) )}
`; } function renderContext( context ) { const chain = Array.isArray( context?.chain ) ? context.chain : []; if ( !chain.length ) { updateModal( `
没有可展示的上下文。
` ); return; } const notes = []; if ( context.trace ?.stoppedReason && ![ '已到达 root' ].includes( context.trace .stoppedReason ) && chain.length > 1 ) { notes.push( `
已展示目前能够确认的对话链;更早部分可能因知乎接口数据缺失而没有继续追溯。
` ); } updateModal( buildDebugBlock( context ) + notes.join('') + `
${buildChainHtml( chain )}
`, chain.length ); } /* * ========================================== * * 悬停生命周期 * * 保持 v0.4 行为 * * ========================================== */ async function openContext( match ) { if ( !match?.comment || !isNestedReply( match.comment ) ) { return; } const expectedId = match.comment.id; showModalSkeleton( { host: match.element } ); try { const context = await resolveFullChain( match.comment ); /* * 请求回来时如果鼠标已经换到别的评论, * 不覆盖当前状态。 */ if ( !activeComment || activeComment.id !== expectedId ) { return; } renderContext( context ); } catch (err) { console.error( `[ZhihuContext v${VERSION}] openContext error:`, err ); updateModal( `
${escapeHtml( err?.message || '加载失败' )}
` ); } } function scheduleHoverOpen( match ) { clearHoverCloseTimer(); clearHoverOpenTimer(); activeHost = match.element; activeComment = match.comment; hoverOpenTimer = setTimeout( () => { hoverOpenTimer = 0; if ( !activeComment || activeComment.id !== match.comment.id ) { return; } openContext( match ); }, HOVER_OPEN_DELAY ); } function handleMouseOver( event ) { const target = event.target; if ( !(target instanceof Element) ) { return; } /* * 鼠标进入浮窗。 */ if ( target.closest?.( `#${MODAL_ID}` ) ) { clearHoverCloseTimer(); return; } lastMouseTarget = target; const match = findBestCommentMatch( target ); if (!match) { return; } /* * 只显示: * * parent != root * * 一级回复仍然不弹。 */ if ( !isNestedReply( match.comment ) ) { return; } /* * 同一条评论内部移动。 */ if ( activeComment?.id === match.comment.id && activeHost === match.element ) { clearHoverCloseTimer(); return; } scheduleHoverOpen( match ); } function handleMouseOut( event ) { if ( !activeHost ) { return; } const related = event.relatedTarget; const modal = document.getElementById( MODAL_ID ); if ( related instanceof Node ) { /* * 仍在当前评论 DOM 内。 */ if ( activeHost.contains( related ) ) { return; } /* * 从评论进入浮窗。 */ if ( modal?.contains( related ) ) { clearHoverCloseTimer(); return; } } schedulePopoverClose(); } function installHoverDelegation() { if ( window .__tmZhihuContextHoverDelegationV051 ) { return; } window .__tmZhihuContextHoverDelegationV051 = true; document.addEventListener( 'mouseover', handleMouseOver, true ); document.addEventListener( 'mouseout', handleMouseOut, true ); } /* * =============================== * 浮窗重新定位 * =============================== */ function initPopoverReposition() { const handler = () => { const modal = document.getElementById( MODAL_ID ); if ( !modal || !activeAnchor ) { return; } positionPopover( modal, activeAnchor ); }; window.addEventListener( 'resize', handler ); window.addEventListener( 'scroll', handler, true ); } /* * =============================== * 调试接口 * =============================== */ function installDebugApi() { PAGE .__tmZhihuContextDebug = { version: VERSION, enabled: DEBUG, enable() { setDebugEnabled( true ); return ( '调试模式已开启,刷新知乎页面后生效。' ); }, disable() { setDebugEnabled( false ); return ( '调试模式已关闭,刷新知乎页面后生效。' ); }, status() { const all = Array.from( commentById.values() ); const nested = all.filter( isNestedReply ); return { version: VERSION, enabled: localStorage .getItem( DEBUG_KEY ) === '1', cachedComments: all.length, cachedRoots: commentsByRoot.size, nestedReplies: nested.length, pagingRoots: pagingNextByRoot.size, domMatchCount, lastMatch: lastMatchDebug }; }, cache() { return Array.from( commentById.values() ); }, nested() { return Array .from( commentById.values() ) .filter( isNestedReply ); }, last() { return lastMatchDebug; }, async chain( commentId ) { const id = toId( commentId ); const comment = commentById.get( id ); if (!comment) { return null; } return await resolveFullChain( comment ); }, probe() { if ( !lastMouseTarget ) { return null; } const match = findBestCommentMatch( lastMouseTarget ); if (!match) { return { matched: false, debug: lastMatchDebug }; } return { matched: true, id: match.comment.id, root: match.comment.root, parent: match.comment.parent, nested: isNestedReply( match.comment ), author: match.comment.author, replyTarget: match.comment .replyTargetName, content: match.comment.content, score: match.score, source: match.source, element: match.element, debug: lastMatchDebug }; } }; } /* * =============================== * 启动 * =============================== */ function boot() { installNetworkHooks(); installHoverDelegation(); initPopoverReposition(); installDebugApi(); log( `知乎楼中楼上下文 v${VERSION} 已启动:v0.4 稳定内核 + 展示层优化` ); } /* * 网络 Hook 仍然尽量提前。 */ installNetworkHooks(); if ( document.readyState === 'loading' ) { document.addEventListener( 'DOMContentLoaded', boot, { once: true } ); } else { boot(); } })();