LoginSignup
4
4

More than 5 years have passed since last update.

PuppeteerでScratchプログラムを自動生成する

Posted at

Puppeteerを使ってScratchプログラムを自動生成してみました。

環境はこんな感じです。
Windows10でプログラミングしましたが、Macでも動きました。

>node -v
v10.15.1
>nodist -v
0.8.8
>npm -v
4.0.5

Puppeteerのセットアップはこのようにしました。

npm init -y
npm install --save puppeteer@1.11.0

下記のscratch.jsを用意してコマンドを実行すれば簡単なScratchプログラムを自動生成します。

node scratch.js

別途、settings.jsを用意しておきましょう。

settings.js
exports.username='scratchのID';
exports.password='scratchのパスワード';
exports.email='あなたのEメールアドレス';

ちなみに、Scratchのバージョンは3.0です。
バージョンが違うと動かない可能性があります。

scratch.js
const puppeteer = require('puppeteer');
const settings = require('./settings.js');
(async() => {
    const options = process.env.DEBUG ? {
        devtools: true,
        slowMo: 100
    } : {
    };
    const browser = await puppeteer.launch(options);
    const page = await browser.newPage();
    const VIEWPORT = {width: 1200, height: 800, deviceScaleFactor: 1};
    await page.setViewport(VIEWPORT);
    await page.setUserAgent('Puppeteer ' + settings.email);
    await Promise.all([
        page.waitFor('.login-item'),
        page.goto('https://scratch.mit.edu', {waitUntil: "domcontentloaded"})
    ]);
    // await page.evaluate(() => { debugger; });

    // ログイン.
    await page.click('.login-item');
    await page.type('input[name=username]', settings.username);
    await page.type('input[name=password]', settings.password);
    await Promise.all([
        page.waitFor('.account-nav'),
        page.click('.submit-button')
    ]);

    // 作る.
    const inputProjectTitleSelector = 'input[class*="project-title-input"]';
    await Promise.all([
        page.waitFor(inputProjectTitleSelector),
        page.goto('https://scratch.mit.edu/projects/editor', {waitUntil: "domcontentloaded"})
    ]);

    // 作品タイトル設定.
    const projectTitle = await page.$(inputProjectTitleSelector);
    selectText(page, inputProjectTitleSelector);
    const nowDate = new Date();
    const title = 'Created by Puppeteer at ' + (nowDate.getMonth() + 1) + '/' + nowDate.getDate() + ' ' + nowDate.getHours() + ':' + nowDate.getMinutes();
    await projectTitle.type(title);

    // await Test1(page);
    await Test2(page);

    // 「旗」.
    const greenFlag = await page.$('[class*="green-flag_green-flag"]')
    if (greenFlag != null) {
        await greenFlag.click();
        await page.waitFor(11000);
    }

    // await page.evaluate(() => { debugger; });

    // 「直ちに保存」.
    await page.waitFor(3000);
    const save = await page.$('[class*="save-status_save-now"]');
    if (save != null) {
        await save.click();
        await page.waitFor(3000);
    }
    // サインアウト.
    await page.waitFor('div[class*="account-nav_dropdown-caret"]');
    await page.click('div[class*="account-nav_dropdown-caret"]');
    await page.click('div[class*="menu-bar_account-info"] li[class*="menu_menu-section"]');
    await page.waitFor(3000);
    await browser.close();
})();

const selectText = async function (page, selector) {
    await page.evaluate(selector => {
        const title = document.querySelector(selector);
        title.select();
    }, selector);
};

const dragAndDrop = async function (page, fromX, fromY, toX, toY) {
    await page.mouse.move(fromX, fromY);
    await page.mouse.down();
    await page.mouse.move(toX, toY, {steps: 1});
    await page.mouse.up();
};

const searchBlockPalette = async function (page, dataId, yMax) {
    for (let count=0; count < 1000; count++) {
        let rectBlock = await page.$eval('[data-id="'+dataId+'"]', el => {
            const { width, height, top: y, left: x } = el.getBoundingClientRect();
            return { width, height, x, y };
        });
        if (0 < rectBlock.y && rectBlock.y < yMax) {
            break;
        }
        let rectScrollbarHandle = await page.$eval('.blocklyFlyoutScrollbar .blocklyScrollbarHandle', el => {
            const { width, height, top: y, left: x } = el.getBoundingClientRect();
            return { width, height, x, y };
        });
        let clickPosX = rectScrollbarHandle.x + (rectScrollbarHandle.width / 2);
        let clickPosY = rectScrollbarHandle.y;
        if (rectBlock.y <= 0) {
            clickPosY -= 10;
            if (clickPosY < 0) {
                clickPosY = 0;
            }
        }
        else if (rectBlock.y >= yMax) {
            clickPosY += (10 + rectScrollbarHandle.height);
        }
        console.log('click:('+clickPosX+','+clickPosY+')');
        await page.mouse.move(clickPosX, clickPosY);
        await page.mouse.down();
        await page.mouse.up();
    }
};

const selectCategory = async function (page, category) {
    const categoryControl = await page.$('.scratchCategoryId-'+category);
    if (categoryControl != null) {
        await categoryControl.click();
        await page.waitFor(1000);
    }
};

const addBlock = async function (page, dataId, parentRect, nest=false) {
    let rect = await page.$eval('[data-id="'+dataId+'"]', el => {
        const { width, height, top: y, left: x } = el.getBoundingClientRect();
        return { width, height, x, y };
    });
    const yMax = 500;
    if (rect.y > yMax) {
        await searchBlockPalette(page, dataId, yMax);
        rect = await page.$eval('[data-id="'+dataId+'"]', el => {
            const { width, height, top: y, left: x } = el.getBoundingClientRect();
            return { width, height, x, y };
        });
    }
    const offset_x = 20;
    const offset_y = 20;
    const move_x = 0;
    const move_y = parentRect.height;
    const nest_x = 20;
    const nest_y = 30;
    let fromX = rect.x + offset_x;
    let fromY = rect.y + offset_y;
    let toX = parentRect.x + offset_x;
    let toY = parentRect.y + offset_y;
    if (nest) {
        toX += nest_x;
        toY += nest_y;
    }
    else {
        toX += move_x;
        toY += move_y;
    }
    console.log('fromX:'+fromX+' fromY:'+fromY+' toX:'+toX+' toY:'+toY);
    await dragAndDrop(page, fromX, fromY, toX, toY);
};

const addBlockEx = async function (page, category, dataId, blockId, parentBlockId='', nest=false) {
    console.log('addBlockEx start');
    // カテゴリ.
    console.log('category:'+category);
    await selectCategory(page, category);
    // 親ブロック.
    let parentRect = { width: 0, height: 0, x: 0, y: 0 };
    if (parentBlockId != '') {
        console.log('parentBlockId:'+parentBlockId);
        parentRect = await page.$eval('[data-block-id="'+parentBlockId+'"]', el => {
            const { width, height, top: y, left: x } = el.getBoundingClientRect();
            return { width, height, x, y };
        });
    }
    else {
        console.log('dataId:'+dataId);
        const rect = await page.$eval('[data-id="'+dataId+'"]', el => {
            const { width, height, top: y, left: x } = el.getBoundingClientRect();
            return { width, height, x, y };
        });
        parentRect = { width: 0, height: 0, x: rect.x + 400, y: rect.y + 0 };
    }
    // ブロック.
    console.log('parentRect width:'+parentRect.width+' height:'+parentRect.height+' x:'+parentRect.x+' y:'+parentRect.y);
    await addBlock(page, dataId, parentRect, nest);
    console.log('evaluate');
    await page.evaluate((selector, blockId) => {
        console.log('selector:'+selector);
        const el = document.querySelector(selector);
        console.log('blockId:'+blockId);
        el.setAttribute('data-block-id', blockId);
    }, '.blocklySelected', blockId);
};

const Test1 = async function (page) {
    // 「イベント」.
    // 「フラッグが押されたとき」.
    const dataBlockIdWhenFlagClicked_0 = 'dataBlockIdWhenFlagClicked_0';
    await addBlockEx(page, 'events', 'event_whenflagclicked', dataBlockIdWhenFlagClicked_0);
    // 「制御」.
    // 「10回繰り返す」.
    const dataBlockIdRepeat_0 = 'dataBlockIdRepeat_0';
    await addBlockEx(page, 'control', 'control_repeat', dataBlockIdRepeat_0, dataBlockIdWhenFlagClicked_0);
    // 「動き」.
    // 「10歩動かす」.
    const dataBlockIdMovesteps_0 = 'dataBlockIdMovesteps_0';
    await addBlockEx(page, 'motion', 'motion_movesteps', dataBlockIdMovesteps_0, dataBlockIdRepeat_0, true);
    // 「制御」.
    // 「1秒待つ」.
    const dataBlockIdWait_0 = 'dataBlockIdWait_0';
    await addBlockEx(page, 'control', 'control_wait', dataBlockIdWait_0, dataBlockIdMovesteps_0);
};

// ネコあるき.
const Test2 = async function (page) {
    // 「イベント」.
    // 「フラッグが押されたとき」.
    const dataBlockIdWhenFlagClicked_0 = 'dataBlockIdWhenFlagClicked_0';
    await addBlockEx(page, 'events', 'event_whenflagclicked', dataBlockIdWhenFlagClicked_0);
    // 「動き」.
    // 「回転方法を左右のみにする」.
    const dataBlockIdSetRotationStyle_0 = 'dataBlockIdSetRotationStyle_0';
    await addBlockEx(page, 'motion', 'motion_setrotationstyle', dataBlockIdSetRotationStyle_0, dataBlockIdWhenFlagClicked_0);
    // 「制御」.
    // 「ずっと」.
    const dataBlockIdForever_0 = 'dataBlockIdForever_0';
    await addBlockEx(page, 'control', 'forever', dataBlockIdForever_0, dataBlockIdSetRotationStyle_0);
    // 「動き」.
    // 「10歩動かす」.
    const dataBlockIdMovesteps_0 = 'dataBlockIdMovesteps_0';
    await addBlockEx(page, 'motion', 'motion_movesteps', dataBlockIdMovesteps_0, dataBlockIdForever_0, true);
    // 「動き」.
    // 「もし端に着いたら、跳ね返る」.
    const dataBlockIdIfOnEdgeBounce_0 = 'dataBlockIdIfOnEdgeBounce_0';
    await addBlockEx(page, 'motion', 'motion_ifonedgebounce', dataBlockIdIfOnEdgeBounce_0, dataBlockIdMovesteps_0);
    // 「見た目」.
    // 「次のコスチュームにする」.
    const dataBlockIdNextCostume_0 = 'dataBlockIdNextCostume_0';
    await addBlockEx(page, 'looks', 'looks_nextcostume', dataBlockIdNextCostume_0, dataBlockIdMovesteps_0);
    // 「動き」.
    let category = 'motion';
    await selectCategory(page, category);
    // 「15度回す」×3.
    const offset_x = 20;
    const offset_y = 20;
    let dataId = 'motion_turnleft';
    let rect = await page.$eval('[data-id="'+dataId+'"]', el => {
        const { width, height, top: y, left: x } = el.getBoundingClientRect();
        return { width, height, x, y };
    });
    await page.mouse.move(rect.x + offset_x, rect.y + offset_y);
    for (let i=0; i < 3; i++) {
        await page.mouse.down();
        await page.mouse.up();
    }
};
4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4