Twitter Click'n'Save

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

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