import {
ScrapeEngineBase,
ScrapeResult,
ScrapedPost,
ScrapeEngineFeature,
ContentType,
} from "../ScrapeEngine.js";
interface Attachment {
url: string; // original URL, ?sensitive=true stripped
previewUrl?: string; // local cached preview (for video posters)
contentType: ContentType;
sensitive: boolean;
}
/**
* Handles Mastodon-compatible instances, including forks such as baraag.net.
*
* On a Mastodon post page, media is rendered as:
*
*
* The anchor href is the original (often federated) file. The
is a
* scaled-down local cache. We read the anchor href, never the img src.
*
* Multi-image posts repeat the same structure; we return one ScrapedPost
* per anchor, matching how Pixiv/Twitter engines behave.
*/
export default class Mastodon extends ScrapeEngineBase {
name = "mastodon";
features: ScrapeEngineFeature[] = ["content", "rating", "source"];
notes = [
"Rating is assumed to be unsafe when source is marked sensitive."
];
// Explicit hosts we claim. Other Mastodon instances are picked up by the
// URL-shape check in canImport().
supportedHosts = ["baraag.net"];
canImport(url: Location): boolean {
if (this.supportedHosts.indexOf(url.host.replace("www.", "")) !== -1) {
return true;
}
// Generic Mastodon status URL shape: /@user/
return /^\/@[^/]+\/\d+\/?$/.test(url.pathname);
}
scrapeDocument(document: Document): ScrapeResult {
const result = new ScrapeResult(this.name);
const attachments = this.findAttachments(document);
this.log(`Found ${attachments.length} attachment(s).`);
for (const att of attachments) {
const post = new ScrapedPost();
post.pageUrl = document.location.href;
post.contentUrl = att.url;
post.contentType = att.contentType;
post.sources = [document.location.href];
post.referrer = document.location.origin;
// Baraag is an adult-focused instance: default to safe,
// and bump to explicit when the source flags the attachment.
post.rating = att.sensitive ? "unsafe" : "safe";
// Cached preview as a poster frame for videos.
if (att.contentType === "video" && att.previewUrl) {
post.extraContentUrl = att.previewUrl;
}
result.tryAddPost(post);
}
if (attachments.length === 0) {
this.log("No media attachments found on this page.");
}
return result;
}
private findAttachments(document: Document): Attachment[] {
const out: Attachment[] = [];
// Primary selector, confirmed against baraag's rendered HTML for both
// single- and multi-image posts.
let anchors = Array.from(
document.querySelectorAll("a.media-gallery__item-thumbnail[href]")
);
// Looser fallback for themes that drop the class.
if (anchors.length === 0) {
anchors = Array.from(
document.querySelectorAll("a[href*='/media_attachments/']")
);
}
for (const a of anchors) {
const rawHref = a.getAttribute("href");
if (!rawHref) continue;
const img = a.querySelector("img");
const preview = img?.currentSrc || img?.src;
const { url, sensitive } = this.normalizeUrl(rawHref);
const finalUrl = url || this.largestFromSrcset(img);
if (!finalUrl) continue;
out.push({
url: finalUrl,
previewUrl: preview && preview !== finalUrl ? preview : undefined,
contentType: this.contentTypeFor(finalUrl),
sensitive,
});
}
return out;
}
/**
* Strip Mastodon's ?sensitive=true marker and report whether it was present.
* The marker is metadata, not part of the media URL, and leaving it in can
* confuse booru deduplication and content-type sniffing.
*/
private normalizeUrl(href: string): { url: string; sensitive: boolean } {
try {
const u = new URL(href, document.location.href);
const sensitive = u.searchParams.get("sensitive") === "true";
u.searchParams.delete("sensitive");
return { url: u.toString(), sensitive };
} catch {
return { url: href, sensitive: false };
}
}
/** Pick the highest-resolution candidate out of an
. */
private largestFromSrcset(img: HTMLImageElement | null): string | undefined {
if (!img) return undefined;
const srcset = img.getAttribute("srcset");
if (!srcset) return img.src || undefined;
let best: { url: string; w: number } | undefined;
for (const part of srcset.split(",")) {
const [urlPart, descriptor] = part.trim().split(/\s+/);
if (!urlPart) continue;
const w = descriptor?.endsWith("w") ? parseInt(descriptor, 10) : 0;
if (!best || w > best.w) best = { url: urlPart, w };
}
return best?.url;
}
private contentTypeFor(url: string): ContentType {
const path = url.split("?")[0].toLowerCase();
if (/\.(mp4|webm|mov|m4v)$/.test(path)) return "video";
return "image";
}
}