Twitter Click'n'Save

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

目前為 2021-07-31 提交的版本,檢視 最新版本

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