88 let dataLoadAttempted = false ;
99 let initAttempted = false ;
1010 let observer = null ;
11+ let cleanupTimer = null ;
1112
1213 // 检查功能开关
1314 function isEnabled ( ) {
101102 const score = calcMatchRate ( text , v . text ) ;
102103 if ( score > bestScore ) {
103104 bestScore = score ;
104- best = { quest : q , lang : v . lang } ;
105+ best = { quest : q , lang : v . lang , qText : v . text } ;
105106 }
106107 }
107108 }
128129 return parts . join ( '' ) . trim ( ) ;
129130 }
130131
132+ // 提取 stopEl 之前的所有纯文本(用于定位 pa-reply 链接前的问题文字)
133+ function getTextBefore ( element , stopEl ) {
134+ const parts = [ ] ;
135+ function walk ( node ) {
136+ if ( node === stopEl ) return true ;
137+ if ( node . nodeType === Node . TEXT_NODE ) {
138+ const t = ( node . textContent || '' ) . trim ( ) ;
139+ if ( t ) parts . push ( t ) ;
140+ } else if ( node . nodeType === Node . ELEMENT_NODE ) {
141+ const tag = node . tagName ;
142+ if ( tag === 'A' || tag === 'SCRIPT' || tag === 'STYLE' ) return false ;
143+ for ( let child = node . firstChild ; child ; child = child . nextSibling ) {
144+ if ( walk ( child ) ) return true ;
145+ }
146+ }
147+ return false ;
148+ }
149+ walk ( element ) ;
150+ return parts . join ( '' ) . trim ( ) ;
151+ }
152+
131153 // 从消息元素获取用于匹配的文本段列表
132- // 返回数组,每个元素独立匹配,避免拼接答案选项导致双向匹配失败
154+ // 返回 { text, el } 列表:text 独立匹配,el 为该文本的来源元素
133155 function getMessageTexts ( element ) {
134156 // PA消息(带 pa-reply 链接):提取第一个 pa-reply 之前的文本(即问题文本)
135157 var paReply = element . querySelector ( 'a.pa-reply' ) ;
136158 if ( paReply ) {
137- var parts = [ ] ;
138- var children = element . children ;
139- for ( var i = 0 ; i < children . length ; i ++ ) {
140- // 遇到包含 pa-reply 的子元素就停止
141- if ( children [ i ] . querySelector ( 'a.pa-reply' ) ) break ;
142- var t = extractText ( children [ i ] ) ;
143- if ( t ) parts . push ( t ) ;
144- }
145- if ( parts . length > 0 ) return [ parts . join ( ' ' ) . trim ( ) ] ;
159+ var text = getTextBefore ( element , paReply ) ;
160+ if ( text ) return [ { text : text , el : element } ] ;
146161 return [ ] ;
147162 }
148163
149164 // 聊天消息/其他:优先按子元素分割独立匹配
150- var texts = [ ] ;
165+ var results = [ ] ;
151166 var children = element . children ;
152167 if ( children . length > 1 ) {
153168 for ( var i = 0 ; i < children . length ; i ++ ) {
154169 var childText = extractText ( children [ i ] ) ;
155170 if ( childText && childText . length > 3 ) {
156- texts . push ( childText ) ;
171+ results . push ( { text : childText , el : children [ i ] } ) ;
157172 }
158173 }
159174 // 同时加入全部合并文本(处理问题被拆分到多个span的情况)
160175 var fullText = extractText ( element ) ;
161- if ( fullText ) texts . push ( fullText ) ;
176+ if ( fullText ) results . push ( { text : fullText , el : element } ) ;
162177 }
163178 // 如果子元素分割没有结果,退回到整体提取
164- if ( texts . length === 0 ) {
179+ if ( results . length === 0 ) {
165180 var fullText = extractText ( element ) ;
166- if ( fullText ) texts . push ( fullText ) ;
181+ if ( fullText ) results . push ( { text : fullText , el : element } ) ;
167182 }
168- return texts ;
183+ return results ;
184+ }
185+
186+ // 定位 PA 问题消息元素:从链接向上找最近的、包含该链接且链接前有足够文本的 DIV,
187+ // 避免把答案框追加到聊天窗口/页面级包装元素上
188+ function findPaMessageElement ( link ) {
189+ var el = link . parentElement ;
190+ while ( el && el !== document . body ) {
191+ if ( el . tagName === 'DIV' ) {
192+ var text = getTextBefore ( el , link ) ;
193+ if ( text && text . length >= 3 && ! isChatContainer ( el )
194+ && ! el . querySelector ( '.input-group' ) ) {
195+ return el ;
196+ }
197+ }
198+ el = el . parentElement ;
199+ }
200+ return null ;
201+ }
202+
203+ // 防御:答案框只允许插入消息级元素,拒绝聊天容器/聊天窗口/页面级包装元素
204+ function isSafeAnswerParent ( el ) {
205+ if ( ! el || el === document . body ) return false ;
206+ if ( isChatContainer ( el ) ) return false ;
207+ if ( el . querySelector && el . querySelector ( '.input-group' ) ) return false ;
208+ // 内部仍嵌套消息容器,说明是包装层而非单条消息
209+ if ( el . querySelector && el . querySelector ( 'div.css-xo2rg1.e1llepen2, div[style*="column-reverse"][style*="overflow"]' ) ) return false ;
210+ // PA 消息:包含 pa-reply 链接
211+ if ( el . querySelector && el . querySelector ( 'a.pa-reply' ) ) return true ;
212+ // 聊天消息:必须是已知消息容器的直接子元素
213+ return ! ! ( el . parentElement && isChatContainer ( el . parentElement ) ) ;
169214 }
170215
171216 // 创建答案UI
249294 if ( element . scPaProcessed ) return ;
250295 element . scPaProcessed = true ;
251296
252- var texts = getMessageTexts ( element ) ;
253- for ( var ti = 0 ; ti < texts . length ; ti ++ ) {
254- if ( ! texts [ ti ] || texts [ ti ] . length < 3 ) continue ;
255- var match = findBestMatch ( texts [ ti ] ) ;
297+ if ( ! isSafeAnswerParent ( element ) ) {
298+ return ;
299+ }
300+
301+ var entries = getMessageTexts ( element ) ;
302+ for ( var ti = 0 ; ti < entries . length ; ti ++ ) {
303+ if ( ! entries [ ti ] . text || entries [ ti ] . text . length < 3 ) continue ;
304+ var match = findBestMatch ( entries [ ti ] . text ) ;
256305 if ( match ) {
257306 if ( element . querySelector ( '.sc-pa-answer-box' ) ) return ;
258307 var answerUI = createAnswerUI ( match ) ;
259- element . appendChild ( answerUI ) ;
308+ answerUI . dataset . scPaQuestion = match . qText ;
309+ // 若匹配文本来自子元素且该子元素本身是安全的消息内容块,优先插到该子元素
310+ var target = element ;
311+ if ( entries [ ti ] . el !== element && entries [ ti ] . el . tagName === 'DIV'
312+ && isSafeAnswerParent ( entries [ ti ] . el ) ) {
313+ target = entries [ ti ] . el ;
314+ }
315+ if ( ! isSafeAnswerParent ( target ) ) return ;
316+ target . appendChild ( answerUI ) ;
260317 return ;
261318 }
262319 }
268325
269326 // 1. 处理 PA 系统消息(通过 pa-reply 链接定位)
270327 document . querySelectorAll ( 'a.pa-reply' ) . forEach ( function ( link ) {
271- let el = link . parentElement ;
272- for ( let i = 0 ; i < 10 && el && el !== document . body ; i ++ ) {
273- var texts = getMessageTexts ( el ) ;
274- if ( texts . length > 0 && ! el . scPaProcessed ) {
275- processMessage ( el ) ;
276- break ;
277- }
278- el = el . parentElement ;
328+ var msgEl = findPaMessageElement ( link ) ;
329+ if ( msgEl && ! msgEl . scPaProcessed ) {
330+ processMessage ( msgEl ) ;
279331 }
280332 } ) ;
281333
323375 return document . querySelectorAll ( 'div[style*="column-reverse"][style*="overflow"]' ) ;
324376 }
325377
378+ // 判断是否为聊天消息容器(与 findChatContainers 同一套选择器)
379+ function isChatContainer ( el ) {
380+ if ( ! el || el . nodeType !== 1 || ! el . matches ) return false ;
381+ return el . matches ( 'div.css-xo2rg1.e1llepen2' ) ||
382+ el . matches ( 'div[style*="column-reverse"][style*="overflow"]' ) ;
383+ }
384+
385+ // 判断是否为消息级元素(聊天容器的直接子级,或包含 pa-reply 链接的 PA 消息)
386+ function isMessageLevelElement ( el ) {
387+ if ( ! el || el === document . body || isChatContainer ( el ) ) return false ;
388+ if ( el . querySelector && el . querySelector ( 'a.pa-reply' ) ) return true ;
389+ return ! ! ( el . parentElement && isChatContainer ( el . parentElement ) ) ;
390+ }
391+
392+ // 清理过期答案框:宿主被 React 清空/复用、宿主为聊天容器本身、
393+ // 或宿主文本不再匹配原问题时,移除答案框并允许重新评估宿主
394+ function cleanupStaleAnswers ( ) {
395+ var boxes = document . querySelectorAll ( '.sc-pa-answer-box' ) ;
396+ for ( var i = 0 ; i < boxes . length ; i ++ ) {
397+ var box = boxes [ i ] ;
398+ var parent = box . parentElement ;
399+ if ( ! parent || ! parent . isConnected ) {
400+ box . remove ( ) ;
401+ continue ;
402+ }
403+ var qText = box . dataset . scPaQuestion ;
404+ var valid = false ;
405+ if ( qText && ! isChatContainer ( parent ) ) {
406+ var entries = getMessageTexts ( parent ) ;
407+ for ( var ti = 0 ; ti < entries . length ; ti ++ ) {
408+ if ( calcMatchRate ( entries [ ti ] . text , qText ) >= MATCH_THRESHOLD ) {
409+ valid = true ;
410+ break ;
411+ }
412+ }
413+ }
414+ if ( valid ) continue ;
415+
416+ box . remove ( ) ;
417+ // 宿主可能已被 React 复用装入新消息:重置标记并重新评估
418+ parent . scPaProcessed = false ;
419+ if ( isMessageLevelElement ( parent ) ) {
420+ processMessage ( parent ) ;
421+ }
422+ }
423+ }
424+
425+ // 防抖调度清理,避免每次 DOM 变更都全量校验
426+ function scheduleCleanup ( ) {
427+ if ( cleanupTimer ) return ;
428+ cleanupTimer = setTimeout ( function ( ) {
429+ cleanupTimer = null ;
430+ cleanupStaleAnswers ( ) ;
431+ } , 300 ) ;
432+ }
433+
326434 // 初始化
327435 async function init ( ) {
328436 // 仅在 /messages/ 页面运行
348456
349457 // 扫描现有消息
350458 scanPage ( ) ;
459+ cleanupStaleAnswers ( ) ;
351460
352461 // 监听变化
353462 if ( observer ) observer . disconnect ( ) ;
364473 }
365474 }
366475 }
476+ scheduleCleanup ( ) ;
367477 } ) ;
368478
369479 // 观察聊天容器
381491
382492 // 如果是 pa-reply 链接,处理其消息容器
383493 if ( element . tagName === 'A' && element . classList . contains ( 'pa-reply' ) ) {
384- let el = element . parentElement ;
385- for ( let i = 0 ; i < 10 && el && el !== document . body ; i ++ ) {
386- var texts = getMessageTexts ( el ) ;
387- if ( texts . length > 0 && ! el . scPaProcessed ) {
388- processMessage ( el ) ;
389- break ;
390- }
391- el = el . parentElement ;
494+ var msgEl = findPaMessageElement ( element ) ;
495+ if ( msgEl && ! msgEl . scPaProcessed ) {
496+ processMessage ( msgEl ) ;
392497 }
393498 return ;
394499 }
406511 var links = element . querySelectorAll ( 'a.pa-reply' ) ;
407512 if ( links . length > 0 ) {
408513 links . forEach ( function ( link ) {
409- let el = link . parentElement ;
410- for ( let i = 0 ; i < 10 && el && el !== document . body ; i ++ ) {
411- var linkTexts = getMessageTexts ( el ) ;
412- if ( linkTexts . length > 0 && ! el . scPaProcessed ) {
413- processMessage ( el ) ;
414- break ;
415- }
416- el = el . parentElement ;
514+ var msgEl = findPaMessageElement ( link ) ;
515+ if ( msgEl && ! msgEl . scPaProcessed ) {
516+ processMessage ( msgEl ) ;
417517 }
418518 } ) ;
419519 }
420520 }
421521
522+ // React 整体重建消息容器时,容器内部的消息不会作为独立新增节点出现,
523+ // 这里递归处理新增节点内部嵌套的已知聊天容器及其消息子元素
524+ if ( element . querySelectorAll && ! isChatContainer ( element ) ) {
525+ var nestedContainers = element . querySelectorAll ( 'div.css-xo2rg1.e1llepen2, div[style*="column-reverse"][style*="overflow"]' ) ;
526+ nestedContainers . forEach ( function ( c ) {
527+ c . querySelectorAll ( ':scope > div' ) . forEach ( function ( msgEl ) {
528+ if ( ! msgEl . scPaProcessed ) processMessage ( msgEl ) ;
529+ } ) ;
530+ } ) ;
531+ }
532+
422533 // 通用后备:处理新增的有文本内容的元素(新聊天消息、新PA消息等)
423534 if ( ! element . scPaProcessed && element . nodeType === 1
424535 && element . textContent . trim ( ) . length > 5 ) {
444555 setTimeout ( init , 500 ) ;
445556
446557 return { init } ;
447- } ) ( ) ;
558+ } ) ( ) ;
0 commit comments