Twitter Click'n'Save

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

当前为 2021-07-30 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Click'n'Save
  3. // @version 0.3.2
  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. // ==/UserScript==
  10. // ---------------------------------------------------------------------------------------------------------------------
  11. // ---------------------------------------------------------------------------------------------------------------------
  12.  
  13.  
  14. // --- Features to execute --- //
  15. function execFeaturesOnce() {
  16. Features.addRequiredCSS();
  17. Features.hideSignUpBottomBar();
  18. Features.hideTrends();
  19. Features.highlightVisitedLinks();
  20. Features.hideTopicsToFollowInstantly();
  21. }
  22. function execFeaturesImmediately() {
  23. Features.expandSpoilers();
  24. }
  25. function execFeatures() {
  26. Features.imagesHandler();
  27. Features.videoHandler();
  28. Features.expandSpoilers();
  29. Features.hideSignUpSection();
  30. Features.hideTopicsToFollow();
  31. Features.directLinks();
  32. Features.handleTitle();
  33. }
  34.  
  35. // ---------------------------------------------------------------------------------------------------------------------
  36. // ---------------------------------------------------------------------------------------------------------------------
  37.  
  38.  
  39. // --- For debug --- //
  40. const verbose = false;
  41.  
  42.  
  43. // --- [Violentmonkey + Firefox 90 + Strict Tracking Protection] fix --- //
  44. const fetch = (globalThis.wrappedJSObject && typeof globalThis.wrappedJSObject.fetch === "function") ? function(resource, init) {
  45. return globalThis.wrappedJSObject.fetch(resource, cloneInto(init, document));
  46. } : globalThis.fetch;
  47.  
  48.  
  49. // --- "Imports" --- //
  50. const {
  51. sleep, fetchResource, download,
  52. addCSS,
  53. getCookie,
  54. throttle,
  55. xpath, xpathAll,
  56. getNearestElementByType, getParentWithSiblingDataset,
  57. } = getUtils({verbose});
  58. const LS = hoistLS({verbose});
  59.  
  60. const API = hoistAPI();
  61. const Tweet = hoistTweet();
  62. const Features = hoistFeatures();
  63. const I18N = getLanguageConstants();
  64.  
  65.  
  66. // --- That to use for the image history --- //
  67. // "TWEET_ID" or "IMAGE_NAME"
  68. const imagesHistoryBy = LS.getItem("ujs-images-history-by", "IMAGE_NAME");
  69. // With "TWEET_ID" downloading of 1 image of 4 will mark all 4 images as "already downloaded"
  70. // on the next time when the tweet will appear.
  71. // "IMAGE_NAME" will count each image of a tweet, but it will take more data to store.
  72.  
  73.  
  74. // ---------------------------------------------------------------------------------------------------------------------
  75. // ---------------------------------------------------------------------------------------------------------------------
  76. // --- Script runner --- //
  77.  
  78. (function starter(feats) {
  79. const {once, onChangeImmediate, onChange} = feats;
  80.  
  81. once();
  82. onChangeImmediate();
  83. const onChangeThrottled = throttle(onChange, 250);
  84. onChangeThrottled();
  85.  
  86. const targetNode = document.querySelector("body");
  87. const observerOptions = {
  88. subtree: true,
  89. childList: true,
  90. };
  91. const observer = new MutationObserver(callback);
  92. observer.observe(targetNode, observerOptions);
  93.  
  94. function callback(mutationList, observer) {
  95. verbose && console.log(mutationList);
  96. onChangeImmediate();
  97. onChangeThrottled();
  98. }
  99. })({
  100. once: execFeaturesOnce,
  101. onChangeImmediate: execFeaturesImmediately,
  102. onChange: execFeatures
  103. });
  104.  
  105. // ---------------------------------------------------------------------------------------------------------------------
  106. // ---------------------------------------------------------------------------------------------------------------------
  107. // --- Twitter Specific code --- //
  108.  
  109.  
  110. const downloadedImages = new LS("ujs-twitter-downloaded-images-names");
  111. const downloadedImageTweetIds = new LS("ujs-twitter-downloaded-image-tweet-ids");
  112. const downloadedVideoTweetIds = new LS("ujs-twitter-downloaded-video-tweet-ids");
  113.  
  114. // --- Twitter.Features --- //
  115. function hoistFeatures() {
  116. class Features {
  117. static _ImageHistory = class {
  118. static getImageNameFromUrl(url) {
  119. const _url = new URL(url);
  120. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  121. return filename.match(/^[^\.]+/)[0]; // remove extension
  122. }
  123. static isDownloaded({id, url}) {
  124. if (imagesHistoryBy === "TWEET_ID") {
  125. return downloadedImageTweetIds.hasItem(id);
  126. } else if (imagesHistoryBy === "IMAGE_NAME") {
  127. const name = Features._ImageHistory.getImageNameFromUrl(url);
  128. return downloadedImages.hasItem(name);
  129. }
  130. }
  131. static async markDownloaded({id, url}) {
  132. if (imagesHistoryBy === "TWEET_ID") {
  133. await downloadedImageTweetIds.pushItem(id);
  134. } else if (imagesHistoryBy === "IMAGE_NAME") {
  135. const name = Features._ImageHistory.getImageNameFromUrl(url);
  136. await downloadedImages.pushItem(name);
  137. }
  138. }
  139. }
  140. static async imagesHandler() {
  141. const images = document.querySelectorAll("img");
  142. for (const img of images) {
  143.  
  144. if (img.width < 200 || img.dataset.handled) {
  145. continue;
  146. }
  147. verbose && console.log(img, img.width);
  148.  
  149. img.dataset.handled = "true";
  150.  
  151. const btn = document.createElement("div");
  152. btn.classList.add("ujs-btn-download");
  153. btn.dataset.url = img.src;
  154.  
  155. btn.addEventListener("click", Features._imageClickHandler);
  156.  
  157. let anchor = getNearestElementByType(img, "a");
  158. // if an image is _opened_ "https://twitter.com/UserName/status/1234567890123456789/photo/1" [fake-url]
  159. if (!anchor) {
  160. anchor = img.parentNode;
  161. }
  162. anchor.append(btn);
  163.  
  164. const downloaded = Features._ImageHistory.isDownloaded({
  165. id: Tweet.of(btn).id,
  166. url: btn.dataset.url
  167. });
  168. if (downloaded) {
  169. btn.classList.add("ujs-already-downloaded");
  170. }
  171. }
  172. }
  173. static async _imageClickHandler(event) {
  174. event.preventDefault();
  175. event.stopImmediatePropagation();
  176.  
  177. const btn = event.currentTarget;
  178. const url = handleImgUrl(btn.dataset.url);
  179. verbose && console.log(url);
  180.  
  181. function handleImgUrl(url) {
  182. const urlObj = new URL(url);
  183. urlObj.searchParams.set("name", "orig");
  184. return urlObj.toString();
  185. }
  186.  
  187. const {id, author} = Tweet.of(btn);
  188. verbose && console.log(id, author);
  189.  
  190.  
  191. btn.classList.add("ujs-downloading");
  192. const {blob, lastModifiedDate, extension, name} = await fetchResource(url);
  193.  
  194. const filename = `[twitter] ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
  195. download(blob, filename, url);
  196.  
  197. const downloaded = btn.classList.contains("already-downloaded");
  198. if (!downloaded) {
  199. await Features._ImageHistory.markDownloaded({id, url});
  200. }
  201. btn.classList.remove("ujs-downloading");
  202. btn.classList.add("ujs-downloaded");
  203. }
  204.  
  205.  
  206. static async videoHandler() {
  207. const videos = document.querySelectorAll("video");
  208.  
  209. for (const vid of videos) {
  210. if (vid.dataset.handled) {
  211. continue;
  212. }
  213. verbose && console.log(vid);
  214. vid.dataset.handled = "true";
  215.  
  216. const btn = document.createElement("div");
  217. btn.classList.add("ujs-btn-download");
  218. btn.classList.add("ujs-video");
  219. btn.addEventListener("click", Features._videoClickHandler);
  220.  
  221. let elem = vid.parentNode.parentNode.parentNode;
  222. elem.after(btn);
  223.  
  224. const id = Tweet.of(btn).id;
  225. const downloaded = downloadedVideoTweetIds.hasItem(id);
  226. if (downloaded) {
  227. btn.classList.add("ujs-already-downloaded");
  228. }
  229. }
  230. }
  231. static async _videoClickHandler(event) {
  232. event.preventDefault();
  233. event.stopImmediatePropagation();
  234.  
  235. const btn = event.currentTarget;
  236. const {id, author} = Tweet.of(btn);
  237. const video = await API.getVideoInfo(id); // {bitrate, content_type, url}
  238. verbose && console.log(video);
  239.  
  240. btn.classList.add("ujs-downloading");
  241. const url = video.url;
  242. const {blob, lastModifiedDate, extension, name} = await fetchResource(url);
  243.  
  244. const filename = `[twitter] ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
  245. download(blob, filename, url);
  246.  
  247. const downloaded = btn.classList.contains("ujs-already-downloaded");
  248. if (!downloaded) {
  249. await downloadedVideoTweetIds.pushItem(id);
  250. }
  251. btn.classList.remove("ujs-downloading");
  252. btn.classList.add("ujs-downloaded");
  253. }
  254.  
  255.  
  256. static addRequiredCSS() {
  257. addCSS(getUserScriptCSS());
  258. }
  259.  
  260. // it depends of `directLinks()` // use only it after `directLinks()`
  261. // it looks it sometimes does not work correctly, probably it executes before `directLinks`.
  262. // todo: keep short urls and rerun this (Note: with the original title) after `directLinks` handled them.
  263. static handleTitle() {
  264. // if not a opened tweet
  265. if (!location.href.match(/twitter\.com\/[^\/]+\/status\/\d+/)) {
  266. return;
  267. }
  268.  
  269. let titleText = document.title;
  270. if (titleText === Features.lastHandledTitle) {
  271. return;
  272. }
  273.  
  274. const [OPEN_QUOTE, CLOSE_QUOTE] = I18N.QUOTES;
  275. const urlsToReplace = [
  276. ...titleText.matchAll(new RegExp(`https:\\/\\/t\\.co\\/[^ ${CLOSE_QUOTE}]+`, "g"))
  277. ].map(el => el[0]);
  278. // the last one may be the URL to the tweet // or to an embedded shared URL
  279.  
  280. const map = new Map();
  281. const anchors = document.querySelectorAll(`a[data-redirect^="https://t.co/"]`);
  282. for (const anchor of anchors) {
  283. if (urlsToReplace.includes(anchor.dataset.redirect)) {
  284. map.set(anchor.dataset.redirect, anchor.href);
  285. }
  286. }
  287.  
  288. const lastUrl = urlsToReplace.slice(-1)[0];
  289. let lastUrlIsAttachment = false;
  290. let attachmentDescription = "";
  291. if (!map.has(lastUrl)) {
  292. const a = document.querySelector(`a[href="${lastUrl}?amp=1"]`);
  293. if (a) {
  294. lastUrlIsAttachment = true;
  295. attachmentDescription = document.querySelectorAll(`a[href="${lastUrl}?amp=1"]`)[1].innerText;
  296. attachmentDescription = attachmentDescription.replaceAll("\n", " — ");
  297. }
  298. }
  299.  
  300.  
  301. for (const [key, value] of map.entries()) {
  302. titleText = titleText.replaceAll(key, value + ` (${key})`);
  303. }
  304.  
  305. titleText = titleText.replace(new RegExp(` ${I18N.ON_TWITTER}(?=: ${OPEN_QUOTE})`), "");
  306. titleText = titleText.replace(new RegExp(`(?<=${CLOSE_QUOTE}) \\\/ ${I18N.TWITTER}$`), "");
  307. if (!lastUrlIsAttachment) {
  308. const regExp = new RegExp(`(?<short> https:\\/\\/t\\.co\\/.{6,14})${CLOSE_QUOTE}$`);
  309. titleText = titleText.replace(regExp, (match, p1, p2, offset, string) => `${CLOSE_QUOTE} ${p1}`);
  310. } else {
  311. titleText = titleText.replace(lastUrl, `${lastUrl} (${attachmentDescription})`);
  312. }
  313. document.title = titleText; // Note: some characters will be removed automatically (`\n`, extra spaces)
  314. Features.lastHandledTitle = document.title;
  315. }
  316. static lastHandledTitle = "";
  317.  
  318.  
  319. static directLinks() {
  320. const anchors = xpathAll(`.//a[@dir="ltr" and child::span and not(@data-handled)]`);
  321. for (const anchor of anchors) {
  322. const redirectUrl = new URL(anchor.href);
  323. anchor.dataset.redirect = redirectUrl.origin + redirectUrl.pathname; // remove "?amp=1"
  324. anchor.dataset.handled = "true";
  325.  
  326. const nodes = xpathAll(`./span[text() != "…"]|./text()`, anchor);
  327. const url = nodes.map(node => node.textContent).join("");
  328. anchor.href = url;
  329. anchor.rel = "nofollow noopener noreferrer";
  330. }
  331. }
  332.  
  333. // Do NOT throttle it
  334. static expandSpoilers() {
  335. const main = document.querySelector("main[role=main]");
  336. if (!main) {
  337. return;
  338. }
  339.  
  340. const a = main.querySelectorAll("[data-testid=primaryColumn] [role=button]")
  341. a && [...a]
  342. .find(el => el.textContent === I18N.YES_VIEW_PROFILE)
  343. ?.click();
  344.  
  345. // todo: expand spoiler commentary in photo view mode (.../photo/1)
  346. const b = main.querySelectorAll("article article[role=article] [role=button]");
  347. b && [...b]
  348. .filter(el => el.textContent === I18N.VIEW)
  349. .forEach(el => el.click());
  350. }
  351.  
  352. static hideSignUpSection() { // "New to Twitter?"
  353. if (!I18N.SIGNUP) { return; }
  354. const elem = document.querySelector(`section[aria-label="${I18N.SIGNUP}"][role=region]`);
  355. if (elem) {
  356. elem.parentNode.classList.add("ujs-hidden");
  357. }
  358. }
  359.  
  360. // Call it once.
  361. // "Don’t miss what’s happening" if you are not logged in.
  362. // It looks that `#layers` is used only for this bar.
  363. static hideSignUpBottomBar() {
  364. addCSS(`
  365. #layers > div:nth-child(1) {
  366. display: none;
  367. }
  368. `);
  369. }
  370.  
  371. // "Trends for you"
  372. static hideTrends() {
  373. if (!I18N.TRENDS) { return; }
  374. addCSS(`
  375. [aria-label="${I18N.TRENDS}"]
  376. {
  377. display: none;
  378. }
  379. `);
  380. }
  381. static highlightVisitedLinks() {
  382. addCSS(`
  383. a:visited {
  384. color: darkorange;
  385. }
  386. `);
  387. }
  388.  
  389.  
  390. // Use it once. To prevent blinking.
  391. static hideTopicsToFollowInstantly() {
  392. if (!I18N.TOPICS_TO_FOLLOW) { return; }
  393. addCSS(`
  394. div[aria-label="${I18N.TOPICS_TO_FOLLOW}"] {
  395. display: none;
  396. }
  397. `);
  398. }
  399. // Hides container and "separator line"
  400. static hideTopicsToFollow() {
  401. if (!I18N.TOPICS_TO_FOLLOW) { return; }
  402. const elem = xpath(`.//section[@role="region" and child::div[@aria-label="${I18N.TOPICS_TO_FOLLOW}"]]/../..`);
  403. if (!elem) {
  404. return;
  405. }
  406. elem.classList.add("ujs-hidden");
  407.  
  408. elem.previousSibling.classList.add("ujs-hidden"); // a "separator line" (empty element of "TRENDS", for example)
  409. // in fact it's a hack // todo rework // may hide "You might like" section [bug]
  410. }
  411.  
  412. // todo split to two methods
  413. // todo fix it, currently it works questionably
  414. // not tested with non eng langs
  415. static footerHandled = false;
  416. static hideAndMoveFooter() { // "Terms of Service Privacy Policy Cookie Policy"
  417. let footer = document.querySelector(`main[role=main] nav[aria-label=${I18N.FOOTER}][role=navigation]`);
  418. const nav = document.querySelector("nav[aria-label=Primary][role=navigation]"); // I18N."Primary" [?]
  419.  
  420. if (footer) {
  421. footer = footer.parentNode;
  422. const separatorLine = footer.previousSibling;
  423.  
  424. if (Features.footerHandled) {
  425. footer.remove();
  426. separatorLine.remove();
  427. return;
  428. }
  429.  
  430. nav.append(separatorLine);
  431. nav.append(footer);
  432. footer.classList.add("ujs-show-on-hover");
  433. separatorLine.classList.add("ujs-show-on-hover");
  434.  
  435. Features.footerHandled = true;
  436. }
  437. }
  438. }
  439. return Features;
  440. }
  441.  
  442. // --- Twitter.RequiredCSS --- //
  443. function getUserScriptCSS() {
  444. const labelText = I18N.IMAGE || "Image";
  445. const css = `
  446. .ujs-hidden {
  447. display: none;
  448. }
  449. .ujs-show-on-hover:hover {
  450. opacity: 1;
  451. transition: opacity 1s ease-out 0.1s;
  452. }
  453. .ujs-show-on-hover {
  454. opacity: 0;
  455. transition: opacity 0.5s ease-out;
  456. }
  457. .ujs-btn-download {
  458. cursor: pointer;
  459. top: 0.5em;
  460. left: 0.5em;
  461. width: 33px;
  462. height: 33px;
  463. background: #e0245e; /*red*/
  464. opacity: 0;
  465. position: absolute;
  466. border-radius: 0.3em;
  467. background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  468. }
  469. article[role=article]:hover .ujs-btn-download {
  470. opacity: 1;
  471. }
  472. div[aria-label="${labelText}"]:hover .ujs-btn-download {
  473. opacity: 1;
  474. }
  475. .ujs-btn-download.ujs-downloaded {
  476. background: #4caf50; /*green*/
  477. background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  478. opacity: 1;
  479. }
  480. .ujs-btn-download.ujs-video {
  481. left: calc(0.5em + 33px + 3px);
  482. }
  483. article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded) {
  484. background: #1da1f2; /*blue*/
  485. background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  486. }
  487. div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded) {
  488. background: #1da1f2; /*blue*/
  489. background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
  490. }
  491. /* -------------------------------------------------------- */
  492. /* Shadow the button on hover, active and while downloading */
  493. .ujs-btn-download:hover {
  494. background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  495. }
  496. .ujs-btn-download:active {
  497. background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  498. }
  499. .ujs-btn-download.ujs-downloading {
  500. background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  501. }
  502. article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded):hover {
  503. background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  504. }
  505. article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded):active {
  506. background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  507. }
  508. article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded).ujs-downloading {
  509. background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  510. }
  511. div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded):hover {
  512. background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
  513. }
  514. div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded):active {
  515. background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
  516. }
  517. div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded).ujs-downloading {
  518. background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
  519. }
  520. /* -------------------------------------------------------- */
  521. `;
  522. return css.replaceAll(" ".repeat(8), "");
  523. }
  524.  
  525. // --- Twitter.LangConstants --- //
  526. function getLanguageConstants() { //todo: "ja", "zh", "de", "fr"
  527. const defaultQuotes = [`"`, `"`];
  528.  
  529. const SUPPORTED_LANGUAGES = ["en", "ru", "es", "zh", ];
  530. const VIEW = ["View", "Посмотреть", "Ver", "查看", ];
  531. const YES_VIEW_PROFILE = ["Yes, view profile", "Да, посмотреть профиль", "Sí, ver perfil", "是,查看个人资料", ];
  532. const SIGNUP = ["Sign up", "Зарегистрироваться", "Regístrate", "注册", ];
  533. const TRENDS = ["Timeline: Trending now", "Лента: Актуальные темы", "Cronología: Tendencias del momento", "时间线:当前趋势", ];
  534. const TOPICS_TO_FOLLOW = ["Timeline: ", "Лента: ", "Cronología: ", "时间线:",/*suggestion*/];
  535. const WHO_TO_FOLLOW = ["Who to follow", "Кого читать", "A quién seguir", "推荐关注", ];
  536. const FOOTER = ["Footer", "Нижний колонтитул", "Pie de página", "页脚", ];
  537. const QUOTES = [defaultQuotes, [`«`, `»`], defaultQuotes, defaultQuotes, ];
  538. const ON_TWITTER = ["on Twitter", "в Твиттере", "en Twitter", "在 Twitter", ];
  539. const TWITTER = ["Twitter", "Твиттер", "Twitter", "Twitter", ];
  540. const IMAGE = ["Image", "Изображение", "Imagen", "图像", ];
  541.  
  542. const lang = document.querySelector("html").getAttribute("lang");
  543. const langIndex = SUPPORTED_LANGUAGES.indexOf(lang);
  544.  
  545. return {
  546. SUPPORTED_LANGUAGES,
  547. VIEW: VIEW[langIndex],
  548. YES_VIEW_PROFILE: YES_VIEW_PROFILE[langIndex],
  549. SIGNUP: SIGNUP[langIndex],
  550. TRENDS: TRENDS[langIndex],
  551. TOPICS_TO_FOLLOW: TOPICS_TO_FOLLOW[langIndex],
  552. WHO_TO_FOLLOW: WHO_TO_FOLLOW[langIndex],
  553. FOOTER: FOOTER[langIndex],
  554. QUOTES: QUOTES[langIndex],
  555. ON_TWITTER: ON_TWITTER[langIndex],
  556. TWITTER: TWITTER[langIndex],
  557. IMAGE: IMAGE[langIndex],
  558. }
  559. }
  560.  
  561. // --- Twitter.Tweet --- //
  562. function hoistTweet() {
  563. class Tweet {
  564. constructor(elem) {
  565. this.elem = elem;
  566. this.url = Tweet.getUrl(elem);
  567. }
  568. static of(innerElem) {
  569. const elem = getParentWithSiblingDataset(innerElem, "testid", "tweet");
  570. if (!elem) { // opened image
  571. verbose && console.log("no-tweet elem");
  572. }
  573. return new Tweet(elem);
  574. }
  575. static getUrl(elem) {
  576. if (!elem) { // if opened image
  577. return location.href;
  578. }
  579.  
  580. const tweetAnchor = [...elem.querySelectorAll("a")].find(el => {
  581. return el.childNodes[0]?.nodeName === "TIME";
  582. });
  583.  
  584. if (tweetAnchor) {
  585. return tweetAnchor.href;
  586. }
  587. // else if selected tweet
  588. return location.href;
  589. }
  590.  
  591. get author() {
  592. return this.url.match(/(?<=twitter\.com\/).+?(?=\/)/)?.[0];
  593. }
  594. get id() {
  595. return this.url.match(/(?<=\/status\/)[^\/]+/)?.[0];
  596. }
  597. }
  598. return Tweet;
  599. }
  600.  
  601. // --- Twitter.API --- //
  602. function hoistAPI() {
  603. class API {
  604. static guestToken = getCookie("gt");
  605. static csrfToken = getCookie("ct0"); // todo: lazy — not available at the first run
  606. // Guest/Suspended account Bearer token
  607. static guestAuthorization = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
  608.  
  609. static async _requestBearerToken() {
  610. const scriptSrc = [...document.querySelectorAll("script")]
  611. .find(el => el.src.match(/https:\/\/abs\.twimg\.com\/responsive-web\/client-web\/main[\w\d\.]*\.js/)).src;
  612. const text = await (await fetch(scriptSrc)).text();
  613. const authorizationKey = text.match(/(?<=")AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D.+?(?=")/)[0];
  614. const authorization = `Bearer ${authorizationKey}`;
  615.  
  616. return authorization;
  617. }
  618.  
  619. static async getAuthorization() {
  620. if (!API.authorization) {
  621. API.authorization = await API._requestBearerToken();
  622. }
  623. return API.authorization;
  624. }
  625.  
  626. // @return {bitrate, content_type, url}
  627. static async getVideoInfo(tweetId) {
  628. // Hm... it always is the same. Even for a logged user.
  629. // const authorization = API.guestToken ? API.guestAuthorization : await API.getAuthorization();
  630. const authorization = API.guestAuthorization;
  631.  
  632. // for debug
  633. verbose && sessionStorage.setItem("guestAuthorization", API.guestAuthorization);
  634. verbose && sessionStorage.setItem("authorization", API.authorization);
  635. verbose && sessionStorage.setItem("x-csrf-token", API.csrfToken);
  636. verbose && sessionStorage.setItem("x-guest-token", API.guestToken);
  637.  
  638. // const url = new URL(`https://api.twitter.com/2/timeline/conversation/${tweetId}.json`); // only for suspended/anon
  639. const url = new URL(`https://twitter.com/i/api/2/timeline/conversation/${tweetId}.json`);
  640. url.searchParams.set("tweet_mode", "extended");
  641.  
  642. const headers = new Headers({
  643. authorization,
  644. "x-csrf-token": API.csrfToken,
  645. });
  646. if (API.guestToken) {
  647. headers.append("x-guest-token", API.guestToken);
  648. } else { // may be skipped
  649. headers.append("x-twitter-active-user", "yes");
  650. headers.append("x-twitter-auth-type", "OAuth2Session");
  651. }
  652.  
  653. const response = await fetch(url, {headers});
  654. const json = await response.json();
  655.  
  656. verbose && console.warn(JSON.stringify(json, null, " "));
  657. // 429 - [{code: 88, message: "Rate limit exceeded"}] — for suspended accounts
  658.  
  659.  
  660. const tweetData = json.globalObjects.tweets[tweetId];
  661. const videoVariants = tweetData.extended_entities.media[0].video_info.variants;
  662. verbose && console.log(videoVariants);
  663.  
  664.  
  665. const video = videoVariants
  666. .filter(el => el.bitrate !== undefined) // if content_type: "application/x-mpegURL" // .m3u8
  667. .reduce((acc, cur) => cur.bitrate > acc.bitrate ? cur : acc);
  668. return video;
  669. }
  670. }
  671. return API;
  672. }
  673.  
  674. // ---------------------------------------------------------------------------------------------------------------------
  675. // ---------------------------------------------------------------------------------------------------------------------
  676. // --- Common Utils --- //
  677.  
  678. // --- LocalStorage util class --- //
  679. function hoistLS(settings = {}) {
  680. const {
  681. verbose, // debug "messages" in the document.title
  682. } = settings;
  683. class LS {
  684. constructor(name) {
  685. this.name = name;
  686. }
  687. getItem(defaultValue) {
  688. return LS.getItem(this.name, defaultValue);
  689. }
  690. setItem(value) {
  691. LS.setItem(this.name, value);
  692. }
  693. removeItem() {
  694. LS.removeItem(this.name);
  695. }
  696. async pushItem(value) { // array method
  697. await LS.pushItem(this.name, value);
  698. }
  699. async popItem(value) { // array method
  700. await LS.popItem(this.name, value);
  701. }
  702. hasItem(value) { // array method
  703. return LS.hasItem(this.name, value);
  704. }
  705.  
  706. static getItem(name, defaultValue) {
  707. const value = localStorage.getItem(name);
  708. if (value === undefined) {
  709. return undefined;
  710. }
  711. if (value === null) { // when there is no such item
  712. LS.setItem(name, defaultValue);
  713. return defaultValue;
  714. }
  715. return JSON.parse(value);
  716. }
  717. static setItem(name, value) {
  718. localStorage.setItem(name, JSON.stringify(value));
  719. }
  720. static removeItem(name) {
  721. localStorage.removeItem(name);
  722. }
  723. static async pushItem(name, value) {
  724. const array = LS.getItem(name, []);
  725. array.push(value);
  726. LS.setItem(name, array);
  727.  
  728. //sanity check
  729. await sleep(50);
  730. if (!LS.hasItem(name, value)) {
  731. if (verbose) {
  732. document.title = "🟥" + document.title;
  733. }
  734. await LS.pushItem(name, value);
  735. }
  736. }
  737. static async popItem(name, value) { // remove from an array
  738. const array = LS.getItem(name, []);
  739. if (array.indexOf(value) !== -1) {
  740. array.splice(array.indexOf(value), 1);
  741. LS.setItem(name, array);
  742.  
  743. //sanity check
  744. await sleep(50);
  745. if (LS.hasItem(name, value)) {
  746. if (verbose) {
  747. document.title = "🟨" + document.title;
  748. }
  749. await LS.popItem(name, value);
  750. }
  751. }
  752. }
  753. static hasItem(name, value) { // has in array
  754. const array = LS.getItem(name, []);
  755. return array.indexOf(value) !== -1;
  756. }
  757. }
  758. return LS;
  759. }
  760.  
  761. // --- Just groups them in a function for the convenient code looking --- //
  762. function getUtils({verbose}) {
  763. function sleep(time) {
  764. return new Promise(resolve => setTimeout(resolve, time));
  765. }
  766.  
  767. async function fetchResource(url) {
  768. try {
  769. const response = await fetch(url, {
  770. cache: "force-cache",
  771. });
  772. const lastModifiedDateSeconds = response.headers.get("last-modified");
  773. const contentType = response.headers.get("content-type");
  774.  
  775. const lastModifiedDate = dateToDayDateString(lastModifiedDateSeconds);
  776. const extension = extensionFromMime(contentType);
  777. const blob = await response.blob();
  778.  
  779. // https://pbs.twimg.com/media/AbcdEFgijKL01_9?format=jpg&name=orig -> AbcdEFgijKL01_9
  780. // https://pbs.twimg.com/ext_tw_video_thumb/1234567890123456789/pu/img/Ab1cd2345EFgijKL.jpg?name=orig -> Ab1cd2345EFgijKL.jpg
  781. // https://video.twimg.com/ext_tw_video/1234567890123456789/pu/vid/946x720/Ab1cd2345EFgijKL.mp4?tag=10 -> Ab1cd2345EFgijKL.mp4
  782. const _url = new URL(url);
  783. const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
  784.  
  785. const {name} = filename.match(/(?<name>^[^\.]+)/).groups;
  786. return {blob, lastModifiedDate, contentType, extension, name};
  787. } catch (error) {
  788. verbose && console.error(url, error);
  789. throw error;
  790. }
  791. }
  792.  
  793. function extensionFromMime(mimeType) {
  794. let extension = mimeType.match(/(?<=\/).+/)[0];
  795. extension = extension === "jpeg" ? "jpg" : extension;
  796. return extension;
  797. }
  798.  
  799. // the original download url will be posted as hash of the blob url, so you can check it in the download manager's history
  800. function download(blob, name = "", url = "") {
  801. const a = document.createElement("a");
  802. a.href = URL.createObjectURL(blob) + "#" + url;
  803. a.download = name;
  804. a.click();
  805.  
  806. setTimeout(_ => {
  807. URL.revokeObjectURL(a.href);
  808. }, 1000 * 30);
  809. }
  810.  
  811. // "Sun, 10 Jan 2021 22:22:22 GMT" -> "2021.01.10"
  812. function dateToDayDateString(dateValue, utc = true) {
  813. const _date = new Date(dateValue);
  814. function pad(str) {
  815. return str.toString().padStart(2, "0");
  816. }
  817. const _utc = utc ? "UTC" : "";
  818. const year = _date[`get${_utc}FullYear`]();
  819. const month = _date[`get${_utc}Month`]() + 1;
  820. const date = _date[`get${_utc}Date`]();
  821.  
  822. return year + "." + pad(month) + "." + pad(date);
  823. }
  824.  
  825.  
  826. function addCSS(css) {
  827. const styleElem = document.createElement("style");
  828. styleElem.textContent = css;
  829. document.body.append(styleElem);
  830. return styleElem;
  831. }
  832.  
  833.  
  834. function getCookie(name) {
  835. verbose && console.log(document.cookie);
  836. const regExp = new RegExp(`(?<=${name}=)[^;]+`);
  837. return document.cookie.match(regExp)?.[0];
  838. }
  839.  
  840. function throttle(runnable, time = 50) {
  841. let waiting = false;
  842. let queued = false;
  843. let context;
  844. let args;
  845.  
  846. return function() {
  847. if (!waiting) {
  848. waiting = true;
  849. setTimeout(function() {
  850. if (queued) {
  851. runnable.apply(context, args);
  852. context = args = undefined;
  853. }
  854. waiting = queued = false;
  855. }, time);
  856. return runnable.apply(this, arguments);
  857. } else {
  858. queued = true;
  859. context = this;
  860. args = arguments;
  861. }
  862. }
  863. }
  864. function throttleWithResult(func, time = 50) {
  865. let waiting = false;
  866. let args;
  867. let context;
  868. let timeout;
  869. let promise;
  870.  
  871. return async function() {
  872. if (!waiting) {
  873. waiting = true;
  874. timeout = new Promise(async resolve => {
  875. await sleep(time);
  876. waiting = false;
  877. resolve();
  878. });
  879. return func.apply(this, arguments);
  880. } else {
  881. args = arguments;
  882. context = this;
  883. }
  884.  
  885. if (!promise) {
  886. promise = new Promise(async resolve => {
  887. await timeout;
  888. const result = func.apply(context, args);
  889. args = context = promise = undefined;
  890. resolve(result);
  891. });
  892. }
  893. return promise;
  894. }
  895. }
  896.  
  897.  
  898. function xpath(path, node = document) {
  899. let xPathResult = document.evaluate(path, node, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  900. return xPathResult.singleNodeValue;
  901. }
  902. function xpathAll(path, node = document) {
  903. let xPathResult = document.evaluate(path, node, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
  904. const nodes = [];
  905. try {
  906. let node = xPathResult.iterateNext();
  907.  
  908. while (node) {
  909. nodes.push(node);
  910. node = xPathResult.iterateNext();
  911. }
  912. return nodes;
  913. }
  914. catch (e) {
  915. // todo need investigate it
  916. console.error(e); // "The document has mutated since the result was returned."
  917. return [];
  918. }
  919. }
  920.  
  921.  
  922. function getNearestElementByType(elem, type) {
  923. const parent = elem.parentNode;
  924. if (parent === document) {
  925. return null;
  926. }
  927. if (parent.nodeName === type.toUpperCase()) {
  928. return parent;
  929. }
  930. return getNearestElementByType(parent, type);
  931. }
  932. function getParentWithSiblingDataset(node, name, value) {
  933. const parent = node.parentNode;
  934. if (parent === document) {
  935. return null;
  936. }
  937. // console.log(parent, parent.childNodes);
  938. const elem = [...parent.childNodes].find(el => {
  939. if (el.dataset?.[name] === value) {
  940. return true;
  941. }
  942. });
  943. if (!elem) {
  944. return getParentWithSiblingDataset(parent, name, value);
  945. }
  946. return parent;
  947. }
  948.  
  949. return {
  950. sleep, fetchResource, extensionFromMime, download, dateToDayDateString,
  951. addCSS,
  952. getCookie,
  953. throttle, throttleWithResult,
  954. xpath, xpathAll,
  955. getNearestElementByType, getParentWithSiblingDataset,
  956. }
  957. }
  958.  
  959.  
  960. // ---------------------------------------------------------------------------------------------------------------------
  961. // ---------------------------------------------------------------------------------------------------------------------