Twitter Media Downloader

Save Video/Photo by One-Click.

Per 13-03-2021. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name        Twitter Media Downloader
// @name:ja     Twitter Media Downloader
// @name:zh-cn  Twitter 媒体下载
// @name:zh-tw  Twitter 媒體下載
// @description    Save Video/Photo by One-Click.
// @description:ja ワンクリックで動画・画像を保存する。
// @description:zh-cn 一键保存视频/图片
// @description:zh-tw 一鍵保存視頻/圖片
// @version     0.53
// @author      AMANE
// @namespace   none
// @match       https://twitter.com/*
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_download
// ==/UserScript==
/* jshint esversion: 8 */

(function () {
  'use strict';

  const preset_filename = 'twitter_{user-name}(@{user-id})_{date-time}_{status-id}_{file-type}';

  const language = {
    en: {download: 'Download', completed: 'Download Completed', settings: 'Download Settings', save: 'Save', confirm: 'Confirm Save As Dialog', record: 'Remember Download History', clear: '(Clear)', clear_confirm: 'Clear download history?', pattern: 'File Name Pattern'},
    ja: {download: 'ダウンロード', completed: 'ダウンロード完了', settings: 'ダウンロード設定', save: '保存', confirm: '保存場所を確認する', record: 'ダウンロード履歴を保存する', clear: '(クリア)', clear_confirm: 'ダウンロード履歴を削除する?', pattern: 'ファイル名パターン'},
    zh: {download: '下载', completed: '下载完成', settings: '下载设置', save: '保存', confirm: '确认文件名和保存位置', record: '保存下载记录', clear: '(清除)', clear_confirm: '确认要清除下载记录?', pattern: '文件名格式'},
    'zh-Hant': {download: '下載', completed: '下載完成', settings: '下載設置', save: '保存', confirm: '確認文件名和保存位置', record: '保存下載記錄', clear: '(清除)', clear_confirm: '確認要清除下載記錄?', pattern: '文件名規則'},
  };
  const str = language[document.querySelector('html').lang] || language.en;

  const svg = `
<g class="download"><path d="M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l4,4 q1,1 2,0 l4,-4 M12,3 v11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></g>
<g class="completed"><path d="M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l3,4 q1,1 2,0 l8,-11" fill="none" stroke="#1DA1F2" stroke-width="2" stroke-linecap="round" /></g>
<g class="loading"><circle cx="12" cy="12" r="10" fill="none" stroke="#1DA1F2" stroke-width="4" opacity="0.4" /><path d="M12,2 a10,10 0 0 1 10,10" fill="none" stroke="#1DA1F2" stroke-width="4" stroke-linecap="round" /></g>
<g class="failed"><circle cx="12" cy="12" r="11" fill="#f33" stroke="currentColor" stroke-width="2" opacity="0.8" /><path d="M14,5 a1,1 0 0 0 -4,0 l0.5,9.5 a1.5,1.5 0 0 0 3,0 z M12,17 a2,2 0 0 0 0,4 a2,2 0 0 0 0,-4" fill="#fff" stroke="none" /></g>
`;

  const css = `
.tmd-down > div > div > div:nth-child(2) {display: none}
.tmd-down:hover > div > div {color: rgba(29, 161, 242, 1.0);}
.tmd-down:hover > div > div > div > div {background-color: rgba(29, 161, 242, 0.1);}
.tmd-down:active > div > div > div > div {background-color: rgba(29, 161, 242, 0.2);}
.tmd-down.loading svg {animation: spin 1s linear infinite;}
.tmd-down g {display: none;}
.tmd-down.download g.download, .tmd-down.completed g.completed, .tmd-down.loading g.loading,.tmd-down.failed g.failed {display: unset;}
@keyframes spin {0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);}}
.tmd-btn {display: inline-block; background-color: #1DA1F2; color: #FFFFFF; padding: 0 20px; border-radius: 99px;}
.tmd-tag {display: inline-block; background-color: #FFFFFF; color: #1DA1F2; padding: 0 10px; border-radius: 10px; border: 1px solid #1DA1F2;  font-weight: bold; margin: 5px;}
.tmd-btn:hover {background-color: rgba(29, 161, 242, 0.9);}
.tmd-tag:hover {background-color: rgba(29, 161, 242, 0.1);}
`;

  let history = storage('history');
  document.head.insertAdjacentHTML('beforeend', '<style>' + css + '</style>');
  new MutationObserver(mutations => mutations.forEach(mutation => {
    mutation.addedNodes.forEach(node => {
      let article = node.tagName == 'DIV' && node.querySelector('article');
      btn_inject(article);
    });
  })).observe(document.body, {childList: true, subtree: true});

  function btn_inject(article) {
    let media = article.querySelector('div[role="progressbar"], div[data-testid="playButton"], a[href*="/photo/1"], a[href="/settings/safety"]');
    if (!media || article.dataset.injected) return;
    article.dataset.injected = 'true';
    let status_id = article.querySelector('a[href*="/status/"]').href.split('/status/').pop().split('/').shift();
    let is_exist = history.indexOf(status_id) >= 0;
    let group = article.querySelector('div[role="group"]');
    let btn = group.querySelector(':scope>:first-child').cloneNode(true);
    btn.querySelector('svg').innerHTML = svg;
    group.appendChild(btn);
    btn_status(btn, is_exist ? 'completed' : 'download', is_exist ? str.completed : str.download);
    btn.onclick = () => btn_click(btn, status_id, is_exist);
    btn.oncontextmenu = e => {
      e.preventDefault();
      down_settings();
    };
  }

  async function btn_click(btn, status_id, is_exist) {
    if (btn.classList.contains('loading')) return;
    btn_status(btn, 'loading');
    let filename = (await GM_getValue('filename', preset_filename)).split('\n').join('');
    let confirm = await GM_getValue('confirm', false);
    let record = await GM_getValue('record', true);
    let json = await fetch_json(status_id);
    let tweet = json.globalObjects.tweets[status_id];
    let user = json.globalObjects.users[tweet.user_id_str];
    let info = {
      'status-id': status_id,
      'user-name': user.name,
      'user-id': user.screen_name,
      'date-time': formatDate(tweet.created_at, 'YYYYMMDD-hhmmss')
    };
    let medias = tweet.extended_entities && tweet.extended_entities.media;
    if (medias) {
      let tasks = medias.length;
      medias.forEach((media, i) => {
        info.url = media.type == 'photo' ? media.media_url + ':orig' : media.video_info.variants.filter(n => n.content_type == 'video/mp4').sort((a, b) => b.bitrate - a.bitrate)[0].url;
        info.file = info.url.split('/').pop().split(/[:?]/).shift();
        info['file-name'] = info.file.split('.').shift();
        info['file-ext'] = info.file.split('.').pop();
        info['file-type'] = media.type.replace('animated_', '');
        info.out = (filename.replace(/\.?{file-ext}/, '') + (medias.length > 1 && !filename.match('{file-name}') ? '-' + i : '') + '.{file-ext}').replace(/{([^{}]+)}/g, (match, name) => info[name]);
        GM_download({
          url: info.url,
          name: info.out,
          // saveAs: confirm,
          onload: () => {
            tasks -= 1;
            if (tasks === 0) {
              btn_status(btn, 'completed', str.completed);
              if (record && !is_exist) {
                history.push(status_id);
                storage('history', status_id);
              }
            }
          },
          onerror: result => {
            tasks = - 1;
            btn_status(btn, 'failed', result.details.current);
          }
        });
      });
    } else {
      btn_status(btn, 'failed', 'MEDIA_NOT_FOUND');
    }
  }

  function btn_status(btn, css, title) {
    btn.classList.remove('tmd-down', 'download', 'completed', 'loading', 'failed');
    btn.classList.add('tmd-down', css);
    if (title) btn.title = title;
  }

  async function down_settings() {
    const $element = (parent, tag, style, content, css) => {
      let el = document.createElement(tag);
      if (style) el.style.cssText = style;
      if (typeof content !== 'undefined') {
        if (tag == 'input') {
          if (content == 'checkbox') el.type = content;
          else el.value = content;
        } else el.innerHTML = content;
      }
      if (css) css.split(' ').forEach(c => el.classList.add(c));
      parent.appendChild(el);
      return el;
    };
    let wapper = $element(document.body, 'div', 'position: fixed; left: 0px; top: 0px; width: 100%; height: 100%; background-color: #0009; z-index: 10;');
    let wapper_close;
    wapper.onmousedown = e => {
      wapper_close = e.target == wapper;
    };
    wapper.onmouseup = e => {
      if (wapper_close && e.target == wapper) wapper.remove();
    };
    let dialog = $element(wapper, 'div', 'position: absolute; left: 50%; top: 50%; transform: translateX(-50%) translateY(-50%); width: fit-content; width: -moz-fit-content; background-color: #f3f3f3; border: 1px solid #ccc; border-radius: 10px;');
    let title = $element(dialog, 'h3', 'margin: 10px 20px;', str.settings);
    let options = $element(dialog, 'div', 'margin: 10px; border: 1px solid #ccc; border-radius: 5px;');
    // let confirm_input = $element($element(options, 'label', 'display: block; margin: 10px;', str.confirm), 'input', 'float: left;', 'checkbox');
    // confirm_input.checked = await GM_getValue('confirm', false);
    // confirm_input.onchange = () => GM_setValue('confirm', confirm_input.checked);
    let record_label = $element(options, 'label', 'display: block; margin: 10px;', str.record);
    let record_input = $element(record_label, 'input', 'float: left;', 'checkbox');
    record_input.checked = await GM_getValue('history', true);
    record_input.onchange = () => GM_setValue('history', record_input.checked);
    $element(record_label, 'label', 'margin: 10px; color: blue;', str.clear).onclick = () => {
      if (confirm(str.clear_confirm)) {
        history = [];
        localStorage.removeItem('history');
      }
    };
    let filename_div = $element(dialog, 'div', 'margin: 10px; border: 1px solid #ccc; border-radius: 5px;');
    let filename_label = $element(filename_div, 'label', 'display: block; margin: 10px 15px;', str.pattern);
    let filename_input = $element(filename_label, 'textarea', 'display: block; min-width: 500px; max-width: 500px; min-height: 100px; font-size: inherit;', await GM_getValue('filename', preset_filename));
    let filename_tags = $element(filename_div, 'label', 'display: table; margin: 10px;', `
<span class="tmd-tag" title="user name">{user-name}</span>
<span class="tmd-tag" title="The user name after @ sign.">{user-id}</span>
<span class="tmd-tag" title="example: 1234567890987654321">{status-id}</span>
<span class="tmd-tag" title="YYYYMMDD-hhmmss\nexample: 20201231-235959">{date-time}</span><br>
<span class="tmd-tag" title="Type of &#34;video&#34; or &#34;photo&#34; or &#34;gif&#34;.">{file-type}</span>
<span class="tmd-tag" title="Original filename from URL.">{file-name}</span>
<span class="tmd-tag" title="Unnecessary. Will be added automatically.">{file-ext}</span>
`);
    filename_input.selectionStart = filename_input.value.length;
    filename_tags.querySelectorAll('.tmd-tag').forEach(tag => {
      tag.onclick = () => {
        let ss = filename_input.selectionStart;
        let se = filename_input.selectionEnd;
        filename_input.value = filename_input.value.substring(0, ss) + tag.innerText + filename_input.value.substring(se);
        filename_input.selectionStart = ss + tag.innerText.length;
        filename_input.selectionEnd = ss + tag.innerText.length;
        filename_input.focus();
      };
    });
    let btn_save = $element(title, 'label', 'float: right;', str.save, 'tmd-btn');
    btn_save.onclick = async() => {
      await GM_setValue('filename', filename_input.value);
      wapper.remove();
    };
  }

  function storage(name, value) {
    let data = JSON.parse(localStorage.getItem(name) || '[]');
    if (value) data.push(value);
    else return data;
    localStorage.setItem(name, JSON.stringify(data));
  }

  async function fetch_json(status_id) {
    let url = 'https://twitter.com/i/api/2/timeline/conversation/' + status_id + '.json?tweet_mode=extended&include_entities=false&include_user_entities=false';
    let cookies = getCookie();
    let headers = {
      'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
      'x-twitter-active-user': 'yes',
      'x-twitter-client-language': cookies.lang,
      'x-csrf-token': cookies.ct0
    };
    if (cookies.ct0.length == 32) headers['x-guest-token'] = cookies.gt;
    return await fetch(url, {headers: headers}).then(result => result.json());
  }

  function getCookie(name) {
    let cookies = {};
    document.cookie.split(';').filter(n => n.indexOf('=') > 0).forEach(n => {
      n.replace(/^([^=]+)=(.+)$/, (match, name, value) => {
        cookies[name.trim()] = value.trim();
      });
    });
    return name ? cookies[name] : cookies;
  }

  function formatDate(i, o) {
    let d = new Date(i);
    let v = {
      YYYY: d.getUTCFullYear().toString(),
      YY: d.getUTCFullYear().toString(),
      MM: '0' + (d.getUTCMonth() + 1),
      DD: '0' + d.getUTCDate(),
      hh: '0' + d.getUTCHours(),
      mm: '0' + d.getUTCMinutes(),
      ss: '0' + d.getUTCSeconds()
    };
    return o.replace(/(YY(YY)?|MM|DD|hh|mm|ss)/g, n => v[n].substr( - n.length));
  }

})();