diff --git a/src/NeoScraper.ts b/src/NeoScraper.ts
index e1dc61c..8a26913 100644
--- a/src/NeoScraper.ts
+++ b/src/NeoScraper.ts
@@ -16,6 +16,7 @@ export default class NeoScraper {
new engines.FurAffinity(),
new engines.Gelbooru(),
new engines.Inkbunny(),
+ new engines.Mastodon(),
new engines.Moebooru(),
new engines.Nozomi(),
new engines.Philomena(),
diff --git a/src/engines/Mastodon.ts b/src/engines/Mastodon.ts
new file mode 100644
index 0000000..d1f4170
--- /dev/null
+++ b/src/engines/Mastodon.ts
@@ -0,0 +1,162 @@
+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 = [
+ "Uses the original attachment URL from the media gallery anchor,",
+ "not the scaled-down preview image.",
+ "Defaults rating to 'safe'; bumps to 'explicit' when the",
+ "attachment is marked sensitive in the source.",
+ ];
+
+ // 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 ? "explicit" : "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";
+ }
+}
\ No newline at end of file
diff --git a/src/engines/index.ts b/src/engines/index.ts
index 6693c94..86e7941 100644
--- a/src/engines/index.ts
+++ b/src/engines/index.ts
@@ -7,6 +7,7 @@ export { default as Fallback } from "./Fallback.js";
export { default as FurAffinity } from "./FurAffinity.js";
export { default as Gelbooru } from "./Gelbooru.js";
export { default as Inkbunny } from "./Inkbunny.js";
+export { default as Mastodon } from "./Mastodon.js";
export { default as Moebooru } from "./Moebooru.js";
export { default as Nozomi } from "./Nozomi.js";
export { default as Philomena } from "./Philomena.js";