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

QingJ © 2025

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