Twitter Click'n'Save

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

当前为 2023-07-04 提交的版本,查看 最新版本

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

QingJ © 2025

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