Download stories (videos and images) from Facebook and Instagram.
// ==UserScript==
// @name Story Downloader - Facebook and Instagram
// @namespace https://github.com/oscar370
// @version 2.2.1
// @description Download stories (videos and images) from Facebook and Instagram.
// @author oscar370
// @match *://*.facebook.com/*
// @match *://*.instagram.com/*
// @grant none
// @license GPL3
// ==/UserScript==
"use strict";
(() => {
var __defProp = Object.defineProperty;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsEnum = Object.prototype.propertyIsEnumerable;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __spreadValues = (a, b) => {
for (var prop in b || (b = {}))
if (__hasOwnProp.call(b, prop))
__defNormalProp(a, prop, b[prop]);
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(b)) {
if (__propIsEnum.call(b, prop))
__defNormalProp(a, prop, b[prop]);
}
return a;
};
// src/constants.ts
var MAX_ATTEMPTS = 10;
var SELECTORS = {
facebook: {
topBar: "div.xtotuo0",
userName: "span.xuxw1ft.xlyipyv"
},
instagram: {
topBar: "div.x1xmf6yo",
userName: ".x1i10hfl"
}
};
// src/utils.ts
function $(selector, scope = document) {
return scope.querySelector(selector);
}
function $$(selector, scope = document) {
return [...scope.querySelectorAll(selector)];
}
function create(tag, attrs = {}) {
const el = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
el[k] = v;
});
return el;
}
function remove(el) {
el.remove();
}
function append(p, c) {
p.append(c);
}
function css(el, styles) {
Object.assign(el.style, styles);
}
function on(el, ev, fn, opts) {
el.addEventListener(ev, fn, opts);
}
function html(el, value) {
if (value === void 0) return el.innerHTML;
el.innerHTML = value;
}
function text(el, value) {
if (value === void 0) return el.textContent;
el.textContent = value;
}
// src/helpers.ts
var isDev = false;
function log(...args) {
if (isDev) console.log("[StoryDownloader]", ...args);
}
function isMobile() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
const isMobileUA = /android|iphone|kindle|ipad|playbook|silk/i.test(
userAgent
);
return isMobileUA;
}
function isFacebookPage() {
return /(facebook)/.test(window.location.href);
}
function getPlatformConfig() {
const platform = isFacebookPage() ? "facebook" : "instagram";
return SELECTORS[platform];
}
function getFbMobileProfileNodes() {
const nameSpans = $$(
"div[data-mcomponent='ServerTextArea'] span"
);
const nameSpan = nameSpans.find(
(el) => el.offsetHeight > 0 && el.closest("[role='button']")
);
if (!nameSpan) return null;
return { nameSpan };
}
// src/store.ts
var state = {
mediaUrl: null,
detectedVideo: null,
observerTimeout: null,
lastUrl: "",
isPolling: false
};
function getState() {
return __spreadValues({}, state);
}
function setState(partial) {
const newState = __spreadValues(__spreadValues({}, getState()), partial);
state = newState;
}
// src/downloader.ts
async function detectMedia() {
const video = findVideo();
const image = findImage();
if (video) {
setState({ mediaUrl: video, detectedVideo: true });
} else if (image) {
setState({ mediaUrl: image.src, detectedVideo: false });
}
log("Media URL detected:", getState().mediaUrl);
}
function findVideo() {
const videos = $$("video").filter(
(v) => v.offsetHeight > 0
);
log("Video elements: ", videos);
for (const video of videos) {
const url = searchVideoSource(video);
if (url) {
return url;
}
}
return null;
}
function searchVideoSource(video) {
if (!video.currentSrc.startsWith("blob")) {
return video.currentSrc;
}
const rootElement = findReactRoot();
if (!rootElement) return null;
const rootFiberKey = Object.keys(rootElement).find(
(key) => key.startsWith("__reactContainer") || key.startsWith("__reactFiber")
);
if (!rootFiberKey) return null;
const rootFiber = rootElement[rootFiberKey];
const videoFiber = findFiberForElement(rootFiber, video);
if (!videoFiber) return null;
let fiber = videoFiber.return;
while (fiber) {
const props = fiber.memoizedProps || fiber.pendingProps;
if (props) {
const url = findVideoUrlInProps(props);
if (url) return url;
}
fiber = fiber.return;
}
return null;
}
function findReactRoot() {
const bodyElements = document.querySelectorAll("body *");
for (const el of bodyElements) {
if (Object.keys(el).some((key) => key.startsWith("__reactContainer"))) {
return el;
}
}
const allElements = document.querySelectorAll("*");
for (const el of allElements) {
if (Object.keys(el).some((key) => key.startsWith("__reactContainer"))) {
return el;
}
}
return null;
}
function findFiberForElement(rootFiber, target) {
let found = null;
function dfs(fiber) {
if (!fiber || found) return;
if (fiber.stateNode === target) {
found = fiber;
return;
}
if (fiber.child) dfs(fiber.child);
if (fiber.sibling) dfs(fiber.sibling);
}
dfs(rootFiber);
return found;
}
function findVideoUrlInProps(obj, visited = /* @__PURE__ */ new Set()) {
if (!obj || typeof obj !== "object" || visited.has(obj)) return null;
visited.add(obj);
const urlProps = [
"hd_src",
"sd_src",
"hdSrc",
"sdSrc",
"playable_url",
"browser_native_hd_url",
"browser_native_sd_url",
"progressive_url",
"src",
"url",
"videoUrl"
];
for (const prop of urlProps) {
try {
const value = obj[prop];
if (typeof value === "string" && value.startsWith("http")) {
return value;
}
} catch (e) {
}
}
for (const key in obj) {
try {
const value = obj[key];
if (value && typeof value === "object") {
const result = findVideoUrlInProps(value, visited);
if (result) return result;
}
} catch (e) {
}
}
return null;
}
function findImage() {
const images = $$("img").filter(
(img) => img.offsetHeight > 0 && img.src.includes("cdn")
);
return images.find((img) => img.height > 400) || null;
}
function generateFileName() {
var _a;
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
const config = getPlatformConfig();
const isFb = isFacebookPage();
const detectedVideo = getState().detectedVideo;
let userName = "unknown";
const user = $$(config.userName).find((e) => {
if (!(e instanceof HTMLElement)) return false;
if (isFb && !isMobile()) {
return e.offsetWidth > 0;
}
return e.offsetHeight > 0 && e.offsetHeight < 35;
});
if (user) {
log(`Element with the username:`);
log(user);
if (isFb) {
userName = text(user) || userName;
} else {
userName = user.pathname.replace(/\//g, "") || userName;
}
} else if (isMobile()) {
if (isFb) {
const nameSpan = (_a = getFbMobileProfileNodes()) == null ? void 0 : _a.nameSpan;
log(`Element with the username:`);
log(nameSpan);
if (nameSpan) {
userName = nameSpan.textContent;
}
}
}
const extension = detectedVideo ? "mp4" : "jpg";
return `${userName}-${timestamp}.${extension}`;
}
async function downloadMedia(url, filename) {
try {
const response = await fetch(url);
const blob = await response.blob();
const link = create("a", {
href: URL.createObjectURL(blob),
download: filename
});
append(document.body, link);
link.click();
remove(link);
URL.revokeObjectURL(link.href);
} catch (error) {
console.error("Download error:", error);
}
}
// src/dom.ts
var DOWNLOAD_ICON = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"
class="bi bi-file-arrow-down-fill" viewBox="0 0 16 16">
<path d="M12 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2
M8 5a.5.5 0 0 1 .5.5v3.793l1.146-1.147a.5.5 0 0 1
.708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 1 1
.708-.708L7.5 9.293V5.5A.5.5 0 0 1 8 5"/>
</svg>
`;
var BUTTON_STYLES = {
position: "relative",
border: "none",
background: "transparent",
color: "white",
cursor: "pointer",
zIndex: "9999"
};
var CONTAINER_MOBILE_STYLES = {
position: "absolute",
bottom: "70px",
right: "20px"
};
function createButtonWithPolling() {
const isPolling = getState().isPolling;
if (isPolling) return;
setState({ isPolling: true });
let attempts = 0;
const poll = () => {
const existingBtn = $("#downloadBtn");
if (existingBtn) {
setState({ isPolling: false });
return;
}
const createdBtn = createButton();
if (createdBtn || attempts >= MAX_ATTEMPTS) {
setState({ isPolling: false });
return;
}
attempts++;
setTimeout(poll, 500);
};
poll();
}
function createButton() {
if (isFacebookPage() && isMobile()) {
const container = create("div");
css(container, CONTAINER_MOBILE_STYLES);
const btn2 = create("button", { id: "downloadBtn" });
html(btn2, DOWNLOAD_ICON);
css(btn2, BUTTON_STYLES);
on(btn2, "click", () => handleDownload());
append(container, btn2);
append(document.body, container);
return btn2;
}
const config = getPlatformConfig();
const topBars = $$(config.topBar);
const topBar = topBars.find(
(bar) => bar instanceof HTMLElement && bar.offsetHeight > 0
);
if (!topBar) {
log("No suitable top bar found");
return null;
}
const btn = create("button", { id: "downloadBtn" });
html(btn, DOWNLOAD_ICON);
css(btn, BUTTON_STYLES);
on(btn, "click", () => handleDownload());
append(topBar, btn);
log("Download button added", btn);
return btn;
}
async function handleDownload() {
try {
await detectMedia();
const mediaUrl = getState().mediaUrl;
if (!mediaUrl) throw new Error("No multimedia content was found");
const filename = generateFileName();
await downloadMedia(mediaUrl, filename);
} catch (error) {
log("Download failed:", error);
}
}
// src/main.ts
log("Initializing observer...");
setupMutationObserver();
function setupMutationObserver() {
const observer = new MutationObserver((mutations) => {
const hasRelevantChanges = mutations.some(
(m) => m.addedNodes.length > 0 || m.removedNodes.length > 0
);
if (!hasRelevantChanges) return;
const observerTimeout = getState().observerTimeout;
if (observerTimeout) clearTimeout(observerTimeout);
const timeout = setTimeout(() => {
checkPageStructure();
}, 300);
setState({ observerTimeout: timeout });
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
function checkPageStructure() {
const lastUrl = getState().lastUrl;
const currentUrl = window.location.href;
const isStoryPage = /(\/stories\/)/.test(currentUrl);
const btn = $("#downloadBtn");
if (!isStoryPage) {
if (btn) remove(btn);
return;
}
if (currentUrl !== lastUrl || !btn) {
setState({ lastUrl: currentUrl });
createButtonWithPolling();
}
}
})();