Skip to content

Instantly share code, notes, and snippets.

@travishorn
Last active February 4, 2024 08:09
Show Gist options
  • Star 31 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save travishorn/c2b6111a4e63efdbf87a1de84c833ab1 to your computer and use it in GitHub Desktop.
Save travishorn/c2b6111a4e63efdbf87a1de84c833ab1 to your computer and use it in GitHub Desktop.
Saving the images and videos from your ClassDojo storyline

Archived

Please see Patrick330's fork of this script.

ClassDojo changes their site regularly, so a script like this needs regular maintenance. I have decided to archive this project and stop providing updates. Patrick330's fork linked above may be a good alternative.

Original Purpose

ClassDojo is a classroom communication app used to share reports between parents and teachers. Teachers track student behavior and upload photos or videos. The gamification style system teaches developmental skills through real-time feedback.

When your child's teacher shares a photo, it goes on your parent "storyline". Unfortunately, ClassDojo does not provide any means of saving these photos. In fact, the photos are displayed in a <div> using style: background-image('...'); so right-clicking and choosing "Save image" is not an option.

@Patrick330
Copy link

This wasn't working for me, so I updated it, starting from @pnwguy's snipped above. It downloads videos too!

/* run this in the console on the ClassDojo page */


function download(url) {
fetch(url).then(function(t) {
    return t.blob().then((b)=> {
        var a = document.createElement("a");
        a.href = URL.createObjectURL(b);
        var n = url.lastIndexOf('/');
        var filename = url.substring(n + 1);
        a.setAttribute("download", filename);
        a.click();
    }
    );
});
}

var els = document.querySelectorAll('[data-test-name="storyPostImage"]');
                                
els.forEach(
	function(currentValue) {
		url = window.getComputedStyle(currentValue).getPropertyValue("background-image");
		download(url.slice(5, -8));
  })


var video_els = document.querySelectorAll('[type="video/mp4"]');

video_els.forEach(
	function(currentValue) {
		download(currentValue.src);
  })

@travishorn
Copy link
Author

Thank you @pnwguy and @Patrick330. I've updated the main Gist file with this code.

@Patrick330
Copy link

Patrick330 commented Jun 24, 2021

Thanks @travishorn!

It might be useful to note that you’ll want to do this on a small number of photos first so that you can tell your browser to automatically save them rather than asking you what to do with each one.

@fboulet
Copy link

fboulet commented Jul 2, 2021

Since I have multiple kids and multi-year historical data, I added a prefix to the downloaded file, so that it looks like "Teacher name - Class name - Post date-filename.jpg"

Thanks for the code, works perfectly!

/* run this in the console on the ClassDojo page */


function download(url, prefix) {
fetch(url).then(function(t) {
    return t.blob().then((b)=> {
        var a = document.createElement("a");
        a.href = URL.createObjectURL(b);
        var n = url.lastIndexOf('/');
        var filename = url.substring(n + 1);
        a.setAttribute("download", prefix+filename);
        a.click();
    }
    );
});
}

var els = document.querySelectorAll('[data-test-name="storyPostImage"]');
                                
els.forEach(
	function(currentValue) {
	
		// GetClassName
		var headerNode = currentValue.parentNode.parentNode.parentNode.querySelector('[data-test-name="storyPostHeader"]');
		var teacher = headerNode.querySelector('.css-tg8rmq').innerText;
		var className = headerNode.querySelectorAll('.css-7guneq')[0].innerText;
		var postDate = headerNode.querySelectorAll('.css-7guneq')[1].innerText;
		
		var filePrefix = teacher + ' - ' + className + ' - ' + postDate + "-";

		
		
		url = window.getComputedStyle(currentValue).getPropertyValue("background-image");
		download(url.slice(5, -8), filePrefix);
  })

var video_els = document.querySelectorAll('[type="video/mp4"]');

video_els.forEach(
	function(currentValue) {
		download(currentValue.src);
  });

@Coopersweb
Copy link

Hi,

I have tried the different scripts and it either does not work or only downloads a 4 images. Nothing else.

Any ideas - using imac with chrome and firefox

Cheers

@Coopersweb
Copy link

fboulet = your codes works, but does not download them all.

Also, where do I check my node version?

Cheers

@ought74
Copy link

ought74 commented Jul 19, 2021

The code works very well for me thanks. Would it be possible to add to it so it renames other files such as .mp4 too please?

Thanks.

@Coopersweb
Copy link

Coopersweb commented Jul 19, 2021

#fboulet / @fboulet

  • do you have ideas as to why it is only downloading a few images??

@trunkspacehosting
Copy link

trunkspacehosting commented Aug 14, 2021

3 things:
1- The console script, especially the modifications with prefixes by @fboulet and others work really well for the FIRST IMAGE in a post with multiple images. However, the rest of the images are ignored/not downloaded. For example, on May 12, 2021, my kid's post has 10 images from the same day - it only seems to grab the first one. Can anyone advise how you would go about modifying the script to grab all 10 (that you usually have to scroll through by clicking the first and then clicking the right arrow?)
2- Is there a limit on how far it goes back in time? I figured it was whatever you had 'scrolled down' on your desktop to, since it seems to pre-load about 10 days of images at a time -- so I scrolled as far back as I wanted it to download, but it seems to stop at about the ~18 month mark or so?
3- @fboulet: Yours works great for pictures but doesn't seem to download videos for me. @Patrick330: Yours seems to download photos and videos (without the prefixes that @fboulet's modified script added). Only difference I see between your 2 scripts is the semi-colon at the end of the videos one -- but removing the extra semicolon doesn't seem to fix @fboulet's for videos -- so weird!

@Patrick330
Copy link

I've taken another look, and @Coopersweb and @trunkspacehosting are right - it only downloads only the first photo. The result is the same whether you use my script or @fboulet's modification. It's very possible that this has always been the case, and that only @fboulet's modification made it noticeable.

I couldn't really seem to figure out how to identify the URLs of subsequent photos. I'm a developer, but not in JavaScript, and the minified and obfuscated code is challenging to interpret. I set an event listener on the click function and stepped through the code, and it appears that the photo URLs are buried within a long array of functions inside a webpack.

I decided to try another approach. The code now loops through each story item and "clicks" to advance through the set of photos. It also downloads videos story by story, so that process should be more robust. In addition I added some date parsing enable the failes to be saved with sortable data.

Hope this works for you!

/* run this in the console on the ClassDojo page */

function download(url, prefix) {
    fetch(url).then(function(t) {
        return t.blob().then((b)=> {
            var a = document.createElement("a");
            a.href = URL.createObjectURL(b);
            var n = url.lastIndexOf('/');
            var filename = url.substring(n + 1);
            a.setAttribute("download", prefix+filename);
            a.click();
        }
        );
    });
}

function eventFire(el, etype){
    if (el.fireEvent) {
        el.fireEvent('on' + etype);
    } else {
        var evObj = document.createEvent('Events');
        evObj.initEvent(etype, true, false);
        el.dispatchEvent(evObj);
    }
    }

var els = document.querySelectorAll('[data-test-name="storyPostContents"]');
                                
els.forEach(
    function(currentValue) {

        firstImage = true;
        lastImage = false;

        // loop in order to catch each image in a set
        while(firstImage == true || lastImage == false) {

            // Get content node
            contentNode = currentValue.childNodes[0].childNodes[0];
            // if( currentValue.querySelector('[data-test-name="storyPostImage"]') === null) {
            //     if( !(currentValue.querySelector('[data-test-name="videoComponent"]') === null)) {
            //         contentNode = currentValue.querySelector('[data-test-name="videoComponent"]').parentNode;
            //     } else {
            //         contentNode = currentValue.childNodes[0].childNodes[0];
            //     }
            // } else {
            //     contentNode = currentValue.querySelector('[data-test-name="storyPostImage"]');
            // }

            // Get content for file name
            var headerNode = currentValue.parentNode.querySelector('[data-test-name="storyPostHeader"]');
            var teacher = headerNode.querySelector('.css-tg8rmq').innerText;
            var className = headerNode.querySelectorAll('.css-7guneq')[0].innerText;
            var postDate = headerNode.querySelectorAll('.css-7guneq')[1].innerText;

            // provide sortable date
            if(postDate.split(' ').length == 3) {
                postYear = postDate.split(' ')[2]
            } else {
                postYear = new Date().getFullYear()
            }

            switch (postDate.split(' ')[0]) {
                case 'Jan':
                    postMonth = '01';
                    break;
                case 'Feb':
                    postMonth = '02';
                    break;
                case 'Mar':
                    postMonth = '03';
                    break;
                case 'Apr':
                    postMonth = '04';
                    break;
                case 'May':
                    postMonth = '05';
                    break;
                case 'Jun':
                    postMonth = '06';
                    break;
                case 'Jul':
                    postMonth = '07';
                    break;
                case 'Aug':
                    postMonth = '08';
                    break;
                case 'Sep':
                    postMonth = '09';
                    break;
                case 'Oct':
                    postMonth = '10';
                    break;
                case 'Nov':
                    postMonth = '11';
                    break;
                case 'Dec':
                    postMonth = '12';
                    break;
            }

            postDay = ('00' + postDate.split(' ')[1].replace(',', '')).slice(-2)
            
            var filePrefix = postYear + '-' + postMonth + '-' + postDay + ' - ' + className + ' - ' + teacher + " ";

            // download image and video  
            var video_els = contentNode.querySelector('[type="video/mp4"]');
            if(video_els !== null) {
                download(video_els.src, filePrefix);
            }
            
            url = window.getComputedStyle(contentNode).getPropertyValue("background-image");
            if(url != 'none') {
                download(url.slice(5, -8), filePrefix);
            }
            
            // check if there are more images
            numDivs = contentNode.parentNode.childNodes.length;
            
            // click or set lastImage to true as necessary
            switch(numDivs) {
                case 1: 
                    lastImage = true;
                    break;
                case 3:
                    if (firstImage == true) {
                        lastImage = false;
                        eventFire(contentNode.nextSibling, 'click');
                    } else {
                        lastImage = true;
                    }
                    break;
                case 4: 
                eventFire(contentNode.nextSibling.nextSibling, 'click');
            }
            
            firstImage = false;
        }
    });

@trunkspacehosting
Copy link

trunkspacehosting commented Aug 15, 2021

@Patrick330: Brilliant, thank you! However, for me anyway, it only goes back to 3 posts (or 3 days). I have 2 years of posts to save :) Is there anything limiting the timeframe in your code? If so, I haven't spotted it

Thanks so much for helping with this! I used a node.js script to save EVERYTHING yesterday, but it doesn't add prefixes / titles or divide up the class rooms (and I have 2 children in 2 different classrooms), so your & @fboulet's method would work way better for me!

@Patrick330
Copy link

@trunkspacehosting There is nothing limiting the number of posts downloaded. I was able to use it to download ~7000 photos going back a year and a half, but I first had to hold my “end” key down and scroll back to posts that old.

What browser are you using? I ran this on Firefox.

@fredbuteau
Copy link

fredbuteau commented Aug 30, 2021

Hi everyone!

First of all, thanks a whole lot for this script! The logic is well made and it allowed me to download my child's year story worth of photos (about 500 items). As is, the script did not work for me and gave me the infamous 'undefined' message. I spent a few minutes debugging and here are my observations :

  1. In this section
    var headerNode = currentValue.parentNode.querySelector('[data-test-name="storyPostHeader"]');
    var teacher = headerNode.querySelector('.css-tg8rmq').innerText;
    var className = headerNode.querySelectorAll('.css-7guneq')[0].innerText;
    var postDate = headerNode.querySelectorAll('.css-7guneq')[1].innerText;

I had to find the right css codes for my story... So I console.log()'d the value of headerNode and I was able to find the needed values within the output just before the teacher's and class names. I replaced the values for the three lines and it worked for me. My code looked like this :

        var headerNode = currentValue.parentNode.querySelector('[data-test-name="storyPostHeader"]');
        var teacher = headerNode.querySelector('.css-rryfp1').innerText;
        var className = headerNode.querySelectorAll('.css-18csift')[0].innerText;
        var postDate = headerNode.querySelectorAll('.css-18csift')[1].innerText;
  1. The post at the top of my list was a post with no media, so as is, the script ran in an endless loop. That's because the case at the bottom of the script didn't handle a post with no images. So in the case I added a case 2, which is basically the same logic as the first one, to reset the lastImage variable and exit the first while loop (I first tried a 'default' case, but for a lot of the stories with multiple images the script did not retrieve all of them)
    switch(numDivs) {
    case 1:
    lastImage = true;
    break;
    case 2:
    lastImage = true;
    break;
    case 3:
    if (firstImage == true) {
    lastImage = false;
    eventFire(contentNode.nextSibling, 'click');
    } else {
    lastImage = true;
    }
    break;
    case 4:
    eventFire(contentNode.nextSibling.nextSibling, 'click');
    }

  2. Lastly, to make sure I did not get only a few posts, I had to 'load' the entire year by going at the bottom of the page (pressing page down or end will do the trick).

I ran this with Edge and my 500 or so files were downloaded in a less than 10 minutes. Thanks again!

@crystalRubesie
Copy link

crystalRubesie commented Oct 3, 2021

Hi
This is awesome and it works excellent for me. Does anyone know how I can expand to "PDF" on the ClassDojo page? Thanks.

@rateschrisa
Copy link

I tried Patrick 330's script in Firefox, Edge, and Chrome and get the same errors.
VM30:52 Uncaught TypeError: Cannot read properties of null (reading 'innerText')
at :52:66
at NodeList.forEach ()
at :29:5
I don't know enough about javascript but am wondering if fredbuteau's fix works for this. I don't understand, however, how to go about the following, if this even is the fix
"console.log()'d the value of headerNode and I was able to find the needed values within the output just before the teacher's and class names. " if this even is a way of fixing that. Any help would be greatly appreciated!

@mweth
Copy link

mweth commented Mar 30, 2022

@Patrick330's script works still. As @fredbuteau noted, you have to change the .ccs vars. To find the right .css entries to substitute, search for your teacher's name in inspector, and the code will be right before the teacher name. Then search for the class name and it will be right before that entry.

@gonzalocamarero
Copy link

@mweth @Patrick330 It's needed to declare "postMonth" variable too .

@Patrick330
Copy link

Ok, I've updated this and made it more robust. In particular, I:

  • Made the code robust to variations in the CSS classes assigned (a major source of errors)
  • Corrected an issue with certain date formats
  • Corrected an issue with unusual content formats.

It seems to be working well for me, but let me know if additional enhancements are needed. I'm going to fork it, so you can find my updated code here: https://gist.github.com/Patrick330/aa5d16efaacaee9ffb76e52ba4fb86f6

@elliottbenzle
Copy link

Thank you! Worked pretty well. Better than inspecting each image.

@RahulSDeshpande
Copy link

When I run this code in my Mac Chrome's console, the fails with the error Undefined.
I really dont know what is going wrong bcoz I know nothing about JS.
I am using this https://gist.github.com/Patrick330/aa5d16efaacaee9ffb76e52ba4fb86f6.

Can @Patrick330, @travishorn or anyone else help me here??

@pedralmeida
Copy link

pedralmeida commented Jun 27, 2023

When I run this code in my Mac Chrome's console, the fails with the error Undefined. I really dont know what is going wrong bcoz I know nothing about JS. I am using this https://gist.github.com/Patrick330/aa5d16efaacaee9ffb76e52ba4fb86f6.

Can @Patrick330, @travishorn or anyone else help me here??

The script isn't working because fields changed names again. "data-test-name" is now only "data-name", for instance. Even then it takes a few more tweaks because of the dates, I personally gave up.

if you have access to a PC, what I suggest, for a layman like you, is to use the ChromeCacheView app. it will allow you to bulk download every content loaded by the browser into its cache, in a very simple way.

@vizyonok
Copy link

vizyonok commented Jul 26, 2023

Guys, if somebody has created a working version of the script, please share it or let us know here.
Thanks in advance! 🤗

@Loksly
Copy link

Loksly commented Aug 1, 2023

Use the API instead of scrap:

const FIRST_FEED = "https://home.classdojo.com/api/storyFeed?withStudentCommentsAndLikes=true&withArchived=false";

function getFeed(url) {
    return fetch(url, {
        "headers": {
            "accept": "*/*",
            "accept-language": "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7",
            "cache-control": "no-cache",
            "pragma": "no-cache",
            "sec-ch-ua": "\"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"114\", \"Google Chrome\";v=\"114\"",
            "sec-ch-ua-mobile": "?0",
            "sec-ch-ua-platform": "\"Linux\"",
            "sec-fetch-dest": "empty",
            "sec-fetch-mode": "cors",
            "sec-fetch-site": "same-origin",
            "x-client-identifier": "Web",
            "x-sign-attachment-urls": "true"
        },
        "referrer": "https://home.classdojo.com/",
        "referrerPolicy": "strict-origin-when-cross-origin",
        "body": null,
        "method": "GET",
        "mode": "cors",
        "credentials": "include"
    }).then((response) => response.json());
}

const attachments = [];

async function d() {
    let feed = await getFeed(FIRST_FEED);
    grabFeedAttachments(feed);
    while (feed._links.next && feed._items.length > 0) {
        feed = await getFeed(feed._links.next.href);
        grabFeedAttachments(feed);
    }

    attachments.reduce((p, c, i) => {
        return p.then(() => downloadAttachment(c, i));
    }, Promise.resolve());
}

function grabFeedAttachments(feed) {
    feed._items.forEach((item) => {
        item.contents.attachments?.forEach((attachment) => {
            if (typeof attachment.path === "string") {
                attachments.push({
                    url: attachment.path,
                    time: item.time
                });
            }
        });
    });
}

function downloadAttachment(attachment, counter) {

    return fetch(attachment.url).then((t) => {
            return t.blob().then((b)=> {
            let a = document.createElement("a");
            a.href = URL.createObjectURL(b);

            let filename = String(counter) + "_" + attachment.time.split('T')[0];

            a.setAttribute("download", filename);
            a.click();
            counter++;
        }
        );
    });
}

d();

Note: you'd better configure your browser so not to ask for every downloaded file where should be stored.

Please consider supporting Classdojo.

@vizyonok
Copy link

vizyonok commented Aug 1, 2023

@Loksly amazing! Thanks a ton! It works just flawlessly 👌❤️🙏

@joaoubaldo
Copy link

Here's the python script I've been using so far
https://gist.github.com/joaoubaldo/ce75db527bb9b865bd8cc464160be19d

  1. Save each response from https://home.classdojo.com/api/storyFeed (scroll down on the classdojo story feed) as a .json file in the same directory as this script
  2. Run script. Media files will be downloaded in parallel.

@neurolizer
Copy link

Is anyone else getting a CORS policy error with the wonderful API provided by @Loksly ? My feed only has one post, but the post has 7 videos in it. The API script downloads the first 4 videos great, but then throws this CORS error and stops. I tried switching to no-cors, which allows it to download the first 5 videos, but then throws the same error:

Access to fetch at 'https://svideos.classdojo.com/0e5...003/2023-08-17/590...a2c/436...4ef.mp4?Policy=eyJ...dfQ__&Key-Pair-Id=KS4...06Z&Signature=HUU...g__' from origin 'https://home.classdojo.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
GET https://svideos.classdojo.com/0e5...003/2023-08-17/590...a2c/436...4ef.mp4?Policy=eyJ...Q__&Key-Pair-Id=KS4...06Z&Signature=HUU...g__ net::ERR_FAILED 200
Uncaught (in promise) TypeError: Failed to fetch
at downloadAttachment (:58:12)
at :39:29

Also, does anyone know how to turn the API script into something we could use in Tampermonkey that would give us a download all button when we are on the home story page?

@neurolizer
Copy link

neurolizer commented Aug 21, 2023

I was able to get Loksly's API code working in Tampermonkey so it displays a Download All button in the top left when you are on home.classdojo.com. Conveniently, when the script runs via Tampermonkey it is able to continue past the CORS error to download the last video and the image on the default new class post. Strangely the one video I actually want is the one that errors and doesn't download, but I was able to get it manually. Hopefully it will work better for future posts.

// ==UserScript==
// @name         ClassDojo Story Feed Download All Button
// @namespace    https://gist.github.com/travishorn/c2b6111a4e63efdbf87a1de84c833ab1?permalink_comment_id=4647516
// @version      0.1
// @description  Download attachments from ClassDojo feed with a "Download All" button in the top left
// @author       Loksly
// @match        https://home.classdojo.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const FIRST_FEED = "https://home.classdojo.com/api/storyFeed?withStudentCommentsAndLikes=true&withArchived=false";

    function getFeed(url) {
        return fetch(url, { // Headers and options here...
        //      "headers": {
        //        "accept": "*/*",
        /*        "accept-language": "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7",
                "cache-control": "no-cache",
                "pragma": "no-cache",
                "sec-ch-ua": "\"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"114\", \"Google Chrome\";v=\"114\"",
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": "\"Linux\"",
                "sec-fetch-dest": "empty",
                "sec-fetch-mode": "cors",
                "sec-fetch-site": "same-origin",
                "x-client-identifier": "Web",
                "x-sign-attachment-urls": "true"
            },
            "referrer": "https://home.classdojo.com/",
            "referrerPolicy": "strict-origin-when-cross-origin",
            "body": null,
            "method": "GET",
            "mode": "cors",
            "credentials": "include" //*/
        }).then((response) => response.json());
    }

    const attachments = [];

    async function downloadAttachments() {
        let feed = await getFeed(FIRST_FEED);
        grabFeedAttachments(feed);
        while (feed._links.next && feed._items.length > 0) {
            feed = await getFeed(feed._links.next.href);
            grabFeedAttachments(feed);
        }

        await Promise.all(attachments.map((attachment, i) => downloadAttachment(attachment, i)));
    }

    function grabFeedAttachments(feed) {
        feed._items.forEach((item) => {
            item.contents.attachments?.forEach((attachment) => {
                if (typeof attachment.path === "string") {
                    attachments.push({
                        url: attachment.path,
                        time: item.time
                    });
                }
            });
        });
    }

    function downloadAttachment(attachment, counter) {
        return fetch(attachment.url).then((t) => {
            return t.blob().then((b) => {
                let a = document.createElement("a");
                a.href = URL.createObjectURL(b);

                let filename = String(counter+1) + "_" + attachment.time.split('T')[0];

                a.setAttribute("download", filename);
                a.click();
                URL.revokeObjectURL(a.href);
            });
        });
    }

    function createDownloadAllButton() {
        const downloadAllButton = document.createElement("button");
        downloadAllButton.textContent = "Download All";
        downloadAllButton.style.position = "fixed";
        downloadAllButton.style.top = "8.3%"; // Adjust Download All button location with style.top and left percentages
        downloadAllButton.style.left = "6%";
        downloadAllButton.style.zIndex = "9999";
        downloadAllButton.addEventListener("click", () => {
            downloadAttachments();
        });

        document.body.appendChild(downloadAllButton);
    }

    // Call the function to create the "Download All" button
    createDownloadAllButton();
})();

@MrMPM
Copy link

MrMPM commented Sep 4, 2023

When I go on Class Dojo website, the address is: https://home.classdojo.com/#/story
Should I use this or as per scripts above, /api/storyFeed? Anyway, neither works for me... @Loksly script does something, but returns error, other scripts return "undefined" immediately.

@neurolizer
Copy link

https://home.classdojo.com/api/storyFeed?withStudentCommentsAndLikes=true&withArchived=false is still the correct URL. If you go there directly, you can see the json (or whatever) formatted data that Loksly's script parses. If you just need a few pictures/videos, you can find the links in the api/storyFeed data and download them manually.
The code I posted is designed to be used inside the Tampermonkey extension for Chrome. It errors a lot for me too though, probably downloads less than half the pictures/videos because of CORS errors that I haven't found a way to fix.

@reloadfast
Copy link

Huge thank you @Loksly!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment