|
// ==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, "&") |
|
.replace(/</g, "<") |
|
.replace(/>/g, ">") |
|
.replace(/"/g, """) |
|
.replace(/'/g, "'"); |
|
} |
|
|
|
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(); |
|
})(); |
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
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