ふと、puppeteerがおもしろそうだなと思い、前から欲しかった
TwitterブックマークをJSONファイルにエクスポートするツールを題材に、
いろいろ遊んでみた時に備忘録。
puppeteerはサクッと使えるので、すてき(´ω`)
こんな感じで勝手に操作してエクスポートしてくれます(´ω`)
puppeteerで自動ログインして、ブクマをJOSNでエクスポートできるように(*´ω`*)わかりやすいように背景色を変えたりしてる(*´ω`*) pic.twitter.com/UJiGAiw5KN
— 積読ハウマッチ📚きらぷか (@kira_puka) October 1, 2019
最終的なソースコードはGitHubで公開中。
- memory-lovers/export_twitter_bookmarks_puppeteer: Twitter Bookmark Export Tool using 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();
// ブラウザの起動: 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へ移動
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");
// 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'});
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);
やりたいことは、こんな感じ。
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の仕様によっているけど
といった、感じのことをしている。
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みたいなのを対象にするのは結構大変だった。。
特にscoped CSSを使っていて、class名がないdivばかりだとつらい
対象サイトのJavaScriptが正しく動作しない場合がある。。
あと、サイトのデザインが変わると追従対応しないといけない。。
便利だけど、かなり大変そうな感じ(´ω`)
けど、ポイントを守ればかなり便利だなと、今更ながら体感(´ω`)
積読用の読書管理アプリ 『積読ハウマッチ』をリリースしました!
積読ハウマッチは、Nuxt.js+Firebaseで開発してます!
もしよかったら、遊んでみてくださいヽ(=´▽`=)ノ
要望・感想・アドバイスなどあれば、
公式アカウント(@MemoryLoverz)や開発者(@kira_puka)まで♪
Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。
また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!
こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?
コメント