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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址