Google Classroom - Download Post Attachments

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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!");
})();