Twitter Click'n'Save

Add buttons to download images and videos in Twitter, also does some other enhancements.

当前为 2022-05-26 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Click'n'Save
  3. // @version 0.6.10-2022.05.25
  4. // @namespace gh.alttiri
  5. // @description Add buttons to download images and videos in Twitter, also does some other enhancements.
  6. // @match https://twitter.com/*
  7. // @homepageURL https://github.com/AlttiRi/twitter-click-and-save
  8. // @supportURL https://github.com/AlttiRi/twitter-click-and-save/issues
  9. // @license GPL-3.0
  10. // @grant GM_registerMenuCommand
  11. // ==/UserScript==
  12. // ---------------------------------------------------------------------------------------------------------------------
  13. // ---------------------------------------------------------------------------------------------------------------------
  14.  
  15. if (globalThis.GM_registerMenuCommand /* undefined in Firefox with VM */ || typeof GM_registerMenuCommand === "function") {
  16. GM_registerMenuCommand("Show settings", showSettings);
  17. }
  18.  
  19. // --- For debug --- //
  20. const verbose = false;
  21.  
  22.  
  23. const settings = loadSettings();
  24.  
  25. function loadSettings() {
  26. const defaultSettings = {
  27. hideTrends: true,
  28. hideSignUpSection: true,
  29. hideTopicsToFollow: false,
  30. hideTopicsToFollowInstantly: false,
  31. hideSignUpBottomBarAndMessages: true,
  32. doNotPlayVideosAutomatically: false,
  33. goFromMobileToMainSite: false,
  34.  
  35. highlightVisitedLinks: true,
  36. expandSpoilers: true,
  37.  
  38. directLinks: true,
  39. handleTitle: true,
  40.  
  41. imagesHandler: true,
  42. videoHandler: true,
  43. addRequiredCSS: true,
  44. preventBlinking: true,
  45. };
  46.  
  47. let savedSettings;
  48. try {
  49. savedSettings = JSON.parse(localStorage.getItem("ujs-click-n-save-settings")) || {};
  50. } catch (e) {
  51. console.error("[ujs]", e);
  52. localStorage.removeItem("ujs-click-n-save-settings");
  53. savedSettings = {};
  54. }
  55. savedSettings = Object.assign(defaultSettings, savedSettings);
  56. return savedSettings;
  57. }
  58. function showSettings() {
  59. closeSetting();
  60. if (window.scrollY > 0) {
  61. document.querySelector("html").classList.add("ujs-scroll-initial");
  62. document.body.classList.add("ujs-scrollbar-width-margin-right");
  63. }
  64. document.body.classList.add("ujs-no-scroll");
  65.  
  66. const modalWrapperStyle = `
  67. width: 100%;
  68. height: 100%;
  69. position: fixed;
  70. display: flex;
  71. justify-content: center;
  72. align-items: center;
  73. z-index: 99999;
  74. backdrop-filter: blur(4px);
  75. background-color: rgba(255, 255, 255, 0.5);
  76. `;
  77. const modalSettingsStyle = `
  78. background-color: white;
  79. min-width: 320px;
  80. min-height: 320px;
  81. border: 1px solid darkgray;
  82. padding: 8px;
  83. box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
  84. `;
  85. const s = settings;
  86. document.body.insertAdjacentHTML("afterbegin", `
  87. <div class="ujs-modal-wrapper" style="${modalWrapperStyle}">
  88. <div class="ujs-modal-settings" style="${modalSettingsStyle}">
  89. <fieldset>
  90. <legend>Optional</legend>
  91. <label><input type="checkbox" ${s.hideTrends ? "checked" : ""} name="hideTrends">Hide <b>Trends</b> (in the right column)*<br/></label>
  92. <label><input type="checkbox" ${s.hideSignUpSection ? "checked" : ""} name="hideSignUpSection">Hide <b title='"New to Twitter?" (If yoy are not logged in)'>Sign Up</b> section (in the right column)*<br/></label>
  93. <label><input type="checkbox" ${s.hideSignUpBottomBarAndMessages ? "checked" : ""} name="hideSignUpBottomBarAndMessages">Hide <b>Sign Up Bar</b> and <b>Messages</b> (in the bottom)<br/></label>
  94. <label hidden><input type="checkbox" ${s.doNotPlayVideosAutomatically ? "checked" : ""} name="doNotPlayVideosAutomatically">Do <i>Not</i> Play Videos Automatically</b><br/></label>
  95. <label hidden><input type="checkbox" ${s.goFromMobileToMainSite ? "checked" : ""} name="goFromMobileToMainSite">Redirect from Mobile version (beta)<br/></label>
  96. </fieldset>
  97. <fieldset>
  98. <legend>Recommended</legend>
  99. <label><input type="checkbox" ${s.highlightVisitedLinks ? "checked" : ""} name="highlightVisitedLinks">Highlight Visited Links<br/></label>
  100. <label title="Note: since the recent update the most NSFW spoilers are impossible to expand without an account"><input type="checkbox" ${s.expandSpoilers ? "checked" : ""} name="expandSpoilers">Expand Spoilers (if possible)*<br/></label>
  101. </fieldset>
  102. <fieldset>
  103. <legend>Highly Recommended</legend>
  104. <label><input type="checkbox" ${s.directLinks ? "checked" : ""} name="directLinks">Direct Links</label><br/>
  105. <label><input type="checkbox" ${s.handleTitle ? "checked" : ""} name="handleTitle">Enchance Title*<br/></label>
  106. </fieldset>
  107. <fieldset>
  108. <legend>Main</legend>
  109. <label><input type="checkbox" ${s.imagesHandler ? "checked" : ""} name="imagesHandler">Image Download Button<br/></label>
  110. <label><input type="checkbox" ${s.videoHandler ? "checked" : ""} name="videoHandler">Video Download Button<br/></label>
  111. <label title="Prevent the tweet backgroubd blinking on the button click"><input type="checkbox" ${s.preventBlinking ? "checked" : ""} name="preventBlinking">Prevent blinking on click<br/></label>
  112. <label hidden><input type="checkbox" ${s.addRequiredCSS ? "checked" : ""} name="addRequiredCSS">Add Required CSS*<br/></label><!-- * Only for the image download button in /photo/1 mode -->
  113. </fieldset>
  114. <fieldset>
  115. <legend title="Outdated due to Twitter's updates, impossible to reimplement">Outdated</legend>
  116. <strike>
  117. <label><input type="checkbox" ${s.hideTopicsToFollow ? "checked" : ""} name="hideTopicsToFollow">Hide <b>Topics To Follow</b> (in the right column)*<br/></label>
  118. <label hidden><input type="checkbox" ${s.hideTopicsToFollowInstantly ? "checked" : ""} name="hideTopicsToFollowInstantly">Hide <b>Topics To Follow</b> Instantly*<br/></label>
  119. </strike>
  120. </fieldset>
  121. <hr>
  122. <div style="display: flex; justify-content: space-around;">
  123. <button class="ujs-save-setting-button" style="padding: 5px">Save Settings</button>
  124. <button class="ujs-close-setting-button" style="padding: 5px">Close Settings</button>
  125. </div>
  126. <hr>
  127. <h4 style="margin: 0; padding-left: 8px; color: #444;">Notes:</h4>
  128. <ul style="margin: 2px; padding-left: 16px; color: #444;">
  129. <li>Click on <b>Save Settings</b> and reload the page to apply changes.</li>
  130. <li><b>*</b>-marked settings are language dependent. Currently, the follow languages are supported:<br/> "en", "ru", "es", "zh", "ja".</li>
  131. <li hidden>The extension downloads only from twitter.com, not from <b>mobile</b>.twitter.com</li>
  132. </ul>
  133. </div>
  134. </div>`);
  135.  
  136. document.querySelector("body > .ujs-modal-wrapper .ujs-save-setting-button").addEventListener("click", saveSetting);
  137. document.querySelector("body > .ujs-modal-wrapper .ujs-close-setting-button").addEventListener("click", closeSetting);
  138.  
  139. function saveSetting() {
  140. const entries = [...document.querySelectorAll("body > .ujs-modal-wrapper input[type=checkbox]")]
  141. .map(checkbox => [checkbox.name, checkbox.checked]);
  142. const settings = Object.fromEntries(entries);
  143. settings.hideTopicsToFollowInstantly = settings.hideTopicsToFollow;
  144. // console.log("[ujs]", settings);
  145. localStorage.setItem("ujs-click-n-save-settings", JSON.stringify(settings));
  146. }
  147.  
  148. function closeSetting() {
  149. document.body.classList.remove("ujs-no-scroll");
  150. document.body.classList.remove("ujs-scrollbar-width-margin-right");
  151. document.querySelector("html").classList.remove("ujs-scroll-initial");
  152. document.querySelector("body > .ujs-modal-wrapper")?.remove();
  153. }
  154. }
  155.  
  156.  
  157.  
  158. // ---------------------------------------------------------------------------------------------------------------------
  159. // ---------------------------------------------------------------------------------------------------------------------
  160.  
  161. // --- Features to execute --- //
  162. const doNotPlayVideosAutomatically = false;
  163.  
  164. function execFeaturesOnce() {
  165. settings.goFromMobileToMainSite && Features.goFromMobileToMainSite();
  166. settings.addRequiredCSS && Features.addRequiredCSS();
  167. settings.hideSignUpBottomBarAndMessages && Features.hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically);
  168. settings.hideTrends && Features.hideTrends();
  169. settings.highlightVisitedLinks && Features.highlightVisitedLinks();
  170. settings.hideTopicsToFollowInstantly && Features.hideTopicsToFollowInstantly();
  171. }
  172. function execFeaturesImmediately() {
  173. settings.expandSpoilers && Features.expandSpoilers();
  174. }
  175. function execFeatures() {
  176. settings.imagesHandler && Features.imagesHandler(settings.preventBlinking);
  177. settings.videoHandler && Features.videoHandler(settings.preventBlinking);
  178. settings.expandSpoilers && Features.expandSpoilers();
  179. settings.hideSignUpSection && Features.hideSignUpSection();
  180. settings.hideTopicsToFollow && Features.hideTopicsToFollow();
  181. settings.directLinks && Features.directLinks();
  182. settings.handleTitle && Features.handleTitle();
  183. }
  184.  
  185. // ---------------------------------------------------------------------------------------------------------------------
  186. // ---------------------------------------------------------------------------------------------------------------------
  187.  
  188. if (verbose) {
  189. console.log("[ujs][settings]", settings);
  190. // showSettings();
  191. }
  192.  
  193. // --- [VM/GM + Firefox ~90+ + Enabled "Strict Tracking Protection"] fix --- //
  194. const fetch = (globalThis.wrappedJSObject && typeof globalThis.wrappedJSObject.fetch === "function") ? function(resource, init = {}) {
  195. verbose && console.log("wrappedJSObject.fetch", resource, init);
  196.  
  197. if (init.headers instanceof Headers) {
  198. // Since `Headers` are not allowed for structured cloning.
  199. init.headers = Object.fromEntries(init.headers.entries());
  200. }
  201.  
  202. return globalThis.wrappedJSObject.fetch(cloneInto(resource, document), cloneInto(init, document));
  203. } : globalThis.fetch;
  204.  
  205.  
  206. // --- "Imports" --- //
  207. const {
  208. sleep, fetchResource, download,
  209. addCSS,
  210. getCookie,
  211. throttle,
  212. xpath, xpathAll,
  213. getNearestElementByType, getParentWithSiblingDataset,
  214. } = getUtils({verbose});
  215. const LS = hoistLS({verbose});
  216.  
  217. const API = hoistAPI();
  218. const Tweet = hoistTweet();
  219. const Features = hoistFeatures();
  220. const I18N = getLanguageConstants();
  221.  
  222.  
  223. // --- That to use for the image history --- //
  224. // "TWEET_ID" or "IMAGE_NAME"
  225. const imagesHistoryBy = LS.getItem("ujs-images-history-by", "IMAGE_NAME");
  226. // With "TWEET_ID" downloading of 1 image of 4 will mark all 4 images as "already downloaded"
  227. // on the next time when the tweet will appear.
  228. // "IMAGE_NAME" will count each image of a tweet, but it will take more data to store.
  229.  
  230.  
  231. // ---------------------------------------------------------------------------------------------------------------------
  232. // ---------------------------------------------------------------------------------------------------------------------
  233. // --- Script runner --- //
  234.  
  235. (function starter(feats) {
  236. const {once, onChangeImmediate, onChange} = feats;
  237.  
  238. once();
  239. onChangeImmediate();
  240. const onChangeThrottled = throttle(onChange, 250);
  241. onChangeThrottled();
  242.  
  243. const targetNode = document.querySelector("body");
  244. const observerOptions = {
  245. subtree: true,
  246. childList: true,
  247. };
  248. const observer = new MutationObserver(callback);
  249. observer.observe(targetNode, observerOptions);
  250.  
  251. function callback(mutationList, observer) {
  252. verbose && console.log(mutationList);
  253. onChangeImmediate();
  254. onChangeThrottled();
  255. }
  256. })({
  257. once: execFeaturesOnce,
  258. onChangeImmediate: execFeaturesImmediately,
  259. onChange: execFeatures
  260. });
  261.  
  262. // ---------------------------------------------------------------------------------------------------------------------
  263. // ---------------------------------------------------------------------------------------------------------------------
  264. // --- Twitter Specific code --- //
  265.  
  266.  
  267. const downloadedImages = new LS("ujs-twitter-downloaded-images-names");
  268. const downloadedImageTweetIds = new LS("ujs-twitter-downloaded-image-tweet-ids");
  269. const downloadedVideoTweetIds = new LS("ujs-twitter-downloaded-video-tweet-ids");
  270.  
  271. // --- Twitter.Features --- //
  272. function hoistFeatures() {
  273. class Features {
  274. static goFromMobileToMainSite() {
  275. if (location.href.startsWith("https://mobile.twitter.com/")) {
  276. location.href = location.href.replace("https://mobile.twitter.com/", "https://twitter.com/");
  277. }
  278. // TODO: add #redirected, remove by timer // to prevent a potential infinity loop
  279. }
  280. static _ImageHistory = class {
  281. static getImageNameFromUrl(url) {
  282. const _url = new URL(url);
  283. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  284. return filename.match(/^[^\.]+/)[0]; // remove extension
  285. }
  286. static isDownloaded({id, url}) {
  287. if (imagesHistoryBy === "TWEET_ID") {
  288. return downloadedImageTweetIds.hasItem(id);
  289. } else if (imagesHistoryBy === "IMAGE_NAME") {
  290. const name = Features._ImageHistory.getImageNameFromUrl(url);
  291. return downloadedImages.hasItem(name);
  292. }
  293. }
  294. static async markDownloaded({id, url}) {
  295. if (imagesHistoryBy === "TWEET_ID") {
  296. await downloadedImageTweetIds.pushItem(id);
  297. } else if (imagesHistoryBy === "IMAGE_NAME") {
  298. const name = Features._ImageHistory.getImageNameFromUrl(url);
  299. await downloadedImages.pushItem(name);
  300. }
  301. }
  302. }
  303. static async imagesHandler(preventBlinking) {
  304. verbose && console.log("[ujs-cns][imagesHandler]");
  305. const images = document.querySelectorAll("img");
  306. for (const img of images) {
  307.  
  308. if (img.width < 150 || img.dataset.handled) {
  309. continue;
  310. }
  311. verbose && console.log(img, img.width);
  312.  
  313. img.dataset.handled = "true";
  314.  
  315. const btn = document.createElement("div");
  316. btn.classList.add("ujs-btn-download");
  317. btn.dataset.url = img.src;
  318.  
  319. btn.addEventListener("click", Features._imageClickHandler);
  320.  
  321. let anchor = getNearestElementByType(img, "a");
  322. // if an image is _opened_ "https://twitter.com/UserName/status/1234567890123456789/photo/1" [fake-url]
  323. if (!anchor) {
  324. anchor = img.parentNode;
  325. }
  326. anchor.append(btn);
  327. if (preventBlinking) {
  328. Features._preventBlinking(btn);
  329. }
  330.  
  331. const downloaded = Features._ImageHistory.isDownloaded({
  332. id: Tweet.of(btn).id,
  333. url: btn.dataset.url
  334. });
  335. if (downloaded) {
  336. btn.classList.add("ujs-already-downloaded");
  337. }
  338. }
  339. }
  340. static hasBlinkListenerWeakSet;
  341. static _preventBlinking(clickBtnElem) {
  342. const weakSet = Features.hasBlinkListenerWeakSet || (Features.hasBlinkListenerWeakSet = new WeakSet());
  343. let wrapper;
  344. clickBtnElem.addEventListener("mouseenter", () => {
  345. if (!weakSet.has(clickBtnElem)) {
  346. wrapper = Features._preventBlinkingHandler(clickBtnElem);
  347. weakSet.add(clickBtnElem);
  348. }
  349. });
  350. clickBtnElem.addEventListener("mouseleave", () => {
  351. verbose && console.log("[ujs] Btn mouseleave");
  352. if (wrapper?.observer?.disconnect) {
  353. weakSet.delete(clickBtnElem);
  354. wrapper.observer.disconnect();
  355. }
  356. });
  357. }
  358. static _preventBlinkingHandler(clickBtnElem) {
  359. let targetNode = clickBtnElem.closest("[aria-labelledby]");
  360. if (!targetNode) {
  361. return;
  362. }
  363. let config = {attributes: true, subtree: true, attributeOldValue: true};
  364. const wrapper = {};
  365. wrapper.observer = new MutationObserver(callback);
  366. wrapper.observer.observe(targetNode, config);
  367. function callback(mutationsList, observer) {
  368. for (const mutation of mutationsList) {
  369. if (mutation.type === "attributes" && mutation.attributeName === "class") {
  370. if (mutation.target.classList.contains("ujs-btn-download")) {
  371. return;
  372. }
  373. // Don't allow to change classList
  374. mutation.target.className = mutation.oldValue;
  375. // Recreate, to prevent an infinity loop
  376. wrapper.observer.disconnect();
  377. wrapper.observer = new MutationObserver(callback);
  378. wrapper.observer.observe(targetNode, config);
  379. }
  380. }
  381. }
  382. return wrapper;
  383. }
  384. static async _imageClickHandler(event) {
  385. event.preventDefault();
  386. event.stopImmediatePropagation();
  387.  
  388. const btn = event.currentTarget;
  389. const url = handleImgUrl(btn.dataset.url);
  390. verbose && console.log(url);
  391.  
  392. function handleImgUrl(url) {
  393. const urlObj = new URL(url);
  394. urlObj.searchParams.set("name", "orig");
  395. return urlObj.toString();
  396. }
  397.  
  398. const {id, author} = Tweet.of(btn);
  399. verbose && console.log(id, author);
  400.  
  401. async function safeFetchResource(url) {
  402. let fallbackUsed = false;
  403. retry:
  404. while (true) {
  405. try {
  406. return await fetchResource(url);
  407. } catch (e) {
  408. if (fallbackUsed) {
  409. throw "Fallback URL failed";
  410. }
  411. const _url = new URL(url);
  412. _url.searchParams.set("name", "4096x4096");
  413. url = _url.href;
  414. verbose && console.warn("[safeFetchResource] Fallback URL:", url);
  415. fallbackUsed = true;
  416. continue retry;
  417. }
  418. }
  419.  
  420. }
  421.  
  422. btn.classList.add("ujs-downloading");
  423. const {blob, lastModifiedDate, extension, name} = await safeFetchResource(url);
  424.  
  425. const filename = `[twitter] ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
  426. download(blob, filename, url);
  427.  
  428. const downloaded = btn.classList.contains("already-downloaded");
  429. if (!downloaded) {
  430. await Features._ImageHistory.markDownloaded({id, url});
  431. }
  432. btn.classList.remove("ujs-downloading");
  433. btn.classList.add("ujs-downloaded");
  434. }
  435.  
  436.  
  437. static async videoHandler(preventBlinking) {
  438. const videos = document.querySelectorAll("video");
  439.  
  440. for (const vid of videos) {
  441. if (vid.dataset.handled) {
  442. continue;
  443. }
  444. verbose && console.log(vid);
  445. vid.dataset.handled = "true";
  446.  
  447. const btn = document.createElement("div");
  448. btn.classList.add("ujs-btn-download");
  449. btn.classList.add("ujs-video");
  450. btn.addEventListener("click", Features._videoClickHandler);
  451.  
  452. let elem = vid.parentNode.parentNode.parentNode;
  453. elem.after(btn);
  454. if (preventBlinking) {
  455. Features._preventBlinking(btn);
  456. }
  457.  
  458. const id = Tweet.of(btn).id;
  459. const downloaded = downloadedVideoTweetIds.hasItem(id);
  460. if (downloaded) {
  461. btn.classList.add("ujs-already-downloaded");
  462. }
  463. }
  464. }
  465. static async _videoClickHandler(event) {
  466. event.preventDefault();
  467. event.stopImmediatePropagation();
  468.  
  469. const btn = event.currentTarget;
  470. const {id, author} = Tweet.of(btn);
  471. const video = await API.getVideoInfo(id); // {bitrate, content_type, url}
  472. verbose && console.log(video);
  473.  
  474. btn.classList.add("ujs-downloading");
  475. const url = video.url;
  476. const {blob, lastModifiedDate, extension, name} = await fetchResource(url);
  477.  
  478. const filename = `[twitter] ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
  479. download(blob, filename, url);
  480.  
  481. const downloaded = btn.classList.contains("ujs-already-downloaded");
  482. if (!downloaded) {
  483. await downloadedVideoTweetIds.pushItem(id);
  484. }
  485. btn.classList.remove("ujs-downloading");
  486. btn.classList.add("ujs-downloaded");
  487. }
  488.  
  489.  
  490. static addRequiredCSS() {
  491. addCSS(getUserScriptCSS());
  492. }
  493.  
  494. // it depends of `directLinks()` use only it after `directLinks()`
  495. static handleTitle(title) {
  496. if (!I18N.QUOTES) { // Unsupported lang, no QUOTES, ON_TWITTER, TWITTER constants
  497. return;
  498. }
  499. // if not a opened tweet
  500. if (!location.href.match(/twitter\.com\/[^\/]+\/status\/\d+/)) {
  501. return;
  502. }
  503.  
  504. let titleText = title || document.title;
  505. if (titleText === Features.lastHandledTitle) {
  506. return;
  507. }
  508. Features.originalTitle = titleText;
  509.  
  510. const [OPEN_QUOTE, CLOSE_QUOTE] = I18N.QUOTES;
  511. const urlsToReplace = [
  512. ...titleText.matchAll(new RegExp(`https:\\/\\/t\\.co\\/[^ ${CLOSE_QUOTE}]+`, "g"))
  513. ].map(el => el[0]);
  514. // the last one may be the URL to the tweet // or to an embedded shared URL
  515.  
  516. const map = new Map();
  517. const anchors = document.querySelectorAll(`a[data-redirect^="https://t.co/"]`);
  518. for (const anchor of anchors) {
  519. if (urlsToReplace.includes(anchor.dataset.redirect)) {
  520. map.set(anchor.dataset.redirect, anchor.href);
  521. }
  522. }
  523.  
  524. const lastUrl = urlsToReplace.slice(-1)[0];
  525. let lastUrlIsAttachment = false;
  526. let attachmentDescription = "";
  527. if (!map.has(lastUrl)) {
  528. const a = document.querySelector(`a[href="${lastUrl}?amp=1"]`);
  529. if (a) {
  530. lastUrlIsAttachment = true;
  531. attachmentDescription = document.querySelectorAll(`a[href="${lastUrl}?amp=1"]`)[1].innerText;
  532. attachmentDescription = attachmentDescription.replaceAll("\n", " — ");
  533. }
  534. }
  535.  
  536.  
  537. for (const [key, value] of map.entries()) {
  538. titleText = titleText.replaceAll(key, value + ` (${key})`);
  539. }
  540.  
  541. titleText = titleText.replace(new RegExp(`${I18N.ON_TWITTER}(?= ${OPEN_QUOTE})`), ":");
  542. titleText = titleText.replace(new RegExp(`(?<=${CLOSE_QUOTE}) \\\/ ${I18N.TWITTER}$`), "");
  543. if (!lastUrlIsAttachment) {
  544. const regExp = new RegExp(`(?<short> https:\\/\\/t\\.co\\/.{6,14})${CLOSE_QUOTE}$`);
  545. titleText = titleText.replace(regExp, (match, p1, p2, offset, string) => `${CLOSE_QUOTE} ${p1}`);
  546. } else {
  547. titleText = titleText.replace(lastUrl, `${lastUrl} (${attachmentDescription})`);
  548. }
  549. document.title = titleText; // Note: some characters will be removed automatically (`\n`, extra spaces)
  550. Features.lastHandledTitle = document.title;
  551. }
  552. static lastHandledTitle = "";
  553. static originalTitle = "";
  554.  
  555. static profileUrlCache = new Map();
  556. static async directLinks() {
  557. verbose && console.log("[ujs][directLinks]");
  558. const hasHttp = url => Boolean(url.match(/^https?:\/\//));
  559. const anchors = xpathAll(`.//a[@dir="ltr" and child::span and not(@data-handled)]`);
  560. for (const anchor of anchors) {
  561. const redirectUrl = new URL(anchor.href);
  562. const shortUrl = redirectUrl.origin + redirectUrl.pathname; // remove "?amp=1"
  563. anchor.dataset.redirect = shortUrl;
  564. anchor.dataset.handled = "true";
  565. anchor.rel = "nofollow noopener noreferrer";
  566.  
  567. if (Features.profileUrlCache.has(shortUrl)) {
  568. anchor.href = Features.profileUrlCache.get(shortUrl);
  569. continue;
  570. }
  571.  
  572. const nodes = xpathAll(`./span[text() != "…"]|./text()`, anchor);
  573. let url = nodes.map(node => node.textContent).join("");
  574. const doubleProtocolPrefix = url.match(/(?<dup>^https?:\/\/)(?=https?:)/)?.groups.dup;
  575. if (doubleProtocolPrefix) {
  576. url = url.slice(doubleProtocolPrefix.length);
  577. const span = anchor.querySelector(`[aria-hidden="true"]`);
  578. if (hasHttp(span.textContent)) { // Fix Twitter's bug related to text copying
  579. span.style = "display: none;";
  580. }
  581. }
  582. anchor.href = url;
  583. if (anchor.dataset?.testid === "UserUrl") {
  584. const href = anchor.getAttribute("href");
  585. const profileUrl = hasHttp(href) ? href : "https://" + href;
  586. anchor.href = profileUrl;
  587. verbose && console.log("[ujs][directLinks][UserUrl]", profileUrl);
  588. // Restore if URL's text content is too long
  589. if (anchor.textContent.endsWith("…")) {
  590. anchor.href = shortUrl;
  591. try {
  592. const author = location.pathname.slice(1).match(/[^\/]+/)[0];
  593. const expanded_url = await API.getUserInfo(author); // todo: make lazy
  594. anchor.href = expanded_url;
  595. Features.profileUrlCache.set(shortUrl, expanded_url);
  596. } catch (e) {
  597. verbose && console.error(e);
  598. }
  599. }
  600. }
  601. }
  602. if (anchors.length) {
  603. Features.handleTitle(Features.originalTitle);
  604. }
  605. }
  606.  
  607. // Do NOT throttle it
  608. static expandSpoilers() {
  609. const main = document.querySelector("main[role=main]");
  610. if (!main) {
  611. return;
  612. }
  613. if (!I18N.YES_VIEW_PROFILE) { // Unsupported lang, no YES_VIEW_PROFILE, SHOW_NUDITY, VIEW constants
  614. return;
  615. }
  616.  
  617. const a = main.querySelectorAll("[data-testid=primaryColumn] [role=button]");
  618. if (a) {
  619. const elems = [...a];
  620. const button = elems.find(el => el.textContent === I18N.YES_VIEW_PROFILE);
  621. if (button) {
  622. button.click();
  623. }
  624. // "Content warning: Nudity"
  625. // "The Tweet author flagged this Tweet as showing sensitive content.""
  626. // "Show"
  627. const buttonShow = elems.find(el => el.textContent === I18N.SHOW_NUDITY);
  628. if (buttonShow) {
  629. //const verifing = a.previousSibling.textContent.includes("Nudity"); // todo?
  630. //if (verifing) {
  631. buttonShow.click();
  632. //}
  633. }
  634. }
  635. // todo: expand spoiler commentary in photo view mode (.../photo/1)
  636. const b = main.querySelectorAll("article [role=presentation] div[role=button]");
  637. if (b) {
  638. const elems = [...b];
  639. const buttons = elems.filter(el => el.textContent === I18N.VIEW);
  640. if (buttons.length) {
  641. buttons.forEach(el => el.click());
  642. }
  643. }
  644. }
  645.  
  646. static hideSignUpSection() { // "New to Twitter?"
  647. if (!I18N.SIGNUP) {// Unsupported lang, no SIGNUP constant
  648. return;
  649. }
  650. const elem = document.querySelector(`section[aria-label="${I18N.SIGNUP}"][role=region]`);
  651. if (elem) {
  652. elem.parentNode.classList.add("ujs-hidden");
  653. }
  654. }
  655.  
  656. // Call it once.
  657. // "Don’t miss what’s happening" if you are not logged in.
  658. // It looks that `#layers` is used only for this bar.
  659. static hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically) {
  660. if (doNotPlayVideosAutomatically) {
  661. addCSS(`
  662. #layers > div:nth-child(1) {
  663. display: none;
  664. }
  665. `);
  666. } else {
  667. addCSS(`
  668. #layers > div:nth-child(1) {
  669. height: 1px;
  670. opacity: 0;
  671. }
  672. `);
  673. }
  674. }
  675.  
  676. // "Trends for you"
  677. static hideTrends() {
  678. if (!I18N.TRENDS) { // Unsupported lang, no TRENDS constant
  679. return;
  680. }
  681. addCSS(`
  682. [aria-label="${I18N.TRENDS}"]
  683. {
  684. display: none;
  685. }
  686. `);
  687. }
  688. static highlightVisitedLinks() {
  689. addCSS(`
  690. a:visited {
  691. color: darkorange;
  692. }
  693. `);
  694. }
  695.  
  696. // Hides "TOPICS TO FOLLOW" only in the right column, NOT in timeline.
  697. // Use it once. To prevent blinking.
  698. static hideTopicsToFollowInstantly() {
  699. if (!I18N.TOPICS_TO_FOLLOW) { // Unsupported lang, no TOPICS_TO_FOLLOW constant
  700. return;
  701. }
  702. addCSS(`
  703. div[aria-label="${I18N.TOPICS_TO_FOLLOW}"] {
  704. display: none;
  705. }
  706. `);
  707. }
  708. // Hides container and "separator line"
  709. static hideTopicsToFollow() {
  710. if (!I18N.TOPICS_TO_FOLLOW) { // Unsupported lang, no TOPICS_TO_FOLLOW constant
  711. return;
  712. }
  713. const elem = xpath(`.//section[@role="region" and child::div[@aria-label="${I18N.TOPICS_TO_FOLLOW}"]]/../..`);
  714. if (!elem) {
  715. return;
  716. }
  717. elem.classList.add("ujs-hidden");
  718.  
  719. elem.previousSibling.classList.add("ujs-hidden"); // a "separator line" (empty element of "TRENDS", for example)
  720. // in fact it's a hack // todo rework // may hide "You might like" section [bug]
  721. }
  722.  
  723. // todo split to two methods
  724. // todo fix it, currently it works questionably
  725. // not tested with non eng langs
  726. static footerHandled = false;
  727. static hideAndMoveFooter() { // "Terms of Service Privacy Policy Cookie Policy"
  728. let footer = document.querySelector(`main[role=main] nav[aria-label=${I18N.FOOTER}][role=navigation]`);
  729. const nav = document.querySelector("nav[aria-label=Primary][role=navigation]"); // I18N."Primary" [?]
  730.  
  731. if (footer) {
  732. footer = footer.parentNode;
  733. const separatorLine = footer.previousSibling;
  734.  
  735. if (Features.footerHandled) {
  736. footer.remove();
  737. separatorLine.remove();
  738. return;
  739. }
  740.  
  741. nav.append(separatorLine);
  742. nav.append(footer);
  743. footer.classList.add("ujs-show-on-hover");
  744. separatorLine.classList.add("ujs-show-on-hover");
  745.  
  746. Features.footerHandled = true;
  747. }
  748. }
  749. }
  750. return Features;
  751. }
  752.  
  753. // --- Twitter.RequiredCSS --- //
  754. function getUserScriptCSS() {
  755. const labelText = I18N.IMAGE || "Image";
  756. // By default the scroll is showed all time, since <html style="overflow-y: scroll;>,
  757. // so it works — no need to use `getScrollbarWidth` function from SO (13382516).
  758. const scrollbarWidth = window.innerWidth - document.body.offsetWidth;
  759. const css = `
  760. .ujs-hidden {
  761. display: none;
  762. }
  763. .ujs-no-scroll {
  764. overflow-y: hidden;
  765. }
  766. .ujs-scroll-initial {
  767. overflow-y: initial!important;
  768. }
  769. .ujs-scrollbar-width-margin-right {
  770. margin-right: ${scrollbarWidth}px;
  771. }
  772. .ujs-show-on-hover:hover {
  773. opacity: 1;
  774. transition: opacity 1s ease-out 0.1s;
  775. }
  776. .ujs-show-on-hover {
  777. opacity: 0;
  778. transition: opacity 0.5s ease-out;
  779. }
  780. .ujs-btn-download {
  781. cursor: pointer;
  782. top: 0.5em;
  783. left: 0.5em;
  784. width: 33px;
  785. height: 33px;
  786. background: #e0245e; /*red*/
  787. opacity: 0;
  788. position: absolute;
  789. border-radius: 0.3em;
  790. background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  791. }
  792. article[role=article]:hover .ujs-btn-download {
  793. opacity: 1;
  794. }
  795. div[aria-label="${labelText}"]:hover .ujs-btn-download {
  796. opacity: 1;
  797. }
  798. .ujs-btn-download.ujs-downloaded {
  799. background: #4caf50; /*green*/
  800. background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  801. opacity: 1;
  802. }
  803. .ujs-btn-download.ujs-video {
  804. left: calc(0.5em + 33px + 3px);
  805. }
  806. article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded) {
  807. background: #1da1f2; /*blue*/
  808. background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  809. }
  810. div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded) {
  811. background: #1da1f2; /*blue*/
  812. background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  813. }
  814. /* -------------------------------------------------------- */
  815. /* Shadow the button on hover, active and while downloading */
  816. .ujs-btn-download:hover {
  817. background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  818. }
  819. .ujs-btn-download:active {
  820. background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  821. }
  822. .ujs-btn-download.ujs-downloading {
  823. background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  824. }
  825. article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded):hover {
  826. background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  827. }
  828. article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded):active {
  829. background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  830. }
  831. article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded).ujs-downloading {
  832. background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  833. }
  834. div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded):hover {
  835. background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  836. }
  837. div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded):active {
  838. background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  839. }
  840. div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded).ujs-downloading {
  841. background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  842. }
  843. /* -------------------------------------------------------- */
  844. `;
  845. return css.replaceAll(" ".repeat(8), "");
  846. }
  847.  
  848. /*
  849. Features depend on:
  850.  
  851. addRequiredCSS: IMAGE
  852.  
  853. expandSpoilers: YES_VIEW_PROFILE, SHOW_NUDITY, VIEW
  854. handleTitle: QUOTES, ON_TWITTER, TWITTER
  855. hideSignUpSection: SIGNUP
  856. hideTrends: TRENDS
  857. hideTopicsToFollowInstantly: TOPICS_TO_FOLLOW,
  858. hideTopicsToFollow: TOPICS_TO_FOLLOW,
  859.  
  860. [unused]
  861. hideAndMoveFooter: FOOTER
  862. */
  863.  
  864. // --- Twitter.LangConstants --- //
  865. function getLanguageConstants() { //todo: "de", "fr"
  866. const defaultQuotes = [`"`, `"`];
  867.  
  868. const SUPPORTED_LANGUAGES = ["en", "ru", "es", "zh", "ja", ];
  869. // texts
  870. const VIEW = ["View", "Посмотреть", "Ver", "查看", "表示", ];
  871. const YES_VIEW_PROFILE = ["Yes, view profile", "Да, посмотреть профиль", "Sí, ver perfil", "是,查看个人资料", "プロフィールを表示する", ];
  872. const SHOW_NUDITY = ["Show", "Показать", "Mostrar", "显示", "表示", ];
  873. // aria-label texts
  874. const IMAGE = ["Image", "Изображение", "Imagen", "图像", "画像", ];
  875. const SIGNUP = ["Sign up", "Зарегистрироваться", "Regístrate", "注册", "アカウント作成", ];
  876. const TRENDS = ["Timeline: Trending now", "Лента: Актуальные темы", "Cronología: Tendencias del momento", "时间线:当前趋势", "タイムライン: トレンド", ];
  877. const TOPICS_TO_FOLLOW = ["Timeline: ", "Лента: ", "Cronología: ", "时间线:", /*[1]*/ "タイムライン: ", /*[1]*/ ];
  878. const WHO_TO_FOLLOW = ["Who to follow", "Кого читать", "A quién seguir", "推荐关注", "おすすめユーザー" ];
  879. const FOOTER = ["Footer", "Нижний колонтитул", "Pie de página", "页脚", "フッター", ];
  880. // *1 — it's a suggestion, need to recheck. But I can't find a page where I can check it. Was it deleted?
  881. // document.title "{AUTHOR}{ON_TWITTER} {QUOTES[0]}{TEXT}{QUOTES[1]} / {TWITTER}"
  882. const QUOTES = [defaultQuotes, [`«`, `»`], defaultQuotes, defaultQuotes, [`「`, `」`], ];
  883. const ON_TWITTER = [" on Twitter:", " в Твиттере:", " en Twitter:", " 在 Twitter:", "さんはTwitterを使っています", ];
  884. const TWITTER = ["Twitter", "Твиттер", "Twitter", "Twitter", "Twitter", ];
  885. const lang = document.querySelector("html").getAttribute("lang");
  886. const langIndex = SUPPORTED_LANGUAGES.indexOf(lang);
  887.  
  888. return {
  889. SUPPORTED_LANGUAGES,
  890. VIEW: VIEW[langIndex],
  891. YES_VIEW_PROFILE: YES_VIEW_PROFILE[langIndex],
  892. SIGNUP: SIGNUP[langIndex],
  893. TRENDS: TRENDS[langIndex],
  894. TOPICS_TO_FOLLOW: TOPICS_TO_FOLLOW[langIndex],
  895. WHO_TO_FOLLOW: WHO_TO_FOLLOW[langIndex],
  896. FOOTER: FOOTER[langIndex],
  897. QUOTES: QUOTES[langIndex],
  898. ON_TWITTER: ON_TWITTER[langIndex],
  899. TWITTER: TWITTER[langIndex],
  900. IMAGE: IMAGE[langIndex],
  901. SHOW_NUDITY: SHOW_NUDITY[langIndex],
  902. }
  903. }
  904.  
  905. // --- Twitter.Tweet --- //
  906. function hoistTweet() {
  907. class Tweet {
  908. constructor(elem) {
  909. this.elem = elem;
  910. this.url = Tweet.getUrl(elem);
  911. }
  912. static of(innerElem) {
  913. const elem = getParentWithSiblingDataset(innerElem, "testid", "tweet");
  914. if (!elem) { // opened image
  915. verbose && console.log("no-tweet elem");
  916. }
  917. return new Tweet(elem);
  918. }
  919. static getUrl(elem) {
  920. if (!elem) { // if opened image
  921. return location.href;
  922. }
  923.  
  924. const tweetAnchor = [...elem.querySelectorAll("a")].find(el => {
  925. return el.childNodes[0]?.nodeName === "TIME";
  926. });
  927.  
  928. if (tweetAnchor) {
  929. return tweetAnchor.href;
  930. }
  931. // else if selected tweet
  932. return location.href;
  933. }
  934.  
  935. get author() {
  936. return this.url.match(/(?<=twitter\.com\/).+?(?=\/)/)?.[0];
  937. }
  938. get id() {
  939. return this.url.match(/(?<=\/status\/)\d+/)?.[0];
  940. }
  941. }
  942. return Tweet;
  943. }
  944.  
  945. // --- Twitter.API --- //
  946. function hoistAPI() {
  947. class API {
  948. static guestToken = getCookie("gt");
  949. static csrfToken = getCookie("ct0"); // todo: lazy — not available at the first run
  950. // Guest/Suspended account Bearer token
  951. static guestAuthorization = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
  952.  
  953. // Seems to be outdated at 2022.05
  954. static async _requestBearerToken() {
  955. const scriptSrc = [...document.querySelectorAll("script")]
  956. .find(el => el.src.match(/https:\/\/abs\.twimg\.com\/responsive-web\/client-web\/main[\w\d\.]*\.js/)).src;
  957.  
  958. let text;
  959. try {
  960. text = await (await fetch(scriptSrc)).text();
  961. } catch (e) {
  962. console.error(e, scriptSrc);
  963. throw e;
  964. }
  965.  
  966. const authorizationKey = text.match(/(?<=")AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D.+?(?=")/)[0];
  967. const authorization = `Bearer ${authorizationKey}`;
  968.  
  969. return authorization;
  970. }
  971.  
  972. static async getAuthorization() {
  973. if (!API.authorization) {
  974. API.authorization = await API._requestBearerToken();
  975. }
  976. return API.authorization;
  977. }
  978.  
  979. static async apiRequest(url) {
  980. const _url = url.toString();
  981. verbose && console.log("[ujs][apiRequest]", _url);
  982. // Hm... it always is the same. Even for a logged user.
  983. // const authorization = API.guestToken ? API.guestAuthorization : await API.getAuthorization();
  984. const authorization = API.guestAuthorization;
  985.  
  986. // for debug
  987. verbose && sessionStorage.setItem("guestAuthorization", API.guestAuthorization);
  988. verbose && sessionStorage.setItem("authorization", API.authorization);
  989. verbose && sessionStorage.setItem("x-csrf-token", API.csrfToken);
  990. verbose && sessionStorage.setItem("x-guest-token", API.guestToken);
  991.  
  992. const headers = new Headers({
  993. authorization,
  994. "x-csrf-token": API.csrfToken,
  995. });
  996. if (API.guestToken) {
  997. headers.append("x-guest-token", API.guestToken);
  998. } else { // may be skipped
  999. headers.append("x-twitter-active-user", "yes");
  1000. headers.append("x-twitter-auth-type", "OAuth2Session");
  1001. }
  1002.  
  1003. let json;
  1004. try {
  1005. const response = await fetch(_url, {headers});
  1006. json = await response.json();
  1007. } catch (e) {
  1008. console.error(e, _url);
  1009. throw e;
  1010. }
  1011.  
  1012. verbose && console.log("[ujs][apiRequest]", JSON.stringify(json, null, " "));
  1013. // 429 - [{code: 88, message: "Rate limit exceeded"}] — for suspended accounts
  1014. return json;
  1015. }
  1016.  
  1017. // @return {bitrate, content_type, url}
  1018. static async getVideoInfo(tweetId) {
  1019. // const url = new URL(`https://api.twitter.com/2/timeline/conversation/${tweetId}.json`); // only for suspended/anon
  1020. const url = new URL(`https://twitter.com/i/api/2/timeline/conversation/${tweetId}.json`);
  1021. url.searchParams.set("tweet_mode", "extended");
  1022.  
  1023. const json = await API.apiRequest(url);
  1024. const tweetData = json.globalObjects.tweets[tweetId];
  1025. const videoVariants = tweetData.extended_entities.media[0].video_info.variants;
  1026. verbose && console.log(videoVariants);
  1027.  
  1028.  
  1029. const video = videoVariants
  1030. .filter(el => el.bitrate !== undefined) // if content_type: "application/x-mpegURL" // .m3u8
  1031. .reduce((acc, cur) => cur.bitrate > acc.bitrate ? cur : acc);
  1032. return video;
  1033. }
  1034.  
  1035. static async getUserInfo(username) {
  1036. const qHash = "Bhlf1dYJ3bYCKmLfeEQ31A"; // todo: change
  1037. const variables = JSON.stringify({"screen_name": username, "withSafetyModeUserFields": true, "withSuperFollowsUserFields": true});
  1038. const url = `https://twitter.com/i/api/graphql/${qHash}/UserByScreenName?variables=${encodeURIComponent(variables)}`;
  1039. const json = await API.apiRequest(url);
  1040. verbose && console.log("[getUserInfo]", json);
  1041. return json.data.user.result.legacy.entities.url?.urls[0].expanded_url;
  1042. }
  1043. }
  1044. return API;
  1045. }
  1046.  
  1047. // ---------------------------------------------------------------------------------------------------------------------
  1048. // ---------------------------------------------------------------------------------------------------------------------
  1049. // --- Common Utils --- //
  1050.  
  1051. // --- LocalStorage util class --- //
  1052. function hoistLS(settings = {}) {
  1053. const {
  1054. verbose, // debug "messages" in the document.title
  1055. } = settings;
  1056. class LS {
  1057. constructor(name) {
  1058. this.name = name;
  1059. }
  1060. getItem(defaultValue) {
  1061. return LS.getItem(this.name, defaultValue);
  1062. }
  1063. setItem(value) {
  1064. LS.setItem(this.name, value);
  1065. }
  1066. removeItem() {
  1067. LS.removeItem(this.name);
  1068. }
  1069. async pushItem(value) { // array method
  1070. await LS.pushItem(this.name, value);
  1071. }
  1072. async popItem(value) { // array method
  1073. await LS.popItem(this.name, value);
  1074. }
  1075. hasItem(value) { // array method
  1076. return LS.hasItem(this.name, value);
  1077. }
  1078.  
  1079. static getItem(name, defaultValue) {
  1080. const value = localStorage.getItem(name);
  1081. if (value === undefined) {
  1082. return undefined;
  1083. }
  1084. if (value === null) { // when there is no such item
  1085. LS.setItem(name, defaultValue);
  1086. return defaultValue;
  1087. }
  1088. return JSON.parse(value);
  1089. }
  1090. static setItem(name, value) {
  1091. localStorage.setItem(name, JSON.stringify(value));
  1092. }
  1093. static removeItem(name) {
  1094. localStorage.removeItem(name);
  1095. }
  1096. static async pushItem(name, value) {
  1097. const array = LS.getItem(name, []);
  1098. array.push(value);
  1099. LS.setItem(name, array);
  1100.  
  1101. //sanity check
  1102. await sleep(50);
  1103. if (!LS.hasItem(name, value)) {
  1104. if (verbose) {
  1105. document.title = "🟥" + document.title;
  1106. }
  1107. await LS.pushItem(name, value);
  1108. }
  1109. }
  1110. static async popItem(name, value) { // remove from an array
  1111. const array = LS.getItem(name, []);
  1112. if (array.indexOf(value) !== -1) {
  1113. array.splice(array.indexOf(value), 1);
  1114. LS.setItem(name, array);
  1115.  
  1116. //sanity check
  1117. await sleep(50);
  1118. if (LS.hasItem(name, value)) {
  1119. if (verbose) {
  1120. document.title = "🟨" + document.title;
  1121. }
  1122. await LS.popItem(name, value);
  1123. }
  1124. }
  1125. }
  1126. static hasItem(name, value) { // has in array
  1127. const array = LS.getItem(name, []);
  1128. return array.indexOf(value) !== -1;
  1129. }
  1130. }
  1131. return LS;
  1132. }
  1133.  
  1134. // --- Just groups them in a function for the convenient code looking --- //
  1135. function getUtils({verbose}) {
  1136. function sleep(time) {
  1137. return new Promise(resolve => setTimeout(resolve, time));
  1138. }
  1139.  
  1140. async function fetchResource(url) {
  1141. try {
  1142. const response = await fetch(url, {
  1143. cache: "force-cache",
  1144. });
  1145. const lastModifiedDateSeconds = response.headers.get("last-modified");
  1146. const contentType = response.headers.get("content-type");
  1147.  
  1148. const lastModifiedDate = dateToDayDateString(lastModifiedDateSeconds);
  1149. const extension = extensionFromMime(contentType);
  1150. const blob = await response.blob();
  1151.  
  1152. // https://pbs.twimg.com/media/AbcdEFgijKL01_9?format=jpg&name=orig -> AbcdEFgijKL01_9
  1153. // https://pbs.twimg.com/ext_tw_video_thumb/1234567890123456789/pu/img/Ab1cd2345EFgijKL.jpg?name=orig -> Ab1cd2345EFgijKL.jpg
  1154. // https://video.twimg.com/ext_tw_video/1234567890123456789/pu/vid/946x720/Ab1cd2345EFgijKL.mp4?tag=10 -> Ab1cd2345EFgijKL.mp4
  1155. const _url = new URL(url);
  1156. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  1157.  
  1158. const {name} = filename.match(/(?<name>^[^\.]+)/).groups;
  1159. return {blob, lastModifiedDate, contentType, extension, name};
  1160. } catch (error) {
  1161. verbose && console.error("[fetchResource]", url, error);
  1162. throw error;
  1163. }
  1164. }
  1165.  
  1166. function extensionFromMime(mimeType) {
  1167. let extension = mimeType.match(/(?<=\/).+/)[0];
  1168. extension = extension === "jpeg" ? "jpg" : extension;
  1169. return extension;
  1170. }
  1171.  
  1172. // the original download url will be posted as hash of the blob url, so you can check it in the download manager's history
  1173. function download(blob, name, url) {
  1174. const anchor = document.createElement("a");
  1175. anchor.setAttribute("download", name || "");
  1176. const blobUrl = URL.createObjectURL(blob);
  1177. anchor.href = blobUrl + (url ? ("#" + url) : "");
  1178. anchor.click();
  1179. setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
  1180. }
  1181.  
  1182. // "Sun, 10 Jan 2021 22:22:22 GMT" -> "2021.01.10"
  1183. function dateToDayDateString(dateValue, utc = true) {
  1184. const _date = new Date(dateValue);
  1185. function pad(str) {
  1186. return str.toString().padStart(2, "0");
  1187. }
  1188. const _utc = utc ? "UTC" : "";
  1189. const year = _date[`get${_utc}FullYear`]();
  1190. const month = _date[`get${_utc}Month`]() + 1;
  1191. const date = _date[`get${_utc}Date`]();
  1192.  
  1193. return year + "." + pad(month) + "." + pad(date);
  1194. }
  1195.  
  1196.  
  1197. function addCSS(css) {
  1198. const styleElem = document.createElement("style");
  1199. styleElem.textContent = css;
  1200. document.body.append(styleElem);
  1201. return styleElem;
  1202. }
  1203.  
  1204.  
  1205. function getCookie(name) {
  1206. verbose && console.log(document.cookie);
  1207. const regExp = new RegExp(`(?<=${name}=)[^;]+`);
  1208. return document.cookie.match(regExp)?.[0];
  1209. }
  1210.  
  1211. function throttle(runnable, time = 50) {
  1212. let waiting = false;
  1213. let queued = false;
  1214. let context;
  1215. let args;
  1216.  
  1217. return function() {
  1218. if (!waiting) {
  1219. waiting = true;
  1220. setTimeout(function() {
  1221. if (queued) {
  1222. runnable.apply(context, args);
  1223. context = args = undefined;
  1224. }
  1225. waiting = queued = false;
  1226. }, time);
  1227. return runnable.apply(this, arguments);
  1228. } else {
  1229. queued = true;
  1230. context = this;
  1231. args = arguments;
  1232. }
  1233. }
  1234. }
  1235. function throttleWithResult(func, time = 50) {
  1236. let waiting = false;
  1237. let args;
  1238. let context;
  1239. let timeout;
  1240. let promise;
  1241.  
  1242. return async function() {
  1243. if (!waiting) {
  1244. waiting = true;
  1245. timeout = new Promise(async resolve => {
  1246. await sleep(time);
  1247. waiting = false;
  1248. resolve();
  1249. });
  1250. return func.apply(this, arguments);
  1251. } else {
  1252. args = arguments;
  1253. context = this;
  1254. }
  1255.  
  1256. if (!promise) {
  1257. promise = new Promise(async resolve => {
  1258. await timeout;
  1259. const result = func.apply(context, args);
  1260. args = context = promise = undefined;
  1261. resolve(result);
  1262. });
  1263. }
  1264. return promise;
  1265. }
  1266. }
  1267.  
  1268.  
  1269. function xpath(path, node = document) {
  1270. let xPathResult = document.evaluate(path, node, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  1271. return xPathResult.singleNodeValue;
  1272. }
  1273. function xpathAll(path, node = document) {
  1274. let xPathResult = document.evaluate(path, node, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
  1275. const nodes = [];
  1276. try {
  1277. let node = xPathResult.iterateNext();
  1278.  
  1279. while (node) {
  1280. nodes.push(node);
  1281. node = xPathResult.iterateNext();
  1282. }
  1283. return nodes;
  1284. }
  1285. catch (e) {
  1286. // todo need investigate it
  1287. console.error(e); // "The document has mutated since the result was returned."
  1288. return [];
  1289. }
  1290. }
  1291.  
  1292.  
  1293. function getNearestElementByType(elem, type) {
  1294. const parent = elem.parentNode;
  1295. if (parent === document) {
  1296. return null;
  1297. }
  1298. if (parent.nodeName === type.toUpperCase()) {
  1299. return parent;
  1300. }
  1301. return getNearestElementByType(parent, type);
  1302. }
  1303. function getParentWithSiblingDataset(node, name, value) {
  1304. const parent = node.parentNode;
  1305. if (parent === document) {
  1306. return null;
  1307. }
  1308. // console.log(parent, parent.childNodes);
  1309. const elem = [...parent.childNodes].find(el => {
  1310. if (el.dataset?.[name] === value) {
  1311. return true;
  1312. }
  1313. });
  1314. if (!elem) {
  1315. return getParentWithSiblingDataset(parent, name, value);
  1316. }
  1317. return parent;
  1318. }
  1319.  
  1320. return {
  1321. sleep, fetchResource, extensionFromMime, download, dateToDayDateString,
  1322. addCSS,
  1323. getCookie,
  1324. throttle, throttleWithResult,
  1325. xpath, xpathAll,
  1326. getNearestElementByType, getParentWithSiblingDataset,
  1327. }
  1328. }
  1329.  
  1330.  
  1331. // ---------------------------------------------------------------------------------------------------------------------
  1332. // ---------------------------------------------------------------------------------------------------------------------