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