Twitter Click'n'Save

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

当前为 2022-10-30 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Click'n'Save
  3. // @version 0.13.7-2022.10.30
  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. const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") !== -1;
  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. highlightOnlySpecialVisitedLinks: true,
  37. expandSpoilers: true,
  38.  
  39. directLinks: true,
  40. handleTitle: true,
  41.  
  42. imagesHandler: true,
  43. videoHandler: true,
  44. addRequiredCSS: true,
  45. preventBlinking: false,
  46.  
  47. hideLoginPopup: false,
  48. addBorder: false,
  49.  
  50. downloadProgress: true,
  51. strictTrackingProtectionFix: false,
  52. };
  53.  
  54. let savedSettings;
  55. try {
  56. savedSettings = JSON.parse(localStorage.getItem("ujs-click-n-save-settings")) || {};
  57. } catch (e) {
  58. console.error("[ujs]", e);
  59. localStorage.removeItem("ujs-click-n-save-settings");
  60. savedSettings = {};
  61. }
  62. savedSettings = Object.assign(defaultSettings, savedSettings);
  63. return savedSettings;
  64. }
  65. function showSettings() {
  66. closeSetting();
  67. if (window.scrollY > 0) {
  68. document.querySelector("html").classList.add("ujs-scroll-initial");
  69. document.body.classList.add("ujs-scrollbar-width-margin-right");
  70. }
  71. document.body.classList.add("ujs-no-scroll");
  72.  
  73. const modalWrapperStyle = `
  74. width: 100%;
  75. height: 100%;
  76. position: fixed;
  77. display: flex;
  78. justify-content: center;
  79. align-items: center;
  80. z-index: 99999;
  81. backdrop-filter: blur(4px);
  82. background-color: rgba(255, 255, 255, 0.5);
  83. `;
  84. const modalSettingsStyle = `
  85. background-color: white;
  86. min-width: 320px;
  87. min-height: 320px;
  88. border: 1px solid darkgray;
  89. padding: 8px;
  90. box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
  91. `;
  92. const s = settings;
  93. const downloadProgressFFTitle = `Disable the download progress if you use Firefox with "Enhanced Tracking Protection" set to "Strict" and ViolentMonkey, or GreaseMonkey extension`;
  94. const strictTrackingProtectionFixFFTitle = `Choose this if you use ViolentMonkey, or GreaseMonkey in Firefox with "Enhanced Tracking Protection" set to "Strict". It is not required in case you use TamperMonkey.`;
  95. document.body.insertAdjacentHTML("afterbegin", `
  96. <div class="ujs-modal-wrapper" style="${modalWrapperStyle}">
  97. <div class="ujs-modal-settings" style="${modalSettingsStyle}">
  98. <fieldset>
  99. <legend>Optional</legend>
  100. <label><input type="checkbox" ${s.hideTrends ? "checked" : ""} name="hideTrends">Hide <b>Trends</b> (in the right column)*<br/></label>
  101. <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>
  102. <label><input type="checkbox" ${s.hideSignUpBottomBarAndMessages ? "checked" : ""} name="hideSignUpBottomBarAndMessages">Hide <b>Sign Up Bar</b> and <b>Messages</b> (in the bottom)<br/></label>
  103. <label hidden><input type="checkbox" ${s.doNotPlayVideosAutomatically ? "checked" : ""} name="doNotPlayVideosAutomatically">Do <i>Not</i> Play Videos Automatically</b><br/></label>
  104. <label hidden><input type="checkbox" ${s.goFromMobileToMainSite ? "checked" : ""} name="goFromMobileToMainSite">Redirect from Mobile version (beta)<br/></label>
  105. <label title="Makes the button more visible"><input type="checkbox" ${s.addBorder ? "checked" : ""} name="addBorder">Add a white border to the download button<br/></label>
  106. <label title="Hides the modal login pop up. Useful if you have no account. \nWARNING: Currently it will close any popup, not only the login one.\nIt's reccommended to use only if you do not have an account to hide the annoiyng login popup."><input type="checkbox" ${s.hideLoginPopup ? "checked" : ""} name="hideLoginPopup">Hide <strike>Login</strike> Popups (beta)<br/></label>
  107. </fieldset>
  108. <fieldset>
  109. <legend>Recommended</legend>
  110. <label><input type="checkbox" ${s.highlightVisitedLinks ? "checked" : ""} name="highlightVisitedLinks">Highlight Visited Links<br/></label>
  111. <label title="In most cases absolute links are 3rd-party links"><input type="checkbox" ${s.highlightOnlySpecialVisitedLinks ? "checked" : ""} name="highlightOnlySpecialVisitedLinks">Highlight Only Absolute Visited Links<br/></label>
  112.  
  113. <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>
  114. </fieldset>
  115. <fieldset>
  116. <legend>Highly Recommended</legend>
  117. <label><input type="checkbox" ${s.directLinks ? "checked" : ""} name="directLinks">Direct Links</label><br/>
  118. <label><input type="checkbox" ${s.handleTitle ? "checked" : ""} name="handleTitle">Enchance Title*<br/></label>
  119. </fieldset>
  120. <fieldset ${isFirefox ? '': 'style="display: none"'}>
  121. <legend>Firefox only</legend>
  122. <label title='${downloadProgressFFTitle}'><input type="radio" ${s.downloadProgress ? "checked" : ""} name="firefoxDownloadProgress" value="downloadProgress">Download Progress<br/></label>
  123. <label title='${strictTrackingProtectionFixFFTitle}'><input type="radio" ${s.strictTrackingProtectionFix ? "checked" : ""} name="firefoxDownloadProgress" value="strictTrackingProtectionFix">Strict Tracking Protection Fix<br/></label>
  124. </fieldset>
  125. <fieldset>
  126. <legend>Main</legend>
  127. <label><input type="checkbox" ${s.imagesHandler ? "checked" : ""} name="imagesHandler">Image Download Button<br/></label>
  128. <label><input type="checkbox" ${s.videoHandler ? "checked" : ""} name="videoHandler">Video Download Button<br/></label>
  129. <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 -->
  130. </fieldset>
  131. <fieldset>
  132. <legend title="Outdated due to Twitter's updates, or impossible to reimplement">Outdated</legend>
  133. <strike>
  134.  
  135. <label title="It seems Twitter no more shows this section."><input type="checkbox" ${s.hideTopicsToFollow ? "checked" : ""} name="hideTopicsToFollow">Hide <b>Topics To Follow</b> (in the right column)*<br/></label>
  136. <label title="Prevent the tweet backgroubd blinking on the button/image click. \nOutdated. \nTwitter have removed this disgusting behavior. This option is more no need."><input type="checkbox" ${s.preventBlinking ? "checked" : ""} name="preventBlinking">Prevent blinking on click (outdated)<br/></label>
  137.  
  138. <label hidden><input type="checkbox" ${s.hideTopicsToFollowInstantly ? "checked" : ""} name="hideTopicsToFollowInstantly">Hide <b>Topics To Follow</b> Instantly*<br/></label>
  139. </strike>
  140. </fieldset>
  141. <hr>
  142. <div style="display: flex; justify-content: space-around;">
  143. <button class="ujs-save-setting-button" style="padding: 5px">Save Settings</button>
  144. <button class="ujs-close-setting-button" style="padding: 5px">Close Settings</button>
  145. </div>
  146. <hr>
  147. <h4 style="margin: 0; padding-left: 8px; color: #444;">Notes:</h4>
  148. <ul style="margin: 2px; padding-left: 16px; color: #444;">
  149. <li>Click on <b>Save Settings</b> and <b>reload the page</b> to apply changes.</li>
  150. <li><b>*</b>-marked settings are language dependent. Currently, the follow languages are supported:<br/> "en", "ru", "es", "zh", "ja".</li>
  151. <li hidden>The extension downloads only from twitter.com, not from <b>mobile</b>.twitter.com</li>
  152. </ul>
  153. </div>
  154. </div>`);
  155.  
  156. document.querySelector("body > .ujs-modal-wrapper .ujs-save-setting-button").addEventListener("click", saveSetting);
  157. document.querySelector("body > .ujs-modal-wrapper .ujs-close-setting-button").addEventListener("click", closeSetting);
  158.  
  159. function saveSetting() {
  160. const entries = [...document.querySelectorAll("body > .ujs-modal-wrapper input[type=checkbox]")]
  161. .map(checkbox => [checkbox.name, checkbox.checked]);
  162. const radioEntries = [...document.querySelectorAll("body > .ujs-modal-wrapper input[type=radio]")]
  163. .map(checkbox => [checkbox.value, checkbox.checked])
  164. const settings = Object.fromEntries([entries, radioEntries].flat());
  165. settings.hideTopicsToFollowInstantly = settings.hideTopicsToFollow;
  166. // console.log("[ujs]", settings);
  167. localStorage.setItem("ujs-click-n-save-settings", JSON.stringify(settings));
  168. }
  169.  
  170. function closeSetting() {
  171. document.body.classList.remove("ujs-no-scroll");
  172. document.body.classList.remove("ujs-scrollbar-width-margin-right");
  173. document.querySelector("html").classList.remove("ujs-scroll-initial");
  174. document.querySelector("body > .ujs-modal-wrapper")?.remove();
  175. }
  176. }
  177.  
  178. // ---------------------------------------------------------------------------------------------------------------------
  179. // ---------------------------------------------------------------------------------------------------------------------
  180.  
  181. // --- Features to execute --- //
  182. const doNotPlayVideosAutomatically = false;
  183.  
  184. function execFeaturesOnce() {
  185. settings.goFromMobileToMainSite && Features.goFromMobileToMainSite();
  186. settings.addRequiredCSS && Features.addRequiredCSS();
  187. settings.hideSignUpBottomBarAndMessages && Features.hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically);
  188. settings.hideTrends && Features.hideTrends();
  189. settings.highlightVisitedLinks && Features.highlightVisitedLinks();
  190. settings.hideTopicsToFollowInstantly && Features.hideTopicsToFollowInstantly();
  191. settings.hideLoginPopup && Features.hideLoginPopup();
  192. }
  193. function execFeaturesImmediately() {
  194. settings.expandSpoilers && Features.expandSpoilers();
  195. }
  196. function execFeatures() {
  197. settings.imagesHandler && Features.imagesHandler(settings.preventBlinking);
  198. settings.videoHandler && Features.videoHandler(settings.preventBlinking);
  199. settings.expandSpoilers && Features.expandSpoilers();
  200. settings.hideSignUpSection && Features.hideSignUpSection();
  201. settings.hideTopicsToFollow && Features.hideTopicsToFollow();
  202. settings.directLinks && Features.directLinks();
  203. settings.handleTitle && Features.handleTitle();
  204. }
  205.  
  206. // ---------------------------------------------------------------------------------------------------------------------
  207. // ---------------------------------------------------------------------------------------------------------------------
  208.  
  209. if (verbose) {
  210. console.log("[ujs][settings]", settings);
  211. // showSettings();
  212. }
  213.  
  214. // --- [VM/GM + Firefox ~90+ + Enabled "Strict Tracking Protection"] fix --- //
  215. const fetch = (settings.strictTrackingProtectionFix && typeof wrappedJSObject === "object" && typeof wrappedJSObject.fetch === "function") ? function(resource, init = {}) {
  216. verbose && console.log("wrappedJSObject.fetch", resource, init);
  217.  
  218. if (init.headers instanceof Headers) {
  219. // Since `Headers` are not allowed for structured cloning.
  220. init.headers = Object.fromEntries(init.headers.entries());
  221. }
  222.  
  223. return wrappedJSObject.fetch(cloneInto(resource, document), cloneInto(init, document));
  224. } : globalThis.fetch;
  225.  
  226. // --- "Imports" --- //
  227. const {
  228. sleep, fetchResource, downloadBlob,
  229. addCSS,
  230. getCookie,
  231. throttle,
  232. xpath, xpathAll,
  233. responseProgressProxy,
  234. } = getUtils({verbose});
  235. const LS = hoistLS({verbose});
  236.  
  237. const API = hoistAPI();
  238. const Tweet = hoistTweet();
  239. const Features = hoistFeatures();
  240. const I18N = getLanguageConstants();
  241.  
  242. // --- That to use for the image history --- //
  243. // "TWEET_ID" or "IMAGE_NAME"
  244. const imagesHistoryBy = LS.getItem("ujs-images-history-by", "IMAGE_NAME");
  245. // With "TWEET_ID" downloading of 1 image of 4 will mark all 4 images as "already downloaded"
  246. // on the next time when the tweet will appear.
  247. // "IMAGE_NAME" will count each image of a tweet, but it will take more data to store.
  248.  
  249. // ---------------------------------------------------------------------------------------------------------------------
  250. // ---------------------------------------------------------------------------------------------------------------------
  251. // --- Script runner --- //
  252.  
  253. (function starter(feats) {
  254. const {once, onChangeImmediate, onChange} = feats;
  255.  
  256. once();
  257. onChangeImmediate();
  258. const onChangeThrottled = throttle(onChange, 250);
  259. onChangeThrottled();
  260.  
  261. const targetNode = document.querySelector("body");
  262. const observerOptions = {
  263. subtree: true,
  264. childList: true,
  265. };
  266. const observer = new MutationObserver(callback);
  267. observer.observe(targetNode, observerOptions);
  268.  
  269. function callback(mutationList, observer) {
  270. verbose && console.log(mutationList);
  271. onChangeImmediate();
  272. onChangeThrottled();
  273. }
  274. })({
  275. once: execFeaturesOnce,
  276. onChangeImmediate: execFeaturesImmediately,
  277. onChange: execFeatures
  278. });
  279.  
  280. // ---------------------------------------------------------------------------------------------------------------------
  281. // ---------------------------------------------------------------------------------------------------------------------
  282. // --- Twitter Specific code --- //
  283.  
  284. const downloadedImages = new LS("ujs-twitter-downloaded-images-names");
  285. const downloadedImageTweetIds = new LS("ujs-twitter-downloaded-image-tweet-ids");
  286. const downloadedVideoTweetIds = new LS("ujs-twitter-downloaded-video-tweet-ids");
  287.  
  288. // --- Twitter.Features --- //
  289. function hoistFeatures() {
  290. class Features {
  291. static goFromMobileToMainSite() {
  292. if (location.href.startsWith("https://mobile.twitter.com/")) {
  293. location.href = location.href.replace("https://mobile.twitter.com/", "https://twitter.com/");
  294. }
  295. // TODO: add #redirected, remove by timer // to prevent a potential infinity loop
  296. }
  297.  
  298. static createButton({url, downloaded, isVideo}) {
  299. const btn = document.createElement("div");
  300. btn.innerHTML = `
  301. <div class="ujs-btn-common ujs-btn-background"></div>
  302. <div class="ujs-btn-common ujs-hover"></div>
  303. <div class="ujs-btn-common ujs-shadow"></div>
  304. <div class="ujs-btn-common ujs-progress" style="--progress: 0%"></div>
  305. <div class="ujs-btn-common ujs-btn-error-text"></div>`.slice(1);
  306. btn.classList.add("ujs-btn-download");
  307. if (!downloaded) {
  308. btn.classList.add("ujs-not-downloaded");
  309. } else {
  310. btn.classList.add("ujs-already-downloaded");
  311. }
  312. if (isVideo) {
  313. btn.classList.add("ujs-video");
  314. }
  315. if (url) {
  316. btn.dataset.url = url;
  317. }
  318. return btn;
  319. }
  320.  
  321. static hasBlinkListenerWeakSet;
  322. static _preventBlinking(clickBtnElem) {
  323. const weakSet = Features.hasBlinkListenerWeakSet || (Features.hasBlinkListenerWeakSet = new WeakSet());
  324. let wrapper;
  325. clickBtnElem.addEventListener("mouseenter", () => {
  326. if (!weakSet.has(clickBtnElem)) {
  327. wrapper = Features._preventBlinkingHandler(clickBtnElem);
  328. weakSet.add(clickBtnElem);
  329. }
  330. });
  331. clickBtnElem.addEventListener("mouseleave", () => {
  332. verbose && console.log("[ujs] Btn mouseleave");
  333. if (wrapper?.observer?.disconnect) {
  334. weakSet.delete(clickBtnElem);
  335. wrapper.observer.disconnect();
  336. }
  337. });
  338. }
  339. static _preventBlinkingHandler(clickBtnElem) {
  340. let targetNode = clickBtnElem.closest("[aria-labelledby]");
  341. if (!targetNode) {
  342. return;
  343. }
  344. let config = {attributes: true, subtree: true, attributeOldValue: true};
  345. const wrapper = {};
  346. wrapper.observer = new MutationObserver(callback);
  347. wrapper.observer.observe(targetNode, config);
  348.  
  349. function callback(mutationsList, observer) {
  350. for (const mutation of mutationsList) {
  351. if (mutation.type === "attributes" && mutation.attributeName === "class") {
  352. if (mutation.target.classList.contains("ujs-btn-download")) {
  353. return;
  354. }
  355. // Don't allow to change classList
  356. mutation.target.className = mutation.oldValue;
  357.  
  358. // Recreate, to prevent an infinity loop
  359. wrapper.observer.disconnect();
  360. wrapper.observer = new MutationObserver(callback);
  361. wrapper.observer.observe(targetNode, config);
  362. }
  363. }
  364. }
  365.  
  366. return wrapper;
  367. }
  368.  
  369. // Banner/Background
  370. static async _downloadBanner(url, btn) {
  371. const username = location.pathname.slice(1).split("/")[0];
  372.  
  373. btn.classList.add("ujs-downloading");
  374.  
  375. // https://pbs.twimg.com/profile_banners/34743251/1596331248/1500x500
  376. const {
  377. id, seconds, res
  378. } = url.match(/(?<=\/profile_banners\/)(?<id>\d+)\/(?<seconds>\d+)\/(?<res>\d+x\d+)/)?.groups || {};
  379.  
  380. const {blob, lastModifiedDate, extension, name} = await fetchResource(url);
  381.  
  382. Features.verifyBlob(blob, url, btn);
  383.  
  384. const filename = `[twitter][bg] ${username}—${lastModifiedDate}—${id}—${seconds}.${extension}`;
  385. downloadBlob(blob, filename, url);
  386.  
  387. btn.classList.remove("ujs-downloading");
  388. btn.classList.add("ujs-downloaded");
  389. }
  390.  
  391. static _ImageHistory = class {
  392. static getImageNameFromUrl(url) {
  393. const _url = new URL(url);
  394. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  395. return filename.match(/^[^.]+/)[0]; // remove extension
  396. }
  397. static isDownloaded({id, url}) {
  398. if (imagesHistoryBy === "TWEET_ID") {
  399. return downloadedImageTweetIds.hasItem(id);
  400. } else if (imagesHistoryBy === "IMAGE_NAME") {
  401. const name = Features._ImageHistory.getImageNameFromUrl(url);
  402. return downloadedImages.hasItem(name);
  403. }
  404. }
  405. static async markDownloaded({id, url}) {
  406. if (imagesHistoryBy === "TWEET_ID") {
  407. await downloadedImageTweetIds.pushItem(id);
  408. } else if (imagesHistoryBy === "IMAGE_NAME") {
  409. const name = Features._ImageHistory.getImageNameFromUrl(url);
  410. await downloadedImages.pushItem(name);
  411. }
  412. }
  413. }
  414. static async imagesHandler(preventBlinking) {
  415. verbose && console.log("[ujs-cns][imagesHandler]");
  416. const images = document.querySelectorAll("img");
  417. for (const img of images) {
  418.  
  419. if (img.width < 150 || img.dataset.handled) {
  420. continue;
  421. }
  422. verbose && console.log(img, img.width);
  423.  
  424. img.dataset.handled = "true";
  425.  
  426. const btn = Features.createButton({url: img.src});
  427. btn.addEventListener("click", Features._imageClickHandler);
  428.  
  429. let anchor = img.closest("a");
  430. // if an image is _opened_ "https://twitter.com/UserName/status/1234567890123456789/photo/1" [fake-url]
  431. if (!anchor) {
  432. anchor = img.parentNode;
  433. }
  434. anchor.append(btn);
  435. if (preventBlinking) {
  436. Features._preventBlinking(btn);
  437. }
  438.  
  439. const downloaded = Features._ImageHistory.isDownloaded({
  440. id: Tweet.of(btn).id,
  441. url: btn.dataset.url
  442. });
  443. if (downloaded) {
  444. btn.classList.add("ujs-already-downloaded");
  445. }
  446. }
  447. }
  448. static async _imageClickHandler(event) {
  449. event.preventDefault();
  450. event.stopImmediatePropagation();
  451.  
  452. const btn = event.currentTarget;
  453. const btnErrorTextElem = btn.querySelector(".ujs-btn-error-text");
  454. let url = btn.dataset.url;
  455.  
  456. const isBanner = url.includes("/profile_banners/");
  457. if (isBanner) {
  458. return Features._downloadBanner(url, btn);
  459. }
  460.  
  461. const {id, author} = Tweet.of(btn);
  462. verbose && console.log(id, author);
  463.  
  464. const btnProgress = btn.querySelector(".ujs-progress");
  465. if (btn.textContent !== "") {
  466. btnErrorTextElem.textContent = "";
  467. }
  468. btn.classList.remove("ujs-error");
  469. btn.classList.add("ujs-downloading");
  470.  
  471. let onProgress = null;
  472. if (settings.downloadProgress) {
  473. onProgress = ({loaded, total}) => btnProgress.style.cssText = "--progress: " + loaded / total * 90 + "%";
  474. }
  475.  
  476. const originals = ["orig", "4096x4096"];
  477. const samples = ["large", "medium", "900x900", "small", "360x360", /*"240x240", "120x120", "tiny"*/];
  478. let isSample = false;
  479. const previewSize = new URL(url).searchParams.get("name");
  480. if (!samples.includes(previewSize)) {
  481. samples.push(previewSize);
  482. }
  483.  
  484. function handleImgUrl(url) {
  485. const urlObj = new URL(url);
  486. if (originals.length) {
  487. urlObj.searchParams.set("name", originals.shift());
  488. } else if (samples.length) {
  489. isSample = true;
  490. urlObj.searchParams.set("name", samples.shift());
  491. } else {
  492. throw new Error("All fallback URLs are failed to download.");
  493. }
  494. url = urlObj.toString();
  495. verbose && console.log("[handleImgUrl]", url);
  496. return url;
  497. }
  498.  
  499. async function safeFetchResource(url) {
  500. while (true) {
  501. url = handleImgUrl(url);
  502. try {
  503. return await fetchResource(url, onProgress);
  504. } catch (e) {
  505. if (!originals.length) {
  506. btn.classList.add("ujs-error");
  507. btnErrorTextElem.textContent = "";
  508. // Add ⚠
  509. btnErrorTextElem.style = `background-image: url("https://abs-0.twimg.com/emoji/v2/svg/26a0.svg"); background-size: 1.5em; background-position: center; background-repeat: no-repeat;`;
  510. btn.title = "[warning] Original images are not available.";
  511. }
  512. if (!samples.length) {
  513. btnErrorTextElem.textContent = "";
  514. // Add ❌
  515. btnErrorTextElem.style = `background-image: url("https://abs-0.twimg.com/emoji/v2/svg/274c.svg"); background-size: 1.5em; background-position: center; background-repeat: no-repeat;`;
  516. btn.title = "Failed to download the image.";
  517. throw new Error("[error] Fallback URLs are failed.");
  518. }
  519. }
  520. }
  521. }
  522.  
  523. const {blob, lastModifiedDate, extension, name} = await safeFetchResource(url);
  524.  
  525. Features.verifyBlob(blob, url, btn);
  526.  
  527. btnProgress.style.cssText = "--progress: 100%";
  528.  
  529. const sampleText = !isSample ? "" : "[sample]";
  530. const filename = `[twitter]${sampleText} ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
  531. downloadBlob(blob, filename, url);
  532.  
  533. const downloaded = btn.classList.contains("already-downloaded");
  534. if (!downloaded && !isSample) {
  535. await Features._ImageHistory.markDownloaded({id, url});
  536. }
  537. btn.classList.remove("ujs-downloading");
  538. btn.classList.add("ujs-downloaded");
  539.  
  540. await sleep(40);
  541. btnProgress.style.cssText = "--progress: 0%";
  542. }
  543.  
  544. static tweetVidWeakMap = new WeakMap();
  545. static async videoHandler(preventBlinking) {
  546. const videos = document.querySelectorAll("video");
  547.  
  548. for (const vid of videos) {
  549. if (vid.dataset.handled) {
  550. continue;
  551. }
  552. verbose && console.log(vid);
  553. vid.dataset.handled = "true";
  554.  
  555. const poster = vid.getAttribute("poster");
  556.  
  557. const btn = Features.createButton({isVideo: true, url: poster});
  558. btn.addEventListener("click", Features._videoClickHandler);
  559.  
  560. let elem = vid.parentNode.parentNode.parentNode;
  561. elem.after(btn);
  562. if (preventBlinking) {
  563. Features._preventBlinking(btn);
  564. }
  565.  
  566. const tweet = Tweet.of(btn);
  567. const id = tweet.id;
  568. const tweetElem = tweet.elem;
  569. let vidNumber = 0;
  570.  
  571. const map = Features.tweetVidWeakMap;
  572. if (map.has(tweetElem)) {
  573. vidNumber = map.get(tweetElem) + 1;
  574. map.set(tweetElem, vidNumber);
  575. } else {
  576. map.set(tweetElem, vidNumber);
  577. }
  578.  
  579. const historyId = vidNumber ? id + "-" + vidNumber : id;
  580.  
  581. const downloaded = downloadedVideoTweetIds.hasItem(historyId);
  582. if (downloaded) {
  583. btn.classList.add("ujs-already-downloaded");
  584. }
  585. }
  586. }
  587. static async _videoClickHandler(event) {
  588. event.preventDefault();
  589. event.stopImmediatePropagation();
  590.  
  591. const btn = event.currentTarget;
  592. const btnErrorTextElem = btn.querySelector(".ujs-btn-error-text");
  593. let {id, author} = Tweet.of(btn);
  594.  
  595. if (btn.textContent !== "") {
  596. btnErrorTextElem.textContent = "";
  597. }
  598. btn.classList.remove("ujs-error");
  599. btn.classList.add("ujs-downloading");
  600.  
  601. const posterUrl = btn.dataset.url;
  602.  
  603. let video; // {bitrate, content_type, url}
  604. let vidNumber = 0;
  605. try {
  606. ({video, tweetId: id, screenName: author, vidNumber} = await API.getVideoInfo(id, author, posterUrl));
  607. verbose && console.log(video);
  608. } catch (e) {
  609. btn.classList.add("ujs-error");
  610. btnErrorTextElem.textContent = "Error";
  611. btn.title = "API.getVideoInfo Error";
  612. throw new Error("API.getVideoInfo Error");
  613. }
  614.  
  615. const btnProgress = btn.querySelector(".ujs-progress");
  616.  
  617. const url = video.url;
  618. let onProgress = null;
  619. if (settings.downloadProgress) {
  620. onProgress = ({loaded, total}) => btnProgress.style.cssText = "--progress: " + loaded / total * 90 + "%";
  621. }
  622.  
  623. const {blob, lastModifiedDate, extension, name} = await fetchResource(url, onProgress);
  624.  
  625. btnProgress.style.cssText = "--progress: 100%";
  626.  
  627. Features.verifyBlob(blob, url, btn);
  628.  
  629. const filename = `[twitter] ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
  630. downloadBlob(blob, filename, url);
  631.  
  632. const downloaded = btn.classList.contains("ujs-already-downloaded");
  633. const historyId = vidNumber ? id + "-" + vidNumber : id;
  634. if (!downloaded) {
  635. await downloadedVideoTweetIds.pushItem(historyId);
  636. }
  637. btn.classList.remove("ujs-downloading");
  638. btn.classList.add("ujs-downloaded");
  639.  
  640. await sleep(40);
  641. btnProgress.style.cssText = "--progress: 0%";
  642. }
  643.  
  644. static verifyBlob(blob, url, btn) {
  645. if (!blob.size) {
  646. btn.classList.add("ujs-error");
  647. btn.querySelector(".ujs-btn-error-text").textContent = "Error";
  648. btn.title = "Download Error";
  649. throw new Error("Zero size blob: " + url);
  650. }
  651. }
  652.  
  653. static addRequiredCSS() {
  654. const code = getUserScriptCSS();
  655. addCSS(code);
  656. }
  657.  
  658. // it depends of `directLinks()` use only it after `directLinks()`
  659. static handleTitle(title) {
  660.  
  661. if (!I18N.QUOTES) { // Unsupported lang, no QUOTES, ON_TWITTER, TWITTER constants
  662. return;
  663. }
  664.  
  665. // if not an opened tweet
  666. if (!location.href.match(/twitter\.com\/[^\/]+\/status\/\d+/)) {
  667. return;
  668. }
  669.  
  670. let titleText = title || document.title;
  671. if (titleText === Features.lastHandledTitle) {
  672. return;
  673. }
  674. Features.originalTitle = titleText;
  675.  
  676. const [OPEN_QUOTE, CLOSE_QUOTE] = I18N.QUOTES;
  677. const urlsToReplace = [
  678. ...titleText.matchAll(new RegExp(`https:\\/\\/t\\.co\\/[^ ${CLOSE_QUOTE}]+`, "g"))
  679. ].map(el => el[0]);
  680. // the last one may be the URL to the tweet // or to an embedded shared URL
  681.  
  682. const map = new Map();
  683. const anchors = document.querySelectorAll(`a[data-redirect^="https://t.co/"]`);
  684. for (const anchor of anchors) {
  685. if (urlsToReplace.includes(anchor.dataset.redirect)) {
  686. map.set(anchor.dataset.redirect, anchor.href);
  687. }
  688. }
  689.  
  690. const lastUrl = urlsToReplace.slice(-1)[0];
  691. let lastUrlIsAttachment = false;
  692. let attachmentDescription = "";
  693. if (!map.has(lastUrl)) {
  694. const a = document.querySelector(`a[href="${lastUrl}?amp=1"]`);
  695. if (a) {
  696. lastUrlIsAttachment = true;
  697. attachmentDescription = document.querySelectorAll(`a[href="${lastUrl}?amp=1"]`)[1].innerText;
  698. attachmentDescription = attachmentDescription.replaceAll("\n", " — ");
  699. }
  700. }
  701.  
  702. for (const [key, value] of map.entries()) {
  703. titleText = titleText.replaceAll(key, value + ` (${key})`);
  704. }
  705.  
  706. titleText = titleText.replace(new RegExp(`${I18N.ON_TWITTER}(?= ${OPEN_QUOTE})`), ":");
  707. titleText = titleText.replace(new RegExp(`(?<=${CLOSE_QUOTE}) \\\/ ${I18N.TWITTER}$`), "");
  708. if (!lastUrlIsAttachment) {
  709. const regExp = new RegExp(`(?<short> https:\\/\\/t\\.co\\/.{6,14})${CLOSE_QUOTE}$`);
  710. titleText = titleText.replace(regExp, (match, p1, p2, offset, string) => `${CLOSE_QUOTE} ${p1}`);
  711. } else {
  712. titleText = titleText.replace(lastUrl, `${lastUrl} (${attachmentDescription})`);
  713. }
  714. document.title = titleText; // Note: some characters will be removed automatically (`\n`, extra spaces)
  715. Features.lastHandledTitle = document.title;
  716. }
  717. static lastHandledTitle = "";
  718. static originalTitle = "";
  719.  
  720. static profileUrlCache = new Map();
  721. static async directLinks() {
  722. verbose && console.log("[ujs][directLinks]");
  723. const hasHttp = url => Boolean(url.match(/^https?:\/\//));
  724. const anchors = xpathAll(`.//a[@dir="ltr" and child::span and not(@data-handled)]`);
  725. for (const anchor of anchors) {
  726. const redirectUrl = new URL(anchor.href);
  727. const shortUrl = redirectUrl.origin + redirectUrl.pathname; // remove "?amp=1"
  728.  
  729. const hrefAttr = anchor.getAttribute("href");
  730. if (hrefAttr.startsWith("/")) {
  731. anchor.dataset.handled = "true";
  732. return;
  733. }
  734.  
  735. verbose && console.log("[ujs][directLinks]", hrefAttr, redirectUrl.href, shortUrl);
  736.  
  737. anchor.dataset.redirect = shortUrl;
  738. anchor.dataset.handled = "true";
  739. anchor.rel = "nofollow noopener noreferrer";
  740.  
  741. if (Features.profileUrlCache.has(shortUrl)) {
  742. anchor.href = Features.profileUrlCache.get(shortUrl);
  743. continue;
  744. }
  745.  
  746. const nodes = xpathAll(`./span[text() != "…"]|./text()`, anchor);
  747. let url = nodes.map(node => node.textContent).join("");
  748.  
  749. const doubleProtocolPrefix = url.match(/(?<dup>^https?:\/\/)(?=https?:)/)?.groups.dup;
  750. if (doubleProtocolPrefix) {
  751. url = url.slice(doubleProtocolPrefix.length);
  752. const span = anchor.querySelector(`[aria-hidden="true"]`);
  753. if (hasHttp(span.textContent)) { // Fix Twitter's bug related to text copying
  754. span.style = "display: none;";
  755. }
  756. }
  757.  
  758. anchor.href = url;
  759.  
  760. if (anchor.dataset?.testid === "UserUrl") {
  761. const href = anchor.getAttribute("href");
  762. const profileUrl = hasHttp(href) ? href : "https://" + href;
  763. anchor.href = profileUrl;
  764. verbose && console.log("[ujs][directLinks][UserUrl]", profileUrl);
  765.  
  766. // Restore if URL's text content is too long
  767. if (anchor.textContent.endsWith("…")) {
  768. anchor.href = shortUrl;
  769.  
  770. try {
  771. const author = location.pathname.slice(1).match(/[^\/]+/)[0];
  772. const expanded_url = await API.getUserInfo(author); // todo: make lazy
  773. anchor.href = expanded_url;
  774. Features.profileUrlCache.set(shortUrl, expanded_url);
  775. } catch (e) {
  776. verbose && console.error(e);
  777. }
  778. }
  779. }
  780. }
  781. if (anchors.length) {
  782. Features.handleTitle(Features.originalTitle);
  783. }
  784. }
  785.  
  786. // Do NOT throttle it
  787. static expandSpoilers() {
  788. const main = document.querySelector("main[role=main]");
  789. if (!main) {
  790. return;
  791. }
  792.  
  793. if (!I18N.YES_VIEW_PROFILE) { // Unsupported lang, no YES_VIEW_PROFILE, SHOW_NUDITY, VIEW constants
  794. return;
  795. }
  796.  
  797. const a = main.querySelectorAll("[data-testid=primaryColumn] [role=button]");
  798. if (a) {
  799. const elems = [...a];
  800. const button = elems.find(el => el.textContent === I18N.YES_VIEW_PROFILE);
  801. if (button) {
  802. button.click();
  803. }
  804.  
  805. // "Content warning: Nudity"
  806. // "The Tweet author flagged this Tweet as showing sensitive content."
  807. // "Show"
  808. const buttonShow = elems.find(el => el.textContent === I18N.SHOW_NUDITY);
  809. if (buttonShow) {
  810. // const verifying = a.previousSibling.textContent.includes("Nudity"); // todo?
  811. // if (verifying) {
  812. buttonShow.click();
  813. // }
  814. }
  815. }
  816.  
  817. // todo: expand spoiler commentary in photo view mode (.../photo/1)
  818. const b = main.querySelectorAll("article [role=presentation] div[role=button]");
  819. if (b) {
  820. const elems = [...b];
  821. const buttons = elems.filter(el => el.textContent === I18N.VIEW);
  822. if (buttons.length) {
  823. buttons.forEach(el => el.click());
  824. }
  825. }
  826. }
  827.  
  828. static hideSignUpSection() { // "New to Twitter?"
  829. if (!I18N.SIGNUP) {// Unsupported lang, no SIGNUP constant
  830. return;
  831. }
  832. const elem = document.querySelector(`section[aria-label="${I18N.SIGNUP}"][role=region]`);
  833. if (elem) {
  834. elem.parentNode.classList.add("ujs-hidden");
  835. }
  836. }
  837.  
  838. // Call it once.
  839. // "Don’t miss what’s happening" if you are not logged in.
  840. // It looks that `#layers` is used only for this bar.
  841. static hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically) {
  842. if (doNotPlayVideosAutomatically) {
  843. addCSS(`
  844. #layers > div:nth-child(1) {
  845. display: none;
  846. }
  847. `);
  848. } else {
  849. addCSS(`
  850. #layers > div:nth-child(1) {
  851. height: 1px;
  852. opacity: 0;
  853. }
  854. `);
  855. }
  856. }
  857.  
  858. // "Trends for you"
  859. static hideTrends() {
  860. if (!I18N.TRENDS) { // Unsupported lang, no TRENDS constant
  861. return;
  862. }
  863. addCSS(`
  864. [aria-label="${I18N.TRENDS}"]
  865. {
  866. display: none;
  867. }
  868. `);
  869. }
  870.  
  871. static highlightVisitedLinks() {
  872. if (settings.highlightOnlySpecialVisitedLinks) {
  873. addCSS(`
  874. a[href^="http"]:visited {
  875. color: darkorange;
  876. }
  877. `);
  878. return;
  879. }
  880. addCSS(`
  881. a:visited {
  882. color: darkorange;
  883. }
  884. `);
  885. }
  886.  
  887. // Hides "TOPICS TO FOLLOW" only in the right column, NOT in timeline.
  888. // Use it once. To prevent blinking.
  889. static hideTopicsToFollowInstantly() {
  890. if (!I18N.TOPICS_TO_FOLLOW) { // Unsupported lang, no TOPICS_TO_FOLLOW constant
  891. return;
  892. }
  893. addCSS(`
  894. div[aria-label="${I18N.TOPICS_TO_FOLLOW}"] {
  895. display: none;
  896. }
  897. `);
  898. }
  899.  
  900. // Hides container and "separator line"
  901. static hideTopicsToFollow() {
  902. if (!I18N.TOPICS_TO_FOLLOW) { // Unsupported lang, no TOPICS_TO_FOLLOW constant
  903. return;
  904. }
  905.  
  906. const elem = xpath(`.//section[@role="region" and child::div[@aria-label="${I18N.TOPICS_TO_FOLLOW}"]]/../..`);
  907. if (!elem) {
  908. return;
  909. }
  910. elem.classList.add("ujs-hidden");
  911.  
  912. elem.previousSibling.classList.add("ujs-hidden"); // a "separator line" (empty element of "TRENDS", for example)
  913. // in fact it's a hack // todo rework // may hide "You might like" section [bug]
  914. }
  915.  
  916. // todo split to two methods
  917. // todo fix it, currently it works questionably
  918. // not tested with non eng langs
  919. static footerHandled = false;
  920. static hideAndMoveFooter() { // "Terms of Service Privacy Policy Cookie Policy"
  921. let footer = document.querySelector(`main[role=main] nav[aria-label=${I18N.FOOTER}][role=navigation]`);
  922. const nav = document.querySelector("nav[aria-label=Primary][role=navigation]"); // I18N."Primary" [?]
  923.  
  924. if (footer) {
  925. footer = footer.parentNode;
  926. const separatorLine = footer.previousSibling;
  927.  
  928. if (Features.footerHandled) {
  929. footer.remove();
  930. separatorLine.remove();
  931. return;
  932. }
  933.  
  934. nav.append(separatorLine);
  935. nav.append(footer);
  936. footer.classList.add("ujs-show-on-hover");
  937. separatorLine.classList.add("ujs-show-on-hover");
  938.  
  939. Features.footerHandled = true;
  940. }
  941. }
  942.  
  943. static hideLoginPopup() { // When you are not logged in
  944. const targetNode = document.querySelector("html");
  945. const observerOptions = {
  946. attributes: true,
  947. };
  948. const observer = new MutationObserver(callback);
  949. observer.observe(targetNode, observerOptions);
  950.  
  951. function callback(mutationList, observer) {
  952. const html = document.querySelector("html");
  953. console.log(mutationList);
  954. // overflow-y: scroll; overscroll-behavior-y: none; font-size: 15px; // default
  955. // overflow: hidden; overscroll-behavior-y: none; font-size: 15px; margin-right: 15px; // popup
  956. if (html.style["overflow"] === "hidden") {
  957. html.style["overflow"] = "";
  958. html.style["overflow-y"] = "scroll";
  959. html.style["margin-right"] = "";
  960. }
  961. const popup = document.querySelector(`#layers div[data-testid="sheetDialog"]`);
  962. if (popup) {
  963. popup.closest(`div[role="dialog"]`).remove();
  964. verbose && (document.title = "⚒" + document.title);
  965. // observer.disconnect();
  966. }
  967. }
  968. }
  969.  
  970. }
  971.  
  972. return Features;
  973. }
  974.  
  975. // --- Twitter.RequiredCSS --- //
  976. function getUserScriptCSS() {
  977. const labelText = I18N.IMAGE || "Image";
  978.  
  979. // By default, the scroll is showed all time, since <html style="overflow-y: scroll;>,
  980. // so it works — no need to use `getScrollbarWidth` function from SO (13382516).
  981. const scrollbarWidth = window.innerWidth - document.body.offsetWidth;
  982.  
  983. const css = `
  984. .ujs-hidden {
  985. display: none;
  986. }
  987. .ujs-no-scroll {
  988. overflow-y: hidden;
  989. }
  990. .ujs-scroll-initial {
  991. overflow-y: initial!important;
  992. }
  993. .ujs-scrollbar-width-margin-right {
  994. margin-right: ${scrollbarWidth}px;
  995. }
  996.  
  997. .ujs-show-on-hover:hover {
  998. opacity: 1;
  999. transition: opacity 1s ease-out 0.1s;
  1000. }
  1001. .ujs-show-on-hover {
  1002. opacity: 0;
  1003. transition: opacity 0.5s ease-out;
  1004. }
  1005.  
  1006. :root {
  1007. --ujs-shadow-1: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  1008. --ujs-shadow-2: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  1009. --ujs-shadow-3: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  1010. --ujs-shadow-4: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  1011. --ujs-red: #e0245e;
  1012. --ujs-blue: #1da1f2;
  1013. --ujs-green: #4caf50;
  1014. --ujs-gray: #c2cbd0;
  1015. --ujs-error: white;
  1016. }
  1017.  
  1018. .ujs-progress {
  1019. background-image: linear-gradient(to right, var(--ujs-green) var(--progress), transparent 0%);
  1020. }
  1021.  
  1022. .ujs-shadow {
  1023. background-image: var(--ujs-shadow-1);
  1024. }
  1025. .ujs-btn-download:hover .ujs-hover {
  1026. background-image: var(--ujs-shadow-2);
  1027. }
  1028. .ujs-btn-download.ujs-downloading .ujs-shadow {
  1029. background-image: var(--ujs-shadow-3);
  1030. }
  1031. .ujs-btn-download:active .ujs-shadow {
  1032. background-image: var(--ujs-shadow-4);
  1033. }
  1034.  
  1035. article[role=article]:hover .ujs-btn-download {
  1036. opacity: 1;
  1037. }
  1038. div[aria-label="${labelText}"]:hover .ujs-btn-download {
  1039. opacity: 1;
  1040. }
  1041. .ujs-btn-download.ujs-downloaded {
  1042. opacity: 1;
  1043. }
  1044. .ujs-btn-download.ujs-downloading {
  1045. opacity: 1;
  1046. }
  1047.  
  1048. .ujs-btn-download {
  1049. cursor: pointer;
  1050. top: 0.5em;
  1051. left: 0.5em;
  1052. position: absolute;
  1053. opacity: 0;
  1054. }
  1055. .ujs-btn-common {
  1056. width: 33px;
  1057. height: 33px;
  1058. border-radius: 0.3em;
  1059. top: 0;
  1060. position: absolute;
  1061. border: 1px solid transparent;
  1062. border-color: var(--ujs-gray);
  1063. ${settings.addBorder ? "border: 2px solid white;" : "border-color: var(--ujs-gray);"}
  1064. }
  1065. .ujs-not-downloaded .ujs-btn-background {
  1066. background: var(--ujs-red);
  1067. }
  1068.  
  1069. .ujs-already-downloaded .ujs-btn-background {
  1070. background: var(--ujs-blue);
  1071. }
  1072.  
  1073. .ujs-downloaded .ujs-btn-background {
  1074. background: var(--ujs-green);
  1075. }
  1076.  
  1077. .ujs-error .ujs-btn-background {
  1078. background: var(--ujs-error);
  1079. }
  1080.  
  1081. .ujs-btn-error-text {
  1082. display: flex;
  1083. align-items: center;
  1084. justify-content: center;
  1085. color: black;
  1086. font-size: 100%;
  1087. }`;
  1088. return css.slice(1);
  1089. }
  1090.  
  1091. /*
  1092. Features depend on:
  1093.  
  1094. addRequiredCSS: IMAGE
  1095.  
  1096. expandSpoilers: YES_VIEW_PROFILE, SHOW_NUDITY, VIEW
  1097. handleTitle: QUOTES, ON_TWITTER, TWITTER
  1098. hideSignUpSection: SIGNUP
  1099. hideTrends: TRENDS
  1100. hideTopicsToFollowInstantly: TOPICS_TO_FOLLOW,
  1101.  
  1102. hideTopicsToFollow: TOPICS_TO_FOLLOW,
  1103.  
  1104. [unused]
  1105. hideAndMoveFooter: FOOTER
  1106. */
  1107.  
  1108. // --- Twitter.LangConstants --- //
  1109. function getLanguageConstants() { //todo: "de", "fr"
  1110. const defaultQuotes = [`"`, `"`];
  1111.  
  1112. const SUPPORTED_LANGUAGES = ["en", "ru", "es", "zh", "ja", ];
  1113.  
  1114. // texts
  1115. const VIEW = ["View", "Посмотреть", "Ver", "查看", "表示", ];
  1116. const YES_VIEW_PROFILE = ["Yes, view profile", "Да, посмотреть профиль", "Sí, ver perfil", "是,查看个人资料", "プロフィールを表示する", ];
  1117.  
  1118. const SHOW_NUDITY = ["Show", "Показать", "Mostrar", "显示", "表示", ];
  1119. // aria-label texts
  1120. const IMAGE = ["Image", "Изображение", "Imagen", "图像", "画像", ];
  1121. const SIGNUP = ["Sign up", "Зарегистрироваться", "Regístrate", "注册", "アカウント作成", ];
  1122. const TRENDS = ["Timeline: Trending now", "Лента: Актуальные темы", "Cronología: Tendencias del momento", "时间线:当前趋势", "タイムライン: トレンド", ];
  1123. const TOPICS_TO_FOLLOW = ["Timeline: ", "Лента: ", "Cronología: ", "时间线:", /*[1]*/ "タイムライン: ", /*[1]*/ ];
  1124. const WHO_TO_FOLLOW = ["Who to follow", "Кого читать", "A quién seguir", "推荐关注", "おすすめユーザー" ];
  1125. const FOOTER = ["Footer", "Нижний колонтитул", "Pie de página", "页脚", "フッター", ];
  1126. // *1 — it's a suggestion, need to recheck. But I can't find a page where I can check it. Was it deleted?
  1127.  
  1128. // document.title "{AUTHOR}{ON_TWITTER} {QUOTES[0]}{TEXT}{QUOTES[1]} / {TWITTER}"
  1129. const QUOTES = [defaultQuotes, [`«`, `»`], defaultQuotes, defaultQuotes, [`「`, `」`], ];
  1130. const ON_TWITTER = [" on Twitter:", " в Твиттере:", " en Twitter:", " 在 Twitter:", "さんはTwitterを使っています", ];
  1131. const TWITTER = ["Twitter", "Твиттер", "Twitter", "Twitter", "Twitter", ];
  1132.  
  1133. const lang = document.querySelector("html").getAttribute("lang");
  1134. const langIndex = SUPPORTED_LANGUAGES.indexOf(lang);
  1135.  
  1136. return {
  1137. SUPPORTED_LANGUAGES,
  1138. VIEW: VIEW[langIndex],
  1139. YES_VIEW_PROFILE: YES_VIEW_PROFILE[langIndex],
  1140. SIGNUP: SIGNUP[langIndex],
  1141. TRENDS: TRENDS[langIndex],
  1142. TOPICS_TO_FOLLOW: TOPICS_TO_FOLLOW[langIndex],
  1143. WHO_TO_FOLLOW: WHO_TO_FOLLOW[langIndex],
  1144. FOOTER: FOOTER[langIndex],
  1145. QUOTES: QUOTES[langIndex],
  1146. ON_TWITTER: ON_TWITTER[langIndex],
  1147. TWITTER: TWITTER[langIndex],
  1148. IMAGE: IMAGE[langIndex],
  1149. SHOW_NUDITY: SHOW_NUDITY[langIndex],
  1150. }
  1151. }
  1152.  
  1153. // --- Twitter.Tweet --- //
  1154. function hoistTweet() {
  1155. class Tweet {
  1156. constructor({elem, url}) {
  1157. if (url) {
  1158. this.elem = null;
  1159. this.url = url;
  1160. } else {
  1161. this.elem = elem;
  1162. this.url = Tweet.getUrl(elem);
  1163. }
  1164. }
  1165.  
  1166. static of(innerElem) {
  1167. // Workaround for media from a quoted tweet
  1168. const url = innerElem.closest(`a[href^="/"]`)?.href;
  1169. if (url && url.includes("/status/")) {
  1170. return new Tweet({url});
  1171. }
  1172.  
  1173. const elem = innerElem.closest(`[data-testid="tweet"]`);
  1174. if (!elem) { // opened image
  1175. verbose && console.log("no-tweet elem");
  1176. }
  1177. return new Tweet({elem});
  1178. }
  1179.  
  1180. static getUrl(elem) {
  1181. if (!elem) { // if opened image
  1182. return location.href;
  1183. }
  1184.  
  1185. const tweetAnchor = [...elem.querySelectorAll("a")].find(el => {
  1186. return el.childNodes[0]?.nodeName === "TIME";
  1187. });
  1188.  
  1189. if (tweetAnchor) {
  1190. return tweetAnchor.href;
  1191. }
  1192. // else if selected tweet
  1193. return location.href;
  1194. }
  1195.  
  1196. get author() {
  1197. return this.url.match(/(?<=twitter\.com\/).+?(?=\/)/)?.[0];
  1198. }
  1199.  
  1200. get id() {
  1201. return this.url.match(/(?<=\/status\/)\d+/)?.[0];
  1202. }
  1203. }
  1204.  
  1205. return Tweet;
  1206. }
  1207.  
  1208. // --- Twitter.API --- //
  1209. function hoistAPI() {
  1210. class API {
  1211. static guestToken = getCookie("gt");
  1212. static csrfToken = getCookie("ct0"); // todo: lazy — not available at the first run
  1213. // Guest/Suspended account Bearer token
  1214. static guestAuthorization = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
  1215.  
  1216. // Seems to be outdated at 2022.05
  1217. static async _requestBearerToken() {
  1218. const scriptSrc = [...document.querySelectorAll("script")]
  1219. .find(el => el.src.match(/https:\/\/abs\.twimg\.com\/responsive-web\/client-web\/main[\w.]*\.js/)).src;
  1220.  
  1221. let text;
  1222. try {
  1223. text = await (await fetch(scriptSrc)).text();
  1224. } catch (e) {
  1225. console.error(e, scriptSrc);
  1226. throw e;
  1227. }
  1228.  
  1229. const authorizationKey = text.match(/(?<=")AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D.+?(?=")/)[0];
  1230. const authorization = `Bearer ${authorizationKey}`;
  1231.  
  1232. return authorization;
  1233. }
  1234.  
  1235. static async getAuthorization() {
  1236. if (!API.authorization) {
  1237. API.authorization = await API._requestBearerToken();
  1238. }
  1239. return API.authorization;
  1240. }
  1241.  
  1242. static async apiRequest(url) {
  1243. const _url = url.toString();
  1244. verbose && console.log("[ujs][apiRequest]", _url);
  1245.  
  1246. // Hm... it is always the same. Even for a logged user.
  1247. // const authorization = API.guestToken ? API.guestAuthorization : await API.getAuthorization();
  1248. const authorization = API.guestAuthorization;
  1249.  
  1250. // for debug
  1251. verbose && sessionStorage.setItem("guestAuthorization", API.guestAuthorization);
  1252. verbose && sessionStorage.setItem("authorization", API.authorization);
  1253. verbose && sessionStorage.setItem("x-csrf-token", API.csrfToken);
  1254. verbose && sessionStorage.setItem("x-guest-token", API.guestToken);
  1255.  
  1256. const headers = new Headers({
  1257. authorization,
  1258. "x-csrf-token": API.csrfToken,
  1259. "x-twitter-client-language": "en",
  1260. "x-twitter-active-user": "yes"
  1261. });
  1262. if (API.guestToken) {
  1263. headers.append("x-guest-token", API.guestToken);
  1264. } else { // may be skipped
  1265. headers.append("x-twitter-auth-type", "OAuth2Session");
  1266. }
  1267.  
  1268. let json;
  1269. try {
  1270. const response = await fetch(_url, {headers});
  1271. json = await response.json();
  1272. } catch (e) {
  1273. console.error(e, _url);
  1274. throw e;
  1275. }
  1276.  
  1277. verbose && console.log("[ujs][apiRequest]", JSON.stringify(json, null, " "));
  1278. // 429 - [{code: 88, message: "Rate limit exceeded"}] — for suspended accounts
  1279.  
  1280. return json;
  1281. }
  1282.  
  1283. // @return {bitrate, content_type, url, vidNumber}
  1284. static async getVideoInfo(tweetId, screenName, posterUrl) {
  1285. // const url = new URL(`https://api.twitter.com/2/timeline/conversation/${tweetId}.json`); // only for suspended/anon
  1286. const url = new URL(`https://twitter.com/i/api/2/timeline/conversation/${tweetId}.json`);
  1287. url.searchParams.set("tweet_mode", "extended");
  1288.  
  1289. const json = await API.apiRequest(url);
  1290. let tweetData = json.globalObjects.tweets[tweetId];
  1291.  
  1292. const isVideoInQuotedPost = !tweetData.extended_entities || tweetData.extended_entities.media
  1293. .findIndex(e => e.media_url_https === posterUrl) === -1;
  1294.  
  1295. if (tweetData.quoted_status_id_str && isVideoInQuotedPost) {
  1296. tweetId = tweetData.quoted_status_id_str;
  1297. const userIdStr = json.globalObjects.tweets[tweetId].user_id_str;
  1298. screenName = json.globalObjects.users[userIdStr].screen_name;
  1299. tweetData = json.globalObjects.tweets[tweetId];
  1300. }
  1301.  
  1302. // types: "photo", "video", "animated_gif"
  1303.  
  1304. let vidNumber = tweetData.extended_entities.media
  1305. .filter(e => e.type !== "photo")
  1306. .findIndex(e => e.media_url_https === posterUrl);
  1307.  
  1308. let mediaIndex = tweetData.extended_entities.media
  1309. .findIndex(e => e.media_url_https === posterUrl);
  1310.  
  1311. if (vidNumber === -1 || mediaIndex === -1) {
  1312. verbose && console.log("[ujs][warning]: vidNumber === -1 || mediaIndex === -1");
  1313. vidNumber = 0;
  1314. mediaIndex = 0;
  1315. }
  1316. const videoVariants = tweetData.extended_entities.media[mediaIndex].video_info.variants;
  1317. verbose && console.log("[getVideoInfo]", videoVariants);
  1318.  
  1319. const video = videoVariants
  1320. .filter(el => el.bitrate !== undefined) // if content_type: "application/x-mpegURL" // .m3u8
  1321. .reduce((acc, cur) => cur.bitrate > acc.bitrate ? cur : acc);
  1322.  
  1323. if (!video) {
  1324. throw new Error("No video URL");
  1325. }
  1326.  
  1327. return {video, tweetId, screenName, vidNumber};
  1328. }
  1329.  
  1330. static async getUserInfo(username) {
  1331. const qHash = "Bhlf1dYJ3bYCKmLfeEQ31A"; // todo: change
  1332. const variables = JSON.stringify({
  1333. "screen_name": username,
  1334. "withSafetyModeUserFields": true,
  1335. "withSuperFollowsUserFields": true
  1336. });
  1337. const url = `https://twitter.com/i/api/graphql/${qHash}/UserByScreenName?variables=${encodeURIComponent(variables)}`;
  1338. const json = await API.apiRequest(url);
  1339. verbose && console.log("[getUserInfo]", json);
  1340. return json.data.user.result.legacy.entities.url?.urls[0].expanded_url;
  1341. }
  1342. }
  1343.  
  1344. return API;
  1345. }
  1346.  
  1347. // ---------------------------------------------------------------------------------------------------------------------
  1348. // ---------------------------------------------------------------------------------------------------------------------
  1349. // --- Common Utils --- //
  1350.  
  1351. // --- LocalStorage util class --- //
  1352. function hoistLS(settings = {}) {
  1353. const {
  1354. verbose, // debug "messages" in the document.title
  1355. } = settings;
  1356.  
  1357. class LS {
  1358. constructor(name) {
  1359. this.name = name;
  1360. }
  1361. getItem(defaultValue) {
  1362. return LS.getItem(this.name, defaultValue);
  1363. }
  1364. setItem(value) {
  1365. LS.setItem(this.name, value);
  1366. }
  1367. removeItem() {
  1368. LS.removeItem(this.name);
  1369. }
  1370. async pushItem(value) { // array method
  1371. await LS.pushItem(this.name, value);
  1372. }
  1373. async popItem(value) { // array method
  1374. await LS.popItem(this.name, value);
  1375. }
  1376. hasItem(value) { // array method
  1377. return LS.hasItem(this.name, value);
  1378. }
  1379.  
  1380. static getItem(name, defaultValue) {
  1381. const value = localStorage.getItem(name);
  1382. if (value === undefined) {
  1383. return undefined;
  1384. }
  1385. if (value === null) { // when there is no such item
  1386. LS.setItem(name, defaultValue);
  1387. return defaultValue;
  1388. }
  1389. return JSON.parse(value);
  1390. }
  1391. static setItem(name, value) {
  1392. localStorage.setItem(name, JSON.stringify(value));
  1393. }
  1394. static removeItem(name) {
  1395. localStorage.removeItem(name);
  1396. }
  1397. static async pushItem(name, value) {
  1398. const array = LS.getItem(name, []);
  1399. array.push(value);
  1400. LS.setItem(name, array);
  1401.  
  1402. //sanity check
  1403. await sleep(50);
  1404. if (!LS.hasItem(name, value)) {
  1405. if (verbose) {
  1406. document.title = "🟥" + document.title;
  1407. }
  1408. await LS.pushItem(name, value);
  1409. }
  1410. }
  1411. static async popItem(name, value) { // remove from an array
  1412. const array = LS.getItem(name, []);
  1413. if (array.indexOf(value) !== -1) {
  1414. array.splice(array.indexOf(value), 1);
  1415. LS.setItem(name, array);
  1416.  
  1417. //sanity check
  1418. await sleep(50);
  1419. if (LS.hasItem(name, value)) {
  1420. if (verbose) {
  1421. document.title = "🟨" + document.title;
  1422. }
  1423. await LS.popItem(name, value);
  1424. }
  1425. }
  1426. }
  1427. static hasItem(name, value) { // has in array
  1428. const array = LS.getItem(name, []);
  1429. return array.indexOf(value) !== -1;
  1430. }
  1431. }
  1432.  
  1433. return LS;
  1434. }
  1435.  
  1436. // --- Just groups them in a function for the convenient code looking --- //
  1437. function getUtils({verbose}) {
  1438. function sleep(time) {
  1439. return new Promise(resolve => setTimeout(resolve, time));
  1440. }
  1441.  
  1442. async function fetchResource(url, onProgress = props => console.log(props)) {
  1443. try {
  1444. let response = await fetch(url, {
  1445. // cache: "force-cache",
  1446. });
  1447. const lastModifiedDateSeconds = response.headers.get("last-modified");
  1448. const contentType = response.headers.get("content-type");
  1449.  
  1450. const lastModifiedDate = dateToDayDateString(lastModifiedDateSeconds);
  1451. const extension = contentType ? extensionFromMime(contentType) : null;
  1452.  
  1453. if (onProgress) {
  1454. response = responseProgressProxy(response, onProgress);
  1455. }
  1456.  
  1457. const blob = await response.blob();
  1458.  
  1459. // https://pbs.twimg.com/media/AbcdEFgijKL01_9?format=jpg&name=orig -> AbcdEFgijKL01_9
  1460. // https://pbs.twimg.com/ext_tw_video_thumb/1234567890123456789/pu/img/Ab1cd2345EFgijKL.jpg?name=orig -> Ab1cd2345EFgijKL.jpg
  1461. // https://video.twimg.com/ext_tw_video/1234567890123456789/pu/vid/946x720/Ab1cd2345EFgijKL.mp4?tag=10 -> Ab1cd2345EFgijKL.mp4
  1462. const _url = new URL(url);
  1463. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  1464.  
  1465. const {name} = filename.match(/(?<name>^[^.]+)/).groups;
  1466. return {blob, lastModifiedDate, contentType, extension, name};
  1467. } catch (error) {
  1468. verbose && console.error("[fetchResource]", url, error);
  1469. throw error;
  1470. }
  1471. }
  1472.  
  1473. function extensionFromMime(mimeType) {
  1474. let extension = mimeType.match(/(?<=\/).+/)[0];
  1475. extension = extension === "jpeg" ? "jpg" : extension;
  1476. return extension;
  1477. }
  1478.  
  1479. // the original download url will be posted as hash of the blob url, so you can check it in the download manager's history
  1480. function downloadBlob(blob, name, url) {
  1481. const anchor = document.createElement("a");
  1482. anchor.setAttribute("download", name || "");
  1483. const blobUrl = URL.createObjectURL(blob);
  1484. anchor.href = blobUrl + (url ? ("#" + url) : "");
  1485. anchor.click();
  1486. setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
  1487. }
  1488.  
  1489. // "Sun, 10 Jan 2021 22:22:22 GMT" -> "2021.01.10"
  1490. function dateToDayDateString(dateValue, utc = true) {
  1491. const _date = new Date(dateValue);
  1492. function pad(str) {
  1493. return str.toString().padStart(2, "0");
  1494. }
  1495. const _utc = utc ? "UTC" : "";
  1496. const year = _date[`get${_utc}FullYear`]();
  1497. const month = _date[`get${_utc}Month`]() + 1;
  1498. const date = _date[`get${_utc}Date`]();
  1499.  
  1500. return year + "." + pad(month) + "." + pad(date);
  1501. }
  1502.  
  1503. function addCSS(css) {
  1504. const styleElem = document.createElement("style");
  1505. styleElem.textContent = css;
  1506. document.body.append(styleElem);
  1507. return styleElem;
  1508. }
  1509.  
  1510. function getCookie(name) {
  1511. verbose && console.log(document.cookie);
  1512. const regExp = new RegExp(`(?<=${name}=)[^;]+`);
  1513. return document.cookie.match(regExp)?.[0];
  1514. }
  1515.  
  1516. function throttle(runnable, time = 50) {
  1517. let waiting = false;
  1518. let queued = false;
  1519. let context;
  1520. let args;
  1521.  
  1522. return function() {
  1523. if (!waiting) {
  1524. waiting = true;
  1525. setTimeout(function() {
  1526. if (queued) {
  1527. runnable.apply(context, args);
  1528. context = args = undefined;
  1529. }
  1530. waiting = queued = false;
  1531. }, time);
  1532. return runnable.apply(this, arguments);
  1533. } else {
  1534. queued = true;
  1535. context = this;
  1536. args = arguments;
  1537. }
  1538. }
  1539. }
  1540.  
  1541. function throttleWithResult(func, time = 50) {
  1542. let waiting = false;
  1543. let args;
  1544. let context;
  1545. let timeout;
  1546. let promise;
  1547.  
  1548. return async function() {
  1549. if (!waiting) {
  1550. waiting = true;
  1551. timeout = new Promise(async resolve => {
  1552. await sleep(time);
  1553. waiting = false;
  1554. resolve();
  1555. });
  1556. return func.apply(this, arguments);
  1557. } else {
  1558. args = arguments;
  1559. context = this;
  1560. }
  1561.  
  1562. if (!promise) {
  1563. promise = new Promise(async resolve => {
  1564. await timeout;
  1565. const result = func.apply(context, args);
  1566. args = context = promise = undefined;
  1567. resolve(result);
  1568. });
  1569. }
  1570. return promise;
  1571. }
  1572. }
  1573.  
  1574. function xpath(path, node = document) {
  1575. let xPathResult = document.evaluate(path, node, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  1576. return xPathResult.singleNodeValue;
  1577. }
  1578. function xpathAll(path, node = document) {
  1579. let xPathResult = document.evaluate(path, node, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
  1580. const nodes = [];
  1581. try {
  1582. let node = xPathResult.iterateNext();
  1583.  
  1584. while (node) {
  1585. nodes.push(node);
  1586. node = xPathResult.iterateNext();
  1587. }
  1588. return nodes;
  1589. } catch (e) {
  1590. // todo need investigate it
  1591. console.error(e); // "The document has mutated since the result was returned."
  1592. return [];
  1593. }
  1594. }
  1595.  
  1596. const identityContentEncodings = new Set([null, "identity", "no encoding"]);
  1597. function getOnProgressProps(response) {
  1598. const {headers, status, statusText, url, redirected, ok} = response;
  1599. const isIdentity = identityContentEncodings.has(headers.get("Content-Encoding"));
  1600. const compressed = !isIdentity;
  1601. const _contentLength = parseInt(headers.get("Content-Length")); // `get()` returns `null` if no header present
  1602. const contentLength = isNaN(_contentLength) ? null : _contentLength;
  1603. const lengthComputable = isIdentity && _contentLength !== null;
  1604.  
  1605. // Original XHR behaviour; in TM it equals to `contentLength`, or `-1` if `contentLength` is `null` (and `0`?).
  1606. const total = lengthComputable ? contentLength : 0;
  1607. const gmTotal = contentLength > 0 ? contentLength : -1; // Like `total` is in TM and GM.
  1608.  
  1609. return {
  1610. gmTotal, total, lengthComputable,
  1611. compressed, contentLength,
  1612. headers, status, statusText, url, redirected, ok
  1613. };
  1614. }
  1615. function responseProgressProxy(response, onProgress) {
  1616. const onProgressProps = getOnProgressProps(response);
  1617. let loaded = 0;
  1618. const reader = response.body.getReader();
  1619. const readableStream = new ReadableStream({
  1620. async start(controller) {
  1621. while (true) {
  1622. const {done, /** @type {Uint8Array} */ value} = await reader.read();
  1623. if (done) {
  1624. break;
  1625. }
  1626. loaded += value.length;
  1627. try {
  1628. onProgress({loaded, ...onProgressProps});
  1629. } catch (e) {
  1630. console.error("[onProgress]:", e);
  1631. }
  1632. controller.enqueue(value);
  1633. }
  1634. controller.close();
  1635. reader.releaseLock();
  1636. },
  1637. cancel() {
  1638. void reader.cancel();
  1639. }
  1640. });
  1641. return new ResponseEx(readableStream, response);
  1642. }
  1643. class ResponseEx extends Response {
  1644. [Symbol.toStringTag] = "ResponseEx";
  1645.  
  1646. constructor(body, {headers, status, statusText, url, redirected, type}) {
  1647. super(body, {
  1648. status, statusText, headers: {
  1649. ...headers,
  1650. "content-type": headers.get("content-type").split("; ")[0] // Fixes Blob type ("text/html; charset=UTF-8") in TM
  1651. }
  1652. });
  1653. this._type = type;
  1654. this._url = url;
  1655. this._redirected = redirected;
  1656. this._headers = headers; // `HeadersLike` is more user-friendly for debug than the original `Headers` object
  1657. }
  1658. get redirected() { return this._redirected; }
  1659. get url() { return this._url; }
  1660. get type() { return this._type || "basic"; }
  1661. /** @returns {HeadersLike} */
  1662. get headers() { return this._headers; }
  1663. }
  1664.  
  1665. return {
  1666. sleep, fetchResource, extensionFromMime, downloadBlob, dateToDayDateString,
  1667. addCSS,
  1668. getCookie,
  1669. throttle, throttleWithResult,
  1670. xpath, xpathAll,
  1671. responseProgressProxy,
  1672. }
  1673. }
  1674.  
  1675. // ---------------------------------------------------------------------------------------------------------------------
  1676. // ---------------------------------------------------------------------------------------------------------------------