Twitter Click'n'Save

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

当前为 2022-09-30 提交的版本,查看 最新版本

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

QingJ © 2025

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