Fix Threads post text extraction

This commit is contained in:
Jordan Wages 2026-08-25 01:02:35 -05:00
commit ece11a8352
4 changed files with 52 additions and 2 deletions

View file

@ -8,7 +8,7 @@ VibeGuard is a local-first browser extension that hides toxic social-media posts
- Configurable toxicity threshold and filtering mode: collapse posts with a Show button or hide them completely.
- Optional toxicity scores on filtered-post placeholders.
- Declarative site definitions with URL patterns, selectors, and stable identifiers.
- Built-in support for the official Mastodon web interface.
- Built-in support for the official Mastodon web interface and Threads.
- Versioned JSON definition collections with weekly subscriptions and manual refresh.
- Toolbar popup with current-site status, matched definition, pause/resume control, and settings access.
- Firefox Manifest V2 and Chromium Manifest V3 builds.
@ -47,7 +47,7 @@ Preparation writes the Transformers.js-compatible files and `model-manifest.json
If the model uses generic labels such as `LABEL_0` and `LABEL_1`, pass `--toxic-index` and `--non-toxic-index` to `prepare`; the command refuses to guess an ambiguous mapping.
VibeGuard injects a lightweight content script on ordinary web pages, but reads and processes content only when a validated site definition matches the page. The extension includes a bundled definition collection for the official Mastodon web interface. The default subscription follows the repository's raw JSON collection and refreshes weekly, while the bundled copy remains available offline. Additional JSON subscriptions, local overrides, and disabled definitions are managed in the options page. Definitions are declarative selectors and URL patterns, never executable code.
VibeGuard injects a lightweight content script on ordinary web pages, but reads and processes content only when a validated site definition matches the page. The extension includes a bundled definition collection for the official Mastodon web interface and Threads. The Threads definition covers both `threads.com` and legacy `threads.net` URLs using semantic and data-attribute selectors. The default subscription follows the repository's raw JSON collection and refreshes weekly, while the bundled copy remains available offline. Additional JSON subscriptions, local overrides, and disabled definitions are managed in the options page. Definitions are declarative selectors and URL patterns, never executable code.
## Runtime architecture

View file

@ -16,6 +16,23 @@
"idAttributes": ["data-id"],
"permalinkSelectors": ["a.status__relative-time[href]", "a.detailed-status__datetime[href]", "a.u-url.u-uid[href]"]
}
},
{
"id": "threads",
"name": "Threads",
"urlPatterns": [
"https://threads.com/*",
"https://www.threads.com/*",
"https://threads.net/*",
"https://www.threads.net/*"
],
"requiredSelectors": ["[data-pressable-container=\"true\"], [role=\"article\"], article"],
"post": {
"rootSelectors": ["[data-pressable-container=\"true\"]", "[role=\"article\"]", "article"],
"textSelectors": ["span[dir=\"auto\"]:not(a span):not(time span):not([role=\"button\"] span)"],
"excludedSelectors": ["button", "[role=\"button\"]", "time", "svg"],
"permalinkSelectors": ["a[href*=\"/post/\"]"]
}
}
]
}

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { DefinitionEngine, matchesUrlPattern, selectDefinition } from "../src/content/definition-engine";
import { BUILTIN_DEFINITIONS } from "../src/content/definitions";
import type { SiteDefinition } from "../src/shared/types";
@ -59,4 +60,21 @@ describe("DefinitionEngine", () => {
stop();
expect(observed).toContain("content warning revealed text");
});
it("discovers Threads posts from stable semantic/data selectors and permalink IDs", () => {
document.body.innerHTML = readFileSync("tests/fixtures/threads-feed.html", "utf8");
const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!;
const posts = new DefinitionEngine(threads).discover(document);
expect(posts.map((post) => post.id)).toEqual(["threads:http://localhost:3000/@alice/post/ABC123", "threads:http://localhost:3000/@bob/post/DEF456"]);
expect(posts.map((post) => post.text)).toEqual(["first threads post with visible text", "a reply in the conversation"]);
});
it("selects Threads on current and legacy domains", () => {
const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!;
document.body.innerHTML = '<div data-pressable-container="true"></div>';
for (const url of ["https://threads.com/home", "https://www.threads.com/@alice/post/ABC123", "https://threads.net/home", "https://www.threads.net/@alice/post/ABC123"]) {
expect(selectDefinition([threads], url)).toBe(threads);
}
expect(selectDefinition([threads], "https://example.com/home")).toBeUndefined();
});
});

15
tests/fixtures/threads-feed.html vendored Normal file
View file

@ -0,0 +1,15 @@
<main>
<div data-pressable-container="true">
<a href="/@alice"><span dir="auto">alice</span></a>
<a href="/@alice/post/ABC123"><time><span dir="auto">12h</span></time></a>
<span dir="auto">First Threads post with <span>visible text</span></span>
<button><span dir="auto">Like</span></button>
<time><span dir="auto">1h</span></time>
</div>
<div role="article">
<a href="/@bob"><span dir="auto">bob</span></a>
<a href="/@bob/post/DEF456"><time><span dir="auto">1d</span></time></a>
<span dir="auto">A reply in the conversation</span>
<div aria-hidden="true">Decorative content</div>
</div>
</main>