Add initial VibeGuard browser extension
This commit is contained in:
parent
f251f0f8ce
commit
9c65a61ebb
43 changed files with 125275 additions and 2 deletions
41
src/content/filter.ts
Normal file
41
src/content/filter.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { InferenceResult, Settings } from "../shared/types";
|
||||
|
||||
const HIDDEN = "data-vibeguard-hidden";
|
||||
const ORIGINAL_DISPLAY = "data-vibeguard-display";
|
||||
|
||||
export function applyResult(element: Element, result: InferenceResult, settings: Settings): void {
|
||||
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold;
|
||||
if (!shouldFilter) { restore(element); return; }
|
||||
|
||||
element.setAttribute(HIDDEN, "true");
|
||||
if (settings.filterMode === "hide") {
|
||||
element.setAttribute(ORIGINAL_DISPLAY, (element as HTMLElement).style.display);
|
||||
(element as HTMLElement).style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const html = element as HTMLElement;
|
||||
if (!html.dataset.vibeguardOriginalDisplay) html.dataset.vibeguardOriginalDisplay = html.style.display;
|
||||
html.style.display = "none";
|
||||
let placeholder = element.nextElementSibling;
|
||||
if (!placeholder?.matches("[data-vibeguard-placeholder]")) {
|
||||
placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("data-vibeguard-placeholder", "true");
|
||||
element.insertAdjacentElement("afterend", placeholder);
|
||||
}
|
||||
placeholder.className = "vibeguard-placeholder";
|
||||
placeholder.textContent = `Content hidden as toxic${settings.showScore ? ` (${Math.round(result.probability * 100)}%)` : ""}`;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Show";
|
||||
button.addEventListener("click", () => { restore(element); placeholder?.remove(); });
|
||||
placeholder.append(" ", button);
|
||||
}
|
||||
|
||||
export function restore(element: Element): void {
|
||||
const html = element as HTMLElement;
|
||||
html.style.display = html.dataset.vibeguardOriginalDisplay ?? "";
|
||||
delete html.dataset.vibeguardOriginalDisplay;
|
||||
element.removeAttribute(HIDDEN);
|
||||
element.nextElementSibling?.matches("[data-vibeguard-placeholder]") && element.nextElementSibling.remove();
|
||||
}
|
||||
48
src/content/main.ts
Normal file
48
src/content/main.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { createParser, siteForLocation } from "./parsers";
|
||||
import { applyResult } from "./filter";
|
||||
import { loadSettings } from "../shared/settings";
|
||||
import { hashText } from "../shared/hash";
|
||||
import { priorityFor } from "../inference/queue";
|
||||
import type { InferenceRequest, RuntimeMessage, Settings } from "../shared/types";
|
||||
|
||||
const site = siteForLocation();
|
||||
if (site) void start(site);
|
||||
|
||||
async function start(activeSite: NonNullable<typeof site>): Promise<void> {
|
||||
let settings = await requestSettings();
|
||||
if (!settings.enabledSites[activeSite]) return;
|
||||
const parser = createParser(activeSite);
|
||||
const navigationId = crypto.randomUUID();
|
||||
const seen = new WeakSet<Element>();
|
||||
|
||||
const process = (posts: ReturnType<typeof parser.discover>): void => {
|
||||
posts.forEach((post) => {
|
||||
if (seen.has(post.element)) return;
|
||||
seen.add(post.element);
|
||||
const request: InferenceRequest = {
|
||||
id: post.id, text: post.text, site: activeSite, navigationId,
|
||||
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
|
||||
requestId: crypto.randomUUID()
|
||||
};
|
||||
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>( { type: "INFER", request })
|
||||
.then((response) => { if (response?.result) applyResult(post.element, response.result, settings); });
|
||||
});
|
||||
};
|
||||
|
||||
process(parser.discover(document));
|
||||
const stop = parser.observe(process);
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area === "local" && changes["vibeguard.settings"]?.newValue) settings = await loadSettings();
|
||||
});
|
||||
window.addEventListener("pagehide", () => { stop(); parser.dispose(); });
|
||||
}
|
||||
|
||||
async function requestSettings(): Promise<Settings> {
|
||||
const response = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });
|
||||
return response?.settings ?? loadSettings();
|
||||
}
|
||||
|
||||
function isVisible(element: Element): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.bottom >= 0 && rect.top <= window.innerHeight;
|
||||
}
|
||||
40
src/content/parsers/base.ts
Normal file
40
src/content/parsers/base.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { IPostParser, NormalizedPost, SupportedSite } from "../../shared/types";
|
||||
|
||||
export abstract class SelectorParser implements IPostParser {
|
||||
abstract readonly site: SupportedSite;
|
||||
protected abstract readonly selectors: string[];
|
||||
|
||||
discover(root: Document | Element): NormalizedPost[] {
|
||||
const nodes = root.querySelectorAll(this.selectors.join(","));
|
||||
return Array.from(nodes).flatMap((element, index) => {
|
||||
const text = this.extractText(element);
|
||||
if (!text) return [];
|
||||
const id = this.getId(element, index);
|
||||
element.setAttribute("data-vibeguard-id", id);
|
||||
return [{ id, text, element, site: this.site }];
|
||||
});
|
||||
}
|
||||
|
||||
observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) onPosts(this.discover(node as Element));
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
dispose(): void {}
|
||||
|
||||
protected extractText(element: Element): string {
|
||||
return (element.getAttribute("data-vibeguard-text") ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
private getId(element: Element, index: number): string {
|
||||
const nativeId = element.getAttribute("data-testid") ?? element.getAttribute("data-id") ?? element.id;
|
||||
return `${this.site}:${nativeId || `${index}-${this.extractText(element).slice(0, 32)}`}`;
|
||||
}
|
||||
}
|
||||
6
src/content/parsers/facebook.ts
Normal file
6
src/content/parsers/facebook.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { SelectorParser } from "./base";
|
||||
|
||||
export class FacebookParser extends SelectorParser {
|
||||
readonly site = "facebook" as const;
|
||||
protected readonly selectors = ["div[role='article']"];
|
||||
}
|
||||
17
src/content/parsers/index.ts
Normal file
17
src/content/parsers/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { IPostParser, SupportedSite } from "../../shared/types";
|
||||
import { FacebookParser } from "./facebook";
|
||||
import { RedditParser } from "./reddit";
|
||||
import { TwitterParser } from "./twitter";
|
||||
|
||||
export function siteForLocation(hostname = location.hostname): SupportedSite | undefined {
|
||||
if (hostname === "reddit.com" || hostname.endsWith(".reddit.com")) return "reddit";
|
||||
if (hostname === "x.com" || hostname.endsWith(".x.com") || hostname === "twitter.com" || hostname.endsWith(".twitter.com")) return "twitter";
|
||||
if (hostname === "facebook.com" || hostname.endsWith(".facebook.com")) return "facebook";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createParser(site: SupportedSite): IPostParser {
|
||||
if (site === "reddit") return new RedditParser();
|
||||
if (site === "twitter") return new TwitterParser();
|
||||
return new FacebookParser();
|
||||
}
|
||||
6
src/content/parsers/reddit.ts
Normal file
6
src/content/parsers/reddit.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { SelectorParser } from "./base";
|
||||
|
||||
export class RedditParser extends SelectorParser {
|
||||
readonly site = "reddit" as const;
|
||||
protected readonly selectors = ["shreddit-post", "shreddit-comment", "article[data-testid='post-container']", "div[data-testid='comment']"];
|
||||
}
|
||||
6
src/content/parsers/twitter.ts
Normal file
6
src/content/parsers/twitter.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { SelectorParser } from "./base";
|
||||
|
||||
export class TwitterParser extends SelectorParser {
|
||||
readonly site = "twitter" as const;
|
||||
protected readonly selectors = ["article[data-testid='tweet']"];
|
||||
}
|
||||
Loading…
Reference in a new issue