Skip to content

Instantly share code, notes, and snippets.

@bdf0506
Last active September 9, 2026 22:06
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 metadata (EXIF for JPEG, the equivalent chunks for PNG)
  • Embeds the post's caption into each photo too — EXIF ImageDescription and XMP XMP-dc:Description for JPEG, the PNG equivalent for PNG files — XMP is what Google Photos actually reads for its description field
  • Detects each photo's real file type from its actual bytes, not just the URL — ClassDojo has been observed serving PNG data at URLs that look like .jpg, and the script corrects the extension automatically when this happens
  • Names files predictably: YYYYMMDD_HHMMSS_originalfilename.ext for both photos and videos, sorted chronologically, stable across re-runs (the same file always gets the same name, 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)
  • Can filter to only download posts from specific senders (e.g. just the teacher, not the principal or other staff) — set once in the script or adjust per run in the on-page form
  • 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). Download links/values are embedded safely regardless of special characters (quotes, ampersands, smart quotes, etc.) in captions or signed URLs, and stuck/expired downloads fail fast with a timeout instead of hanging indefinitely

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. Enable user script support — recent versions of Chrome/Edge require an extra opt-in for this:
    • Go to chrome://extensions (or edge://extensions), find Tampermonkey, click Details
    • Turn on "Allow User Scripts"
    • (You may also see an option for "Allow access to file URLs" — this isn't required for this script, since everything runs against https://home.classdojo.com, not local files. Safe to leave off unless something else you use needs it.)
    • If you don't see an "Allow User Scripts" toggle at all, your Tampermonkey/browser version may not need this step — just proceed to step 3 and see if the script runs.
  3. Click the Tampermonkey icon → Dashboard.
  4. Click the + tab to create a new script.
  5. Delete everything in the default template (it starts with placeholder text like // ==UserScript== and a description of "try to take over the world!") — don't just paste on top of it.
  6. Paste in the entire contents of classdojo-downloader.user.js from this repo.
  7. Save with Ctrl+S (or File → Save). This step matters — closing the tab without saving won't register the script.
  8. Confirm the script appears and is enabled (toggle switch on) back in the Dashboard's script list.

If you don't see the floating "Run" button appear later (see Usage below), double-check: the script is enabled in the Dashboard, "Allow User Scripts" is on, and you're actually on a home.classdojo.com page (the button only appears there).

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
    • Only include posts from — a comma-separated list of sender names (e.g. just your child's teacher). Leave blank to include everyone.

    Both sections are 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. Run this fairly soon after it's generated — ClassDojo's video links are signed and expire after a while, so a .ps1 left sitting for too long before you run it may fail on some videos. If that happens, just re-run the Tampermonkey script to generate a fresh one.

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.

Note on video dates specifically: video files (QuickTime/MP4) store dates differently than photos do. Unlike a JPEG's EXIF date (which is plain local time with no timezone), the video format's native date fields are specified to hold UTC — confirmed by testing against a real file that Google Photos reads them this way. The script accounts for this automatically (writing genuine UTC into video files, local time into photos), so no action is needed on your part — just worth knowing if you ever inspect a video's raw date with a tool that doesn't do the UTC-to-local conversion, since it'll look "wrong" by a few hours until you account for that.

Also worth knowing: video captions are correctly embedded (verifiable with exiftool -Description -XMP-dc:Description), but Google Photos' Details panel doesn't currently display descriptions for videos the way it does for photos — the data is there even if that panel doesn't show it.

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).
INCLUDED_SENDERS [] Only posts from these senders are kept — matched against ClassDojo's own senderName field (case-insensitive, whitespace-trimmed). Check any post_metadata.json from a previous run to see the exact sender name strings ClassDojo uses. Set to [] to include everyone. Also editable per-run from the on-page form — whatever's entered there overrides this default just for that run.
DELAY_MS 300 Delay between image downloads, in milliseconds — a light rate-limit courtesy to ClassDojo's servers.
VIDEO_DOWNLOAD_TIMEOUT_SECONDS 300 Max seconds download_videos.ps1 will spend on any single video before giving up and moving to the next one. Prevents a slow connection or an expired signed URL from making the whole script hang indefinitely with no output. Raise this if you have very large videos on a slow connection.

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, .png, .mp4 etc. The downloaded photos and videos — both use the same date_time_originalname.ext naming scheme, sorted chronologically by filename
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 and PNG get embedded metadata; GIF/WEBP don't yet. Date and caption embedding works on both JPEG (EXIF + XMP) and PNG (via PNG's own eXIf/iTXt chunks). GIF and WEBP photos still download fine, just without embedded metadata. The script also detects each image's real format from its actual bytes rather than trusting the URL — ClassDojo has been observed serving PNG data at URLs that look like .jpg, and the script corrects the file extension automatically when this happens (logged when it does).
  • Windows File Explorer's "Date taken" column may show blank for PNGs, even though the date is genuinely embedded correctly. Confirmed by testing: exiftool reads the correct date from these files every time. This appears to be a Windows limitation, not a bug in the files — Explorer's metadata display relies on Windows' own image codec (WIC), whose documented PNG support centers on the older tEXt text-chunk convention, not the eXIf chunk this script (and the PNG spec since 2017) uses. Even Chromium only added browser support for reading PNG's eXIf chunk in 2024, which gives a sense of how recent and unevenly-adopted this part of the spec still is. Google Photos reads it correctly (confirmed by testing), which is what matters for this script's actual purpose.
  • Video captions are embedded correctly but may not display in Google Photos' UI. Confirmed by direct testing: the caption is genuinely present in a video's Description, Comment, and XMP-dc:Description fields (verifiable with exiftool), but Google Photos' Details panel doesn't surface video descriptions the way it does for photos. This appears to be a Google Photos display limitation, not a bug in the embedded data.
  • 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.
  • Video download links expire. ClassDojo's video URLs are signed and time-limited. Run download_videos.ps1 reasonably soon after generating it — if you wait too long, some or all videos may fail to download, in which case just re-run the Tampermonkey script for fresh links.

Troubleshooting

Tampermonkey editor shows a squiggly "syntax" warning on some lines, but the script still runs fine. This is Tampermonkey's built-in editor linter being overly cautious about certain valid JavaScript patterns — it's cosmetic, not a real error. If the script runs and downloads photos successfully (check the log panel), it's working correctly regardless of what the editor's static checker flags.

The floating "Run" button doesn't appear on ClassDojo. Check, in order: the script is enabled in Tampermonkey's Dashboard; "Allow User Scripts" is turned on for Tampermonkey in your browser's extension settings (see Installation); you're on a home.classdojo.com page specifically, not a different ClassDojo domain.

download_videos.ps1 reports videos as failed, or the whole thing seems to hang with no output. Almost always one of:

  • The signed video links expired before you got around to running the script — re-run the Tampermonkey script to generate a fresh download_videos.ps1 and run it promptly.
  • A slow/stuck connection — the script gives up on any single video after VIDEO_DOWNLOAD_TIMEOUT_SECONDS (5 minutes by default) rather than hanging forever, so if it's been stuck much longer than that with zero progress, something else is wrong (check your internet connection, or that curl.exe exists — run curl.exe --version in PowerShell to confirm).

Running download_videos.ps1 fails immediately with an "execution policy" error. Make sure you're launching it with the exact command shown in Usage above (powershell -ExecutionPolicy Bypass -File .\download_videos.ps1), not by double-clicking the file directly.

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.2
// @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
//
// 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;
// Only posts from these senders are kept — matched against ClassDojo's own
// "senderName" field (case-insensitive, whitespace-trimmed), which you can
// see in any post_metadata.json from a previous run. Leave as an empty array
// to include everyone. Also editable per-run from the on-page form.
var INCLUDED_SENDERS = [];
// Delay between image downloads in ms
var DELAY_MS = 300;
// Max seconds curl.exe will spend on any single video before giving up and
// moving to the next one. Without this, a slow/stuck connection (or a signed
// URL that quietly expired) can make download_videos.ps1 hang indefinitely
// with no output and no error. Raise this if you have very large videos on a
// slow connection.
var VIDEO_DOWNLOAD_TIMEOUT_SECONDS = 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;
}
// Google Photos' description box (and several other viewers) don't reliably
// render embedded newlines as a line break OR a space — they just drop them,
// running adjacent words together ("sentence one.sentence two"). Converting
// newlines to spaces trades "multi-line" for "always readable," which is the
// safer bet across tools. Collapses any resulting run of spaces down to one.
function collapseNewlinesForCaption(text) {
if (!text) return text;
return text.replace(/\r\n|\r|\n/g, " ").replace(/[ \t]+/g, " ").trim();
}
function sanitizeCaption(text, maxLen) {
if (!text) return "";
maxLen = maxLen || 900;
text = collapseNewlinesForCaption(text);
// 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;
}
// Base64-encodes a string (UTF-8 safe) for embedding into the generated
// PowerShell script. Base64 output only ever contains [A-Za-z0-9+/=] — none
// of which PowerShell's parser treats specially — so decoding it into a
// variable at runtime and using that variable sidesteps every possible
// quoting/escaping edge case for URLs, filenames, and captions (ampersands,
// quotes, dollar signs, backticks, anything) in one shot, rather than trying
// to hand-escape for each special character individually.
function toBase64Utf8(str) {
var bytes = new TextEncoder().encode(str);
var binary = "";
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
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());
}
// Unlike EXIF (photos), QuickTime/MP4's native date fields (CreateDate,
// ModifyDate, MediaCreateDate, TrackCreateDate) are specified by the format
// itself to hold UTC, with players expected to convert to local time for
// display. Google Photos was confirmed (by testing against a real video) to
// follow that spec-compliant behavior, subtracting the local UTC offset when
// displaying a value written as local time — so, unlike formatExifDate above,
// this one deliberately keeps UTC components rather than converting to local.
function formatQuickTimeUtcDate(isoString) {
var d = new Date(isoString);
var pad = function (n) { return String(n).padStart(2, "0"); };
return d.getUTCFullYear() + ":" + pad(d.getUTCMonth() + 1) + ":" + pad(d.getUTCDate()) +
" " + pad(d.getUTCHours()) + ":" + pad(d.getUTCMinutes()) + ":" + pad(d.getUTCSeconds());
}
// 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;
text = collapseNewlinesForCaption(text);
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 the ACTUAL image format from the file's own bytes (magic number),
// not from the URL's apparent extension or the server's Content-Type header —
// both of which ClassDojo has been observed to get wrong (e.g. screenshots
// served at a ".jpg"-looking URL that are actually PNG data underneath).
function detectRealImageType(bytes) {
if (bytes.length >= 3 && bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return "jpeg";
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47 &&
bytes[4] === 0x0D && bytes[5] === 0x0A && bytes[6] === 0x1A && bytes[7] === 0x0A) return "png";
if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return "gif";
if (bytes.length >= 12 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return "webp";
return "unknown";
}
// The correct file extension for a real (byte-detected) image type.
function extensionForRealType(type) {
if (type === "jpeg") return ".jpg";
if (type === "png") return ".png";
if (type === "gif") return ".gif";
if (type === "webp") return ".webp";
return null; // unrecognized — caller should keep whatever extension it already had
}
function binaryStringToUint8Array(str) {
var arr = new Uint8Array(str.length);
for (var i = 0; i < str.length; i++) arr[i] = str.charCodeAt(i) & 0xFF;
return arr;
}
// Converts a Uint8Array to a base64 string without blowing the call stack.
// String.fromCharCode.apply(null, bytes) passes every byte as an individual
// function argument, and JS engines cap how many arguments a call can take
// (commonly ~65K-128K) — real photos are routinely several hundred KB to a
// few MB, well past that limit, causing "Maximum call stack size exceeded"
// on larger JPEGs. Processing in bounded chunks avoids this entirely while
// staying much faster than a naive per-byte string-concatenation loop.
function uint8ArrayToBase64(bytes) {
var CHUNK_SIZE = 0x8000; // 32768 — safely under typical engine argument limits
var binary = "";
for (var i = 0; i < bytes.length; i += CHUNK_SIZE) {
var chunk = bytes.subarray(i, i + CHUNK_SIZE);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
// ---- PNG metadata embedding (eXIf + iTXt chunks) ----
// Verified against exiftool (correct EXIF:DateTimeOriginal, EXIF:ImageDescription,
// and XMP-dc:Description readback), an independent PNG chunk walker cross-checking
// every CRC32 with Python's own zlib, and Pillow confirming pixel data is
// byte-for-byte unchanged before shipping this.
var PNG_CRC_TABLE = (function () {
var table = [];
for (var n = 0; n < 256; n++) {
var c = n;
for (var k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[n] = c >>> 0;
}
return table;
})();
function pngCrc32(bytes) {
var crc = 0xFFFFFFFF;
for (var i = 0; i < bytes.length; i++) {
crc = PNG_CRC_TABLE[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function buildPngChunk(typeStr, dataBytes) {
var typeBytes = new Uint8Array(4);
for (var i = 0; i < 4; i++) typeBytes[i] = typeStr.charCodeAt(i);
var len = dataBytes.length;
var chunk = new Uint8Array(4 + 4 + len + 4);
chunk[0] = (len >>> 24) & 0xFF;
chunk[1] = (len >>> 16) & 0xFF;
chunk[2] = (len >>> 8) & 0xFF;
chunk[3] = len & 0xFF;
chunk.set(typeBytes, 4);
chunk.set(dataBytes, 8);
var crcInput = new Uint8Array(4 + len);
crcInput.set(typeBytes, 0);
crcInput.set(dataBytes, 4);
var crc = pngCrc32(crcInput);
chunk[8 + len] = (crc >>> 24) & 0xFF;
chunk[8 + len + 1] = (crc >>> 16) & 0xFF;
chunk[8 + len + 2] = (crc >>> 8) & 0xFF;
chunk[8 + len + 3] = crc & 0xFF;
return chunk;
}
// Inserts one or more complete chunks right after IHDR (which must be the
// PNG's first chunk per spec) — a safe, always-valid position for metadata
// chunks like eXIf and iTXt, before any image data (IDAT) appears.
function insertPngChunksAfterIHDR(pngBytes, newChunks) {
if (pngBytes.length < 8) return pngBytes;
var offset = 8; // skip the 8-byte PNG signature
if (offset + 8 > pngBytes.length) return pngBytes;
var ihdrLen = ((pngBytes[offset] << 24) | (pngBytes[offset + 1] << 16) | (pngBytes[offset + 2] << 8) | pngBytes[offset + 3]) >>> 0;
var ihdrTypeOk = pngBytes[offset + 4] === 0x49 && pngBytes[offset + 5] === 0x48 && pngBytes[offset + 6] === 0x44 && pngBytes[offset + 7] === 0x52; // "IHDR"
if (!ihdrTypeOk) return pngBytes; // not structured as expected — bail out safely, unmodified
var insertPos = offset + 8 + ihdrLen + 4; // length(4) + type(4) + data + crc(4)
var totalNewLen = newChunks.reduce(function (sum, c) { return sum + c.length; }, 0);
var result = new Uint8Array(pngBytes.length + totalNewLen);
result.set(pngBytes.subarray(0, insertPos), 0);
var pos = insertPos;
newChunks.forEach(function (c) {
result.set(c, pos);
pos += c.length;
});
result.set(pngBytes.subarray(insertPos), pos);
return result;
}
// Builds the data payload for a PNG iTXt chunk carrying an XMP packet — the
// standard convention (keyword "XML:com.adobe.xmp", uncompressed) that
// XMP-aware tools look for in PNG files.
function buildXmpItxtChunkData(xmpXml) {
var keyword = "XML:com.adobe.xmp";
var parts = [];
for (var i = 0; i < keyword.length; i++) parts.push(keyword.charCodeAt(i));
parts.push(0, 0, 0, 0, 0); // null-terminator, compression flag=0, method=0, empty lang+null, empty translated-keyword+null
var xmlBytes = new TextEncoder().encode(xmpXml);
var data = new Uint8Array(parts.length + xmlBytes.length);
data.set(parts, 0);
data.set(xmlBytes, parts.length);
return data;
}
// piexif.dump() includes a 6-byte "Exif\0\0" prefix — a JPEG/APP1-specific
// convention that identifies the segment as Exif data among other possible
// APP1 uses. PNG's eXIf chunk expects to start directly with the raw TIFF
// header instead, so that prefix must be stripped before use here.
var EXIF_APP1_PREFIX = "Exif\0\0";
function stripExifApp1Prefix(exifBinaryString) {
return exifBinaryString.indexOf(EXIF_APP1_PREFIX) === 0
? exifBinaryString.slice(EXIF_APP1_PREFIX.length)
: exifBinaryString;
}
// Builds a piexif EXIF object with DateTimeOriginal (+ImageDescription if a
// caption is given) from an existing object (or a blank one), shared by both
// the JPEG and PNG embedding paths so the date/caption logic stays identical
// between formats.
function buildExifObj(existingObj, isoTime, caption) {
var exifObj = existingObj || { "0th": {}, "Exif": {}, "GPS": {}, "1st": {} };
var piexif = window.piexif;
var exifDate = formatExifDate(isoTime);
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;
}
return exifObj;
}
async function embedMetadataIntoJpeg(bytes, isoTime, caption) {
var piexif = await loadPiexif();
var dataUrl = "data:image/jpeg;base64," + uint8ArrayToBase64(bytes);
var exifObj;
try {
exifObj = piexif.load(dataUrl);
} catch (e) {
exifObj = null;
}
exifObj = buildExifObj(exifObj, isoTime, caption);
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" });
}
async function embedMetadataIntoPng(bytes, isoTime, caption) {
var piexif = await loadPiexif();
var exifObj = buildExifObj(null, isoTime, caption);
var exifBinaryString = piexif.dump(exifObj);
var rawTiff = stripExifApp1Prefix(exifBinaryString);
var exifChunk = buildPngChunk("eXIf", binaryStringToUint8Array(rawTiff));
var chunksToInsert = [exifChunk];
var xmlCaption = sanitizeForXml(caption); // full Unicode, for XMP
if (xmlCaption) {
var xmpXml = buildXmpDescriptionPacket(xmlCaption);
chunksToInsert.push(buildPngChunk("iTXt", buildXmpItxtChunkData(xmpXml)));
}
var finalBytes = insertPngChunksAfterIHDR(bytes, chunksToInsert);
return new Blob([finalBytes], { type: "image/png" });
}
// Detects the image's REAL format from its own bytes and embeds
// DateTimeOriginal + caption (EXIF + XMP-dc:Description) accordingly.
// Returns { blob, realExt } — realExt is the correct extension for what the
// file actually is (which may differ from what its URL suggested), or null
// if the format isn't one we can embed metadata into (caller should keep
// its existing extension in that case).
async function embedImageMetadata(blob, isoTime, caption) {
var buf = await blob.arrayBuffer();
var bytes = new Uint8Array(buf);
var realType = detectRealImageType(bytes);
var realExt = extensionForRealType(realType);
try {
if (realType === "jpeg") {
return { blob: await embedMetadataIntoJpeg(bytes, isoTime, caption), realExt: realExt };
}
if (realType === "png") {
return { blob: await embedMetadataIntoPng(bytes, isoTime, caption), realExt: realExt };
}
} catch (e) {
console.warn(" Metadata write failed (" + realType + "), saving without embedded date/caption: " + e.message);
return { blob: blob, realExt: realExt };
}
// gif/webp/unknown — no embedding support yet, but still report the real
// type so the caller can at least fix a wrong file extension.
return { blob: blob, realExt: realExt };
}
// ---- 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;
}
}
// Phase 2c: Sender filter
if (INCLUDED_SENDERS.length > 0) {
var includedLower = INCLUDED_SENDERS.map(function (s) { return s.trim().toLowerCase(); });
var beforeSenderFilterCount = attachments.length;
attachments = attachments.filter(function (a) {
var sender = (a.post.senderName || "").trim().toLowerCase();
return includedLower.indexOf(sender) !== -1;
});
console.log(
"Sender filter: kept " + attachments.length + " of " + beforeSenderFilterCount +
" attachment(s) from " + INCLUDED_SENDERS.join(", ") + "."
);
if (attachments.length === 0) {
console.error("Nothing from " + INCLUDED_SENDERS.join(", ") + " in this range. 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 = vDate + "_" + vTime + "_" + vBaseName + vExt;
var vExifDate = formatQuickTimeUtcDate(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 b64Url = toBase64Utf8(vid.url);
var b64Filename = toBase64Utf8(vFilename);
var vCaption = sanitizeCaption(vid.post.body); // Latin-1-safe, for -Description/-Comment
var b64Caption = toBase64Utf8(vCaption);
var vCaptionXml = sanitizeForXml(vid.post.body); // full Unicode, for -XMP-dc:Description
var b64CaptionXml = toBase64Utf8(vCaptionXml);
lines.push("$tmp = [System.IO.Path]::GetTempFileName()");
lines.push("try {");
lines.push(" $url = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('" + b64Url + "'))");
lines.push(" $filename = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('" + b64Filename + "'))");
lines.push(" & curl.exe -s -L -f --connect-timeout 20 --max-time " + VIDEO_DOWNLOAD_TIMEOUT_SECONDS + " -o $tmp $url");
lines.push(" if ($LASTEXITCODE -ne 0 -or -not (Test-Path $tmp) -or (Get-Item $tmp).Length -eq 0) { throw 'download failed (HTTP error, timeout, or an expired link — try re-running the Tampermonkey script to generate fresh links, then run this .ps1 again soon after)' }");
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 $filename");
lines.push(" $dt = [datetime]::Parse('" + vid.time + "', $null, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal)");
lines.push(" (Get-Item $filename).LastWriteTimeUtc = $dt");
lines.push(" (Get-Item $filename).CreationTimeUtc = $dt");
lines.push(" if ($exiftoolPath) {");
lines.push(" $captionLatin1 = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('" + b64Caption + "'))");
lines.push(" $captionXml = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('" + b64CaptionXml + "'))");
lines.push(" $exifArgs = @('-overwrite_original','-q','-q',\"-CreateDate=" + vExifDate + "\",\"-ModifyDate=" + vExifDate + "\",\"-MediaCreateDate=" + vExifDate + "\",\"-TrackCreateDate=" + vExifDate + "\")");
lines.push(" if ($captionLatin1) { $exifArgs += @(\"-Description=$captionLatin1\", \"-Comment=$captionLatin1\") }");
lines.push(" if ($captionXml) { $exifArgs += @(\"-XMP-dc:Description=$captionXml\") }");
lines.push(" $exifArgs += $filename");
lines.push(" & $exiftoolPath @exifArgs *> $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: ' + $filename + ' - ' + $_.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); // provisional — may be corrected below once we see the real bytes
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 a metadata-modified copy to disk below.
var hash = await hashBlob(blob);
if (existingHashes.has(hash)) {
skipped++;
outcome = "skipped (duplicate content)";
} else {
var embedResult = await embedImageMetadata(blob, img.time, img.post.body);
// ClassDojo has been observed serving the wrong file type at a URL
// that looks like a different extension (e.g. a PNG screenshot at
// a ".jpg"-looking URL) — trust the actual bytes over the URL guess.
if (embedResult.realExt && embedResult.realExt !== ext) {
console.log(" Correcting extension " + ext + " -> " + embedResult.realExt + " for " + filename + " (actual file content differs from URL)");
ext = embedResult.realExt;
filename = datePart + "_" + timePart + "_" + baseName + ext;
}
var fileHandle = await dirHandle.getFileHandle(filename, { create: true });
var writable = await fileHandle.createWritable();
await writable.write(embedResult.blob);
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 and INCLUDED_SENDERS 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:240px;" +
"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="font-size:12px;color:#333;margin-bottom:8px;font-weight:600;">Only include posts from</div>' +
'<label style="font-size:11px;color:#555;display:block;margin-bottom:2px;">Sender name(s), comma-separated — leave blank for everyone</label>' +
'<input type="text" id="cd-senders" placeholder="e.g. Ms. Smith" 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 sendersInput = form.querySelector("#cd-senders");
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/INCLUDED_SENDERS 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 : "";
sendersInput.value = INCLUDED_SENDERS.join(", ");
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;
INCLUDED_SENDERS = sendersInput.value
.split(",")
.map(function (s) { return s.trim(); })
.filter(function (s) { return s.length > 0; });
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") +
"; senders: " + (INCLUDED_SENDERS.length > 0 ? INCLUDED_SENDERS.join(", ") : "everyone") + ")"
);
// 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

@brimaster

Copy link
Copy Markdown

This is great! Much better than previous methods. That said, I ran into the same problem with quotations in the Powershell script. Fixing them manually allowed the script to run and download hundreds of videos. However, I'm running into issues with duplicate videos being downloaded and I can't figure out why. Also, re-running the downloader on Classdojo consistently makes all photos re-download, overwriting those that were already downloaded, instead of skipping them.

@bdf0506

bdf0506 commented Aug 12, 2026

Copy link
Copy Markdown
Author

Thanks for the feedback. My kid's school rarely uploads videos, and I don't have any active ones on their current story so I haven't been able to test the Powershell side of things. It was taking it from the initial gist that I modeled things off of, and I think Claude just got the best of me.
I updated to a new version which includes the ability to include only certain senders, as well as modifications on the Powershell side which hopefully fixes things. Also updated the readme to provide some clarification on the TamperMonkey setup.

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