// ==UserScript==
// @name Story Downloader - Facebook and Instagram (Hardened)
// @namespace https://github.com/oscar370
// @version 2.1.1
// @description Download stories (videos and images) from Facebook and Instagram. Hardened selectors, robust detection, visible feedback.
// @author oscar370 (patched)
// @match *://*.facebook.com/*
// @match *://*.instagram.com/*
// @grant none
// @license GPL3
// ==/UserScript==
(() => {
"use strict";
const POLL_INTERVAL_MS = 250;
const MAX_POLL_ATTEMPTS = 120; // 30s total
const TOAST_ZINDEX = 2147483647;
const isDev = false;
class StoryDownloader {
constructor() {
this.mediaUrl = null;
this.detectedVideo = false;
this._poller = null;
this._toastTimer = null;
this.init();
}
init() {
this.log("init");
this.injectStyles();
this.setupObservers();
this.checkPageStructure();
window.addEventListener("popstate", () => this.checkPageStructure());
}
log(...args) {
if (isDev) console.log("[StoryDownloader]", ...args);
}
// -------------------------
// Page structure detection
// -------------------------
checkPageStructure() {
const onStory = this._isStoryUrl() || this._looksLikeStoryModal();
if (onStory) {
this.startPollingForButton();
} else {
this.removeAllUI();
this.stopPollingForButton();
}
}
_isStoryUrl() {
try {
return /(\/stories\/|\/reels\/|\/stories$|\/stories\/\d+)/i.test(location.href);
} catch (e) {
return false;
}
}
_looksLikeStoryModal() {
try {
// Dialog with media
const dialog = document.querySelector('div[role="dialog"], div[role="presentation"]');
if (dialog) {
if (dialog.querySelector("video, img, [style*='background-image']")) return true;
}
// facebook story pagelets/aria labels
if (document.querySelector('[data-pagelet*="Story"], [aria-label*="Story"]')) return true;
} catch (e) {
/* ignore */
}
return false;
}
// -------------------------
// Styles and UI
// -------------------------
injectStyles() {
if (document.getElementById("sd-styles")) return;
const style = document.createElement("style");
style.id = "sd-styles";
style.textContent = `
#downloadBtn {
border: none;
background: rgba(0,0,0,0.36);
color: white;
cursor: pointer;
z-index: ${TOAST_ZINDEX};
padding: 6px 8px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
backdrop-filter: blur(4px);
}
#sd-floating-btn {
z-index: ${TOAST_ZINDEX};
box-shadow: 0 6px 18px rgba(0,0,0,0.3);
}
#sd-toast {
position: fixed;
right: 12px;
top: 12px;
z-index: ${TOAST_ZINDEX};
background: rgba(0,0,0,0.85);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
max-width: 320px;
word-break: break-word;
}
`;
document.head.appendChild(style);
}
showToast(text, duration = 2500) {
try {
let toast = document.getElementById("sd-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "sd-toast";
document.body.appendChild(toast);
}
toast.textContent = text;
if (this._toastTimer) clearTimeout(this._toastTimer);
if (duration > 0) {
this._toastTimer = setTimeout(() => {
try { toast.remove(); } catch (e) {}
this._toastTimer = null;
}, duration);
}
} catch (e) {
this.log("toast error", e);
}
}
removeAllUI() {
try {
const b = document.getElementById("downloadBtn"); if (b) b.remove();
const f = document.getElementById("sd-floating-btn"); if (f) f.remove();
const t = document.getElementById("sd-toast"); if (t) t.remove();
} catch (e) { /* ignore */ }
}
// -------------------------
// Polling & Button inject
// -------------------------
startPollingForButton() {
if (this._poller) return;
let attempts = 0;
this._poller = setInterval(() => {
attempts++;
// sanity: if page no longer looks like story, stop
if (!this._isStoryUrl() && !this._looksLikeStoryModal()) {
this.stopPollingForButton();
return;
}
if (document.getElementById("downloadBtn") || document.getElementById("sd-floating-btn")) {
// already present
if (attempts > 0) { /* let it exist */ }
return;
}
const created = this._tryCreateButtonAtTopBar() || this._tryCreateButtonInDialog() || null;
if (created) {
this.log("created button", created);
return;
}
if (attempts >= MAX_POLL_ATTEMPTS) {
this.log("fallback: injecting floating button");
this._injectFloatingButton();
// keep polling in case a top-bar becomes available later; don't clear
}
}, POLL_INTERVAL_MS);
}
stopPollingForButton() {
if (this._poller) {
clearInterval(this._poller);
this._poller = null;
}
}
_tryCreateButtonAtTopBar() {
// robust selection list
const selectors = [
'header[role="banner"]',
'[role="toolbar"]',
'[data-testid="story-header"]',
'div[aria-label*="Story"]',
'div[style*="position: absolute"][style*="top"]',
'div[style*="position: fixed"][style*="top"]',
'div[role="navigation"]'
];
for (const sel of selectors) {
const nodes = Array.from(document.querySelectorAll(sel));
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.offsetParent === null) continue; // not visible
// append button if not present
try {
const btn = this._createButtonElement();
node.appendChild(btn);
return btn;
} catch (e) {
this.log("append failed", e);
try {
const btn = this._createButtonElement();
node.insertBefore(btn, node.firstChild);
return btn;
} catch (e2) {
this.log("insertBefore failed", e2);
}
}
}
}
return null;
}
_tryCreateButtonInDialog() {
const dialog = document.querySelector('div[role="dialog"], div[role="presentation"]');
if (!dialog || !(dialog instanceof HTMLElement) || dialog.offsetParent === null) return null;
// try to place in dialog toolbar or top-right
const candidate = dialog.querySelector('[role="toolbar"], header, div[style*="position: absolute"], div[style*="position: fixed"]');
if (candidate && candidate instanceof HTMLElement && candidate.offsetParent !== null) {
const btn = this._createButtonElement();
try {
candidate.appendChild(btn);
} catch (e) {
candidate.insertBefore(btn, candidate.firstChild);
}
return btn;
}
return null;
}
_createButtonElement() {
if (document.getElementById("downloadBtn")) return document.getElementById("downloadBtn");
const btn = document.createElement("button");
btn.id = "downloadBtn";
btn.title = "Download story";
btn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 0a2 2 0 0 0-2 2v6H3.5a.5.5 0 0 0 0 1h2.5v3.5a.5.5 0 0 0 1 0V9h2.5a.5.5 0 0 0 0-1H7V2a2 2 0 0 0-2-2z"/>
</svg>
<span style="font-size:12px;line-height:12px">Save</span>
`;
btn.addEventListener("click", () => this._onClickDownload());
return btn;
}
_injectFloatingButton() {
if (document.getElementById("sd-floating-btn")) return document.getElementById("sd-floating-btn");
const floatBtn = document.createElement("button");
floatBtn.id = "sd-floating-btn";
floatBtn.title = "Download story";
floatBtn.style.position = "fixed";
floatBtn.style.right = "12px";
floatBtn.style.top = "72px";
floatBtn.style.zIndex = `${TOAST_ZINDEX}`;
floatBtn.style.padding = "8px";
floatBtn.style.borderRadius = "8px";
floatBtn.style.background = "rgba(0,0,0,0.6)";
floatBtn.style.color = "white";
floatBtn.style.border = "none";
floatBtn.style.cursor = "pointer";
floatBtn.style.fontSize = "13px";
floatBtn.style.boxShadow = "0 6px 18px rgba(0,0,0,0.3)";
floatBtn.innerText = "⬇ Story";
floatBtn.addEventListener("click", () => this._onClickDownload());
document.body.appendChild(floatBtn);
return floatBtn;
}
// -------------------------
// Download flow
// -------------------------
async _onClickDownload() {
this.showToast("Detecting story...");
try {
await this._detectMedia();
if (!this.mediaUrl) throw new Error("No media detected");
const filename = this._generateFileName();
await this._downloadResource(this.mediaUrl, filename);
this.showToast(`Saved: ${filename}`, 3000);
} catch (err) {
this.log("download error", err);
this.showToast(`Download failed: ${err?.message || "unknown"}`, 4500);
}
}
async _detectMedia() {
this.mediaUrl = null;
this.detectedVideo = false;
// 1) direct video element scanning
const directVideo = this._scanForVideoDirect();
if (directVideo) {
this.mediaUrl = directVideo;
this.detectedVideo = true;
this.log("detected video direct", directVideo);
return;
}
// 2) meta tags (og:video)
const metaV = this._readMeta(['og:video', 'og:video:url', 'og:video:secure_url']);
if (metaV) {
this.mediaUrl = metaV;
this.detectedVideo = true;
this.log("detected video meta", metaV);
return;
}
// 3) direct image scanning
const img = this._scanForImageDirect();
if (img) {
// img might be element or URL string
this.mediaUrl = (typeof img === "string") ? img : img.src || this._getBackgroundUrl(img);
this.detectedVideo = false;
this.log("detected image direct", this.mediaUrl);
return;
}
// 4) meta image (og:image)
const metaImg = this._readMeta(['og:image', 'twitter:image']);
if (metaImg) {
this.mediaUrl = metaImg;
this.detectedVideo = false;
this.log("detected image meta", metaImg);
return;
}
// 5) last resort: React fiber spelunk (fragile)
const react = this._detectVideoViaReact();
if (react) {
this.mediaUrl = react;
this.detectedVideo = true;
this.log("detected video react fallback", react);
return;
}
// nothing found
this.log("no media detected");
}
_scanForVideoDirect() {
try {
// visible video elements
const vids = Array.from(document.querySelectorAll("video")).filter(v => v instanceof HTMLVideoElement && v.offsetParent !== null && v.offsetHeight > 8 && v.offsetWidth > 8);
for (const v of vids) {
const src = v.currentSrc || v.src || (v.querySelector('source') && v.querySelector('source').src);
if (src) return src;
}
// direct <source> tags anywhere
const sources = Array.from(document.querySelectorAll("source")).map(s => s.src).filter(Boolean);
if (sources.length) return sources[0];
} catch (e) {
this.log("video scan error", e);
}
return null;
}
_scanForImageDirect() {
try {
// Prefer visible images with a reasonable natural size
const imgs = Array.from(document.querySelectorAll("img")).filter(img => {
if (!(img instanceof HTMLImageElement)) return false;
if (img.offsetParent === null) return false;
try {
return img.naturalWidth >= 150 && img.naturalHeight >= 150 && img.src && !img.src.startsWith("data:");
} catch (e) {
return false;
}
});
// prefer CDN-looking urls
const cdn = imgs.find(i => /cdn|fbcdn|instagram|akamai|cdninstagram/i.test(i.src));
if (cdn) return cdn;
if (imgs.length) return imgs[0];
// fallback: background-image inline elements
const elems = Array.from(document.querySelectorAll("div, span")).filter(e => {
try { return e.offsetParent !== null && window.getComputedStyle(e).backgroundImage && window.getComputedStyle(e).backgroundImage !== "none"; } catch (e) { return false; }
});
if (elems.length) return elems[0];
} catch (e) {
this.log("image scan error", e);
}
return null;
}
_readMeta(names = []) {
try {
for (const key of names) {
const el = document.querySelector(`meta[property="${key}"], meta[name="${key}"], meta[itemprop="${key}"]`);
if (el && el.content) return el.content;
}
} catch (e) { /* ignore */ }
return null;
}
_getBackgroundUrl(elem) {
try {
const bg = window.getComputedStyle(elem).backgroundImage;
if (!bg || bg === "none") return null;
const m = bg.match(/url\\((?:'|")?(.*?)(?:'|")?\\)/);
return m ? m[1] : null;
} catch (e) {
return null;
}
}
_detectVideoViaReact() {
try {
const videos = Array.from(document.querySelectorAll("video")).filter(v => v instanceof HTMLVideoElement);
for (const video of videos) {
const keys = Object.keys(video).filter(k => k.startsWith("__react") || k.startsWith("_react"));
if (!keys.length) continue;
for (const k of keys) {
try {
const fiber = video[k];
// attempt to find videoData in nearby props
const parent = video.parentElement?.parentElement?.parentElement?.parentElement;
const reactKey = k.replace("__reactFiber", "");
const props = parent?.[`__reactProps${reactKey}`] || parent?.props || fiber?.return?.stateNode?.props || {};
const impl = props?.children?.[0]?.props?.children?.props?.implementations || props?.implementations || props?.videoData || {};
if (Array.isArray(impl)) {
for (const c of impl) {
const data = c?.data;
const url = data?.hdSrc || data?.sdSrc || data?.hd_src || data?.sd_src || data?.url;
if (url) return url;
}
} else {
const url = impl?.hdSrc || impl?.sdSrc || impl?.hd_src || impl?.sd_src || impl?.url;
if (url) return url;
}
const videoData = fiber?.return?.stateNode?.props?.videoData || {};
const alt = videoData?.$1 || videoData;
if (alt) {
return alt.hd_src || alt.sd_src || alt.url || null;
}
} catch (e) {
this.log("react fiber inner error", e);
}
}
}
} catch (e) {
this.log("react fallback error", e);
}
return null;
}
// -------------------------
// Filename & sanitize
// -------------------------
_generateFileName() {
const date = new Date().toISOString().split("T")[0];
let name = this._extractUserName() || (location.hostname.includes("facebook") ? "facebook-user" : "instagram-user");
// sanitize to safe filename
name = String(name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 64) || "user";
const ext = this.detectedVideo ? "mp4" : "jpg";
return `${name}-${date}.${ext}`;
}
_extractUserName() {
try {
// og:title often includes the user/page name
const og = document.querySelector('meta[property="og:title"]')?.content;
if (og) return og.split(" - ")[0].split("|")[0].trim();
const canonical = document.querySelector('link[rel="canonical"]')?.href;
if (canonical) {
const parts = new URL(canonical).pathname.split("/").filter(Boolean);
if (parts.length) return parts[parts.length - 1];
}
const usernameAnchor = Array.from(document.querySelectorAll('a[href*="/stories/"], a[href*="/"]')).find(a => {
try {
const h = a.getAttribute("href");
return h && !h.includes("http") && h.split("/").length <= 3 && h !== "/";
} catch (e) { return false; }
});
if (usernameAnchor) return usernameAnchor.pathname.replace(/\//g, "");
// fallback: short text candidate
const texts = Array.from(document.querySelectorAll("h1,h2,span,strong")).map(n => n.textContent?.trim()).filter(Boolean);
if (texts.length) {
const short = texts.sort((a, b) => a.length - b.length)[0];
if (short && short.length < 64) return short;
}
} catch (e) { /* ignore */ }
return null;
}
// -------------------------
// Download helper
// -------------------------
async _downloadResource(url, filename) {
if (!url) throw new Error("No url");
// blob or data url: download directly
try {
if (url.startsWith("blob:") || url.startsWith("data:")) {
this._triggerAnchorDownload(url, filename);
return;
}
} catch (e) { /* ignore */ }
// Try fetch first (may fail with CORS)
try {
const resp = await fetch(url, { credentials: "include" });
if (!resp.ok) {
throw new Error(`Network error: ${resp.status}`);
}
const blob = await resp.blob();
const objectUrl = URL.createObjectURL(blob);
try {
this._triggerAnchorDownload(objectUrl, filename);
} finally {
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000);
}
return;
} catch (err) {
// fetch could fail due to CORS or other network reasons. fallback to opening the url in a new tab.
this.log("fetch failed, fallback to open", err);
try {
// open in new tab so user can right-click -> save as
window.open(url, "_blank");
this.showToast("Opened media in new tab. Right-click > Save as...", 4500);
return;
} catch (e) {
throw new Error("Unable to fetch or open the resource.");
}
}
}
_triggerAnchorDownload(href, filename) {
const a = document.createElement("a");
a.href = href;
a.download = filename || "";
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
try { a.remove(); } catch (e) {}
}, 50);
}
}
// initialize
try {
new StoryDownloader();
} catch (e) {
if (typeof console !== "undefined") console.error("StoryDownloader init failed", e);
}
})();