您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
One-click downloader for Facebook & Instagram stories (video & image).
// ==UserScript== // @name Story Downloader - Facebook & Instagram (Overlay + Toast) // @namespace https://github.com/oscar370 // @version 2.2.0 // @description One-click downloader for Facebook & Instagram stories (video & image). // @author oscar370 (original), fd2013 (improvements) // @match https://*.facebook.com/* // @match https://*.instagram.com/* // @run-at document-idle // @grant GM_download // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect *.fbcdn.net // @connect *.cdninstagram.com // @connect *.fna.fbcdn.net // @connect * // @license GPL-3.0 // ==/UserScript== (() => { "use strict"; const isDev = false; const log = (...a) => { if (isDev) console.log("[StoryDL]", ...a); }; // --- Styles: fixed overlay button + toasts --- const CSS = ` #sdx-btn { position: fixed; right: 14px; bottom: 14px; z-index: 999999 !important; display: none; /* only shown on /stories/ */ align-items: center; gap: 8px; padding: 10px 12px; border-radius: 12px; background: rgba(20,20,20,.85); color: #fff; border: 1px solid rgba(255,255,255,.16); backdrop-filter: blur(6px); font: 600 13px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; cursor: pointer; user-select: none; } #sdx-btn:hover { background: rgba(30,30,30,.9); } #sdx-btn svg { display: block; } .sdx-toast { position: fixed; right: 16px; bottom: 60px; max-width: 320px; z-index: 1000000 !important; background: rgba(15,15,15,.95); color: #fff; border: 1px solid rgba(255,255,255,.15); border-radius: 12px; padding: 10px 12px; margin-top: 8px; box-shadow: 0 6px 24px rgba(0,0,0,.4); font: 500 13px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans"; opacity: 0; transform: translateY(6px); transition: opacity .18s ease, transform .18s ease; pointer-events: none; } .sdx-toast.show { opacity: 1; transform: translateY(0); } .sdx-toast.ok { border-left: 4px solid #2ecc71; } .sdx-toast.err { border-left: 4px solid #e74c3c; } `; // --- Utilities --- const isStoriesUrl = () => /\/stories\//i.test(location.pathname); const isFacebook = () => /facebook\.com$/i.test(location.hostname) || /(^|\.)facebook\.com$/i.test(location.hostname); const ensureCSS = () => { if (typeof GM_addStyle === "function") { GM_addStyle(CSS); } else if (!document.getElementById("sdx-style")) { const s = document.createElement("style"); s.id = "sdx-style"; s.textContent = CSS; document.head.appendChild(s); } }; const toast = (msg, type = "ok", timeout = 2200) => { let t = document.createElement("div"); t.className = `sdx-toast ${type}`; t.textContent = msg; document.body.appendChild(t); // force reflow for transition // eslint-disable-next-line no-unused-expressions t.offsetHeight; t.classList.add("show"); setTimeout(() => { t.classList.remove("show"); setTimeout(() => t.remove(), 200); }, timeout); }; const filenameSanitize = (s, fallback = "unknown") => (s || fallback).replace(/[^\w.\-]+/g, "_").slice(0, 80) || fallback; // --- Button lifecycle --- const ensureButton = () => { let btn = document.getElementById("sdx-btn"); if (btn) return btn; btn = document.createElement("button"); btn.id = "sdx-btn"; btn.title = "Download this story (video/image)"; btn.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" aria-hidden="true" viewBox="0 0 16 16"> <path fill="currentColor" 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-2Zm-4 4.5a.5.5 0 0 1 .5.5v5.293l1.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 10.293V5a.5.5 0 0 1 .5-.5Z"/> </svg> <span>Download</span> `; btn.addEventListener("click", onDownloadClick); document.body.appendChild(btn); return btn; }; const showButtonIfStories = () => { const btn = ensureButton(); btn.style.display = isStoriesUrl() ? "inline-flex" : "none"; }; // Observe SPA URL changes (pushState/replaceState/popstate) and DOM churn. const hookHistory = () => { const wrap = (type) => { const orig = history[type]; if (typeof orig === "function") { history[type] = function () { const ret = orig.apply(this, arguments); setTimeout(showButtonIfStories, 0); return ret; }; } }; wrap("pushState"); wrap("replaceState"); window.addEventListener("popstate", showButtonIfStories); }; const observeDom = () => { const mo = new MutationObserver(() => { // Cheap throttle via microtask; only cares about URL mode. showButtonIfStories(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); }; // --- Media detection (robust) --- const findVideoUrl = () => { const vids = Array.from(document.querySelectorAll("video")).filter(v => v.offsetHeight > 0); for (const v of vids) { if (v.currentSrc) return v.currentSrc; if (v.src) return v.src; const s = v.querySelector("source[src]"); if (s?.src) return s.src; // React internals (FB often) const fiberKey = Object.keys(v).find(k => k.startsWith("__reactFiber")); if (fiberKey) { const suffix = fiberKey.replace("__reactFiber", ""); const parent = v.parentElement?.parentElement?.parentElement?.parentElement; const props = parent?.[`__reactProps${suffix}`]; const impl = props?.children?.[0]?.props?.children?.props?.implementations ?? props?.children?.props?.children?.props?.implementations; if (impl && Array.isArray(impl)) { for (const idx of [1, 0, 2]) { const data = impl[idx]?.data; const url = data?.hdSrc || data?.sdSrc || data?.hd_src || data?.sd_src || data?.src; if (url) return url; } } const vd = v[fiberKey]?.return?.stateNode?.props?.videoData?.$1; const url2 = vd?.hd_src || vd?.sd_src || vd?.src; if (url2) return url2; } } return null; }; const findImageUrl = () => { const imgs = Array.from(document.querySelectorAll("img")).filter(img => img.offsetHeight > 0 && (img.naturalWidth >= 400 || img.naturalHeight >= 400) ); // Prefer CDNs used by FB/IG stories const cdn = imgs.find(img => /fbcdn|cdninstagram|scontent|fna\.fbcdn/i.test(img.src)); return (cdn || imgs[0])?.src || null; }; const findCandidateFilename = (isVideo) => { // Try to extract username or visible display name let name = "unknown"; if (isFacebook()) { // Any visible text element at the top may contain the poster's name; keep it simple const span = Array.from(document.querySelectorAll("span")) .find(el => el instanceof HTMLElement && el.offsetWidth > 0 && el.offsetHeight > 0 && el.innerText?.trim()); name = (span?.innerText || "").trim() || name; } else { // IG: anchors or aria labels near header const a = Array.from(document.querySelectorAll("a")) .find(u => u instanceof HTMLAnchorElement && u.offsetHeight > 0 && /\/stories\/|\/[^/]+$/.test(u.getAttribute("href") || "") && (u.innerText || u.getAttribute("title") || u.getAttribute("aria-label"))); name = (a?.innerText || a?.getAttribute("title") || a?.getAttribute("aria-label") || "").trim() || name; } name = filenameSanitize(name); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); return `${name}-${stamp}.${isVideo ? "mp4" : "jpg"}`; }; // --- Download (prefers GM_download, then <a>, then fetch blob) --- const doDownload = async (url, filename) => { if (typeof GM_download === "function") { await new Promise((res, rej) => { try { GM_download({ url, name: filename, onload: () => res(), ontimeout: () => rej(new Error("Download timed out")), onerror: e => rej(new Error(e?.error || "GM_download failed")), }); } catch (e) { rej(e); } }); return; } // Try native link first (often works even when fetch is CORS-blocked) try { const a = document.createElement("a"); a.href = url; a.download = filename; a.rel = "noopener"; a.target = "_blank"; document.body.appendChild(a); a.click(); a.remove(); return; } catch { /* fallthrough */ } // Blob fallback (may fail on strict CORS endpoints) const resp = await fetch(url, { credentials: "include" }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); const obj = URL.createObjectURL(blob); const a2 = document.createElement("a"); a2.href = obj; a2.download = filename; document.body.appendChild(a2); a2.click(); a2.remove(); URL.revokeObjectURL(obj); }; // --- Click handler --- async function onDownloadClick() { try { const v = findVideoUrl(); let url, isVideo = false; if (v) { url = v; isVideo = true; } else { const img = findImageUrl(); if (img) url = img; } if (!url) { toast("No story media found here.", "err"); return; } const fname = findCandidateFilename(isVideo); await doDownload(url, fname); toast(`Saved: ${fname}`, "ok"); } catch (e) { log("Download failed", e); toast(`Download failed: ${e?.message || e}`, "err", 3000); } } // --- Boot --- ensureCSS(); hookHistory(); observeDom(); showButtonIfStories(); // Optional: quick keyboard shortcut (Alt+D) on stories pages document.addEventListener("keydown", (ev) => { if (!isStoriesUrl()) return; if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey && ev.code === "KeyD") { ev.preventDefault(); onDownloadClick(); } }); })();