Twitter Click'n'Save

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

当前为 2024-05-17 提交的版本,查看 最新版本

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