Greasy Fork 还支持 简体中文。

Twitter Click'n'Save

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

目前為 2022-09-20 提交的版本,檢視 最新版本

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