Twitter Click'n'Save

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

目前为 2021-09-14 提交的版本,查看 最新版本

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