puppeteer初心者がTwitterブックマークをエクスポートするツールを作りながら、使い方をまとめてみた

ふと、puppeteerがおもしろそうだなと思い、前から欲しかった
TwitterブックマークをJSONファイルにエクスポートするツールを題材に、
いろいろ遊んでみた時に備忘録。

puppeteerはサクッと使えるので、すてき(*´ω｀*)

### 作ったもの

こんな感じで勝手に操作してエクスポートしてくれます(*´ω｀*)

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">puppeteerで自動ログインして、ブクマをJOSNでエクスポートできるように(*´ω｀*)<br><br>わかりやすいように背景色を変えたりしてる(*´ω｀*) <a href="https://t.co/UJiGAiw5KN">pic.twitter.com/UJiGAiw5KN</a></p>&mdash; 積読ハウマッチ📚きらぷか (@kira_puka) <a href="https://twitter.com/kira_puka/status/1178913910432256002?ref_src=twsrc%5Etfw">October 1, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

最終的なソースコードはGitHubで公開中。
- [memory-lovers/export_twitter_bookmarks_puppeteer: Twitter Bookmark Export Tool using Puppeteer](https://github.com/memory-lovers/export_twitter_bookmarks_puppeteer)

ただ、注意事項がたくさんですが。。(-_-;)

----

### puppeteerの使い方

#### インストール

```console
$ npm install -S puppeteer
```

#### 基本的な雛形

基本的にはこんな感じ。

1. ブラウザを起動
2. ページを作成
3. なんか処理する
4. ブラウザの終了

```javascript
const puppeteer = require("puppeteer");
const fs = require("fs");

async function main() {
  let browser = null;
  try {
    // ブラウザの起動
    browser = await puppeteer.launch();
    // ページの作成
    const page = await browser.newPage();

    // 何らかの処理
    
  } catch (error) {
    console.error(`Error: ${error}`, error);
  } finally {
    // ブラウザの終了
    if (!!browser) await browser.close();
  }
}

main().then();
```

#### puppeteerでできること

##### ブラウザの起動/停止

```javascript
// ブラウザの起動: headlessで起動
const browser = await puppeteer.launch();

// ブラウザの起動: headlessじゃなく起動
const browser = await puppeteer.launch({ headless: false, slowMo: 10 });

// ブラウザの終了
await browser.close();
```

`headless: false`にすると、ブラウザが立ち上がって、動作確認画できる。
`slowMo: 10`の値を大きくすると、スローモーションのように操作がゆっくりになる。


##### ページの開く/閉じる

```javascript
// 新規ページの作成
const page = await browser.newPage();

// 画面サイズの設定
await page.setViewport({ width: 1280, height: 1200 });

// ページを閉じる
await page.close();
```

##### 指定したURLへ移動

```javascript
// 指定したURLへ移動
await page.goto("https://www.google.com", { waitUntil: "networkidle2" });

// 指定したURLへ移動: waitを設定
await page.goto("https://www.google.com", { waitUntil: "networkidle2" });
```

オプションの`waitUntil`を指定すると、その条件が満たされるまでwaitする。
指定できるのは、以下の4つ。

- `load`: `load`イベントが発火するまで
- `domcontentloaded`: `DOMContentLoaded`イベントが発火するまで
- `networkidle0`: ネットワーク接続が0個である状態が500ミリ秒続いたとき
- `networkidle2`: ネットワーク接続が2個である状態が500ミリ秒続いたとき

SPAとかの場合は、`networkidle2`とかまで待つと良さそう。

参考: [PuppeteerによるJavaScriptレンダリングされたHTMLの取得 - コードログ](https://codeday.me/jp/qa/20190513/814601.html)

##### 要素の取得

```javascript
// 最初の`.button`の要素を取得
const button = await page.$('.button');

// すべての`.button`の要素を取得
const buttonList = await page.$$('.button');
```

実際は[ElementHandle](https://pptr.dev/#?product=Puppeteer&version=v1.20.0&show=api-class-elementhandle)が返ってくる。

1件取得と全件取得があるので注意。
セレクタの書き方は[CSS selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors)が使える。

[XPATH](https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate)で書ける`page.$x();`というのもある。


##### 要素のクリック

```javascript
// クリック: ページからセレクタで指定
await page.click('.button');

// クリック: ElementHandlerからクリック
const button = await page.$('.button');
await button.click();

// クリック: ページからElementHandlerを使ってevaluate
const button = await page.$('.button');
await page.evaluate(v => v.click(), button)

// クリック: ElementHandlerからevaluateでクリック
const button = await page.$('.button');
await button.evaluate(v => v.click())
```

クリックなど、JavaScriptを実行する方法はいくつかある。
SPAなサイトだとうまく行かない場合があるが、`page.evaluaate`などを使うとうまくいく時がある。

##### 入力する

```javascript
// テキストを入力する: ページからセレクタで指定
await page.type('#text-input', "Hello");

// テキストを入力する: ElementHandlerで指定
const inputText = await page.$('#text-input');
await inputText.type("Hello");
```

##### 待つ/waitする

```javascript
// 1000ms待つ
await page.waitFor(1000);

// 指定した要素が表示されるまで待つ
await page.waitForSelector(`.foo`);
// or 
await page.waitFor('.foo');

// 条件を満たすまで待つ
await page.waitFor(() => !!document.querySelector('.foo'));

// 移動するまで待つ
await Promise.all([
  page.waitForNavigation(),
  page.click('a.my-link'),
]);

// or 
const navigationPromise = page.waitForNavigation();
await page.click('a.my-link'),
await navigationPromise;
```

##### その他もろもろ

`evaluate`を使うとHTML要素に対して実行できるので、いろいろできる

```javascript

// innerTextを取得
const innerText = await elm.evaluate(node => node.innerText);

// textContentを取得
const textContent = await elm.evaluate(node => node.textContent);

// href属性の取得
const href = await elm.evaluate(node => node.href);

// 背景色変更
await elm.evaluate((v, color) => (v.style.backgroundColor = color), "gray");

// URLの取得
const url = await page.evaluate(_ => location.origin);

// スクロール: 1画面分
await page.evaluate(_ => window.scrollBy(0, window.innerHeight));

// スクロール: 指定要素まで
await page.evaluate(elm => window.scrollBy(0, elm.getBoundingClientRect().top), elm);
```

##### スクリーンショットの取得

```javascript
// スクリーンショットの取得: 表示範囲のみ
await page.screenshot({ path: "screenshot.png" });

// スクリーンショットの取得: フルページを指定
await page.screenshot({ path: "screenshot.png", fullPage: true });

// スクリーンショットの取得: 指定要素のみ
const element = await page.$('h1');
await element.screenshot({path: 'screenshot_h1.png'});
```

##### 描画されたHTMLの取得

```javascript
const fs = require("fs");

// HTMLの取得: ページ全体
const html = await page.content();
fs.writeFileSync("output.html", html);

// HTMLの取得: 指定要素のみ
const bodyHandle = await page.$('body');
const html_body = await page.evaluate(body => body.innerHTML, bodyHandle);
fs.writeFileSync("output_body.html", html_body);
```

### エクスポートするツールを作ってみる

やりたいことは、こんな感じ。

1. ブラウザ起動
2. ログイン
3. ブックマークページに移動
4. 以下繰り返し: 取得できる情報がなくなるまで
   - ブックマークの情報を取得
   - ブックマークの削除
5. 取得した情報を.jsonファイルに書き出し
6. ブラウザの停止


#### メインの処理はこんな感じ

```javascript
async function exportBookmarkMain() {
  let browser = null;
  try {
    // ブラウザの起動
    browser = await puppeteer.launch({ headless: false, slowMo: 10 });

    // ページの作成
    const page = await browser.newPage();
    await page.setViewport({ width: 1280, height: 1200 });

    // ログイン: ログインページに移動&認証
    await login(page);

    // ブックマークのエクスポート: ブックマークページに移動&ツイート上の取得
    const bookmarks = await getTwitterBookmarks(browser, page);
    console.log(`bookmarks size is ${bookmarks.length}`);

    // 取得した情報の書き出し
    const timestamp = dayjs().format("YYYYMMDD_HHmmss");
    const outputFile = `twitter_bookmarks_${timestamp}.json`;
    fs.writeFileSync(`output/${outputFile}`, JSON.stringify(bookmarks));

  } catch (error) {
    console.error(`Error: ${error}`, error);
  } finally {
    // ブラウザの停止
    if (!!browser) await browser.close();
  }
}
```

#### ログイン処理

```javascript
/**
 * ログイン処理
 */
async function login(page) {  
  // dotenvからアカウント情報の取得
  const account = process.env.TWITTER_ACCOUNT;
  const password = process.env.TWITTER_PASSWORD;

  // 指定したURLへ移動: waitを設定
  await page.goto("https://twitter.com/", { waitUntil: "networkidle2" });
  await page.waitForSelector(`.LoginForm > .LoginForm-username > .text-input`);

  // アカウントとパスワード入力
  await page.type(`.LoginForm > .LoginForm-username > .text-input`, account);
  await page.type(`.LoginForm > .LoginForm-password > .text-input`, password);

  // ログインボタンを押して、ページ遷移するまで待つ
  const navigationPromise = page.waitForNavigation();
  await page.click(` .LoginForm > .EdgeButton`);
  await navigationPromise;
}
```

#### ブックマークのエクスポート処理

くり返す処理はこんな感じ。
ツイートは`<article>`タグのようなので、それを起点に処理を進めていく。

```javascript
async function getTwitterBookmarks(browser, page) {
  const bookmarks = [];
  
  try {
    // ブックマークに移動
    const bookmarksURL = "https://twitter.com/i/bookmarks";
    await page.goto(bookmarksURL, { waitUntil: "networkidle2" });

    // ブックマークしたツイートのHTML要素の取得
    const articles = await page.$$("article");

    for (let i = 0; i < articles.length; i++) {
      const article = articles[i];

      // ツイートまでスクロール
      await page.evaluate(elm => window.scrollBy(0, elm.getBoundingClientRect().top), article);
      await page.waitFor(1000);

      // articleから情報を取得(別処理)
      const data = await toArticleData(browser, page, article);
      bookmarks.push(data);

      // ブックマークの削除(別処理)
      await deleteBookmark(browser, page, article);
    }
  } catch (error) {
    console.error(`** Error occuerred: ${error}`, error);
  }
  return bookmarks;
}
```

無限ローディングを持つような場合、適宜スクロールしないと要素が表示されないので、
ツイートごとにスクロールしている。


##### ブックマークしたツイートから情報を取得

かなりTwitterの仕様によっているけど

1. 取得したい要素を特定して、
2. その要素を取得するセレクタを書き、
3. innterTextやtextContentで文字を取得する

といった、感じのことをしている。

```javascript
async function toArticleData(browser, page, article) => {
  // 初期化
  const articleData = {
    accountName: "",
    accountId: "",
    accountURL: "",
    tweetText: "",
    tweetURL: "",
    links: []
  };

  // ツイートしたユーザのアカウント名とTwitterIdを取得
  const account = "div > div:nth-of-type(2) > div:nth-of-type(2) > div:nth-of-type(1)";
  const accountName = await article.$(`${account} a > div:nth-of-type(1) > div:nth-of-type(1)`);
  const accountId = await article.$(`${account} a > div:nth-of-type(1) > div:nth-of-type(2)`);
  articleData.accountName = await accountName.evaluate(node => node.innerText);
  articleData.accountId = await accountId.evaluate(node => node.innerText);

  // ツイートの内容を取得
  const tweetData = "div > div:nth-of-type(2) > div:nth-of-type(2)";
  const tweet = await article.$(`${tweetData} > div:nth-of-type(2)`);
  const tweetText = await tweet.evaluate(node => node.innerText);
  articleData.tweetText = tweetText;

  // ツイートに含まれるリンク(<a>)をすべて取得
  const aTags = await article.$$(`${tweetData} a`);
  for (let i = 0; i < aTags.length; i++) {
    const aTag = aTags[i];
    const text = await aTag.evaluate(node => node.textContent);
    const link = await aTag.evaluate(node => node.href);
    articleData.links.push({ link: link, text: text });
  }
  // <a>の1つ目はユーザのURL
  articleData.accountURL = articleData.links[0].link;
  // <a>の2つ目はツイートのURL
  articleData.tweetURL = articleData.links[1].link;
  articleData.links.splice(0, 2);

  return articleData;
};
```

##### ブックマークの削除

```javascript
async deleteBookmark(browser, page, article) {
  const waitTime = 1500; // 待ち時間

  // 削除対象までスクロール
  await page.evaluate(elm => window.scrollBy(0, elm.getBoundingClientRect().top), article);
  await page.waitFor(1000);

  // 「ツイートを共有」ボタンをクリック
  const button = await article.$("div[aria-label='ツイートを共有']");
  await page.evaluate(v => v.click(), button);
  // すこし待つ
  await page.waitFor(waitTime);

  // クリックするとメニューが出てくるので、取得
  const menuItems = await page.$$("div[role='menuitem']");
  
  // 非公開アカウントかどうかにより、メニューの数が変わるの処理を分ける
  if (menuItems.length === 3) {
    // 通常、メニューが3つあり、2つ目が削除ボタン
    await menuItems[1].click();
    await page.waitFor(waitTime);
  } else if (menuItems.length === 1) {
    // 非公開の場合は、削除ボタンのみ表示
    await menuItems[0].click();
    await page.waitFor(waitTime);
  }
};
```

こんな感じで、「要素を探す→クリック→少し待つ」のくり返し。
ただ、ブラウザで操作しているときでも、削除されないときがある。。

## 使ってみた感想

スクレイピング自体始めてだったけど、puppeteer自体がすごくよく、簡単に使うことができた(*´ω｀*)

ただ、Twitterみたいなのを対象にするのは結構大変だった。。

##### 1. どうセレクタを書けば、期待する要素をとってこれるのかを考えないといけない
特にscoped CSSを使っていて、class名がないdivばかりだとつらい

##### 2. SPAなど動的に変わる部分が多いサイトだと、クリックなどがうまく動かないことがある
対象サイトのJavaScriptが正しく動作しない場合がある。。

##### 3. 実行や動作確認に時間がかかるので、テストにかなり時間がかかる

あと、サイトのデザインが変わると追従対応しないといけない。。
便利だけど、かなり大変そうな感じ(*´ω｀*)

けど、ポイントを守ればかなり便利だなと、今更ながら体感(*´ω｀*)


## こんなのつくってます!!

積読用の読書管理アプリ 『積読ハウマッチ』をリリースしました！
[積読ハウマッチ](https://tsundoku.site)は、Nuxt.js+Firebaseで開発してます!

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/572d4947-f40b-e4dc-1c9c-bc584cd2a66c.png" width="200"/>

もしよかったら、遊んでみてくださいヽ(=´▽`=)ﾉ

要望・感想・アドバイスなどあれば、
公式アカウント([@MemoryLoverz](https://twitter.com/MemoryLoverz))や開発者([@kira_puka](https://twitter.com/kira_puka))まで♪


# 参考にしたサイト様
- [Puppeteerのセットアップから使い方まで〜ブラウザ操作の自動化〜 - Qiita](https://qiita.com/kanoe/items/9043a81d28a1b733b2e2)
- [Puppeteer v1.20.0](https://pptr.dev/)
- [PuppeteerによるJavaScriptレンダリングされたHTMLの取得 - コードログ](https://codeday.me/jp/qa/20190513/814601.html)
- [puppeteerを使ったスクレイピング - Qiita](https://qiita.com/unhurried/items/56ea099c895fa437b56e#1-%E4%B8%80%E5%AE%9A%E6%99%82%E9%96%93%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF%E9%80%9A%E4%BF%A1%E3%81%AE%E3%81%AA%E3%81%84%E3%81%93%E3%81%A8%E3%81%A7%E5%AE%8C%E4%BA%86%E3%82%92%E5%88%A4%E5%AE%9A%E3%81%99%E3%82%8B)
- [絶対顔本スクレイピングするマン](https://gist.github.com/nnks1010/10abf3032933609d1b2bd5b99fc12586)
- [puppeteerでの要素の取得方法 - Qiita](https://qiita.com/go_sagawa/items/85f97deab7ccfdce53ea)
- [CSSのnth-childとnth-of-typeについて基本から学ぼう | Stocker.jp / diary](https://stocker.jp/diary/nth-child/)
- [puppeteerでスクレイピング - Qiita](https://qiita.com/tomi_linka/items/a68cf7840c3da002c6e0#%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0%E3%82%92%E5%BE%85%E3%81%A4)
- [puppeteerでよく使うであろう処理の書き方 - Qiita](https://qiita.com/rh_taro/items/32bb6851303cbc613124#%E9%81%B7%E7%A7%BB%E3%82%92%E5%BE%85%E3%81%A4-%E3%81%9D%E3%81%AE2)
- [puppeteerを体験してみた - Qiita](https://qiita.com/AYA_iro/items/e5dd0956b3ba82f6bf31)