Twitter Click'n'Save

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

当前为 2022-02-05 提交的版本,查看 最新版本

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