// ==UserScript==
// @name CompanyCam Timeline - Download All Full-Size Images
// @namespace cc-downloader
// @version 1.0.0
// @description Adds a button to download all full-size images from CompanyCam timeline/gallery pages.
// @author you
// @match https://api.companycam.com/timeline/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=companycam.com
// @grant GM_download
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @connect img.companycam.com
// @connect companycam-pending.s3.amazonaws.com
// @connect *.amazonaws.com
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
/** Why: Consistent UI placement regardless of site CSS */
GM_addStyle(`
#ccdlr-wrap { position: fixed; z-index: 999999; bottom: 16px; right: 16px; display:flex; flex-direction:column; gap:8px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#ccdlr-panel { background: #111a; backdrop-filter: blur(6px); color: #fff; padding: 10px 12px; border-radius: 12px; min-width: 280px; box-shadow: 0 6px 24px rgba(0,0,0,0.25); }
#ccdlr-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; }
#ccdlr-btns { display:flex; gap:8px; }
.ccdlr-btn { cursor:pointer; border:0; border-radius:10px; padding:8px 10px; font-weight:600; }
.ccdlr-primary { background:#4f46e5; color:#fff; }
.ccdlr-secondary { background:#334155; color:#fff; }
.ccdlr-danger { background:#b91c1c; color:#fff; }
#ccdlr-list { max-height: 220px; overflow:auto; margin-top:6px; font-size:12px; background:#0006; padding:6px; border-radius:8px; }
#ccdlr-meta { font-size:12px; opacity:0.85; }
#ccdlr-toggle { display:flex; align-items:center; gap:6px; font-size:12px; margin-bottom:6px; }
#ccdlr-toggle input { transform: translateY(1px); }
`);
const state = {
urls: new Map(), // key -> { url, originUrl?, file }
queue: [],
active: 0,
maxConcurrent: 3,
canceled: false,
started: false,
preferOriginal: false,
retries: new Map(), // url -> count
counters: { total: 0, done: 0, failed: 0 },
index: 0,
};
const UI = {
wrap: null, panel: null, list: null, meta: null,
btnDownload: null, btnCancel: null, btnRescan: null, toggleOriginal: null,
};
init();
function init() {
buildUI();
harvestLoop(); // initial harvest + MutationObserver
GM_registerMenuCommand("Download All Images (CompanyCam)", startDownloads);
}
function buildUI() {
UI.wrap = el("div", { id: "ccdlr-wrap" });
UI.panel = el("div", { id: "ccdlr-panel" });
const head = el("div", { id: "ccdlr-row" },
el("div", { style: "font-weight:700" }, "CompanyCam Downloader"),
el("div", { id: "ccdlr-meta" }, "0 found"),
);
UI.toggleOriginal = el("label", { id: "ccdlr-toggle", title: "Decode Base64 in URL to hit original S3 object" },
el("input", { type: "checkbox", id: "ccdlr-pref-orig" }),
el("span", {}, "Prefer original S3 URL"),
);
const btnRow = el("div", { id: "ccdlr-btns" });
UI.btnDownload = el("button", { class: "ccdlr-btn ccdlr-primary" }, "Download All");
UI.btnCancel = el("button", { class: "ccdlr-btn ccdlr-danger", disabled: "true" }, "Cancel");
UI.btnRescan = el("button", { class: "ccdlr-btn ccdlr-secondary" }, "Rescan");
btnRow.append(UI.btnDownload, UI.btnCancel, UI.btnRescan);
UI.list = el("div", { id: "ccdlr-list" });
UI.panel.append(head, UI.toggleOriginal, btnRow, UI.list);
UI.wrap.append(UI.panel);
document.body.appendChild(UI.wrap);
UI.meta = head.querySelector("#ccdlr-meta");
UI.btnDownload.addEventListener("click", startDownloads);
UI.btnCancel.addEventListener("click", () => state.canceled = true);
UI.btnRescan.addEventListener("click", () => {
scanOnce();
flash("Rescanned.");
});
UI.toggleOriginal.querySelector("input").addEventListener("change", (e) => {
state.preferOriginal = !!e.target.checked;
// Recompute targets
recomputeFiles();
renderList();
});
}
function flash(msg) {
UI.meta.textContent = `${msg} • ${state.urls.size} found`;
}
/** Why: Pages are dynamic; this keeps finding new images as you scroll */
function harvestLoop() {
scanOnce(); // initial
const mo = new MutationObserver(debounce(scanOnce, 400));
mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
window.addEventListener("scroll", debounce(scanOnce, 300), { passive: true });
setInterval(scanOnce, 4000); // periodic safety
}
function scanOnce() {
// IMG elements (current + lazy)
document.querySelectorAll("img[src], img[data-src], img[data-original], img[data-lazy-src]").forEach(img => {
const cand = img.getAttribute("src")
|| img.getAttribute("data-src")
|| img.getAttribute("data-original")
|| img.getAttribute("data-lazy-src");
if (cand) addCandidateUrl(cand);
// srcset (choose largest)
const ss = img.getAttribute("srcset");
if (ss) {
const best = pickLargestFromSrcset(ss);
if (best) addCandidateUrl(best);
}
});
// Direct anchors to images
document.querySelectorAll("a[href]").forEach(a => {
const href = a.getAttribute("href");
if (isLikelyImageUrl(href)) addCandidateUrl(href);
});
recomputeFiles();
renderList();
}
function pickLargestFromSrcset(srcset) {
const parts = srcset.split(",").map(s => s.trim());
let best = null, max = -1;
for (const p of parts) {
const [u, d] = p.split(/\s+/);
const n = d?.endsWith("w") ? parseInt(d) : (d?.endsWith("x") ? parseFloat(d) : 0);
if (n > max) { max = n; best = u; }
}
return best;
}
function isLikelyImageUrl(u) {
if (!u) return false;
try {
const url = new URL(u, location.href);
return /img\.companycam\.com/.test(url.hostname) || /\.(jpg|jpeg|png|webp)(\?|$)/i.test(url.pathname);
} catch { return false; }
}
function addCandidateUrl(u) {
try {
const url = new URL(u, location.href).toString();
const key = stableKey(url);
if (!state.urls.has(key)) {
state.urls.set(key, { url });
}
} catch { /* ignore invalid */ }
}
function stableKey(url) {
try {
const u = new URL(url, location.href);
u.hash = "";
u.search = "";
return u.toString();
} catch { return url; }
}
function recomputeFiles() {
let idx = 1;
for (const entry of state.urls.values()) {
const targetUrl = state.preferOriginal ? (decodeOriginalUrl(entry.url) || entry.url) : entry.url;
const filename = buildFilename(targetUrl, idx++);
entry.originUrl = targetUrl;
entry.file = filename;
}
state.counters.total = state.urls.size;
}
/** Why: The CompanyCam CDN URL often embeds a Base64 of the original S3 object path */
function decodeOriginalUrl(url) {
try {
const u = new URL(url, location.href);
if (!/img\.companycam\.com/.test(u.hostname)) return null;
const path = u.pathname; // contains ".../aHR0cHM6Ly9.../more/segments.jpg"
const segs = path.split("/");
const start = segs.findIndex(s => /^aHR0/.test(s)); // Base64 starts with "aHR0" for "http"
if (start === -1) return null;
// Re-join all segments from start until one ending with .jpg|.jpeg|.png|webp (since Base64 may include extension)
const collected = [];
for (let i = start; i < segs.length; i++) collected.push(segs[i]);
// Remove trailing extension duplication if present (keep Base64 only)
let base = collected.join("");
// Strip any known trailing proxy extensions not part of base64
base = base.replace(/(\.jpg|\.jpeg|\.png|\.webp)$/i, "");
// URL-safe base64 fix (not always needed)
base = base.replace(/-/g, "+").replace(/_/g, "/");
const decoded = atob(base);
if (!/^https?:\/\//i.test(decoded)) return null;
return decoded;
} catch {
return null;
}
}
function buildFilename(rawUrl, index) {
try {
const u = new URL(rawUrl, location.href);
const name = u.pathname.split("/").pop() || "image";
const clean = name.replace(/[%?#].*$/, "");
const safe = clean.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 140);
const pad = String(index).padStart(4, "0");
return `${pad}__${safe}`;
} catch {
const pad = String(index).padStart(4, "0");
return `${pad}__image.jpg`;
}
}
function renderList() {
UI.list.innerHTML = "";
const frag = document.createDocumentFragment();
let i = 0;
for (const { originUrl, url, file } of state.urls.values()) {
i++;
const row = el("div", {}, `${i}. `, el("a", { href: originUrl || url, target: "_blank", rel: "noreferrer noopener" }, file));
frag.appendChild(row);
}
UI.list.appendChild(frag);
UI.meta.textContent = `${state.urls.size} found • ${state.counters.done} done • ${state.counters.failed} failed`;
}
async function startDownloads() {
if (state.started) return;
state.started = true;
state.canceled = false;
UI.btnDownload.disabled = true;
UI.btnCancel.disabled = false;
state.queue = Array.from(state.urls.values()).map(x => ({ url: x.originUrl || x.url, file: x.file }));
pumpQueue();
}
function pumpQueue() {
while (state.active < state.maxConcurrent && state.queue.length && !state.canceled) {
const job = state.queue.shift();
state.active++;
downloadOne(job)
.finally(() => {
state.active--;
if (state.canceled) {
UI.meta.textContent = `Canceled • ${state.counters.done} done • ${state.counters.failed} failed`;
return;
}
renderList();
if (state.queue.length === 0 && state.active === 0) {
UI.btnCancel.disabled = true;
UI.meta.textContent = `All done • ${state.counters.done} done • ${state.counters.failed} failed`;
} else {
setTimeout(pumpQueue, 120); // small pacing
}
});
}
}
function downloadOne({ url, file }) {
return new Promise((resolve) => {
const tries = state.retries.get(url) || 0;
GM_download({
url,
name: file,
saveAs: false,
onload: () => {
state.counters.done++;
resolve();
},
onerror: () => {
if (tries < 2 && !state.canceled) {
state.retries.set(url, tries + 1);
// Backoff to avoid hammering CDN
setTimeout(() => {
state.queue.unshift({ url, file });
resolve();
}, 500 * (tries + 1));
} else {
state.counters.failed++;
resolve();
}
}
});
});
}
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
if (v === null || v === undefined) return;
if (k === "style") node.setAttribute("style", v);
else if (k in node) node[k] = v;
else node.setAttribute(k, v);
});
for (const c of children) {
node.append(typeof c === "string" ? document.createTextNode(c) : c);
}
return node;
}
function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn.apply(null, args), ms);
};
}
})();