Adbandon

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

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==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);
})();