Help us understand the problem. What is going on with this article?

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

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

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

作ったもの

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

最終的なソースコードはGitHubで公開中。
- memory-lovers/export_twitter_bookmarks_puppeteer: Twitter Bookmark Export Tool using Puppeteer

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


puppeteerの使い方

インストール

$ npm install -S puppeteer

基本的な雛形

基本的にはこんな感じ。

  1. ブラウザを起動
  2. ページを作成
  3. なんか処理する
  4. ブラウザの終了
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でできること

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

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

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

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

ページの開く/閉じる
// 新規ページの作成
const page = await browser.newPage();

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

// ページを閉じる
await page.close();
指定したURLへ移動
// 指定した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の取得 - コードログ

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

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

実際はElementHandleが返ってくる。

1件取得と全件取得があるので注意。
セレクタの書き方はCSS selectorsが使える。

XPATHで書けるpage.$x();というのもある。

要素のクリック
// クリック: ページからセレクタで指定
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などを使うとうまくいく時がある。

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

// テキストを入力する: ElementHandlerで指定
const inputText = await page.$('#text-input');
await inputText.type("Hello");
待つ/waitする
// 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要素に対して実行できるので、いろいろできる

// 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);
スクリーンショットの取得
// スクリーンショットの取得: 表示範囲のみ
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の取得
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. ブラウザの停止

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

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();
  }
}

ログイン処理

/**
 * ログイン処理
 */
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>タグのようなので、それを起点に処理を進めていく。

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で文字を取得する

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

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;
};
ブックマークの削除
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. 実行や動作確認に時間がかかるので、テストにかなり時間がかかる

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

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

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

積読用の読書管理アプリ 『積読ハウマッチ』をリリースしました!
積読ハウマッチは、Nuxt.js+Firebaseで開発してます!

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

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

参考にしたサイト様

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした