この記事は Node.js Advent Calendar 2020 の2日目の記事です。

puppeteer でファイルダウンロードする方法はこちらの issue がまだopenなことからも分かるように、すんなり実現できる公式の方法はなさそうです。
とはいっても、スクレイピングをしてて、ファイルダウンロードしたいケースはあるわけで、実際に試してみようと思います。

前準備

とりあえず、puppeteerをインストールします。puppeteer以外に必要なライブラリは、個別に追記しています。

npm install puppeteer --save

実行方法

サンプルソースを適当な名前のファイル(たとえばsample.js)にコピペしてnodeコマンドで実行するだけで、実際に動かすことができます。

node sample.js

ファイルをダウンロードする

方法1【△】: 単純にブラウザのファイルダウンロード処理を実行

ChromiumのPage.setDownloadBehaviorメソッドを利用して、ブラウザのダウンロード処理を許可する方法です。
概ねうまく動きますが、ブラウザのビューアーで表示するケースはダウンロードできません。

const puppeteer = require('puppeteer');

(async () => {
    async function sleep(msec) {
        setTimeout(() => { }, msec);
    }
    async function download(pageUrl, selector) {
        const browser = await puppeteer.launch();
        const page = await browser.newPage();
        await page.goto(pageUrl);
        client = await page.target().createCDPSession();
        client.send('Page.setDownloadBehavior', {
            behavior: 'allow', // ダウンロードを許可
            downloadPath: 'downloads', // ダウンロード先のフォルダを指定
        });
        await page.click(selector);
        await sleep(5000); // ダウンロード完了を待つ
        await browser.close();
    }
    await download('https://www.soumu.go.jp/toukei_toukatsu/index/seido/9-5.htm', 'a[href*=".csv"]'); // OK
    await download('https://www.soumu.go.jp/toukei_toukatsu/index/seido/9-5.htm', 'a[href*=".pdf"]'); // OK
    await download('https://pdf-xml-download-test.vercel.app/', '#link-pdf'); // OK
    await download('https://pdf-xml-download-test.vercel.app/', '#link-xml'); // NG
})();

方法2【○】: 強制的にブラウザのファイルダウンロード処理を実行

こちらに記載されている方法を参考に、方法1を改良しています。
Fetchを利用して、xmlやpdfの場合はResponseHeaderにcontent-disposition: attachmentを追加することで、無理やりダウンロードさせる方法です。
この方法はいくつかのサイトで試して見たところ問題なく動いてくれましたが、2点ほど問題があります。

  • ダウンロード完了をもう少し賢く待ち合わせる必要がある(今は5秒だけ待つようにしてます。ここは頑張れば対応できる)
  • Page.setDownloadBehaviorがdeprecatedでいつ削除されるか分からない(これは対応方法が不明)

    const puppeteer = require('puppeteer');
    
    (async () => {
        async function sleep(msec) {
            setTimeout(() => { }, msec);
        }
        async function download(pageUrl, selector) {
            const browser = await puppeteer.launch();
            const page = await browser.newPage();
            await page.goto(pageUrl);
            client = await page.target().createCDPSession();
            client.send('Page.setDownloadBehavior', {
                behavior: 'allow', // ダウンロードを許可
                downloadPath: 'downloads', // ダウンロード先のフォルダを指定
            });
    
            await client.send('Fetch.enable', { // Fetchを有効に
                patterns: [{ urlPattern: '*', requestStage: 'Response' }] // ResponseステージをFetch
            });
    
            await client.on('Fetch.requestPaused', async (requestEvent) => { // ここで要求を一時停止
                const { requestId } = requestEvent;
                let responseHeaders = requestEvent.responseHeaders || [];
                let contentType = responseHeaders.filter(
                    header => header.name.toLowerCase() === 'content-type')[0].value;
    
                // pdfとxml以外はそのまま
                if (!contentType.endsWith('pdf') && !contentType.endsWith('xml')) {
                    await client.send('Fetch.continueRequest', { requestId }); // リクエストを続行
                    return;
                }
    
                // pdfとxmlの場合は`content-disposition: attachment`をつける
                responseHeaders.push({ name: 'content-disposition', value: 'attachment' });
                const response = await client.send('Fetch.getResponseBody', { requestId }); // bodyを取得
                await client.send('Fetch.fulfillRequest', // レスポンスを指定
                    { requestId, responseCode: 200, responseHeaders, body: response.body });
            });
    
            await page.click(selector);
            await sleep(5000); // ダウンロード完了を待つ
            await browser.close();
        }
        await download('https://www.soumu.go.jp/toukei_toukatsu/index/seido/9-5.htm', 'a[href*=".csv"]'); // OK
        await download('https://www.soumu.go.jp/toukei_toukatsu/index/seido/9-5.htm', 'a[href*=".pdf"]'); // OK
        await download('https://pdf-xml-download-test.vercel.app/', '#link-pdf'); // OK
        await download('https://pdf-xml-download-test.vercel.app/', '#link-xml'); // OK
    })();

方法3【◎】: ダウンロードのrequestをキャプチャして、同じ内容で別途requestを送信

こちらを参考に、実際に動くように書き加えたものになります。
リクエストイベントをキャプチャして、取得したリクエストの内容でchromium関係なくrequest-promiseを使ってリクエストを送信する方法です。cookieにも対応しており、思いつく範囲でうまく動いてくれる気がします。また、すでにわかっている問題点もありません。

npm install request request-promise uuid mime-types --save
const puppeteer = require('puppeteer');
const rp = require('request-promise');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const mime = require('mime-types');

(async () => {
    // 対象ページ内の対象selectorをクリックしてファイルをダウンロード
    async function download(pageUrl, selector) {
        const browser = await puppeteer.launch();
        const page = await browser.newPage();
        await page.goto(pageUrl);
        await page.setRequestInterception(true); // リクエストをインターセプト
        page.on('request', async (request) => {
            await sendRequest(request, await page.cookies()); // キャプチャした内容で別途リクエストを送信
            request.abort(); // リクエストを終了
        });
        await page.click(selector);
        await browser.close();
    }

    // request-promiseを使って別途リクエストを送信
    async function sendRequest(request, cookies) {
        const options = {
            encoding: null,
            method: request._method,
            uri: request._url,
            body: request._postData,
            headers: request._headers,
            resolveWithFullResponse: true, // content-typeを取得したいので、FullResponseを取得する設定
        }
        options.headers.Cookie = cookies.map(cookie => cookie.name + '=' + cookie.value).join(';');
        const response = await rp(options);
        const outputPath = `downloads/${uuidv4()}.${mime.extension(response.headers['content-type'])}`; // content-typeから拡張子を判定し、出力パスを生成
        fs.writeFile(outputPath, response.body, () => { }); // ファイルに出力
    }

    await download('https://www.soumu.go.jp/toukei_toukatsu/index/seido/9-5.htm', 'a[href*=".csv"]'); // OK
    await download('https://www.soumu.go.jp/toukei_toukatsu/index/seido/9-5.htm', 'a[href*=".pdf"]'); // OK
    await download('https://pdf-xml-download-test.vercel.app/', '#link-pdf'); // OK
    await download('https://pdf-xml-download-test.vercel.app/', '#link-xml'); // OK
})();

方法4【×】: response.bufferを利用

検索すると最初にヒットする stackoverflow で紹介されていた方法も試してみました。
この方法は、response.bodyをstream downloadできるだけで、ファイルダウンロードのケースやPDFファイルのように<embeded>で読み込むケースではうまく動きませんでした。

const puppeteer = require('puppeteer');
const fs = require('fs');

(async () => {
    async function downlaod(targetUrl) {
        const browser = await puppeteer.launch();
        const page = await browser.newPage(); // ページを開く
        const outputPath = `downloads/${targetUrl.match(/([^/]+\.[^/]+)($|\?)/)[1]}`; // URLからファイル名取得して、出力パス

        await page.setRequestInterception(true); // リクエストをインターセプト
        let isFirstRequest = true;
        page.on('request', request => {
            if (!isFirstRequest && request.isNavigationRequest()) { // 初回リクエスト以外で画面遷移の場合は終了
                isFirstRequest = false;
                return request.abort();
            }
            request.continue(); // リクエストを継続する
        });
        page.on('response', async (response) => {
            if (response.url() === targetUrl) {
                const buffer = await response.buffer();
                fs.writeFile(outputPath, buffer, (err) => {
                    if (err) throw err;
                });
            }
        });

        await page.goto(targetUrl);
        await browser.close();
    }

    await downlaod('https://www.soumu.go.jp/menu_syokai/index.html'); // OK
    await downlaod('https://www.soumu.go.jp/main_content/000269738.jpg'); // OK
    await downlaod('https://www.soumu.go.jp/main_content/000608358.csv'); // NG: 「Error: Protocol error (Network.getResponseBody): No resource with given identifier found」が発生する
    await downlaod('https://www.soumu.go.jp/main_content/000323620.pdf'); // NG: 「Error: Protocol error (Network.getResponseBody): No resource with given identifier found」が発生する
})();

このほか、browser.on('targetcreated', function)を利用する方法もありそうですが、方法3より完璧に動く感じではないと思われるので試しておりません。

その他

ページのスクリーンショットを取る

ファイルダウンロードとは異なり、開いたWebページのスクリーンショットを取るのはとても簡単です。

const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    await page.goto('https://www.google.com/');
    await page.screenshot({ path: 'downloads/sample1.png' })
    await browser.close();
})();

ページをPDFとして保存する

同様に、開いたWebページをPDFとして保存するのもとても簡単です。
headless=falseにするとエラーが発生するのでそこだけご注意ください。

const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch(); // NOTE: headless=trueじゃないと、「UnhandledPromiseRejectionWarning: Error: Protocol error (Page.printToPDF): PrintToPDF is not implemented」が発生する
    const page = await browser.newPage();
    await page.goto('https://www.google.com/');
    await page.pdf({ path: 'downloads/sample2.pdf', format: 'A4' })
    await browser.close();
})();

さいごに

puppeteer のようなメジャーなライブラリでも、意外にファイルダウンロードの方法がまとまった記事がなく、色々と試行錯誤してみました。
今回調べてみて、実務でもなんとか使えそうなやり方(方法3)を見つけることができてよかったです。

検証に使ったソースコードは以下に置いてあります。
https://github.com/rinoguchi/python_scraping_sample/tree/master/src/puppeteer