Skip to content

Instantly share code, notes, and snippets.

@bdf0506
Created August 11, 2026 13:21
Show Gist options
  • Select an option

  • Save bdf0506/02a3126757d863d2e20c5de018df4d32 to your computer and use it in GitHub Desktop.

Select an option

Save bdf0506/02a3126757d863d2e20c5de018df4d32 to your computer and use it in GitHub Desktop.

ClassDojo Story Feed Downloader

A Tampermonkey userscript that downloads your own child's ClassDojo story photos (and gives you a script for videos) straight to your computer — with the correct date and caption embedded into each photo, so tools like Google Photos display them properly instead of showing the day you happened to download them.

Runs entirely in your browser against your own logged-in ClassDojo session. Nothing is sent anywhere except ClassDojo's own servers (to fetch your feed) and your local disk (to save the files).

What it does

  • Scans your ClassDojo story feed and downloads every photo in a date range you choose
  • Fixes the date problem: photos normally show the date you downloaded them, not the date they were taken. This embeds the real date directly into each photo's EXIF data
  • Embeds the post's caption into each photo too, in both EXIF (ImageDescription) and XMP (XMP-dc:Description) — the latter is what Google Photos actually reads for its description field
  • Names files predictably: YYYYMMDD_HHMMSS_originalfilename.ext, sorted chronologically, stable across re-runs (the same photo always gets the same filename, even if ClassDojo's feed order changes between runs)
  • Skips anything you've already downloaded (content-hash based, so it's safe to re-run repeatedly, e.g. weekly)
  • Excludes .webp images by default (configurable — see below)
  • Remembers your chosen download folder after the first run — no repeated folder pickers
  • For videos: since browsers are blocked from reading cross-origin video bytes, videos can't be downloaded directly from the page. Instead, the script generates a download_videos.ps1 file you run once in Windows PowerShell to fetch them, with the same date/caption embedding applied via exiftool (optional — see below)

Requirements

  • Chrome or Edge (needs the File System Access API — Firefox and Safari aren't supported)
  • The Tampermonkey browser extension
  • Windows, if you want the video-download step (download_videos.ps1 is a PowerShell script). Photos work on any OS.
  • A ClassDojo parent account, logged in

Installation

  1. Install Tampermonkey for your browser.
  2. Click the Tampermonkey icon → Dashboard.
  3. Click the + tab to create a new script.
  4. Delete the default template content, and paste in the entire contents of classdojo-downloader.user.js from this repo.
  5. Save with Ctrl+S (or File → Save). Confirm the script appears and is enabled in the dashboard.

Usage

  1. Go to https://home.classdojo.com and log in.
  2. You should see a blue "Run ClassDojo Downloader" button floating in the bottom-right corner of the page.
  3. Click it. A small form appears asking for a date range (From/To) — leave either blank to leave that side unbounded. This is pre-filled with whatever's currently set in the script's config.
  4. Click Start. A log panel opens showing live progress. Photos download directly into the folder you pick (first run only — it's remembered after that).
  5. If any videos were found in that range, you'll see a note that download_videos.ps1 was saved into the same folder. Open Windows PowerShell (not WSL, not Command Prompt) and run:
    cd "<your download folder>"
    powershell -ExecutionPolicy Bypass -File .\download_videos.ps1
    curl.exe and PowerShell itself are already built into Windows 10/11, so nothing extra to install for this step.

If there are no videos in the range you picked, you're done after step 4 — no PowerShell needed at all.

Optional: embedding metadata into videos too

The video script will also embed the correct date and caption into each video file, but only if exiftool is installed and on your PATH (or dropped directly into the download folder as exiftool.exe). Without it, videos still download correctly, just without the embedded metadata.

Configuration

All settings live as var declarations near the top of the script. Edit, save (Ctrl+S), and re-run to pick up changes.

Variable Default Purpose
FIRST_FEED (all-students feed URL) The ClassDojo API endpoint to scan. Works for one child by default. If you have multiple children and want to filter to just one, add &studentId=<24-char-id> — find it in DevTools → Network tab on a storyFeed request.
MIN_DATE / MAX_DATE set via the on-page form each run Bounds the date range, YYYY-MM-DD. Can also be preset here as defaults — whatever's set here pre-fills the on-page date form each time you click Run. null on either one leaves that side unbounded (no lower/upper limit).
STOP_SCAN_WHEN_PAST_MIN_DATE true Stops paginating the feed early once it's scanned past MIN_DATE, instead of always walking your full post history. Assumes ClassDojo's feed is sorted newest-first. If you ever notice missing recent posts, set this to false to force a full scan. Has no effect if MIN_DATE is null.
REMEMBER_FOLDER true Remembers your chosen download folder (via IndexedDB) so you're not prompted every run. Set to false to always see the folder picker.
EXCLUDE_WEBP true Skips .webp images entirely — they won't be downloaded at all. ClassDojo occasionally serves photos as WEBP, which most photo viewers and EXIF/XMP tools handle less reliably than JPEG. Set to false to include them (they'll download fine, just without embedded date/caption metadata, same as PNG/GIF).
DELAY_MS 300 Delay between image downloads, in milliseconds — a light rate-limit courtesy to ClassDojo's servers.

There are also three internal variables (HANDLE_DB_NAME, HANDLE_STORE_NAME, HANDLE_KEY) used to name the IndexedDB storage that remembers your folder choice. These aren't settings — no reason to edit them unless you specifically want to reset/rename that storage.

Output files

Everything is saved into your chosen folder:

File What it is
YYYYMMDD_HHMMSS_*.jpg etc. The downloaded photos
post_metadata.json Full metadata for every photo/video: caption, sender, class, likes, comments, tags, etc.
download_videos.ps1 Only created if videos were found — run once in PowerShell to fetch them
.url_manifest.json, .content_hashes.txt Internal bookkeeping for skip-if-already-downloaded — don't need to touch these

Limitations and notes

  • JPEG only for embedded metadata. The date/caption embedding only works on JPEGs (the vast majority of what ClassDojo serves). PNG/GIF/WEBP photos still download fine, just without embedded EXIF/XMP.
  • Single-run trigger, not a scheduled job. This is a click-to-run tool, not a background/unattended automation. That's intentional — see the note on ClassDojo's Terms below.
  • The folder-remember feature is tied to your browser profile. If you clear site data for home.classdojo.com, use Incognito, or switch browsers/profiles, you'll be prompted to pick a folder again.
  • STOP_SCAN_WHEN_PAST_MIN_DATE assumes a newest-first feed. This has worked reliably in testing, but if you ever suspect it's skipping real posts, disable it and re-run.

A note on ClassDojo's Terms

ClassDojo's Terms of Service prohibit accessing the service "using automated means... without our prior permission." This script is designed to stay on the right side of that: it only runs when you manually click the button on your own logged-in session — nothing scheduled, nothing running in the background without you present. Use it accordingly (for your own account, on-demand, not as an unattended scraper), and at your own discretion/risk.

Credits

Based on this gist by travishorn, extended with date filtering, corrected local-time EXIF/XMP metadata embedding, stable filenames, folder persistence, and a Tampermonkey UI.

// ==UserScript==
// @name ClassDojo Story Feed Downloader
// @namespace http://tampermonkey.net/
// @version 1.0
// @description One-click download of ClassDojo story photos (corrected dates/captions) and a video-download script, for your own logged-in account.
// @author bdf0506
// @match https://home.classdojo.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=classdojo.com
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
// ClassDojo Story Feed Downloader (images + videos)
// Based on https://gist.github.com/travishorn/c2b6111a4e63efdbf87a1de84c833ab1#gistcomment-4647516
//
// Usage:
// 1. Log into https://home.classdojo.com in Chrome or Edge
// 2. Open DevTools console (F12 -> Console)
// 3. Paste this entire script and press Enter
// 4. First run: pick your download folder when prompted. Later runs will
// reuse that same folder automatically (see REMEMBER_FOLDER below).
// 5. Images download directly, with the correct local date and caption
// embedded into EXIF (JPEG only — see notes below for other formats).
// 6. Videos are saved as download_videos.ps1 (the CDN blocks the browser
// from reading video bytes directly, so this needs a separate step).
// Run it in Windows PowerShell (Start menu -> PowerShell, NOT WSL/bash):
// cd "<your download folder>"
// powershell -ExecutionPolicy Bypass -File .\download_videos.ps1
// Windows 10/11 already include curl.exe and PowerShell — nothing extra
// to install just to run it. exiftool.exe is optional (see the script's
// own header comment) for embedding CreateDate/caption into videos too.
//
// Re-run safe: hashes existing files first and skips anything already downloaded.
// Uses `var` throughout so you can re-paste without "redeclaration of const" errors.
//
// Note on the download folder: browsers can't let a webpage silently write to an
// arbitrary hardcoded path — that's blocked by design so a malicious site can't
// touch your filesystem without you ever seeing a prompt. What this script does
// instead is remember the folder you pick via the browser's IndexedDB storage
// (tied to home.classdojo.com in this browser/profile) and reuse it silently on
// later runs, so you only see the picker once. Set REMEMBER_FOLDER = false below
// to always be asked, or just clear this site's storage in Chrome settings to
// forget the saved folder and pick a new one.
// No studentId needed for a single-child account — omitting it returns the
// combined feed across everything connected to your account, which for one
// kid is just their full feed. (If you ever add a second child and want to
// filter to just one, add "&studentId=<24-char-id>" — find it in DevTools ->
// Network tab -> a "storyFeed" request while viewing that child's story.)
var FIRST_FEED =
"https://home.classdojo.com/api/storyFeed?withStudentCommentsAndLikes=true&withArchived=false";
// Only keep posts on/after this date (YYYY-MM-DD). Set to null to disable.
var MIN_DATE = "2026-08-01";
// Only keep posts on/before this date (YYYY-MM-DD). Set to null to disable.
// Useful for pulling an older historical batch, e.g. MIN_DATE="2025-01-01", MAX_DATE="2025-12-31".
var MAX_DATE = null;
// If true (and MIN_DATE is set), stop paginating the feed as soon as a page's
// newest post is older than MIN_DATE, instead of scanning your entire post
// history every run. This assumes ClassDojo's feed is sorted newest-first,
// which matches its normal pagination behavior — but if you ever notice
// fewer results than expected (missing recent posts), set this to false to
// force a full scan, and that would mean the assumption doesn't hold for
// your account.
var STOP_SCAN_WHEN_PAST_MIN_DATE = true;
// If true, remember the folder you pick and reuse it silently on future runs
// (via IndexedDB in this browser/profile). If false, you'll get the folder
// picker every time.
var REMEMBER_FOLDER = true;
// If true, skip .webp images entirely (they won't be downloaded at all).
// ClassDojo occasionally serves photos as WEBP, which most photo viewers and
// EXIF/XMP tools handle less reliably than JPEG. Set to false to include them.
var EXCLUDE_WEBP = true;
// Delay between image downloads in ms
var DELAY_MS = 300;
function getFeed(url) {
return fetch(url, {
headers: {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
"cache-control": "no-cache",
pragma: "no-cache",
"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(function (r) {
if (!r.ok) {
var err = new Error("HTTP " + r.status + " fetching feed");
err.status = r.status;
throw err;
}
return r.json();
});
}
function grabFeedAttachments(feed) {
var results = [];
for (var item of feed._items) {
for (var att of (item.contents.attachments ?? [])) {
if (typeof att.path === "string") {
results.push({
url: att.path,
time: item.time,
studentName: item.contents.studentName ?? "unknown",
post: {
id: item._id,
senderName: item.senderName ?? "",
className: item.headerSubtext ?? "",
body: item.contents.body ?? "",
type: item.type ?? "",
likeCount: item.likeCount ?? 0,
commentCount: item.commentCount ?? 0,
tags: item.tags ?? [],
},
attachment: {
id: att._id ?? "",
type: att.type ?? "",
originalFilename: att.metadata?.filename ?? "",
width: att.metadata?.width ?? null,
height: att.metadata?.height ?? null,
},
});
}
}
}
return results;
}
function extensionFromUrl(url) {
try {
var pathname = new URL(url).pathname;
var ext = pathname.split(".").pop()?.toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp", "mp4", "mov", "heic"].includes(ext)) {
return "." + ext;
}
} catch (e) {}
return ".jpg";
}
function urlPathKey(url) {
try { return new URL(url).pathname; } catch (e) { return url; }
}
function sanitizeForFilename(text, maxLen) {
if (!text) return "";
maxLen = maxLen || 80;
var safe = text
.replace(/\n/g, " ")
.replace(/[\/\\:*?"<>|]/g, "")
.replace(/[\u{FE00}-\u{FE0F}\u{200D}]/gu, "")
.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{2B50}\u{2934}-\u{2935}\u{25AA}-\u{25FE}\u{2190}-\u{21FF}\u{0080}-\u{00FF}]/gu, "")
.replace(/\s+/g, " ")
.trim();
if (safe.length > maxLen) {
safe = safe.substring(0, maxLen).trim();
}
return safe;
}
function sanitizeCaption(text, maxLen) {
if (!text) return "";
maxLen = maxLen || 900;
// EXIF's ImageDescription field only reliably supports Latin-1 (code points 0-255);
// piexifjs uses btoa() internally, which throws on anything outside that range.
// First swap common "smart" punctuation for plain ASCII equivalents so we don't
// lose those unnecessarily...
var normalized = text
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
.replace(/[\u2013\u2014]/g, "-")
.replace(/\u2026/g, "...")
.replace(/\u00A0/g, " ");
// ...then drop anything still outside Latin-1 (emoji, CJK, Cyrillic, etc.) so the
// EXIF write doesn't fail. The full original caption (emoji and all) is always
// preserved separately in post_metadata.json regardless of this filtering.
var latin1Only = Array.from(normalized)
.filter(function (ch) { return ch.codePointAt(0) <= 255; })
.join("");
var safe = latin1Only.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "").trim();
if (safe.length > maxLen) {
safe = safe.substring(0, maxLen).trim() + "...";
}
return safe;
}
// Escapes a string for safe embedding inside a PowerShell single-quoted literal
// ('' represents one literal quote; everything else in single quotes is verbatim).
function escapePs(s) { return s.replace(/'/g, "''"); }
function isVideoUrl(url) {
try {
var ext = new URL(url).pathname.split(".").pop()?.toLowerCase();
return ["mp4", "mov", "webm"].includes(ext);
} catch (e) {}
return false;
}
function isWebpUrl(url) {
try {
var ext = new URL(url).pathname.split(".").pop()?.toLowerCase();
return ext === "webp";
} catch (e) {}
return false;
}
function sleep(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
async function hashBlob(blob) {
var buffer = await blob.arrayBuffer();
var hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
var hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(function (b) { return b.toString(16).padStart(2, "0"); }).join("");
}
// ---- Date formatting helpers ----
function formatExifDate(isoString) {
// EXIF carries NO timezone info — the field is meant to hold plain local
// wall-clock time, not UTC. ClassDojo's item.time is UTC ("...Z"), so we
// convert to the LOCAL time of the computer running this script (assumed to
// be the same timezone the events actually happened in) before formatting.
// Using UTC components directly here would shift displayed times by your
// UTC offset (e.g. a 7:30pm post showing as 11:30pm for US Eastern).
var d = new Date(isoString);
var pad = function (n) { return String(n).padStart(2, "0"); };
return d.getFullYear() + ":" + pad(d.getMonth() + 1) + ":" + pad(d.getDate()) +
" " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
}
// Local (not UTC) calendar date as "YYYY-MM-DD" — used for filenames and the
// MIN_DATE/MAX_DATE filter, so a post made late at night in your local timezone
// isn't mislabeled with the next UTC day's date.
function localDateOnly(isoString) {
var d = new Date(isoString);
var pad = function (n) { return String(n).padStart(2, "0"); };
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
}
// Same as localDateOnly but without dashes ("20260810") — used only for
// filenames. Kept separate from localDateOnly because that one's dashed
// "YYYY-MM-DD" format has to match MIN_DATE/MAX_DATE for the date filter.
function localDateForFilename(isoString) {
var d = new Date(isoString);
var pad = function (n) { return String(n).padStart(2, "0"); };
return "" + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate());
}
// Local time-of-day as "HHMMSS" — paired with localDateOnly so filenames sort
// chronologically and don't depend on this run's array position.
function localTimeOnlyHHMMSS(isoString) {
var d = new Date(isoString);
var pad = function (n) { return String(n).padStart(2, "0"); };
return pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds());
}
// A short, stable, content-derived suffix for filenames — uses ClassDojo's own
// attachment ID when available (guaranteed unique, identical on every run), or
// falls back to a deterministic hash of the URL if an ID is ever missing. This
// is what makes filenames reproducible: the same photo always gets the exact
// same filename, regardless of feed order, pagination, or what else changed.
function stableSuffix(attachmentId, url) {
if (attachmentId) return attachmentId.slice(-8);
var h = 0;
for (var i = 0; i < url.length; i++) { h = (h * 31 + url.charCodeAt(i)) >>> 0; }
return h.toString(16).padStart(8, "0").slice(-8);
}
// The name portion of "date_time_name.ext" — uses ClassDojo's own original
// filename (e.g. "IMG_1234.JPG") when available, stripped of its own
// extension since we add our own derived one. Falls back to the same stable
// attachment-ID suffix used elsewhere on the rare occasions ClassDojo didn't
// supply an original filename, so two different attachments can never
// silently collide onto the same filename.
function baseNameFromOriginal(originalFilename, attachmentId, url) {
if (originalFilename) {
var withoutExt = originalFilename.replace(/\.[a-zA-Z0-9]+$/, "");
var cleaned = sanitizeForFilename(withoutExt, 100);
if (cleaned) return cleaned;
}
return stableSuffix(attachmentId, url);
}
// ---- EXIF injection for JPEG photos ----
var _piexifPromise = null;
function loadPiexif() {
if (window.piexif) return Promise.resolve(window.piexif);
if (_piexifPromise) return _piexifPromise;
_piexifPromise = new Promise(function (resolve, reject) {
var s = document.createElement("script");
s.src = "https://cdn.jsdelivr.net/npm/piexifjs@1.0.6/piexif.js";
s.onload = function () { resolve(window.piexif); };
s.onerror = function () { reject(new Error("Failed to load piexifjs from CDN")); };
document.head.appendChild(s);
});
return _piexifPromise;
}
function blobToDataURL(blob) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () { resolve(reader.result); };
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function dataURLtoBlob(dataUrl) {
var parts = dataUrl.split(",");
var mimeMatch = parts[0].match(/:(.*?);/);
var mime = mimeMatch ? mimeMatch[1] : "image/jpeg";
var binary = atob(parts[1]);
var array = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return new Blob([array], { type: mime });
}
function dataURLtoUint8Array(dataUrl) {
var binary = atob(dataUrl.split(",")[1]);
var array = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return array;
}
// Unlike sanitizeCaption (used for EXIF, which is Latin-1 only), XMP is plain
// UTF-8 XML and supports full Unicode natively — emoji included. Only strips
// control characters that would break XML.
function sanitizeForXml(text, maxLen) {
if (!text) return "";
maxLen = maxLen || 1500;
var safe = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "").trim();
if (safe.length > maxLen) {
safe = safe.substring(0, maxLen).trim() + "...";
}
return safe;
}
function xmlEscapeForXmp(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function buildXmpDescriptionPacket(caption) {
var esc = xmlEscapeForXmp(caption);
return '<?xpacket begin="\uFEFF" id="W5M0MpCehiHzreSzNTczkc9d"?>' +
'<x:xmpmeta xmlns:x="adobe:ns:meta/">' +
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
'<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
'<dc:description><rdf:Alt><rdf:li xml:lang="x-default">' + esc + '</rdf:li></rdf:Alt></dc:description>' +
'</rdf:Description>' +
'</rdf:RDF>' +
'</x:xmpmeta>' +
'<?xpacket end="w"?>';
}
// Splices a standard XMP APP1 segment into a JPEG byte array, placed right
// after any existing APPn segments (e.g. the EXIF block written just before
// this runs) and before the first non-APPn marker. This is what lets Google
// Photos (and most other XMP-aware tools) actually read the caption as a
// "description" — unlike EXIF's ImageDescription field, XMP supports full
// Unicode natively, so nothing needs to be stripped here. Verified against
// exiftool and an independent JPEG decoder (Pillow) to confirm this doesn't
// corrupt the file or alter the underlying image data — only adds a header segment.
function insertXmpDescriptionSegment(uint8, caption) {
if (uint8[0] !== 0xFF || uint8[1] !== 0xD8) return uint8; // not a JPEG — leave untouched
var xmpXml = buildXmpDescriptionPacket(caption);
var xmpHeader = "http://ns.adobe.com/xap/1.0/\0";
var headerBytes = [];
for (var i = 0; i < xmpHeader.length; i++) headerBytes.push(xmpHeader.charCodeAt(i));
var xmlBytes = new TextEncoder().encode(xmpXml);
var payloadLen = headerBytes.length + xmlBytes.length;
if (payloadLen + 2 > 65535) return uint8; // caption too large for one APP1 segment (very unlikely) — skip rather than risk a malformed file
var segLen = payloadLen + 2;
var segment = new Uint8Array(2 + 2 + payloadLen);
segment[0] = 0xFF; segment[1] = 0xE1;
segment[2] = (segLen >> 8) & 0xFF; segment[3] = segLen & 0xFF;
segment.set(headerBytes, 4);
segment.set(xmlBytes, 4 + headerBytes.length);
// Walk segments after SOI; insert right after the last contiguous APPn
// segment (e.g. EXIF) and before the first non-APPn marker (DQT/SOF/etc).
var offset = 2;
while (offset + 4 <= uint8.length) {
if (uint8[offset] !== 0xFF) break;
var marker = uint8[offset + 1];
if (marker === 0xD8 || marker === 0xD9) break;
if (marker >= 0xE0 && marker <= 0xEF) {
var len = (uint8[offset + 2] << 8) | uint8[offset + 3];
offset += 2 + len;
continue;
}
break;
}
var result = new Uint8Array(uint8.length + segment.length);
result.set(uint8.subarray(0, offset), 0);
result.set(segment, offset);
result.set(uint8.subarray(offset), offset + segment.length);
return result;
}
// Returns a new blob with EXIF DateTimeOriginal, EXIF ImageDescription, and
// XMP-dc:Description (Google Photos' actual description field) embedded, if
// blob is a JPEG. Non-JPEG blobs (png/gif/webp) are returned unchanged —
// both piexifjs and this XMP injector are JPEG-specific.
async function setExifMetadataIfJpeg(blob, isoTime, caption) {
var isJpeg = blob.type === "image/jpeg" || blob.type === "image/jpg";
if (!isJpeg) return blob;
try {
var piexif = await loadPiexif();
var dataUrl = await blobToDataURL(blob);
var exifDate = formatExifDate(isoTime);
var exifObj;
try {
exifObj = piexif.load(dataUrl);
} catch (e) {
exifObj = { "0th": {}, "Exif": {}, "GPS": {}, "1st": {} };
}
exifObj["0th"][piexif.ImageIFD.DateTime] = exifDate;
var exifIfd = exifObj["Exif"];
exifIfd[piexif.ExifIFD.DateTimeOriginal] = exifDate;
exifIfd[piexif.ExifIFD.DateTimeDigitized] = exifDate;
var cleanCaption = sanitizeCaption(caption); // Latin-1-safe, for EXIF
if (cleanCaption) {
exifObj["0th"][piexif.ImageIFD.ImageDescription] = cleanCaption;
}
var exifBytes = piexif.dump(exifObj);
var afterExifDataUrl = piexif.insert(exifBytes, dataUrl);
var xmlCaption = sanitizeForXml(caption); // full Unicode, for XMP
if (!xmlCaption) {
return dataURLtoBlob(afterExifDataUrl);
}
var afterExifBytes = dataURLtoUint8Array(afterExifDataUrl);
var finalBytes = insertXmpDescriptionSegment(afterExifBytes, xmlCaption);
return new Blob([finalBytes], { type: "image/jpeg" });
} catch (e) {
console.warn(" EXIF/XMP write failed, saving without embedded date/caption: " + e.message);
return blob;
}
}
// ---- Manifest / hash / metadata persistence (downloaded files) ----
async function loadUrlManifest(dirHandle) {
try {
var fh = await dirHandle.getFileHandle(".url_manifest.json");
var file = await fh.getFile();
var text = await file.text();
return JSON.parse(text);
} catch (e) {
return {};
}
}
async function saveUrlManifest(dirHandle, manifest) {
var fh = await dirHandle.getFileHandle(".url_manifest.json", { create: true });
var writable = await fh.createWritable();
await writable.write(JSON.stringify(manifest));
await writable.close();
}
async function saveContentHashes(dirHandle, hashes) {
var fh = await dirHandle.getFileHandle(".content_hashes.txt", { create: true });
var writable = await fh.createWritable();
var lines = [];
for (var h of hashes) { lines.push(h); }
await writable.write(lines.join("\n") + "\n");
await writable.close();
}
async function loadPostMetadata(dirHandle) {
try {
var fh = await dirHandle.getFileHandle("post_metadata.json");
var file = await fh.getFile();
var text = await file.text();
return JSON.parse(text);
} catch (e) {
return {};
}
}
async function savePostMetadata(dirHandle, metadata) {
var fh = await dirHandle.getFileHandle("post_metadata.json", { create: true });
var writable = await fh.createWritable();
await writable.write(JSON.stringify(metadata, null, 2));
await writable.close();
}
// ---- Remembered download folder (IndexedDB-backed) ----
var HANDLE_DB_NAME = "classdojo_downloader";
var HANDLE_STORE_NAME = "handles";
var HANDLE_KEY = "downloadDir";
function openHandleDb() {
return new Promise(function (resolve, reject) {
var req = indexedDB.open(HANDLE_DB_NAME, 1);
req.onupgradeneeded = function () {
req.result.createObjectStore(HANDLE_STORE_NAME);
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
async function saveDirHandle(handle) {
var db = await openHandleDb();
return new Promise(function (resolve, reject) {
var tx = db.transaction(HANDLE_STORE_NAME, "readwrite");
tx.objectStore(HANDLE_STORE_NAME).put(handle, HANDLE_KEY);
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
});
}
async function loadDirHandle() {
var db = await openHandleDb();
return new Promise(function (resolve, reject) {
var tx = db.transaction(HANDLE_STORE_NAME, "readonly");
var req = tx.objectStore(HANDLE_STORE_NAME).get(HANDLE_KEY);
req.onsuccess = function () { resolve(req.result || null); };
req.onerror = function () { reject(req.error); };
});
}
// Returns a usable directory handle: silently reuses a previously-picked folder
// (if REMEMBER_FOLDER is true and permission is still valid), otherwise shows
// the native folder picker and remembers the choice for next time.
async function getDownloadDirHandle() {
if (REMEMBER_FOLDER) {
try {
var stored = await loadDirHandle();
if (stored) {
var perm = await stored.queryPermission({ mode: "readwrite" });
if (perm === "prompt") {
perm = await stored.requestPermission({ mode: "readwrite" });
}
if (perm === "granted") {
console.log("Using remembered folder: " + stored.name + "/ (set REMEMBER_FOLDER = false to change)");
return stored;
}
}
} catch (e) {
console.warn("Could not reuse the saved folder (" + e.message + ") — you'll be asked to pick again.");
}
}
var handle;
try {
handle = await window.showDirectoryPicker({ mode: "readwrite" });
} catch (e) {
if (e.name === "AbortError") {
console.error("Folder selection cancelled. Aborting.");
} else {
console.error("Folder picker failed (" + e.name + "): " + e.message + ". Aborting.");
}
return null;
}
if (REMEMBER_FOLDER) {
try {
await saveDirHandle(handle);
console.log("Remembered '" + handle.name + "' — future runs won't prompt for a folder.");
} catch (e) {
console.warn("Picked '" + handle.name + "' but couldn't remember it for next time: " + e.message);
}
}
return handle;
}
async function downloadAllClassDojo() {
if (typeof window.showDirectoryPicker !== "function") {
console.error(
"WRONG BROWSER: this browser has no window.showDirectoryPicker (File System Access API).\n" +
"Firefox and Safari can't run this script — use Chrome or Edge (step 1 of the usage notes)."
);
return;
}
if (location.hostname !== "home.classdojo.com") {
console.error(
"WRONG PAGE: run this from a tab on https://home.classdojo.com (you're on " + location.hostname + ").\n" +
"Log in there, open the console on that tab, and re-paste the script."
);
return;
}
var dirHandle = await getDownloadDirHandle();
if (!dirHandle) return;
console.log("Saving to: " + dirHandle.name + "/");
// Phase 1: Load URL manifest — derive content hashes from its values
var rawManifest = await loadUrlManifest(dirHandle);
var urlManifest = {};
var migrated = 0;
for (var key in rawManifest) {
var pathKey = urlPathKey(key);
if (pathKey !== key) migrated++;
urlManifest[pathKey] = rawManifest[key];
}
var existingHashes = new Set(Object.values(urlManifest));
console.log("Loaded URL manifest (" + Object.keys(urlManifest).length + " URLs, " + existingHashes.size + " unique hashes" + (migrated > 0 ? ", migrated " + migrated + " keys to path-only" : "") + ").");
// Phase 2: Scan feed pages (with early exit once we're past MIN_DATE — see
// STOP_SCAN_WHEN_PAST_MIN_DATE above)
console.log("Fetching story feed pages...");
var attachments = [];
var pageCount = 0;
// Returns true if every item on this page is older than MIN_DATE — meaning
// (assuming newest-first order) every later page will be too.
function pageIsPastMinDate(feedPage) {
if (!MIN_DATE || !STOP_SCAN_WHEN_PAST_MIN_DATE) return false;
if (!feedPage._items || feedPage._items.length === 0) return false;
var newestInPage = null;
for (var pi = 0; pi < feedPage._items.length; pi++) {
var d = localDateOnly(feedPage._items[pi].time);
if (newestInPage === null || d > newestInPage) newestInPage = d;
}
return newestInPage !== null && newestInPage < MIN_DATE;
}
try {
var feed = await getFeed(FIRST_FEED);
if (!Array.isArray(feed._items)) {
console.error(
"UNEXPECTED FEED RESPONSE (no _items array) — you may not be logged in, " +
"or FIRST_FEED's URL got mangled. Raw response:", feed
);
return;
}
attachments.push(...grabFeedAttachments(feed));
pageCount = 1;
var stoppedEarly = pageIsPastMinDate(feed);
if (stoppedEarly) {
console.log("Reached posts older than MIN_DATE (" + MIN_DATE + ") on the first page — stopping scan early.");
}
while (!stoppedEarly && feed._links?.next && feed._items.length > 0) {
feed = await getFeed(feed._links.next.href);
attachments.push(...grabFeedAttachments(feed));
pageCount++;
console.log(" Scanned page " + pageCount + " (" + attachments.length + " attachments so far)");
if (pageIsPastMinDate(feed)) {
stoppedEarly = true;
console.log(
"Reached posts older than MIN_DATE (" + MIN_DATE + ") — stopping scan early " +
"(assumes the feed is newest-first; set STOP_SCAN_WHEN_PAST_MIN_DATE = false if this seems to skip real posts)."
);
}
}
} catch (e) {
if (e.status === 401 || e.status === 403) {
console.error(
"NOT LOGGED IN (HTTP " + e.status + "): the feed request was rejected.\n" +
"Log into home.classdojo.com in this tab, then re-paste the script."
);
return;
}
if (e.status === 400 || e.status === 404) {
console.error(
"REQUEST REJECTED (HTTP " + e.status + "): if you've added a &studentId= to FIRST_FEED, " +
"it's probably wrong — find the correct one in DevTools -> Network tab -> a 'storyFeed' " +
"request while viewing that child's story. Otherwise, this may be an unrelated API error."
);
return;
}
if (attachments.length > 0) {
console.warn(
"Feed scan stopped early on page " + (pageCount + 1) + " (" + e.message + "). " +
"Continuing with the " + attachments.length + " attachments found so far — re-run later to pick up the rest."
);
} else {
console.error("Failed to fetch story feed: " + e.message);
return;
}
}
if (attachments.length === 0) {
console.error(
"Feed loaded but contained no attachments (" + pageCount + " page(s) scanned). " +
"If you expected photos here, double-check MIN_DATE/MAX_DATE and that you're logged into the right account."
);
return;
}
// Phase 2b: Date filter
if (MIN_DATE || MAX_DATE) {
var beforeFilterCount = attachments.length;
attachments = attachments.filter(function (a) {
var d = localDateOnly(a.time);
if (MIN_DATE && d < MIN_DATE) return false;
if (MAX_DATE && d > MAX_DATE) return false;
return true;
});
var rangeDesc = (MIN_DATE || "…") + " to " + (MAX_DATE || "…");
console.log("Date filter (" + rangeDesc + "): kept " + attachments.length + " of " + beforeFilterCount + " attachments.");
if (attachments.length === 0) {
console.error("No attachments in range " + rangeDesc + ". Nothing to download.");
return;
}
}
// Split into images and videos
var images = [];
var videos = [];
var excludedWebpCount = 0;
for (var att of attachments) {
if (isVideoUrl(att.url)) {
videos.push(att);
} else if (EXCLUDE_WEBP && isWebpUrl(att.url)) {
excludedWebpCount++;
} else {
images.push(att);
}
}
console.log("Found " + attachments.length + " total: " + images.length + " images, " + videos.length + " videos across " + pageCount + " pages.");
if (excludedWebpCount > 0) {
console.log("Excluded " + excludedWebpCount + " .webp image(s) (set EXCLUDE_WEBP = false to include them).");
}
var postMetadata = await loadPostMetadata(dirHandle);
// Phase 3: Generate a Windows PowerShell script for videos (CORS-blocked CDN) —
// early, so you can run it in parallel while images download below.
if (videos.length > 0) {
console.log("\n--- Generating download script for " + videos.length + " videos ---");
var lines = [
"# ClassDojo video download script (Windows PowerShell)",
"# Generated " + new Date().toISOString(),
"# " + videos.length + " videos",
"#",
"# Sets each file's timestamps to the date it was actually posted, and — if",
"# exiftool.exe is available — also writes CreateDate/MediaCreateDate into",
"# the video itself, which is what Google Photos actually reads.",
"# Optional: get exiftool for Windows at https://exiftool.org, rename",
'# "exiftool(-k).exe" to "exiftool.exe", and put it on PATH or in this folder.',
"#",
"# Run from Windows PowerShell (Start menu -> PowerShell), NOT WSL/bash:",
"# cd \"<your download folder>\"",
"# powershell -ExecutionPolicy Bypass -File .\\download_videos.ps1",
"",
"$ErrorActionPreference = 'Stop'",
"Set-Location -Path $PSScriptRoot",
"",
"$hashFile = '.content_hashes.txt'",
"$existingHashes = New-Object System.Collections.Generic.HashSet[string]",
"if (Test-Path $hashFile) {",
" Get-Content $hashFile | ForEach-Object { if ($_ -ne '') { [void]$existingHashes.Add($_.ToLower()) } }",
"}",
"",
"$exiftoolPath = $null",
"$exiftoolCmd = Get-Command exiftool.exe -ErrorAction SilentlyContinue",
"if ($exiftoolCmd) { $exiftoolPath = $exiftoolCmd.Source }",
"elseif (Test-Path '.\\exiftool.exe') { $exiftoolPath = (Resolve-Path '.\\exiftool.exe').Path }",
"",
"$downloaded = 0",
"$skipped = 0",
"$failed = 0",
"",
];
for (var v = 0; v < videos.length; v++) {
var vid = videos[v];
var vExt = extensionFromUrl(vid.url);
var vDate = localDateForFilename(vid.time);
var vTime = localTimeOnlyHHMMSS(vid.time);
var vBaseName = baseNameFromOriginal(vid.attachment.originalFilename, vid.attachment.id, vid.url);
var vFilename = "vid_" + vDate + "_" + vTime + "_" + vBaseName + vExt;
var vExifDate = formatExifDate(vid.time);
postMetadata[vFilename] = {
postId: vid.post.id,
time: vid.time,
url: vid.url,
senderName: vid.post.senderName,
className: vid.post.className,
body: vid.post.body,
postType: vid.post.type,
likeCount: vid.post.likeCount,
commentCount: vid.post.commentCount,
tags: vid.post.tags,
attachmentType: vid.attachment.type,
originalFilename: vid.attachment.originalFilename,
width: vid.attachment.width,
height: vid.attachment.height,
};
var escapedUrl = escapePs(vid.url);
var escapedFilename = escapePs(vFilename);
var vCaption = sanitizeCaption(vid.post.body); // Latin-1-safe, for -Description/-Comment
var escapedCaption = escapePs(vCaption);
var vCaptionXml = sanitizeForXml(vid.post.body); // full Unicode, for -XMP-dc:Description
var escapedCaptionXml = escapePs(vCaptionXml);
lines.push("$tmp = [System.IO.Path]::GetTempFileName()");
lines.push("try {");
lines.push(" & curl.exe -s -L -o $tmp '" + escapedUrl + "'");
lines.push(" if ($LASTEXITCODE -ne 0 -or -not (Test-Path $tmp) -or (Get-Item $tmp).Length -eq 0) { throw 'download failed' }");
lines.push(" $newh = (Get-FileHash -Algorithm SHA256 -Path $tmp).Hash.ToLower()");
lines.push(" if ($existingHashes.Contains($newh)) {");
lines.push(" $skipped++");
lines.push(" Remove-Item $tmp -Force -ErrorAction SilentlyContinue");
lines.push(" } else {");
lines.push(" Move-Item -Force -Path $tmp -Destination '" + escapedFilename + "'");
lines.push(" $dt = [datetime]::Parse('" + vid.time + "', $null, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal)");
lines.push(" (Get-Item '" + escapedFilename + "').LastWriteTimeUtc = $dt");
lines.push(" (Get-Item '" + escapedFilename + "').CreationTimeUtc = $dt");
lines.push(" if ($exiftoolPath) {");
var exifArgs = [
"'-overwrite_original'", "'-q'", "'-q'",
"'-CreateDate=" + vExifDate + "'", "'-ModifyDate=" + vExifDate + "'",
"'-MediaCreateDate=" + vExifDate + "'", "'-TrackCreateDate=" + vExifDate + "'",
];
if (vCaption) {
exifArgs.push("'-Description=" + escapedCaption + "'", "'-Comment=" + escapedCaption + "'");
}
if (vCaptionXml) {
exifArgs.push("'-XMP-dc:Description=" + escapedCaptionXml + "'");
}
exifArgs.push("'" + escapedFilename + "'");
lines.push(" & $exiftoolPath " + exifArgs.join(" ") + " *> $null");
lines.push(" }");
lines.push(" Add-Content -Path $hashFile -Value $newh");
lines.push(" [void]$existingHashes.Add($newh)");
lines.push(" $downloaded++");
lines.push(" }");
lines.push("} catch {");
lines.push(" $failed++");
lines.push(" Write-Warning ('Failed: ' + '" + escapedFilename + "' + ' - ' + $_.Exception.Message)");
lines.push("} finally {");
lines.push(" if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }");
lines.push("}");
lines.push("");
}
lines.push("Write-Host \"Videos done: $downloaded new, $skipped already had, $failed failed.\"");
lines.push("if (-not $exiftoolPath) {");
lines.push(' Write-Host "(exiftool.exe not found - videos only got corrected timestamps, not embedded metadata."');
lines.push(' Write-Host " Get it from https://exiftool.org and re-run for the more reliable fix.)"');
lines.push("}");
var script = lines.join("\n") + "\n";
var scriptHandle = await dirHandle.getFileHandle("download_videos.ps1", { create: true });
var scriptWritable = await scriptHandle.createWritable();
await scriptWritable.write(script);
await scriptWritable.close();
console.log("Saved download_videos.ps1 (" + videos.length + " videos). Run it now in PowerShell while images download:");
console.log(" cd \"C:\\Users\\<you>\\Pictures\\TobiasDojo\"; powershell -ExecutionPolicy Bypass -File .\\download_videos.ps1");
}
// Phase 4: Download images (fetch + File System Access API)
var saved = 0;
var skipped = 0;
var failed = 0;
console.log("\n--- Downloading " + images.length + " images ---");
for (var i = 0; i < images.length; i++) {
var img = images[i];
var ext = extensionFromUrl(img.url);
var datePart = localDateForFilename(img.time);
var timePart = localTimeOnlyHHMMSS(img.time);
var baseName = baseNameFromOriginal(img.attachment.originalFilename, img.attachment.id, img.url);
var filename = datePart + "_" + timePart + "_" + baseName + ext;
try {
var manifestKey = urlPathKey(img.url);
var cachedHash = urlManifest[manifestKey];
var outcome;
if (cachedHash && existingHashes.has(cachedHash)) {
skipped++;
outcome = "skipped (already have it)";
} else {
var response = await fetch(img.url);
if (!response.ok) throw new Error("HTTP " + response.status);
var blob = await response.blob();
// Hash the ORIGINAL bytes so dedupe stays compatible with earlier runs,
// even though we save an EXIF-modified copy to disk below.
var hash = await hashBlob(blob);
if (existingHashes.has(hash)) {
skipped++;
outcome = "skipped (duplicate content)";
} else {
var blobToSave = await setExifMetadataIfJpeg(blob, img.time, img.post.body);
var fileHandle = await dirHandle.getFileHandle(filename, { create: true });
var writable = await fileHandle.createWritable();
await writable.write(blobToSave);
await writable.close();
existingHashes.add(hash);
saved++;
outcome = "saved";
}
urlManifest[manifestKey] = hash;
if (i < images.length - 1) {
await sleep(DELAY_MS);
}
}
var metaHash = urlManifest[manifestKey] ?? "";
postMetadata[filename] = {
contentHash: metaHash,
postId: img.post.id,
time: img.time,
url: img.url,
senderName: img.post.senderName,
className: img.post.className,
body: img.post.body,
postType: img.post.type,
likeCount: img.post.likeCount,
commentCount: img.post.commentCount,
tags: img.post.tags,
attachmentType: img.attachment.type,
originalFilename: img.attachment.originalFilename,
width: img.attachment.width,
height: img.attachment.height,
};
console.log(" [" + (i + 1) + "/" + images.length + "] " + filename + " — " + outcome);
if ((i + 1) % 50 === 0 || i === images.length - 1) {
console.log(" Images: " + (i + 1) + "/" + images.length + " (" + saved + " new, " + skipped + " skipped, " + failed + " failed)");
}
} catch (e) {
failed++;
console.warn(" Failed [" + i + "] " + filename + ": " + e.message);
}
}
await saveUrlManifest(dirHandle, urlManifest);
await saveContentHashes(dirHandle, existingHashes);
console.log("Images done: " + saved + " new, " + skipped + " already had, " + failed + " failed.");
await savePostMetadata(dirHandle, postMetadata);
console.log("Saved post_metadata.json (" + Object.keys(postMetadata).length + " entries).");
console.log(
"\nAll done! " + saved + " new images, " + skipped + " skipped, " + failed + " failed" +
(videos.length > 0 ? ", " + videos.length + " video(s) queued in download_videos.ps1" : "") + "."
);
}
// ---- Inject a small floating "Run" button onto the page ----
// Runs on page load, but does NOT call downloadAllClassDojo() automatically —
// showDirectoryPicker() requires a genuine user click, so nothing happens
// until you actually press this button.
function injectRunButton() {
if (document.getElementById("cd-downloader-btn")) return; // already injected
var btn = document.createElement("button");
btn.id = "cd-downloader-btn";
btn.textContent = "Run ClassDojo Downloader";
btn.style.cssText =
"position:fixed;bottom:20px;right:20px;z-index:999999;" +
"padding:10px 16px;font-size:14px;font-family:sans-serif;" +
"background:#1a73e8;color:#fff;border:none;border-radius:6px;" +
"cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,0.3);";
// On-page log panel — mirrors console.log/warn/error while a run is in
// progress, so you can watch what's happening without opening DevTools.
// Hidden until the first run starts.
var panel = document.createElement("div");
panel.id = "cd-downloader-log";
panel.style.cssText =
"position:fixed;bottom:64px;right:20px;z-index:999999;" +
"width:500px;display:none;" +
"background:#1e1e1e;color:#ddd;font:12px/1.4 Menlo,Consolas,monospace;" +
"border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,0.4);overflow:hidden;";
var panelHeader = document.createElement("div");
panelHeader.style.cssText =
"display:flex;justify-content:space-between;align-items:center;" +
"padding:6px 10px;background:#2a2a2a;border-bottom:1px solid #3a3a3a;";
var panelTitle = document.createElement("span");
panelTitle.textContent = "ClassDojo Downloader log";
panelTitle.style.cssText = "color:#aaa;font-size:11px;";
var closeBtn = document.createElement("button");
closeBtn.textContent = "×";
closeBtn.title = "Close log";
closeBtn.style.cssText =
"background:none;border:none;color:#aaa;font-size:16px;line-height:1;" +
"cursor:pointer;padding:0 4px;";
closeBtn.addEventListener("click", function () {
panel.style.display = "none";
});
panelHeader.appendChild(panelTitle);
panelHeader.appendChild(closeBtn);
var logContent = document.createElement("div");
logContent.style.cssText = "max-height:260px;overflow-y:auto;padding:10px;white-space:pre-wrap;word-break:break-word;";
panel.appendChild(panelHeader);
panel.appendChild(logContent);
document.body.appendChild(panel);
function logLine(text, kind) {
var line = document.createElement("div");
line.textContent = text;
if (kind === "warn") line.style.color = "#f2c94c";
if (kind === "error") line.style.color = "#eb5757";
logContent.appendChild(line);
logContent.scrollTop = logContent.scrollHeight;
}
// Small form for choosing MIN_DATE/MAX_DATE per run, pre-filled with
// whatever's currently set in the script. Shown when Run is clicked;
// the actual download only starts once Start is pressed here.
var form = document.createElement("div");
form.id = "cd-downloader-form";
form.style.cssText =
"position:fixed;bottom:64px;right:20px;z-index:999999;width:220px;" +
"display:none;background:#fff;border:1px solid #ccc;border-radius:6px;" +
"padding:12px;box-shadow:0 2px 6px rgba(0,0,0,0.3);font-family:sans-serif;";
form.innerHTML =
'<div style="font-size:12px;color:#333;margin-bottom:8px;font-weight:600;">Date range (optional)</div>' +
'<label style="font-size:11px;color:#555;display:block;margin-bottom:2px;">From</label>' +
'<input type="date" id="cd-min-date" style="width:100%;box-sizing:border-box;margin-bottom:8px;padding:4px;font-size:12px;">' +
'<label style="font-size:11px;color:#555;display:block;margin-bottom:2px;">To</label>' +
'<input type="date" id="cd-max-date" style="width:100%;box-sizing:border-box;margin-bottom:10px;padding:4px;font-size:12px;">' +
'<div style="display:flex;gap:6px;">' +
'<button id="cd-start-btn" style="flex:1;padding:6px;background:#1a73e8;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px;">Start</button>' +
'<button id="cd-cancel-btn" style="flex:1;padding:6px;background:#eee;color:#333;border:none;border-radius:4px;cursor:pointer;font-size:12px;">Cancel</button>' +
'</div>';
document.body.appendChild(form);
var minDateInput = form.querySelector("#cd-min-date");
var maxDateInput = form.querySelector("#cd-max-date");
var startBtn = form.querySelector("#cd-start-btn");
var cancelBtn = form.querySelector("#cd-cancel-btn");
function setButtonState(bg, text, enabled) {
btn.style.background = bg;
btn.textContent = text;
btn.disabled = !enabled;
}
btn.addEventListener("click", function () {
// Leaves MIN_DATE/MAX_DATE untouched if you don't change the fields —
// this just exposes the script's current values for editing per run.
minDateInput.value = (typeof MIN_DATE === "string") ? MIN_DATE : "";
maxDateInput.value = (typeof MAX_DATE === "string") ? MAX_DATE : "";
form.style.display = "block";
btn.style.display = "none";
});
cancelBtn.addEventListener("click", function () {
form.style.display = "none";
btn.style.display = "block";
});
startBtn.addEventListener("click", function () {
// Native <input type="date"> yields "" when empty, or "YYYY-MM-DD" —
// exactly the format the rest of the script already expects.
MIN_DATE = minDateInput.value || null;
MAX_DATE = maxDateInput.value || null;
form.style.display = "none";
btn.style.display = "block";
startRun();
});
function startRun() {
setButtonState("#5f6368", "Running…", false);
panel.style.display = "block";
logContent.textContent = "";
logLine("Starting… (range: " + (MIN_DATE || "earliest") + " to " + (MAX_DATE || "latest") + ")");
// Properly stringify each console argument for display — plain
// .join(" ") reduces objects to "[object Object]" via their default
// toString(), so anything passed as an object needs JSON.stringify instead.
function formatArgs(args) {
return Array.prototype.map.call(args, function (a) {
if (typeof a === "string") return a;
if (a instanceof Error) return a.message;
try {
return JSON.stringify(a);
} catch (e) {
return String(a);
}
}).join(" ");
}
// Mirror console output into the on-page panel for the duration of
// this run (still logs to the real DevTools console too — nothing is
// suppressed, this just adds a visible copy on the page itself).
var origLog = console.log;
var origWarn = console.warn;
var origError = console.error;
console.log = function () {
logLine(formatArgs(arguments));
origLog.apply(console, arguments);
};
console.warn = function () {
logLine(formatArgs(arguments), "warn");
origWarn.apply(console, arguments);
};
console.error = function () {
logLine(formatArgs(arguments), "error");
origError.apply(console, arguments);
};
downloadAllClassDojo()
.then(function () {
setButtonState("#1e8e3e", "Done — click to run again", true);
})
.catch(function (e) {
logLine("Unexpected error: " + e.message, "error");
setButtonState("#d93025", "Failed — click to run again", true);
})
.finally(function () {
console.log = origLog;
console.warn = origWarn;
console.error = origError;
});
}
document.body.appendChild(btn);
}
injectRunButton();
})();
@chunte

chunte commented Aug 12, 2026

Copy link
Copy Markdown

Some setup needed for TamperMonkey, and it worked fine.
Manage Extensions > Allow User Scripts, and Allow access to file URLs (not sure if this was needed)

For the powershell script didn't work yet, There errors like these.

At C:\Users\chunt\Downloads\Classdojo 2026\download_videos.ps1:809 char:514

  • ... YXRlTGVzc1RoYW4iOnsiQVdTOkVwb2NoVGltZSI6MTc4NjU4MjgwMH19fV19&Key-Pair ...
  •                                                             ~
    

The ampersand (&) character is not allowed. The & operator is reserved for future use; wrap an ampersand in double
quotation marks ("&") to pass it as part of a string.


from ChatGPT:
I found the actual fault: Windows PowerShell treats curly “smart” apostrophes as quote characters too. The final caption contains undoubled what’s, We’re, and Here’s, which breaks the single-quoted ExifTool argument. I’m normalising all smart quotes across the script and preserving UTF-8 BOM compatibility.

Also, I got chatgpt to fix the above, but the powershell script didn't work for me. Said two videos not found, but It just hung there. Have not digged into what's wrong

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