Greasy Fork 还支持 简体中文。

Twitter Click'n'Save

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

目前為 2023-09-17 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Twitter Click'n'Save
  3. // @version 1.6.5-2023.09.17
  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. // Please, report bugs and suggestions on GitHub, not Greasyfork.
  16. // --> https://github.com/AlttiRi/twitter-click-and-save/issues <--
  17.  
  18. // ---------------------------------------------------------------------------------------------------------------------
  19. // ---------------------------------------------------------------------------------------------------------------------
  20. // --- "Imports" --- //
  21.  
  22. const {StorageNames, StorageNamesOld} = getStorageNames();
  23.  
  24. const {verbose, debugPopup} = getDebugSettings(); // --- For debug --- //
  25.  
  26.  
  27. const {
  28. sleep, fetchResource, downloadBlob,
  29. addCSS,
  30. getCookie,
  31. throttle,
  32. xpath, xpathAll,
  33. responseProgressProxy,
  34. dateToDayDateString,
  35. toLineJSON,
  36. isFirefox,
  37. getBrowserName,
  38. } = getUtils({verbose});
  39.  
  40. const LS = hoistLS({verbose});
  41.  
  42. const API = hoistAPI();
  43. const Tweet = hoistTweet();
  44. const Features = hoistFeatures();
  45. const I18N = getLanguageConstants();
  46.  
  47. // ---------------------------------------------------------------------------------------------------------------------
  48.  
  49. function getStorageNames() {
  50. // New LocalStorage key names 2023.07.05
  51. const StorageNames = {
  52. settings: "ujs-twitter-click-n-save-settings",
  53. settingsImageHistoryBy: "ujs-twitter-click-n-save-settings-image-history-by",
  54. downloadedImageNames: "ujs-twitter-click-n-save-downloaded-image-names",
  55. downloadedImageTweetIds: "ujs-twitter-click-n-save-downloaded-image-tweet-ids",
  56. downloadedVideoTweetIds: "ujs-twitter-click-n-save-downloaded-video-tweet-ids",
  57.  
  58. migrated: "ujs-twitter-click-n-save-migrated", // Currently unused
  59. browserName: "ujs-twitter-click-n-save-browser-name", // Hidden settings
  60. verbose: "ujs-twitter-click-n-save-verbose", // Hidden settings for debug
  61. debugPopup: "ujs-twitter-click-n-save-debug-popup", // Hidden settings for debug
  62. };
  63. const StorageNamesOld = {
  64. settings: "ujs-click-n-save-settings",
  65. settingsImageHistoryBy: "ujs-images-history-by",
  66. downloadedImageNames: "ujs-twitter-downloaded-images-names",
  67. downloadedImageTweetIds: "ujs-twitter-downloaded-image-tweet-ids",
  68. downloadedVideoTweetIds: "ujs-twitter-downloaded-video-tweet-ids",
  69. };
  70. return {StorageNames, StorageNamesOld};
  71. }
  72.  
  73. function getDebugSettings() {
  74. let verbose = false;
  75. let debugPopup = false;
  76. try {
  77. verbose = Boolean(JSON.parse(localStorage.getItem(StorageNames.verbose)));
  78. } catch (err) {}
  79. try {
  80. debugPopup = Boolean(JSON.parse(localStorage.getItem(StorageNames.debugPopup)));
  81. } catch (err) {}
  82.  
  83. return {verbose, debugPopup};
  84. }
  85.  
  86. const historyHelper = getHistoryHelper();
  87. historyHelper.migrateLocalStore();
  88.  
  89. // ---------------------------------------------------------------------------------------------------------------------
  90.  
  91.  
  92. // ---------------------------------------------------------------------------------------------------------------------
  93.  
  94. if (globalThis.GM_registerMenuCommand /* undefined in Firefox with VM */ || typeof GM_registerMenuCommand === "function") {
  95. GM_registerMenuCommand("Show settings", showSettings);
  96. }
  97.  
  98. const settings = loadSettings();
  99.  
  100. if (verbose) {
  101. console.log("[ujs][settings]", settings);
  102. }
  103. if (debugPopup) {
  104. showSettings();
  105. }
  106.  
  107. // ---------------------------------------------------------------------------------------------------------------------
  108.  
  109. const fetch = ujs_getGlobalFetch({verbose, strictTrackingProtectionFix: settings.strictTrackingProtectionFix});
  110.  
  111. function ujs_getGlobalFetch({verbose, strictTrackingProtectionFix} = {}) {
  112. const useFirefoxStrictTrackingProtectionFix = strictTrackingProtectionFix === undefined ? true : strictTrackingProtectionFix; // Let's use by default
  113. const useFirefoxFix = useFirefoxStrictTrackingProtectionFix && typeof wrappedJSObject === "object" && typeof wrappedJSObject.fetch === "function";
  114. // --- [VM/GM + Firefox ~90+ + Enabled "Strict Tracking Protection"] fix --- //
  115. function fixedFirefoxFetch(resource, init = {}) {
  116. verbose && console.log("[ujs][wrappedJSObject.fetch]", resource, init);
  117. if (init.headers instanceof Headers) {
  118. // Since `Headers` are not allowed for structured cloning.
  119. init.headers = Object.fromEntries(init.headers.entries());
  120. }
  121. return wrappedJSObject.fetch(cloneInto(resource, document), cloneInto(init, document));
  122. }
  123. return useFirefoxFix ? fixedFirefoxFetch : globalThis.fetch;
  124. }
  125.  
  126. // ---------------------------------------------------------------------------------------------------------------------
  127. // --- Features to execute --- //
  128.  
  129. const doNotPlayVideosAutomatically = false; // Hidden settings
  130.  
  131. function execFeaturesOnce() {
  132. settings.goFromMobileToMainSite && Features.goFromMobileToMainSite();
  133. settings.addRequiredCSS && Features.addRequiredCSS();
  134. settings.hideSignUpBottomBarAndMessages && Features.hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically);
  135. settings.hideTrends && Features.hideTrends();
  136. settings.highlightVisitedLinks && Features.highlightVisitedLinks();
  137. settings.hideLoginPopup && Features.hideLoginPopup();
  138. }
  139. function execFeaturesImmediately() {
  140. settings.expandSpoilers && Features.expandSpoilers();
  141. }
  142. function execFeatures() {
  143. settings.imagesHandler && Features.imagesHandler();
  144. settings.videoHandler && Features.videoHandler();
  145. settings.expandSpoilers && Features.expandSpoilers();
  146. settings.hideSignUpSection && Features.hideSignUpSection();
  147. settings.directLinks && Features.directLinks();
  148. settings.handleTitle && Features.handleTitle();
  149. }
  150.  
  151. // ---------------------------------------------------------------------------------------------------------------------
  152.  
  153. // ---------------------------------------------------------------------------------------------------------------------
  154. // --- Script runner --- //
  155.  
  156. (function starter(feats) {
  157. const {once, onChangeImmediate, onChange} = feats;
  158.  
  159. once();
  160. onChangeImmediate();
  161. const onChangeThrottled = throttle(onChange, 250);
  162. onChangeThrottled();
  163.  
  164. const targetNode = document.querySelector("body");
  165. const observerOptions = {
  166. subtree: true,
  167. childList: true,
  168. };
  169. const observer = new MutationObserver(callback);
  170. observer.observe(targetNode, observerOptions);
  171.  
  172. function callback(mutationList, _observer) {
  173. verbose && console.log("[ujs][mutationList]", mutationList);
  174. onChangeImmediate();
  175. onChangeThrottled();
  176. }
  177. })({
  178. once: execFeaturesOnce,
  179. onChangeImmediate: execFeaturesImmediately,
  180. onChange: execFeatures
  181. });
  182.  
  183. // ---------------------------------------------------------------------------------------------------------------------
  184. // ---------------------------------------------------------------------------------------------------------------------
  185.  
  186. function loadSettings() {
  187. const defaultSettings = {
  188. hideTrends: true,
  189. hideSignUpSection: false,
  190. hideSignUpBottomBarAndMessages: false,
  191. doNotPlayVideosAutomatically: false,
  192. goFromMobileToMainSite: false,
  193.  
  194. highlightVisitedLinks: true,
  195. highlightOnlySpecialVisitedLinks: true,
  196. expandSpoilers: true,
  197.  
  198. directLinks: true,
  199. handleTitle: true,
  200.  
  201. imagesHandler: true,
  202. videoHandler: true,
  203. addRequiredCSS: true,
  204.  
  205. hideLoginPopup: false,
  206. addBorder: false,
  207.  
  208. downloadProgress: true,
  209. strictTrackingProtectionFix: false,
  210. };
  211.  
  212. let savedSettings;
  213. try {
  214. savedSettings = JSON.parse(localStorage.getItem(StorageNames.settings)) || {};
  215. } catch (err) {
  216. console.error("[ujs][parse-settings]", err);
  217. localStorage.removeItem(StorageNames.settings);
  218. savedSettings = {};
  219. }
  220. savedSettings = Object.assign(defaultSettings, savedSettings);
  221. return savedSettings;
  222. }
  223. function showSettings() {
  224. closeSetting();
  225. if (window.scrollY > 0) {
  226. document.querySelector("html").classList.add("ujs-scroll-initial");
  227. document.body.classList.add("ujs-scrollbar-width-margin-right");
  228. }
  229. document.body.classList.add("ujs-no-scroll");
  230.  
  231. const modalWrapperStyle = `
  232. width: 100%;
  233. height: 100%;
  234. position: fixed;
  235. display: flex;
  236. justify-content: center;
  237. align-items: center;
  238. z-index: 99999;
  239. backdrop-filter: blur(4px);
  240. background-color: rgba(255, 255, 255, 0.5);
  241. `;
  242. const modalSettingsStyle = `
  243. background-color: white;
  244. min-width: 320px;
  245. min-height: 320px;
  246. border: 1px solid darkgray;
  247. padding: 8px;
  248. box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
  249. `;
  250. const s = settings;
  251. const downloadProgressFFTitle = `Disable the download progress if you use Firefox with "Enhanced Tracking Protection" set to "Strict" and ViolentMonkey, or GreaseMonkey extension`;
  252. 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.`;
  253. document.body.insertAdjacentHTML("afterbegin", `
  254. <div class="ujs-modal-wrapper" style="${modalWrapperStyle}">
  255. <div class="ujs-modal-settings" style="${modalSettingsStyle}">
  256. <fieldset>
  257. <legend>Optional</legend>
  258. <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>
  259. <label title="WARNING: It may broke the login page, but it works fine if you logged in and want to hide 'Messages'"><input type="checkbox" ${s.hideSignUpBottomBarAndMessages ? "checked" : ""} name="hideSignUpBottomBarAndMessages">Hide <strike><b>Sign Up Bar</b> and</strike> <b>Messages</b> (in the bottom). <span title="WARNING: It may broke the login page!">(beta)</span><br/></label>
  260. <label><input type="checkbox" ${s.hideTrends ? "checked" : ""} name="hideTrends">Hide <b>Trends</b> (in the right column)*<br/></label>
  261. <label hidden><input type="checkbox" ${s.doNotPlayVideosAutomatically ? "checked" : ""} name="doNotPlayVideosAutomatically">Do <i>Not</i> Play Videos Automatically</b><br/></label>
  262. <label hidden><input type="checkbox" ${s.goFromMobileToMainSite ? "checked" : ""} name="goFromMobileToMainSite">Redirect from Mobile version (beta)<br/></label>
  263. </fieldset>
  264. <fieldset>
  265. <legend>Recommended</legend>
  266. <label><input type="checkbox" ${s.highlightVisitedLinks ? "checked" : ""} name="highlightVisitedLinks">Highlight Visited Links<br/></label>
  267. <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>
  268.  
  269. <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>
  270. </fieldset>
  271. <fieldset>
  272. <legend>Highly Recommended</legend>
  273. <label><input type="checkbox" ${s.directLinks ? "checked" : ""} name="directLinks">Direct Links</label><br/>
  274. <label><input type="checkbox" ${s.handleTitle ? "checked" : ""} name="handleTitle">Enchance Title*<br/></label>
  275. </fieldset>
  276. <fieldset ${isFirefox ? '': 'style="display: none"'}>
  277. <legend>Firefox only</legend>
  278. <label title='${downloadProgressFFTitle}'><input type="radio" ${s.downloadProgress ? "checked" : ""} name="firefoxDownloadProgress" value="downloadProgress">Download Progress<br/></label>
  279. <label title='${strictTrackingProtectionFixFFTitle}'><input type="radio" ${s.strictTrackingProtectionFix ? "checked" : ""} name="firefoxDownloadProgress" value="strictTrackingProtectionFix">Strict Tracking Protection Fix<br/></label>
  280. </fieldset>
  281. <fieldset>
  282. <legend>Main</legend>
  283. <label><input type="checkbox" ${s.imagesHandler ? "checked" : ""} name="imagesHandler">Image Download Button<br/></label>
  284. <label><input type="checkbox" ${s.videoHandler ? "checked" : ""} name="videoHandler">Video Download Button<br/></label>
  285. <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 -->
  286. </fieldset>
  287. <fieldset>
  288. <legend title="Outdated due to Twitter's updates, or impossible to reimplement">Outdated</legend>
  289. <strike>
  290.  
  291. <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>
  292. <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 recommended 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>
  293.  
  294. </strike>
  295. </fieldset>
  296. <hr>
  297. <div style="display: flex; justify-content: space-around;">
  298. <div>
  299. History:
  300. <button class="ujs-reload-export-button" style="padding: 5px" >Export</button>
  301. <button class="ujs-reload-import-button" style="padding: 5px" >Import</button>
  302. <button class="ujs-reload-merge-button" style="padding: 5px" >Merge</button>
  303. </div>
  304. <div>
  305. <button class="ujs-reload-setting-button" style="padding: 5px" title="Reload the web page to apply changes">Reload page</button>
  306. <button class="ujs-close-setting-button" style="padding: 5px" title="Just close this popup.\nNote: You need to reload the web page to apply changes.">Close popup</button>
  307. </div>
  308. </div>
  309. <hr>
  310. <h4 style="margin: 0; padding-left: 8px; color: #444;">Notes:</h4>
  311. <ul style="margin: 2px; padding-left: 16px; color: #444;">
  312. <li>Click on <b>Save Settings</b> and <b>reload the page</b> to apply changes.</li>
  313. <li><b>*</b>-marked settings are language dependent. Currently, the follow languages are supported:<br/> "en", "ru", "es", "zh", "ja".</li>
  314. <li hidden>The extension downloads only from twitter.com, not from <b>mobile</b>.twitter.com</li>
  315. </ul>
  316. </div>
  317. </div>`);
  318.  
  319. async function onDone(button) {
  320. button.classList.remove("ujs-btn-error");
  321. button.classList.add("ujs-btn-done");
  322. await sleep(900);
  323. button.classList.remove("ujs-btn-done");
  324. }
  325. async function onError(button, err) {
  326. button.classList.remove("ujs-btn-done");
  327. button.classList.add("ujs-btn-error");
  328. button.title = err.message;
  329. await sleep(1800);
  330. button.classList.remove("ujs-btn-error");
  331. }
  332.  
  333. const exportButton = document.querySelector("body > .ujs-modal-wrapper .ujs-reload-export-button");
  334. const importButton = document.querySelector("body > .ujs-modal-wrapper .ujs-reload-import-button");
  335. const mergeButton = document.querySelector("body > .ujs-modal-wrapper .ujs-reload-merge-button");
  336.  
  337. exportButton.addEventListener("click", (event) => {
  338. const button = event.currentTarget;
  339. historyHelper.exportHistory(() => onDone(button));
  340. });
  341. sleep(50).then(() => {
  342. const infoObj = getStoreInfo();
  343. exportButton.title = Object.entries(infoObj).reduce((acc, [key, value]) => {
  344. acc += `${key}: ${value}\n`;
  345. return acc;
  346. }, "");
  347. });
  348.  
  349. importButton.addEventListener("click", (event) => {
  350. const button = event.currentTarget;
  351. historyHelper.importHistory(
  352. () => onDone(button),
  353. (err) => onError(button, err)
  354. );
  355. });
  356. mergeButton.addEventListener("click", (event) => {
  357. const button = event.currentTarget;
  358. historyHelper.mergeHistory(
  359. () => onDone(button),
  360. (err) => onError(button, err)
  361. );
  362. });
  363.  
  364. document.querySelector("body > .ujs-modal-wrapper .ujs-reload-setting-button").addEventListener("click", () => {
  365. location.reload();
  366. });
  367.  
  368. const checkboxList = document.querySelectorAll("body > .ujs-modal-wrapper input[type=checkbox], body > .ujs-modal-wrapper input[type=radio]");
  369. checkboxList.forEach(checkbox => {
  370. checkbox.addEventListener("change", saveSetting);
  371. });
  372.  
  373. document.querySelector("body > .ujs-modal-wrapper .ujs-close-setting-button").addEventListener("click", closeSetting);
  374.  
  375. function saveSetting() {
  376. const entries = [...document.querySelectorAll("body > .ujs-modal-wrapper input[type=checkbox]")]
  377. .map(checkbox => [checkbox.name, checkbox.checked]);
  378. const radioEntries = [...document.querySelectorAll("body > .ujs-modal-wrapper input[type=radio]")]
  379. .map(checkbox => [checkbox.value, checkbox.checked])
  380. const settings = Object.fromEntries([entries, radioEntries].flat());
  381. // verbose && console.log("[ujs][save-settings]", settings);
  382. localStorage.setItem(StorageNames.settings, JSON.stringify(settings));
  383. }
  384.  
  385. function closeSetting() {
  386. document.body.classList.remove("ujs-no-scroll");
  387. document.body.classList.remove("ujs-scrollbar-width-margin-right");
  388. document.querySelector("html").classList.remove("ujs-scroll-initial");
  389. document.querySelector("body > .ujs-modal-wrapper")?.remove();
  390. }
  391.  
  392.  
  393. }
  394.  
  395. // ---------------------------------------------------------------------------------------------------------------------
  396. // ---------------------------------------------------------------------------------------------------------------------
  397. // --- Twitter Specific code --- //
  398.  
  399. const downloadedImages = new LS(StorageNames.downloadedImageNames);
  400. const downloadedImageTweetIds = new LS(StorageNames.downloadedImageTweetIds);
  401. const downloadedVideoTweetIds = new LS(StorageNames.downloadedVideoTweetIds);
  402.  
  403. // --- That to use for the image history --- //
  404. /** @type {"TWEET_ID" | "IMAGE_NAME"} */
  405. const imagesHistoryBy = LS.getItem(StorageNames.settingsImageHistoryBy, "IMAGE_NAME"); // Hidden settings
  406. // With "TWEET_ID" downloading of 1 image of 4 will mark all 4 images as "already downloaded"
  407. // on the next time when the tweet will appear.
  408. // "IMAGE_NAME" will count each image of a tweet, but it will take more data to store.
  409.  
  410.  
  411. // ---------------------------------------------------------------------------------------------------------------------
  412. // --- Twitter.Features --- //
  413. function hoistFeatures() {
  414. class Features {
  415. static createButton({url, downloaded, isVideo}) {
  416. const btn = document.createElement("div");
  417. btn.innerHTML = `
  418. <div class="ujs-btn-common ujs-btn-background"></div>
  419. <div class="ujs-btn-common ujs-hover"></div>
  420. <div class="ujs-btn-common ujs-shadow"></div>
  421. <div class="ujs-btn-common ujs-progress" style="--progress: 0%"></div>
  422. <div class="ujs-btn-common ujs-btn-error-text"></div>`.slice(1);
  423. btn.classList.add("ujs-btn-download");
  424. if (!downloaded) {
  425. btn.classList.add("ujs-not-downloaded");
  426. } else {
  427. btn.classList.add("ujs-already-downloaded");
  428. }
  429. if (isVideo) {
  430. btn.classList.add("ujs-video");
  431. }
  432. if (url) {
  433. btn.dataset.url = url;
  434. }
  435. return btn;
  436. }
  437.  
  438.  
  439. // Banner/Background
  440. static async _downloadBanner(url, btn) {
  441. const username = location.pathname.slice(1).split("/")[0];
  442.  
  443. btn.classList.add("ujs-downloading");
  444.  
  445. // https://pbs.twimg.com/profile_banners/34743251/1596331248/1500x500
  446. const {
  447. id, seconds, res
  448. } = url.match(/(?<=\/profile_banners\/)(?<id>\d+)\/(?<seconds>\d+)\/(?<res>\d+x\d+)/)?.groups || {};
  449.  
  450. const {blob, lastModifiedDate, extension, name} = await fetchResource(url);
  451.  
  452. Features.verifyBlob(blob, url, btn);
  453.  
  454. const filename = `[twitter][bg] ${username}—${lastModifiedDate}—${id}—${seconds}.${extension}`;
  455. downloadBlob(blob, filename, url);
  456.  
  457. btn.classList.remove("ujs-downloading");
  458. btn.classList.add("ujs-downloaded");
  459. }
  460.  
  461. static _ImageHistory = class {
  462. static getImageNameFromUrl(url) {
  463. const _url = new URL(url);
  464. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  465. return filename.match(/^[^.]+/)[0]; // remove extension
  466. }
  467. static isDownloaded({id, url}) {
  468. if (imagesHistoryBy === "TWEET_ID") {
  469. return downloadedImageTweetIds.hasItem(id);
  470. } else if (imagesHistoryBy === "IMAGE_NAME") {
  471. const name = Features._ImageHistory.getImageNameFromUrl(url);
  472. return downloadedImages.hasItem(name);
  473. }
  474. }
  475. static async markDownloaded({id, url}) {
  476. if (imagesHistoryBy === "TWEET_ID") {
  477. await downloadedImageTweetIds.pushItem(id);
  478. } else if (imagesHistoryBy === "IMAGE_NAME") {
  479. const name = Features._ImageHistory.getImageNameFromUrl(url);
  480. await downloadedImages.pushItem(name);
  481. }
  482. }
  483. }
  484. static async imagesHandler() {
  485. verbose && console.log("[ujs][imagesHandler]");
  486. const images = document.querySelectorAll(`img:not([data-handled]):not([src$=".svg"])`);
  487. for (const img of images) {
  488. if (img.dataset.handled) {
  489. continue;
  490. }
  491. img.dataset.handled = "true";
  492. if (img.width < 140) {
  493. continue;
  494. }
  495. verbose && console.log("[ujs][imagesHandler]", {img, img_width: img.width});
  496.  
  497. const isMobileVideo = img.src.includes("ext_tw_video_thumb") || img.closest(`a[aria-label="Embedded video"]`) || img.alt === "Animated Text GIF" || img.alt === "Embedded video";
  498. if (isMobileVideo) {
  499. await Features.mobileVideoHandler(img);
  500. continue;
  501. }
  502.  
  503. const btn = Features.createButton({url: img.src});
  504. btn.addEventListener("click", Features._imageClickHandler);
  505.  
  506. let anchor = img.closest("a");
  507. // if an image is _opened_ "https://twitter.com/UserName/status/1234567890123456789/photo/1" [fake-url]
  508. if (!anchor) {
  509. anchor = img.parentNode;
  510. }
  511. anchor.append(btn);
  512.  
  513. const downloaded = Features._ImageHistory.isDownloaded({
  514. id: Tweet.of(btn).id,
  515. url: btn.dataset.url
  516. });
  517. if (downloaded) {
  518. btn.classList.add("ujs-already-downloaded");
  519. }
  520. }
  521. }
  522. static async _imageClickHandler(event) {
  523. event.preventDefault();
  524. event.stopImmediatePropagation();
  525.  
  526. const btn = event.currentTarget;
  527. const btnErrorTextElem = btn.querySelector(".ujs-btn-error-text");
  528. let url = btn.dataset.url;
  529.  
  530. const isBanner = url.includes("/profile_banners/");
  531. if (isBanner) {
  532. return Features._downloadBanner(url, btn);
  533. }
  534.  
  535. const {id, author} = Tweet.of(btn);
  536. verbose && console.log("[ujs][_imageClickHandler]", {id, author});
  537.  
  538. const btnProgress = btn.querySelector(".ujs-progress");
  539. if (btn.textContent !== "") {
  540. btnErrorTextElem.textContent = "";
  541. }
  542. btn.classList.remove("ujs-error");
  543. btn.classList.add("ujs-downloading");
  544.  
  545. let onProgress = null;
  546. if (settings.downloadProgress) {
  547. onProgress = ({loaded, total}) => btnProgress.style.cssText = "--progress: " + loaded / total * 90 + "%";
  548. }
  549.  
  550. const originals = ["orig", "4096x4096"];
  551. const samples = ["large", "medium", "900x900", "small", "360x360", /*"240x240", "120x120", "tiny"*/];
  552. let isSample = false;
  553. const previewSize = new URL(url).searchParams.get("name");
  554. if (!samples.includes(previewSize)) {
  555. samples.push(previewSize);
  556. }
  557.  
  558. function handleImgUrl(url) {
  559. const urlObj = new URL(url);
  560. if (originals.length) {
  561. urlObj.searchParams.set("name", originals.shift());
  562. } else if (samples.length) {
  563. isSample = true;
  564. urlObj.searchParams.set("name", samples.shift());
  565. } else {
  566. throw new Error("All fallback URLs are failed to download.");
  567. }
  568. if (urlObj.searchParams.get("format") === "webp") {
  569. urlObj.searchParams.set("format", "jpg");
  570. }
  571. url = urlObj.toString();
  572. verbose && console.log("[ujs][handleImgUrl][url]", url);
  573. return url;
  574. }
  575.  
  576. async function safeFetchResource(url) {
  577. while (true) {
  578. url = handleImgUrl(url);
  579. try {
  580. const result = await fetchResource(url, onProgress);
  581. if (result.status === 404) {
  582. const urlObj = new URL(url);
  583. const params = urlObj.searchParams;
  584. if (params.get("name") === "orig" && params.get("format") === "jpg") {
  585. params.set("format", "png");
  586. url = urlObj.toString();
  587. return await fetchResource(url, onProgress);
  588. }
  589. }
  590. return result;
  591. } catch (err) {
  592. if (!originals.length) {
  593. btn.classList.add("ujs-error");
  594. btnErrorTextElem.textContent = "";
  595. // Add ⚠
  596. 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;`;
  597. btn.title = "[warning] Original images are not available.";
  598. }
  599.  
  600. const ffAutoAllocateChunkSizeBug = err.message.includes("autoAllocateChunkSize"); // https://bugzilla.mozilla.org/show_bug.cgi?id=1757836
  601. if (!samples.length || ffAutoAllocateChunkSizeBug) {
  602. btn.classList.add("ujs-error");
  603. btnErrorTextElem.textContent = "";
  604. // Add ❌
  605. 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;`;
  606.  
  607. const ffHint = isFirefox && !settings.strictTrackingProtectionFix && ffAutoAllocateChunkSizeBug ? "\nTry to enable 'Strict Tracking Protection Fix' in the userscript settings." : "";
  608. btn.title = "Failed to download the image." + ffHint;
  609. throw new Error("[error] Fallback URLs are failed.");
  610. }
  611. }
  612. }
  613. }
  614.  
  615. const {blob, lastModifiedDate, extension, name} = await safeFetchResource(url);
  616.  
  617. Features.verifyBlob(blob, url, btn);
  618.  
  619. btnProgress.style.cssText = "--progress: 100%";
  620.  
  621. const sampleText = !isSample ? "" : "[sample]";
  622. const filename = `[twitter]${sampleText} ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
  623. downloadBlob(blob, filename, url);
  624.  
  625. const downloaded = btn.classList.contains("ujs-already-downloaded") || btn.classList.contains("ujs-downloaded");
  626. if (!downloaded && !isSample) {
  627. await Features._ImageHistory.markDownloaded({id, url});
  628. }
  629. btn.classList.remove("ujs-downloading");
  630. btn.classList.add("ujs-downloaded");
  631.  
  632. await sleep(40);
  633. btnProgress.style.cssText = "--progress: 0%";
  634. }
  635.  
  636. // Quick Dirty Fix // todo refactor
  637. static async mobileVideoHandler(imgElem) {
  638. verbose && console.log("[ujs][mobileVideoHandler][vid]", imgElem);
  639.  
  640. const btn = Features.createButton({isVideo: true, url: imgElem.src});
  641. btn.addEventListener("click", Features._videoClickHandler);
  642.  
  643. let anchor = imgElem.closest("a");
  644. if (!anchor) {
  645. anchor = imgElem.parentNode;
  646. }
  647. anchor.append(btn);
  648.  
  649. const tweet = Tweet.of(btn);
  650. const id = tweet.id;
  651. const tweetElem = tweet.elem || btn.closest(`[data-testid="tweet"]`);
  652. let vidNumber = 0;
  653.  
  654. const map = Features.tweetVidWeakMapMobile;
  655. if (map.has(tweetElem)) {
  656. vidNumber = map.get(tweetElem) + 1;
  657. map.set(tweetElem, vidNumber);
  658. } else {
  659. map.set(tweetElem, vidNumber); // can throw an error for null
  660. }
  661. const historyId = vidNumber ? id + "-" + vidNumber : id;
  662.  
  663. const downloaded = downloadedVideoTweetIds.hasItem(historyId);
  664. if (downloaded) {
  665. btn.classList.add("ujs-already-downloaded");
  666. }
  667. }
  668.  
  669. static tweetVidWeakMapMobile = new WeakMap();
  670. static tweetVidWeakMap = new WeakMap();
  671. static async videoHandler() {
  672. const videos = document.querySelectorAll("video:not([data-handled])");
  673. for (const vid of videos) {
  674. if (vid.dataset.handled) {
  675. continue;
  676. }
  677. vid.dataset.handled = "true";
  678. verbose && console.log("[ujs][videoHandler][vid]", vid);
  679.  
  680. const poster = vid.getAttribute("poster");
  681.  
  682. const btn = Features.createButton({isVideo: true, url: poster});
  683. btn.addEventListener("click", Features._videoClickHandler);
  684.  
  685. let elem = vid.parentNode.parentNode.parentNode;
  686. elem.after(btn);
  687.  
  688. const tweet = Tweet.of(btn);
  689. const id = tweet.id;
  690. const tweetElem = tweet.elem;
  691. let vidNumber = 0;
  692.  
  693. const map = Features.tweetVidWeakMap;
  694. if (map.has(tweetElem)) {
  695. vidNumber = map.get(tweetElem) + 1;
  696. map.set(tweetElem, vidNumber);
  697. } else {
  698. map.set(tweetElem, vidNumber);
  699. }
  700.  
  701. const historyId = vidNumber ? id + "-" + vidNumber : id;
  702.  
  703. const downloaded = downloadedVideoTweetIds.hasItem(historyId);
  704. if (downloaded) {
  705. btn.classList.add("ujs-already-downloaded");
  706. }
  707. }
  708. }
  709. static async _videoClickHandler(event) {
  710. event.preventDefault();
  711. event.stopImmediatePropagation();
  712.  
  713. const btn = event.currentTarget;
  714. const btnErrorTextElem = btn.querySelector(".ujs-btn-error-text");
  715. let {id, author} = Tweet.of(btn);
  716.  
  717. if (btn.textContent !== "") {
  718. btnErrorTextElem.textContent = "";
  719. }
  720. btn.classList.remove("ujs-error");
  721. btn.classList.add("ujs-downloading");
  722.  
  723. const posterUrl = btn.dataset.url;
  724.  
  725. let video; // {bitrate, content_type, url}
  726. let vidNumber = 0;
  727. let videoTweetId = id;
  728. try {
  729. ({video, tweetId: videoTweetId, screenName: author, vidNumber} = await API.getVideoInfo(id, author, posterUrl));
  730. verbose && console.log("[ujs][videoHandler][video]", video);
  731. } catch (err) {
  732. btn.classList.add("ujs-error");
  733. btnErrorTextElem.textContent = "Error";
  734. btn.title = "API.getVideoInfo Error";
  735. throw new Error("API.getVideoInfo Error");
  736. }
  737.  
  738. const btnProgress = btn.querySelector(".ujs-progress");
  739.  
  740. const url = video.url;
  741. let onProgress = null;
  742. if (settings.downloadProgress) {
  743. onProgress = ({loaded, total}) => btnProgress.style.cssText = "--progress: " + loaded / total * 90 + "%";
  744. }
  745.  
  746. const {blob, lastModifiedDate, extension, name} = await fetchResource(url, onProgress);
  747.  
  748. btnProgress.style.cssText = "--progress: 100%";
  749.  
  750. Features.verifyBlob(blob, url, btn);
  751.  
  752. const filename = `[twitter] ${author}—${lastModifiedDate}—${videoTweetId}—${name}.${extension}`;
  753. downloadBlob(blob, filename, url);
  754.  
  755. const downloaded = btn.classList.contains("ujs-already-downloaded");
  756. const historyId = vidNumber ? videoTweetId + "-" + vidNumber : videoTweetId;
  757. if (!downloaded) {
  758. await downloadedVideoTweetIds.pushItem(historyId);
  759. if (videoTweetId !== id) { // if QRT
  760. const historyId = vidNumber ? id + "-" + vidNumber : id;
  761. await downloadedVideoTweetIds.pushItem(historyId);
  762. }
  763. }
  764. btn.classList.remove("ujs-downloading");
  765. btn.classList.add("ujs-downloaded");
  766.  
  767. await sleep(40);
  768. btnProgress.style.cssText = "--progress: 0%";
  769. }
  770.  
  771. static verifyBlob(blob, url, btn) {
  772. if (!blob.size) {
  773. btn.classList.add("ujs-error");
  774. btn.querySelector(".ujs-btn-error-text").textContent = "Error";
  775. btn.title = "Download Error";
  776. throw new Error("Zero size blob: " + url);
  777. }
  778. }
  779.  
  780. static addRequiredCSS() {
  781. const code = getUserScriptCSS();
  782. addCSS(code);
  783. }
  784.  
  785. // it depends on `directLinks()` use only it after `directLinks()`
  786. static handleTitle(title) {
  787.  
  788. if (!I18N.QUOTES) { // Unsupported lang, no QUOTES, ON_TWITTER, TWITTER constants
  789. return;
  790. }
  791.  
  792. // if not an opened tweet
  793. if (!location.href.match(/twitter\.com\/[^\/]+\/status\/\d+/)) {
  794. return;
  795. }
  796.  
  797. let titleText = title || document.title;
  798. if (titleText === Features.lastHandledTitle) {
  799. return;
  800. }
  801. Features.originalTitle = titleText;
  802.  
  803. const [OPEN_QUOTE, CLOSE_QUOTE] = I18N.QUOTES;
  804. const urlsToReplace = [
  805. ...titleText.matchAll(new RegExp(`https:\\/\\/t\\.co\\/[^ ${CLOSE_QUOTE}]+`, "g"))
  806. ].map(el => el[0]);
  807. // the last one may be the URL to the tweet // or to an embedded shared URL
  808.  
  809. const map = new Map();
  810. const anchors = document.querySelectorAll(`a[data-redirect^="https://t.co/"]`);
  811. for (const anchor of anchors) {
  812. if (urlsToReplace.includes(anchor.dataset.redirect)) {
  813. map.set(anchor.dataset.redirect, anchor.href);
  814. }
  815. }
  816.  
  817. const lastUrl = urlsToReplace.slice(-1)[0];
  818. let lastUrlIsAttachment = false;
  819. let attachmentDescription = "";
  820. if (!map.has(lastUrl)) {
  821. const a = document.querySelector(`a[href="${lastUrl}?amp=1"]`);
  822. if (a) {
  823. lastUrlIsAttachment = true;
  824. attachmentDescription = document.querySelectorAll(`a[href="${lastUrl}?amp=1"]`)[1].innerText;
  825. attachmentDescription = attachmentDescription.replaceAll("\n", " — ");
  826. }
  827. }
  828.  
  829. for (const [key, value] of map.entries()) {
  830. titleText = titleText.replaceAll(key, value + ` (${key})`);
  831. }
  832.  
  833. titleText = titleText.replace(new RegExp(`${I18N.ON_TWITTER}(?= ${OPEN_QUOTE})`), ":");
  834. titleText = titleText.replace(new RegExp(`(?<=${CLOSE_QUOTE}) \\\/ ${I18N.TWITTER}$`), "");
  835. if (!lastUrlIsAttachment) {
  836. const regExp = new RegExp(`(?<short> https:\\/\\/t\\.co\\/.{6,14})${CLOSE_QUOTE}$`);
  837. titleText = titleText.replace(regExp, (match, p1, p2, offset, string) => `${CLOSE_QUOTE} ${p1}`);
  838. } else {
  839. titleText = titleText.replace(lastUrl, `${lastUrl} (${attachmentDescription})`);
  840. }
  841. document.title = titleText; // Note: some characters will be removed automatically (`\n`, extra spaces)
  842. Features.lastHandledTitle = document.title;
  843. }
  844. static lastHandledTitle = "";
  845. static originalTitle = "";
  846.  
  847. static profileUrlCache = new Map();
  848. static async directLinks() {
  849. verbose && console.log("[ujs][directLinks]");
  850. const hasHttp = url => Boolean(url.match(/^https?:\/\//));
  851. const anchors = xpathAll(`.//a[@dir="ltr" and child::span and not(@data-handled)]`);
  852. for (const anchor of anchors) {
  853. const redirectUrl = new URL(anchor.href);
  854. const shortUrl = redirectUrl.origin + redirectUrl.pathname; // remove "?amp=1"
  855.  
  856. const hrefAttr = anchor.getAttribute("href");
  857. if (hrefAttr.startsWith("/")) {
  858. anchor.dataset.handled = "true";
  859. return;
  860. }
  861.  
  862. verbose && console.log("[ujs][directLinks]", {hrefAttr, redirectUrl_href: redirectUrl.href, shortUrl});
  863.  
  864. anchor.dataset.redirect = shortUrl;
  865. anchor.dataset.handled = "true";
  866. anchor.rel = "nofollow noopener noreferrer";
  867.  
  868. if (Features.profileUrlCache.has(shortUrl)) {
  869. anchor.href = Features.profileUrlCache.get(shortUrl);
  870. continue;
  871. }
  872.  
  873. const nodes = xpathAll(`./span[text() != "…"]|./text()`, anchor);
  874. let url = nodes.map(node => node.textContent).join("");
  875.  
  876. const doubleProtocolPrefix = url.match(/(?<dup>^https?:\/\/)(?=https?:)/)?.groups.dup;
  877. if (doubleProtocolPrefix) {
  878. url = url.slice(doubleProtocolPrefix.length);
  879. const span = anchor.querySelector(`[aria-hidden="true"]`);
  880. if (hasHttp(span.textContent)) { // Fix Twitter's bug related to text copying
  881. span.style = "display: none;";
  882. }
  883. }
  884.  
  885. anchor.href = url;
  886.  
  887. if (anchor.dataset?.testid === "UserUrl") {
  888. const href = anchor.getAttribute("href");
  889. const profileUrl = hasHttp(href) ? href : "https://" + href;
  890. anchor.href = profileUrl;
  891. verbose && console.log("[ujs][directLinks][profileUrl]", profileUrl);
  892.  
  893. // Restore if URL's text content is too long
  894. if (anchor.textContent.endsWith("…")) {
  895. anchor.href = shortUrl;
  896.  
  897. try {
  898. const author = location.pathname.slice(1).match(/[^\/]+/)[0];
  899. const expanded_url = await API.getUserInfo(author); // todo: make lazy
  900. anchor.href = expanded_url;
  901. Features.profileUrlCache.set(shortUrl, expanded_url);
  902. } catch (err) {
  903. verbose && console.error("[ujs]", err);
  904. }
  905. }
  906. }
  907. }
  908. if (anchors.length) {
  909. Features.handleTitle(Features.originalTitle);
  910. }
  911. }
  912.  
  913. // Do NOT throttle it
  914. static expandSpoilers() {
  915. const main = document.querySelector("main[role=main]");
  916. if (!main) {
  917. return;
  918. }
  919.  
  920. if (!I18N.YES_VIEW_PROFILE) { // Unsupported lang, no YES_VIEW_PROFILE, SHOW_NUDITY, VIEW constants
  921. return;
  922. }
  923.  
  924. const a = main.querySelectorAll("[data-testid=primaryColumn] [role=button]");
  925. if (a) {
  926. const elems = [...a];
  927. const button = elems.find(el => el.textContent === I18N.YES_VIEW_PROFILE);
  928. if (button) {
  929. button.click();
  930. }
  931.  
  932. // "Content warning: Nudity"
  933. // "The Tweet author flagged this Tweet as showing sensitive content."
  934. // "Show"
  935. const buttonShow = elems.find(el => el.textContent === I18N.SHOW_NUDITY);
  936. if (buttonShow) {
  937. // const verifying = a.previousSibling.textContent.includes("Nudity"); // todo?
  938. // if (verifying) {
  939. buttonShow.click();
  940. // }
  941. }
  942. }
  943.  
  944. // todo: expand spoiler commentary in photo view mode (.../photo/1)
  945. const b = main.querySelectorAll("article [role=presentation] div[role=button]");
  946. if (b) {
  947. const elems = [...b];
  948. const buttons = elems.filter(el => el.textContent === I18N.VIEW);
  949. if (buttons.length) {
  950. buttons.forEach(el => el.click());
  951. }
  952. }
  953. }
  954.  
  955. static hideSignUpSection() { // "New to Twitter?"
  956. if (!I18N.SIGNUP) {// Unsupported lang, no SIGNUP constant
  957. return;
  958. }
  959. const elem = document.querySelector(`section[aria-label="${I18N.SIGNUP}"][role=region]`);
  960. if (elem) {
  961. elem.parentNode.classList.add("ujs-hidden");
  962. }
  963. }
  964.  
  965. // Call it once.
  966. // "Don’t miss what’s happening" if you are not logged in.
  967. // It looks that `#layers` is used only for this bar.
  968. static hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically) {
  969. if (doNotPlayVideosAutomatically) {
  970. addCSS(`
  971. #layers > div:nth-child(1) {
  972. display: none;
  973. }
  974. `);
  975. } else {
  976. addCSS(`
  977. #layers > div:nth-child(1) {
  978. height: 1px;
  979. opacity: 0;
  980. }
  981. `);
  982. }
  983. }
  984.  
  985. // "Trends for you"
  986. static hideTrends() {
  987. if (!I18N.TRENDS) { // Unsupported lang, no TRENDS constant
  988. return;
  989. }
  990. addCSS(`
  991. [aria-label="${I18N.TRENDS}"]
  992. {
  993. display: none;
  994. }
  995. `);
  996. }
  997.  
  998. static highlightVisitedLinks() {
  999. if (settings.highlightOnlySpecialVisitedLinks) {
  1000. addCSS(`
  1001. a[href^="http"]:visited {
  1002. color: darkorange;
  1003. }
  1004. `);
  1005. return;
  1006. }
  1007. addCSS(`
  1008. a:visited {
  1009. color: darkorange;
  1010. }
  1011. `);
  1012. }
  1013.  
  1014. // todo split to two methods
  1015. // todo fix it, currently it works questionably
  1016. // not tested with non eng languages
  1017. static footerHandled = false;
  1018. static hideAndMoveFooter() { // "Terms of Service Privacy Policy Cookie Policy"
  1019. let footer = document.querySelector(`main[role=main] nav[aria-label=${I18N.FOOTER}][role=navigation]`);
  1020. const nav = document.querySelector("nav[aria-label=Primary][role=navigation]"); // I18N."Primary" [?]
  1021.  
  1022. if (footer) {
  1023. footer = footer.parentNode;
  1024. const separatorLine = footer.previousSibling;
  1025.  
  1026. if (Features.footerHandled) {
  1027. footer.remove();
  1028. separatorLine.remove();
  1029. return;
  1030. }
  1031.  
  1032. nav.append(separatorLine);
  1033. nav.append(footer);
  1034. footer.classList.add("ujs-show-on-hover");
  1035. separatorLine.classList.add("ujs-show-on-hover");
  1036.  
  1037. Features.footerHandled = true;
  1038. }
  1039. }
  1040.  
  1041. static hideLoginPopup() { // When you are not logged in
  1042. const targetNode = document.querySelector("html");
  1043. const observerOptions = {
  1044. attributes: true,
  1045. };
  1046. const observer = new MutationObserver(callback);
  1047. observer.observe(targetNode, observerOptions);
  1048.  
  1049. function callback(mutationList, _observer) {
  1050. const html = document.querySelector("html");
  1051. verbose && console.log("[ujs][hideLoginPopup][mutationList]", mutationList);
  1052. // overflow-y: scroll; overscroll-behavior-y: none; font-size: 15px; // default
  1053. // overflow: hidden; overscroll-behavior-y: none; font-size: 15px; margin-right: 15px; // popup
  1054. if (html.style["overflow"] === "hidden") {
  1055. html.style["overflow"] = "";
  1056. html.style["overflow-y"] = "scroll";
  1057. html.style["margin-right"] = "";
  1058. }
  1059. const popup = document.querySelector(`#layers div[data-testid="sheetDialog"]`);
  1060. if (popup) {
  1061. popup.closest(`div[role="dialog"]`).remove();
  1062. verbose && (document.title = "⚒" + document.title);
  1063. // observer.disconnect();
  1064. }
  1065. }
  1066. }
  1067.  
  1068. static goFromMobileToMainSite() { // uncompleted
  1069. if (location.href.startsWith("https://mobile.twitter.com/")) {
  1070. location.href = location.href.replace("https://mobile.twitter.com/", "https://twitter.com/");
  1071. }
  1072. // TODO: add #redirected, remove by timer // to prevent a potential infinity loop
  1073. }
  1074. }
  1075.  
  1076. return Features;
  1077. }
  1078.  
  1079. function getStoreInfo() {
  1080. const resultObj = {
  1081. total: 0
  1082. };
  1083. for (const [name, lsKey] of Object.entries(StorageNames)) {
  1084. const valueStr = localStorage.getItem(lsKey);
  1085. if (valueStr) {
  1086. try {
  1087. const value = JSON.parse(valueStr);
  1088. if (Array.isArray(value)) {
  1089. const size = new Set(value).size;
  1090. resultObj[name] = size;
  1091. resultObj.total += size;
  1092. }
  1093. } catch (err) {
  1094. // ...
  1095. }
  1096. }
  1097. }
  1098. return resultObj;
  1099. }
  1100.  
  1101. // --- Twitter.RequiredCSS --- //
  1102. function getUserScriptCSS() {
  1103. const labelText = I18N.IMAGE || "Image";
  1104.  
  1105. // By default, the scroll is showed all time, since <html style="overflow-y: scroll;>,
  1106. // so it works — no need to use `getScrollbarWidth` function from SO (13382516).
  1107. const scrollbarWidth = window.innerWidth - document.body.offsetWidth;
  1108.  
  1109. const css = `
  1110. .ujs-hidden {
  1111. display: none;
  1112. }
  1113. .ujs-no-scroll {
  1114. overflow-y: hidden;
  1115. }
  1116. .ujs-scroll-initial {
  1117. overflow-y: initial!important;
  1118. }
  1119. .ujs-scrollbar-width-margin-right {
  1120. margin-right: ${scrollbarWidth}px;
  1121. }
  1122.  
  1123. .ujs-show-on-hover:hover {
  1124. opacity: 1;
  1125. transition: opacity 1s ease-out 0.1s;
  1126. }
  1127. .ujs-show-on-hover {
  1128. opacity: 0;
  1129. transition: opacity 0.5s ease-out;
  1130. }
  1131.  
  1132. :root {
  1133. --ujs-shadow-1: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  1134. --ujs-shadow-2: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  1135. --ujs-shadow-3: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  1136. --ujs-shadow-4: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  1137. --ujs-red: #e0245e;
  1138. --ujs-blue: #1da1f2;
  1139. --ujs-green: #4caf50;
  1140. --ujs-gray: #c2cbd0;
  1141. --ujs-error: white;
  1142. }
  1143.  
  1144. .ujs-progress {
  1145. background-image: linear-gradient(to right, var(--ujs-green) var(--progress), transparent 0%);
  1146. }
  1147.  
  1148. .ujs-shadow {
  1149. background-image: var(--ujs-shadow-1);
  1150. }
  1151. .ujs-btn-download:hover .ujs-hover {
  1152. background-image: var(--ujs-shadow-2);
  1153. }
  1154. .ujs-btn-download.ujs-downloading .ujs-shadow {
  1155. background-image: var(--ujs-shadow-3);
  1156. }
  1157. .ujs-btn-download:active .ujs-shadow {
  1158. background-image: var(--ujs-shadow-4);
  1159. }
  1160.  
  1161. article[role=article]:hover .ujs-btn-download {
  1162. opacity: 1;
  1163. }
  1164. div[aria-label="${labelText}"]:hover .ujs-btn-download {
  1165. opacity: 1;
  1166. }
  1167. .ujs-btn-download.ujs-downloaded {
  1168. opacity: 1;
  1169. }
  1170. .ujs-btn-download.ujs-downloading {
  1171. opacity: 1;
  1172. }
  1173.  
  1174. .ujs-btn-download {
  1175. cursor: pointer;
  1176. top: 0.5em;
  1177. left: 0.5em;
  1178. position: absolute;
  1179. opacity: 0;
  1180. }
  1181. .ujs-btn-common {
  1182. width: 33px;
  1183. height: 33px;
  1184. border-radius: 0.3em;
  1185. top: 0;
  1186. position: absolute;
  1187. border: 1px solid transparent;
  1188. border-color: var(--ujs-gray);
  1189. ${settings.addBorder ? "border: 2px solid white;" : "border-color: var(--ujs-gray);"}
  1190. }
  1191. .ujs-not-downloaded .ujs-btn-background {
  1192. background: var(--ujs-red);
  1193. }
  1194.  
  1195. .ujs-already-downloaded .ujs-btn-background {
  1196. background: var(--ujs-blue);
  1197. }
  1198.  
  1199. .ujs-btn-done {
  1200. box-shadow: 0 0 6px var(--ujs-green);
  1201. }
  1202. .ujs-btn-error {
  1203. box-shadow: 0 0 6px var(--ujs-red);
  1204. }
  1205.  
  1206. .ujs-downloaded .ujs-btn-background {
  1207. background: var(--ujs-green);
  1208. }
  1209.  
  1210. .ujs-error .ujs-btn-background {
  1211. background: var(--ujs-error);
  1212. }
  1213.  
  1214. .ujs-btn-error-text {
  1215. display: flex;
  1216. align-items: center;
  1217. justify-content: center;
  1218. color: black;
  1219. font-size: 100%;
  1220. }`;
  1221. return css.slice(1);
  1222. }
  1223.  
  1224. /*
  1225. Features depend on:
  1226.  
  1227. addRequiredCSS: IMAGE
  1228.  
  1229. expandSpoilers: YES_VIEW_PROFILE, SHOW_NUDITY, VIEW
  1230. handleTitle: QUOTES, ON_TWITTER, TWITTER
  1231. hideSignUpSection: SIGNUP
  1232. hideTrends: TRENDS
  1233.  
  1234. [unused]
  1235. hideAndMoveFooter: FOOTER
  1236. */
  1237.  
  1238. // --- Twitter.LangConstants --- //
  1239. function getLanguageConstants() { // todo: "de", "fr"
  1240. const defaultQuotes = [`"`, `"`];
  1241.  
  1242. const SUPPORTED_LANGUAGES = ["en", "ru", "es", "zh", "ja", ];
  1243.  
  1244. // texts
  1245. const VIEW = ["View", "Посмотреть", "Ver", "查看", "表示", ];
  1246. const YES_VIEW_PROFILE = ["Yes, view profile", "Да, посмотреть профиль", "Sí, ver perfil", "是,查看个人资料", "プロフィールを表示する", ];
  1247. const SHOW_NUDITY = ["Show", "Показать", "Mostrar", "显示", "表示", ];
  1248.  
  1249. // aria-label texts
  1250. const IMAGE = ["Image", "Изображение", "Imagen", "图像", "画像", ];
  1251. const SIGNUP = ["Sign up", "Зарегистрироваться", "Regístrate", "注册", "アカウント作成", ];
  1252. const TRENDS = ["Timeline: Trending now", "Лента: Актуальные темы", "Cronología: Tendencias del momento", "时间线:当前趋势", "タイムライン: トレンド", ];
  1253. const FOOTER = ["Footer", "Нижний колонтитул", "Pie de página", "页脚", "フッター", ];
  1254.  
  1255. // document.title "{AUTHOR}{ON_TWITTER} {QUOTES[0]}{TEXT}{QUOTES[1]} / {TWITTER}"
  1256. const QUOTES = [defaultQuotes, [`«`, `»`], defaultQuotes, defaultQuotes, [`「`, `」`], ];
  1257. const ON_TWITTER = [" on Twitter:", " в Твиттере:", " en Twitter:", " 在 Twitter:", "さんはTwitterを使っています", ];
  1258. const TWITTER = ["Twitter", "Твиттер", "Twitter", "Twitter", "Twitter", ];
  1259.  
  1260. const lang = document.querySelector("html").getAttribute("lang");
  1261. const langIndex = SUPPORTED_LANGUAGES.indexOf(lang);
  1262.  
  1263. return {
  1264. SUPPORTED_LANGUAGES,
  1265. VIEW: VIEW[langIndex],
  1266. YES_VIEW_PROFILE: YES_VIEW_PROFILE[langIndex],
  1267. SHOW_NUDITY: SHOW_NUDITY[langIndex],
  1268. IMAGE: IMAGE[langIndex],
  1269. SIGNUP: SIGNUP[langIndex],
  1270. TRENDS: TRENDS[langIndex],
  1271. FOOTER: FOOTER[langIndex],
  1272. QUOTES: QUOTES[langIndex],
  1273. ON_TWITTER: ON_TWITTER[langIndex],
  1274. TWITTER: TWITTER[langIndex],
  1275. }
  1276. }
  1277.  
  1278. // --- Twitter.Tweet --- //
  1279. function hoistTweet() {
  1280. class Tweet {
  1281. constructor({elem, url}) {
  1282. if (url) {
  1283. this.elem = null;
  1284. this.url = url;
  1285. } else {
  1286. this.elem = elem;
  1287. this.url = Tweet.getUrl(elem);
  1288. }
  1289. }
  1290.  
  1291. static of(innerElem) {
  1292. // Workaround for media from a quoted tweet
  1293. const url = innerElem.closest(`a[href^="/"]`)?.href;
  1294. if (url && url.includes("/status/")) {
  1295. return new Tweet({url});
  1296. }
  1297.  
  1298. const elem = innerElem.closest(`[data-testid="tweet"]`);
  1299. if (!elem) { // opened image
  1300. verbose && console.log("[ujs][Tweet.of]", "No-tweet elem");
  1301. }
  1302. return new Tweet({elem});
  1303. }
  1304.  
  1305. static getUrl(elem) {
  1306. if (!elem) {
  1307. verbose && console.log("[ujs][Tweet.getUrl]", "Opened full screen image");
  1308. return location.href;
  1309. }
  1310. const quotedTweetAnchorEl = [...elem.querySelectorAll("a")].find(el => {
  1311. return el.childNodes[0]?.nodeName === "TIME";
  1312. });
  1313. if (quotedTweetAnchorEl) {
  1314. verbose && console.log("[ujs][Tweet.getUrl]", "Quoted/Re Tweet");
  1315. return quotedTweetAnchorEl.href;
  1316. }
  1317. verbose && console.log("[ujs][Tweet.getUrl]", "Unreachable"); // Is it used?
  1318. return location.href;
  1319. }
  1320.  
  1321. get author() {
  1322. return this.url.match(/(?<=twitter\.com\/).+?(?=\/)/)?.[0];
  1323. }
  1324.  
  1325. get id() {
  1326. return this.url.match(/(?<=\/status\/)\d+/)?.[0];
  1327. }
  1328. }
  1329.  
  1330. return Tweet;
  1331. }
  1332.  
  1333. // --- Twitter.API --- //
  1334. function hoistAPI() {
  1335. class API {
  1336. static guestToken = getCookie("gt");
  1337. static csrfToken = getCookie("ct0"); // todo: lazy — not available at the first run
  1338. // Guest/Suspended account Bearer token
  1339. static guestAuthorization = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
  1340.  
  1341. // Seems to be outdated at 2022.05
  1342. static async _requestBearerToken() {
  1343. const scriptSrc = [...document.querySelectorAll("script")]
  1344. .find(el => el.src.match(/https:\/\/abs\.twimg\.com\/responsive-web\/client-web\/main[\w.]*\.js/)).src;
  1345.  
  1346. let text;
  1347. try {
  1348. text = await (await fetch(scriptSrc)).text();
  1349. } catch (err) {
  1350. /* verbose && */ console.error("[ujs][_requestBearerToken][scriptSrc]", scriptSrc);
  1351. /* verbose && */ console.error("[ujs][_requestBearerToken]", err);
  1352. throw err;
  1353. }
  1354.  
  1355. const authorizationKey = text.match(/(?<=")AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D.+?(?=")/)[0];
  1356. const authorization = `Bearer ${authorizationKey}`;
  1357.  
  1358. return authorization;
  1359. }
  1360.  
  1361. static async getAuthorization() {
  1362. if (!API.authorization) {
  1363. API.authorization = await API._requestBearerToken();
  1364. }
  1365. return API.authorization;
  1366. }
  1367.  
  1368. static requestCache = new Map();
  1369. static vacuumCache() {
  1370. if (API.requestCache.size > 16) {
  1371. API.requestCache.delete(API.requestCache.keys().next().value);
  1372. }
  1373. }
  1374.  
  1375. static async apiRequest(url) {
  1376. const _url = url.toString();
  1377. verbose && console.log("[ujs][apiRequest]", _url);
  1378.  
  1379. if (API.requestCache.has(_url)) {
  1380. verbose && console.log("[ujs][apiRequest] Use cached API request", _url);
  1381. return API.requestCache.get(_url);
  1382. }
  1383.  
  1384. // Hm... it is always the same. Even for a logged user.
  1385. // const authorization = API.guestToken ? API.guestAuthorization : await API.getAuthorization();
  1386. const authorization = API.guestAuthorization;
  1387.  
  1388. // for debug
  1389. verbose && sessionStorage.setItem("guestAuthorization", API.guestAuthorization);
  1390. verbose && sessionStorage.setItem("authorization", API.authorization);
  1391. verbose && sessionStorage.setItem("x-csrf-token", API.csrfToken);
  1392. verbose && sessionStorage.setItem("x-guest-token", API.guestToken);
  1393.  
  1394. const headers = new Headers({
  1395. authorization,
  1396. "x-csrf-token": API.csrfToken,
  1397. "x-twitter-client-language": "en",
  1398. "x-twitter-active-user": "yes"
  1399. });
  1400. if (API.guestToken) {
  1401. headers.append("x-guest-token", API.guestToken);
  1402. } else { // may be skipped
  1403. headers.append("x-twitter-auth-type", "OAuth2Session");
  1404. }
  1405.  
  1406. let json;
  1407. try {
  1408. const response = await fetch(_url, {headers});
  1409. json = await response.json();
  1410. if (response.ok) {
  1411. verbose && console.log("[ujs][apiRequest]", "Cache API request", _url);
  1412. API.vacuumCache();
  1413. API.requestCache.set(_url, json);
  1414. }
  1415. } catch (err) {
  1416. /* verbose && */ console.error("[ujs][apiRequest]", _url);
  1417. /* verbose && */ console.error("[ujs][apiRequest]", err);
  1418. throw err;
  1419. }
  1420.  
  1421. verbose && console.log("[ujs][apiRequest][json]", JSON.stringify(json, null, " "));
  1422. // 429 - [{code: 88, message: "Rate limit exceeded"}] — for suspended accounts
  1423.  
  1424. return json;
  1425. }
  1426.  
  1427. /** @return {video, tweetId, screenName, vidNumber} */
  1428. static async getVideoInfo(tweetId, screenName /* author */, posterUrl) {
  1429. const url = API.createTweetDataEndpointUrl(tweetId);
  1430.  
  1431. const json = await API.apiRequest(url);
  1432. verbose && console.log("[ujs][getVideoInfo]", json, JSON.stringify(json));
  1433.  
  1434. const instruction = json.data.threaded_conversation_with_injections_v2.instructions.find(ins => ins.type === "TimelineAddEntries");
  1435. const tweetEntry = instruction.entries.find(ins => ins.entryId === "tweet-" + tweetId);
  1436. const tweetResult = tweetEntry.content.itemContent.tweet_results.result
  1437. let tweetData = tweetResult.legacy;
  1438.  
  1439. if (!tweetData) { // todo: parse the URL from HTML
  1440. tweetData = tweetResult.tweet.legacy; /* For "Embedded video" */
  1441. }
  1442.  
  1443. // [note] if `posterUrl` has `searchParams`, it will have no extension at the end of `pathname`.
  1444. const posterUrlObj = new URL(posterUrl);
  1445. const keys = [];
  1446. posterUrlObj.searchParams.forEach((v, k) => { keys.push(k); }); // FF fix // Instead of [...posterUrlObj.searchParams.keys()]
  1447. for (const key of keys) {
  1448. posterUrlObj.searchParams.delete(key);
  1449. }
  1450. posterUrl = posterUrlObj.toString();
  1451.  
  1452. const isVideoInQuotedPost = !tweetData.extended_entities || tweetData.extended_entities.media.findIndex(e => e.media_url_https.startsWith(posterUrl)) === -1;
  1453. if (tweetData.quoted_status_id_str && isVideoInQuotedPost) {
  1454. const tweetDataQuoted = tweetResult.quoted_status_result.result.legacy;
  1455. const tweetDataQuotedCore = tweetResult.quoted_status_result.result.core.user_results.result.legacy;
  1456.  
  1457. tweetId = tweetData.quoted_status_id_str;
  1458. screenName = tweetDataQuotedCore.screen_name;
  1459. tweetData = tweetDataQuoted;
  1460. }
  1461.  
  1462. // types: "photo", "video", "animated_gif"
  1463.  
  1464. let vidNumber = tweetData.extended_entities.media
  1465. .filter(e => e.type !== "photo")
  1466. .findIndex(e => e.media_url_https.startsWith(posterUrl));
  1467.  
  1468. let mediaIndex = tweetData.extended_entities.media
  1469. .findIndex(e => e.media_url_https.startsWith(posterUrl));
  1470.  
  1471. if (vidNumber === -1 || mediaIndex === -1) {
  1472. verbose && console.warn("[ujs][getVideoInfo][warning]: vidNumber === -1 || mediaIndex === -1");
  1473. vidNumber = 0;
  1474. mediaIndex = 0;
  1475. }
  1476. const videoVariants = tweetData.extended_entities.media[mediaIndex].video_info.variants;
  1477. verbose && console.log("[ujs][getVideoInfo][videoVariants]", videoVariants);
  1478.  
  1479. const video = videoVariants
  1480. .filter(el => el.bitrate !== undefined) // if content_type: "application/x-mpegURL" // .m3u8
  1481. .reduce((acc, cur) => cur.bitrate > acc.bitrate ? cur : acc);
  1482.  
  1483. if (!video) {
  1484. throw new Error("No video URL found");
  1485. }
  1486.  
  1487. return {video, tweetId, screenName, vidNumber};
  1488. }
  1489.  
  1490. // todo: keep `queryId` updated
  1491. // https://github.com/fa0311/TwitterInternalAPIDocument/blob/master/docs/json/API.json
  1492. static TweetDetailQueryId = "xOhkmRac04YFZmOzU9PJHg"; // TweetDetail (for videos)
  1493. static UserByScreenNameQueryId = "G3KGOASz96M-Qu0nwmGXNg"; // UserByScreenName (for the direct user profile url)
  1494.  
  1495. static createTweetDataEndpointUrl(tweetId) {
  1496. const variables = {
  1497. "focalTweetId": tweetId,
  1498. "with_rux_injections": false,
  1499. "includePromotedContent": true,
  1500. "withCommunity": true,
  1501. "withQuickPromoteEligibilityTweetFields": true,
  1502. "withBirdwatchNotes": true,
  1503. "withVoice": true,
  1504. "withV2Timeline": true
  1505. };
  1506. const features = {
  1507. "rweb_lists_timeline_redesign_enabled": true,
  1508. "responsive_web_graphql_exclude_directive_enabled": true,
  1509. "verified_phone_label_enabled": false,
  1510. "creator_subscriptions_tweet_preview_api_enabled": true,
  1511. "responsive_web_graphql_timeline_navigation_enabled": true,
  1512. "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
  1513. "tweetypie_unmention_optimization_enabled": true,
  1514. "responsive_web_edit_tweet_api_enabled": true,
  1515. "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
  1516. "view_counts_everywhere_api_enabled": true,
  1517. "longform_notetweets_consumption_enabled": true,
  1518. "responsive_web_twitter_article_tweet_consumption_enabled": false,
  1519. "tweet_awards_web_tipping_enabled": false,
  1520. "freedom_of_speech_not_reach_fetch_enabled": true,
  1521. "standardized_nudges_misinfo": true,
  1522. "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
  1523. "longform_notetweets_rich_text_read_enabled": true,
  1524. "longform_notetweets_inline_media_enabled": true,
  1525. "responsive_web_media_download_video_enabled": false,
  1526. "responsive_web_enhance_cards_enabled": false
  1527. };
  1528. const fieldToggles = {
  1529. "withArticleRichContentState": false
  1530. };
  1531.  
  1532. const urlBase = `https://twitter.com/i/api/graphql/${API.TweetDetailQueryId}/TweetDetail`;
  1533. const urlObj = new URL(urlBase);
  1534. urlObj.searchParams.set("variables", JSON.stringify(variables));
  1535. urlObj.searchParams.set("features", JSON.stringify(features));
  1536. urlObj.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
  1537. const url = urlObj.toString();
  1538. return url;
  1539. }
  1540.  
  1541. static async getUserInfo(username) {
  1542. const variables = JSON.stringify({
  1543. "screen_name": username,
  1544. "withSafetyModeUserFields": true,
  1545. "withSuperFollowsUserFields": true
  1546. });
  1547. const url = `https://twitter.com/i/api/graphql/${API.UserByScreenNameQueryId}/UserByScreenName?variables=${encodeURIComponent(variables)}`;
  1548. const json = await API.apiRequest(url);
  1549. verbose && console.log("[ujs][getUserInfo][json]", json);
  1550. return json.data.user.result.legacy.entities.url?.urls[0].expanded_url;
  1551. }
  1552. }
  1553.  
  1554. return API;
  1555. }
  1556.  
  1557. function getHistoryHelper() {
  1558. function migrateLocalStore() {
  1559. // 2023.07.05 // todo: uncomment after two+ months
  1560. // Currently I disable it for cases if some browser's tabs uses the old version of the script.
  1561. // const migrated = localStorage.getItem(StorageNames.migrated);
  1562. // if (migrated === "true") {
  1563. // return;
  1564. // }
  1565.  
  1566. const newToOldNameMap = [
  1567. [StorageNames.settings, StorageNamesOld.settings],
  1568. [StorageNames.settingsImageHistoryBy, StorageNamesOld.settingsImageHistoryBy],
  1569. [StorageNames.downloadedImageNames, StorageNamesOld.downloadedImageNames],
  1570. [StorageNames.downloadedImageTweetIds, StorageNamesOld.downloadedImageTweetIds],
  1571. [StorageNames.downloadedVideoTweetIds, StorageNamesOld.downloadedVideoTweetIds],
  1572. ];
  1573.  
  1574. /**
  1575. * @param {string} newName
  1576. * @param {string} oldName
  1577. * @param {string} value
  1578. */
  1579. function setValue(newName, oldName, value) {
  1580. try {
  1581. localStorage.setItem(newName, value);
  1582. } catch (err) {
  1583. localStorage.removeItem(oldName); // if there is no space ("exceeded the quota")
  1584. localStorage.setItem(newName, value);
  1585. }
  1586. localStorage.removeItem(oldName);
  1587. }
  1588.  
  1589. function mergeOldWithNew({newName, oldName}) {
  1590. const oldValueStr = localStorage.getItem(oldName);
  1591. if (oldValueStr === null) {
  1592. return;
  1593. }
  1594. const newValueStr = localStorage.getItem(newName);
  1595. if (newValueStr === null) {
  1596. setValue(newName, oldName, oldValueStr);
  1597. return;
  1598. }
  1599. try {
  1600. const oldValue = JSON.parse(oldValueStr);
  1601. const newValue = JSON.parse(newValueStr);
  1602. if (Array.isArray(oldValue) && Array.isArray(newValue)) {
  1603. const resultArray = [...new Set([...newValue, ...oldValue])];
  1604. const resultArrayStr = JSON.stringify(resultArray);
  1605. setValue(newName, oldName, resultArrayStr);
  1606. }
  1607. } catch (err) {
  1608. // return;
  1609. }
  1610. }
  1611.  
  1612. for (const [newName, oldName] of newToOldNameMap) {
  1613. mergeOldWithNew({newName, oldName});
  1614. }
  1615. // localStorage.setItem(StorageNames.migrated, "true");
  1616. }
  1617.  
  1618. function exportHistory(onDone) {
  1619. const exportObject = [
  1620. StorageNames.settings,
  1621. StorageNames.settingsImageHistoryBy,
  1622. StorageNames.downloadedImageNames, // only if "settingsImageHistoryBy" === "IMAGE_NAME" (by default)
  1623. StorageNames.downloadedImageTweetIds, // only if "settingsImageHistoryBy" === "TWEET_ID" (need to set manually with DevTools)
  1624. StorageNames.downloadedVideoTweetIds,
  1625. ].reduce((acc, name) => {
  1626. const valueStr = localStorage.getItem(name);
  1627. if (valueStr === null) {
  1628. return acc;
  1629. }
  1630. let value = JSON.parse(valueStr);
  1631. if (Array.isArray(value)) {
  1632. value = [...new Set(value)];
  1633. }
  1634. acc[name] = value;
  1635. return acc;
  1636. }, {});
  1637. const browserName = localStorage.getItem(StorageNames.browserName) || getBrowserName();
  1638. const browserLine = browserName ? "-" + browserName : "";
  1639.  
  1640. downloadBlob(new Blob([toLineJSON(exportObject, true)]), `ujs-twitter-click-n-save-export-${dateToDayDateString(new Date())}${browserLine}.json`);
  1641. onDone();
  1642. }
  1643.  
  1644. function verify(jsonObject) {
  1645. if (Array.isArray(jsonObject)) {
  1646. throw new Error("Wrong object! JSON contains an array.");
  1647. }
  1648. if (Object.keys(jsonObject).some(key => !key.startsWith("ujs-twitter-click-n-save"))) {
  1649. throw new Error("Wrong object! The keys should start with 'ujs-twitter-click-n-save'.");
  1650. }
  1651. }
  1652.  
  1653. function importHistory(onDone, onError) {
  1654. const importInput = document.createElement("input");
  1655. importInput.type = "file";
  1656. importInput.accept = "application/json";
  1657. importInput.style.display = "none";
  1658. document.body.prepend(importInput);
  1659. importInput.addEventListener("change", async _event => {
  1660. let json;
  1661. try {
  1662. json = JSON.parse(await importInput.files[0].text());
  1663. verify(json);
  1664.  
  1665. Object.entries(json).forEach(([key, value]) => {
  1666. if (Array.isArray(value)) {
  1667. value = [...new Set(value)];
  1668. }
  1669. localStorage.setItem(key, JSON.stringify(value));
  1670. });
  1671. onDone();
  1672. } catch (err) {
  1673. onError(err);
  1674. } finally {
  1675. await sleep(1000);
  1676. importInput.remove();
  1677. }
  1678. });
  1679. importInput.click();
  1680. }
  1681.  
  1682. function mergeHistory(onDone, onError) { // Only merges arrays
  1683. const mergeInput = document.createElement("input");
  1684. mergeInput.type = "file";
  1685. mergeInput.accept = "application/json";
  1686. mergeInput.style.display = "none";
  1687. document.body.prepend(mergeInput);
  1688. mergeInput.addEventListener("change", async _event => {
  1689. let json;
  1690. try {
  1691. json = JSON.parse(await mergeInput.files[0].text());
  1692. verify(json);
  1693. Object.entries(json).forEach(([key, value]) => {
  1694. if (!Array.isArray(value)) {
  1695. return;
  1696. }
  1697. const existedValue = JSON.parse(localStorage.getItem(key));
  1698. if (Array.isArray(existedValue)) {
  1699. const resultValue = [...new Set([...existedValue, ...value])];
  1700. localStorage.setItem(key, JSON.stringify(resultValue));
  1701. } else {
  1702. localStorage.setItem(key, JSON.stringify(value));
  1703. }
  1704. });
  1705. onDone();
  1706. } catch (err) {
  1707. onError(err);
  1708. } finally {
  1709. await sleep(1000);
  1710. mergeInput.remove();
  1711. }
  1712. });
  1713. mergeInput.click();
  1714. }
  1715.  
  1716. return {exportHistory, importHistory, mergeHistory, migrateLocalStore};
  1717. }
  1718.  
  1719. // ---------------------------------------------------------------------------------------------------------------------
  1720. // ---------------------------------------------------------------------------------------------------------------------
  1721. // --- Common Utils --- //
  1722.  
  1723. // --- LocalStorage util class --- //
  1724. function hoistLS(settings = {}) {
  1725. const {
  1726. verbose, // debug "messages" in the document.title
  1727. } = settings;
  1728.  
  1729. class LS {
  1730. constructor(name) {
  1731. this.name = name;
  1732. }
  1733. getItem(defaultValue) {
  1734. return LS.getItem(this.name, defaultValue);
  1735. }
  1736. setItem(value) {
  1737. LS.setItem(this.name, value);
  1738. }
  1739. removeItem() {
  1740. LS.removeItem(this.name);
  1741. }
  1742. async pushItem(value) { // array method
  1743. await LS.pushItem(this.name, value);
  1744. }
  1745. async popItem(value) { // array method
  1746. await LS.popItem(this.name, value);
  1747. }
  1748. hasItem(value) { // array method
  1749. return LS.hasItem(this.name, value);
  1750. }
  1751.  
  1752. static getItem(name, defaultValue) {
  1753. const value = localStorage.getItem(name);
  1754. if (value === undefined) {
  1755. return undefined;
  1756. }
  1757. if (value === null) { // when there is no such item
  1758. LS.setItem(name, defaultValue);
  1759. return defaultValue;
  1760. }
  1761. return JSON.parse(value);
  1762. }
  1763. static setItem(name, value) {
  1764. localStorage.setItem(name, JSON.stringify(value));
  1765. }
  1766. static removeItem(name) {
  1767. localStorage.removeItem(name);
  1768. }
  1769. static async pushItem(name, value) {
  1770. const array = LS.getItem(name, []);
  1771. array.push(value);
  1772. LS.setItem(name, array);
  1773.  
  1774. //sanity check
  1775. await sleep(50);
  1776. if (!LS.hasItem(name, value)) {
  1777. if (verbose) {
  1778. document.title = "🟥" + document.title;
  1779. }
  1780. await LS.pushItem(name, value);
  1781. }
  1782. }
  1783. static async popItem(name, value) { // remove from an array
  1784. const array = LS.getItem(name, []);
  1785. if (array.indexOf(value) !== -1) {
  1786. array.splice(array.indexOf(value), 1);
  1787. LS.setItem(name, array);
  1788.  
  1789. //sanity check
  1790. await sleep(50);
  1791. if (LS.hasItem(name, value)) {
  1792. if (verbose) {
  1793. document.title = "🟨" + document.title;
  1794. }
  1795. await LS.popItem(name, value);
  1796. }
  1797. }
  1798. }
  1799. static hasItem(name, value) { // has in array
  1800. const array = LS.getItem(name, []);
  1801. return array.indexOf(value) !== -1;
  1802. }
  1803. }
  1804.  
  1805. return LS;
  1806. }
  1807.  
  1808. // --- Just groups them in a function for the convenient code looking --- //
  1809. function getUtils({verbose}) {
  1810. function sleep(time) {
  1811. return new Promise(resolve => setTimeout(resolve, time));
  1812. }
  1813.  
  1814. async function fetchResource(url, onProgress = props => console.log(props)) {
  1815. try {
  1816. /** @type {Response} */
  1817. let response = await fetch(url, {
  1818. // cache: "force-cache",
  1819. });
  1820. const lastModifiedDateSeconds = response.headers.get("last-modified");
  1821. const contentType = response.headers.get("content-type");
  1822.  
  1823. const lastModifiedDate = dateToDayDateString(lastModifiedDateSeconds);
  1824. const extension = contentType ? extensionFromMime(contentType) : null;
  1825.  
  1826. if (onProgress) {
  1827. response = responseProgressProxy(response, onProgress);
  1828. }
  1829.  
  1830. const blob = await response.blob();
  1831.  
  1832. // https://pbs.twimg.com/media/AbcdEFgijKL01_9?format=jpg&name=orig -> AbcdEFgijKL01_9
  1833. // https://pbs.twimg.com/ext_tw_video_thumb/1234567890123456789/pu/img/Ab1cd2345EFgijKL.jpg?name=orig -> Ab1cd2345EFgijKL.jpg
  1834. // https://video.twimg.com/ext_tw_video/1234567890123456789/pu/vid/946x720/Ab1cd2345EFgijKL.mp4?tag=10 -> Ab1cd2345EFgijKL.mp4
  1835. const _url = new URL(url);
  1836. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  1837.  
  1838. const {name} = filename.match(/(?<name>^[^.]+)/).groups;
  1839. return {blob, lastModifiedDate, contentType, extension, name, status: response.status};
  1840. } catch (error) {
  1841. verbose && console.error("[ujs][fetchResource]", url);
  1842. verbose && console.error("[ujs][fetchResource]", error);
  1843. throw error;
  1844. }
  1845. }
  1846.  
  1847. function extensionFromMime(mimeType) {
  1848. let extension = mimeType.match(/(?<=\/).+/)[0];
  1849. extension = extension === "jpeg" ? "jpg" : extension;
  1850. return extension;
  1851. }
  1852.  
  1853. // the original download url will be posted as hash of the blob url, so you can check it in the download manager's history
  1854. function downloadBlob(blob, name, url) {
  1855. const anchor = document.createElement("a");
  1856. anchor.setAttribute("download", name || "");
  1857. const blobUrl = URL.createObjectURL(blob);
  1858. anchor.href = blobUrl + (url ? ("#" + url) : "");
  1859. anchor.click();
  1860. setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
  1861. }
  1862.  
  1863. // "Sun, 10 Jan 2021 22:22:22 GMT" -> "2021.01.10"
  1864. function dateToDayDateString(dateValue, utc = true) {
  1865. const _date = new Date(dateValue);
  1866. function pad(str) {
  1867. return str.toString().padStart(2, "0");
  1868. }
  1869. const _utc = utc ? "UTC" : "";
  1870. const year = _date[`get${_utc}FullYear`]();
  1871. const month = _date[`get${_utc}Month`]() + 1;
  1872. const date = _date[`get${_utc}Date`]();
  1873.  
  1874. return year + "." + pad(month) + "." + pad(date);
  1875. }
  1876.  
  1877. function addCSS(css) {
  1878. const styleElem = document.createElement("style");
  1879. styleElem.textContent = css;
  1880. document.body.append(styleElem);
  1881. return styleElem;
  1882. }
  1883.  
  1884. function getCookie(name) {
  1885. verbose && console.log("[ujs][getCookie]", document.cookie);
  1886. const regExp = new RegExp(`(?<=${name}=)[^;]+`);
  1887. return document.cookie.match(regExp)?.[0];
  1888. }
  1889.  
  1890. function throttle(runnable, time = 50) {
  1891. let waiting = false;
  1892. let queued = false;
  1893. let context;
  1894. let args;
  1895.  
  1896. return function() {
  1897. if (!waiting) {
  1898. waiting = true;
  1899. setTimeout(function() {
  1900. if (queued) {
  1901. runnable.apply(context, args);
  1902. context = args = undefined;
  1903. }
  1904. waiting = queued = false;
  1905. }, time);
  1906. return runnable.apply(this, arguments);
  1907. } else {
  1908. queued = true;
  1909. context = this;
  1910. args = arguments;
  1911. }
  1912. }
  1913. }
  1914.  
  1915. function throttleWithResult(func, time = 50) {
  1916. let waiting = false;
  1917. let args;
  1918. let context;
  1919. let timeout;
  1920. let promise;
  1921.  
  1922. return async function() {
  1923. if (!waiting) {
  1924. waiting = true;
  1925. timeout = new Promise(async resolve => {
  1926. await sleep(time);
  1927. waiting = false;
  1928. resolve();
  1929. });
  1930. return func.apply(this, arguments);
  1931. } else {
  1932. args = arguments;
  1933. context = this;
  1934. }
  1935.  
  1936. if (!promise) {
  1937. promise = new Promise(async resolve => {
  1938. await timeout;
  1939. const result = func.apply(context, args);
  1940. args = context = promise = undefined;
  1941. resolve(result);
  1942. });
  1943. }
  1944. return promise;
  1945. }
  1946. }
  1947.  
  1948. function xpath(path, node = document) {
  1949. let xPathResult = document.evaluate(path, node, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  1950. return xPathResult.singleNodeValue;
  1951. }
  1952. function xpathAll(path, node = document) {
  1953. let xPathResult = document.evaluate(path, node, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
  1954. const nodes = [];
  1955. try {
  1956. let node = xPathResult.iterateNext();
  1957.  
  1958. while (node) {
  1959. nodes.push(node);
  1960. node = xPathResult.iterateNext();
  1961. }
  1962. return nodes;
  1963. } catch (err) {
  1964. // todo need investigate it
  1965. console.error(err); // "The document has mutated since the result was returned."
  1966. return [];
  1967. }
  1968. }
  1969.  
  1970. const identityContentEncodings = new Set([null, "identity", "no encoding"]);
  1971. function getOnProgressProps(response) {
  1972. const {headers, status, statusText, url, redirected, ok} = response;
  1973. const isIdentity = identityContentEncodings.has(headers.get("Content-Encoding"));
  1974. const compressed = !isIdentity;
  1975. const _contentLength = parseInt(headers.get("Content-Length")); // `get()` returns `null` if no header present
  1976. const contentLength = isNaN(_contentLength) ? null : _contentLength;
  1977. const lengthComputable = isIdentity && _contentLength !== null;
  1978.  
  1979. // Original XHR behaviour; in TM it equals to `contentLength`, or `-1` if `contentLength` is `null` (and `0`?).
  1980. const total = lengthComputable ? contentLength : 0;
  1981. const gmTotal = contentLength > 0 ? contentLength : -1; // Like `total` is in TM and GM.
  1982.  
  1983. return {
  1984. gmTotal, total, lengthComputable,
  1985. compressed, contentLength,
  1986. headers, status, statusText, url, redirected, ok
  1987. };
  1988. }
  1989. function responseProgressProxy(response, onProgress) {
  1990. const onProgressProps = getOnProgressProps(response);
  1991. let loaded = 0;
  1992. const reader = response.body.getReader();
  1993. const readableStream = new ReadableStream({
  1994. async start(controller) {
  1995. while (true) {
  1996. const {done, /** @type {Uint8Array} */ value} = await reader.read();
  1997. if (done) {
  1998. break;
  1999. }
  2000. loaded += value.length;
  2001. try {
  2002. onProgress({loaded, ...onProgressProps});
  2003. } catch (err) {
  2004. console.error("[ujs][onProgress]:", err);
  2005. }
  2006. controller.enqueue(value);
  2007. }
  2008. controller.close();
  2009. reader.releaseLock();
  2010. },
  2011. cancel() {
  2012. void reader.cancel();
  2013. }
  2014. });
  2015. return new ResponseEx(readableStream, response);
  2016. }
  2017. class ResponseEx extends Response {
  2018. [Symbol.toStringTag] = "ResponseEx";
  2019.  
  2020. constructor(body, {headers, status, statusText, url, redirected, type, ok}) {
  2021. super(body, {
  2022. status, statusText, headers: {
  2023. ...headers,
  2024. "content-type": headers.get("content-type")?.split("; ")[0] // Fixes Blob type ("text/html; charset=UTF-8") in TM
  2025. }
  2026. });
  2027. this._type = type;
  2028. this._url = url;
  2029. this._redirected = redirected;
  2030. this._ok = ok;
  2031. this._headers = headers; // `HeadersLike` is more user-friendly for debug than the original `Headers` object
  2032. }
  2033. get redirected() { return this._redirected; }
  2034. get url() { return this._url; }
  2035. get type() { return this._type || "basic"; }
  2036. get ok() { return this._ok; }
  2037. /** @returns {Headers} - `Headers`-like object */
  2038. get headers() { return this._headers; }
  2039. }
  2040.  
  2041. function toLineJSON(object, prettyHead = false) {
  2042. let result = "{\n";
  2043. const entries = Object.entries(object);
  2044. const length = entries.length;
  2045. if (prettyHead && length > 0) {
  2046. result += `"${entries[0][0]}":${JSON.stringify(entries[0][1], null, " ")}`;
  2047. if (length > 1) {
  2048. result += `,\n\n`;
  2049. }
  2050. }
  2051. for (let i = 1; i < length - 1; i++) {
  2052. result += `"${entries[i][0]}":${JSON.stringify(entries[i][1])},\n`;
  2053. }
  2054. if (length > 0 && !prettyHead || length > 1) {
  2055. result += `"${entries[length - 1][0]}":${JSON.stringify(entries[length - 1][1])}`;
  2056. }
  2057. result += `\n}`;
  2058. return result;
  2059. }
  2060.  
  2061. const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") !== -1;
  2062.  
  2063. function getBrowserName() {
  2064. const userAgent = window.navigator.userAgent.toLowerCase();
  2065. return userAgent.indexOf("edge") > -1 ? "edge-legacy"
  2066. : userAgent.indexOf("edg") > -1 ? "edge"
  2067. : userAgent.indexOf("opr") > -1 && !!window.opr ? "opera"
  2068. : userAgent.indexOf("chrome") > -1 && !!window.chrome ? "chrome"
  2069. : userAgent.indexOf("firefox") > -1 ? "firefox"
  2070. : userAgent.indexOf("safari") > -1 ? "safari"
  2071. : "";
  2072. }
  2073.  
  2074. return {
  2075. sleep, fetchResource, extensionFromMime, downloadBlob, dateToDayDateString,
  2076. addCSS,
  2077. getCookie,
  2078. throttle, throttleWithResult,
  2079. xpath, xpathAll,
  2080. responseProgressProxy,
  2081. toLineJSON,
  2082. isFirefox,
  2083. getBrowserName,
  2084. }
  2085. }
  2086.  
  2087. // ---------------------------------------------------------------------------------------------------------------------
  2088. // ---------------------------------------------------------------------------------------------------------------------