您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Download stories (videos/images) from facebook.com and instagram.com (desktop). Small icon fallback top-right if UI insertion fails. Dev logs ON for testing.
// ==UserScript== // @name Story Downloader - Facebook & Instagram (Desktop — Icon Fallback) // @namespace https://github.com/oscar370 // @version 2.2.0 // @description Download stories (videos/images) from facebook.com and instagram.com (desktop). Small icon fallback top-right if UI insertion fails. Dev logs ON for testing. // @author patched // @match *://*.facebook.com/* // @match *://*.instagram.com/* // @grant none // @license GPL3 // ==/UserScript== (() => { "use strict"; // CONFIG const POLL_INTERVAL_MS = 300; const MAX_POLL_ATTEMPTS = 120; // ~36s before guaranteed fallback shown (but fallback will also show earlier if needed) const TOAST_ZINDEX = 2147483647; const isDev = true; // set to false after testing class StoryDownloader { constructor() { this.mediaUrl = null; this.detectedVideo = false; this._poller = null; this._toastTimer = null; this._attempts = 0; this.init(); } init() { this.log("init"); this.injectStyles(); this.setupObservers(); this.checkPageStructure(); // run initial check window.addEventListener("popstate", () => this.checkPageStructure()); } log(...args) { if (isDev) console.log("[SD]", ...args); } // ------------------------- // Page checks // ------------------------- checkPageStructure() { const onStory = this._isStoryUrl() || this._looksLikeStoryModal(); this.log("checkPageStructure - onStory:", onStory); 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 { const dialog = document.querySelector('div[role="dialog"], [role="presentation"]'); if (dialog && dialog.querySelector("video, img, [style*='background-image']")) return true; // FB-specific hints if (document.querySelector('[data-pagelet*="Story"], [aria-label*="Story"]')) return true; } catch (e) { /* ignore */ } return false; } // ------------------------- // Styles & small icon button // ------------------------- injectStyles() { if (document.getElementById("sd-styles")) return; const style = document.createElement("style"); style.id = "sd-styles"; style.textContent = ` #sd-download-icon { position: fixed; top: 10px; right: 10px; width: 40px; height: 40px; background: rgba(0,0,0,0.6); color: white; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; z-index: ${TOAST_ZINDEX}; cursor: pointer; box-shadow: 0 6px 18px rgba(0,0,0,0.35); transition: transform .12s ease; } #sd-download-icon:hover { transform: translateY(-2px); } #downloadBtn { border: none; background: transparent; color: inherit; cursor: pointer; } #sd-toast { position: fixed; right: 12px; top: 60px; z-index: ${TOAST_ZINDEX}; background: rgba(0,0,0,0.85); color: #fff; padding: 8px 12px; border-radius: 6px; font-size: 13px; max-width: 360px; 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-download-icon"); if (f) f.remove(); const t = document.getElementById("sd-toast"); if (t) t.remove(); } catch (e) { /* ignore */ } } // ------------------------- // Poll for UI & insert button // ------------------------- startPollingForButton() { if (this._poller) return; this._attempts = 0; this._poller = setInterval(() => { this._attempts++; this.log("poll attempt", this._attempts); // if story no longer present stop if (!this._isStoryUrl() && !this._looksLikeStoryModal()) { this.stopPollingForButton(); return; } // if already injected, skip if (document.getElementById("downloadBtn") || document.getElementById("sd-download-icon")) { // keep it visible if present if (this._attempts === 1) this.log("button already present"); return; } // try insertion targets const tryInsert = this._insertIntoTopBar() || this._insertIntoDialog() || null; if (tryInsert) { this.log("Inserted into page UI", tryInsert); return; } // after some attempts, ensure fallback icon exists if (this._attempts >= 6) { this.log("creating guaranteed fallback icon"); this._createFallbackIcon(); // idempotent } // if beyond max attempts, keep fallback, but keep polling in case topbar appears. if (this._attempts >= MAX_POLL_ATTEMPTS) { this.log("max attempts reached — keeping fallback icon"); } }, POLL_INTERVAL_MS); } stopPollingForButton() { if (this._poller) { clearInterval(this._poller); this._poller = null; } } _createFallbackIcon() { if (document.getElementById("sd-download-icon")) return document.getElementById("sd-download-icon"); const icon = document.createElement("div"); icon.id = "sd-download-icon"; icon.title = "Download story"; icon.innerHTML = ` <button id="downloadBtn" aria-label="Download story" style="all:unset;display:inline-flex;align-items:center;justify-content:center;"> <svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <path d="M.5 9.9v3.6A1 1 0 0 0 1.5 15h13a1 1 0 0 0 1-1.5V9.9a.5.5 0 0 0-1 0v3.6a.001.001 0 0 1-.001.001H1.5a.001.001 0 0 1-.001-.001V9.9a.5.5 0 0 0-1 0zM8 0a.5.5 0 0 0-.5.5V9H5.354a.5.5 0 0 0-.354.854l2.647 2.646a.5.5 0 0 0 .707 0L10.999 9.854a.5.5 0 0 0-.354-.854H8V.5A.5.5 0 0 0 8 0z"/> </svg> </button> `; // click handled by the inner button icon.querySelector("#downloadBtn").addEventListener("click", () => this._onClickDownload()); document.body.appendChild(icon); return icon; } _insertIntoTopBar() { const selectors = [ 'header[role="banner"]', '[role="toolbar"]', '[data-testid="story-header"]', 'div[aria-label*="Story"]', 'div[style*="position: sticky"][style*="top"]', 'div[style*="position: fixed"][style*="top"]' ]; for (const sel of selectors) { try { const nodes = Array.from(document.querySelectorAll(sel)).filter(n => n instanceof HTMLElement && n.offsetParent !== null); if (!nodes.length) continue; for (const node of nodes) { if (node.querySelector("#downloadBtn")) return node.querySelector("#downloadBtn"); try { const btn = this._createPageButton(); node.appendChild(btn); return btn; } catch (e) { this.log("append failed for selector", sel, e); try { const btn = this._createPageButton(); node.insertBefore(btn, node.firstChild); return btn; } catch (e2) { this.log("insertBefore failed", e2); continue; } } } } catch (e) { this.log("selector error", sel, e); } } return null; } _insertIntoDialog() { const dialog = document.querySelector('div[role="dialog"], [role="presentation"]'); if (!dialog || !(dialog instanceof HTMLElement) || dialog.offsetParent === null) return null; // try to find a top-right or toolbar inside the dialog const candidate = dialog.querySelector('[role="toolbar"], header, div[style*="position: absolute"], div[style*="position: fixed"]'); if (candidate && candidate instanceof HTMLElement && candidate.offsetParent !== null) { if (candidate.querySelector("#downloadBtn")) return candidate.querySelector("#downloadBtn"); try { const btn = this._createPageButton(); candidate.appendChild(btn); return btn; } catch (e) { this.log("dialog append failed", e); try { const btn = this._createPageButton(); candidate.insertBefore(btn, candidate.firstChild); return btn; } catch (e2) { this.log("dialog insert failed", e2); } } } return null; } _createPageButton() { if (document.getElementById("downloadBtn")) return document.getElementById("downloadBtn"); const btn = document.createElement("button"); btn.id = "downloadBtn"; btn.title = "Download story"; btn.style.all = "unset"; btn.style.cursor = "pointer"; btn.style.display = "inline-flex"; btn.style.alignItems = "center"; btn.style.gap = "6px"; btn.innerHTML = ` <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <path d="M8 0a.5.5 0 0 0-.5.5V8H5.354a.5.5 0 0 0-.354.854l2.647 2.646a.5.5 0 0 0 .707 0L10.999 8.854A.5.5 0 0 0 10.646 8H8V.5A.5.5 0 0 0 8 0z"/> </svg> `; btn.addEventListener("click", () => this._onClickDownload()); return btn; } // ------------------------- // 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}`, 3200); } 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 elements const v = this._scanForVideoDirect(); if (v) { this.mediaUrl = v; this.detectedVideo = true; this.log("video direct", v); return; } // 2) meta 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("video meta", metaV); return; } // 3) images const imgElemOrUrl = this._scanForImageDirect(); if (imgElemOrUrl) { this.mediaUrl = typeof imgElemOrUrl === "string" ? imgElemOrUrl : (imgElemOrUrl.src || this._getBackgroundUrl(imgElemOrUrl)); this.detectedVideo = false; this.log("image direct", this.mediaUrl); return; } // 4) meta image const metaImg = this._readMeta(['og:image', 'twitter:image']); if (metaImg) { this.mediaUrl = metaImg; this.detectedVideo = false; this.log("image meta", metaImg); return; } // 5) React internal fallback const react = this._detectVideoViaReact(); if (react) { this.mediaUrl = react; this.detectedVideo = true; this.log("react fallback", react); return; } this.log("no media after all strategies"); } _scanForVideoDirect() { try { 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; } 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 { const imgs = Array.from(document.querySelectorAll("img")).filter(img => img instanceof HTMLImageElement && img.offsetParent !== null && img.naturalWidth >= 120 && img.naturalHeight >= 120 && img.src && !img.src.startsWith("data:")); if (imgs.length) { const cdn = imgs.find(i => /cdn|fbcdn|instagram|akama|akamai|cdninstagram/i.test(i.src)); return cdn || imgs[0]; } 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(el) { try { if (!el) return null; const bg = window.getComputedStyle(el).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]; 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 url = c?.data?.hdSrc || c?.data?.sdSrc || c?.data?.hd_src || c?.data?.sd_src || c?.data?.url || c?.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 inner error", e); } } } } catch (e) { this.log("react fallback error", e); } return null; } // ------------------------- // File naming & sanitize // ------------------------- _generateFileName() { const date = new Date().toISOString().split("T")[0]; let name = this._extractUserName() || (location.hostname.includes("facebook") ? "facebook-user" : "instagram-user"); 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 { 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 anchor = 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 (anchor) return anchor.pathname.replace(/\//g, ""); const texts = Array.from(document.querySelectorAll("h1,h2,span,strong")).map(n => n.textContent?.trim()).filter(Boolean); if (texts.length) return texts.sort((a,b)=>a.length-b.length)[0]; } catch (e) { /* ignore */ } return null; } // ------------------------- // Download helper // ------------------------- async _downloadResource(url, filename) { if (!url) throw new Error("No url"); try { if (url.startsWith("blob:") || url.startsWith("data:")) { this._triggerAnchorDownload(url, filename); return; } } catch (e) {} 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) { this.log("fetch failed; opening in new tab as fallback", err); try { 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) {} }, 80); } } // start try { new StoryDownloader(); console.log("[SD] StoryDownloader injected (dev logs ON)."); } catch (e) { console.error("[SD] init failed", e); } })();