fix(extension): 修复 #612 即刻切帖时正文与来源链接错配 (#614)

* fix(extension): prevent Jike post content and source mismatches

* fix(extension): normalize existing mention prefixes on Jike mobile
This commit is contained in:
LuoQianhong authored and GitHub committed 2026-10-18 15:10:58 +08:00
1 parent 862132acc7
commit 3cc42871e0
6 files changed
+105 -13

No files matched your search

+26 -2
View File
@@ -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('@@');
});
+14
View File
@@ -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;
};
+45 -1
View File
@@ -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('《斯坦福大学人生设计课》');
});
+4 -2
View File
@@ -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"));
}
+7 -5
View File
@@ -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";
+9 -3
View File
@@ -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 };
}