Auto-select advertisements

This automatically selects submission notifications, that are advertisements.

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

  1. // ==UserScript==
  2. // @name Auto-select advertisements
  3. // @namespace https://github.com/f1r3w4rr10r/fa-utils
  4. // @version 0.1.0
  5. // @description This automatically selects submission notifications, that are advertisements.
  6. // @author f1r3w4rr10r
  7. // @match https://www.furaffinity.net/msg/submissions/*
  8. // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
  9. // @license MIT
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. (async function () {
  14. "use strict";
  15.  
  16. // The second "c" is a kyrillic "s";
  17. const commissionRegexString = "[cс]omm(?:ission)?s?";
  18. const commissionBoundedRegexString = `(?:^|\\W)${commissionRegexString}\\b`;
  19. const commissionRegex = new RegExp(commissionBoundedRegexString, "i");
  20. const userRefRegex =
  21. /(?:by|for|from)\s*(?::(?:icon\w+|\w+icon):|@@\w+)|YCH\s+for\s+\w+/i;
  22.  
  23. /** @type {AdvertisementCheckSpec[]} */
  24. const advertisementCheckSpecs = [
  25. {
  26. specName: "obvious ads",
  27. name: {
  28. triggers: [
  29. /\badopt(?:(?:able)?s?|ing)\b/i,
  30. /\bpicarto\.tv\b/i,
  31. /\breminder+\b/i,
  32. /\bstreaming\b/i,
  33. /^REM$/,
  34. ],
  35. isAlwaysAd: true,
  36. },
  37. },
  38. {
  39. specName: "WIPs",
  40. name: {
  41. triggers: [/\bwip\b/i],
  42. isAlwaysAd: true,
  43. },
  44. tags: {
  45. triggers: [/\bwip\b/i],
  46. isAlwaysAd: true,
  47. },
  48. },
  49. {
  50. specName: "commission ads",
  51. name: {
  52. triggers: [/\bauction\b/i, commissionRegex, /\bwing.its?\b/i],
  53. isAdExpressions: [
  54. /\bclosed\b/i,
  55. /\bhalfbody\b/i,
  56. /\bopen(?:ed)?\b/i,
  57. /\bsale\b/i,
  58. /\bslots?\b/i,
  59. ],
  60. isNotAdExpressions: [
  61. /\bfor\b/i,
  62. new RegExp(`\\[${commissionRegexString}\\]`, "i"),
  63. new RegExp(`^${commissionRegexString}$`, "i"),
  64. ],
  65. },
  66. description: {
  67. triggers: [/.*/i],
  68. isNotAdExpressions: [userRefRegex],
  69. },
  70. },
  71. {
  72. specName: "streams by name",
  73. name: {
  74. triggers: [/\b(?:live)?stream\b/i],
  75. isAdExpressions: [
  76. /\blive\b/i,
  77. /\boffline\b/i,
  78. /\bonline\b/i,
  79. /\bpreorders?\b/i,
  80. /\bslots?\b/i,
  81. /\bup\b/i,
  82. ],
  83. },
  84. },
  85. {
  86. specName: "streams",
  87. name: {
  88. triggers: [/\bstream\b/i],
  89. isAlwaysAd: true,
  90. },
  91. tags: {
  92. triggers: [/\bstream\b/i],
  93. isAlwaysAd: true,
  94. },
  95. },
  96. {
  97. specName: "YCHs by name",
  98. name: {
  99. triggers: [/\by ?c ?h\b/i],
  100. isAdExpressions: [
  101. /\bauction\b/i,
  102. /\bavailable\b/i,
  103. /\bdiscount\b/i,
  104. /\bmultislot\b/i,
  105. /\bo ?p ?e ?n\b/i,
  106. /\bprice\b/i,
  107. /\bpreview\b/i,
  108. /\braffle\b/i,
  109. /\brem(?:ind(?:er)?)?\d*\b/i,
  110. /\brmd\b/i,
  111. /\bsale\b/i,
  112. /\bslots?\b/i,
  113. /\bsold\b/i,
  114. /\btaken\b/i,
  115. /\busd\b/i,
  116. /\b\$\d+\b/i,
  117. ],
  118. isNotAdExpressions: [commissionRegex, /\bfinished\b/i, /\bresult\b/i],
  119. },
  120. description: {
  121. triggers: [/.*/i],
  122. isNotAdExpressions: [userRefRegex],
  123. },
  124. untaggedIsAd: true,
  125. },
  126. {
  127. specName: "YCHs by name and tag",
  128. name: {
  129. triggers: [/\by ?c ?h\b/i],
  130. },
  131. tags: {
  132. triggers: [/\bauction\b/i, /\bych\b/i],
  133. isAlwaysAd: true,
  134. },
  135. },
  136. {
  137. specName: "discounts",
  138. name: {
  139. triggers: [/\bdiscount\b/i, /\bsale\b/i],
  140. isAdExpressions: [
  141. /\$/,
  142. /\bbase\b/i,
  143. /\bclaimed\b/i,
  144. /\b(?:multi)?slot\b/i,
  145. /\boffer\b/i,
  146. /\bprice\b/i,
  147. ],
  148. },
  149. },
  150. {
  151. specName: "price lists",
  152. name: {
  153. triggers: [/\bprice\b/i],
  154. isAdExpressions: [/\blist\b/i, /\bsheet\b/i],
  155. },
  156. },
  157. {
  158. specName: "raffles",
  159. name: {
  160. triggers: [/\braffle\b/i],
  161. isAdExpressions: [/\bwinners?\b/i],
  162. },
  163. },
  164. {
  165. specName: "memberships in names",
  166. name: {
  167. triggers: [
  168. /\bboosty\b/i,
  169. /\bp[@a]treon\b/i,
  170. /\bsub(?:scribe)?\s?star\b/i,
  171. ],
  172. isAdExpressions: [
  173. /\bdiscount\b/i,
  174. /\bnow on\b/i,
  175. /\bposted to\b/i,
  176. /\bpreview\b/i,
  177. /\bteaser?\b/i,
  178. ],
  179. },
  180. },
  181. {
  182. specName: "memberships teasers in name and description",
  183. name: {
  184. triggers: [/\bpreview\b/i, /\bteaser\b/i],
  185. },
  186. description: {
  187. triggers: [
  188. /\b(?:available|n[eo]w|out)\b.*\bon\b.*\b(?:boosty|p[@a]treon|sub(?:scribe)?\s?star)\b/i,
  189. ],
  190. isAlwaysAd: true,
  191. },
  192. },
  193. {
  194. specName: "shops",
  195. name: {
  196. triggers: [/\bshop\b/i],
  197. isAdExpressions: [/\bprint\b/i],
  198. },
  199. },
  200. {
  201. specName: "multislots",
  202. name: {
  203. triggers: [/\b(?:multi)?slots?\b/i],
  204. isAdExpressions: [/\bavailable\b/i, /\bopen\b/i, /\bsketch\b/i],
  205. },
  206. },
  207. {
  208. specName: "remaining name",
  209. name: {
  210. triggers: [
  211. /\bclosed\b/i,
  212. /\bopen\b/i,
  213. /\bpoll\b/i,
  214. /\bpreview\b/i,
  215. /\brem\b/i,
  216. /\bsold\b/i,
  217. /\bteaser\b/i,
  218. /\bwip\b/i,
  219. ],
  220. },
  221. },
  222. {
  223. specName: "remaining tags",
  224. tags: {
  225. triggers: [/\bteaser\b/i],
  226. },
  227. },
  228. ];
  229.  
  230. class AdvertisementCheckResult {
  231. /**
  232. * @param {boolean} isTagged
  233. * @param {AdvertisementLevel | null} [nameResult]
  234. * @param {AdvertisementLevel | null} [descriptionResult]
  235. * @param {AdvertisementLevel | null} [tagsResult]
  236. */
  237. constructor(isTagged, nameResult, descriptionResult, tagsResult) {
  238. this.#isTagged = isTagged;
  239. this.#nameResult = nameResult ?? null;
  240. this.#descriptionResult = descriptionResult ?? null;
  241. this.#tagsResult = tagsResult ?? null;
  242. }
  243.  
  244. /** @type {AdvertisementLevel | null} */
  245. #nameResult = null;
  246.  
  247. /** @type {AdvertisementLevel | null} */
  248. #descriptionResult = null;
  249.  
  250. /** @type {AdvertisementLevel | null} */
  251. #tagsResult = null;
  252.  
  253. #isTagged = false;
  254.  
  255. /** @type {DecisionLogEntry[]} */
  256. #decisionLog = [];
  257.  
  258. /**
  259. * @returns {AdvertisementLevel | null}
  260. */
  261. get nameResult() {
  262. return this.#nameResult;
  263. }
  264.  
  265. /**
  266. * @param {AdvertisementLevel | null} value
  267. */
  268. set nameResult(value) {
  269. this.#nameResult = this.#coalesceResultLevel(this.#nameResult, value);
  270. }
  271.  
  272. /**
  273. * @returns {AdvertisementLevel | null}
  274. */
  275. get descriptionResult() {
  276. return this.#descriptionResult;
  277. }
  278.  
  279. /**
  280. * @param {AdvertisementLevel | null} value
  281. */
  282. set descriptionResult(value) {
  283. this.#descriptionResult = this.#coalesceResultLevel(
  284. this.#descriptionResult,
  285. value,
  286. );
  287. }
  288.  
  289. /**
  290. * @returns {AdvertisementLevel | null}
  291. */
  292. get tagsResult() {
  293. return this.#tagsResult;
  294. }
  295.  
  296. /**
  297. * @param {AdvertisementLevel | null} value
  298. */
  299. set tagsResult(value) {
  300. this.#tagsResult = this.#coalesceResultLevel(this.#tagsResult, value);
  301. }
  302.  
  303. /**
  304. * @returns {AdvertisementLevel | null}
  305. */
  306. get result() {
  307. if (
  308. this.#nameResult === "notAdvertisement" ||
  309. this.#descriptionResult === "notAdvertisement" ||
  310. this.#tagsResult === "notAdvertisement"
  311. ) {
  312. return "notAdvertisement";
  313. }
  314.  
  315. if (
  316. this.#nameResult === "advertisement" ||
  317. this.#descriptionResult === "advertisement" ||
  318. this.#tagsResult === "advertisement"
  319. ) {
  320. return "advertisement";
  321. }
  322.  
  323. if (
  324. this.#nameResult === "ambiguous" ||
  325. this.#descriptionResult === "ambiguous" ||
  326. this.#tagsResult === "ambiguous"
  327. ) {
  328. return "ambiguous";
  329. }
  330.  
  331. return null;
  332. }
  333.  
  334. /**
  335. * @returns {boolean}
  336. */
  337. get isTagged() {
  338. return this.#isTagged;
  339. }
  340.  
  341. get decisionLog() {
  342. return this.#decisionLog;
  343. }
  344.  
  345. /**
  346. * @param {DecisionLogEntry} log
  347. */
  348. addToLog(log) {
  349. if (log.name === null && log.description === null && log.tags === null) {
  350. return;
  351. }
  352.  
  353. this.#decisionLog.push(log);
  354. }
  355.  
  356. /**
  357. * @param {AdvertisementLevel | null} current
  358. * @param {AdvertisementLevel | null} newValue
  359. * @returns {AdvertisementLevel | null}
  360. */
  361. #coalesceResultLevel(current, newValue) {
  362. if (newValue === null) {
  363. return current;
  364. }
  365.  
  366. if (current === "notAdvertisement" || newValue === "notAdvertisement") {
  367. return "notAdvertisement";
  368. }
  369.  
  370. if (current === "advertisement" && newValue === "ambiguous") {
  371. return current;
  372. }
  373.  
  374. return newValue;
  375. }
  376. }
  377.  
  378. /**
  379. * @param {string} text
  380. * @param {AdvertisementCheckSpecPart} spec
  381. * @returns {[AdvertisementLevel | null, DecisionLogEntryPart | null]}
  382. */
  383. function checkAgainstAdvertisementSpec(text, spec) {
  384. /** @type {AdvertisementLevel | null} */
  385. let level = null;
  386.  
  387. /** @type {DecisionLogEntryPart | null} */
  388. let log = null;
  389.  
  390. for (const regex of spec.triggers) {
  391. if (regex.test(text)) {
  392. level = "ambiguous";
  393. log = { trigger: regex, level };
  394. break;
  395. }
  396. }
  397.  
  398. if (level === "ambiguous") {
  399. if (spec.isAlwaysAd) {
  400. level = "advertisement";
  401. if (log) {
  402. log.isAlwaysAd = true;
  403. log.level = level;
  404. }
  405. } else if (spec.isAdExpressions) {
  406. for (const regex of spec.isAdExpressions) {
  407. if (regex.test(text)) {
  408. level = "advertisement";
  409. if (log) {
  410. log.isAdExpression = regex;
  411. log.level = level;
  412. }
  413. break;
  414. }
  415. }
  416. }
  417. }
  418.  
  419. if (level !== null && spec.isNotAdExpressions) {
  420. for (const regex of spec.isNotAdExpressions) {
  421. if (regex.test(text)) {
  422. level = "notAdvertisement";
  423. if (log) {
  424. log.isNotAdExpression = regex;
  425. log.level = level;
  426. }
  427. break;
  428. }
  429. }
  430. }
  431.  
  432. return [level, log];
  433. }
  434.  
  435. /**
  436. * @param {string} submissionName
  437. * @param {string} description
  438. * @param {string} tags
  439. * @returns {AdvertisementCheckResult}
  440. */
  441. function checkAgainstAdvertisementSpecs(submissionName, description, tags) {
  442. const isUntagged = tags === "";
  443.  
  444. /** @type {AdvertisementCheckResult} */
  445. const result = new AdvertisementCheckResult(!isUntagged);
  446.  
  447. for (const spec of advertisementCheckSpecs) {
  448. const [nameResult, nameLog] = checkAgainstAdvertisementSpec(
  449. submissionName,
  450. {
  451. triggers: [],
  452. ...spec.name,
  453. },
  454. );
  455.  
  456. const [descriptionResult, descriptionLog] = checkAgainstAdvertisementSpec(
  457. description,
  458. {
  459. triggers: [],
  460. ...spec.description,
  461. },
  462. );
  463.  
  464. /** @type {AdvertisementLevel | null} */
  465. let tagsResult = null;
  466.  
  467. /** @type {DecisionLogEntryPart | null} */
  468. let tagsLog = null;
  469.  
  470. if (isUntagged) {
  471. if (
  472. (["advertisement", "ambiguous"].includes(nameResult) ||
  473. ["advertisement", "ambiguous"].includes(descriptionResult)) &&
  474. spec.untaggedIsAd
  475. ) {
  476. tagsResult = "advertisement";
  477. tagsLog = {
  478. level: "advertisement",
  479. trigger: /^$/,
  480. isAlwaysAd: true,
  481. };
  482. }
  483. } else {
  484. [tagsResult, tagsLog] = checkAgainstAdvertisementSpec(tags, {
  485. triggers: [],
  486. ...spec.tags,
  487. });
  488. }
  489.  
  490. // TODO: Maybe change the accumulation to an overall weighting algorithm.
  491.  
  492. // Parts present in the same spec are interpreted as being "AND"
  493. // connected.
  494. let specPartsCount = 0;
  495. if (spec.name) specPartsCount++;
  496. if (spec.description) specPartsCount++;
  497. if (spec.tags) specPartsCount++;
  498. if (specPartsCount > 1) {
  499. if (spec.name && !nameResult) continue;
  500. if (spec.description && !descriptionResult) continue;
  501. if (spec.tags && !tagsResult) continue;
  502. }
  503.  
  504. result.nameResult = nameResult;
  505. result.descriptionResult = descriptionResult;
  506. result.tagsResult = tagsResult;
  507.  
  508. result.addToLog({
  509. specName: spec.specName,
  510. level: result.result,
  511. name: nameLog,
  512. description: descriptionLog,
  513. tags: tagsLog,
  514. });
  515. }
  516.  
  517. return result;
  518. }
  519.  
  520. /**
  521. * @returns {[number, number, number]}
  522. */
  523. function iterateSubmissions() {
  524. const figures = Array.from(
  525. document.querySelectorAll("section.gallery figure"),
  526. );
  527. let advertisements = 0;
  528. let ambiguous = 0;
  529. let untagged = 0;
  530.  
  531. for (const figure of figures) {
  532. const figcaption = figure.querySelector("figcaption");
  533. const checkbox = figure.querySelector("input");
  534. const nameAnchor = figcaption.querySelector("a");
  535. const submissionName = nameAnchor.textContent;
  536. const tags = figure.querySelector("img").dataset.tags;
  537. const description = descriptions[checkbox.value].description;
  538.  
  539. const result = checkAgainstAdvertisementSpecs(
  540. submissionName,
  541. description,
  542. tags,
  543. );
  544. const decisionLog = result.decisionLog;
  545.  
  546. if (decisionLog.length) {
  547. const button = document.createElement("button");
  548. button.type = "button";
  549. button.textContent = "Log";
  550. button.addEventListener("click", () => console.log(result.decisionLog));
  551. checkbox.parentElement.appendChild(button);
  552. }
  553.  
  554. switch (result.result) {
  555. case "advertisement":
  556. figure.classList.add("advertisement");
  557. checkbox.checked = true;
  558. advertisements += 1;
  559. break;
  560.  
  561. case "ambiguous":
  562. figure.classList.add("maybe-advertisement");
  563. ambiguous += 1;
  564. break;
  565. }
  566.  
  567. if (!result.isTagged) {
  568. figcaption.classList.add("not-tagged");
  569. untagged += 1;
  570. }
  571. }
  572.  
  573. return [advertisements, ambiguous, untagged];
  574. }
  575.  
  576. const sheet = new CSSStyleSheet();
  577. sheet.replaceSync(
  578. `
  579. figure.advertisement { outline: orange 3px solid; }
  580. figure.maybe-advertisement { outline: yellow 3px solid; }
  581. figcaption.not-tagged input { outline: orange 3px solid; }
  582. figcaption button { line-height: 1; margin-left: 1rem; padding: 0; }
  583. `.trim(),
  584. );
  585. document.adoptedStyleSheets.push(sheet);
  586.  
  587. const sectionHeader = document.querySelector(".section-header");
  588.  
  589. const advertisementsSelectMessage = document.createElement("p");
  590. advertisementsSelectMessage.textContent =
  591. "Checking for advertisement submissions…";
  592. sectionHeader.appendChild(advertisementsSelectMessage);
  593.  
  594. const [advertisements, ambiguous, untagged] = iterateSubmissions();
  595.  
  596. const message = `Selected ${advertisements} advertisement and ${ambiguous} ambiguous submissions. ${untagged} submissions were not tagged.`;
  597.  
  598. advertisementsSelectMessage.textContent = message;
  599. })();