Add the media scrape engine, store, and puppet walker
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Media extraction: classify one core message into media items.
|
||||
* Rules: docs/media-scraper.md. Third-party embeds are never downloaded.
|
||||
*/
|
||||
|
||||
import type { CoreAttachment, CoreEmbed, CoreSticker, CoreMessage, MediaKind, MediaMessage } from "./types.js";
|
||||
|
||||
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "webp", "bmp", "avif"]);
|
||||
const GIF_EXT = new Set(["gif"]);
|
||||
const VIDEO_EXT = new Set(["mp4", "webm", "mov", "mkv"]);
|
||||
const AUDIO_EXT = new Set(["mp3", "m4a", "ogg", "wav", "flac"]);
|
||||
|
||||
/** Sticker formatType to file extension. 3 is Lottie JSON; not archived. */
|
||||
const STICKER_EXT: Record<number, string | undefined> = { 1: "png", 2: "apng", 4: "gif" };
|
||||
|
||||
/** A media item extracted from a message. `url` is the original source link. */
|
||||
export interface ExtractedMedia {
|
||||
kind: MediaKind;
|
||||
url: string;
|
||||
provider: string | null;
|
||||
contentType: string | null;
|
||||
size: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
durationSecs: number | null;
|
||||
/** False for every embed. Downloads are a later step, never for third-party embeds. */
|
||||
downloaded: boolean;
|
||||
previewUrl: string | null;
|
||||
}
|
||||
|
||||
function extension(filename: string | null | undefined): string {
|
||||
const match = /\.([a-z0-9]+)$/i.exec(filename ?? "");
|
||||
return match ? match[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
function classifyAttachment(attachment: CoreAttachment, voiceMessage: boolean): MediaKind | null {
|
||||
const ext = extension(attachment.filename);
|
||||
const type = (attachment.contentType ?? "").toLowerCase();
|
||||
|
||||
// A voice-message flag wins unless the file is clearly an image or a video.
|
||||
if (
|
||||
voiceMessage &&
|
||||
!IMAGE_EXT.has(ext) &&
|
||||
!GIF_EXT.has(ext) &&
|
||||
!VIDEO_EXT.has(ext) &&
|
||||
!type.startsWith("image/") &&
|
||||
!type.startsWith("video/")
|
||||
) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
if (AUDIO_EXT.has(ext) || type.startsWith("audio/")) return "audio";
|
||||
if (VIDEO_EXT.has(ext) || type.startsWith("video/")) return "video";
|
||||
if (GIF_EXT.has(ext) || type === "image/gif") return "gif";
|
||||
if (IMAGE_EXT.has(ext) || type.startsWith("image/")) return "image";
|
||||
return null;
|
||||
}
|
||||
|
||||
function hostname(url: string | null): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractEmbed(embed: CoreEmbed): ExtractedMedia | null {
|
||||
const provider = embed.provider ?? hostname(embed.url);
|
||||
const common = {
|
||||
provider,
|
||||
downloaded: false,
|
||||
size: null,
|
||||
width: null,
|
||||
height: null,
|
||||
durationSecs: null
|
||||
};
|
||||
|
||||
if (embed.videoUrl) {
|
||||
return {
|
||||
...common,
|
||||
kind: "video",
|
||||
url: embed.url ?? embed.videoUrl,
|
||||
contentType: null,
|
||||
previewUrl: embed.thumbnailUrl ?? embed.imageUrl
|
||||
};
|
||||
}
|
||||
|
||||
const providerKey = (provider ?? "").toLowerCase();
|
||||
if (embed.type === "gifv" || providerKey.includes("tenor") || providerKey.includes("giphy")) {
|
||||
return {
|
||||
...common,
|
||||
kind: "gif",
|
||||
url: embed.url ?? embed.imageUrl ?? "",
|
||||
contentType: null,
|
||||
previewUrl: embed.thumbnailUrl ?? embed.imageUrl
|
||||
};
|
||||
}
|
||||
|
||||
if (embed.imageUrl) {
|
||||
return {
|
||||
...common,
|
||||
kind: "image",
|
||||
url: embed.url ?? embed.imageUrl,
|
||||
contentType: null,
|
||||
previewUrl: embed.thumbnailUrl ?? embed.imageUrl
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSticker(sticker: CoreSticker): ExtractedMedia | null {
|
||||
if (!sticker.id) return null;
|
||||
const ext = STICKER_EXT[sticker.formatType ?? 1];
|
||||
if (!ext) return null;
|
||||
|
||||
return {
|
||||
kind: ext === "gif" ? "gif" : "image",
|
||||
url: `https://cdn.discordapp.com/stickers/${sticker.id}.${ext}`,
|
||||
provider: "discord",
|
||||
contentType: null,
|
||||
size: null,
|
||||
width: null,
|
||||
height: null,
|
||||
durationSecs: null,
|
||||
downloaded: false,
|
||||
previewUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
function extractFrom(message: MediaMessage): ExtractedMedia[] {
|
||||
const items: ExtractedMedia[] = [];
|
||||
|
||||
for (const attachment of message.attachments ?? []) {
|
||||
if (!attachment.url) continue;
|
||||
const kind = classifyAttachment(attachment, message.voiceMessage);
|
||||
if (!kind) continue;
|
||||
items.push({
|
||||
kind,
|
||||
url: attachment.url,
|
||||
provider: "discord",
|
||||
contentType: attachment.contentType,
|
||||
size: attachment.size,
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
durationSecs: attachment.durationSecs,
|
||||
downloaded: false,
|
||||
previewUrl: null
|
||||
});
|
||||
}
|
||||
|
||||
for (const embed of message.embeds ?? []) {
|
||||
const item = extractEmbed(embed);
|
||||
if (item?.url) items.push(item);
|
||||
}
|
||||
|
||||
for (const sticker of message.stickerItems ?? []) {
|
||||
const item = extractSticker(sticker);
|
||||
if (item) items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Extract every media item from a message, including forwarded snapshots. Deduped by URL. */
|
||||
export function extractMedia(message: CoreMessage): ExtractedMedia[] {
|
||||
const items = extractFrom(message);
|
||||
|
||||
for (const snapshot of message.forwarded ?? []) {
|
||||
items.push(...extractFrom(snapshot));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.url)) return false;
|
||||
seen.add(item.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user