MBBR / Behance Project Downloader (High Resolution)

// ==UserScript==
// @name         Behance Project Downloader (High Resolution)
// @namespace    sg.behance.downloader
// @version      1.2
// @description  Download all images from a Behance project in the highest available resolution.
// @license      MIT
// @match        https://www.behance.net/*
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      *
// ==/UserScript==

(function () {
'use strict';

/* ================= CONFIG ================= */

const DOWNLOAD_DELAY = 1200;
const POST_SUCCESS_DELAY = 300;
const MAX_RETRIES = 2;
const RETRY_DELAY = 1500;
const STORAGE_KEY = "downloaded_behance_projects";
const SIDEBAR_WIDTH = 420;

const sleep = ms => new Promise(r => setTimeout(r, ms));

/* ================= STORAGE ================= */

function getProjects() {
    return GM_getValue(STORAGE_KEY, {});
}

function saveProjects(data) {
    GM_setValue(STORAGE_KEY, data);
}

function resetProjectStatus(id) {
    const projects = getProjects();
    delete projects[id];
    saveProjects(projects);
}

/* ================= UTIL ================= */

function sanitize(text) {
    return text.replace(/[<>:"/\\|?*]+/g, '').trim();
}

function pad(n) {
    return String(n).padStart(3, "0");
}

function getProjectTitle() {
    return sanitize(document.title.replace(" :: Behance", ""));
}

function getUserDisplayName() {
    const el = document.querySelector(".qa-user-link, .rf-owners__owner-name");
    if (!el) return "Unknown Author";
    return sanitize(el.textContent.replace("by:", "").trim());
}

function getProjectId() {
    const match = location.pathname.match(/gallery\/(\d+)/);
    return match ? match[1] : null;
}

function extractFilename(url) {
    return url.split("/").pop().split("?")[0];
}

function extractExtension(url) {
    return extractFilename(url).split(".").pop().toLowerCase();
}

function formatBytes(bytes) {
    if (!bytes) return "";
    const sizes = ["Bytes", "KB", "MB", "GB"];
    const i = Math.floor(Math.log(bytes) / Math.log(1024));
    return (bytes / Math.pow(1024, i)).toFixed(2) + " " + sizes[i];
}

/* ================= SCORE (INALTERADO) ================= */

function scoreUrl(url) {
    if (!url) return 0;
    if (url.includes("/source/")) return 10000;
    const maxMatch = url.match(/max_(\d+)/);
    if (maxMatch) return parseInt(maxMatch[1]);
    const genericMatch = url.match(/\/(\d+)_webp/);
    if (genericMatch) return parseInt(genericMatch[1]);
    if (url.includes("fs")) return 1920;
    if (url.includes("hd")) return 1240;
    if (url.includes("disp")) return 600;
    return 0;
}

function collectCandidates(img) {
    const candidates = new Set();
    if (img.src) candidates.add(img.src);
    if (img.srcset) {
        img.srcset.split(",").forEach(entry => {
            candidates.add(entry.trim().split(" ")[0]);
        });
    }
    const picture = img.closest("picture");
    if (picture) {
        picture.querySelectorAll("source[srcset]").forEach(source => {
            source.getAttribute("srcset").split(",").forEach(entry => {
                candidates.add(entry.trim().split(" ")[0]);
            });
        });
    }
    return [...candidates];
}

function getOrderedCandidates(img) {
    const candidates = collectCandidates(img);
    candidates.sort((a, b) => scoreUrl(b) - scoreUrl(a));
    return candidates;
}

/* ================= SIDEBAR (EVOLUÇÃO VISUAL) ================= */

const Sidebar = (() => {

    let total = 0;
    let completed = 0;
    let projectBytes = 0;

    function mount(count) {

        total = count;
        completed = 0;
        projectBytes = 0;

        if (document.getElementById("bp-sidebar")) return;

        const el = document.createElement("div");
        el.id = "bp-sidebar";

        el.innerHTML = `
            <div class="bp-header">
                <span>Project Downloader</span>
                <button id="bp-close">✕</button>
            </div>
            <div class="bp-counter">0 / ${count} (0%)</div>
            <div class="bp-total">
                <div class="bp-total-bar"><div class="bp-total-fill"></div></div>
            </div>
            <div class="bp-list"></div>
        `;

        document.body.appendChild(el);

        const btn = document.getElementById("behance-project-downloader");
        if (btn) {
            btn.style.transition = "right 0.25s ease";
            btn.style.right = (SIDEBAR_WIDTH + 20) + "px";
        }

        document.getElementById("bp-close").onclick = () => {
            el.remove();
            if (btn) btn.style.right = "20px";
        };

        injectStyles();
    }

    function updateCounter(current) {
        const percent = Math.round((current / total) * 100);
        const counter = document.querySelector(".bp-counter");
        if (counter) {
            counter.textContent =
                `${current} / ${total} (${percent}%)` +
                (projectBytes ? ` • ${formatBytes(projectBytes)}` : "");
        }
    }

    function finishCounter() {
        const counter = document.querySelector(".bp-counter");
        if (counter) {
            counter.textContent =
                `Concluído ${total} / ${total} (100%) • ${formatBytes(projectBytes)}`;
        }
    }

    function addItem(id, fullPath) {

        const parts = fullPath.split("/");
        const filename = parts.pop();
        const path = parts.join("/");

        const list = document.querySelector(".bp-list");

        const item = document.createElement("div");
        item.className = "bp-item";
        item.dataset.id = id;

        item.innerHTML = `
            <div class="bp-path">${path}</div>
            <div class="bp-name-line">
                <span class="bp-name">${filename}</span>
                <span class="bp-resolution"></span>
                <span class="bp-size"></span>
            </div>
            <div class="bp-bar"><div class="bp-fill"></div></div>
            <div class="bp-meta">
                <span class="bp-percent">0%</span>
                <span class="bp-status">Waiting</span>
            </div>
        `;

        list.appendChild(item);
    }

    function setResolution(id, resolution) {
        const item = document.querySelector(`[data-id="${id}"]`);
        if (!item) return;
        const el = item.querySelector(".bp-resolution");

        let color = "#e74c3c";
        if (resolution === 10000) color = "#2ecc71";
        else if (resolution >= 3000) color = "#27ae60";
        else if (resolution >= 2000) color = "#3498db";
        else if (resolution >= 1000) color = "#f39c12";

        el.textContent = `• ${resolution === 10000 ? "MAX" : resolution + "px"}`;
        el.style.color = color;
        el.style.fontWeight = resolution === 10000 ? "700" : "600";
    }

    function setFileSize(id, bytes) {
        const item = document.querySelector(`[data-id="${id}"]`);
        if (!item) return;
        item.querySelector(".bp-size").textContent =
            bytes ? ` • ${formatBytes(bytes)}` : "";
        projectBytes += bytes || 0;
    }

    function updateProgress(id, loaded, totalBytes) {
        const item = document.querySelector(`[data-id="${id}"]`);
        if (!item) return;
        if (totalBytes) {
            const percent = Math.floor((loaded / totalBytes) * 100);
            item.querySelector(".bp-fill").style.width = percent + "%";
            item.querySelector(".bp-percent").textContent = percent + "%";
        }
        item.querySelector(".bp-status").textContent = "Downloading";
    }

    function markDone(id) {
        const item = document.querySelector(`[data-id="${id}"]`);
        if (!item) return;
        item.querySelector(".bp-fill").style.width = "100%";
        item.querySelector(".bp-percent").textContent = "100%";
        item.querySelector(".bp-status").textContent = "Done";
        completed++;
        const percent = Math.round((completed / total) * 100);
        document.querySelector(".bp-total-fill").style.width = percent + "%";
    }

    function injectStyles() {
        if (document.getElementById("bp-style")) return;

        const style = document.createElement("style");
        style.id = "bp-style";
        style.textContent = `
        #bp-sidebar {
            position: fixed;
            top: 0;
            right: 0;
            width: ${SIDEBAR_WIDTH}px;
            height: 100vh;
            background: #111;
            color: #eee;
            z-index: 999999;
            display: flex;
            flex-direction: column;
            box-shadow: -4px 0 20px rgba(0,0,0,0.6);
            font-family: Arial;
            font-size: 13px;
        }
        .bp-header { padding:12px; display:flex; justify-content:space-between; background:#1a1a1a; font-weight:bold; }
        .bp-counter { padding:10px; }
        .bp-total { padding:0 10px 10px; }
        .bp-total-bar { height:6px; background:#222; border-radius:4px; overflow:hidden; }
        .bp-total-fill { height:100%; width:0%; background:#4caf50; transition: width .3s; }
        .bp-list { flex:1; overflow-y:auto; padding:10px; }
        .bp-item { margin-bottom:14px; border-bottom:1px solid #222; padding-bottom:10px; }
        .bp-path { font-size:11px; opacity:0.6; margin-bottom:2px; }
        .bp-name-line { display:flex; gap:6px; font-size:12px; flex-wrap:wrap; }
        .bp-bar { height:6px; background:#222; margin:4px 0; border-radius:4px; overflow:hidden; }
        .bp-fill { height:100%; width:0%; background:#1e88e5; transition: width .2s; }
        .bp-meta { display:flex; justify-content:space-between; opacity:0.8; font-size:11px; }
        `;
        document.head.appendChild(style);
    }

    return { mount, addItem, setResolution, setFileSize, updateProgress, markDone, updateCounter, finishCounter };

})();

/* ================= DOWNLOAD CORE (INALTERADO LÓGICA) ================= */

function downloadWithRetry(url, path, id, attempt = 1) {
    return new Promise((resolve, reject) => {

        GM_download({
            url,
            name: path,
            saveAs: false,
            conflictAction: "overwrite",
            headers: { "Referer": location.href },

            onprogress: e => Sidebar.updateProgress(id, e.loaded, e.total),

            onload: async e => {
                Sidebar.setFileSize(id, e.total || 0);
                await sleep(POST_SUCCESS_DELAY);
                resolve(true);
            },

            onerror: async () => {
                if (attempt < MAX_RETRIES) {
                    await sleep(RETRY_DELAY);
                    resolve(downloadWithRetry(url, path, id, attempt + 1));
                } else {
                    reject(false);
                }
            }
        });

    });
}

async function smartDownload(img, basePath, downloadedSet, id) {

    const candidates = getOrderedCandidates(img);

    for (let url of candidates) {

        const filename = extractFilename(url);
        if (downloadedSet.has(filename)) continue;

        const ext = extractExtension(url);
        const finalPath = `${basePath}.${ext}`;

        try {
            await downloadWithRetry(url, finalPath, id);
            Sidebar.setResolution(id, scoreUrl(url));
            downloadedSet.add(filename);
            return true;
        } catch {
            continue;
        }
    }

    return false;
}

/* ================= AUTO SCROLL ================= */

async function autoScrollToBottom() {
    let lastHeight = 0;
    let stable = 0;

    while (stable < 3) {
        window.scrollTo(0, document.body.scrollHeight);
        await sleep(600);
        const newHeight = document.body.scrollHeight;
        if (newHeight === lastHeight) stable++;
        else {
            stable = 0;
            lastHeight = newHeight;
        }
    }
}

function extractImages() {
    return [...new Set([...document.querySelectorAll("img[src*='project_modules']")])];
}

/* ================= MAIN DOWNLOAD ================= */

async function downloadProject(btn) {

    const projectId = getProjectId();
    if (!projectId) return;

    btn.style.background = "#ff9800";
    btn.textContent = "Rolando página...";

    await autoScrollToBottom();

    const images = extractImages();
    if (!images.length) return;

    Sidebar.mount(images.length);

    const project = getProjectTitle();
    const author = getUserDisplayName();

    const projects = getProjects();
    const existing = projects[projectId] || {};
    const downloaded = new Set(existing.downloadedFiles || []);

    let current = 0;
    let successCount = 0;

    for (let img of images) {

        current++;
        Sidebar.updateCounter(current);

        btn.textContent = `Baixando ${current}/${images.length}`;

        const basePath =
            `Behance/${author}/${project}/${project} - ${author} - ${pad(current)}`;

        Sidebar.addItem(current, basePath);

        const success = await smartDownload(img, basePath, downloaded, current);

        if (success) {
            successCount++;
            Sidebar.markDone(current);
        }

        await sleep(DOWNLOAD_DELAY);
    }

    Sidebar.finishCounter();

    projects[projectId] = {
        status: successCount === images.length ? "complete" : "partial",
        title: project,
        author,
        totalFiles: images.length,
        downloadedFiles: [...downloaded],
        downloadedAt: Date.now()
    };

    saveProjects(projects);
    applyStoredStatus(btn);
}
/* ================= DETECÇÃO DE PERFIL ================= */

function isUserProfilePage() {

    const segments = location.pathname.split("/").filter(Boolean);

    if (segments.length === 0) return false;

    const reservedRoutes = [
        "gallery",
        "galleries",
        "for_you",
        "search",
        "jobs",
        "hire",
        "assets",
        "subscriptions",
        "live",
        "blog"
    ];

    // Se o primeiro segmento for reservado → não é perfil
    if (reservedRoutes.includes(segments[0])) return false;

    return true;
}

/* ================= PROFILE BADGE - VERSÃO FINAL OTIMIZADA ================= */

function reconcileProfileBadges() {

    if (!isUserProfilePage()) return;

    const projects = getProjects();

    document.querySelectorAll("a[href*='/gallery/']").forEach(link => {

        const match = link.href.match(/gallery\/(\d+)/);
        if (!match) return;

        const projectId = match[1];
        const card = link.closest("article") || link.parentElement;
        if (!card) return;

        card.style.position = "relative";

        const existingBadge = card.querySelector(".bp-badge");
        const existingCheckbox = card.querySelector(".bp-checkbox");

        const isComplete = projects[projectId]?.status === "complete";

        /* ---------- COMPLETE ---------- */

        if (isComplete) {

            if (existingCheckbox) existingCheckbox.remove();
            if (existingBadge) return;

            const badge = document.createElement("div");
            badge.className = "bp-badge";
            badge.textContent = "✓";

            Object.assign(badge.style, {
                position: "absolute",
                top: "8px",
                right: "8px",
                background: "#2e7d32",
                color: "#fff",
                width: "26px",
                height: "26px",
                borderRadius: "50%",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                fontWeight: "bold",
                fontSize: "16px",
                boxShadow: "0 2px 6px rgba(0,0,0,0.4)",
                cursor: "pointer",
                zIndex: "9999"
            });

            badge.onclick = (e) => {
                e.stopPropagation();
                const updated = getProjects();
                delete updated[projectId];
                saveProjects(updated);
                reconcileProfileBadges();
            };

            card.appendChild(badge);
        }

        /* ---------- NOT COMPLETE ---------- */

        else {

            if (existingBadge) existingBadge.remove();
            if (existingCheckbox) return;

            const checkbox = document.createElement("div");
            checkbox.className = "bp-checkbox";

            Object.assign(checkbox.style, {
                position: "absolute",
                top: "8px",
                right: "8px",
                width: "26px",
                height: "26px",
                borderRadius: "4px",
                border: "2px solid #ffffff",
                background: "rgba(0,0,0,0.35)",
                boxShadow: "0 0 0 2px rgba(0,0,0,0.4)",
                cursor: "pointer",
                zIndex: "9999"
            });

            checkbox.onclick = (e) => {
                e.stopPropagation();
                const updated = getProjects();
                updated[projectId] = {
                    status: "complete",
                    title: "Manual",
                    author: "",
                    totalFiles: 0,
                    downloadedFiles: [],
                    downloadedAt: Date.now()
                };
                saveProjects(updated);
                reconcileProfileBadges();
            };

            card.appendChild(checkbox);
        }
    });
}

const markDownloadedProjectsInProfile = reconcileProfileBadges;

    /* ================= EVENTOS OTIMIZADOS ================= */

function debounce(fn, delay = 150) {
    let t;
    return (...args) => {
        clearTimeout(t);
        t = setTimeout(() => fn.apply(this, args), delay);
    };
}

const debouncedReconcile = debounce(reconcileProfileBadges, 150);

/* ---------- DOM Ready ---------- */
document.addEventListener("DOMContentLoaded", () => {
    reconcileProfileBadges();
});

/* ---------- Scroll (lazy load trigger natural) ---------- */
window.addEventListener("scroll", debouncedReconcile, { passive: true });

/* ---------- SPA Navigation Hook ---------- */

const originalPushState = history.pushState;

history.pushState = function (...args) {
    const result = originalPushState.apply(this, args);
    setTimeout(reconcileProfileBadges, 300);
    return result;
};

window.addEventListener("popstate", () => {
    setTimeout(reconcileProfileBadges, 300);
});


/* ================= OBSERVER + SCROLL ================= */

function isGalleryPage() {
    return /^\/gallery\//.test(location.pathname);
}

if (!isGalleryPage()) {

    let observerTimeout;

    const profileObserver = new MutationObserver(() => {

        if (observerTimeout) return;

        observerTimeout = setTimeout(() => {
            markDownloadedProjectsInProfile();
            observerTimeout = null;
        }, 150);

    });

    profileObserver.observe(document.body, {
        childList: true,
        subtree: true
    });

    window.addEventListener("scroll", () => {
        markDownloadedProjectsInProfile();
    }, { passive: true });

}

/* ================= BUTTON ================= */

function applyStoredStatus(btn) {

    const projectId = getProjectId();
    if (!projectId) return;

    const status = getProjects()[projectId]?.status;

    if (status === "complete") {
        btn.style.background = "#2e7d32";
        btn.textContent = "✓ Já baixado";
    } else if (status === "partial") {
        btn.style.background = "#ff9800";
        btn.textContent = "Parcial";
    } else {
        btn.style.background = "#0057ff";
        btn.textContent = "⬇ Baixar Projeto";
    }
}

function createButton() {

    if (!location.pathname.includes("/gallery/")) return;

    let btn = document.getElementById("behance-project-downloader");

    if (!btn) {

        btn = document.createElement("button");
        btn.id = "behance-project-downloader";

        Object.assign(btn.style, {
            position: "fixed",
            bottom: "20px",
            right: "20px",
            zIndex: 999999,
            padding: "10px 14px",
            borderRadius: "6px",
            border: "none",
            color: "#fff",
            cursor: "pointer"
        });

        btn.onclick = (e) => {

            const projectId = getProjectId();

            if (e.altKey) {
                resetProjectStatus(projectId);
                applyStoredStatus(btn);
                alert("Status resetado.");
                return;
            }

            downloadProject(btn);
        };

        document.body.appendChild(btn);
    }

    applyStoredStatus(btn);
}

/* ================= INIT ================= */

createButton();
markDownloadedProjectsInProfile();
    })();