Twitter Click'n'Save

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

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