Twitter Click'n'Save Sa

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

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