7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Togetterのツイートまとめをスクレイピングしてコマンドやブラウザから一発で取得する機能の開発

Posted at

動作例gif

tttweb.gif
ttt.gif

コードとweb版動作サンプル

khsk/togetter-to-text: TogetterをPuppeteerでスクレイピングする

https://togetter-to-text.now.sh
(Dockerのためインスタンスのたちあがりが非常に遅いです)

メインのスクレイピング処理だけは載せておきます

scrapper.js
const puppeteer = require('puppeteer')
const url = require('url')
const path = require('path')

class Scrapper {
    init() {
        this.id = null
        this.page = null
        this.title = null
        // 各tweetを整形した配列。出力時にJOINしてtextにする
        this.tweets = []
        if (this.browser) {
            this.browser.close()
        }
        this.browser = null
    }

    async getText(id) {
        this.init()
        this.id = id
        await this.initBrowser()
        return await this.scraping()
    }

    async initBrowser() {
        // 表示しながらよりheadlessのほうが異常に遅かったので不要なリクエストのabortを追加で改善

        if (process.env.NODE_ENV === 'production') {
            this.browser = await puppeteer.launch({
                executablePath: '/usr/bin/chromium-browser',
                args: ['--no-sandbox', '--headless', '--disable-gpu', '-—disable-dev-tools'],
                dumpio: true,
                devtools: false,
            })
        } else {
            this.browser = await puppeteer.launch({ headless: true, })
        }

        this.page = await this.browser.newPage()
        await this.page.setDefaultNavigationTimeout(60 * 5 * 1000)
        // 不要なリクエストは中断して読み込みを高速化する https://qiita.com/unhurried/items/56ea099c895fa437b56e
        await this.page.setRequestInterception(true)
        this.page.on('request', interceptedRequest => {
            const reqUrl = url.parse(interceptedRequest.url())
            const reqExt = path.extname(reqUrl.pathname)
            const reqHost = reqUrl.hostname

            if (RegExp('\.(css|png|jpe?g|gif)', 'i').test(reqExt)) {
                interceptedRequest.abort()
            } else if (reqHost.endsWith('togetter.com') || reqHost.endsWith('ajax.googleapis.com')) {
                // 続きを読むはJSでDOM構築なのでjQueryのCDNを許可する
                // console.log(interceptedRequest.url())
                interceptedRequest.continue()
            } else {
                interceptedRequest.abort()
            }
        })
    }

    async scraping() {
        const URL = this.getTogetterURL()
        await this.page.goto(URL)

        this.title =  await this.page.$eval('a.info_title', a => a.textContent)
        do {
            await this.loadMoreTweet()
            this.tweets = this.tweets.concat(await this.getTweet())
        } while (await this.gotoNextPage())
        return this.title + '\n\n' + this.tweets.join('\n\n')
    }

    getTogetterURL() {
        return 'https://togetter.com/li/' + this.id
    }

    async loadMoreTweet() {
        const moreTweetId = '#more_tweet_btn'
        if (await this.page.$(moreTweetId)) {
            if (process.env.NODE_ENV === 'production') {
                // now環境で.click()にError: Node is either not visible or not an HTMLElementがでるのでeval clickで回避する
                await Promise.all([this.page.waitFor((moreTweetId) => !document.querySelector(moreTweetId), moreTweetId), this.page.evaluate(moreTweetId => { document.querySelector(moreTweetId).click() }, moreTweetId),])
            } else {
                await Promise.all([this.page.waitFor((moreTweetId) => !document.querySelector(moreTweetId), moreTweetId), this.page.click(moreTweetId),])
            }
        }
    }

    async getTweet() {
        // コメント欄(も同名クラス)は要らないので親(祖先)指定で絞っておく
        const tweet = await this.page.$$eval('.tweet_box .list_tweet_box', list => {
            return list.map(tweet => {
                const q = (s) => {
                    return tweet.querySelector(s).textContent
                }
                // 表示名 ID 投稿日時 \n 内容 の形式にする。parentのtextContentでは余計な空白が多くなるので一つずつ取得する (投稿日時やアカウントは忍殺において実際重要なので外さない 9割不要なんですがピンクいのとか3/11がですね)
                return q('strong') + ' ' + q('span') + ' ' + q('.status_right').trim() + '\n' + q('.tweet')
            })
        })
        return tweet
    }

    async gotoNextPage() {
        const selector = '.pagenation > a:last-child'
        // evalは存在しないとErrorになるので$で事前にnullチェックする
        if (!await this.page.$(selector)) {
            return false
        }
        if (await this.page.$eval(selector, a => a.textContent !== '次へ')) {
            return false
        }
        if (process.env.NODE_ENV === 'production') {
            return Promise.all([
                this.page.evaluate(selector => { document.querySelector(selector).click() }, selector),
                this.page.waitForNavigation(),
            ])
        } else {
            return Promise.all([
                this.page.click(selector),
                this.page.waitForNavigation(),
            ])
        }
    }
}

module.exports = { Scrapper }

まえがき

ドーモ。皆=サン。
ニンジャスレイヤー読んでいますか。
私ははや3年以上かかってもまだ3部の前半です。

昔から少しずつテキスト化して縦書きビューワーで読んできました。
方法は当時作った
Togetterからツイートをコピーするボタンを追加するユーザースクリプト - Qiita
を改修しながらコピペしていたのですが、当時は十分だと思っていたこの方法すら今や億劫になったので、さらに手を抜きたいと考えました。

Pythonプログラムの改修失敗

当初は
togetter→EPUB変換 – ログ取得ツール
を現代のレイアウトに対応させたプログラムに改修しようと思い修正していましたが、
ツイートの表示数を増やす「続きを読む」の動作が変わっていました。
昔はAPIでHTMLを取得していたようですが、現在は最初からJSでHTMLを持っていて、それをクリック時に追加してやるようでした。(一応通信は発生しますが表示のためのレスポンスではありませんでした)

<script type="text/javascript">
var moreTweetContent = "        <div class='list_box type_link type_profile'>\n            <div class=\"list_tweet_box\">\n                <a class=\"user_link\"\n                   href='https:\/\/twitter.com\/angela_KATSU'\n                   target='_blank'\n                   rel=\"nofollow\">\n                    <img class=\"icon_73 lazy lazy-hidden\"\n                         src=\"https:\/\/s.togetter.com\/static\/1.15.61\/web\/img\/placeholder.gif\"\n

...

文字列なのでpythonでパースすることもできなくはなかったですが、JSのStringを取得してunescapeするのも筋が悪いので1HeadlessブラウザでJSを動かしてボタンクリックでDOMに追加してやることにしました。

なので、お手軽なNode.js + Puppeteerへ逃げ込むことに成功。

Puppeteerでのスクレイピング

特記するものはあまり多くないですね。
公式が充実しすぎているので。

ヘッドレスでのpage.gotoがタイムアウトする

ブラウザを表示しながらスクレイピングを組みそこそこ動くようになりましたが、ヘッドレスにしたとたん最初の読み込みがタイムアウトしました。

ブラウザ出すより出さないほうが遅いなんてありえるのか?
どこかでヘッドレスのせいでコケているんではないか?
と訝しみつつ検索。

Page throws timeout exception only in headless mode · Issue #2963 · GoogleChrome/puppeteer

ひとまず提示されているように

If you run your script with DEBUG=*session node a.js (a.js is the name of the script), you'll see
that server decided not to respond for headless request.

DEBUG=*session を付けて実行してみたところ、ヘッドレスでもちゃんと通信はしている。
ただ、そのスピードがたいへん遅く、通信数も多かったです。

なるほどアイコン画像などたくさんあるしなあとも思っていましたが、やたらと広告配信系の通信も多かったです。

スクレイピングにこれらは不要なので、バッサリカットして読み込み完了を高速化することにしました。

高速化方法は

puppeteerを使ったスクレイピング#不要なリクエスト送信を防ぐ(高速化) - Qiita

を参考に、

必要なリクエストだけ許可
        this.page = await this.browser.newPage()
        await this.page.setDefaultNavigationTimeout(60 * 5 * 1000)
        // 不要なリクエストは中断して読み込みを高速化する https://qiita.com/unhurried/items/56ea099c895fa437b56e
        await this.page.setRequestInterception(true)
        this.page.on('request', interceptedRequest => {
            const reqUrl = url.parse(interceptedRequest.url())
            const reqExt = path.extname(reqUrl.pathname)
            const reqHost = reqUrl.hostname

            if (RegExp('\.(css|png|jpe?g|gif)', 'i').test(reqExt)) {
                interceptedRequest.abort()
            } else if (reqHost.endsWith('togetter.com') || reqHost.endsWith('ajax.googleapis.com')) {
                // 続きを読むはJSでDOM構築なのでjQueryのCDNを許可する
                // console.log(interceptedRequest.url())
                interceptedRequest.continue()
            } else {
                interceptedRequest.abort()
            }
})

とほぼホワイトリスト形式で記述しました。

結果、ヘッドレスでもタイムアウトすることなく(比較的)サクサクと読み込めるようになりました。

要素が消えるのを待つ

「続きを読む」をクリックしてDOM追加の反映待ちをどうするかに少し悩みました。
ここではクリックすることで消える「続きを読む」要素の監視を追加完了トリガーとしました。

通常よく使う、DOM要素が現れるのを待つ記述はawait page.waitFor(selector)で出来るのですが、消えるのを待つものはなさそうだったので、

page.waitFor((moreTweetId) => !document.querySelector(moreTweetId), moreTweetId)

全体で

await Promise.all([
  this.page.waitFor((moreTweetId) => !document.querySelector(moreTweetId), moreTweetId),
  this.page.click(moreTweetId),])

としました。(アロー関数の()不要ですね)
移動や変化待ちするときはPromise.allするようにしてますが、
他の方のコードを見ると

page.変化イベントを起こす操作
await page.waitFor(...)

という書き方もよく見る気がします。
こっちだとwaitFor前に完了している可能性もあって、それでもちゃんと動きますけれどなんとなく気持ち悪いので先に監視するものを宣言しておいてから操作したいという心情があります。
(なのでPromise.allなら書く順番関係ないですが、監視を先に、クリックを後に書いてます)

NuxtでWebサービスを作ろう

普段遣いのPCに開発環境やサイズが大きいChromiumを入れるのも目的にたいして手段が大きすぎたのでホストしてもらうことにしました。

前回の反省
誰でもQiita投稿のtypoをチェックできるようにNowでlint機能を公開しました - Qiita

ただ、テンプレートエンジン考えてCSSフレームワーク考えて~
となると、Vue.js入れてVue.jsのライブラリ、
Element - A Desktop UI Toolkit for Web
などに乗っかるほうが結果的に簡単でよかったのかな?と思いました。

からVue.jsを使う既定路線と、Puppeteerと相性が悪いと噂(という言い訳)のNowを使うことにしました。

Nuxt.js

前回はmicroでしたが、今回はどっぷりとVueに浸かろうと@potato4dさんが推し?ているNuxt.jsを使うことにしました。
氏の記事を何度も見かけなければ未だにNuxtを使わず自前で全部揃えようと頑張っていたかも。

「結局Nuxt.jsって何がいいの?」に対する回答
なんとなくみたことがある記事。

とは言えこれが初利用ではなく、一度だけ触っていました
Nuxt.js 入門 #1 Nuxt.js によるブログアプリ制作 解説メモ - Qiita
この経験のおかげで「置けば動く」がわかっていてスムーズだったのかもしれません。

今回はcreate-nuxt-appでもvue-cliでもなくスクラッチで作成しました。

コード量は本当に少ないんですけれどね。

楽しい

仮想DOMの楽しさ自体は昔にReactやVueを触ったときにも感じていましたが、Nuxtはそれに加えて本当に「サクサク作ってる感」がありました。

書けば動く。素晴らしいです。
Vuexやrouter、webpackが隠蔽?されているので、「(ライブラリ)を使っている」というよりは「やりたいことを書いている」感が強かった気がします。

今回書いたものはVue単体でも収まりそうな程度の少量なのであまりえらくは言えませんが、この程度でもレールに乗っている快適な気分になりましたし、また使いたいと強く想いました。


あらためて書こうとすると、「そのディレクトリに置けばそう扱われる」というのは他のフレームワークでもよくありそうな機能ですし、
SPAもVueの機能と言えそうで「Nuxtの良さ」を言葉にするのが難しいことに気づきました。

サーバー内蔵だとか前述した隠蔽?に加え、Vuevmなんて変数を使わなかった、middlewareが考慮されているなどなど細かくは浮かぶのですが、そういった気にしなくても使える・そろっている細やかさが快適さや魅力の原因なのかなと。(もちろん日本語ドキュメントの細やかさも含まれています!)

Bulma

VueのCSSフレームワークには
Bulma: a modern CSS framework based on Flexbox
modules/packages/bulma at master · nuxt-community/modules

を使いました。導入もモジュールになっているので簡単。

nux.config.js(bulma単体なら)
module.exports = {
    modules: [
      '@nuxtjs/bulma',
    ],
}

ローディング画面やトーストもExtensionとしてあるので入れてクラスを指定するだけでアニメーションしてくれます。
Extensions | Bulma: a modern CSS framework based on Flexbox
前回のポストして遷移しての動きに比べれば、カスタマイズなしでもちょっとは「らしい」動きになったんじゃないでしょうか。

クリップボードへのコピー

任意の文字列をコピーするテクニック - Qiita

ファイルダウンロード

Blobを使って(jQueryを使わずに)JavaScriptでデータをファイルに保存 - Qiita

ここはthis.$storeが使えるので簡単にダウンロードできたなと。(DOMからとるのとそう変わりませんが)

        downloadText(e) {
            const blob = new Blob([this.$store.state.text], {type: 'text/plain'})
            const link = document.createElement('a')
            link.href = window.URL.createObjectURL(blob)
            link.download = this.$store.state.text.split('\n')[0] + '.txt'
            link.click()
        },

(投降直前に気づきましたが、この処理はブラウザ依存のようです。
おそらくFirefox64とIE9ではファイルをダウンロードできません。
gif動画でダウンロードしているブラウザはVivaldiです。
理由は簡単なので気が向いたときか要望が出たときにでも直します)

フォント

デフォルトのフォントでは寂しいので、フォントを変更することにしました。

そう言えばGoogleの日本語フォントが正式対応した記事を見かけたなと思い出したのも一因です。

Google Fontsに日本語WEBフォントが正式追加された話 - Qiita

Google Fonts + Japanese • Google Fonts + 日本語

一応cssファイルでクラスを定義して

assets/font.css
.wf-nicomoji { font-family: "Nico Moji"; }

適当にフォントを選び、共通でCDN読み込みとアセットの適用をコンフィグに記述してあげれば完成です。

nuxt.config.js
module.exports = {
    modules: [
      '@nuxtjs/bulma',
    ],
    css: [
      '@/assets/font.css',
      'bulma-tooltip',
      'bulma-pageloader',
      'bulma-toast',
    ],
    head: {
      link: [
        { rel: 'stylesheet', href: 'https://fonts.googleapis.com/earlyaccess/nicomoji.css' },
      ],
    }
}

取得完了通知

タブのピン留め時の通知をタイトル変更で実現しました。

    methods: {
        getText(e) {
            this.$store.commit('setIsLoadig', true)
            this.$store.dispatch('getText', this.$store.togetterId).then(() => {
                this.$store.commit('setIsLoadig', false)
                if (!this.$store.state.isWindowActive) {
                    document.title = this.$store.state.title + ' 取得完了'
                }
            })
        }
    },
    mounted() {
        window.addEventListener('blur', e => {
            this.$store.commit('setIsWindowActive', false)
        })
        window.addEventListener('focus', e => {
            this.$store.commit('setIsWindowActive', true)
            document.title = this.$store.state.title
        })
    },

非同期前提とはいえ、当たり前ですがActionはちゃんとreturn Promiseしないと自動では同期的に扱えないので注意しましょう。

アクション | Vuex

タブ 通知 Notification 青い丸 JavaScript 方法 非アクティブ タイトル 変更 検知 - Qiita

Nowへのデプロイ

大きく躓きましたが別記事にまとめてあります。

NowでNuxtとPuppeteerをDockerでデプロイする - Qiita

時期がだいたい被っているので、DockerでのデプロイはFaaSになる前のNowでいうバージョン1でのデプロイです。
旧バージョンになりますが、しばらく、または代替案が出来るまではバージョン1がサポートされることが公式で言及されていたのでそのままで。
Puppeteerが動かせる環境はかなり限定されそうで、止まると困りますね…バージョン2でlambdaとしてPuppeteerを動かすこともまだ成功していないので、他に出来そうな場所はHerokuとかでしょうか…?

その他開発中に困ったときの記事

  1. js版で無理やりunescapeした場合 https://qiita.com/khsk/items/f331798acfb99ef347fc

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?