Puppeteerを使ってZennのBookをPDF出力する
はじめに
Zennには隠し機能で/print
のパスがあります。
この/print
パスには1ページにすべてのチャプターが表示されるので、このページを利用しBookのページをPDF化できます。
しかし!!!単純に印刷するだけでは、一部レイアウトやスタイルに問題があります。
そのため手動で、ちまちまスタイルを追加し、レイアウトを修正してPDF化するのは結構大変です。
なので今回Puppeteer(パペティア)を使ってPDF出力を自動化するスクリプトを作成したので紹介します。
スクリプトはGithubのレポジトリにあります。
Puppeteer(パペティア)とは?
PuppeteerとはChromeやChromium(chromeのベースとなるOSSプロジェクト)をDevTool Protocolを利用し操作できるNodeライブラリです。Googleのメンバーが開発したOSSです。類似のツールとしてSeleniumがありますが、Seleniumと異なるAPIを利用しブラウザの操作を行います。
Puppeteerを利用することで、ブラウザの処理をコード化することができます。
zenn-to-pdfの使い方
以下のステップでzennのBookをPDF化できます。
top-level awaitの機能を利用しているので、nodeのバージョンは14.8.0
以上で実行してください。
$ git clone git@github.com:ganezasan/zenn-to-pdf.git
$ npm install
$ mv .env.tmplate .env # GOOGLE_EMAIL, GOOGLE_PASSWORD, ZENN_PAGE_URLを適宜修正してください
$ npm run export
> zenn-to-pdf@1.0.0 export /Users/ganezasan/repos/private/zenn-to-pdf
> node export.mjs
Opening chromium browser...
Access zenn.dev ...
Enter email ...
Enter password ...
Use 2FA with challengetype: APP
Enter your G-code: ******
Enter 2FA code...
Access Zenn page...
Access artile print page...
Export PDF...
The article was successfully exported to PDF 🎉
export: /Users/ganezasan/repos/private/zenn-to-pdf/prod.pdf
PDF化のスクリプトでやっていること
- Googleアカウントでログイン
- CSSの追加
- BookのページをPDFで出力
大きくこの3つのステップを行っています。
1. Googleアカウントでログイン
Bookの/print
ページは認証後のページになるので、Zennにログインする必要があります。
ZennはGoogle認証を利用しています。なのでemailとpasswordを入力してログインします。
puppeteer-extra-plugin-stealth プラグインの利用
puppeteer
でheadless
モードを有効にした際にGoogleの認証で以下のエラーが発生します。
そのため、puppeteer-extraとpuppeteer-extra-plugin-stealthを利用してheadless
であることを隠し、このエラーを回避します。
Couldn't sign you in
For your protection, you can't sign in from this device.
Try again later, or sign in from another device.
puppeteer-extra-plugin-stealth
はウェブサイトへのアクセスをpuppeteer
からであることをわからなくするために作られたpuppeteer-extra
のプラグインです。puppeteer
本家には2019年から同様のissueがありますが、対応されず放置されているので、現状ではこのプラグインを利用するのがよさそうです。
Zennのログインページへアクセス
puppeteer-extra
はpuppeteer
と同じように利用することができます。
puppeteer.use
でプラグインを利用することができます。はじめにpuppeteer
の初期化とzenn
へのアクセスを行います。
import puppeteer from 'puppeteer-extra';
import pluginStealth from 'puppeteer-extra-plugin-stealth';
puppeteer.use(pluginStealth());
// ①
const browser = await puppeteer.launch({ headless });
console.log('Opening chromium browser...');
const page = await browser.newPage();
// ②
const pages = await browser.pages();
// Close the new tab that chromium always opens first.
pages[0].close();
console.log('Access zenn.dev ...');
// ③
await page.goto('https://zenn.dev/enter', { waitUntil: 'networkidle2' });
- ブラウザを起動、 headlessモードはデフォルトでは有効になっています。
- chromiumはブラウザ起動時に常に新しいタブを起動しているため、クローズします。
- zennのログインページへ遷移します。
waitUntil
では以下の4つのオプションから選択し、ページを遷移してから対象のイベントが発生するまで待つことができます。デフォルトはload
となっており、配列で渡しすべてのイベントを待つことも可能です。
- load - load イベントの発生でナビゲーションが終了したとみなす
- domcontentloaded - DOMContentLoaded イベントの発生でナビゲーションが終了したとみなす
- networkidle0 - 少なくとも500ms間ネットワーク接続がない場合、ナビゲーションが終了するとみなす
- networkidle2 - 少なくとも500ms間ネットワーク接続が2つ未満の場合、ナビゲーションが終了するとみなす
https://pptr.dev/#?product=Puppeteer&version=v5.5.0&show=api-pagegotourl-options
emailとpasswordを入力
各要素をクラス名もしくはIDで参照し、emailとpasswordを入力していきます。
DevTools を利用してページの要素を確認し設定します。
// click login button
await page.waitForSelector('button.enter-login-btn');
await page.click('button.enter-login-btn');
await navigationPromise;
// login with google
await page.waitForSelector('#identifierId');
// input email
console.log('Enter email ...');
await page.type('#identifierId', email);
await page.waitForTimeout(1000);
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
// input password
console.log('Enter password ...');
await page.waitForSelector('input[type="password"]');
await page.type('input[type="password"]', password);
await page.waitForTimeout(1000);
await page.keyboard.press('Enter');
// Login via gmail app works autmatically.
await page.waitForTimeout(2000);
2FAの対応
2FAを設定している場合、毎回のログイン時にコードを入力する必要があります。
途中で2FA用のコードを入力するためにreadline-promise
のパッケージを利用しています。
このスクリプトでは2FAの認証方法にSMS
とスマートフォンを利用したAPP
の2つを想定しています。
2FAの仕様は変更が直近であり、2021年1月16日と23日では表示される画面が異なっていました。そのため将来的に現在の仕様が変わる可能性は高いです。
2021年1月23日時点では、2FAにSMSとAPPの両方を設定している場合は、パスワード入力後APPのコードを入力する画面に遷移します。SMSを利用して2FAの認証を行う場合、一度2FA認証の方法を選択する画面に戻る必要があります。
SMSのみを設定している場合、設定された電話番号へコードを送信するかを確認するページへ遷移します。そのページでコードを送信するボタンをクリックすることで初めて電話番号へコードを送信します。
// init readline
const rlp = readline.default.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true
});
// For headless mode, 2FA needs to be handled here.
if (isTwoFA) {
console.log(`Use 2FA with challengetype: ${twoFATool}`);
const twoFASelector = {
APP: '[data-challengetype="6"]',
SMS: '[data-sendmethod="SMS"]',
};
// ①
// if you choose SMS, it needs to click `send` button to send code to your phone
if (twoFATool === "SMS" && page.url().includes('signin/v2/challenge/ipp')) {
console.log('Sending SMS code...');
await page.click('button');
page.waitForTimeout(1000);
}
// ②
// the default is APP, but you want to use SMS as the 2FA
if ((twoFATool === "SMS" && page.url().includes('signin/v2/challenge/totp'))
|| (twoFATool === "APP" && page.url().includes('signin/v2/challenge/ipp'))) {
console.log('Back to 2FA selection page');
await page.click('.daaWTb button');
await page.waitForTimeout(1000);
console.log('Choosing 2FA type...');
await page.waitForTimeout(1000);
await page.waitForSelector(twoFASelector[twoFATool]);
await page.focus(twoFASelector[twoFATool]);
await page.waitForTimeout(1000);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
}
// ③
// enter 2FA code
const code = await rlp.questionAsync('Enter your G-code: ');
console.log('Enter 2FA code...');
await page.waitForSelector('input[type="tel"]');
await page.click('input[type="tel"]');
await page.waitForTimeout(1000);
await page.type('input[type="tel"]', code);
await page.waitForTimeout(1000);
await page.keyboard.press('Enter');
}
- スクリプトで2FAの方法として
SMS
を選択し、Googleアカウント2FAの方法としてSMS
のみを設定していた場合に、コードを電話番号を送る処理を行います - スクリプトで2FAの方法として
SMS
を選択したが、APP
のコードを入力画面が表示された場合、もしくはスクリプトで2FAの方法としてAPP
を選択したが、SMS
のコードの入力画面が表示された場合、1度2FAの方法を選択する画面へ戻り、スクリプトで指定した方法を選択する処理を行いいます -
readline-promise
を利用して、2FAのコードの入力を待ちます、その後コードを入力しログイン処理を進めます
3. BookのページをPDFで出力
Google認証が終わるとZennのトップページへ遷移します。その後印刷対象のBookのページへ移動し、page.addStyleTag
を利用しCSSを適用します。CSSの調整では背景の表示、コードブロックの改行、表紙のサイズ調整、ページのマージンを行っています。
body {
-webkit-print-color-adjust: exact;
}
.code-block-container > pre {
white-space: pre-wrap;
}
.fullscreen-page header > img {
width: 450px;
}
.fullscreen-page header > h1 {
font-size: 32px;
}
@page {
size: auto;
margin: 3cm;
}
// zenn
console.log('Access Zenn page...');
await page.waitForRequest(request => request.url().includes('zenn.dev') && request.method() === 'GET');
await page.waitForSelector('#__next');
await page.waitForTimeout(2000);
// goto the article page
console.log('Access artile print page...');
await page.goto(pageUrl, { waitUntil: ['load', 'networkidle2'] });
// update css style
await page.addStyleTag({path: 'zenn_pdf.css'});
// export the article page to PDF only headless option is true
if (headless) {
console.log('Export PDF...');
await page.pdf({
path: pdfPath,
format: 'A4',
});
console.log(`The article was successfully exported to PDF 🎉`);
console.log(`export: ${process.env.PWD}/${pdfPath}`);
}
// close
await browser.close();
rlp.close();
最後に
技術書典10で「はじめてのJest入門」を公開していたのですが、実はあのPDFはZennのHTMLプレビューをPDF化したものです。これからZennで本を書いて、同じソースでPDFを作って技術書典で発表するという流れができればいいなと思っています。
最後の最後に、もしよかったら、Zennで公開した「はじめてのJest入門」も見てください。Jestのインストールからアサーション、モック化、UIテストからE2EテストやCIツールの導入など基本から実践的な使い方まで紹介しています。
Discussion
これは素晴らしい…
僕も参考にさせていただきます。
コメントありがとうございます :)
CLIのpreviewで
/print
が表示できればもっと簡単にPDF化できそうです!