From 3cc42871e078707bacb0ae9d7e0729ee893771d4 Mon Sep 17 00:00:00 2001 From: LuoQianhong Date: Sun, 18 Oct 2026 15:10:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(extension):=20=E4=BF=AE=E5=A4=8D=20#612=20?= =?UTF-8?q?=E5=8D=B3=E5=88=BB=E5=88=87=E5=B8=96=E6=97=B6=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E4=B8=8E=E6=9D=A5=E6=BA=90=E9=93=BE=E6=8E=A5=E9=94=99=E9=85=8D?= =?UTF-8?q?=20(#614)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(extension): prevent Jike post content and source mismatches * fix(extension): normalize existing mention prefixes on Jike mobile --- apps/extension/article-clip.test.mjs | 28 ++++++++++++++-- apps/extension/jike-web-test-state.mjs | 14 ++++++++ apps/extension/platform-save.test.mjs | 46 +++++++++++++++++++++++++- apps/extension/src/background.ts | 6 ++-- apps/extension/src/jike-clip.ts | 12 ++++--- apps/extension/src/jike-web-state.ts | 12 +++++-- 6 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 apps/extension/jike-web-test-state.mjs diff --git a/apps/extension/article-clip.test.mjs b/apps/extension/article-clip.test.mjs index d78fdf9b..fa6ab4fe 100644 --- a/apps/extension/article-clip.test.mjs +++ b/apps/extension/article-clip.test.mjs @@ -1,18 +1,28 @@ +import { prepareJikeWebDetail } from "./jike-web-test-state.mjs"; +import { readJikeWebStateInPage } from "./src/jike-web-state.ts"; +import { runInNewContext } from "node:vm"; import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { createDocument } from "@mixmark-io/domino"; import { readArticle } from "./src/article-clip.ts"; -import { readJike, jikeExpandButton } from "./src/jike-clip.ts"; +import { readJike as readJikeSource, jikeExpandButton } from "./src/jike-clip.ts"; import { readDouban, doubanCommentTarget } from "./src/douban-clip.ts"; import { isPlatformClip, platformTarget, socialPlatform } from "./src/platform-clip.ts"; import { markdownToDoc } from "../../packages/shared/src/content.ts"; +const detailState=(doc,pageUrl,id)=>runInNewContext(`(${readJikeWebStateInPage.toString()})(id)`,{document:doc,location:{href:pageUrl},URL,id}); +const readJike=(doc,url,target=platformTarget(url),state)=>readJikeSource(doc,url,target,state ?? (url.includes('/u/') ? detailState(doc,url,target?.id) : undefined)); + const urls = { wechat:"https://mp.weixin.qq.com/s/TC-ONpL3PGklsD0yhtcuBQ",sspai:"https://sspai.com/post/92653",juejin:"https://juejin.cn/post/6972051526416072717", jike:"https://m.okjike.com/originalPosts/66ac89b0314f5cced41012d3","jike-grid":"https://m.okjike.com/originalPosts/6715e824d9dc81724c2ec74d","jike-long":"https://m.okjike.com/originalPosts/64c9dd9a2edae6abdc610605", "douban-book":"https://book.douban.com/review/16093028/","douban-movie":"https://movie.douban.com/review/17798662/","douban-music":"https://music.douban.com/review/17855287/","douban-note":"https://www.douban.com/note/814698224/", }; -const fixture=(name)=>createDocument(readFileSync(new URL(`./fixtures/platforms/${name}.html`,import.meta.url),"utf8")); +const fixture=(name)=>{ + const doc=createDocument(readFileSync(new URL(`./fixtures/platforms/${name}.html`,import.meta.url),"utf8")); + if(name==='jike-web-detail'||name==='jike-web-grid')prepareJikeWebDetail(doc,name==='jike-web-grid'?'6715e824d9dc81724c2ec74d':undefined); + return doc; +}; const read=(name,doc=fixture(name))=>name.startsWith("jike") ? readJike(doc,urls[name]) : name.startsWith("douban") ? readDouban(doc,platformTarget(urls[name]),urls[name]) : readArticle(doc,urls[name],"公众号"); test("article URLs normalize aliases and tracking, retaining only WeChat query identity/signature",()=>{ @@ -488,3 +498,17 @@ test('Medium nested quote code and pre paragraph identity preserve language and expect(blocks.map(n=>n.textContent)).toEqual([code,'print(1)\nprint(2)']);expect(blocks.map(n=>n.className)).toEqual(['language-javascript','language-python']);expect(d.documentElement.outerHTML).toBe(before); const saved=[];const walk=n=>{if(n.type==='codeBlock')saved.push(n);for(const child of n.content||[])walk(child)};walk(markdownToDoc(platformHtmlMarkdown(r.body,'https://medium.com/@writer/story-abcdef123456')));expect(saved.map(n=>({language:n.attrs.language,text:n.content.map(c=>c.text).join('')}))).toEqual([{language:'javascript',text:code},{language:'python',text:'print(1)\nprint(2)'}]); }); + +for(const invalid of ['old-post','missing-props','stale-marker'])test(`Jike Web detail rejects ${invalid} identity under a same-author new route`,()=>{ + const d=fixture('jike-web-detail'),card=d.querySelector('[class*="_postCard_"]'),url=jikeWebUrl.replace('64c9dd9a2edae6abdc610605','6715e824d9dc81724c2ec74d'); + if(invalid==='missing-props')delete card.__reactProps$fixture; + if(invalid==='stale-marker')card.setAttribute('data-edgeever-jike-id','6715e824d9dc81724c2ec74d'); + expect(detailState(d,url,'6715e824d9dc81724c2ec74d')).toBeNull(); + expect(readJike(d,url)).toEqual({ok:false,reason:'changed'}); +}); + +test('Jike Web mobile mentions already containing @ match the same public post text',()=>{ + const d=fixture('jike-web-detail'); + for(const mention of d.querySelectorAll('[class*="_mentionUser_"]'))mention.textContent='@'+mention.textContent.trim(); + const r=readJike(d,jikeWebUrl);expect(r.ok).toBe(true);expect(r.clip.markdown).toContain('凌空小鱼儿');expect(r.clip.markdown).not.toContain('@@'); +}); diff --git a/apps/extension/jike-web-test-state.mjs b/apps/extension/jike-web-test-state.mjs new file mode 100644 index 00000000..e187959b --- /dev/null +++ b/apps/extension/jike-web-test-state.mjs @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { createDocument } from "@mixmark-io/domino"; + +// HTML snapshots cannot serialize React host props. Restore the same post's +// independent public share-page data rather than deriving it from the Web DOM. +export const prepareJikeWebDetail = (doc, id = "64c9dd9a2edae6abdc610605") => { + const grid=id==='6715e824d9dc81724c2ec74d',name=grid?'jike-grid':'jike-long'; + const share=createDocument(readFileSync(new URL(`./fixtures/platforms/${name}.html`,import.meta.url),'utf8')); + const post=JSON.parse(share.querySelector('#__NEXT_DATA__').textContent).props.pageProps.post; + const card=doc.querySelector('[data-clickable-feedback][class*="_postCard_"]'); + const username=grid?'af47ed7e-38d4-4798-acd6-04e3cc8d131c':'5e6497e7-a69f-422b-af6e-baf0fc07b512'; + card.__reactProps$fixture={children:{props:{data:{...post,id,type:"ORIGINAL_POST",user:{username}}}}}; + return card; +}; diff --git a/apps/extension/platform-save.test.mjs b/apps/extension/platform-save.test.mjs index 703c737a..53178861 100644 --- a/apps/extension/platform-save.test.mjs +++ b/apps/extension/platform-save.test.mjs @@ -1,3 +1,4 @@ +import { prepareJikeWebDetail } from "./jike-web-test-state.mjs"; import { clipDateTimeText } from "./src/clip-time.ts"; import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; @@ -787,7 +788,7 @@ test('stable rich selection preserves code blank lines and archives its selected for(const navigate of [false,true])test(`current signed-in Jike Web detail ${navigate?'cancels navigation':'saves through the platform route'}`,async()=>{ const pageUrl='https://web.okjike.com/u/5e6497e7-a69f-422b-af6e-baf0fc07b512/post/64c9dd9a2edae6abdc610605'; - const h=harness({platform:'jike',pageUrl,fixture:'jike-web-detail',navigateDuringNotebookLookup:navigate}); + const h=harness({platform:'jike',pageUrl,fixture:'jike-web-detail',prepare:doc=>prepareJikeWebDetail(doc),navigateDuringNotebookLookup:navigate}); expect((await h.toolbar()).ok).toBe(!navigate);expect(h.calls.creates).toHaveLength(navigate?0:1); if(!navigate){expect(h.calls.creates[0].contentMarkdown).toContain('https://m.okjike.com/originalPosts/64c9dd9a2edae6abdc610605');expect(h.calls.creates[0].contentMarkdown).not.toContain('Do not capture comments');expect(h.calls.uploads).toHaveLength(1);} }); @@ -818,3 +819,46 @@ test('Jike Web card identity recycling during notebook lookup cancels before cre cards[0].__reactProps$fixture.children.props.data.id='6715e824d9dc81724c2ec74d';release(); expect(await pending).toMatchObject({kind:'error',message:'platformPageChanged'});expect(h.calls.creates).toHaveLength(0); }); + +for(const stage of ['before-capture','during-notebook-lookup'])test(`Jike Web detail same-author post mismatch ${stage} creates no memo`,async()=>{ + const a='64c9dd9a2edae6abdc610605',b='6715e824d9dc81724c2ec74d'; + let card,release,entered;const started=new Promise(r=>entered=r),gate=new Promise(r=>release=r); + const h=harness({platform:'jike',fixture:'jike-web-detail',pageUrl:`https://web.okjike.com/u/5e6497e7-a69f-422b-af6e-baf0fc07b512/post/${stage==='before-capture'?b:a}`, + prepare:doc=>{card=prepareJikeWebDetail(doc);card.setAttribute('data-edgeever-jike-id',b);}, + ...(stage==='during-notebook-lookup'?{notebookRequested:entered,notebookGate:gate}:{})}); + const pending=h.toolbar(); + if(stage==='during-notebook-lookup'){await started;card.__reactProps$fixture.children.props.data.id=b;release();} + expect(await pending).toMatchObject({ok:false,message:'platformPageChanged'});expect(h.calls.creates).toHaveLength(0); +}); + +test('legacy Jike Web share detail retains its H5 data identity checks',async()=>{ + const h=harness({platform:'jike',fixture:'jike',pageUrl:'https://web.okjike.com/originalPost/66ac89b0314f5cced41012d3'}); + expect((await h.toolbar()).ok).toBe(true);expect(h.calls.creates).toHaveLength(1); +}); + +test('Jike Web detail rejects DOM from another post even when current props match the route',async()=>{ + const h=harness({platform:'jike',fixture:'jike-web-detail',pageUrl:'https://web.okjike.com/u/5e6497e7-a69f-422b-af6e-baf0fc07b512/post/64c9dd9a2edae6abdc610605',prepare:doc=>{ + const card=prepareJikeWebDetail(doc);card.querySelector('[class*="_contentWrapper_"] [class^="_content_"]').textContent='Another post still being rendered'; + }}); + expect((await h.toolbar()).ok).toBe(false);expect(h.calls.creates).toHaveLength(0); +}); + +for(const variant of ['normal','images-only','changed-image'])test(`Jike Web gallery detail ${variant} verifies content and retains nine images`,async()=>{ + const h=harness({platform:'jike',fixture:'jike-web-grid',pageUrl:'https://web.okjike.com/u/af47ed7e-38d4-4798-acd6-04e3cc8d131c/post/6715e824d9dc81724c2ec74d',prepare:doc=>{ + const card=prepareJikeWebDetail(doc,'6715e824d9dc81724c2ec74d'); + if(variant==='images-only'){card.querySelector('[class*="_contentWrapper_"] [class^="_content_"]').remove();card.__reactProps$fixture.children.props.data.content='';} + if(variant==='changed-image')card.querySelector('img[data-anonymous-gallery="true"]').setAttribute('src','https://cdnv2.ruguoapp.com/another-post.jpg'); + }}); + expect((await h.toolbar()).ok).toBe(variant!=='changed-image');expect(h.calls.creates).toHaveLength(variant==='changed-image'?0:1); + if(variant!=='changed-image')expect(h.calls.uploads).toHaveLength(9); +}); + +test('Jike Web detail expands its selected card before verifying the full public body',async()=>{ + const h=harness({platform:'jike',fixture:'jike-web-detail',pageUrl:'https://web.okjike.com/u/5e6497e7-a69f-422b-af6e-baf0fc07b512/post/64c9dd9a2edae6abdc610605',prepare:(doc,window)=>{ + const card=prepareJikeWebDetail(doc),body=card.querySelector('[class*="_contentWrapper_"]'),text=body.querySelector('[class^="_content_"]'),full=text.innerHTML; + text.textContent='我在即刻写东西的核心原则是…';const button=doc.createElement('button');button.textContent='展开全文';body.appendChild(button); + let notify=()=>{};window.MutationObserver=class{constructor(fn){notify=fn;}observe(){}disconnect(){}}; + button.addEventListener('click',()=>{text.innerHTML=full;button.remove();notify();}); + }}); + expect((await h.toolbar()).ok).toBe(true);expect(h.calls.creates).toHaveLength(1);expect(h.calls.creates[0].contentMarkdown).toContain('《斯坦福大学人生设计课》'); +}); diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 83831af7..e9ff0c88 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -1323,6 +1323,8 @@ const describePlatformError = (error: unknown) => { }; const isJikeWebFeed = (url: string) => url.startsWith("https://web.okjike.com/") && !platformTarget(url); +const usesJikeWebState = (url: string) => isJikeWebFeed(url) + || /^https:\/\/web\.okjike\.com\/u\/[^/]+\/post\/[a-f0-9]{24}\/?(?:[?#]|$)/.test(url); const readJikeWebState = async (tabId: number, id?: string) => (await chrome.scripting.executeScript({ target: { tabId, frameIds: [0] }, world: "MAIN", func: readJikeWebStateInPage, args: id ? [id] : [], }))[0]?.result; @@ -1343,7 +1345,7 @@ const readPlatformFromPage = async (tabId: number, target: PlatformTarget, selec const xiaoyuzhouState = target.platform === "xiaoyuzhou" ? (await chrome.scripting.executeScript({ target: { tabId, frameIds: [0] }, world: "MAIN", func: readXiaoyuzhouStateInPage }))[0]?.result : undefined; - const jikeState = target.platform === "jike" && isJikeWebFeed(sourceUrl) ? await readJikeWebState(tabId,target.id) : undefined; + const jikeState = target.platform === "jike" && usesJikeWebState(sourceUrl) ? await readJikeWebState(tabId,target.id) : undefined; await chrome.scripting.executeScript({ target: { tabId, frameIds: [0] }, func: (payload: unknown) => { @@ -1387,7 +1389,7 @@ const performPlatformSave = async (tabId: number, pageUrl: string, selectionFirs "---", clip.markdown, ].filter(Boolean).join("\n\n") : toMarkdown(selection!); const notebookId = await notebookForClip(settings); - if (target.platform === "jike" && isJikeWebFeed(pageUrl) && clip) { + if (target.platform === "jike" && usesJikeWebState(pageUrl) && clip) { const live = await readJikeWebState(tabId,target.id); if (live?.pageUrl !== pageUrl || live.post.id !== target.id) throw new Error(t("platformPageChanged")); } diff --git a/apps/extension/src/jike-clip.ts b/apps/extension/src/jike-clip.ts index b37573b0..14102a41 100644 --- a/apps/extension/src/jike-clip.ts +++ b/apps/extension/src/jike-clip.ts @@ -34,7 +34,10 @@ const bodyInCard = (card: Element) => Array.from(card.querySelectorAll(".text,.i const comparisonText = (body: Element, base: string) => { const comparison=body.cloneNode(true) as Element; for (const n of Array.from(comparison.querySelectorAll('button,[role="button"]'))) n.remove(); - for (const mention of Array.from(comparison.querySelectorAll('[class*="_mentionUser_"]'))) mention.textContent = `@${platformText(mention)}`; + for (const mention of Array.from(comparison.querySelectorAll('[class*="_mentionUser_"]'))) { + const text=platformText(mention); + mention.textContent = text.startsWith("@") ? text : `@${text}`; + } // Share pages shorten URL labels to hostnames. Mentions remain source text. for (const link of Array.from(comparison.querySelectorAll("a[href]"))) { const href=safePlatformUrl(link.getAttribute("href") || "",base); @@ -103,7 +106,7 @@ export const jikePostCard = (doc: Document, target: PlatformTarget, pageUrl: str return Array.from(doc.querySelectorAll(JIKE_CARD_SELECTOR)).find(card=>jikeCardTarget(card,pageUrl)?.id === target.id) || null; }; -/** Signed-in Web detail: bind the single visible post card to its route owner. */ +/** Read the identified Web card only when its visible body matches current public data. */ const readWebDetail = (doc: Document, card: Element, target: PlatformTarget, post?: JikePost): PlatformRead => { const body = card.querySelector(WEB_BODY); if (!body) return {ok:false,reason:"unreadable"}; @@ -131,7 +134,7 @@ const readWebDetail = (doc: Document, card: Element, target: PlatformTarget, pos if (!text && !clean.querySelector("img")) return {ok:false,reason:"unreadable"}; if (post) { const content = ownText(clean); - if (!content || typeof post.content !== "string" || normalizedText(comparisonText(content,target.url),target.url) !== normalizedText(post.content,target.url)) return {ok:false,reason:"incomplete"}; + if (typeof post.content !== "string" || normalizedText(content ? comparisonText(content,target.url) : "",target.url) !== normalizedText(post.content,target.url)) return {ok:false,reason:"incomplete"}; const images = Array.from(clean.querySelectorAll('img[data-anonymous-gallery="true"]')); if (!Array.isArray(post.pictures) || images.length !== post.pictures.length) return {ok:false,reason:"incomplete"}; for (const [index,image] of images.entries()) { @@ -166,12 +169,11 @@ const readWebDetail = (doc: Document, card: Element, target: PlatformTarget, pos export const readJike = (doc: Document, pageUrl: string, target: PlatformTarget | null = platformTarget(pageUrl), currentState?: unknown): PlatformRead => { if (target?.platform !== "jike") return {ok:false,reason:"not-target"}; const card=jikePostCard(doc,target,pageUrl); - if (!platformTarget(pageUrl) && card && isJikeWebCard(card)) { + if (card && isJikeWebCard(card)) { const state=currentState as {pageUrl?: unknown;post?: JikePost} | undefined; if (state?.pageUrl !== pageUrl || state.post?.id !== target.id) return {ok:false,reason:"changed"}; return readWebDetail(doc,card,target,state.post); } - if (card?.matches(WEB_DETAIL_CARD)) return readWebDetail(doc,card,target); const body=card && bodyInCard(card); if (!card || !body) return {ok:false,reason:"unreadable"}; const state=pageData(doc), detail=platformTarget(pageUrl)?.platform === "jike"; diff --git a/apps/extension/src/jike-web-state.ts b/apps/extension/src/jike-web-state.ts index 7a355759..23fcb8f1 100644 --- a/apps/extension/src/jike-web-state.ts +++ b/apps/extension/src/jike-web-state.ts @@ -40,14 +40,20 @@ export function readJikeWebStateInPage(expectedId?: string) { return postsList.length === 1 && postsList[0].type === "ORIGINAL_POST" ? postsList[0] : null; }; const matches = []; - const selector=expectedId ? `[data-edgeever-jike-id="${expectedId}"]` : '[data-edgeever-jike-selected]'; + const url=new URL(location.href); + const route = /^(\/u\/[^/]+)\/post\/([a-f0-9]{24})\/?$/.exec(url.pathname); + if (route && expectedId && route[2] !== expectedId) return null; + const selector=route ? '[data-clickable-feedback][class*="_postCard_"]' : expectedId ? `[data-edgeever-jike-id="${expectedId}"]` : '[data-edgeever-jike-selected]'; for (const card of Array.from(document.querySelectorAll(selector))) { const header = card.querySelector("header"), body = card.querySelector('[class*="_contentWrapper_"]'); if (!card.matches('[data-clickable-feedback]') || !header || !body || body.contains(header) || header.closest('[data-clickable-feedback]') !== card || body.closest('[data-clickable-feedback]') !== card || !visible(card)) continue; + if (route && !Array.from(header.querySelectorAll('a[href]')).some(link => { + try { return visible(link) && new URL(link.getAttribute('href') || '',url.origin).href === `${url.origin}${route[1]}`; } catch { return false; } + })) continue; const post = read(card); - if (post && (!expectedId || post.id === expectedId)) matches.push({ card, post }); + if (post && (!expectedId || post.id === expectedId) && (!route || post.id === route[2])) matches.push({ card, post }); } if (matches.length !== 1) return null; - matches[0].card.setAttribute("data-edgeever-jike-id", matches[0].post.id); + if (!route) matches[0].card.setAttribute("data-edgeever-jike-id", matches[0].post.id); return { pageUrl: location.href, post: matches[0].post }; }