Puppeteer はてなブログのアイキャッチ画像をカテゴリ別に一括変更する

こんにちは。レモンティーです。

今回はpuppeteerではてなブログのアイキャッチ画像をカテゴリ別に一括変更します。
このブログでは長いことすべての記事のアイキャッチ画像が顔つきレモンティーの絵でした。全文表示のときはそれでもあまり関係ないのですが、ブログのデザインをNavyDarkCodeにして記事を一覧表示に変更すると同じ画像が並んでいて結構気になります。しかしすでに記事の数は200を超えています...。そこでpuppeteerです。

以下のようにカテゴリと画像URLの対応だけつくったらあとは自動でそれを適用してくれたらいいですよね。複数カテゴリがある場合は先頭のカテゴリで。(画像URLは、はてなフォトライフとかどっかにアップロードしたURLです。)

カテゴリとURLの対応

const pics = {
  'Javascript':'https://hoge.png',
  'Unity':'https://huga.png',
  ...
}

今回はそんなコードを書きました。

準備

まずモジュール

npm i puppeteer dotenv

それから環境変数

.env

HATENA_ID="hoge"
HATENA_PW="huga"
BLOG_DOMAIN="hogege.hatenablog.com"

そしてコード

index.js

const puppeteer = require("puppeteer");
require("dotenv").config();

(async () => {
    const id = process.env.HATENA_ID;
    const pw = process.env.HATENA_PW;
    const domain = process.env.BLOG_DOMAIN;
    const pics = {
      'hoge':'https://hoge.png'
    }

    try{
        const browser = await puppeteer.launch();
        await hatenaLogin(browser,id,pw);
        const editUrlList = await makeEditUrlList(browser,id,domain);
        let fine = 0,bad = 0,skippedUrl = [];
        for(let url of editUrlList){
            try{
                await changeEyecatch(browser,url,pics);
                fine += 1;
            }catch(err){
                console.log('article skipped because : ',err);
                skippedUrl.push(url);
                bad += 1;
            }
        }
    
        await browser.close();
        console.log('finish');
        console.log(`${fine} articles are changed without any problems.`);
        console.log(`${bad} articles are skipped.`);
        console.log('skipped url list :');
        for(const url of skippedUrl){
            console.log(url);
        }
    }catch(err){
        console.log('stop running because Error :\n',err);
        await browser.close();
    }
})();

async function changeEyecatch(browser,editUrl,pics){
    console.log('start edit :',editUrl);
    const page = await browser.newPage();
    await page.goto(editUrl,{waitUntil:'load',timeout:20000});
    //カテゴリーを取得
    await page.click('.editor-curation-category');
    await page.waitForSelector('.editor-sidebar-category_Categories',{timeout:20000});
    const categorySpan = await page.$('.editor-sidebar-category_Category > span.name');
    if(!categorySpan)throw new Error('no category');
    const category = await (await categorySpan.getProperty('textContent')).jsonValue();
    console.log('category : ',category);
    //画像を決定
    const pic = pics[category];
    if(!pic)throw new Error('unknown category');
    console.log('pic : ',pic);
    //画像を変更
    await page.click('.editor-curation-option');
    await page.waitForSelector('.remove-og-image',{timeout:20000});
    await page.click('.remove-og-image');
    await page.waitFor(500);
    await page.type('#ogimage-input',pic);
    //編集を終了
    await page.click('#submit-button');
    await page.waitForNavigation({waitUntil:'domcontentloaded',timeout:20000});
    await page.close();
    console.log('eyecatch pic changed');
}

async function makeEditUrlList(browser,id,domain,firstArticle=0,maxArticle=300){
    if(firstArticle > maxArticle)return [];
    console.log('start making edit url list');
    const page = await browser.newPage();
    //記事一覧ページに移動
    const articleListPageURL = `https://blog.hatena.ne.jp/${id}/${domain}/entries`;
    await page.goto(articleListPageURL,{waitUntil:'load',timeout:20000});
    //記事をすべて表示(押せなくなるまで「次のページ」を押す)
    while(true){
        await page.waitFor(1000);
        const hasNextPage = await page.$eval('.load-next-entries',(elem) => {
            return elem.style.display !== 'none';
        });
        if(!hasNextPage)break;
        await page.click('.load-next-entries');
    }
    //表示されている記事のURLを取得
    const editLinkTags = await page.$$('a.entry-title');
    if(editLinkTags.length <= firstArticle)throw new Error('firstArticle out of range');
    console.log(`there are ${editLinkTags.length} articles 
    (edit ${firstArticle} to ${Math.min(editLinkTags.length-1,maxArticle)})`);
    let editUrlList = [];
    for(let i=0; i<editLinkTags.length; i++){
        if(i < firstArticle || maxArticle <= i)continue;
        const href = await (await editLinkTags[i].getProperty('href')).jsonValue();
        editUrlList.push(href);
        const title = await (await editLinkTags[i].getProperty('textContent')).jsonValue();
        console.log(`add article : ${title}`);
    }
    await page.close();
    console.log('complete');
    return editUrlList;
}

async function hatenaLogin(browser,id,password){
    const page =  await browser.newPage();
    await page.goto('https://www.hatena.ne.jp/login',{waitUntil:'domcontentloaded'});
    await page.type("#login-name",id);
    await page.type("input.password",password);
    await page.click("input.submit-button");
    await page.waitForNavigation({timeout:60000,waitUntil:'domcontentloaded'});
    console.log("login");
    await page.close();
}

実行

node index.js

で実行されます。
かなりゆっくり進むのでべつのことでもしながら待ってください。もしカテゴリをtypoしたり、picsにないカテゴリに遭遇したり、そもそもカテゴリがない記事にでくわしたりするとその記事はスキップされます。
また、ちょいちょいタイムアウトによるスキップもあります。
スキップされた記事の編集URLは最後にまとめて表示されますので、その分は手動で写真を変えるか、数が多い場合はmakeEditUrlListに範囲の引数を渡して再実行とか…。まああまりできのいいものではないです。全部手動よりはまし、くらいの感じ。公式ではないのでブログがやばいことになっても自己責任でお試しください…。

今回はこれでおしまいです。
www.sawalemontea.com