Adbandon

过滤部分 HLS 动漫源的插播切片广告,可切换为只播放识别到的广告

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Adbandon
// @namespace    https://greasyfork.org/users/1440044
// @version      0.1.5
// @description  过滤部分 HLS 动漫源的插播切片广告,可切换为只播放识别到的广告
// @license      MIT
// @match        *://enlienli.link/*
// @match        *://*.enlienli.link/*
// @match        *://omofuns.xyz/*
// @match        *://*.omofuns.xyz/*
// @match        *://www.dongmandaquan.vip/*
// @match        *://*.dongmandaquan.vip/*
// @match        *://senfun.in/*
// @match        *://*.senfun.in/*
// @run-at       document-start
// @all-frames   true
// @grant        unsafeWindow
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      *
// ==/UserScript==

(function () {
  "use strict";

  const PAGE = typeof unsafeWindow === "object" && unsafeWindow ? unsafeWindow : window;
  const MODE_KEY = "adbandon.mode";
  const MODE_FILTER = "FILTER";
  const MODE_AD_ONLY = "AD_ONLY";

  const CONFIG = {
    minDuration: 10,
    maxDuration: 30,
    minSegments: 3,
    maxSegments: 12,
    minRepeatCount: 2,
    largeNumericJump: 1000,
    sparseMaxGroups: 8,
    sparseLongMinDuration: 60,
    sparseShortMaxLongRatio: 0.35,
  };

  const state = {
    currentHls: null,
    lastOriginalUrl: "",
    lastContentUrl: "",
    lastAdUrl: "",
    lastReport: null,
    probing: new Map(),
  };

  function mode() {
    try {
      return GM_getValue(MODE_KEY, MODE_FILTER) === MODE_AD_ONLY ? MODE_AD_ONLY : MODE_FILTER;
    } catch {
      return MODE_FILTER;
    }
  }

  function setMode(nextMode) {
    try {
      GM_setValue(MODE_KEY, nextMode);
    } catch {}
    broadcastMode(nextMode);
    renderWidget();
    applyLatestMode();
  }

  function toggleMode() {
    setMode(mode() === MODE_AD_ONLY ? MODE_FILTER : MODE_AD_ONLY);
  }

  function modeLabel() {
    return mode() === MODE_AD_ONLY ? "AD" : "FILTER";
  }

  function renderWidget() {
    if (window.top !== window) return;
    const doc = document;
    let button = doc.getElementById("adbandon-mode-button");
    if (!button) {
      button = doc.createElement("button");
      button.id = "adbandon-mode-button";
      button.type = "button";
      button.addEventListener("click", toggleMode);
      doc.documentElement.appendChild(button);
    }
    button.textContent = `Adbandon: ${modeLabel()}`;
    button.title = mode() === MODE_AD_ONLY ? "当前只播放识别到的广告,点击切回默认过滤" : "当前默认过滤广告,点击只播放识别到的广告";
    Object.assign(button.style, {
      position: "fixed",
      right: "14px",
      bottom: "14px",
      zIndex: "2147483647",
      boxSizing: "border-box",
      minWidth: "112px",
      height: "34px",
      padding: "0 12px",
      border: "1px solid rgba(255,255,255,.22)",
      borderRadius: "7px",
      background: mode() === MODE_AD_ONLY ? "#9f1239" : "#111827",
      color: "#fff",
      font: "600 12px/32px system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
      letterSpacing: "0",
      boxShadow: "0 6px 18px rgba(0,0,0,.24)",
      cursor: "pointer",
      opacity: "0.92",
    });
  }

  function broadcastMode(nextMode) {
    try {
      for (const iframe of document.querySelectorAll("iframe")) {
        iframe.contentWindow?.postMessage({ type: "adbandon-mode", mode: nextMode }, "*");
      }
    } catch {}
  }

  function installFrameMessaging() {
    window.addEventListener("message", (event) => {
      const data = event.data;
      if (!data || data.type !== "adbandon-mode") return;
      if (data.mode !== MODE_FILTER && data.mode !== MODE_AD_ONLY) return;
      try {
        GM_setValue(MODE_KEY, data.mode);
      } catch {}
      renderWidget();
      applyLatestMode();
    });
  }

  function log(...args) {
    console.log("[Adbandon]", ...args);
  }

  function isM3u8(url) {
    return typeof url === "string" && /\.m3u8(?:[?#]|$)/i.test(url);
  }

  function absolutize(baseUrl, value) {
    try {
      return new URL(value, baseUrl).href;
    } catch {
      return value;
    }
  }

  function httpGetText(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        timeout: 20000,
        responseType: "text",
        onload: (res) => {
          if (res.status >= 200 && res.status < 400) resolve(res.responseText || "");
          else reject(new Error(`HTTP ${res.status} ${url}`));
        },
        ontimeout: () => reject(new Error(`Timeout ${url}`)),
        onerror: () => reject(new Error(`Request failed ${url}`)),
      });
    });
  }

  function parseExtinf(line) {
    const value = line.includes(":") ? line.split(":", 2)[1] : "";
    return Number.parseFloat(value.split(",", 1)[0].trim());
  }

  function parseSegments(text, manifestUrl) {
    const segments = [];
    const stateLines = new Map();
    let pendingLines = [];
    let pendingDuration = null;
    let pendingDiscontinuity = false;

    for (const raw of text.split(/\r?\n/)) {
      const line = raw.trim();
      if (!line) continue;

      if (line === "#EXT-X-DISCONTINUITY") {
        pendingDiscontinuity = true;
        pendingLines.push(raw);
        continue;
      }
      if (line.startsWith("#EXT-X-KEY") || line.startsWith("#EXT-X-MAP")) {
        stateLines.set(line.split(":", 1)[0], raw);
        pendingLines.push(raw);
        continue;
      }
      if (line.startsWith("#EXT-X-BYTERANGE")) {
        pendingLines.push(raw);
        continue;
      }
      if (line.startsWith("#EXTINF:")) {
        pendingDuration = parseExtinf(line);
        pendingLines.push(raw);
        continue;
      }
      if (line.startsWith("#")) continue;
      if (!Number.isFinite(pendingDuration)) continue;

      const uri = absolutize(manifestUrl, line);
      pendingLines.push(uri);
      segments.push({
        duration: pendingDuration,
        uri,
        rawLines: pendingLines,
        stateLines: new Map(stateLines),
        hasDiscontinuity: pendingDiscontinuity,
      });
      pendingLines = [];
      pendingDuration = null;
      pendingDiscontinuity = false;
    }
    return segments;
  }

  function buildGroups(segments) {
    const groups = [];
    let current = [];
    for (const segment of segments) {
      if (current.length && segment.hasDiscontinuity) {
        groups.push(current);
        current = [];
      }
      current.push(segment);
    }
    if (current.length) groups.push(current);
    return groups;
  }

  function topKey(map) {
    let bestKey = "";
    let bestCount = -1;
    for (const [key, count] of map.entries()) {
      if (count > bestCount) {
        bestKey = key;
        bestCount = count;
      }
    }
    return bestKey;
  }

  function uriParts(uri) {
    try {
      const u = new URL(uri);
      const parts = u.pathname.split("/").filter(Boolean);
      const name = parts[parts.length - 1] || "";
      const parent = "/" + parts.slice(0, -1).join("/");
      const asset = parts.length >= 4 ? "/" + parts.slice(0, 4).join("/") : parent;
      return { host: u.host, parent, asset, name };
    } catch {
      const name = uri.split("?")[0].split("/").pop() || "";
      return { host: "", parent: "", asset: "", name };
    }
  }

  function basename(uri) {
    return uriParts(uri).name || uri;
  }

  function trailingNumber(name) {
    const stem = name.replace(/\.[^.]+$/, "");
    const match = stem.match(/(\d+)$/);
    return match ? Number.parseInt(match[1], 10) : null;
  }

  function groupStats(group) {
    const parents = new Map();
    const assets = new Map();
    const names = [];
    for (const segment of group) {
      const p = uriParts(segment.uri);
      parents.set(p.parent, (parents.get(p.parent) || 0) + 1);
      assets.set(p.asset, (assets.get(p.asset) || 0) + 1);
      names.push(p.name);
    }
    return {
      duration: group.reduce((sum, s) => sum + s.duration, 0),
      segmentCount: group.length,
      parent: topKey(parents),
      asset: topKey(assets),
      firstName: names[0] || "",
      lastName: names[names.length - 1] || "",
      firstNumber: trailingNumber(names[0] || ""),
      lastNumber: trailingNumber(names[names.length - 1] || ""),
      signature: group.map((s) => `${s.duration.toFixed(3)} ${basename(s.uri)}`).join("|"),
      shapeSignature: "",
    };
  }

  function isShortGroup(stats) {
    return (
      stats.duration >= CONFIG.minDuration &&
      stats.duration <= CONFIG.maxDuration &&
      stats.segmentCount >= CONFIG.minSegments &&
      stats.segmentCount <= CONFIG.maxSegments
    );
  }

  function isLongNeighbor(stats) {
    return stats && stats.duration >= CONFIG.sparseLongMinDuration;
  }

  function detectAdGroups(groups) {
    const stats = groups.map(groupStats);
    for (const s of stats) {
      s.shapeSignature = `${s.segmentCount}:${s.duration.toFixed(1)}`;
    }

    const parentCounts = new Map();
    const assetCounts = new Map();
    const signatureCounts = new Map();
    const shapeCounts = new Map();
    for (const s of stats) {
      parentCounts.set(s.parent, (parentCounts.get(s.parent) || 0) + 1);
      assetCounts.set(s.asset, (assetCounts.get(s.asset) || 0) + 1);
      signatureCounts.set(s.signature, (signatureCounts.get(s.signature) || 0) + 1);
      shapeCounts.set(s.shapeSignature, (shapeCounts.get(s.shapeSignature) || 0) + 1);
    }

    const dominantParent = topKey(parentCounts);
    const dominantAsset = topKey(assetCounts);
    const sparse = groups.length <= CONFIG.sparseMaxGroups;

    return stats.map((s, index) => {
      const prev = stats[index - 1] || null;
      const next = stats[index + 1] || null;
      const shortGroup = isShortGroup(s);
      const reasons = [];
      const betweenLong = shortGroup && isLongNeighbor(prev) && isLongNeighbor(next);
      const smallIsland =
        betweenLong &&
        s.duration <= Math.min(prev.duration, next.duration) * CONFIG.sparseShortMaxLongRatio;

      if (shortGroup && (signatureCounts.get(s.signature) || 0) >= CONFIG.minRepeatCount) {
        reasons.push("repeated-signature");
      }
      if (
        sparse &&
        smallIsland &&
        (shapeCounts.get(s.shapeSignature) || 0) >= CONFIG.minRepeatCount
      ) {
        reasons.push("sparse-repeated-shape-island");
      }
      if (sparse && groups.length <= 3 && smallIsland) {
        reasons.push("sparse-single-shape-island");
      }
      if (
        shortGroup &&
        s.parent !== dominantParent &&
        prev &&
        next &&
        prev.parent === dominantParent &&
        next.parent === dominantParent
      ) {
        reasons.push("minority-parent-island");
      }
      if (
        shortGroup &&
        s.asset !== dominantAsset &&
        prev &&
        next &&
        prev.asset === dominantAsset &&
        next.asset === dominantAsset
      ) {
        reasons.push("minority-asset-island");
      }
      if (
        shortGroup &&
        s.parent === dominantParent &&
        prev &&
        next &&
        prev.parent === dominantParent &&
        next.parent === dominantParent &&
        Number.isFinite(prev.lastNumber) &&
        Number.isFinite(s.firstNumber) &&
        Number.isFinite(next.firstNumber) &&
        Math.abs(s.firstNumber - prev.lastNumber) >= CONFIG.largeNumericJump &&
        Math.abs(next.firstNumber - prev.lastNumber) <= 1
      ) {
        reasons.push("numeric-jump-island");
      }

      return { ...s, index, remove: reasons.length > 0, reasons };
    });
  }

  function extractHeaderLines(text) {
    const out = [];
    const seen = new Set();
    for (const raw of text.split(/\r?\n/)) {
      const line = raw.trim();
      if (!line) continue;
      if (line === "#EXT-X-DISCONTINUITY" || line.startsWith("#EXTINF:")) break;
      if (!line.startsWith("#")) break;
      if (
        line.startsWith("#EXT-X-KEY") ||
        line.startsWith("#EXT-X-MAP") ||
        line.startsWith("#EXT-X-BYTERANGE") ||
        line === "#EXT-X-ENDLIST"
      ) {
        continue;
      }
      if (seen.has(line)) continue;
      seen.add(line);
      out.push(raw);
    }
    return out.length ? out : ["#EXTM3U"];
  }

  function buildManifest(text, groups, decisions, keepAds) {
    const out = extractHeaderLines(text);
    const outputState = new Map();
    let wroteAny = false;

    for (const decision of decisions) {
      if (keepAds !== decision.remove) continue;
      const group = groups[decision.index];
      if (!group) continue;
      if (wroteAny) out.push("#EXT-X-DISCONTINUITY");
      for (const segment of group) {
        for (const [stateKey, stateLine] of segment.stateLines.entries()) {
          if (outputState.get(stateKey) !== stateLine) {
            out.push(stateLine);
            outputState.set(stateKey, stateLine);
          }
        }
        for (const line of segment.rawLines) {
          const trimmed = String(line).trim();
          if (
            trimmed === "#EXT-X-DISCONTINUITY" ||
            trimmed.startsWith("#EXT-X-KEY") ||
            trimmed.startsWith("#EXT-X-MAP")
          ) {
            continue;
          }
          out.push(line);
        }
      }
      wroteAny = true;
    }

    if (!wroteAny) return "";
    out.push("#EXT-X-ENDLIST");
    return out.join("\n") + "\n";
  }

  function pickVariantUrl(text, manifestUrl) {
    const lines = text.split(/\r?\n/);
    for (let i = 0; i < lines.length; i += 1) {
      if (!lines[i].trim().startsWith("#EXT-X-STREAM-INF")) continue;
      for (let j = i + 1; j < lines.length; j += 1) {
        const line = lines[j].trim();
        if (!line || line.startsWith("#")) continue;
        return absolutize(manifestUrl, line);
      }
    }
    return "";
  }

  function revokeUrl(url) {
    if (url) URL.revokeObjectURL(url);
  }

  async function analyzeUrl(url, depth = 0) {
    const text = await httpGetText(url);
    if (!text.includes("#EXTM3U")) return null;
    if (text.includes("#EXT-X-STREAM-INF") && depth < 2) {
      const variant = pickVariantUrl(text, url);
      return variant ? analyzeUrl(variant, depth + 1) : null;
    }
    if (!text.includes("#EXT-X-ENDLIST") || !text.includes("#EXT-X-DISCONTINUITY")) return null;

    const segments = parseSegments(text, url);
    const groups = buildGroups(segments);
    if (groups.length < 3) return null;

    const decisions = detectAdGroups(groups);
    const candidateIndexes = decisions.filter((d) => d.remove).map((d) => d.index);
    if (!candidateIndexes.length) return null;

    const contentManifest = buildManifest(text, groups, decisions, false);
    const adManifest = buildManifest(text, groups, decisions, true);
    if (!contentManifest || !adManifest) return null;

    return {
      url,
      groupCount: groups.length,
      candidateIndexes,
      decisions,
      contentManifest,
      adManifest,
    };
  }

  function createManifestUrl(text) {
    return URL.createObjectURL(new Blob([text], { type: "application/vnd.apple.mpegurl" }));
  }

  function loadPlaybackUrl(url) {
    if (!url) return;
    const video = PAGE.document?.querySelector?.("video") || document.querySelector("video");
    if (state.currentHls && typeof state.currentHls.loadSource === "function") {
      state.currentHls.loadSource(url);
      if (video && typeof state.currentHls.attachMedia === "function") state.currentHls.attachMedia(video);
      video?.play?.().catch(() => {});
      return;
    }
    if (video) {
      video.src = url;
      video.play?.().catch(() => {});
    }
  }

  function applyLatestMode() {
    if (!state.lastReport) return;
    const target = mode() === MODE_AD_ONLY ? state.lastAdUrl : state.lastContentUrl;
    loadPlaybackUrl(target);
  }

  function enqueueProbe(url, reason) {
    if (!isM3u8(url) || String(url).startsWith("blob:")) return;
    const absolute = absolutize(PAGE.location?.href || location.href, url);
    if (state.probing.has(absolute)) return;
    const promise = analyzeUrl(absolute)
      .then((report) => {
        state.probing.delete(absolute);
        if (!report) return null;
        revokeUrl(state.lastContentUrl);
        revokeUrl(state.lastAdUrl);
        state.lastOriginalUrl = absolute;
        state.lastReport = report;
        state.lastContentUrl = createManifestUrl(report.contentManifest);
        state.lastAdUrl = createManifestUrl(report.adManifest);
        log("matched", reason, {
          url: report.url,
          groups: report.groupCount,
          candidates: report.candidateIndexes,
          reasons: report.decisions.filter((d) => d.remove).map((d) => [d.index, d.reasons]),
          mode: mode(),
        });
        applyLatestMode();
        return report;
      })
      .catch((error) => {
        state.probing.delete(absolute);
        log("probe failed", reason, absolute, error);
        return null;
      });
    state.probing.set(absolute, promise);
  }

  function installHlsHook() {
    const timer = window.setInterval(() => {
      const Hls = PAGE.Hls;
      if (!Hls || Hls.__adbandonHooked) return;
      Hls.__adbandonHooked = true;
      const originalLoadSource = Hls.prototype.loadSource;
      Hls.prototype.loadSource = function (url) {
        state.currentHls = this;
        enqueueProbe(url, "hls.loadSource");
        return originalLoadSource.apply(this, arguments);
      };
      window.clearInterval(timer);
    }, 200);
  }

  function installVideoHook() {
    const ElementPrototype = PAGE.Element?.prototype || Element.prototype;
    const HTMLMediaElementPrototype = PAGE.HTMLMediaElement?.prototype || HTMLMediaElement.prototype;
    const HTMLVideoElementClass = PAGE.HTMLVideoElement || HTMLVideoElement;

    const originalSetAttribute = ElementPrototype.setAttribute;
    ElementPrototype.setAttribute = function (name, value) {
      if (this instanceof HTMLVideoElementClass && String(name).toLowerCase() === "src") {
        enqueueProbe(value, "video.setAttribute");
      }
      return originalSetAttribute.apply(this, arguments);
    };

    const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElementPrototype, "src");
    if (descriptor?.set && descriptor?.get) {
      Object.defineProperty(HTMLMediaElementPrototype, "src", {
        configurable: true,
        enumerable: descriptor.enumerable,
        get: descriptor.get,
        set(value) {
          if (this instanceof HTMLVideoElementClass) enqueueProbe(value, "video.src");
          return descriptor.set.call(this, value);
        },
      });
    }
  }

  function installNetworkHooks() {
    if (PAGE.__adbandonNetworkHooked) return;
    PAGE.__adbandonNetworkHooked = true;

    const originalFetch = PAGE.fetch;
    if (typeof originalFetch === "function") {
      PAGE.fetch = function (input, init) {
        const url = typeof input === "string" ? input : input?.url;
        enqueueProbe(url, "fetch");
        return originalFetch.apply(this, arguments).then((response) => {
          enqueueProbe(response?.url || url, "fetch.response");
          return response;
        });
      };
    }

    const xhrPrototype = PAGE.XMLHttpRequest?.prototype;
    if (xhrPrototype) {
      const originalOpen = xhrPrototype.open;
      xhrPrototype.open = function (_method, url) {
        this.__adbandonUrl = url;
        enqueueProbe(url, "xhr.open");
        return originalOpen.apply(this, arguments);
      };
      const originalSend = xhrPrototype.send;
      xhrPrototype.send = function () {
        this.addEventListener("load", () => enqueueProbe(this.responseURL || this.__adbandonUrl, "xhr.load"));
        return originalSend.apply(this, arguments);
      };
    }
  }

  function scanKnownPlaces() {
    try {
      for (const entry of PAGE.performance?.getEntriesByType?.("resource") || []) {
        enqueueProbe(entry?.name, "performance");
      }
    } catch {}
    try {
      for (const video of PAGE.document?.querySelectorAll?.("video") || []) {
        enqueueProbe(video.currentSrc, "video.currentSrc");
        enqueueProbe(video.src, "video.src.scan");
      }
    } catch {}
    for (const key of ["player_aaaa", "player_data", "MacPlayer"]) {
      try {
        const value = PAGE[key];
        if (!value) continue;
        for (const prop of ["url", "PlayUrl", "playUrl", "src"]) {
          enqueueProbe(value[prop], `${key}.${prop}`);
        }
      } catch {}
    }
  }

  function installResourceObserver() {
    try {
      const observer = new PAGE.PerformanceObserver((list) => {
        for (const entry of list.getEntries()) enqueueProbe(entry?.name, "performance-observer");
      });
      observer.observe({ type: "resource", buffered: true });
    } catch {}
    window.setInterval(scanKnownPlaces, 2000);
  }

  renderWidget();
  installFrameMessaging();
  installHlsHook();
  installVideoHook();
  installNetworkHooks();
  installResourceObserver();
  window.setTimeout(scanKnownPlaces, 1000);
})();