Auto-select advertisements

This automatically selects submission notifications, that are advertisements.

目前为 2025-03-02 提交的版本。查看 最新版本

// ==UserScript==
// @name         Auto-select advertisements
// @namespace    https://github.com/f1r3w4rr10r/fa-utils
// @version      0.1.0
// @description  This automatically selects submission notifications, that are advertisements.
// @author       f1r3w4rr10r
// @match        https://www.furaffinity.net/msg/submissions/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        none
// ==/UserScript==

(async function () {
  "use strict";

  // The second "c" is a kyrillic "s";
  const commissionRegexString = "[cс]omm(?:ission)?s?";
  const commissionBoundedRegexString = `(?:^|\\W)${commissionRegexString}\\b`;
  const commissionRegex = new RegExp(commissionBoundedRegexString, "i");
  const userRefRegex =
    /(?:by|for|from)\s*(?::(?:icon\w+|\w+icon):|@@\w+)|YCH\s+for\s+\w+/i;

  /** @type {AdvertisementCheckSpec[]} */
  const advertisementCheckSpecs = [
    {
      specName: "obvious ads",
      name: {
        triggers: [
          /\badopt(?:(?:able)?s?|ing)\b/i,
          /\bpicarto\.tv\b/i,
          /\breminder+\b/i,
          /\bstreaming\b/i,
          /^REM$/,
        ],
        isAlwaysAd: true,
      },
    },
    {
      specName: "WIPs",
      name: {
        triggers: [/\bwip\b/i],
        isAlwaysAd: true,
      },
      tags: {
        triggers: [/\bwip\b/i],
        isAlwaysAd: true,
      },
    },
    {
      specName: "commission ads",
      name: {
        triggers: [/\bauction\b/i, commissionRegex, /\bwing.its?\b/i],
        isAdExpressions: [
          /\bclosed\b/i,
          /\bhalfbody\b/i,
          /\bopen(?:ed)?\b/i,
          /\bsale\b/i,
          /\bslots?\b/i,
        ],
        isNotAdExpressions: [
          /\bfor\b/i,
          new RegExp(`\\[${commissionRegexString}\\]`, "i"),
          new RegExp(`^${commissionRegexString}$`, "i"),
        ],
      },
      description: {
        triggers: [/.*/i],
        isNotAdExpressions: [userRefRegex],
      },
    },
    {
      specName: "streams by name",
      name: {
        triggers: [/\b(?:live)?stream\b/i],
        isAdExpressions: [
          /\blive\b/i,
          /\boffline\b/i,
          /\bonline\b/i,
          /\bpreorders?\b/i,
          /\bslots?\b/i,
          /\bup\b/i,
        ],
      },
    },
    {
      specName: "streams",
      name: {
        triggers: [/\bstream\b/i],
        isAlwaysAd: true,
      },
      tags: {
        triggers: [/\bstream\b/i],
        isAlwaysAd: true,
      },
    },
    {
      specName: "YCHs by name",
      name: {
        triggers: [/\by ?c ?h\b/i],
        isAdExpressions: [
          /\bauction\b/i,
          /\bavailable\b/i,
          /\bdiscount\b/i,
          /\bmultislot\b/i,
          /\bo ?p ?e ?n\b/i,
          /\bprice\b/i,
          /\bpreview\b/i,
          /\braffle\b/i,
          /\brem(?:ind(?:er)?)?\d*\b/i,
          /\brmd\b/i,
          /\bsale\b/i,
          /\bslots?\b/i,
          /\bsold\b/i,
          /\btaken\b/i,
          /\busd\b/i,
          /\b\$\d+\b/i,
        ],
        isNotAdExpressions: [commissionRegex, /\bfinished\b/i, /\bresult\b/i],
      },
      description: {
        triggers: [/.*/i],
        isNotAdExpressions: [userRefRegex],
      },
      untaggedIsAd: true,
    },
    {
      specName: "YCHs by name and tag",
      name: {
        triggers: [/\by ?c ?h\b/i],
      },
      tags: {
        triggers: [/\bauction\b/i, /\bych\b/i],
        isAlwaysAd: true,
      },
    },
    {
      specName: "discounts",
      name: {
        triggers: [/\bdiscount\b/i, /\bsale\b/i],
        isAdExpressions: [
          /\$/,
          /\bbase\b/i,
          /\bclaimed\b/i,
          /\b(?:multi)?slot\b/i,
          /\boffer\b/i,
          /\bprice\b/i,
        ],
      },
    },
    {
      specName: "price lists",
      name: {
        triggers: [/\bprice\b/i],
        isAdExpressions: [/\blist\b/i, /\bsheet\b/i],
      },
    },
    {
      specName: "raffles",
      name: {
        triggers: [/\braffle\b/i],
        isAdExpressions: [/\bwinners?\b/i],
      },
    },
    {
      specName: "memberships in names",
      name: {
        triggers: [
          /\bboosty\b/i,
          /\bp[@a]treon\b/i,
          /\bsub(?:scribe)?\s?star\b/i,
        ],
        isAdExpressions: [
          /\bdiscount\b/i,
          /\bnow on\b/i,
          /\bposted to\b/i,
          /\bpreview\b/i,
          /\bteaser?\b/i,
        ],
      },
    },
    {
      specName: "memberships teasers in name and description",
      name: {
        triggers: [/\bpreview\b/i, /\bteaser\b/i],
      },
      description: {
        triggers: [
          /\b(?:available|n[eo]w|out)\b.*\bon\b.*\b(?:boosty|p[@a]treon|sub(?:scribe)?\s?star)\b/i,
        ],
        isAlwaysAd: true,
      },
    },
    {
      specName: "shops",
      name: {
        triggers: [/\bshop\b/i],
        isAdExpressions: [/\bprint\b/i],
      },
    },
    {
      specName: "multislots",
      name: {
        triggers: [/\b(?:multi)?slots?\b/i],
        isAdExpressions: [/\bavailable\b/i, /\bopen\b/i, /\bsketch\b/i],
      },
    },
    {
      specName: "remaining name",
      name: {
        triggers: [
          /\bclosed\b/i,
          /\bopen\b/i,
          /\bpoll\b/i,
          /\bpreview\b/i,
          /\brem\b/i,
          /\bsold\b/i,
          /\bteaser\b/i,
          /\bwip\b/i,
        ],
      },
    },
    {
      specName: "remaining tags",
      tags: {
        triggers: [/\bteaser\b/i],
      },
    },
  ];

  class AdvertisementCheckResult {
    /**
     * @param {boolean} isTagged
     * @param {AdvertisementLevel | null} [nameResult]
     * @param {AdvertisementLevel | null} [descriptionResult]
     * @param {AdvertisementLevel | null} [tagsResult]
     */
    constructor(isTagged, nameResult, descriptionResult, tagsResult) {
      this.#isTagged = isTagged;
      this.#nameResult = nameResult ?? null;
      this.#descriptionResult = descriptionResult ?? null;
      this.#tagsResult = tagsResult ?? null;
    }

    /** @type {AdvertisementLevel | null} */
    #nameResult = null;

    /** @type {AdvertisementLevel | null} */
    #descriptionResult = null;

    /** @type {AdvertisementLevel | null} */
    #tagsResult = null;

    #isTagged = false;

    /** @type {DecisionLogEntry[]} */
    #decisionLog = [];

    /**
     * @returns {AdvertisementLevel | null}
     */
    get nameResult() {
      return this.#nameResult;
    }

    /**
     * @param {AdvertisementLevel | null} value
     */
    set nameResult(value) {
      this.#nameResult = this.#coalesceResultLevel(this.#nameResult, value);
    }

    /**
     * @returns {AdvertisementLevel | null}
     */
    get descriptionResult() {
      return this.#descriptionResult;
    }

    /**
     * @param {AdvertisementLevel | null} value
     */
    set descriptionResult(value) {
      this.#descriptionResult = this.#coalesceResultLevel(
        this.#descriptionResult,
        value,
      );
    }

    /**
     * @returns {AdvertisementLevel | null}
     */
    get tagsResult() {
      return this.#tagsResult;
    }

    /**
     * @param {AdvertisementLevel | null} value
     */
    set tagsResult(value) {
      this.#tagsResult = this.#coalesceResultLevel(this.#tagsResult, value);
    }

    /**
     * @returns {AdvertisementLevel | null}
     */
    get result() {
      if (
        this.#nameResult === "notAdvertisement" ||
        this.#descriptionResult === "notAdvertisement" ||
        this.#tagsResult === "notAdvertisement"
      ) {
        return "notAdvertisement";
      }

      if (
        this.#nameResult === "advertisement" ||
        this.#descriptionResult === "advertisement" ||
        this.#tagsResult === "advertisement"
      ) {
        return "advertisement";
      }

      if (
        this.#nameResult === "ambiguous" ||
        this.#descriptionResult === "ambiguous" ||
        this.#tagsResult === "ambiguous"
      ) {
        return "ambiguous";
      }

      return null;
    }

    /**
     * @returns {boolean}
     */
    get isTagged() {
      return this.#isTagged;
    }

    get decisionLog() {
      return this.#decisionLog;
    }

    /**
     * @param {DecisionLogEntry} log
     */
    addToLog(log) {
      if (log.name === null && log.description === null && log.tags === null) {
        return;
      }

      this.#decisionLog.push(log);
    }

    /**
     * @param {AdvertisementLevel | null} current
     * @param {AdvertisementLevel | null} newValue
     * @returns {AdvertisementLevel | null}
     */
    #coalesceResultLevel(current, newValue) {
      if (newValue === null) {
        return current;
      }

      if (current === "notAdvertisement" || newValue === "notAdvertisement") {
        return "notAdvertisement";
      }

      if (current === "advertisement" && newValue === "ambiguous") {
        return current;
      }

      return newValue;
    }
  }

  /**
   * @param {string} text
   * @param {AdvertisementCheckSpecPart} spec
   * @returns {[AdvertisementLevel | null, DecisionLogEntryPart | null]}
   */
  function checkAgainstAdvertisementSpec(text, spec) {
    /** @type {AdvertisementLevel | null} */
    let level = null;

    /** @type {DecisionLogEntryPart | null} */
    let log = null;

    for (const regex of spec.triggers) {
      if (regex.test(text)) {
        level = "ambiguous";
        log = { trigger: regex, level };
        break;
      }
    }

    if (level === "ambiguous") {
      if (spec.isAlwaysAd) {
        level = "advertisement";
        if (log) {
          log.isAlwaysAd = true;
          log.level = level;
        }
      } else if (spec.isAdExpressions) {
        for (const regex of spec.isAdExpressions) {
          if (regex.test(text)) {
            level = "advertisement";
            if (log) {
              log.isAdExpression = regex;
              log.level = level;
            }
            break;
          }
        }
      }
    }

    if (level !== null && spec.isNotAdExpressions) {
      for (const regex of spec.isNotAdExpressions) {
        if (regex.test(text)) {
          level = "notAdvertisement";
          if (log) {
            log.isNotAdExpression = regex;
            log.level = level;
          }
          break;
        }
      }
    }

    return [level, log];
  }

  /**
   * @param {string} submissionName
   * @param {string} description
   * @param {string} tags
   * @returns {AdvertisementCheckResult}
   */
  function checkAgainstAdvertisementSpecs(submissionName, description, tags) {
    const isUntagged = tags === "";

    /** @type {AdvertisementCheckResult} */
    const result = new AdvertisementCheckResult(!isUntagged);

    for (const spec of advertisementCheckSpecs) {
      const [nameResult, nameLog] = checkAgainstAdvertisementSpec(
        submissionName,
        {
          triggers: [],
          ...spec.name,
        },
      );

      const [descriptionResult, descriptionLog] = checkAgainstAdvertisementSpec(
        description,
        {
          triggers: [],
          ...spec.description,
        },
      );

      /** @type {AdvertisementLevel | null} */
      let tagsResult = null;

      /** @type {DecisionLogEntryPart | null} */
      let tagsLog = null;

      if (isUntagged) {
        if (
          (["advertisement", "ambiguous"].includes(nameResult) ||
            ["advertisement", "ambiguous"].includes(descriptionResult)) &&
          spec.untaggedIsAd
        ) {
          tagsResult = "advertisement";
          tagsLog = {
            level: "advertisement",
            trigger: /^$/,
            isAlwaysAd: true,
          };
        }
      } else {
        [tagsResult, tagsLog] = checkAgainstAdvertisementSpec(tags, {
          triggers: [],
          ...spec.tags,
        });
      }

      // TODO: Maybe change the accumulation to an overall weighting algorithm.

      // Parts present in the same spec are interpreted as being "AND"
      // connected.
      let specPartsCount = 0;
      if (spec.name) specPartsCount++;
      if (spec.description) specPartsCount++;
      if (spec.tags) specPartsCount++;
      if (specPartsCount > 1) {
        if (spec.name && !nameResult) continue;
        if (spec.description && !descriptionResult) continue;
        if (spec.tags && !tagsResult) continue;
      }

      result.nameResult = nameResult;
      result.descriptionResult = descriptionResult;
      result.tagsResult = tagsResult;

      result.addToLog({
        specName: spec.specName,
        level: result.result,
        name: nameLog,
        description: descriptionLog,
        tags: tagsLog,
      });
    }

    return result;
  }

  /**
   * @returns {[number, number, number]}
   */
  function iterateSubmissions() {
    const figures = Array.from(
      document.querySelectorAll("section.gallery figure"),
    );
    let advertisements = 0;
    let ambiguous = 0;
    let untagged = 0;

    for (const figure of figures) {
      const figcaption = figure.querySelector("figcaption");
      const checkbox = figure.querySelector("input");
      const nameAnchor = figcaption.querySelector("a");
      const submissionName = nameAnchor.textContent;
      const tags = figure.querySelector("img").dataset.tags;
      const description = descriptions[checkbox.value].description;

      const result = checkAgainstAdvertisementSpecs(
        submissionName,
        description,
        tags,
      );
      const decisionLog = result.decisionLog;

      if (decisionLog.length) {
        const button = document.createElement("button");
        button.type = "button";
        button.textContent = "Log";
        button.addEventListener("click", () => console.log(result.decisionLog));
        checkbox.parentElement.appendChild(button);
      }

      switch (result.result) {
        case "advertisement":
          figure.classList.add("advertisement");
          checkbox.checked = true;
          advertisements += 1;
          break;

        case "ambiguous":
          figure.classList.add("maybe-advertisement");
          ambiguous += 1;
          break;
      }

      if (!result.isTagged) {
        figcaption.classList.add("not-tagged");
        untagged += 1;
      }
    }

    return [advertisements, ambiguous, untagged];
  }

  const sheet = new CSSStyleSheet();
  sheet.replaceSync(
    `
figure.advertisement { outline: orange 3px solid; }
figure.maybe-advertisement { outline: yellow 3px solid; }
figcaption.not-tagged input { outline: orange 3px solid; }
figcaption button { line-height: 1; margin-left: 1rem; padding: 0; }
`.trim(),
  );
  document.adoptedStyleSheets.push(sheet);

  const sectionHeader = document.querySelector(".section-header");

  const advertisementsSelectMessage = document.createElement("p");
  advertisementsSelectMessage.textContent =
    "Checking for advertisement submissions…";
  sectionHeader.appendChild(advertisementsSelectMessage);

  const [advertisements, ambiguous, untagged] = iterateSubmissions();

  const message = `Selected ${advertisements} advertisement and ${ambiguous} ambiguous submissions. ${untagged} submissions were not tagged.`;

  advertisementsSelectMessage.textContent = message;
})();