4chan Archive Image Downloader

4chan archive thread image downloader for general use across many foolfuuka based imageboards. Downloads all images individually in a thread with original filenames (by default). Optional thread API button, for development purposes.

// ==UserScript==
// @name        4chan Archive Image Downloader
// @namespace   Violentmonkey Scripts
// @match       https://archive.4plebs.org/*/thread/*
// @match       https://desuarchive.org/*/thread/*
// @match       https://boards.fireden.net/*/thread/*
// @match       https://archived.moe/*/thread/*
// @match       https://thebarchive.com/*/thread/*
// @match       https://archiveofsins.com/*/thread/*
// @match       https://archive.alice.al/*/thread/*
// @match       https://arch.b4k.co/*/thread/*
// @match       https://archive.palanq.win/*/thread/*
// @grant       GM_download
// @grant       GM_registerMenuCommand
// @version     1.4.2
// @license     The Unlicense
// @author      ImpatientImport
// @description 4chan archive thread image downloader for general use across many foolfuuka based imageboards. Downloads all images individually in a thread with original filenames (by default). Optional thread API button, for development purposes.
// ==/UserScript==

/* EDIT BELOW THIS LINE */

// User preferences
var indiv_button_enabled = true;

var api_button_enabled = false;

var keep_original_filenames = true; // Prioritized above archive_filenames

var archive_filenames = true; // Only used if keep_original_filenames is false. If so, archive names will be saved; otherwise, 4chan names will be saved.

var confirm_download = true;

var download_limit = 3000; // speed in milliseconds to delay

var named_poster_media_download_only = false;

var named_poster_tag_in_filename = false; // Only used if named_poster_media_download_only is true

/* EDIT ABOVE THIS LINE */


(function() {
  'use strict';

  // Constants for later reference
  const top_of_thread = document.getElementsByClassName("post_controls")[0];
  const thread_URL = document.URL;
  const archive_site = thread_URL.toString().split('/')[2];
  const url_path = new URL(thread_URL).pathname;
  const url_path_split = url_path.toString().split('/')
  const thread_board = url_path_split[1];
  const thread_num = url_path_split[3];


  // checking URL console
  /*
  console.log(url_path_split);
  console.log(url_path);
  console.log(thread_URL);
  console.log(thread_URL.toString().split('/')[2]);
  */

  const api_url = "https://" + archive_site + "/_/api/chan/thread/?board=" + thread_board + "&num=" + thread_num; // important
  //console.log(api_url)

  // Individual thread image downloader button

  var indiv_dl_btn;
  var indiv_dlbtn_elem;
  var indivOriginalStyle;
  var indivOrigStyles;
  if (indiv_button_enabled){
    indiv_dl_btn = document.createElement('a');
    indiv_dl_btn.id = "indiv_btn";
    indiv_dl_btn.classList.add("btnr", "parent");
    indiv_dl_btn.innerText = "Indiv DL";
    top_of_thread.append(indiv_dl_btn);

    indiv_dlbtn_elem = document.getElementById("indiv_btn");
    indivOriginalStyle = window.getComputedStyle(indiv_dl_btn);

    indivOrigStyles = {
      backgroundColor: indivOriginalStyle.backgroundColor,
      color: indivOriginalStyle.color,
    }
  }


  // API button for getting the JSON of a thread in a new tab

  var api_btn;
  var api_btn_elem;
  if (api_button_enabled){
    api_btn = document.createElement('a');
    api_btn.id = "api_btn";
    api_btn.href = api_url;
    api_btn.target = "new";
    api_btn.classList.add("btnr", "parent");
    api_btn.innerText = "Thread API";
    top_of_thread.append(api_btn);

    api_btn_elem = document.getElementById("api_btn");
  }


  function displayButton (elem){
    console.log(elem);

    var current_style = window.getComputedStyle(elem).backgroundColor;
    //console.log(current_style); // debug

    var next_style;

    const button_original_text = {"indiv_btn": "Indiv DL"};

    const button_original_styles = {"indiv_btn": indivOrigStyles};

    const confirmStyles = {
      backgroundColor: 'rgb(255, 64, 64)', // Coral Red
      color:"white",
    }

    const processingStyles = {
      backgroundColor: 'rgb(238, 210, 2)', // Safety Yellow
      color:"black",
    }

    const doneStyles = {
      backgroundColor: 'rgb(46, 139, 87)', // Sea Green
      color:"white",

    }

    const originalStyles = {
      backgroundColor: button_original_styles[elem.id].backgroundColor, // Original, clear
      color: button_original_styles[elem.id].color,

    }

    // Button style switcher
    switch (current_style) {
      case 'rgba(0, 0, 0, 0)': // Original color
        next_style = confirmStyles;
        elem.innerText = "Confirm?";
        break;

      case 'rgb(255, 64, 64)': // Confirm color
        next_style = processingStyles;
        elem.innerText = "Processing";
        break;

      case 'rgb(238, 210, 2)': // Processing color
        next_style = doneStyles;
        elem.innerText = "Done";
        break;

      case 'rgb(46, 139, 87)': // Done Color
        next_style = originalStyles;
        elem.innerText = button_original_text[elem.id];
        break;

    }

    Object.assign(elem.style, next_style);
  }


  // Retrieves media from the thread (in JSON format)
  // If OP only, ignore posts, else get posts
  function retrieve_media(thread_obj) {
    var media_arr = [];
    var media_fnames = [];
    var return_value = [];

    const OP = thread_obj[thread_num].op.media;
    //console.log(OP); // debug

    //If OP is a massive OP,
    var OP_filename = (keep_original_filenames) ? OP.media_filename : OP.media_orig;
    OP_filename = (!keep_original_filenames && archive_filenames) ? OP.media : OP_filename;
    OP_filename = (named_poster_tag_in_filename && named_poster_media_download_only) ? String(thread_obj[thread_num].op.name+"_-_"+OP_filename) : OP_filename;
    var OP_media_link = (OP.media_link == null) ? OP.remote_media_link : OP.media_link;
    if (!named_poster_media_download_only || named_poster_media_download_only && thread_obj[thread_num].op.name != "Anonymous") {
      media_arr.push(OP_media_link);
      media_fnames.push(OP_filename);
    }

    // Boolean, checks if posts are present in thread
    const posts_exist = thread_obj[thread_num].posts != undefined;

    if (posts_exist) {
      const thread_posts = thread_obj[thread_num].posts;
      const post_nums = Object.keys(thread_posts);
      const posts_length = post_nums.length;

      //Adds all post image urls and original filenames to the above arrays
      for (var i = 0; i < posts_length; i++) {

        //equivalent to: thread[posts][post_num][media]
        var temp_media_post = thread_posts[post_nums[i]].media;

        //if media exists (and is from a named poster [non-anonymous poster] if option true),
        //then push media to arrays
        if (temp_media_post !== null && (!named_poster_media_download_only || named_poster_media_download_only && thread_posts[post_nums[i]].name != "Anonymous") ) {

          var working_media_link = (temp_media_post.media_link == null) ? temp_media_post.remote_media_link : temp_media_post.media_link
          var correct_media_fname = (keep_original_filenames) ? temp_media_post.media_filename : temp_media_post.media_orig;
          correct_media_fname = (!keep_original_filenames && archive_filenames) ? temp_media_post.media : correct_media_fname;
          correct_media_fname = (named_poster_tag_in_filename && named_poster_media_download_only) ? String(thread_posts[post_nums[i]].name+"_-_"+correct_media_fname) : correct_media_fname;


          //console.log(working_media_link); //debug
          //console.log(correct_media_fname); //debug

          media_arr.push(working_media_link)
          media_fnames.push(correct_media_fname);

        }
      }
    }

    // Adds the media link array with the media filenames array into the final return
    return_value[0] = media_arr;
    return_value[1] = media_fnames;

    /*
    var count;

    function download_with_scope(){
      GM_download(media_arr[count], media_fnames[count]) // downloads images
    }
    */

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
   }

    async function download_images(){

      for (var i=0; i<media_arr.length; i++){
        //console.log(media_fnames[i] + " "+ media_arr[i]); //debug
        //console.log(download_limit); //debug

        await sleep(download_limit);

        GM_download(media_arr[i], media_fnames[i]) // downloads images

      }
    }

    download_images();


    if(confirm_download){
      displayButton(indiv_dlbtn_elem);
      setTimeout(displayButton(indiv_dlbtn_elem), 3000);
    }

  }

  // Gets the JSON file for the thread with the API
  async function get_archive_thread() {
    const API_response = await fetch(api_url);
    const JSON_file = await API_response.json();
    console.log(JSON_file); // debug
    retrieve_media(JSON_file);
  }

    // Controls what the individual download button does upon being clicked
  function indivDownload(){
    displayButton(indiv_dlbtn_elem);

    // Wait for user to confirm zip if didn't click fast enough for double-click
    setTimeout(function(){
      if (window.getComputedStyle(indiv_dl_btn).backgroundColor == 'rgb(255, 64, 64)'){
        indiv_dl_btn.removeEventListener("click", displayButton);
        indiv_dl_btn.addEventListener("click", get_archive_thread);

        // If user does not confirm, reset the button back to original
        setTimeout(function(){
          indiv_dl_btn.removeEventListener("click", get_archive_thread);
          indiv_dl_btn.addEventListener("click", displayButton);
          Object.assign(indiv_dlbtn_elem.style, indivOrigStyles);
          indiv_dl_btn.innerText = "Indiv DL";
        }, 5000);

      }
    }, 501);

  }

  GM_registerMenuCommand("Download all thread images individually", get_archive_thread);


  // Download thread button event listener(s)
  if(confirm_download){
    indiv_dlbtn_elem.addEventListener("click", indivDownload);
    indiv_dlbtn_elem.addEventListener("dblclick", get_archive_thread);
  }
  else{
    indiv_dlbtn_elem.addEventListener("click", get_archive_thread);
  }

})();