您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Download stories (videos and images) from Facebook and Instagram. Hardened selectors, robust detection, visible feedback.
当前为
// ==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); } })();