Google Classroom - Download Post Attachments

Adds a download button to each Google Classroom post that fetches and downloads all attachments using the Classroom API

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Google Classroom - Download Post Attachments
// @namespace    https://example.com/userscripts
// @version      1.0
// @description  Adds a download button to each Google Classroom post that fetches and downloads all attachments using the Classroom API
// @author       @krispy-snacc (https://github.com/krispy-snacc)
// @match        https://classroom.google.com/*
// @grant        none
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function () {
    "use strict";

    const BTN_CLASS = "gc-download-btn-v4";

    const styles = "." + BTN_CLASS + "{}";

    function injectStyles() {
        if (document.getElementById("gc-download-styles")) return;
        const s = document.createElement("style");
        s.id = "gc-download-styles";
        s.textContent = styles;
        document.head.appendChild(s);
    }

    function getClassId() {
        const url = window.location.href;
        const match = url.match(/\/c\/([^\/]+)/);
        return match ? match[1] : null;
    }

    function getUserId() {
        const url = window.location.href;
        const match = url.match(/\/u\/(\d+)\//);
        return match ? match[1] : "0";
    }

    async function fetchPostAttachments(postId, classId, userId) {
        const postIdDecoded = atob(postId);
        const classIdDecoded = atob(classId);

        const body =
            "f.req=%5B%5B%5B%22tQShAc%22%2C%22%5B%5B%5B%5C%22" +
            encodeURIComponent(postIdDecoded) +
            "%5C%22%2C%5B%5C%22" +
            encodeURIComponent(classIdDecoded) +
            "%5C%22%5D%5D%5D%2C%5B%5B%5Bnull%2C1%2Cnull%2Cnull%2Cnull%2Cnull%2C%5B1%5D%5D%5D%2C%5B%5Bnull%2C1%2Cnull%2Cnull%2Cnull%2Cnull%2C%5B1%5D%5D%5D%2C%5B%5Bnull%2C1%2Cnull%2Cnull%2Cnull%2Cnull%2C%5B1%5D%5D%5D%2Cnull%2Cnull%2C%5B%5Bnull%2C1%2Cnull%2Cnull%2Cnull%2Cnull%2C%5B1%5D%5D%5D%5D%5D%22%2Cnull%2C%22generic%22%5D%5D%5D&at=" +
            encodeURIComponent(window.IJ_values[42]);

        try {
            const res = await fetch(
                "https://classroom.google.com/u/" +
                    userId +
                    "/_/ClassroomUi/data/batchexecute?rpcids=tQShAc&source-path=%2Fu%2F" +
                    userId +
                    "%2Fc%2F" +
                    classId +
                    "%2Fm%2F" +
                    postId +
                    "%2Fdetails&soc-app=1&soc-platform=1&soc-device=1&rt=c",
                {
                    headers: {
                        accept: "*/*",
                        "accept-language": "en-US,en;q=0.9",
                        "content-type":
                            "application/x-www-form-urlencoded;charset=UTF-8",
                    },
                    referrer: "https://classroom.google.com/",
                    body: body,
                    method: "POST",
                    mode: "cors",
                    credentials: "include",
                }
            );

            const text = await res.text();
            const lines = text.split("\n");
            if (lines.length < 4) {
                throw new Error("Invalid API response format");
            }

            const cleaned = lines[3];
            const parsed = JSON.parse(JSON.parse(cleaned)[0][2]);
            const data = parsed[1][0][2];
            const attachments = data[data.length - 1][0][7] || [];

            return attachments.map(function (a) {
                return {
                    name: a[0],
                    fileId: a[2],
                    viewUrl: a[6],
                    downloadUrl:
                        "https://drive.google.com/uc?export=download&id=" +
                        a[2] +
                        "&authuser=" +
                        userId,
                };
            });
        } catch (error) {
            console.error("[GC Download] Error fetching attachments:", error);
            throw error;
        }
    }

    async function downloadFile(url, filename) {
        try {
            // Fetch the file as a blob
            const response = await fetch(url);
            const blob = await response.blob();

            // Create a temporary download link
            const blobUrl = URL.createObjectURL(blob);
            const a = document.createElement("a");
            a.href = blobUrl;
            a.download = filename;
            a.style.display = "none";

            document.body.appendChild(a);
            a.click();

            // Cleanup
            setTimeout(function () {
                document.body.removeChild(a);
                URL.revokeObjectURL(blobUrl);
            }, 100);
        } catch (error) {
            console.warn(
                "[GC Download] Blob download failed, falling back to new tab:",
                error
            );
            // Fallback to opening in new tab if fetch fails
            window.open(url, "_blank");
        }

        // Small delay between downloads
        await new Promise(function (resolve) {
            setTimeout(resolve, 500);
        });
    }

    async function downloadPostAttachments(postElement) {
        const postId = postElement.getAttribute("data-stream-item-id");
        if (!postId) {
            throw new Error("Post ID not found");
        }

        const classId = getClassId();
        const userId = getUserId();

        if (!classId) {
            throw new Error("Class ID not found in URL");
        }

        const encodedPostId = btoa(postId);
        // classId from URL is already base64 encoded, use it directly
        const encodedClassId = classId;

        console.log("[GC Download] Fetching attachments for post:", postId);

        const attachments = await fetchPostAttachments(
            encodedPostId,
            encodedClassId,
            userId
        );

        if (!attachments || attachments.length === 0) {
            alert("No attachments found in this post.");
            return 0;
        }

        console.log(
            "[GC Download] Found " + attachments.length + " attachments"
        );

        for (let i = 0; i < attachments.length; i++) {
            const attachment = attachments[i];
            console.log(
                "[GC Download] Downloading: " +
                    attachment.name +
                    " (" +
                    (i + 1) +
                    "/" +
                    attachments.length +
                    ")"
            );
            await downloadFile(attachment.downloadUrl, attachment.name);
        }

        return attachments.length;
    }

    function insertButtonForPost(postElement) {
        const postId = postElement.getAttribute("data-stream-item-id");
        if (postElement._gcDownloadInitialized) return;
        postElement._gcDownloadInitialized = true;

        // Find the three-dot menu button (the one with more_vert icon)
        const menuButton = postElement.querySelector(
            'button.pYTkkf-Bz112c-LgbsSe[aria-haspopup="menu"]'
        );
        if (!menuButton) {
            console.warn("[GC Download] Menu button not found for post");
            return;
        }

        const clickHandler = async function (e) {
            e.stopPropagation();
            e.preventDefault();

            const originalText =
                e.target.textContent || "Download all attachments";
            e.target.textContent = "Downloading...";

            try {
                const count = await downloadPostAttachments(postElement);
                if (count > 0) {
                    e.target.textContent = "Downloaded " + count + " file(s)";
                } else {
                    e.target.textContent = "No attachments found";
                }
                setTimeout(function () {
                    e.target.textContent = originalText;
                }, 3000);
            } catch (error) {
                console.error("[GC Download] Error:", error);
                e.target.textContent = "Error downloading";
                alert(
                    "Failed to download attachments: " +
                        (error.message || "Unknown error")
                );
                setTimeout(function () {
                    e.target.textContent = originalText;
                }, 3000);
            }
        };

        // Add click listener to the menu button to inject our option when clicked
        menuButton.addEventListener("click", function () {
            console.log("[GC Download] Menu button clicked for post:", postId);

            // Wait for the menu to appear in DOM
            setTimeout(function () {
                // Find the menu popup container and then the ul inside it
                const menuContainer = document.querySelector(
                    'div[id^="ucc"][class^="tB5Jxf-xl07Ob"]'
                );
                if (!menuContainer) {
                    console.log("[GC Download] Menu container not found");
                    return;
                }

                const menu = menuContainer.querySelector(
                    'ul[role="menu"].aqdrmf-rymPhb'
                );
                if (!menu) {
                    console.log(
                        "[GC Download] Menu ul not found inside container"
                    );
                    return;
                }

                if (menu.querySelector("." + BTN_CLASS)) {
                    console.log("[GC Download] Button already exists in menu");
                    return;
                }

                console.log(
                    "[GC Download] Menu found, inserting download option"
                );

                // Create menu item matching the exact structure of "Copy link"
                const menuItem = document.createElement("li");
                menuItem.className =
                    "aqdrmf-rymPhb-ibnC6b aqdrmf-rymPhb-ibnC6b-OWXEXe-hXIJHe aqdrmf-rymPhb-ibnC6b-OWXEXe-SfQLQb-Woal0c-RWgCYc O68mGe-OQAXze-OWXEXe-SfQLQb-Woal0c-RWgCYc O68mGe-xl07Ob-ibnC6b-OWXEXe-r08add O68mGe-xl07Ob-ibnC6b-OWXEXe-E6eRQd " +
                    BTN_CLASS;
                menuItem.setAttribute("role", "menuitem");
                menuItem.setAttribute("tabindex", "-1");
                menuItem.setAttribute("jsname", "SbVnGf");

                // Create the inner structure
                const span1 = document.createElement("span");
                span1.className = "UTNHae";

                const span2 = document.createElement("span");
                span2.className = "dNKuRb aqdrmf-rymPhb-sNKcce";

                const span3 = document.createElement("span");
                span3.className = "aqdrmf-rymPhb-KkROqb";

                const span4 = document.createElement("span");
                span4.className = "aqdrmf-rymPhb-Gtdoyb";

                const textSpan = document.createElement("span");
                textSpan.className = "aqdrmf-rymPhb-fpDzbe-fmcmS";
                textSpan.setAttribute("jsname", "K4r5Ff");
                textSpan.textContent = "Download all attachments";

                span4.appendChild(textSpan);

                const span5 = document.createElement("span");
                span5.setAttribute("jsname", "orbTae");
                span5.className = "aqdrmf-rymPhb-JMEf7e";

                const span6 = document.createElement("span");
                span6.className = "O68mGe-xl07Ob-mQXhdd";

                menuItem.appendChild(span1);
                menuItem.appendChild(span2);
                menuItem.appendChild(span3);
                menuItem.appendChild(span4);
                menuItem.appendChild(span5);
                menuItem.appendChild(span6);

                // Add click handler
                menuItem.onclick = clickHandler;

                // Find the first real menu item (skip the focus trap divs)
                const firstMenuItem = menu.querySelector('li[role="menuitem"]');
                if (firstMenuItem) {
                    menu.insertBefore(menuItem, firstMenuItem);
                    console.log(
                        "[GC Download] Button inserted before first menu item"
                    );
                } else {
                    // If no menu items exist, insert after the focus trap divs
                    const focusTraps = menu.querySelectorAll("div.pw1uU");
                    if (focusTraps.length >= 2) {
                        focusTraps[1].after(menuItem);
                        console.log(
                            "[GC Download] Button inserted after focus traps"
                        );
                    } else {
                        menu.appendChild(menuItem);
                        console.log("[GC Download] Button appended to menu");
                    }
                }

                console.log(
                    "[GC Download] Button inserted successfully, menu now has",
                    menu.querySelectorAll('li[role="menuitem"]').length,
                    "items"
                );
            }, 10);
        });
    }

    function scanAndAddButtons() {
        const posts = document.querySelectorAll(
            "div[data-include-stream-item-materials][data-stream-item-id]"
        );

        console.log("[GC Download] Found " + posts.length + " posts");

        posts.forEach(function (post) {
            insertButtonForPost(post);
        });
    }

    console.log("[GC Download] Script initializing...");
    injectStyles();

    const observer = new MutationObserver(function () {
        try {
            scanAndAddButtons();
        } catch (e) {
            console.error("[GC Download] Error in observer:", e);
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    setTimeout(scanAndAddButtons, 1500);
    setTimeout(scanAndAddButtons, 3000);
    setTimeout(scanAndAddButtons, 5000);

    console.log("[GC Download] Initialized successfully!");
})();