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

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を用意しておきましょう。

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

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

```javascript: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();
    }
};
```