Greasy Fork

来自缓存

Greasy Fork is available in English.

ABDM Download Capture

Capture downloadable links on web pages and send them to AB Download Manager.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         ABDM Download Capture
// @namespace    local.abdm.tampermonkey
// @version      0.1.0
// @description  Capture downloadable links on web pages and send them to AB Download Manager.
// @description:zh-CN  捕获网页中的下载链接,并发送到 AB Download Manager。
// @author       local
// @license      MIT
// @match        http://*/*
// @match        https://*/*
// @exclude      http://127.0.0.1/*
// @exclude      http://localhost/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      127.0.0.1
// @connect      localhost
// ==/UserScript==

(function () {
  "use strict";

  const DEFAULT_EXTENSIONS = [
    "zip", "rar", "7z", "iso", "tar", "gz", "tgz", "bz2", "xz",
    "dmg", "pkg", "app", "exe", "msi", "deb", "rpm", "apk", "ipa",
    "bin", "jar", "war", "cab",
    "pdf", "epub", "mobi", "azw3",
    "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv",
    "mp3", "aac", "m4a", "flac", "wav", "ogg", "opus",
    "mp4", "m4v", "mov", "mkv", "avi", "wmv", "webm", "mpeg", "mpg",
    "srt", "ass", "vtt", "torrent"
  ];

  const DEFAULT_CONFIG = {
    port: 15151,
    enabled: true,
    captureDownloadAttribute: true,
    captureKnownExtensions: true,
    captureMediaLinks: true,
    captureAllSameOriginBlobLinks: false,
    allowBrowserFallback: true,
    silentAdd: false,
    silentStart: false,
    extensions: DEFAULT_EXTENSIONS.join(" "),
    blacklist: ""
  };

  const RECENT_CAPTURE_TTL = 5000;
  const recentCaptures = new Map();

  function getConfig() {
    const config = {};
    Object.keys(DEFAULT_CONFIG).forEach((key) => {
      config[key] = GM_getValue(key, DEFAULT_CONFIG[key]);
    });
    config.port = clampPort(config.port);
    config.extensions = normalizeWords(config.extensions).map((item) => item.replace(/^\./, "").toLowerCase());
    config.blacklist = normalizeLines(config.blacklist);
    return config;
  }

  function setConfig(nextConfig) {
    Object.keys(DEFAULT_CONFIG).forEach((key) => {
      if (Object.prototype.hasOwnProperty.call(nextConfig, key)) {
        GM_setValue(key, nextConfig[key]);
      }
    });
  }

  function clampPort(value) {
    const port = Number(value) || DEFAULT_CONFIG.port;
    return Math.max(1024, Math.min(65535, port));
  }

  function normalizeWords(value) {
    return String(value || "")
      .split(/[\s,;]+/)
      .map((item) => item.trim())
      .filter(Boolean);
  }

  function normalizeLines(value) {
    return String(value || "")
      .split(/\r?\n/)
      .map((item) => item.trim())
      .filter(Boolean);
  }

  function parseUrl(value, base) {
    try {
      return new URL(value, base || location.href);
    } catch (_) {
      return null;
    }
  }

  function fileNameFromUrl(url) {
    const parsed = parseUrl(url);
    if (!parsed) return "";
    const parts = decodeURIComponent(parsed.pathname).split("/").filter(Boolean);
    return parts[parts.length - 1] || "";
  }

  function extensionFromUrl(url) {
    const fileName = fileNameFromUrl(url).split("?")[0].split("#")[0];
    const dot = fileName.lastIndexOf(".");
    if (dot <= 0 || dot === fileName.length - 1) return "";
    return fileName.slice(dot + 1).toLowerCase();
  }

  function wildcardToRegExp(pattern) {
    return new RegExp("^" + String(pattern)
      .replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
      .replace(/\*/g, ".*") + "$", "i");
  }

  function isBlacklisted(url, config) {
    return config.blacklist.some((pattern) => {
      if (pattern.includes("*")) return wildcardToRegExp(pattern).test(url);
      return url.includes(pattern);
    });
  }

  function isRecentlyCaptured(url) {
    const capturedAt = recentCaptures.get(url);
    return capturedAt && Date.now() - capturedAt < RECENT_CAPTURE_TTL;
  }

  function markCaptured(url) {
    recentCaptures.set(url, Date.now());
    setTimeout(() => recentCaptures.delete(url), RECENT_CAPTURE_TTL);
  }

  function closestAnchor(target) {
    for (let node = target; node && node !== document; node = node.parentNode) {
      if (node instanceof HTMLAnchorElement && node.href) return node;
    }
    return null;
  }

  function isPlainPrimaryClick(event) {
    return event.button === 0 &&
      !event.altKey &&
      !event.ctrlKey &&
      !event.metaKey &&
      !event.shiftKey &&
      !event.defaultPrevented;
  }

  function looksLikeMediaLink(url) {
    const parsed = parseUrl(url);
    if (!parsed) return false;
    return /\.(m3u8|mpd)(?:[?#].*)?$/i.test(parsed.pathname + parsed.search);
  }

  function shouldCaptureAnchor(anchor, config) {
    if (!config.enabled || !anchor || !anchor.href) return false;
    if (isRecentlyCaptured(anchor.href)) return false;
    if (isBlacklisted(anchor.href, config) || isBlacklisted(location.href, config)) return false;

    const parsed = parseUrl(anchor.href);
    if (!parsed) return false;
    if (!/^https?:$|^blob:$/.test(parsed.protocol)) return false;

    if (config.captureDownloadAttribute && anchor.hasAttribute("download")) return true;
    if (config.captureMediaLinks && looksLikeMediaLink(anchor.href)) return true;
    if (config.captureKnownExtensions && config.extensions.includes(extensionFromUrl(anchor.href))) return true;

    return config.captureAllSameOriginBlobLinks &&
      parsed.protocol === "blob:" &&
      anchor.href.startsWith(location.origin);
  }

  function requestAbdm(path, body, config) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "POST",
        url: `http://127.0.0.1:${config.port}/${path}`,
        data: body === undefined ? null : JSON.stringify(body),
        timeout: 2500,
        onload(response) {
          if (response.status >= 200 && response.status < 300) {
            resolve(response);
          } else {
            reject(new Error(`ABDM HTTP ${response.status}`));
          }
        },
        ontimeout() {
          reject(new Error("ABDM request timeout"));
        },
        onerror() {
          reject(new Error("ABDM request failed"));
        }
      });
    });
  }

  function createDownloadItem(anchor) {
    return {
      link: anchor.href,
      downloadPage: location.href,
      headers: {
        Referer: location.href,
        "User-Agent": navigator.userAgent
      },
      description: document.title || null,
      suggestedName: anchor.getAttribute("download") || fileNameFromUrl(anchor.href) || null,
      type: looksLikeMediaLink(anchor.href) ? "hls" : "http"
    };
  }

  async function addToAbdm(items, config) {
    await requestAbdm("add", {
      items,
      options: {
        silentAdd: !!config.silentAdd,
        silentStart: !!config.silentStart
      }
    }, config);
    items.forEach((item) => markCaptured(item.link));
  }

  async function pingAbdm(config) {
    await requestAbdm("ping", null, config);
  }

  function continueBrowserNavigation(anchor) {
    const target = anchor.getAttribute("target");
    if (target && target !== "_self") {
      window.open(anchor.href, target);
    } else {
      location.assign(anchor.href);
    }
  }

  async function handleClick(event) {
    if (!isPlainPrimaryClick(event)) return;

    const anchor = closestAnchor(event.target);
    const config = getConfig();
    if (!shouldCaptureAnchor(anchor, config)) return;

    event.preventDefault();
    event.stopImmediatePropagation();

    const item = createDownloadItem(anchor);
    try {
      await addToAbdm([item], config);
      showToast("已发送到 AB Download Manager");
    } catch (error) {
      console.warn("[ABDM] capture failed", error);
      showToast("ABDM 未响应");
      if (config.allowBrowserFallback) continueBrowserNavigation(anchor);
    }
  }

  function collectLinksFromSelection(config) {
    const selection = getSelection();
    if (!selection || selection.rangeCount === 0) return [];

    const root = document.createElement("div");
    for (let i = 0; i < selection.rangeCount; i += 1) {
      root.appendChild(selection.getRangeAt(i).cloneContents());
    }

    const seen = new Set();
    return Array.from(root.querySelectorAll("a[href]"))
      .filter((anchor) => shouldCaptureAnchor(anchor, config))
      .filter((anchor) => {
        if (seen.has(anchor.href)) return false;
        seen.add(anchor.href);
        return true;
      })
      .map(createDownloadItem);
  }

  async function downloadSelection() {
    const config = getConfig();
    const items = collectLinksFromSelection(config);
    if (!items.length) {
      showToast("选区中没有匹配的下载链接");
      return;
    }
    try {
      await addToAbdm(items, config);
      showToast(`已发送 ${items.length} 个链接到 ABDM`);
    } catch (error) {
      console.warn("[ABDM] selection capture failed", error);
      showToast("ABDM 未响应");
    }
  }

  async function testConnection() {
    const config = getConfig();
    try {
      await pingAbdm(config);
      showToast(`ABDM 已连接:127.0.0.1:${config.port}`);
    } catch (error) {
      console.warn("[ABDM] ping failed", error);
      showToast(`ABDM 未连接:127.0.0.1:${config.port}`);
    }
  }

  function promptSettings() {
    const config = getConfig();
    const port = prompt("ABDM 端口", String(config.port));
    if (port === null) return;
    const extensions = prompt("接管扩展名,空格/逗号分隔", config.extensions.join(" "));
    if (extensions === null) return;
    const blacklist = prompt("黑名单网址,每行一个,支持 *", config.blacklist.join("\n"));
    if (blacklist === null) return;

    setConfig({
      port: clampPort(port),
      extensions,
      blacklist
    });
    showToast("ABDM 脚本设置已保存");
  }

  function toggleEnabled() {
    const config = getConfig();
    GM_setValue("enabled", !config.enabled);
    showToast(!config.enabled ? "ABDM 接管已启用" : "ABDM 接管已停用");
  }

  function showToast(message) {
    if (!document.documentElement) return;

    let toast = document.getElementById("__abdm_capture_toast");
    if (!toast) {
      toast = document.createElement("div");
      toast.id = "__abdm_capture_toast";
      Object.assign(toast.style, {
        position: "fixed",
        zIndex: "2147483647",
        right: "16px",
        bottom: "16px",
        maxWidth: "360px",
        padding: "10px 12px",
        borderRadius: "8px",
        background: "rgba(20, 24, 28, 0.92)",
        color: "#fff",
        font: "13px -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
        boxShadow: "0 8px 24px rgba(0, 0, 0, 0.22)",
        pointerEvents: "none",
        transition: "opacity 160ms ease"
      });
      document.documentElement.appendChild(toast);
    }

    toast.textContent = message;
    toast.style.opacity = "1";
    clearTimeout(showToast.timer);
    showToast.timer = setTimeout(() => {
      toast.style.opacity = "0";
    }, 1800);
  }

  GM_registerMenuCommand("ABDM:测试连接", testConnection);
  GM_registerMenuCommand("ABDM:下载选中链接", downloadSelection);
  GM_registerMenuCommand("ABDM:设置端口/扩展名/黑名单", promptSettings);
  GM_registerMenuCommand("ABDM:启用/停用接管", toggleEnabled);

  document.addEventListener("click", (event) => {
    handleClick(event);
  }, true);
})();