ふと、puppeteerがおもしろそうだなと思い、前から欲しかった
TwitterブックマークをJSONファイルにエクスポートするツールを題材に、
いろいろ遊んでみた時に備忘録。
puppeteerはサクッと使えるので、すてき(´ω`)
作ったもの
こんな感じで勝手に操作してエクスポートしてくれます(´ω`)
puppeteerで自動ログインして、ブクマをJOSNでエクスポートできるように(*´ω`*)
— 積読ハウマッチ📚きらぷか (@kira_puka) October 1, 2019
わかりやすいように背景色を変えたりしてる(*´ω`*) pic.twitter.com/UJiGAiw5KN
最終的なソースコードはGitHubで公開中。
ただ、注意事項がたくさんですが。。(-_-;)
puppeteerの使い方
インストール
$ npm install -S puppeteer
基本的な雛形
基本的にはこんな感じ。
- ブラウザを起動
- ページを作成
- なんか処理する
- ブラウザの終了
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);
エクスポートするツールを作ってみる
やりたいことは、こんな感じ。
- ブラウザ起動
- ログイン
- ブックマークページに移動
- 以下繰り返し: 取得できる情報がなくなるまで
- ブックマークの情報を取得
- ブックマークの削除
- 取得した情報を.jsonファイルに書き出し
- ブラウザの停止
メインの処理はこんな感じ
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の仕様によっているけど
- 取得したい要素を特定して、
- その要素を取得するセレクタを書き、
- 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)まで♪
参考にしたサイト様
- Puppeteerのセットアップから使い方まで〜ブラウザ操作の自動化〜 - Qiita
- Puppeteer v1.20.0
- PuppeteerによるJavaScriptレンダリングされたHTMLの取得 - コードログ
- puppeteerを使ったスクレイピング - Qiita
- 絶対顔本スクレイピングするマン
- puppeteerでの要素の取得方法 - Qiita
- CSSのnth-childとnth-of-typeについて基本から学ぼう | Stocker.jp / diary
- puppeteerでスクレイピング - Qiita
- puppeteerでよく使うであろう処理の書き方 - Qiita
- puppeteerを体験してみた - Qiita