155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
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 };
|