This commit is contained in:
Matěj Kubíček
2026-09-02 20:38:45 +02:00
parent 0c524ed6af
commit c7948a788a
46 changed files with 10099 additions and 152 deletions
+154
View File
@@ -0,0 +1,154 @@
import type {
ImageCandidate,
PageCandidate,
PageDisclosureCandidate,
TextCandidate
} from '../shared/types';
import { discoverHeuristics } from './heuristics';
const IMAGE_ID_ATTRIBUTE = 'data-slopdetect-image-id';
const MAX_TEXT_BLOCKS = 8;
const MIN_TEXT_CHARACTERS = 300;
function isVisible(element: Element): boolean {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
}
function makeImageCandidate(element: Element, url: string, alt = ''): ImageCandidate | undefined {
if (!url || !isVisible(element)) return undefined;
const rect = element.getBoundingClientRect();
if (rect.width < 16 || rect.height < 16) return undefined;
const existingId = element.getAttribute(IMAGE_ID_ATTRIBUTE);
const id = existingId || crypto.randomUUID();
element.setAttribute(IMAGE_ID_ATTRIBUTE, id);
return {
id,
url: new URL(url, document.baseURI).href,
alt: alt.trim().slice(0, 200),
width: Math.round(rect.width),
height: Math.round(rect.height)
};
}
function discoverImages(): ImageCandidate[] {
const candidates: ImageCandidate[] = [];
const seenUrls = new Set<string>();
for (const image of document.querySelectorAll('img')) {
const candidate = makeImageCandidate(image, image.currentSrc || image.src, image.alt);
if (candidate && !seenUrls.has(candidate.url)) {
candidates.push(candidate);
seenUrls.add(candidate.url);
}
}
for (const image of document.querySelectorAll('svg image')) {
const href = image.getAttribute('href') || image.getAttribute('xlink:href') || '';
const candidate = makeImageCandidate(image, href, image.getAttribute('aria-label') || 'SVG image');
if (candidate && !seenUrls.has(candidate.url)) {
candidates.push(candidate);
seenUrls.add(candidate.url);
}
}
const elements = [...document.querySelectorAll('body *')].slice(0, 2000);
for (const element of elements) {
const background = window.getComputedStyle(element).backgroundImage;
const match = /^url\(["']?(.*?)["']?\)$/.exec(background);
if (!match?.[1]) continue;
const candidate = makeImageCandidate(element, match[1], element.getAttribute('aria-label') || 'Background image');
if (candidate && !seenUrls.has(candidate.url)) {
candidates.push(candidate);
seenUrls.add(candidate.url);
}
}
return candidates;
}
export function estimateTokens(text: string): number {
return text.trim() ? Math.ceil(text.trim().split(/\s+/).length * 1.3) : 0;
}
function discoverText(): TextCandidate[] {
const selectors = 'article p, main p, [role="main"] p, article li, main li, blockquote';
const values = [...document.querySelectorAll<HTMLElement>(selectors)]
.filter(isVisible)
.map((element) => element.innerText.trim())
.filter((text) => text.length >= MIN_TEXT_CHARACTERS);
const unique = [...new Set(values)].sort((left, right) => right.length - left.length).slice(0, MAX_TEXT_BLOCKS);
if (unique.length === 0) {
const root = document.querySelector<HTMLElement>('article, main, [role="main"]') || document.body;
const combined = root?.innerText.trim().slice(0, 24_000) || '';
if (combined.length >= MIN_TEXT_CHARACTERS) unique.push(combined);
}
return unique.map((text) => ({ id: crypto.randomUUID(), text, tokenEstimate: estimateTokens(text) }));
}
function pushDisclosure(
output: PageDisclosureCandidate[],
source: string,
value: string | null | undefined
): void {
const trimmed = value?.trim();
if (trimmed) output.push({ source, value: trimmed.slice(0, 2000) });
}
function discoverDisclosures(): PageDisclosureCandidate[] {
const candidates: PageDisclosureCandidate[] = [];
const interestingMeta = /ai|artificial|synthetic|provenance|credential|generator|content-origin/i;
for (const meta of document.querySelectorAll<HTMLMetaElement>('meta[name], meta[property]')) {
const name = meta.name || meta.getAttribute('property') || '';
if (interestingMeta.test(name)) {
pushDisclosure(candidates, `meta:${name}`, meta.content);
}
}
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel]')) {
if (/credential|provenance|c2pa|manifest/i.test(link.rel)) {
pushDisclosure(candidates, `link:${link.rel}`, link.href);
}
}
for (const script of document.querySelectorAll<HTMLScriptElement>('script[type="application/ld+json"]')) {
if (/"(?:aiGenerated|isAIGenerated|contentOrigin|digitalSourceType)"\s*:\s*(?:true|"[^"]*(?:trainedAlgorithmicMedia|synthetic|AI)[^"]*")/i.test(script.textContent || '')) {
pushDisclosure(candidates, 'structured-data', script.textContent);
}
}
const labelSelectors = 'figcaption, small, [class*="disclosure" i], [class*="provenance" i], [class*="credit" i], [aria-label*="AI-generated" i]';
for (const element of document.querySelectorAll<HTMLElement>(labelSelectors)) {
const value = (element.innerText || element.getAttribute('aria-label') || '').trim();
if (value.length <= 300 && /\b(?:AI[- ]generated|generated by AI|artificially generated|created with AI|synthetic media)\b/i.test(value)) {
pushDisclosure(candidates, 'visible-label', value);
}
}
for (const element of document.querySelectorAll<HTMLElement>('[data-ai-generated], [data-content-origin]')) {
pushDisclosure(
candidates,
element.hasAttribute('data-ai-generated') ? 'data-ai-generated' : 'data-content-origin',
element.getAttribute('data-ai-generated') || element.getAttribute('data-content-origin')
);
}
return candidates;
}
export function discoverPage(): PageCandidate {
return {
url: location.href,
title: document.title,
images: discoverImages(),
texts: discoverText(),
disclosures: discoverDisclosures(),
heuristics: discoverHeuristics()
};
}
export { IMAGE_ID_ATTRIBUTE };