Twitter Click'n'Save Sa

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

当前为 2023-10-06 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Click'n'Save Sa
  3. // @version 6.10.2023
  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. // @homepageURL https://github.com/AlttiRi/twitter-click-and-save
  8. // @supportURL https://github.com/AlttiRi/twitter-click-and-save/issues
  9. // @license GPL-3.0
  10. // @grant GM_registerMenuCommand
  11. // ==/UserScript==
  12. // ---------------------------------------------------------------------------------------------------------------------
  13. // ---------------------------------------------------------------------------------------------------------------------
  14.  
  15. if (globalThis.GM_registerMenuCommand /* undefined in Firefox with VM */ || typeof GM_registerMenuCommand === "function") {
  16. GM_registerMenuCommand("Show settings", showSettings);
  17. }
  18.  
  19. // --- For debug --- //
  20. const verbose = false;
  21.  
  22. const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") !== -1;
  23. const settings = loadSettings();
  24.  
  25. const filenameGlobal = `twitter @{author}, name. 【{authorName}】, twitter id. {status-id}, mediaName. {media-name}.{extension}`;
  26.  
  27. function makeName(author, authorName, stausId, mediaName, ext)
  28. {
  29. return filenameGlobal.replace(/\.?{author}/, author).replace(/\.?{authorName}/, authorName).replace(/\.?{status-id}/, stausId).replace(/\.?{media-name}/, mediaName).replace(/\.?{extension}/, "." + ext);
  30. }
  31.  
  32. function getFullImage(btn)
  33. { let url = btn.dataset.url;
  34.  
  35. const originals = ["orig", "4096x4096"];
  36. const samples = ["large", "medium", "900x900", "small", "360x360", /*"240x240", "120x120", "tiny"*/];
  37. let isSample = false;
  38. const previewSize = new URL(url).searchParams.get("name");
  39. if (!samples.includes(previewSize)) {
  40. samples.push(previewSize);
  41. }
  42.  
  43. const urlObj = new URL(url);
  44. if (originals.length) {
  45. urlObj.searchParams.set("name", originals.shift());
  46. } else if (samples.length) {
  47. isSample = true;
  48. urlObj.searchParams.set("name", samples.shift());
  49. } else {
  50. throw new Error("All fallback URLs are failed to download.");
  51. }
  52.  
  53. urlObj.searchParams.set('format', 'jpg');
  54. url = urlObj.toString();
  55.  
  56. // sa code
  57. console.log('_________________full image url = ' + url);
  58. return url;
  59. }
  60.  
  61. function loadSettings() {
  62. const defaultSettings = {
  63. hideTrends: true,
  64. hideSignUpSection: true,
  65. hideTopicsToFollow: false,
  66. hideTopicsToFollowInstantly: false,
  67. hideSignUpBottomBarAndMessages: true,
  68. doNotPlayVideosAutomatically: false,
  69. goFromMobileToMainSite: false,
  70.  
  71. highlightVisitedLinks: true,
  72. highlightOnlySpecialVisitedLinks: true,
  73. expandSpoilers: true,
  74.  
  75. directLinks: true,
  76. handleTitle: true,
  77.  
  78. imagesHandler: true,
  79. videoHandler: true,
  80. addRequiredCSS: true,
  81. preventBlinking: false,
  82.  
  83. hideLoginPopup: false,
  84. addBorder: false,
  85.  
  86. downloadProgress: true,
  87. strictTrackingProtectionFix: false,
  88. };
  89.  
  90. let savedSettings;
  91. try {
  92. savedSettings = JSON.parse(localStorage.getItem("ujs-click-n-save-settings")) || {};
  93. } catch (e) {
  94. console.error("[ujs]", e);
  95. localStorage.removeItem("ujs-click-n-save-settings");
  96. savedSettings = {};
  97. }
  98. savedSettings = Object.assign(defaultSettings, savedSettings);
  99. return savedSettings;
  100. }
  101. function showSettings() {
  102. closeSetting();
  103. if (window.scrollY > 0) {
  104. document.querySelector("html").classList.add("ujs-scroll-initial");
  105. document.body.classList.add("ujs-scrollbar-width-margin-right");
  106. }
  107. document.body.classList.add("ujs-no-scroll");
  108.  
  109. const modalWrapperStyle = `
  110. width: 100%;
  111. height: 100%;
  112. position: fixed;
  113. display: flex;
  114. justify-content: center;
  115. align-items: center;
  116. z-index: 99999;
  117. backdrop-filter: blur(4px);
  118. background-color: rgba(255, 255, 255, 0.5);
  119. `;
  120. const modalSettingsStyle = `
  121. background-color: white;
  122. min-width: 320px;
  123. min-height: 320px;
  124. border: 1px solid darkgray;
  125. padding: 8px;
  126. box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
  127. `;
  128. const s = settings;
  129. const downloadProgressFFTitle = `Disable the download progress if you use Firefox with "Enhanced Tracking Protection" set to "Strict" and ViolentMonkey, or GreaseMonkey extension`;
  130. 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.`;
  131. document.body.insertAdjacentHTML("afterbegin", `
  132. <div class="ujs-modal-wrapper" style="${modalWrapperStyle}">
  133. <div class="ujs-modal-settings" style="${modalSettingsStyle}">
  134. <fieldset>
  135. <legend>Optional</legend>
  136. <label><input type="checkbox" ${s.hideTrends ? "checked" : ""} name="hideTrends">Hide <b>Trends</b> (in the right column)*<br/></label>
  137. <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>
  138. <label><input type="checkbox" ${s.hideSignUpBottomBarAndMessages ? "checked" : ""} name="hideSignUpBottomBarAndMessages">Hide <b>Sign Up Bar</b> and <b>Messages</b> (in the bottom)<br/></label>
  139. <label hidden><input type="checkbox" ${s.doNotPlayVideosAutomatically ? "checked" : ""} name="doNotPlayVideosAutomatically">Do <i>Not</i> Play Videos Automatically</b><br/></label>
  140. <label hidden><input type="checkbox" ${s.goFromMobileToMainSite ? "checked" : ""} name="goFromMobileToMainSite">Redirect from Mobile version (beta)<br/></label>
  141. <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>
  142. <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>
  143. </fieldset>
  144. <fieldset>
  145. <legend>Recommended</legend>
  146. <label><input type="checkbox" ${s.highlightVisitedLinks ? "checked" : ""} name="highlightVisitedLinks">Highlight Visited Links<br/></label>
  147. <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>
  148.  
  149. <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>
  150. </fieldset>
  151. <fieldset>
  152. <legend>Highly Recommended</legend>
  153. <label><input type="checkbox" ${s.directLinks ? "checked" : ""} name="directLinks">Direct Links</label><br/>
  154. <label><input type="checkbox" ${s.handleTitle ? "checked" : ""} name="handleTitle">Enchance Title*<br/></label>
  155. </fieldset>
  156. <fieldset ${isFirefox ? '': 'style="display: none"'}>
  157. <legend>Firefox only</legend>
  158. <label title='${downloadProgressFFTitle}'><input type="radio" ${s.downloadProgress ? "checked" : ""} name="firefoxDownloadProgress" value="downloadProgress">Download Progress<br/></label>
  159. <label title='${strictTrackingProtectionFixFFTitle}'><input type="radio" ${s.strictTrackingProtectionFix ? "checked" : ""} name="firefoxDownloadProgress" value="strictTrackingProtectionFix">Strict Tracking Protection Fix<br/></label>
  160. </fieldset>
  161. <fieldset>
  162. <legend>Main</legend>
  163. <label><input type="checkbox" ${s.imagesHandler ? "checked" : ""} name="imagesHandler">Image Download Button<br/></label>
  164. <label><input type="checkbox" ${s.videoHandler ? "checked" : ""} name="videoHandler">Video Download Button<br/></label>
  165. <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 -->
  166. </fieldset>
  167. <fieldset>
  168. <legend title="Outdated due to Twitter's updates, or impossible to reimplement">Outdated</legend>
  169. <strike>
  170.  
  171. <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>
  172. <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>
  173.  
  174. <label hidden><input type="checkbox" ${s.hideTopicsToFollowInstantly ? "checked" : ""} name="hideTopicsToFollowInstantly">Hide <b>Topics To Follow</b> Instantly*<br/></label>
  175. </strike>
  176. </fieldset>
  177. <hr>
  178. <div style="display: flex; justify-content: space-around;">
  179. <button class="ujs-save-setting-button" style="padding: 5px">Save Settings</button>
  180. <button class="ujs-close-setting-button" style="padding: 5px">Close Settings</button>
  181. </div>
  182. <hr>
  183. <h4 style="margin: 0; padding-left: 8px; color: #444;">Notes:</h4>
  184. <ul style="margin: 2px; padding-left: 16px; color: #444;">
  185. <li>Click on <b>Save Settings</b> and <b>reload the page</b> to apply changes.</li>
  186. <li><b>*</b>-marked settings are language dependent. Currently, the follow languages are supported:<br/> "en", "ru", "es", "zh", "ja".</li>
  187. <li hidden>The extension downloads only from twitter.com, not from <b>mobile</b>.twitter.com</li>
  188. </ul>
  189. </div>
  190. </div>`);
  191.  
  192. document.querySelector("body > .ujs-modal-wrapper .ujs-save-setting-button").addEventListener("click", saveSetting);
  193. document.querySelector("body > .ujs-modal-wrapper .ujs-close-setting-button").addEventListener("click", closeSetting);
  194.  
  195. function saveSetting() {
  196. const entries = [...document.querySelectorAll("body > .ujs-modal-wrapper input[type=checkbox]")]
  197. .map(checkbox => [checkbox.name, checkbox.checked]);
  198. const radioEntries = [...document.querySelectorAll("body > .ujs-modal-wrapper input[type=radio]")]
  199. .map(checkbox => [checkbox.value, checkbox.checked])
  200. const settings = Object.fromEntries([entries, radioEntries].flat());
  201. settings.hideTopicsToFollowInstantly = settings.hideTopicsToFollow;
  202. // console.log("[ujs]", settings);
  203. localStorage.setItem("ujs-click-n-save-settings", JSON.stringify(settings));
  204. }
  205.  
  206. function closeSetting() {
  207. document.body.classList.remove("ujs-no-scroll");
  208. document.body.classList.remove("ujs-scrollbar-width-margin-right");
  209. document.querySelector("html").classList.remove("ujs-scroll-initial");
  210. document.querySelector("body > .ujs-modal-wrapper")?.remove();
  211. }
  212. }
  213.  
  214. // ---------------------------------------------------------------------------------------------------------------------
  215. // ---------------------------------------------------------------------------------------------------------------------
  216.  
  217. // --- Features to execute --- //
  218. const doNotPlayVideosAutomatically = false;
  219.  
  220. function execFeaturesOnce() {
  221. settings.goFromMobileToMainSite && Features.goFromMobileToMainSite();
  222. settings.addRequiredCSS && Features.addRequiredCSS();
  223. settings.hideSignUpBottomBarAndMessages && Features.hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically);
  224. settings.hideTrends && Features.hideTrends();
  225. settings.highlightVisitedLinks && Features.highlightVisitedLinks();
  226. settings.hideTopicsToFollowInstantly && Features.hideTopicsToFollowInstantly();
  227. settings.hideLoginPopup && Features.hideLoginPopup();
  228. }
  229. function execFeaturesImmediately() {
  230. settings.expandSpoilers && Features.expandSpoilers();
  231. }
  232. function execFeatures() {
  233. settings.imagesHandler && Features.imagesHandler(settings.preventBlinking);
  234. settings.videoHandler && Features.videoHandler(settings.preventBlinking);
  235. settings.expandSpoilers && Features.expandSpoilers();
  236. settings.hideSignUpSection && Features.hideSignUpSection();
  237. settings.hideTopicsToFollow && Features.hideTopicsToFollow();
  238. settings.directLinks && Features.directLinks();
  239. settings.handleTitle && Features.handleTitle();
  240. }
  241.  
  242. // ---------------------------------------------------------------------------------------------------------------------
  243. // ---------------------------------------------------------------------------------------------------------------------
  244.  
  245. if (verbose) {
  246. console.log("[ujs][settings]", settings);
  247. // showSettings();
  248. }
  249.  
  250. const fetch = ujs_getGlobalFetch({verbose, strictTrackingProtectionFix: settings.strictTrackingProtectionFix});
  251.  
  252. function ujs_getGlobalFetch({verbose, strictTrackingProtectionFix} = {}) {
  253. const useFirefoxStrictTrackingProtectionFix = strictTrackingProtectionFix === undefined ? true : strictTrackingProtectionFix; // Let's use by default
  254. const useFirefoxFix = useFirefoxStrictTrackingProtectionFix && typeof wrappedJSObject === "object" && typeof wrappedJSObject.fetch === "function";
  255. // --- [VM/GM + Firefox ~90+ + Enabled "Strict Tracking Protection"] fix --- //
  256. function fixedFirefoxFetch(resource, init = {}) {
  257. verbose && console.log("wrappedJSObject.fetch", resource, init);
  258. if (init.headers instanceof Headers) {
  259. // Since `Headers` are not allowed for structured cloning.
  260. init.headers = Object.fromEntries(init.headers.entries());
  261. }
  262. return wrappedJSObject.fetch(cloneInto(resource, document), cloneInto(init, document));
  263. }
  264. return useFirefoxFix ? fixedFirefoxFetch : globalThis.fetch;
  265. }
  266.  
  267. // --- "Imports" --- //
  268. const {
  269. sleep, fetchResource, downloadBlob,
  270. addCSS,
  271. getCookie,
  272. throttle,
  273. xpath, xpathAll,
  274. responseProgressProxy,
  275. } = getUtils({verbose});
  276. const LS = hoistLS({verbose});
  277.  
  278. const API = hoistAPI();
  279. const Tweet = hoistTweet();
  280. const Features = hoistFeatures();
  281. const I18N = getLanguageConstants();
  282.  
  283. // --- That to use for the image history --- //
  284. // "TWEET_ID" or "IMAGE_NAME"
  285. const imagesHistoryBy = LS.getItem("ujs-images-history-by", "IMAGE_NAME");
  286. // With "TWEET_ID" downloading of 1 image of 4 will mark all 4 images as "already downloaded"
  287. // on the next time when the tweet will appear.
  288. // "IMAGE_NAME" will count each image of a tweet, but it will take more data to store.
  289.  
  290. // ---------------------------------------------------------------------------------------------------------------------
  291. // ---------------------------------------------------------------------------------------------------------------------
  292. // --- Script runner --- //
  293.  
  294. (function starter(feats) {
  295. const {once, onChangeImmediate, onChange} = feats;
  296.  
  297. once();
  298. onChangeImmediate();
  299. const onChangeThrottled = throttle(onChange, 250);
  300. onChangeThrottled();
  301.  
  302. const targetNode = document.querySelector("body");
  303. const observerOptions = {
  304. subtree: true,
  305. childList: true,
  306. };
  307. const observer = new MutationObserver(callback);
  308. observer.observe(targetNode, observerOptions);
  309.  
  310. function callback(mutationList, observer) {
  311. verbose && console.log(mutationList);
  312. onChangeImmediate();
  313. onChangeThrottled();
  314. }
  315. })({
  316. once: execFeaturesOnce,
  317. onChangeImmediate: execFeaturesImmediately,
  318. onChange: execFeatures
  319. });
  320.  
  321. // ---------------------------------------------------------------------------------------------------------------------
  322. // ---------------------------------------------------------------------------------------------------------------------
  323. // --- Twitter Specific code --- //
  324.  
  325. const downloadedImages = new LS("ujs-twitter-downloaded-images-names");
  326. const downloadedImageTweetIds = new LS("ujs-twitter-downloaded-image-tweet-ids");
  327. const downloadedVideoTweetIds = new LS("ujs-twitter-downloaded-video-tweet-ids");
  328.  
  329. // --- Twitter.Features --- //
  330. function hoistFeatures() {
  331. class Features {
  332. mainUrl;
  333. static goFromMobileToMainSite() {
  334. if (location.href.startsWith("https://mobile.twitter.com/")) {
  335. location.href = location.href.replace("https://mobile.twitter.com/", "https://twitter.com/");
  336. }
  337. // TODO: add #redirected, remove by timer // to prevent a potential infinity loop
  338. }
  339.  
  340. static rightClickHandler(ev, btn) {
  341. ev.preventDefault();
  342. let fullImageUrl = getFullImage(btn);
  343.  
  344. if (window.event.ctrlKey)
  345. { console.log("control right click");
  346. window.open(fullImageUrl, "_blank");
  347. }
  348. else
  349. { navigator.clipboard.writeText(fullImageUrl);
  350. console.log("right click");
  351. }
  352. return false;
  353. }
  354.  
  355. static createButton({url, downloaded, isVideo}) {
  356. this.mainUrl = url;
  357. const btn = document.createElement("div");
  358. btn.innerHTML = `
  359. <div class="ujs-btn-common ujs-btn-background"></div>
  360. <div class="ujs-btn-common ujs-hover"></div>
  361. <div class="ujs-btn-common ujs-shadow"></div>
  362. <div class="ujs-btn-common ujs-progress" style="--progress: 0%"></div>
  363. <div class="ujs-btn-common ujs-btn-error-text"></div>`.slice(1);
  364.  
  365. //btn.classList.add("buttonParent");
  366. btn.classList.add("ujs-btn-download");
  367. if (!downloaded) {
  368. btn.classList.add("ujs-not-downloaded");
  369. } else {
  370. btn.classList.add("ujs-already-downloaded");
  371. }
  372. if (isVideo) {
  373. btn.classList.add("ujs-video");
  374. }
  375. if (url) {
  376. btn.dataset.url = url;
  377. }
  378. return btn;
  379. }
  380.  
  381. static hasBlinkListenerWeakSet;
  382. static _preventBlinking(clickBtnElem) {
  383. const weakSet = Features.hasBlinkListenerWeakSet || (Features.hasBlinkListenerWeakSet = new WeakSet());
  384. let wrapper;
  385. clickBtnElem.addEventListener("mouseenter", () => {
  386. if (!weakSet.has(clickBtnElem)) {
  387. wrapper = Features._preventBlinkingHandler(clickBtnElem);
  388. weakSet.add(clickBtnElem);
  389. }
  390. });
  391. clickBtnElem.addEventListener("mouseleave", () => {
  392. verbose && console.log("[ujs] Btn mouseleave");
  393. if (wrapper?.observer?.disconnect) {
  394. weakSet.delete(clickBtnElem);
  395. wrapper.observer.disconnect();
  396. }
  397. });
  398. }
  399. static _preventBlinkingHandler(clickBtnElem) {
  400. let targetNode = clickBtnElem.closest("[aria-labelledby]");
  401. if (!targetNode) {
  402. return;
  403. }
  404. let config = {attributes: true, subtree: true, attributeOldValue: true};
  405. const wrapper = {};
  406. wrapper.observer = new MutationObserver(callback);
  407. wrapper.observer.observe(targetNode, config);
  408.  
  409. function callback(mutationsList, observer) {
  410. for (const mutation of mutationsList) {
  411. if (mutation.type === "attributes" && mutation.attributeName === "class") {
  412. if (mutation.target.classList.contains("ujs-btn-download")) {
  413. return;
  414. }
  415. // Don't allow to change classList
  416. mutation.target.className = mutation.oldValue;
  417.  
  418. // Recreate, to prevent an infinity loop
  419. wrapper.observer.disconnect();
  420. wrapper.observer = new MutationObserver(callback);
  421. wrapper.observer.observe(targetNode, config);
  422. }
  423. }
  424. }
  425.  
  426. return wrapper;
  427. }
  428.  
  429. // Banner/Background
  430. static async _downloadBanner(url, btn) {
  431. const username = location.pathname.slice(1).split("/")[0];
  432.  
  433. btn.classList.add("ujs-downloading");
  434.  
  435. // https://pbs.twimg.com/profile_banners/34743251/1596331248/1500x500
  436. const {
  437. id, seconds, res
  438. } = url.match(/(?<=\/profile_banners\/)(?<id>\d+)\/(?<seconds>\d+)\/(?<res>\d+x\d+)/)?.groups || {};
  439.  
  440. const {blob, lastModifiedDate, extension, name} = await fetchResource(url);
  441.  
  442. Features.verifyBlob(blob, url, btn);
  443.  
  444. const filename = `twitter[bg] @${username}—${id}—${seconds}—.${extension}`;
  445. downloadBlob(blob, filename, url);
  446.  
  447. btn.classList.remove("ujs-downloading");
  448. btn.classList.add("ujs-downloaded");
  449. }
  450.  
  451. static _ImageHistory = class {
  452. static getImageNameFromUrl(url) {
  453. const _url = new URL(url);
  454. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  455. return filename.match(/^[^.]+/)[0]; // remove extension
  456. }
  457. static isDownloaded({id, url}) {
  458. if (imagesHistoryBy === "TWEET_ID") {
  459. return downloadedImageTweetIds.hasItem(id);
  460. } else if (imagesHistoryBy === "IMAGE_NAME") {
  461. const name = Features._ImageHistory.getImageNameFromUrl(url);
  462. return downloadedImages.hasItem(name);
  463. }
  464. }
  465. static async markDownloaded({id, url}) {
  466. if (imagesHistoryBy === "TWEET_ID") {
  467. await downloadedImageTweetIds.pushItem(id);
  468. } else if (imagesHistoryBy === "IMAGE_NAME") {
  469. const name = Features._ImageHistory.getImageNameFromUrl(url);
  470. await downloadedImages.pushItem(name);
  471. }
  472. }
  473. }
  474. static async imagesHandler(preventBlinking) {
  475. verbose && console.log("[ujs-cns][imagesHandler]");
  476. const images = document.querySelectorAll("img");
  477. for (const img of images) {
  478.  
  479. if (img.width < 150 || img.dataset.handled) {
  480. continue;
  481. }
  482. verbose && console.log(img, img.width);
  483.  
  484. img.dataset.handled = "true";
  485.  
  486. const btn = Features.createButton({url: img.src});
  487. btn.addEventListener("click", Features._imageClickHandler);
  488. btn.addEventListener('contextmenu', (ev) => Features.rightClickHandler(ev, btn));
  489.  
  490. let anchor = img.closest("a");
  491. // if an image is _opened_ "https://twitter.com/UserName/status/1234567890123456789/photo/1" [fake-url]
  492. if (!anchor) {
  493. anchor = img.parentNode;
  494. }
  495. anchor.append(btn);
  496. if (preventBlinking) {
  497. Features._preventBlinking(btn);
  498. }
  499.  
  500. const downloaded = Features._ImageHistory.isDownloaded({
  501. id: Tweet.of(btn).id,
  502. url: btn.dataset.url
  503. });
  504. if (downloaded) {
  505. btn.classList.add("ujs-already-downloaded");
  506. }
  507. }
  508. }
  509. static async _imageClickHandler(event) {
  510. event.preventDefault();
  511. event.stopImmediatePropagation();
  512.  
  513. const btn = event.currentTarget;
  514. const btnErrorTextElem = btn.querySelector(".ujs-btn-error-text");
  515. let url = btn.dataset.url;
  516.  
  517. const isBanner = url.includes("/profile_banners/");
  518. if (isBanner) {
  519. return Features._downloadBanner(url, btn);
  520. }
  521.  
  522. const {id, author} = Tweet.of(btn);
  523. verbose && console.log(id, author);
  524.  
  525. const btnProgress = btn.querySelector(".ujs-progress");
  526. if (btn.textContent !== "") {
  527. btnErrorTextElem.textContent = "";
  528. }
  529. btn.classList.remove("ujs-error");
  530. btn.classList.add("ujs-downloading");
  531.  
  532. let onProgress = null;
  533. if (settings.downloadProgress) {
  534. onProgress = ({loaded, total}) => btnProgress.style.cssText = "--progress: " + loaded / total * 90 + "%";
  535. }
  536.  
  537. const originals = ["orig", "4096x4096"];
  538. const samples = ["large", "medium", "900x900", "small", "360x360", /*"240x240", "120x120", "tiny"*/];
  539. let isSample = false;
  540. const previewSize = new URL(url).searchParams.get("name");
  541. if (!samples.includes(previewSize)) {
  542. samples.push(previewSize);
  543. }
  544.  
  545. function handleImgUrl(url) {
  546. const urlObj = new URL(url);
  547. if (originals.length) {
  548. urlObj.searchParams.set("name", originals.shift());
  549. } else if (samples.length) {
  550. isSample = true;
  551. urlObj.searchParams.set("name", samples.shift());
  552. } else {
  553. throw new Error("All fallback URLs are failed to download.");
  554. }
  555.  
  556. urlObj.searchParams.set('format', 'jpg');
  557. url = urlObj.toString();
  558.  
  559. // sa code
  560. console.log('_________________');
  561. console.log(url);
  562.  
  563. return url;
  564. }
  565.  
  566. async function safeFetchResource(url) {
  567. while (true) {
  568. console.log("___url " + url);
  569. url = handleImgUrl(url);
  570. try {
  571. return await fetchResource(url, onProgress);
  572. } catch (e) {
  573. if (!originals.length) {
  574. btn.classList.add("ujs-error");
  575. btnErrorTextElem.textContent = "";
  576. // Add ⚠
  577. 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;`;
  578. btn.title = "[warning] Original images are not available.";
  579. }
  580.  
  581. const ffAutoAllocateChunkSizeBug = e.message.includes("autoAllocateChunkSize"); // https://bugzilla.mozilla.org/show_bug.cgi?id=1757836
  582. if (!samples.length || ffAutoAllocateChunkSizeBug) {
  583. btn.classList.add("ujs-error");
  584. btnErrorTextElem.textContent = "";
  585. // Add ❌
  586. 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;`;
  587.  
  588. const ffHint = isFirefox && !settings.strictTrackingProtectionFix && ffAutoAllocateChunkSizeBug ? "\nTry to enable 'Strict Tracking Protection Fix' in the userscript settings." : "";
  589. btn.title = "Failed to download the image." + ffHint;
  590. throw new Error("[error] Fallback URLs are failed.");
  591. }
  592. }
  593. }
  594. }
  595.  
  596. const {blob, lastModifiedDate, extension, name} = await safeFetchResource(url);
  597.  
  598. Features.verifyBlob(blob, url, btn);
  599.  
  600. btnProgress.style.cssText = "--progress: 100%";
  601.  
  602. const sampleText = !isSample ? "" : "[sample]";
  603.  
  604. // image filename
  605. // const filename = `twitter${sampleText}—${author}—${id}—${name}—.${extension}`;
  606. console.log("___url : " + url);
  607. let authorName = await API.getAuthorName(btn);
  608.  
  609. let filename = makeName(author, authorName, id, name, extension);
  610. console.log("_____________out: " + filename);
  611.  
  612. downloadBlob(blob, filename, url);
  613.  
  614. const downloaded = btn.classList.contains("already-downloaded");
  615. if (!downloaded && !isSample) {
  616. await Features._ImageHistory.markDownloaded({id, url});
  617. }
  618. btn.classList.remove("ujs-downloading");
  619. btn.classList.add("ujs-downloaded");
  620.  
  621. await sleep(40);
  622. btnProgress.style.cssText = "--progress: 0%";
  623. }
  624.  
  625. static tweetVidWeakMap = new WeakMap();
  626. static async videoHandler(preventBlinking) {
  627. const videos = document.querySelectorAll("video");
  628.  
  629. for (const vid of videos) {
  630. if (vid.dataset.handled) {
  631. continue;
  632. }
  633. verbose && console.log(vid);
  634. vid.dataset.handled = "true";
  635.  
  636. const poster = vid.getAttribute("poster");
  637.  
  638. const btn = Features.createButton({isVideo: true, url: poster});
  639. btn.addEventListener("click", Features._videoClickHandler);
  640.  
  641. let elem = vid.parentNode.parentNode.parentNode;
  642. elem.after(btn);
  643. if (preventBlinking) {
  644. Features._preventBlinking(btn);
  645. }
  646.  
  647. const tweet = Tweet.of(btn);
  648. const id = tweet.id;
  649. const tweetElem = tweet.elem;
  650. let vidNumber = 0;
  651.  
  652. const map = Features.tweetVidWeakMap;
  653. if (map.has(tweetElem)) {
  654. vidNumber = map.get(tweetElem) + 1;
  655. map.set(tweetElem, vidNumber);
  656. } else {
  657. map.set(tweetElem, vidNumber);
  658. }
  659.  
  660. const historyId = vidNumber ? id + "-" + vidNumber : id;
  661.  
  662. const downloaded = downloadedVideoTweetIds.hasItem(historyId);
  663. if (downloaded) {
  664. btn.classList.add("ujs-already-downloaded");
  665. }
  666. }
  667. }
  668. static async _videoClickHandler(event) {
  669. event.preventDefault();
  670. event.stopImmediatePropagation();
  671.  
  672. const btn = event.currentTarget;
  673. const btnErrorTextElem = btn.querySelector(".ujs-btn-error-text");
  674. let {id, author} = Tweet.of(btn);
  675.  
  676. if (btn.textContent !== "") {
  677. btnErrorTextElem.textContent = "";
  678. }
  679. btn.classList.remove("ujs-error");
  680. btn.classList.add("ujs-downloading");
  681.  
  682. const posterUrl = btn.dataset.url;
  683.  
  684. let video; // {bitrate, content_type, url}
  685. let vidNumber = 0;
  686. let authorName;
  687. try {
  688. ({video, tweetId: id, screenName: author, vidNumber, authorName} = await API.getVideoInfo(id, author, posterUrl));
  689. verbose && console.log("[ujs][videoHandler][video]", video);
  690. } catch (e) {
  691. btn.classList.add("ujs-error");
  692. btnErrorTextElem.textContent = "Error";
  693. btn.title = "API.getVideoInfo Error";
  694. throw new Error("API.getVideoInfo Error");
  695. }
  696.  
  697. const btnProgress = btn.querySelector(".ujs-progress");
  698.  
  699. const url = video.url;
  700. let onProgress = null;
  701. if (settings.downloadProgress) {
  702. onProgress = ({loaded, total}) => btnProgress.style.cssText = "--progress: " + loaded / total * 90 + "%";
  703. }
  704.  
  705. const {blob, lastModifiedDate, extension, name} = await fetchResource(url, onProgress);
  706.  
  707. btnProgress.style.cssText = "--progress: 100%";
  708.  
  709. Features.verifyBlob(blob, url, btn);
  710.  
  711. // video filename
  712. //const filename = `twitter ${author}——${id}—${name}—.${extension}`;
  713. //const filename = `twitter @${author}, ${authorName}, ${id}, ${name}.${extension}`;
  714. let filename = makeName(author, authorName, id, name, extension);
  715. console.log("_____________out: " + filename);
  716.  
  717. downloadBlob(blob, filename, url);
  718.  
  719. const downloaded = btn.classList.contains("ujs-already-downloaded");
  720. const historyId = vidNumber ? id + "-" + vidNumber : id;
  721. if (!downloaded) {
  722. await downloadedVideoTweetIds.pushItem(historyId);
  723. }
  724. btn.classList.remove("ujs-downloading");
  725. btn.classList.add("ujs-downloaded");
  726.  
  727. await sleep(40);
  728. btnProgress.style.cssText = "--progress: 0%";
  729. }
  730.  
  731. static verifyBlob(blob, url, btn) {
  732. if (!blob.size) {
  733. btn.classList.add("ujs-error");
  734. btn.querySelector(".ujs-btn-error-text").textContent = "Error";
  735. btn.title = "Download Error";
  736. throw new Error("Zero size blob: " + url);
  737. }
  738. }
  739.  
  740. static addRequiredCSS() {
  741. const code = getUserScriptCSS();
  742. addCSS(code);
  743. }
  744.  
  745. // it depends of `directLinks()` use only it after `directLinks()`
  746. static handleTitle(title) {
  747.  
  748. if (!I18N.QUOTES) { // Unsupported lang, no QUOTES, ON_TWITTER, TWITTER constants
  749. return;
  750. }
  751.  
  752. // if not an opened tweet
  753. if (!location.href.match(/twitter\.com\/[^\/]+\/status\/\d+/)) {
  754. return;
  755. }
  756.  
  757. let titleText = title || document.title;
  758. if (titleText === Features.lastHandledTitle) {
  759. return;
  760. }
  761. Features.originalTitle = titleText;
  762.  
  763. const [OPEN_QUOTE, CLOSE_QUOTE] = I18N.QUOTES;
  764. const urlsToReplace = [
  765. ...titleText.matchAll(new RegExp(`https:\\/\\/t\\.co\\/[^ ${CLOSE_QUOTE}]+`, "g"))
  766. ].map(el => el[0]);
  767. // the last one may be the URL to the tweet // or to an embedded shared URL
  768.  
  769. const map = new Map();
  770. const anchors = document.querySelectorAll(`a[data-redirect^="https://t.co/"]`);
  771. for (const anchor of anchors) {
  772. if (urlsToReplace.includes(anchor.dataset.redirect)) {
  773. map.set(anchor.dataset.redirect, anchor.href);
  774. }
  775. }
  776.  
  777. const lastUrl = urlsToReplace.slice(-1)[0];
  778. let lastUrlIsAttachment = false;
  779. let attachmentDescription = "";
  780. if (!map.has(lastUrl)) {
  781. const a = document.querySelector(`a[href="${lastUrl}?amp=1"]`);
  782. if (a) {
  783. lastUrlIsAttachment = true;
  784. attachmentDescription = document.querySelectorAll(`a[href="${lastUrl}?amp=1"]`)[1].innerText;
  785. attachmentDescription = attachmentDescription.replaceAll("\n", " — ");
  786. }
  787. }
  788.  
  789. for (const [key, value] of map.entries()) {
  790. titleText = titleText.replaceAll(key, value + ` (${key})`);
  791. }
  792.  
  793. titleText = titleText.replace(new RegExp(`${I18N.ON_TWITTER}(?= ${OPEN_QUOTE})`), ":");
  794. titleText = titleText.replace(new RegExp(`(?<=${CLOSE_QUOTE}) \\\/ ${I18N.TWITTER}$`), "");
  795. if (!lastUrlIsAttachment) {
  796. const regExp = new RegExp(`(?<short> https:\\/\\/t\\.co\\/.{6,14})${CLOSE_QUOTE}$`);
  797. titleText = titleText.replace(regExp, (match, p1, p2, offset, string) => `${CLOSE_QUOTE} ${p1}`);
  798. } else {
  799. titleText = titleText.replace(lastUrl, `${lastUrl} (${attachmentDescription})`);
  800. }
  801. document.title = titleText; // Note: some characters will be removed automatically (`\n`, extra spaces)
  802. Features.lastHandledTitle = document.title;
  803. }
  804. static lastHandledTitle = "";
  805. static originalTitle = "";
  806.  
  807. static profileUrlCache = new Map();
  808. static async directLinks() {
  809. verbose && console.log("[ujs][directLinks]");
  810. const hasHttp = url => Boolean(url.match(/^https?:\/\//));
  811. const anchors = xpathAll(`.//a[@dir="ltr" and child::span and not(@data-handled)]`);
  812. for (const anchor of anchors) {
  813. const redirectUrl = new URL(anchor.href);
  814. const shortUrl = redirectUrl.origin + redirectUrl.pathname; // remove "?amp=1"
  815.  
  816. const hrefAttr = anchor.getAttribute("href");
  817. if (hrefAttr.startsWith("/")) {
  818. anchor.dataset.handled = "true";
  819. return;
  820. }
  821.  
  822. verbose && console.log("[ujs][directLinks]", hrefAttr, redirectUrl.href, shortUrl);
  823.  
  824. anchor.dataset.redirect = shortUrl;
  825. anchor.dataset.handled = "true";
  826. anchor.rel = "nofollow noopener noreferrer";
  827.  
  828. if (Features.profileUrlCache.has(shortUrl)) {
  829. anchor.href = Features.profileUrlCache.get(shortUrl);
  830. continue;
  831. }
  832.  
  833. const nodes = xpathAll(`./span[text() != "…"]|./text()`, anchor);
  834. let url = nodes.map(node => node.textContent).join("");
  835.  
  836. const doubleProtocolPrefix = url.match(/(?<dup>^https?:\/\/)(?=https?:)/)?.groups.dup;
  837. if (doubleProtocolPrefix) {
  838. url = url.slice(doubleProtocolPrefix.length);
  839. const span = anchor.querySelector(`[aria-hidden="true"]`);
  840. if (hasHttp(span.textContent)) { // Fix Twitter's bug related to text copying
  841. span.style = "display: none;";
  842. }
  843. }
  844.  
  845. anchor.href = url;
  846.  
  847. if (anchor.dataset?.testid === "UserUrl") {
  848. const href = anchor.getAttribute("href");
  849. const profileUrl = hasHttp(href) ? href : "https://" + href;
  850. anchor.href = profileUrl;
  851. verbose && console.log("[ujs][directLinks][UserUrl]", profileUrl);
  852.  
  853. // Restore if URL's text content is too long
  854. if (anchor.textContent.endsWith("…")) {
  855. anchor.href = shortUrl;
  856.  
  857. try {
  858. const author = location.pathname.slice(1).match(/[^\/]+/)[0];
  859. console.log("___author: ");
  860. console.log("___author: " + author);
  861. const expanded_url = await API.getUserInfo(author); // todo: make lazy
  862. anchor.href = expanded_url;
  863. Features.profileUrlCache.set(shortUrl, expanded_url);
  864. } catch (e) {
  865. verbose && console.error(e);
  866. }
  867. }
  868. }
  869. }
  870. if (anchors.length) {
  871. Features.handleTitle(Features.originalTitle);
  872. }
  873. }
  874.  
  875. // Do NOT throttle it
  876. static expandSpoilers() {
  877. const main = document.querySelector("main[role=main]");
  878. if (!main) {
  879. return;
  880. }
  881.  
  882. if (!I18N.YES_VIEW_PROFILE) { // Unsupported lang, no YES_VIEW_PROFILE, SHOW_NUDITY, VIEW constants
  883. return;
  884. }
  885.  
  886. const a = main.querySelectorAll("[data-testid=primaryColumn] [role=button]");
  887. if (a) {
  888. const elems = [...a];
  889. const button = elems.find(el => el.textContent === I18N.YES_VIEW_PROFILE);
  890. if (button) {
  891. button.click();
  892. }
  893.  
  894. // "Content warning: Nudity"
  895. // "The Tweet author flagged this Tweet as showing sensitive content."
  896. // "Show"
  897. const buttonShow = elems.find(el => el.textContent === I18N.SHOW_NUDITY);
  898. if (buttonShow) {
  899. // const verifying = a.previousSibling.textContent.includes("Nudity"); // todo?
  900. // if (verifying) {
  901. buttonShow.click();
  902. // }
  903. }
  904. }
  905.  
  906. // todo: expand spoiler commentary in photo view mode (.../photo/1)
  907. const b = main.querySelectorAll("article [role=presentation] div[role=button]");
  908. if (b) {
  909. const elems = [...b];
  910. const buttons = elems.filter(el => el.textContent === I18N.VIEW);
  911. if (buttons.length) {
  912. buttons.forEach(el => el.click());
  913. }
  914. }
  915. }
  916.  
  917. static hideSignUpSection() { // "New to Twitter?"
  918. if (!I18N.SIGNUP) {// Unsupported lang, no SIGNUP constant
  919. return;
  920. }
  921. const elem = document.querySelector(`section[aria-label="${I18N.SIGNUP}"][role=region]`);
  922. if (elem) {
  923. elem.parentNode.classList.add("ujs-hidden");
  924. }
  925. }
  926.  
  927. // Call it once.
  928. // "Don’t miss what’s happening" if you are not logged in.
  929. // It looks that `#layers` is used only for this bar.
  930. static hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically) {
  931. if (doNotPlayVideosAutomatically) {
  932. addCSS(`
  933. #layers > div:nth-child(1) {
  934. display: none;
  935. }
  936. `);
  937. } else {
  938. addCSS(`
  939. #layers > div:nth-child(1) {
  940. height: 1px;
  941. opacity: 0;
  942. }
  943. `);
  944. }
  945. }
  946.  
  947. // "Trends for you"
  948. static hideTrends() {
  949. if (!I18N.TRENDS) { // Unsupported lang, no TRENDS constant
  950. return;
  951. }
  952. addCSS(`
  953. [aria-label="${I18N.TRENDS}"]
  954. {
  955. display: none;
  956. }
  957. `);
  958. }
  959.  
  960. static highlightVisitedLinks() {
  961. if (settings.highlightOnlySpecialVisitedLinks) {
  962. addCSS(`
  963. a[href^="http"]:visited {
  964. color: darkorange;
  965. }
  966. `);
  967. return;
  968. }
  969. addCSS(`
  970. a:visited {
  971. color: darkorange;
  972. }
  973. `);
  974. }
  975.  
  976. // Hides "TOPICS TO FOLLOW" only in the right column, NOT in timeline.
  977. // Use it once. To prevent blinking.
  978. static hideTopicsToFollowInstantly() {
  979. if (!I18N.TOPICS_TO_FOLLOW) { // Unsupported lang, no TOPICS_TO_FOLLOW constant
  980. return;
  981. }
  982. addCSS(`
  983. div[aria-label="${I18N.TOPICS_TO_FOLLOW}"] {
  984. display: none;
  985. }
  986. `);
  987. }
  988.  
  989. // Hides container and "separator line"
  990. static hideTopicsToFollow() {
  991. if (!I18N.TOPICS_TO_FOLLOW) { // Unsupported lang, no TOPICS_TO_FOLLOW constant
  992. return;
  993. }
  994.  
  995. const elem = xpath(`.//section[@role="region" and child::div[@aria-label="${I18N.TOPICS_TO_FOLLOW}"]]/../..`);
  996. if (!elem) {
  997. return;
  998. }
  999. elem.classList.add("ujs-hidden");
  1000.  
  1001. elem.previousSibling.classList.add("ujs-hidden"); // a "separator line" (empty element of "TRENDS", for example)
  1002. // in fact it's a hack // todo rework // may hide "You might like" section [bug]
  1003. }
  1004.  
  1005. // todo split to two methods
  1006. // todo fix it, currently it works questionably
  1007. // not tested with non eng langs
  1008. static footerHandled = false;
  1009. static hideAndMoveFooter() { // "Terms of Service Privacy Policy Cookie Policy"
  1010. let footer = document.querySelector(`main[role=main] nav[aria-label=${I18N.FOOTER}][role=navigation]`);
  1011. const nav = document.querySelector("nav[aria-label=Primary][role=navigation]"); // I18N."Primary" [?]
  1012.  
  1013. if (footer) {
  1014. footer = footer.parentNode;
  1015. const separatorLine = footer.previousSibling;
  1016.  
  1017. if (Features.footerHandled) {
  1018. footer.remove();
  1019. separatorLine.remove();
  1020. return;
  1021. }
  1022.  
  1023. nav.append(separatorLine);
  1024. nav.append(footer);
  1025. footer.classList.add("ujs-show-on-hover");
  1026. separatorLine.classList.add("ujs-show-on-hover");
  1027.  
  1028. Features.footerHandled = true;
  1029. }
  1030. }
  1031.  
  1032. static hideLoginPopup() { // When you are not logged in
  1033. const targetNode = document.querySelector("html");
  1034. const observerOptions = {
  1035. attributes: true,
  1036. };
  1037. const observer = new MutationObserver(callback);
  1038. observer.observe(targetNode, observerOptions);
  1039.  
  1040. function callback(mutationList, observer) {
  1041. const html = document.querySelector("html");
  1042. console.log(mutationList);
  1043. // overflow-y: scroll; overscroll-behavior-y: none; font-size: 15px; // default
  1044. // overflow: hidden; overscroll-behavior-y: none; font-size: 15px; margin-right: 15px; // popup
  1045. if (html.style["overflow"] === "hidden") {
  1046. html.style["overflow"] = "";
  1047. html.style["overflow-y"] = "scroll";
  1048. html.style["margin-right"] = "";
  1049. }
  1050. const popup = document.querySelector(`#layers div[data-testid="sheetDialog"]`);
  1051. if (popup) {
  1052. popup.closest(`div[role="dialog"]`).remove();
  1053. verbose && (document.title = "⚒" + document.title);
  1054. // observer.disconnect();
  1055. }
  1056. }
  1057. }
  1058.  
  1059. }
  1060.  
  1061. return Features;
  1062. }
  1063.  
  1064. // --- Twitter.RequiredCSS --- //
  1065. function getUserScriptCSS() {
  1066. const labelText = I18N.IMAGE || "Image";
  1067.  
  1068. // By default, the scroll is showed all time, since <html style="overflow-y: scroll;>,
  1069. // so it works — no need to use `getScrollbarWidth` function from SO (13382516).
  1070. const scrollbarWidth = window.innerWidth - document.body.offsetWidth;
  1071.  
  1072. const css = `
  1073. .ujs-hidden {
  1074. display: none;
  1075. }
  1076. .ujs-no-scroll {
  1077. overflow-y: hidden;
  1078. }
  1079. .ujs-scroll-initial {
  1080. overflow-y: initial!important;
  1081. }
  1082. .ujs-scrollbar-width-margin-right {
  1083. margin-right: ${scrollbarWidth}px;
  1084. }
  1085.  
  1086. .ujs-show-on-hover:hover {
  1087. opacity: 1;
  1088. transition: opacity 1s ease-out 0.1s;
  1089. }
  1090. .ujs-show-on-hover {
  1091. opacity: 0;
  1092. transition: opacity 0.5s ease-out;
  1093. }
  1094.  
  1095. :root {
  1096. --ujs-shadow-1: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  1097. --ujs-shadow-2: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  1098. --ujs-shadow-3: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  1099. --ujs-shadow-4: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  1100. --ujs-red: #e0245e;
  1101. --ujs-blue: #1da1f2;
  1102. --ujs-green: #4caf50;
  1103. --ujs-gray: #c2cbd0;
  1104. --ujs-error: white;
  1105. }
  1106.  
  1107. .ujs-progress {
  1108. background-image: linear-gradient(to right, var(--ujs-green) var(--progress), transparent 0%);
  1109. }
  1110.  
  1111. .ujs-shadow {
  1112. background-image: var(--ujs-shadow-1);
  1113. }
  1114. .ujs-btn-download:hover .ujs-hover {
  1115. background-image: var(--ujs-shadow-2);
  1116. }
  1117. .ujs-btn-download.ujs-downloading .ujs-shadow {
  1118. background-image: var(--ujs-shadow-3);
  1119. }
  1120. .ujs-btn-download:active .ujs-shadow {
  1121. background-image: var(--ujs-shadow-4);
  1122. }
  1123.  
  1124. article[role=article]:hover .ujs-btn-download {
  1125. opacity: 1;
  1126. }
  1127. div[aria-label="${labelText}"]:hover .ujs-btn-download {
  1128. opacity: 1;
  1129. }
  1130. .ujs-btn-download.ujs-downloaded {
  1131. opacity: 1;
  1132. }
  1133. .ujs-btn-download.ujs-downloading {
  1134. opacity: 1;
  1135. }
  1136.  
  1137. .ujs-btn-download {
  1138. cursor: pointer;
  1139. top: 6em;
  1140. left: 0.5em;
  1141. position: absolute;
  1142. opacity: 0;
  1143. }
  1144. .ujs-btn-common {
  1145. width: 33px;
  1146. height: 33px;
  1147. border-radius: 0.3em;
  1148. top: 0;
  1149. position: absolute;
  1150. border: 1px solid transparent;
  1151. border-color: var(--ujs-gray);
  1152. ${settings.addBorder ? "border: 2px solid white;" : "border-color: var(--ujs-gray);"}
  1153. }
  1154. .buttonParent {
  1155. position: absolute;
  1156. height: 100%;
  1157. width: 100%;
  1158. top: 0px;
  1159. left: 0px;
  1160. right: 0px;
  1161. }
  1162. .ujs-not-downloaded .ujs-btn-background {
  1163. background: var(--ujs-red);
  1164. }
  1165.  
  1166. .ujs-already-downloaded .ujs-btn-background {
  1167. background: var(--ujs-green);
  1168. }
  1169.  
  1170. .ujs-downloaded .ujs-btn-background {
  1171. background: var(--ujs-green);
  1172. }
  1173.  
  1174. .ujs-error .ujs-btn-background {
  1175. background: var(--ujs-error);
  1176. }
  1177.  
  1178. .ujs-btn-error-text {
  1179. display: flex;
  1180. align-items: center;
  1181. justify-content: center;
  1182. color: black;
  1183. font-size: 100%;
  1184. }`;
  1185. return css.slice(1);
  1186. }
  1187.  
  1188. /*
  1189. Features depend on:
  1190.  
  1191. addRequiredCSS: IMAGE
  1192.  
  1193. expandSpoilers: YES_VIEW_PROFILE, SHOW_NUDITY, VIEW
  1194. handleTitle: QUOTES, ON_TWITTER, TWITTER
  1195. hideSignUpSection: SIGNUP
  1196. hideTrends: TRENDS
  1197. hideTopicsToFollowInstantly: TOPICS_TO_FOLLOW,
  1198.  
  1199. hideTopicsToFollow: TOPICS_TO_FOLLOW,
  1200.  
  1201. [unused]
  1202. hideAndMoveFooter: FOOTER
  1203. */
  1204.  
  1205. // --- Twitter.LangConstants --- //
  1206. function getLanguageConstants() { //todo: "de", "fr"
  1207. const defaultQuotes = [`"`, `"`];
  1208.  
  1209. const SUPPORTED_LANGUAGES = ["en", "ru", "es", "zh", "ja", ];
  1210.  
  1211. // texts
  1212. const VIEW = ["View", "Посмотреть", "Ver", "查看", "表示", ];
  1213. const YES_VIEW_PROFILE = ["Yes, view profile", "Да, посмотреть профиль", "Sí, ver perfil", "是,查看个人资料", "プロフィールを表示する", ];
  1214.  
  1215. const SHOW_NUDITY = ["Show", "Показать", "Mostrar", "显示", "表示", ];
  1216. // aria-label texts
  1217. const IMAGE = ["Image", "Изображение", "Imagen", "图像", "画像", ];
  1218. const SIGNUP = ["Sign up", "Зарегистрироваться", "Regístrate", "注册", "アカウント作成", ];
  1219. const TRENDS = ["Timeline: Trending now", "Лента: Актуальные темы", "Cronología: Tendencias del momento", "时间线:当前趋势", "タイムライン: トレンド", ];
  1220. const TOPICS_TO_FOLLOW = ["Timeline: ", "Лента: ", "Cronología: ", "时间线:", /*[1]*/ "タイムライン: ", /*[1]*/ ];
  1221. const WHO_TO_FOLLOW = ["Who to follow", "Кого читать", "A quién seguir", "推荐关注", "おすすめユーザー" ];
  1222. const FOOTER = ["Footer", "Нижний колонтитул", "Pie de página", "页脚", "フッター", ];
  1223. // *1 — it's a suggestion, need to recheck. But I can't find a page where I can check it. Was it deleted?
  1224.  
  1225. // document.title "{AUTHOR}{ON_TWITTER} {QUOTES[0]}{TEXT}{QUOTES[1]} / {TWITTER}"
  1226. const QUOTES = [defaultQuotes, [`«`, `»`], defaultQuotes, defaultQuotes, [`「`, `」`], ];
  1227. const ON_TWITTER = [" on Twitter:", " в Твиттере:", " en Twitter:", " 在 Twitter:", "さんはTwitterを使っています", ];
  1228. const TWITTER = ["Twitter", "Твиттер", "Twitter", "Twitter", "Twitter", ];
  1229.  
  1230. const lang = document.querySelector("html").getAttribute("lang");
  1231. const langIndex = SUPPORTED_LANGUAGES.indexOf(lang);
  1232.  
  1233. return {
  1234. SUPPORTED_LANGUAGES,
  1235. VIEW: VIEW[langIndex],
  1236. YES_VIEW_PROFILE: YES_VIEW_PROFILE[langIndex],
  1237. SIGNUP: SIGNUP[langIndex],
  1238. TRENDS: TRENDS[langIndex],
  1239. TOPICS_TO_FOLLOW: TOPICS_TO_FOLLOW[langIndex],
  1240. WHO_TO_FOLLOW: WHO_TO_FOLLOW[langIndex],
  1241. FOOTER: FOOTER[langIndex],
  1242. QUOTES: QUOTES[langIndex],
  1243. ON_TWITTER: ON_TWITTER[langIndex],
  1244. TWITTER: TWITTER[langIndex],
  1245. IMAGE: IMAGE[langIndex],
  1246. SHOW_NUDITY: SHOW_NUDITY[langIndex],
  1247. }
  1248. }
  1249.  
  1250. // --- Twitter.Tweet --- //
  1251. function hoistTweet() {
  1252. class Tweet {
  1253. constructor({elem, url}) {
  1254. if (url) {
  1255. this.elem = null;
  1256. this.url = url;
  1257. } else {
  1258. this.elem = elem;
  1259. this.url = Tweet.getUrl(elem);
  1260. }
  1261. }
  1262.  
  1263. static of(innerElem) {
  1264. // Workaround for media from a quoted tweet
  1265. const url = innerElem.closest(`a[href^="/"]`)?.href;
  1266. if (url && url.includes("/status/")) {
  1267. return new Tweet({url});
  1268. }
  1269.  
  1270. const elem = innerElem.closest(`[data-testid="tweet"]`);
  1271. if (!elem) { // opened image
  1272. verbose && console.log("no-tweet elem");
  1273. }
  1274. return new Tweet({elem});
  1275. }
  1276.  
  1277. static getUrl(elem) {
  1278. if (!elem) { // if opened image
  1279. return location.href;
  1280. }
  1281.  
  1282. const tweetAnchor = [...elem.querySelectorAll("a")].find(el => {
  1283. return el.childNodes[0]?.nodeName === "TIME";
  1284. });
  1285.  
  1286. if (tweetAnchor) {
  1287. return tweetAnchor.href;
  1288. }
  1289. // else if selected tweet
  1290. return location.href;
  1291. }
  1292.  
  1293. get author() {
  1294. console.log("_____________URL: " + this.url);
  1295. return this.url.match(/(?<=twitter\.com\/).+?(?=\/)/)?.[0];
  1296. }
  1297.  
  1298. get id() {
  1299. return this.url.match(/(?<=\/status\/)\d+/)?.[0];
  1300. }
  1301. }
  1302.  
  1303. return Tweet;
  1304. }
  1305.  
  1306. // --- Twitter.API --- //
  1307. function hoistAPI() {
  1308. class API {
  1309. static guestToken = getCookie("gt");
  1310. static csrfToken = getCookie("ct0"); // todo: lazy — not available at the first run
  1311. // Guest/Suspended account Bearer token
  1312. static guestAuthorization = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
  1313.  
  1314. // Seems to be outdated at 2022.05
  1315. static async _requestBearerToken() {
  1316. const scriptSrc = [...document.querySelectorAll("script")]
  1317. .find(el => el.src.match(/https:\/\/abs\.twimg\.com\/responsive-web\/client-web\/main[\w.]*\.js/)).src;
  1318.  
  1319. let text;
  1320. try {
  1321. text = await (await fetch(scriptSrc)).text();
  1322. } catch (e) {
  1323. console.error(e, scriptSrc);
  1324. throw e;
  1325. }
  1326.  
  1327. const authorizationKey = text.match(/(?<=")AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D.+?(?=")/)[0];
  1328. const authorization = `Bearer ${authorizationKey}`;
  1329.  
  1330. return authorization;
  1331. }
  1332.  
  1333. static async getAuthorization() {
  1334. if (!API.authorization) {
  1335. API.authorization = await API._requestBearerToken();
  1336. }
  1337. return API.authorization;
  1338. }
  1339.  
  1340. static async apiRequest(url) {
  1341. const _url = url.toString();
  1342. console.log("[ujs][apiRequest]", _url);
  1343.  
  1344. // Hm... it is always the same. Even for a logged user.
  1345. // const authorization = API.guestToken ? API.guestAuthorization : await API.getAuthorization();
  1346. const authorization = API.guestAuthorization;
  1347.  
  1348. // for debug
  1349. verbose && sessionStorage.setItem("guestAuthorization", API.guestAuthorization);
  1350. verbose && sessionStorage.setItem("authorization", API.authorization);
  1351. verbose && sessionStorage.setItem("x-csrf-token", API.csrfToken);
  1352. verbose && sessionStorage.setItem("x-guest-token", API.guestToken);
  1353.  
  1354. const headers = new Headers({
  1355. authorization,
  1356. "x-csrf-token": API.csrfToken,
  1357. "x-twitter-client-language": "en",
  1358. "x-twitter-active-user": "yes"
  1359. });
  1360. if (API.guestToken) {
  1361. headers.append("x-guest-token", API.guestToken);
  1362. } else { // may be skipped
  1363. headers.append("x-twitter-auth-type", "OAuth2Session");
  1364. }
  1365.  
  1366. let json;
  1367. try {
  1368. //const response = await fetch(_url, {headers});
  1369. const response = await fetch(_url, {headers});
  1370. json = await response.json();
  1371. //console.log("_____________[apiRequest]" + JSON.stringify(json, null, 2));
  1372. } catch (e) {
  1373. console.error(e, _url);
  1374. throw e;
  1375. }
  1376.  
  1377. //console.log("____JSON: " + JSON.stringify(json, null, 2));
  1378.  
  1379. // verbose && console.log("[ujs][apiRequest]", JSON.stringify(json, null, " "));
  1380. // 429 - [{code: 88, message: "Rate limit exceeded"}] — for suspended accounts
  1381.  
  1382. return json;
  1383. }
  1384.  
  1385. static async getAuthorName(button)
  1386. {
  1387. let status_id = Tweet.of(button).id;
  1388. //console.log("___getAuthorName status_id " + status_id);
  1389. let url = ' https://twitter.com/i/api/2/timeline/conversation/' + status_id + '.json?tweet_mode=extended&include_entities=false&include_user_entities=false';
  1390. //console.log("___getAuthorName url " + url);
  1391. const json = await API.apiRequest(url);
  1392. //console.log("___JSON " + JSON.stringify(json, null, 2));
  1393.  
  1394. let tweet = json.globalObjects.tweets[status_id];
  1395. let user = json.globalObjects.users[tweet.user_id_str];
  1396. let invalid_chars = {'\\': '\', '\/': '/', '\|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"', '\u200b': '', '\u200c': '', '\u200d': '', '\u2060': '', '\ufeff': '', '🔞': ''};
  1397.  
  1398. let authorName = user.name.replace(/([\\/|*?:"]|[\u200b-\u200d\u2060\ufeff]|🔞)/g, v => invalid_chars[v]);
  1399.  
  1400. console.log("___authorName : " + authorName);
  1401. return authorName;
  1402. }
  1403.  
  1404. // @return {bitrate, content_type, url, vidNumber, authorName}
  1405. static async getVideoInfo(tweetId, screenName, posterUrl) {
  1406. console.log("___getVideoInfo");
  1407. const url = API.createVideoEndpointUrl(tweetId);
  1408.  
  1409. const json = await API.apiRequest(url);
  1410. //console.log("[getVideoInfo]", json, JSON.stringify(json, null, 2));
  1411.  
  1412. const instruction = json.data.threaded_conversation_with_injections_v2.instructions.find(ins => ins.type === "TimelineAddEntries");
  1413. const tweetEntry = instruction.entries.find(ins => ins.entryId === "tweet-" + tweetId);
  1414. const tweetResult = tweetEntry.content.itemContent.tweet_results.result
  1415. // important
  1416. let tweetData = tweetResult.legacy;
  1417.  
  1418. const isVideoInQuotedPost = !tweetData.extended_entities || tweetData.extended_entities.media.findIndex(e => e.media_url_https === posterUrl) === -1;
  1419. if (tweetData.quoted_status_id_str && isVideoInQuotedPost) {
  1420. const tweetDataQuoted = tweetResult.quoted_status_result.result.legacy;
  1421. const tweetDataQuotedCore = tweetResult.quoted_status_result.result.core.user_results.result.legacy;
  1422.  
  1423. tweetId = tweetData.quoted_status_id_str;
  1424. screenName = tweetDataQuotedCore.screen_name;
  1425. tweetData = tweetDataQuoted;
  1426. console.leg("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
  1427. }
  1428.  
  1429. // types: "photo", "video", "animated_gif"
  1430.  
  1431. let vidNumber = tweetData.extended_entities.media
  1432. .filter(e => e.type !== "photo")
  1433. .findIndex(e => e.media_url_https === posterUrl);
  1434.  
  1435. let mediaIndex = tweetData.extended_entities.media
  1436. .findIndex(e => e.media_url_https === posterUrl);
  1437.  
  1438. if (vidNumber === -1 || mediaIndex === -1) {
  1439. verbose && console.log("[ujs][warning]: vidNumber === -1 || mediaIndex === -1");
  1440. vidNumber = 0;
  1441. mediaIndex = 0;
  1442. }
  1443. const videoVariants = tweetData.extended_entities.media[mediaIndex].video_info.variants;
  1444. verbose && console.log("[getVideoInfo]", videoVariants);
  1445.  
  1446. const video = videoVariants
  1447. .filter(el => el.bitrate !== undefined) // if content_type: "application/x-mpegURL" // .m3u8
  1448. .reduce((acc, cur) => cur.bitrate > acc.bitrate ? cur : acc);
  1449.  
  1450. if (!video) {
  1451. throw new Error("No video URL");
  1452. }
  1453.  
  1454. let authorName = json.data.threaded_conversation_with_injections_v2.instructions[0].entries[0].content.itemContent.tweet_results.result.core.user_results.result.legacy.name;
  1455. console.log("___authorName: " + authorName);
  1456. return {video, tweetId, screenName, vidNumber, authorName};
  1457. }
  1458.  
  1459. // todo: keep `queryId` updated
  1460. static TweetDetailQueryId = "3XDB26fBve-MmjHaWTUZxA"; // TweetDetail (for videos)
  1461. static UserByScreenNameQueryId = "oUZZZ8Oddwxs8Cd3iW3UEA"; // UserByScreenName (for the direct user profile url)
  1462.  
  1463. static createVideoEndpointUrl(tweetId) {
  1464. const variables = {
  1465. "focalTweetId": tweetId,
  1466. "with_rux_injections": false,
  1467. "includePromotedContent": true,
  1468. "withCommunity": true,
  1469. "withQuickPromoteEligibilityTweetFields": true,
  1470. "withBirdwatchNotes": true,
  1471. "withVoice": true,
  1472. "withV2Timeline": true
  1473. };
  1474. const features = {
  1475. "rweb_lists_timeline_redesign_enabled": true,
  1476. "responsive_web_graphql_exclude_directive_enabled": true,
  1477. "verified_phone_label_enabled": false,
  1478. "creator_subscriptions_tweet_preview_api_enabled": true,
  1479. "responsive_web_graphql_timeline_navigation_enabled": true,
  1480. "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
  1481. "tweetypie_unmention_optimization_enabled": true,
  1482. "responsive_web_edit_tweet_api_enabled": true,
  1483. "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
  1484. "view_counts_everywhere_api_enabled": true,
  1485. "longform_notetweets_consumption_enabled": true,
  1486. "responsive_web_twitter_article_tweet_consumption_enabled": false,
  1487. "tweet_awards_web_tipping_enabled": false,
  1488. "freedom_of_speech_not_reach_fetch_enabled": true,
  1489. "standardized_nudges_misinfo": true,
  1490. "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
  1491. "longform_notetweets_rich_text_read_enabled": true,
  1492. "longform_notetweets_inline_media_enabled": true,
  1493. "responsive_web_media_download_video_enabled": false,
  1494. "responsive_web_enhance_cards_enabled": false
  1495. };
  1496. const fieldToggles = {
  1497. "withArticleRichContentState": false
  1498. };
  1499.  
  1500. const urlBase = `https://twitter.com/i/api/graphql/${API.TweetDetailQueryId}/TweetDetail`;
  1501. const urlObj = new URL(urlBase);
  1502. urlObj.searchParams.set("variables", JSON.stringify(variables));
  1503. urlObj.searchParams.set("features", JSON.stringify(features));
  1504. urlObj.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
  1505. const url = urlObj.toString();
  1506. return url;
  1507. }
  1508.  
  1509. static async getUserInfo(username) {
  1510. const variables = JSON.stringify({
  1511. "screen_name": username,
  1512. "withSafetyModeUserFields": true,
  1513. "withSuperFollowsUserFields": true
  1514. });
  1515. const url = `https://twitter.com/i/api/graphql/${API.UserByScreenNameQueryId}/UserByScreenName?variables=${encodeURIComponent(variables)}`;
  1516. const json = await API.apiRequest(url);
  1517. verbose && console.log("[getUserInfo]", json);
  1518. return json.data.user.result.legacy.entities.url?.urls[0].expanded_url;
  1519. }
  1520. }
  1521.  
  1522. return API;
  1523. }
  1524.  
  1525. // ---------------------------------------------------------------------------------------------------------------------
  1526. // ---------------------------------------------------------------------------------------------------------------------
  1527. // --- Common Utils --- //
  1528.  
  1529. // --- LocalStorage util class --- //
  1530. function hoistLS(settings = {}) {
  1531. const {
  1532. verbose, // debug "messages" in the document.title
  1533. } = settings;
  1534.  
  1535. class LS {
  1536. constructor(name) {
  1537. this.name = name;
  1538. }
  1539. getItem(defaultValue) {
  1540. return LS.getItem(this.name, defaultValue);
  1541. }
  1542. setItem(value) {
  1543. LS.setItem(this.name, value);
  1544. }
  1545. removeItem() {
  1546. LS.removeItem(this.name);
  1547. }
  1548. async pushItem(value) { // array method
  1549. await LS.pushItem(this.name, value);
  1550. }
  1551. async popItem(value) { // array method
  1552. await LS.popItem(this.name, value);
  1553. }
  1554. hasItem(value) { // array method
  1555. return LS.hasItem(this.name, value);
  1556. }
  1557.  
  1558. static getItem(name, defaultValue) {
  1559. const value = localStorage.getItem(name);
  1560. if (value === undefined) {
  1561. return undefined;
  1562. }
  1563. if (value === null) { // when there is no such item
  1564. LS.setItem(name, defaultValue);
  1565. return defaultValue;
  1566. }
  1567. return JSON.parse(value);
  1568. }
  1569. static setItem(name, value) {
  1570. localStorage.setItem(name, JSON.stringify(value));
  1571. }
  1572. static removeItem(name) {
  1573. localStorage.removeItem(name);
  1574. }
  1575. static async pushItem(name, value) {
  1576. const array = LS.getItem(name, []);
  1577. array.push(value);
  1578. LS.setItem(name, array);
  1579.  
  1580. //sanity check
  1581. await sleep(50);
  1582. if (!LS.hasItem(name, value)) {
  1583. if (verbose) {
  1584. document.title = "🟥" + document.title;
  1585. }
  1586. await LS.pushItem(name, value);
  1587. }
  1588. }
  1589. static async popItem(name, value) { // remove from an array
  1590. const array = LS.getItem(name, []);
  1591. if (array.indexOf(value) !== -1) {
  1592. array.splice(array.indexOf(value), 1);
  1593. LS.setItem(name, array);
  1594.  
  1595. //sanity check
  1596. await sleep(50);
  1597. if (LS.hasItem(name, value)) {
  1598. if (verbose) {
  1599. document.title = "🟨" + document.title;
  1600. }
  1601. await LS.popItem(name, value);
  1602. }
  1603. }
  1604. }
  1605. static hasItem(name, value) { // has in array
  1606. const array = LS.getItem(name, []);
  1607. return array.indexOf(value) !== -1;
  1608. }
  1609. }
  1610.  
  1611. return LS;
  1612. }
  1613.  
  1614. // --- Just groups them in a function for the convenient code looking --- //
  1615. function getUtils({verbose}) {
  1616. function sleep(time) {
  1617. return new Promise(resolve => setTimeout(resolve, time));
  1618. }
  1619.  
  1620. async function fetchResource(url, onProgress = props => console.log(props)) {
  1621. try {
  1622. let response = await fetch(url, {
  1623. // cache: "force-cache",
  1624. });
  1625. const lastModifiedDateSeconds = response.headers.get("last-modified");
  1626. const contentType = response.headers.get("content-type");
  1627.  
  1628. const lastModifiedDate = dateToDayDateString(lastModifiedDateSeconds);
  1629. const extension = contentType ? extensionFromMime(contentType) : null;
  1630.  
  1631. if (onProgress) {
  1632. response = responseProgressProxy(response, onProgress);
  1633. }
  1634.  
  1635. const blob = await response.blob();
  1636.  
  1637. // https://pbs.twimg.com/media/AbcdEFgijKL01_9?format=jpg&name=orig -> AbcdEFgijKL01_9
  1638. // https://pbs.twimg.com/ext_tw_video_thumb/1234567890123456789/pu/img/Ab1cd2345EFgijKL.jpg?name=orig -> Ab1cd2345EFgijKL.jpg
  1639. // https://video.twimg.com/ext_tw_video/1234567890123456789/pu/vid/946x720/Ab1cd2345EFgijKL.mp4?tag=10 -> Ab1cd2345EFgijKL.mp4
  1640. const _url = new URL(url);
  1641. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  1642.  
  1643. const {name} = filename.match(/(?<name>^[^.]+)/).groups;
  1644. return {blob, lastModifiedDate, contentType, extension, name};
  1645. } catch (error) {
  1646. verbose && console.error("[fetchResource]", url, error);
  1647. throw error;
  1648. }
  1649. }
  1650.  
  1651. function extensionFromMime(mimeType) {
  1652. let extension = mimeType.match(/(?<=\/).+/)[0];
  1653. extension = extension === "jpeg" ? "jpg" : extension;
  1654. return extension;
  1655. }
  1656.  
  1657. // the original download url will be posted as hash of the blob url, so you can check it in the download manager's history
  1658. function downloadBlob(blob, name, url) {
  1659. const anchor = document.createElement("a");
  1660. anchor.setAttribute("download", name || "");
  1661. const blobUrl = URL.createObjectURL(blob);
  1662. anchor.href = blobUrl + (url ? ("#" + url) : "");
  1663. anchor.click();
  1664. setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
  1665. }
  1666.  
  1667. // "Sun, 10 Jan 2021 22:22:22 GMT" -> "2021.01.10"
  1668. function dateToDayDateString(dateValue, utc = true) {
  1669. const _date = new Date(dateValue);
  1670. function pad(str) {
  1671. return str.toString().padStart(2, "0");
  1672. }
  1673. const _utc = utc ? "UTC" : "";
  1674. const year = _date[`get${_utc}FullYear`]();
  1675. const month = _date[`get${_utc}Month`]() + 1;
  1676. const date = _date[`get${_utc}Date`]();
  1677.  
  1678. return year + "." + pad(month) + "." + pad(date);
  1679. }
  1680.  
  1681. function addCSS(css) {
  1682. const styleElem = document.createElement("style");
  1683. styleElem.textContent = css;
  1684. document.body.append(styleElem);
  1685. return styleElem;
  1686. }
  1687.  
  1688. function getCookie(name) {
  1689. verbose && console.log(document.cookie);
  1690. const regExp = new RegExp(`(?<=${name}=)[^;]+`);
  1691. return document.cookie.match(regExp)?.[0];
  1692. }
  1693.  
  1694. function throttle(runnable, time = 50) {
  1695. let waiting = false;
  1696. let queued = false;
  1697. let context;
  1698. let args;
  1699.  
  1700. return function() {
  1701. if (!waiting) {
  1702. waiting = true;
  1703. setTimeout(function() {
  1704. if (queued) {
  1705. runnable.apply(context, args);
  1706. context = args = undefined;
  1707. }
  1708. waiting = queued = false;
  1709. }, time);
  1710. return runnable.apply(this, arguments);
  1711. } else {
  1712. queued = true;
  1713. context = this;
  1714. args = arguments;
  1715. }
  1716. }
  1717. }
  1718.  
  1719. function throttleWithResult(func, time = 50) {
  1720. let waiting = false;
  1721. let args;
  1722. let context;
  1723. let timeout;
  1724. let promise;
  1725.  
  1726. return async function() {
  1727. if (!waiting) {
  1728. waiting = true;
  1729. timeout = new Promise(async resolve => {
  1730. await sleep(time);
  1731. waiting = false;
  1732. resolve();
  1733. });
  1734. return func.apply(this, arguments);
  1735. } else {
  1736. args = arguments;
  1737. context = this;
  1738. }
  1739.  
  1740. if (!promise) {
  1741. promise = new Promise(async resolve => {
  1742. await timeout;
  1743. const result = func.apply(context, args);
  1744. args = context = promise = undefined;
  1745. resolve(result);
  1746. });
  1747. }
  1748. return promise;
  1749. }
  1750. }
  1751.  
  1752. function xpath(path, node = document) {
  1753. let xPathResult = document.evaluate(path, node, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  1754. return xPathResult.singleNodeValue;
  1755. }
  1756. function xpathAll(path, node = document) {
  1757. let xPathResult = document.evaluate(path, node, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
  1758. const nodes = [];
  1759. try {
  1760. let node = xPathResult.iterateNext();
  1761.  
  1762. while (node) {
  1763. nodes.push(node);
  1764. node = xPathResult.iterateNext();
  1765. }
  1766. return nodes;
  1767. } catch (e) {
  1768. // todo need investigate it
  1769. console.error(e); // "The document has mutated since the result was returned."
  1770. return [];
  1771. }
  1772. }
  1773.  
  1774. const identityContentEncodings = new Set([null, "identity", "no encoding"]);
  1775. function getOnProgressProps(response) {
  1776. const {headers, status, statusText, url, redirected, ok} = response;
  1777. const isIdentity = identityContentEncodings.has(headers.get("Content-Encoding"));
  1778. const compressed = !isIdentity;
  1779. const _contentLength = parseInt(headers.get("Content-Length")); // `get()` returns `null` if no header present
  1780. const contentLength = isNaN(_contentLength) ? null : _contentLength;
  1781. const lengthComputable = isIdentity && _contentLength !== null;
  1782.  
  1783. // Original XHR behaviour; in TM it equals to `contentLength`, or `-1` if `contentLength` is `null` (and `0`?).
  1784. const total = lengthComputable ? contentLength : 0;
  1785. const gmTotal = contentLength > 0 ? contentLength : -1; // Like `total` is in TM and GM.
  1786.  
  1787. return {
  1788. gmTotal, total, lengthComputable,
  1789. compressed, contentLength,
  1790. headers, status, statusText, url, redirected, ok
  1791. };
  1792. }
  1793. function responseProgressProxy(response, onProgress) {
  1794. const onProgressProps = getOnProgressProps(response);
  1795. let loaded = 0;
  1796. const reader = response.body.getReader();
  1797. const readableStream = new ReadableStream({
  1798. async start(controller) {
  1799. while (true) {
  1800. const {done, /** @type {Uint8Array} */ value} = await reader.read();
  1801. if (done) {
  1802. break;
  1803. }
  1804. loaded += value.length;
  1805. try {
  1806. onProgress({loaded, ...onProgressProps});
  1807. } catch (e) {
  1808. console.error("[onProgress]:", e);
  1809. }
  1810. controller.enqueue(value);
  1811. }
  1812. controller.close();
  1813. reader.releaseLock();
  1814. },
  1815. cancel() {
  1816. void reader.cancel();
  1817. }
  1818. });
  1819. return new ResponseEx(readableStream, response);
  1820. }
  1821. class ResponseEx extends Response {
  1822. [Symbol.toStringTag] = "ResponseEx";
  1823.  
  1824. constructor(body, {headers, status, statusText, url, redirected, type}) {
  1825. super(body, {
  1826. status, statusText, headers: {
  1827. ...headers,
  1828. "content-type": headers.get("content-type").split("; ")[0] // Fixes Blob type ("text/html; charset=UTF-8") in TM
  1829. }
  1830. });
  1831. this._type = type;
  1832. this._url = url;
  1833. this._redirected = redirected;
  1834. this._headers = headers; // `HeadersLike` is more user-friendly for debug than the original `Headers` object
  1835. }
  1836. get redirected() { return this._redirected; }
  1837. get url() { return this._url; }
  1838. get type() { return this._type || "basic"; }
  1839. /** @returns {HeadersLike} */
  1840. get headers() { return this._headers; }
  1841. }
  1842.  
  1843. return {
  1844. sleep, fetchResource, extensionFromMime, downloadBlob, dateToDayDateString,
  1845. addCSS,
  1846. getCookie,
  1847. throttle, throttleWithResult,
  1848. xpath, xpathAll,
  1849. responseProgressProxy,
  1850. }
  1851. }
  1852.  
  1853. // ---------------------------------------------------------------------------------------------------------------------
  1854. // ---------------------------------------------------------------------------------------------------------------------