📕

Puppeteerを使ってZennのBookをPDF出力する

2021/01/23に公開
2

はじめに

Zennには隠し機能で/printのパスがあります。

https://github.com/zenn-dev/zenn-roadmap/issues/82#issuecomment-699568915

この/printパスには1ページにすべてのチャプターが表示されるので、このページを利用しBookのページをPDF化できます。

しかし!!!単純に印刷するだけでは、一部レイアウトやスタイルに問題があります。
そのため手動で、ちまちまスタイルを追加し、レイアウトを修正してPDF化するのは結構大変です。

なので今回Puppeteer(パペティア)を使ってPDF出力を自動化するスクリプトを作成したので紹介します。

スクリプトはGithubのレポジトリにあります。
https://github.com/ganezasan/zenn-to-pdf

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化のスクリプトでやっていること

  1. Googleアカウントでログイン
  2. CSSの追加
  3. BookのページをPDFで出力

大きくこの3つのステップを行っています。

1. Googleアカウントでログイン

Bookの/printページは認証後のページになるので、Zennにログインする必要があります。
ZennはGoogle認証を利用しています。なのでemailとpasswordを入力してログインします。

puppeteer-extra-plugin-stealth プラグインの利用

puppeteerheadlessモードを有効にした際にGoogleの認証で以下のエラーが発生します。
そのため、puppeteer-extrapuppeteer-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がありますが、対応されず放置されているので、現状ではこのプラグインを利用するのがよさそうです。

https://github.com/puppeteer/puppeteer/issues/4871

Zennのログインページへアクセス

puppeteer-extrapuppeteerと同じように利用することができます。
puppeteer.useでプラグインを利用することができます。はじめにpuppeteerの初期化とzennへのアクセスを行います。

export.mjs
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' });
  1. ブラウザを起動、 headlessモードはデフォルトでは有効になっています。
  2. chromiumはブラウザ起動時に常に新しいタブを起動しているため、クローズします。
  3. 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 を利用してページの要素を確認し設定します。

export.mjs
// 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のみを設定している場合、設定された電話番号へコードを送信するかを確認するページへ遷移します。そのページでコードを送信するボタンをクリックすることで初めて電話番号へコードを送信します。

export.mjs
// 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');
}
  1. スクリプトで2FAの方法としてSMSを選択し、Googleアカウント2FAの方法としてSMSのみを設定していた場合に、コードを電話番号を送る処理を行います
  2. スクリプトで2FAの方法としてSMSを選択したが、APPのコードを入力画面が表示された場合、もしくはスクリプトで2FAの方法としてAPPを選択したが、SMSのコードの入力画面が表示された場合、1度2FAの方法を選択する画面へ戻り、スクリプトで指定した方法を選択する処理を行いいます
  3. readline-promiseを利用して、2FAのコードの入力を待ちます、その後コードを入力しログイン処理を進めます

3. BookのページをPDFで出力

Google認証が終わるとZennのトップページへ遷移します。その後印刷対象のBookのページへ移動し、page.addStyleTagを利用しCSSを適用します。CSSの調整では背景の表示、コードブロックの改行、表紙のサイズ調整、ページのマージンを行っています。

zenn_pdf.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;
}
export.mjs
// 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ツールの導入など基本から実践的な使い方まで紹介しています。
https://zenn.dev/ganezasan/books/78676684ccdeb090f7b8

Discussion

catnosecatnose

これは素晴らしい…
僕も参考にさせていただきます。

ganezasanganezasan

コメントありがとうございます :)
CLIのpreviewで/printが表示できればもっと簡単にPDF化できそうです!