diff --git a/apps/extension/FIREFOX_STORE_LISTING.md b/apps/extension/FIREFOX_STORE_LISTING.md index 962b0f37..85a0008c 100644 --- a/apps/extension/FIREFOX_STORE_LISTING.md +++ b/apps/extension/FIREFOX_STORE_LISTING.md @@ -3,7 +3,7 @@ ## Listing - Name: EdgeEver Web Clipper -- Summary: Save a webpage, selected text, an image, an X post, a Reddit post, a Xiaohongshu note, a Zhihu answer or article, or a GitHub repository to your EdgeEver instance. +- Summary: Save webpages, selections, images, and Hacker News posts to your self-hosted EdgeEver instance. - Category: Bookmarks - Homepage: https://edgeever.org - Support: https://github.com/tianma-if/edgeever/issues @@ -20,6 +20,7 @@ EdgeEver Web Clipper saves the current webpage, selected text, a right-clicked i - On Zhihu, right-click the answer or article text and save the title, author, text, and photos. Comments are left out. On a feed or question page, the item under the pointer is saved. - On Reddit, right-click a post and save its title, author, community, text, links, and images. Comments are left out. - On a GitHub repository page or code tree, right-click the page background, the description, or the README text and save the repository address and description. +- On Hacker News post detail pages, save the title, author, original time, points at capture, linked page and main-post text. Comments are left out. - On a YouTube or Bilibili watch page, save that video as a note. - These commands stay on the top-level right-click menu. - Convert captured HTML to Markdown locally. @@ -52,7 +53,8 @@ The project maintainers do not receive or retain this data. Instance settings ar 11. Open one Zhihu answer or article, right-click the text (not a photo), choose **Save answer or article to EdgeEver**, and verify the note contains the title, text, and source link. 12. Open one Reddit post, right-click the post text, choose **Save Reddit post to EdgeEver**, and verify the note contains the title, author, community, and source link without comments. 13. Verify the created notes in the review EdgeEver instance. -14. On a YouTube or Bilibili watch page, right-click the page and choose **Save video note to EdgeEver**. The new menu runs only after that click. Lives and other unsupported pages show a message and create no note. +14. Open a Hacker News post detail page and choose **Save Hacker News post to EdgeEver** or **Clip current page**. Verify the main post and source are saved without comments; a selected passage takes priority for the toolbar action. +15. On a YouTube or Bilibili watch page, right-click the page and choose **Save video note to EdgeEver**. The new menu runs only after that click. Lives and other unsupported pages show a message and create no note. Restricted browser pages, extension stores, built-in PDF viewers, and other privileged pages cannot be captured. @@ -84,6 +86,7 @@ EdgeEver 网页剪藏插件可将当前网页、选中的文字、右键选中 - 在小红书笔记正文上右键,保存标题、正文和图片。评论不会写入。 - 在知乎回答或文章正文上右键,保存标题、作者、正文和图片。评论不会写入。首页和问题页保存的是指针下的那一篇。 - 在 Reddit 帖子正文上右键,保存标题、作者、社区、正文、链接和图片,不写入评论。 +- 在 Hacker News 主帖详情页保存标题、作者、原始时间、剪藏时的分数、外部链接和主帖正文,不写入评论。 - 可以把 YouTube 或哔哩哔哩播放页保存为视频笔记。 - 这些命令直接出现在右键菜单的第一级。 - 在浏览器本地将 HTML 转换为 Markdown。 diff --git a/apps/extension/README.md b/apps/extension/README.md index c4ecc4c2..b0f54d7f 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -16,12 +16,15 @@ Chrome, Edge, and Firefox Manifest V3 extension for saving the current webpage, - On Zhihu, right-click the answer or article text and choose **Save Zhihu content to EdgeEver**. The title, author, full text, and photos go into one note. Comments are left out. Right-clicking a photo saves that image instead. An open answer or article can be saved immediately. On the home feed or a question page, the extension remembers the item under the pointer; if that listener is not running yet, right-click the same text once more. - On Reddit, right-click a post or its title link and choose **Save Reddit post to EdgeEver**. The selected post becomes one note with its title, author, community, post text, source link, linked page, and available images. Comments are left out. The extension reads the selected post through Reddit's JSON response when available and falls back to its visible content. If the pointer listener is unavailable on a feed, right-click the same post once more. - On a GitHub repository page or code tree, right-click the page background, the description, or the README text and choose **Save this repository to EdgeEver**. The note keeps the repository address, the About text, and the homepage, language, license, and topics when the page shows them. An empty About falls back to the README's first paragraph. Issue, discussion, and single-file pages are left unsaved. The toolbar action uses the same card on those repository pages. Right-clicking a link or an image keeps the existing selection and image commands, so this one stays on the top-level menu. +- On a Hacker News post detail page, right-click the page and choose **Save Hacker News post to EdgeEver**, or click **Clip current page**. The note keeps the title, author, original time, points at capture, external link and main-post text when available, without comments. Ask HN, Show HN and link-only posts are supported. Selecting text before the toolbar action saves only that selection. - On a YouTube or Bilibili watch page, right-click the page or the player and choose **Save video note to EdgeEver**. The note keeps the source and timestamped captions. A cover is uploaded only when the page provided the image bytes. A summary is added when the token includes `ai:generate` and the workspace has a default model. The command runs only after that click. Lives, premieres, bangumi, and other pages are left unsaved. - When the page cannot hand over the image bytes, the extension asks once for access to that image's site. After that, later images from the same site save directly. - Create a searchable EdgeEver memo with the source URL and a `web-clip` tag. The extension does not use a central relay service. The page content is sent directly to the EdgeEver instance configured by the user. +HN usage and verification limits are documented in [English](../../docs/browser-clipper-hacker-news.md) and [简体中文](../../docs/browser-clipper-hacker-news.zh-CN.md). + ## Localization The extension uses the cross-browser `chrome.i18n` compatibility namespace. English is the fallback locale. Simplified Chinese, Japanese, and Polish are available for browsers whose UI language matches. diff --git a/apps/extension/STORE_LISTING.md b/apps/extension/STORE_LISTING.md index d2518412..76c9a498 100644 --- a/apps/extension/STORE_LISTING.md +++ b/apps/extension/STORE_LISTING.md @@ -16,7 +16,7 @@ Select the matching language in the Chrome Web Store developer dashboard and enter the corresponding copy below. Store listing localization is separate from the extension's packaged `_locales` messages. -The detailed descriptions below keep the 0.1.9 wording and add one video-note line for extension 0.1.17. The uploaded store package is still 0.1.9 until that version is submitted. An earlier draft stacked every site name in the opening sentence and repeated those names in the closing paragraph. Chrome Web Store rejected it immediately as excessive keywords (violation reference Yellow Argon). Keep each site in one feature line. The Japanese listing uses the English detailed description. The short summary still comes from the packaged `_locales` `extensionDescription`. +The detailed descriptions below include video notes and Hacker News capture prepared for the next store submission. The uploaded store package is still 0.1.9 until that version is submitted. An earlier draft stacked every site name in the opening sentence and repeated those names in the closing paragraph. Chrome Web Store rejected it immediately as excessive keywords (violation reference Yellow Argon). Keep each site in one feature line. The Japanese listing uses the English detailed description. The short summary still comes from the packaged `_locales` `extensionDescription`. ## Upload files @@ -29,7 +29,7 @@ The detailed descriptions below keep the 0.1.9 wording and add one video-note li #### Summary -将当前网页、选中文字、右键图片、X 推文、Reddit 帖子、小红书笔记、知乎回答或文章,或 GitHub 仓库保存到你自托管的 EdgeEver 实例。 +将网页、选中文字、图片及 Hacker News 主帖保存到你自托管的 EdgeEver 实例。 #### Detailed description @@ -45,6 +45,7 @@ EdgeEver Web Clipper 可以把当前网页,或你选中的那一部分,保 - 在知乎上保存指针下的回答或文章,包括标题、作者、正文和图片,不写入评论。 - 在 Reddit 上右键帖子,保存标题、作者、社区、正文、链接和图片,不写入评论。 - 在 GitHub 仓库页上保存地址和简介;页面上有主页、语言、许可证和话题时一并写入。 +- 在 Hacker News 主帖详情页保存标题、作者、原始时间、剪藏时的分数、外部链接和主帖正文,不写入评论。 - 可以把 YouTube 或哔哩哔哩播放页保存为视频笔记。 - 这些命令直接出现在右键菜单的第一级。 - 笔记会保留来源和剪藏时间。 @@ -59,7 +60,7 @@ EdgeEver 是开源、自托管的现代笔记工作区。项目主页与源代 #### Summary -Save a webpage, selected text, a right-clicked image, an X post, a Reddit post, a Xiaohongshu note, a Zhihu answer or article, or a GitHub repository to your self-hosted EdgeEver. +Save webpages, selections, images, and Hacker News posts to your self-hosted EdgeEver instance. #### Detailed description @@ -75,6 +76,7 @@ Key features: - On Zhihu, save the answer or article under the pointer, including its title, author, text, and photos. Comments are left out. - On Reddit, right-click a post to save its title, author, community, text, links, and images. Comments are left out. - On a GitHub repository page, save the address and description, plus the homepage, language, license, and topics when the page shows them. +- On Hacker News post detail pages, save the title, author, original time, points at capture, linked page and main-post text. Comments are left out. - Save a YouTube or Bilibili watch page as a video note. - These commands stay on the top-level right-click menu. - The note keeps its source and the time it was clipped. @@ -89,12 +91,12 @@ EdgeEver is an open-source, self-hosted modern notes workspace. Project homepage ### Single purpose -Save the current webpage, user-selected text, a user-chosen image, one X post, one Reddit post, one Xiaohongshu note, one Zhihu answer or article, one GitHub repository card, or one video note from a supported watch page to the self-hosted EdgeEver instance explicitly configured by the user. +Save the current webpage, user-selected text, a user-chosen image, one X post, one Reddit post, one Xiaohongshu note, one Zhihu answer or article, one GitHub repository card, one Hacker News post, or one video note from a supported watch page to the self-hosted EdgeEver instance explicitly configured by the user. ### Permission justifications -- `activeTab`: Read the active page only after the user clicks the extension's save action or chooses a save command for selected text, an image, an X post, a Reddit post, a Xiaohongshu note, a Zhihu item, a GitHub repository, or a supported watch page. -- `contextMenus`: Add one top-level item for the thing the user right-clicked: selected text, an image, an X post, a Reddit post, a Xiaohongshu note, a Zhihu answer or article, or a GitHub repository page. The video-note item appears only on a supported YouTube or Bilibili watch page and runs only after the user clicks it. +- `activeTab`: Read the active page only after the user clicks the extension's save action or chooses a save command for selected text, an image, an X post, a Reddit post, a Xiaohongshu note, a Zhihu item, a GitHub repository, a Hacker News post, or a supported watch page. +- `contextMenus`: Add one top-level item for the thing the user right-clicked: selected text, an image, an X post, a Reddit post, a Xiaohongshu note, a Zhihu answer or article, a GitHub repository page, or a Hacker News post detail page. The video-note item appears only on a supported YouTube or Bilibili watch page and runs only after the user clicks it. - `scripting`: Inject the packaged capture script into the active page after the user initiates a capture. - `storage`: Store the user's EdgeEver instance URL, API token, and default notebook ID locally. - Optional host permissions: API calls go only to the EdgeEver instance the user approves. If a page cannot provide an image file, the extension asks for that image's site, or for all sites when the user chooses that option, and uses the access only to download the chosen image. Saving from an X or Xiaohongshu feed may ask for site access to identify the item under the pointer. On Zhihu and Reddit, a content script only remembers the item under the pointer. Its content is read after the user chooses the save command. If that listener is unavailable, the extension asks the user to right-click the same item again. The video-note command does not add a host permission. It runs in the page only after the user clicks it on a supported watch page. diff --git a/apps/extension/capture-bundle.test.mjs b/apps/extension/capture-bundle.test.mjs index 98b8c3b5..b6cc5fb4 100644 --- a/apps/extension/capture-bundle.test.mjs +++ b/apps/extension/capture-bundle.test.mjs @@ -26,6 +26,9 @@ test("the injected page capture is a standalone classic script", async () => { const capture = await readFile(join(outDir, "assets/capture.js"), "utf8"); expect(() => new Script(capture)).not.toThrow(); expect(capture).toContain("capturedPage"); + const platform = await readFile(join(outDir, "assets/capture-platform.js"), "utf8"); + expect(() => new Script(platform)).not.toThrow(); + expect(platform).toContain("pagePlatformRead"); } finally { await rm(outDir, { recursive: true, force: true }); } diff --git a/apps/extension/context-menus.test.mjs b/apps/extension/context-menus.test.mjs index 431d4478..f6ba2ac5 100644 --- a/apps/extension/context-menus.test.mjs +++ b/apps/extension/context-menus.test.mjs @@ -45,6 +45,7 @@ test("registers context menus on install, not on background startup", async () = "save-reddit", "save-reddit-link", "save-video", + "save-hacker-news", ]); expect(created.find((item) => item.id === "save-xhs")).toMatchObject({ contexts: ["page", "video"], @@ -81,6 +82,10 @@ test("registers context menus on install, not on background startup", async () = }); expect(created.find((item) => item.id === "save-selection")?.contexts).toEqual(["selection"]); expect(created.find((item) => item.id === "save-image")?.contexts).toEqual(["image"]); + for (const id of ["save-hacker-news"]) { + expect(created.find((item) => item.id === id)?.contexts).toEqual(["page"]); + } + expect(created.find((item) => item.id === "save-hacker-news")?.documentUrlPatterns).toEqual(["https://news.ycombinator.com/item?*"]); const video = created.find((item) => item.id === "save-video"); expect(video).toMatchObject({ contexts: ["page", "video"], diff --git a/apps/extension/fixtures/platforms/hacker-news.html b/apps/extension/fixtures/platforms/hacker-news.html new file mode 100644 index 00000000..5e3af0a6 --- /dev/null +++ b/apps/extension/fixtures/platforms/hacker-news.html @@ -0,0 +1,3 @@ + +
Show HN: I built a LLM-powered Ask HN: like Perplexity, but for HN comments (hackersearch.net)
92 points by jnnnthnn on May 16, 2024 | hide | past | | 39 comments
Hi HN!

I'm Jonathan and I built Ask Hacker Search (https://hackersearch.net/ask), an LLM-powered version of Hacker News' Ask HN.

Unlike Ask HN, Ask Hacker Search doesn't solicit new contributions from HN readers. Instead, it leverages Hacker News' historical data to answer questions, and offers LLM-generated summaries of those. I've used it for questions like "Should I use Drizzle or Prisma?" or "What is a good screen capture that allows easy zooming effects on Mac?".

It is particularly useful when you're interested in understanding HN readers' sentiment about a topic, or when looking for expert insights on topics of interest to HN readers. I've been using it continually while building it, and have found it particularly useful to find software libraries recommended by HN or get quick vibe checks on hot topics.

This builds on my release of Hacker Search two weeks ago +(https://news.ycombinator.com/item?id=40238509), which offered a semantic search engine over top HN submissions. It's not just a small upgrade: covering comments was the #1 requested feature after that launch, so I rebuilt the near entirety of the product to support that.

Please try it out and let me know what you think of it! I have to limit the number of LLM summaries each person can get for free, as this is entirely self-funded. If you hit the limit, you can subscribe for more summaries generated by a better model ($8/month), or bring your own compute by running inference on Ollama on your machine!

Well done. Matching the HN style with Tailwind is a nice touch.
diff --git a/apps/extension/fixtures/platforms/sources.json b/apps/extension/fixtures/platforms/sources.json new file mode 100644 index 00000000..df5a39b0 --- /dev/null +++ b/apps/extension/fixtures/platforms/sources.json @@ -0,0 +1,12 @@ +{ + "capturedOn": "2026-10-08", + "samples": [ + { + "file": "hacker-news.html", + "url": "https://news.ycombinator.com/item?id=40380738", + "state": "Public, signed out; main post with both text and an external link.", + "expected": "Keep title, jnnnthnn, original time, score 92, complete toptext and hackersearch.net/ask. Exclude the separate comment tree." + } + ], + "redaction": "Only relevant main-post DOM and one comment excerpt remain. Tests derive synthetic boundary states from this fixture; these variants are not live-site acceptance evidence." +} diff --git a/apps/extension/package.json b/apps/extension/package.json index 5ee761f8..c0682b33 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -9,7 +9,7 @@ "dev": "vite --config vite.config.ts", "lint:firefox": "web-ext lint --source-dir dist-firefox --output text", "package:firefox": "bun run build:firefox && bun run lint:firefox && web-ext build --source-dir dist-firefox --artifacts-dir web-ext-artifacts --overwrite-dest", - "test": "bun test manifest.test.mjs context-menus.test.mjs popup.test.mjs capture-bundle.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs xhs-clip.test.mjs zhihu-clip.test.mjs reddit-clip.test.mjs video-clip.test.mjs page-images.test.mjs notebook-clip.test.mjs" + "test": "bun test manifest.test.mjs context-menus.test.mjs popup.test.mjs capture-bundle.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs xhs-clip.test.mjs zhihu-clip.test.mjs reddit-clip.test.mjs video-clip.test.mjs page-images.test.mjs notebook-clip.test.mjs platform-clip.test.mjs platform-save.test.mjs" }, "dependencies": { "@mixmark-io/domino": "2.2.0", diff --git a/apps/extension/page-images.test.mjs b/apps/extension/page-images.test.mjs index 828a5e44..dabc2a1a 100644 --- a/apps/extension/page-images.test.mjs +++ b/apps/extension/page-images.test.mjs @@ -42,6 +42,15 @@ const createClient = ({ failUploadFor = [], failSave = false, session = created, const onlyFrom = (urls) => async (image) => (urls.includes(image.url) ? pngFile : null); describe("page clip images", () => { + test("counts every external image while transferring only the first 30", async () => { + const markdown = Array.from({ length: 32 }, (_, index) => `![${index}](https://example.com/${index}.png)`).join("\n\n"); + const images = pageImageRefs(markdown, "https://example.com", Infinity); + const { client, calls } = createClient(); + const result = await embedPageImages(client, { memoId: "memo", markdown, created, images, download: async () => pngFile }); + expect(result).toEqual({ embedded: 30, total: 32 }); + expect(calls.uploads).toHaveLength(30); + expect(calls.saves[0].body.contentMarkdown).toContain("![31](https://example.com/31.png)"); + }); test("finds remote image addresses, resolving relative ones against the page", () => { const markdown = [ "![Hero](https://cdn.example.com/hero.png)", diff --git a/apps/extension/platform-clip.test.mjs b/apps/extension/platform-clip.test.mjs new file mode 100644 index 00000000..302a0004 --- /dev/null +++ b/apps/extension/platform-clip.test.mjs @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { createDocument } from "@mixmark-io/domino"; +import { platformTarget, platformHtmlMarkdown } from "./src/platform-clip.ts"; +import { readHackerNews } from "./src/hacker-news-clip.ts"; +const labels = { score: "Points at capture", externalLink: "Linked page" }; +const hnUrl = "https://news.ycombinator.com/item?id=40380738"; +const fixture = (name) => createDocument(readFileSync(new URL(`./fixtures/platforms/${name}.html`, import.meta.url), "utf8")); + +test("HN detail URLs retain identity and discard tracking and comment anchors", () => { + expect(platformTarget(`${hnUrl}&utm_source=x#40384810`)).toEqual({ platform: "hacker-news", id: "40380738", url: hnUrl }); + for (const url of ["https://news.ycombinator.com/", `${hnUrl}&id=1`, "https://news.ycombinator.com.evil.test/item?id=1", "https://u:p@news.ycombinator.com/item?id=1", "http://news.ycombinator.com/item?id=1", "https://news.ycombinator.com/item?id=0"]) expect(platformTarget(url)).toBeNull(); +}); + +test("HN real DOM keeps text and external link together, metadata and no comments", () => { + const result = readHackerNews(fixture("hacker-news"), hnUrl, labels); + expect(result.ok).toBe(true); + expect(result.clip).toMatchObject({ platform: "hacker-news", id: "40380738", url: hnUrl, author: "jnnnthnn", publishedAt: { value: "2024-05-16T17:11:58", precision: "datetime" }, tags: ["web-clip", "hacker-news"] }); + expect(result.clip.title).toStartWith("Show HN: I built a LLM-powered Ask HN"); + expect(result.clip.markdown).toContain("Hi HN!"); + expect(result.clip.markdown).toContain("Ollama on your machine!"); + expect(result.clip.markdown).toContain("Linked page: [https://hackersearch.net/ask](https://hackersearch.net/ask)"); + expect(result.clip.markdown).toContain("Points at capture: 92"); + expect(result.clip.markdown).not.toContain("Matching the HN style"); +}); + +test("HN allows Ask text and link-only posts and omits missing metadata", () => { + const text = fixture("hacker-news"); + text.querySelector(".titleline a").setAttribute("href", "item?id=40380738"); + text.querySelector(".score").remove(); text.querySelector(".age").remove(); + const ask = readHackerNews(text, hnUrl, labels); + expect(ask.ok).toBe(true); + expect(ask.clip.markdown).not.toContain("Linked page:"); + expect(ask.clip.markdown).not.toContain("Points at capture:"); + expect(ask.clip.publishedAt).toBeUndefined(); + const link = fixture("hacker-news"); link.querySelector(".toptext").remove(); + expect(readHackerNews(link, hnUrl, labels).clip.markdown).toContain("Linked page:"); +}); + +test("HN rejects comment, deleted, unreadable and mismatched main posts", () => { + const doc = fixture("hacker-news"); + expect(readHackerNews(doc, hnUrl.replace("40380738", "40384810"), labels).ok).toBe(false); + doc.querySelector(".titleline").remove(); + expect(readHackerNews(doc, hnUrl, labels).ok).toBe(false); + const deleted = fixture("hacker-news"); deleted.querySelector(".titleline a").textContent = "[deleted]"; + expect(readHackerNews(deleted, hnUrl, labels).ok).toBe(false); +}); + +test("HTML cleanup preserves semantic content and code language on a copy", () => { + const doc = createDocument('

正文

引用
const x = `safe`;\n```
链接危险链接
'); + const body = doc.querySelector("#body"); const before = body.outerHTML; + const markdown = platformHtmlMarkdown(body, "https://news.ycombinator.com/item?id=40380738"); + expect(markdown).toContain("## 正文"); + expect(markdown).toContain("> 引用"); + expect(markdown).toMatch(/-\s+条目/); + expect(markdown).toContain("````js\nconst x = `safe`;\n```\n````"); + expect(markdown).toContain("[链接](https://news.ycombinator.com/article)"); + expect(markdown).toContain("https://images.example.com/a.png"); + for (const unwanted of ["javascript:", "evil()", "bad()", "分享", "onerror", " (await build({ entryPoints: [fileURLToPath(new URL(`./src/${name}.ts`, import.meta.url))], bundle: true, platform: "browser", format: "iife", write: false })).outputFiles[0].text; +const [background, captureScript] = await Promise.all([bundle("background"), bundle("capture-platform")]); +const urls = { + "hacker-news": "https://news.ycombinator.com/item?id=40380738", +}; +const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); + +// Execute the actual bundled background and injected script. Only browser APIs +// and the instance's HTTP boundary are mocked; extraction and saving are real. +const harness = (options = {}) => { + const platform = options.platform || "hacker-news"; + const pageUrl = options.pageUrl || urls[platform]; + let currentUrl = pageUrl; + const window = createWindow(readFileSync(new URL(`./fixtures/platforms/${platform}.html`, import.meta.url), "utf8"), pageUrl); + window.getSelection = () => null; + options.prepare?.(window.document, window); + const calls = { creates: [], uploads: [], saves: [], injections: [], feedback: [], requests: [], rejectedMessages: [] }; + let onMessage, onClick, menuDone; + const json = (value, status = 200) => Response.json(value, { status }); + const fetchMock = async (url, init) => { + calls.requests.push(String(url)); + const path = new URL(url).pathname; + if (String(url).startsWith("https://edgeever.test")) { + if (path === "/api/v1/notebooks") { + if (options.navigateDuringNotebookLookup) currentUrl = "https://news.ycombinator.com/item?id=40380739"; + return json({ notebooks: [{ id: "nb_inbox", slug: "inbox" }] }); + } + if (path === "/api/v1/memos") { + calls.creates.push(JSON.parse(init.body)); + return json({ memo: { id: "memo_1", revision: 1, contentHash: "original" } }, 201); + } + if (path.endsWith("/resources")) { + calls.uploads.push(init.body); + return options.failUpload ? json({ error: { message: "upload failed" } }, 500) : json({ resource: { id: `res_${calls.uploads.length}` } }); + } + if (path.endsWith("/edit-sessions")) return json({ editSession: { id: "session", baseRevision: options.editConflict ? 2 : 1, baseContentHash: options.editConflict ? "user-edited" : "original" } }); + if (path.endsWith("/save")) { + if (options.failRewrite) return json({ error: { message: "conflict" } }, 409); + calls.saves.push(JSON.parse(init.body)); return json({}); + } + throw new Error(`Unexpected instance request: ${path}`); + } + return options.failDownload ? new Response("Forbidden", { status: 403 }) : new Response(png, { headers: { "content-type": "image/png" } }); + }; + let pageContext; + const chrome = { + i18n: { getMessage: (key) => key }, + runtime: { + id: "extension-under-test", + onInstalled: { addListener() {} }, onMessage: { addListener(listener) { onMessage = listener; } }, + openOptionsPage: async () => {}, + sendMessage: async (message) => { + if (options.spoofMessages && message.type === "pagePlatformRead") { + for (const overrides of [{ tab: { id: 999 } }, { frameId: 3 }, { id: "other-extension" }]) { + const fake = { ...message, result: { ok: true, clip: { ...message.result.clip, title: "Wrong sender" } } }; + onMessage(fake, { id: chrome.runtime.id, tab: { id: 7 }, frameId: 0, url: currentUrl, ...overrides }, () => {}); + calls.rejectedMessages.push(overrides); + } + onMessage({ ...message, requestId: "unrelated", result: { ok: false } }, { id: chrome.runtime.id, tab: { id: 7 }, frameId: 0, url: currentUrl }, () => {}); + } + onMessage(message, { id: chrome.runtime.id, tab: { id: 7 }, frameId: 0, url: currentUrl }, () => {}); + }, + }, + storage: { local: { get: async () => ({ instanceUrl: "https://edgeever.test", token: "test-token", notebookId: "" }) } }, + permissions: { contains: async () => !options.denyInstance }, + tabs: { query: async () => [{ id: 7, url: currentUrl }], get: async () => ({ id: 7, url: currentUrl }) }, + windows: { onRemoved: { addListener() {} } }, + contextMenus: { onClicked: { addListener(listener) { onClick = listener; } } }, + scripting: { executeScript: async ({ func, args = [], files, target }) => { + calls.injections.push({ files, target }); + if (options.denyScripting) throw new Error("Cannot access contents of the page. Missing host permission."); + if (func) { + if (args[0]?.action === "toast") { calls.feedback.push(args[0]); if (calls.feedback.length > 1) menuDone?.(args[0]); return []; } + pageContext.__args = args; + return [{ result: await runInContext(`(${func.toString()})(...__args)`, pageContext) }]; + } + if (files?.[0] === "assets/capture-platform.js") runInContext(captureScript, pageContext); + else if (files?.[0] !== "assets/capture-image.js") throw new Error(`Unexpected generic fallback: ${files}`); + return []; + } }, + action: { setBadgeBackgroundColor: async () => {}, setBadgeText: async () => {} }, + }; + pageContext = createContext({ window, document: window.document, location: { get href() { return currentUrl; } }, URL, chrome, setTimeout, clearTimeout, fetch: fetchMock, AbortSignal, Blob, Uint8Array }); + runInContext(background, createContext({ chrome, fetch: fetchMock, URL, crypto, setTimeout, clearTimeout, setInterval, clearInterval, AbortSignal, AbortController, Blob, File, FormData, Uint8Array, ArrayBuffer, Response })); + return { + calls, + toolbar: () => new Promise((resolve) => onMessage({ type: "captureCurrentPage" }, { id: chrome.runtime.id }, resolve)), + menu: () => new Promise((resolve) => { menuDone = resolve; onClick({ menuItemId: `save-${platform}`, pageUrl, frameId: 0 }, { id: 7, url: pageUrl }); }), + }; +}; + +for (const platform of Object.keys(urls)) { + test(`${platform}: toolbar and dedicated menu save the same note through the real parser`, async () => { + const toolbar = harness({ platform }); const menu = harness({ platform }); + expect((await toolbar.toolbar()).ok).toBe(true); + await menu.menu(); + expect(toolbar.calls.creates).toHaveLength(1); + expect(menu.calls.creates).toHaveLength(1); + const normalize = (note) => ({ ...note, contentMarkdown: note.contentMarkdown.replace(/capturedAtLabel: [^\n]+/, "capturedAtLabel: ") }); + expect(normalize(menu.calls.creates[0])).toEqual(normalize(toolbar.calls.creates[0])); + expect(toolbar.calls.creates[0].tags).toEqual(["web-clip", platform]); + expect(toolbar.calls.creates[0].notebookId).toBe("nb_inbox"); + expect(toolbar.calls.requests.some((url) => /\.(mp3|m4a|mp4)(\?|$)/.test(url))).toBe(false); + }); +} + +test("a new platform toolbar still saves a valid selection instead of its full article", async () => { + const h = harness({ platform: "hacker-news", prepare: (doc, window) => { + window.getSelection = () => ({ rangeCount: 1, toString: () => "Only this passage", getRangeAt: () => ({ cloneContents: () => { + const fragment = doc.createDocumentFragment(); const p = doc.createElement("p"); p.textContent = "Only this passage"; fragment.appendChild(p); return fragment; + } }) }); + } }); + expect((await h.toolbar()).ok).toBe(true); + expect(h.calls.creates[0].tags).toEqual(["web-clip"]); + expect(h.calls.creates[0].contentMarkdown).toContain("Only this passage"); + expect(h.calls.creates[0].contentMarkdown).not.toContain("Ollama on your machine!"); +}); + +test("request, extension, tab and frame correlation reject unrelated responses", async () => { + const h = harness({ spoofMessages: true }); + expect((await h.toolbar()).ok).toBe(true); + expect(h.calls.rejectedMessages).toHaveLength(3); + expect(h.calls.creates).toHaveLength(1); + expect(h.calls.creates[0].title).toStartWith("Show HN"); +}); + +for (const failure of ["failDownload", "failUpload", "failRewrite", "editConflict"]) { + test(`${failure}: keeps the saved body, reports external images, and never creates twice`, async () => { + const h = harness({ [failure]: true, prepare: (doc) => { doc.querySelector(".toptext").innerHTML += ''; } }); + expect(await h.toolbar()).toEqual({ ok: true, message: "platformImagesPartial" }); + expect(h.calls.creates).toHaveLength(1); + expect(h.calls.saves).toHaveLength(0); + expect(h.calls.creates[0].contentMarkdown).toContain("images.test/body.png"); + }); +} + +test("images beyond the limit count towards partial status after 30 successful uploads", async () => { + const h = harness({ platform: "hacker-news", prepare: (doc) => { + const body = doc.querySelector(".toptext"); + body.innerHTML += Array.from({ length: 32 }, (_, i) => ``).join(""); + } }); + expect(await h.toolbar()).toEqual({ ok: true, message: "platformImagesPartial" }); + expect(h.calls.uploads).toHaveLength(30); + expect(h.calls.creates).toHaveLength(1); + expect(h.calls.saves[0].contentMarkdown).toContain("/api/v1/resources/res_1/blob"); + expect(h.calls.saves[0].contentMarkdown).toContain("https://images.test/31.png"); +}); + +for (const platform of Object.keys(urls)) { + test(`${platform}: unreadable content never falls back to a whole-page note`, async () => { + const h = harness({ platform, prepare: (doc) => { doc.body.innerHTML = "

Sign in

Comments and recommendations

"; } }); + expect(await h.toolbar()).toEqual({ ok: false, message: "platformUnreadable" }); + expect(h.calls.creates).toHaveLength(0); + }); +} + +test("navigation while resolving the notebook cancels the old content before creation", async () => { + const h = harness({ platform: "hacker-news", navigateDuringNotebookLookup: true }); + expect(await h.toolbar()).toEqual({ ok: false, message: "platformPageChanged" }); + expect(h.calls.creates).toHaveLength(0); +}); + +for (const [option, message] of [["denyInstance", "instancePermissionRequired"], ["denyScripting", "pageAccessDenied"]]) { + test(`${option}: gives an actionable permission error without creating a note`, async () => { + const h = harness({ [option]: true }); + expect(await h.toolbar()).toEqual({ ok: false, message }); + expect(h.calls.creates).toHaveLength(0); + }); +} diff --git a/apps/extension/popup.html b/apps/extension/popup.html index dec45ea2..12e4b5d0 100644 --- a/apps/extension/popup.html +++ b/apps/extension/popup.html @@ -21,6 +21,7 @@ 小红书 知乎 GitHub + Hacker News