SankakuEasySubs

Automate the management of tab subscriptions on chan.sankakucomplex.com

  1. // ==UserScript==
  2. // @name SankakuEasySubs
  3. // @namespace https://greasyfork.org/users/731869
  4. // @version 0.2
  5. // @license MIT
  6. // @description Automate the management of tab subscriptions on chan.sankakucomplex.com
  7. // @match https://chan.sankakucomplex.com/post/show/*
  8. // @match https://legacy.sankakucomplex.com/post/show/*
  9. // @run-at document-end
  10. // @grant GM.setValue
  11. // @grant GM.getValue
  12. // ==/UserScript==
  13. /* jshint esversion: 8 */
  14. const BASE_TAG_URL = "https://chan.sankakucomplex.com/tag_subscription";
  15. const GET_SUB_URL = `${BASE_TAG_URL}/index`;
  16. const CREATE_GROUP_URL = `${BASE_TAG_URL}/create`;
  17. const SAVE_GROUP_URL = `${BASE_TAG_URL}/update`;
  18. const USER_EDIT_URL = "https://chan.sankakucomplex.com/user/edit";
  19. const TAG_ROW_REGEX = /tag-subscription-row-(?<id>\d+)/;
  20. const MANAGED_PREFIX = "MANAGED_DONT_TOUCH_";
  21. const TAG_LIMIT = 20;
  22. const GROUP_LIMIT = 32;
  23. const GM_STORE_KEY = "subscription";
  24. /**
  25. * Unlike XMPHttpRequest, the fetch API will not reject even if the response has an HTTP error
  26. * status code. This helper function ensures that:
  27. * - it throws an error on HTTP error status codes
  28. *
  29. * @async
  30. * @param {string} url the URL of the request.
  31. * @param {string} on_err the error message when the response has an HTTP error status code.
  32. * @param {?(RequestInit | undefined)} [options] HTTP request options, same as the second
  33. * parameter of {@link fetch}.
  34. * @returns {Promise<Response>}
  35. */
  36. async function fetch_or(url, on_err, options) {
  37. const res = await fetch(url, options);
  38. if (res.status >= 400) {
  39. throw new Error(`${res.status} ${res.statusText}: ${on_err}`);
  40. }
  41. return res;
  42. }
  43. // interface TagSubs {
  44. // existing: number;
  45. // managed: TagGroup[];
  46. // }
  47. // const schema = {
  48. // type: "object",
  49. // properties: {
  50. // existing: { type: "number" },
  51. // managed: {
  52. // type: "array",
  53. // items: {
  54. // type: "object",
  55. // properties: {
  56. // id: { type: "number" },
  57. // tags: {
  58. // type: "array",
  59. // items: { type: "string" }
  60. // }
  61. // }
  62. // }
  63. // }
  64. // }
  65. // };
  66. /**
  67. * An aggregation of the core functionalities of this script.
  68. *
  69. * @class TagSubscriptions
  70. * @typedef {TagSubscriptions}
  71. */
  72. class TagSubscriptions {
  73. constructor(existing, managed) {
  74. this.existing = existing;
  75. this.managed = managed;
  76. const map = new Map();
  77. managed.forEach((group, i) => {
  78. for (const tag of group.tags) {
  79. map.set(tag, i);
  80. }
  81. });
  82. this.map = map;
  83. }
  84. /**
  85. * Initializes the script from {@link GET_SUB_URL} in the rare cases when the storage is wiped or
  86. * this script is removed by the user once.
  87. *
  88. * @private
  89. * @static
  90. * @async
  91. * @returns {Promise<TagSubscriptions>}
  92. */
  93. static async init() {
  94. const res = await fetch_or(GET_SUB_URL, "Failed at fetching subscription page");
  95. const sub_page = new DOMParser().parseFromString(await res.text(), "text/html");
  96. const rows = Array.from(sub_page.querySelectorAll("tr[id^='tag-subscription-row']"));
  97. const groups = rows
  98. .map(row => {
  99. var _a, _b, _c;
  100. const name = (_a = row.querySelector("input[id$='name']")) === null || _a === void 0 ? void 0 : _a.getAttribute("value");
  101. if (name === null || name === undefined) {
  102. throw new Error("Malformed tag subscription group name");
  103. }
  104. const id_match = TAG_ROW_REGEX.exec(row.id);
  105. if (id_match === null) {
  106. throw new Error("Malformed tag subscription row id");
  107. }
  108. const id = parseInt(id_match[1]);
  109. const tags = (_c = (_b = row.querySelector("input[id$='tag_query']")) === null || _b === void 0 ? void 0 : _b.getAttribute("value")) === null || _c === void 0 ? void 0 : _c.split(" ");
  110. if (tags === undefined) {
  111. throw new Error("Malformed tag subscription query");
  112. }
  113. return { name, id, tags };
  114. });
  115. const managed = groups.filter(group => group.name.startsWith(MANAGED_PREFIX)).map(({ id, tags }) => {
  116. return { id, tags };
  117. });
  118. managed.sort((a, b) => a.id - b.id);
  119. const subs = new TagSubscriptions(rows.length - managed.length, managed);
  120. // on initialization all un-managed tags are copied to managed groups
  121. for (const group of groups.filter(g => !g.name.startsWith(MANAGED_PREFIX))) {
  122. for (const tag of group.tags) {
  123. await subs.add_tag_without_commit(tag);
  124. }
  125. }
  126. await subs.commit();
  127. return subs;
  128. }
  129. /**
  130. * The usual way to initialize this script. The data has to be synchronized to the store because
  131. * of the following assumptions of user behavior:
  132. * 1. Users may load the script on multiple tabs simultaneously.
  133. * 2. Users are unlikely to interact with (i.e. click on generated links) the script on multiple
  134. * tabs simultaneously.
  135. * @static
  136. * @async
  137. * @returns {Promise<TagSubscriptions>}
  138. */
  139. static async init_from_store() {
  140. const stored = await GM.getValue(GM_STORE_KEY);
  141. if (typeof stored === "string") {
  142. // sankaku replaces the default JSON stringify functionality with a non-standard polyfill, in
  143. // this case an array of objects is stringified to a string instead
  144. const subs = JSON.parse(stored);
  145. return new TagSubscriptions(subs.existing, JSON.parse(subs.managed));
  146. }
  147. else {
  148. const subs = await TagSubscriptions.init();
  149. await subs.save_to_store();
  150. return subs;
  151. }
  152. }
  153. async save_to_store() {
  154. await GM.setValue(GM_STORE_KEY, JSON.stringify({
  155. existing: this.existing,
  156. managed: this.managed
  157. }));
  158. }
  159. async load_from_store() {
  160. const subs = await TagSubscriptions.init_from_store();
  161. this.existing = subs.existing;
  162. this.managed = subs.managed;
  163. this.map = subs.map;
  164. }
  165. async add_group() {
  166. // sankaku server rejects this POST request if these headers are not present
  167. const headers = {
  168. // "X-Prototype-Version": "1.6.0.3",
  169. "X-Requested-With": "XMLHttpRequest"
  170. };
  171. const res = await fetch_or(CREATE_GROUP_URL, "Failed at creating new group", { method: "POST", headers });
  172. const id_match = TAG_ROW_REGEX.exec(await res.text());
  173. if (id_match === null) {
  174. throw new Error("Malformed group creation response");
  175. }
  176. return parseInt(id_match[1]);
  177. }
  178. /**
  179. * Test whether a tag is contained by any of the managed tag groups.
  180. *
  181. * @param {string} tag
  182. * @returns {boolean}
  183. */
  184. contains_tag(tag) {
  185. return this.map.has(tag);
  186. }
  187. async add_tag_without_commit(tag) {
  188. if (this.contains_tag(tag)) {
  189. return;
  190. }
  191. let idx = this.managed.findIndex(group => group.tags.length < TAG_LIMIT);
  192. if (idx === -1) {
  193. if (this.existing + this.managed.length > GROUP_LIMIT) {
  194. throw new Error("Exceeded maximum number of tags");
  195. }
  196. const id = await this.add_group();
  197. this.managed.push({ id, tags: [] });
  198. idx = this.managed.length - 1;
  199. }
  200. this.managed[idx].tags.push(tag);
  201. this.map.set(tag, idx);
  202. }
  203. /**
  204. * Add a tag to the tag subscription. This operation must be synchronized or the changes made by
  205. * the user on other simultaneous tabs will be reverted.
  206. *
  207. * @async
  208. * @param {string} tag
  209. * @returns {*}
  210. */
  211. async add_tag(tag) {
  212. await this.load_from_store();
  213. await this.add_tag_without_commit(tag);
  214. await this.commit();
  215. await this.save_to_store();
  216. }
  217. /**
  218. * Remove a tag from the tag subscription. This operation must be synchronized or the changes made
  219. * by the user on other simultaneous tabs will be reverted.
  220. *
  221. * @async
  222. * @param {string} tag
  223. * @returns {*}
  224. */
  225. async remove_tag(tag) {
  226. await this.load_from_store();
  227. const idx = this.map.get(tag);
  228. if (idx === undefined) {
  229. return;
  230. }
  231. const query = this.managed[idx].tags;
  232. query.splice(query.indexOf(tag), 1);
  233. this.map.delete(tag);
  234. await this.commit();
  235. await this.save_to_store();
  236. }
  237. /**
  238. * Commit the current state of managed tag groups to the sankaku website.
  239. *
  240. * @async
  241. * @returns {*}
  242. */
  243. async commit() {
  244. const form_data = new URLSearchParams({
  245. "commit": "Save"
  246. });
  247. this.managed.forEach((group, i) => {
  248. form_data.append(`tag_subscription[${group.id}][name]`, `${MANAGED_PREFIX}${i.toString().padStart(2, "0")}`);
  249. form_data.append(`tag_subscription[${group.id}][tag_query]`, group.tags.join(" "));
  250. form_data.append(`tag_subscription[${group.id}][is_visible_on_profile]`, "false");
  251. });
  252. await fetch_or(SAVE_GROUP_URL, "Failed at updating tag groups", {
  253. method: "POST",
  254. body: form_data,
  255. redirect: "manual",
  256. });
  257. }
  258. }
  259. /**
  260. * Add links to easily add or remove tags from tag subscription on post page. Accepts
  261. * {@link TagSubscriptions} as a parameter because otherwise this function has to be async, but it
  262. * doesn't actually perform any async operation beside (potentially) initializing
  263. * {@link TagSubscriptions}.
  264. *
  265. * @param {TagSubscriptions} subs
  266. */
  267. function on_post_page_load(subs) {
  268. var _a;
  269. function detect_login_state() {
  270. var _a;
  271. const first_header = (_a = document.querySelector("#navbar > li:first-child > a")) === null || _a === void 0 ? void 0 : _a.textContent;
  272. return first_header === "My Account";
  273. }
  274. function anchor(sign, tag) {
  275. const a = document.createElement("a");
  276. a.href = "#";
  277. a.text = sign;
  278. a.addEventListener('click', async (e) => {
  279. e.preventDefault();
  280. e.stopPropagation();
  281. a.setAttribute("style", "pointer-events: none;");
  282. if (a.text === "+") {
  283. await subs.add_tag(tag);
  284. a.text = "-";
  285. }
  286. else {
  287. await subs.remove_tag(tag);
  288. a.text = "+";
  289. }
  290. a.setAttribute("style", "");
  291. });
  292. return a;
  293. }
  294. if (!detect_login_state()) {
  295. throw new Error("Not logged in");
  296. }
  297. const sidebar = document.querySelector("#tag-sidebar");
  298. if (sidebar === null) {
  299. throw new Error("Tag sidebar not found");
  300. }
  301. for (const div of sidebar.querySelectorAll("li[class^='tag-type'] > div[id^='tag_container']")) {
  302. const tag = (_a = div.querySelector("a")) === null || _a === void 0 ? void 0 : _a.text.replace(" ", "_");
  303. if (tag === undefined) {
  304. throw new Error("Tag name not found");
  305. }
  306. const sign = subs.contains_tag(tag) ? "-" : "+";
  307. div.prepend(document.createTextNode(" "));
  308. div.prepend(anchor(sign, tag));
  309. }
  310. }
  311. /**
  312. * Test whether user is logged in to sankaku. The better idea is to check the status code and
  313. * Location header of each http response to see whether they are redirected to the login page,
  314. * however that's currently impossible because of:
  315. * - Redirected http requests are opaque in fetch API and invisible in XMLHttpRequest API _by
  316. * design_.
  317. * - Those requests would be blocked by most user's browser because sankaku redirects HTTPS requests
  318. * to HTTP login page, triggering un-catchable "insecure content on secure page" error hence the
  319. * final destination is unobtainable in an user-script.
  320. *
  321. * ## known problems
  322. * The current implementation of login test is vulnerable to TOCTOU problems, e.g. if users log out
  323. * then browser back to a post page they will be able to click generated links while logged out thus
  324. * cause inconsistency.
  325. *
  326. * @async
  327. * @returns {Promise<boolean>} return `true` if user is logged in.
  328. */
  329. async function login_test() {
  330. const res = await fetch_or(USER_EDIT_URL, "login test failed", { redirect: "manual" });
  331. return !res.redirected;
  332. }
  333. (async () => {
  334. "use strict";
  335. if (!await login_test()) {
  336. throw new Error("Not logged in");
  337. }
  338. const subs = await TagSubscriptions.init_from_store();
  339. on_post_page_load(subs);
  340. })().catch(console.error);