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

読了目安:15分

ふと、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)まで♪

参考にしたサイト様

Originally published at qiita.com

きらぷか@i18n補助ツール『トランスノート』開発者

フリーエンジニア/今はNuxt.js/いつかFlutter 受託&アプリ/Webサービス/ゲームを #個人開発 CS修士→SIer/R&D→フリー #paiza はAランクで満足/AtCoderしたい 仕事依頼やご相談はDMまで Kotlin/Python/Swift/Unity/Java/Haskell/DDD

Crieitは個人で開発中です。 興味がある方は是非記事の投稿をお願いします! どんな軽い内容でも嬉しいです。
なぜCrieitを作ろうと思ったか

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください!

ボードとは?

関連記事

コメント