Scratch Hammer

Scratchのファイルサイズの5MB制限を突破する

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Scratch Hammer
// @namespace    https://scratch.mit.edu/
// @version      20240602
// @description  Scratchのファイルサイズの5MB制限を突破する
// @author       Yukkku
// @match        https://scratch.mit.edu/projects/*
// @grant        none
// @require      https://unpkg.com/[email protected]/dist/jszip.min.js
// @license      MIT
// ==/UserScript==

// @ts-check

(() => {
    'use strict';

    /**
     * project.jsonの中身を削減する
     * @param {string} json
     * @returns string
     */
    const compress = (json) => {
        /**
         * uuidを`n`個生成する
         * @param {number} n
         * @returns {string[]}
         */
        const makeUids = n => {
            const soup = '!#%()*+,-./:;=?@[]^_`{|}~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
            /** @type {(i: number) => string} */
            const id = i => i === 0 ? '' : id(Math.floor((i - 1) / soup.length)) + soup[(i - 1) % soup.length];
            /** @type {string[]} */
            let r = [];
            for (let i = 1; r.length < n; i++) {
                let f = id(i);
                // オブジェクトのプロパティの順序について, 非負整数だけ例外なのでそれを除外
                if (!/^(0|[1-9][0-9]*)$/.exec(f)) r.push(f);
            }
            return r;
        };
        /**
         * オブジェクトが空か判定する
         * @param {any} v
         * @returns {boolean}
         */
        const isEmpty = v => {
            for (const _ in v) return false;
            return true;
        };

        const knownExtentions = new Set(['motion', 'looks', 'sound', 'event', 'control', 'sensing', 'operator', 'data']);
        // 自動で型変換がされるので短くなるように数値に変えていい型
        const nocast = {
            looks_sayforsecs: ['MESSAGE'],
            looks_say: ['MESSAGE'],
            looks_thinkforsecs: ['MESSAGE'],
            looks_think: ['MESSAGE'],
            looks_switchcostumeto: ['COSTUME'],
            looks_switchbackdropto: ['BACKDROP'],
            looks_switchbackdroptoandwait: ['BACKDROP'],
            sound_play: ['SOUND_MENU'],
            sound_playuntildone: ['SOUND_MENU'],
            sensing_keypressed: ['KEY_OPTION'],
            data_setvariableto: ['VALUE'],
            data_addtolist: ['ITEM'],
            data_insertatlist: ['ITEM'],
            data_replaceitemoflist: ['ITEM'],
        };

        const val = JSON.parse(json);
        delete val.meta.vm;
        delete val.meta.agent;
        for (const target of val.targets) {
            // 不要な諸々を消す
            if (target.tempo === 60) delete target.tempo;
            if (target.volume === 100) delete target.volume;
            if (target.videoTransparency === 50) delete target.videoTransparency;
            if (target.videoState === 'on') delete target.videoState;
            if (target.textToSpeechLanguage === 'null') delete target.textToSpeechLanguage;
            if (isEmpty(target.lists)) delete target.lists;
            if (isEmpty(target.broadcasts)) delete target.broadcasts;
            if (isEmpty(target.comments)) delete target.comments;
            if (target.x === 0) delete target.x;
            if (target.y === 0) delete target.y;
            if (target.direction === 90) delete target.direction;
            if (target.size === 100) delete target.size;
            if (target.visible === true) delete target.visible;
            if (target.currentCostume === 0) delete target.currentCostume;
            if (target.rotationStyle === 'all around') delete target.rotationStyle;
            if (target.draggable === false) delete target.draggable;
            for (const costume of target.costumes)
                if (costume.md5ext === `${costume.assetId}.${costume.dataFormat}`) delete costume.md5ext;
            for (const blockId in target.blocks) {
                const block = target.blocks[blockId];
                if (Array.isArray(block)) continue;
                if (isEmpty(block.inputs)) delete block.inputs;
                if (isEmpty(block.fields)) delete block.fields;
                if (knownExtentions.has(block.opcode.split('_')[0]))
                    for (const inputName in block.inputs) {
                        if (nocast[block.opcode]?.has?.(inputName)) continue;
                        const input = block.inputs[inputName];
                        for (let i = 1; i < input.length; i++) {
                            if (typeof input[i] === 'string' || input[i] == null) continue;
                            if (![4, 5, 6, 7, 8, 10].includes(input[i][0])) continue;
                            const v = JSON.parse(JSON.stringify(Number(input[i][1])));
                            if (typeof v === 'number' && String(v) === input[i][1]) input[i][1] = v;
                        }
                    }
            }
            // BlockIdを短く貼りかえる
            /** @type {Map<string, string>} */
            const mp = new Map();
            {
                const ids = Object.keys(target.blocks);
                const uids = makeUids(Object.keys(target.blocks).length);
                for (let i = 0; i < ids.length; i++) mp.set(ids[i], uids[i]);
            }
            const nb = Object.create(null);
            for (const blockId in target.blocks) {
                const block = target.blocks[blockId];
                nb[mp.get(blockId) ?? blockId] = block;
                if (Array.isArray(block)) continue;
                if (isEmpty(block.inputs)) delete block.inputs;
                if (isEmpty(block.fields)) delete block.fields;
                if (typeof block.next === 'string') block.next = mp.get(block.next) ?? block.next;
                if (typeof block.parent === 'string') block.parent = mp.get(block.parent) ?? block.parent;
                for (const inputName in block.inputs) {
                    const input = block.inputs[inputName];
                    for (let i = 1; i < input.length; i++)
                        if (typeof input[i] === 'string') input[i] = mp.get(input[i]) ?? input[i];
                }
            }
            for (const commentId in target.comments) {
                const comment = target.comments[commentId];
                if (typeof comment.blockId === 'string') comment.blockId = mp.get(comment.blockId) ?? comment.blockId;
            }
            target.blocks = nb;
        }
        return JSON.stringify(val);
    };

    // sendメソッド自体を書き換えてやって, プロジェクトの情報を送ろうとしている時だけ処理を変える
    XMLHttpRequest.prototype._send = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = async function (...data) {
        let zip;
        try {
            if (data.length === 1
                && this.method === 'put'
                && new URL(this.url).origin === 'https://projects.scratch.mit.edu'
            ) {
                zip = new JSZip();
                zip.file('project.json', compress(data[0]).replaceAll('\\b', '\\u\\b0008'));
                zip = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
            } else {
                throw 0;
            }
        } catch (_) {
            this._send(...data);
            return;
        }

        this.setRequestHeader('Content-Type', 'application/zip');
        this._send(zip);
    };
})();