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

# 動作例gif

![tttweb.gif]
![ttt.gif]

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

[khsk/togetter-to-text: TogetterをPuppeteerでスクレイピングする](https://github.com/khsk/togetter-to-text)

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](https://qiita.com/khsk/items/785202bb70646029193e)
を改修しながらコピペしていたのですが、当時は十分だと思っていたこの方法すら今や億劫になったので、さらに手を抜きたいと考えました。

## Pythonプログラムの改修失敗

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

```js
<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するのも筋が悪いので[^1]HeadlessブラウザでJSを動かしてボタンクリックでDOMに追加してやることにしました。

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

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

# Puppeteerでのスクレイピング

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

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

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

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

[Page throws timeout exception only in headless mode · Issue #2963 · GoogleChrome/puppeteer](https://github.com/GoogleChrome/puppeteer/issues/2963)

ひとまず提示されているように
>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](https://qiita.com/unhurried/items/56ea099c895fa437b56e#%E4%B8%8D%E8%A6%81%E3%81%AA%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E9%80%81%E4%BF%A1%E3%82%92%E9%98%B2%E3%81%90%E9%AB%98%E9%80%9F%E5%8C%96)

を参考に、

```js:必要なリクエストだけ許可
        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)`で出来るのですが、消えるのを待つものはなさそうだったので、

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

全体で

```js
await Promise.all([
  this.page.waitFor((moreTweetId) => !document.querySelector(moreTweetId), moreTweetId),
  this.page.click(moreTweetId),])
```
としました。(アロー関数の`()`不要ですね)
移動や変化待ちするときは`Promise.all`するようにしてますが、
他の方のコードを見ると

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

# NuxtでWebサービスを作ろう

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

前回の反省
[誰でもQiita投稿のtypoをチェックできるようにNowでlint機能を公開しました - Qiita](https://qiita.com/khsk/items/fea81fb4dfd41b7eaf19)
>ただ、テンプレートエンジン考えてCSSフレームワーク考えて～
となると、Vue.js入れてVue.jsのライブラリ、
[Element - A Desktop UI Toolkit for Web](http://element.eleme.io/#/en-US)
などに乗っかるほうが結果的に簡単でよかったのかな？と思いました。

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

# Nuxt.js

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

[「結局Nuxt.jsって何がいいの？」に対する回答](https://slides.com/potato4d/vuejs_meetup7#/)
なんとなくみたことがある記事。

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

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

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

## 楽しい

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

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

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

***

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

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

## Bulma

VueのCSSフレームワークには
[Bulma: a modern CSS framework based on Flexbox](https://bulma.io/)
[modules/packages/bulma at master · nuxt-community/modules](https://github.com/nuxt-community/modules/tree/master/packages/bulma)

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

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

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

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

[任意の文字列をコピーするテクニック - Qiita](https://qiita.com/khsk/items/5812ed8a73b8acd1eff2)

# ファイルダウンロード

[Blobを使って（jQueryを使わずに）JavaScriptでデータをファイルに保存 - Qiita](https://qiita.com/iton/items/dc0d7831f9783c0808da)

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

```js
        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](https://qiita.com/kamina/items/86769d037783948e5e6d)

[Google Fonts + Japanese • Google Fonts + 日本語](https://googlefonts.github.io/japanese/)


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

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


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

```js: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' },
      ],
    }
}
```

# 取得完了通知

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

```js
    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](https://vuex.vuejs.org/ja/guide/actions.html)

[タブ 通知 Notification 青い丸 JavaScript 方法 非アクティブ タイトル 変更 検知 - Qiita](https://qiita.com/khsk/items/215cf51064be6ac3a513)

# Nowへのデプロイ

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

[NowでNuxtとPuppeteerをDockerでデプロイする - Qiita](https://qiita.com/khsk/items/221cf71abd0a86520fd9)

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

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

* [Nuxt.jsで自分に外部APIを立てるのにexpressなどは必要がない - Qiita](https://qiita.com/khsk/items/7bd4fb480c80124c00e2)
* [Nuxt.jsからPuppeteerが実行できない TypeError: Cannot read property 'on' of undefined - Qiita](https://qiita.com/khsk/items/ed40f36f78a9498baee7)


[ttt.gif]:https://qiita-image-store.s3.amazonaws.com/0/62354/9f9c7cd3-2cd1-d954-67b9-77ca42b3b5f8.gif

[tttweb.gif]:https://qiita-image-store.s3.amazonaws.com/0/62354/cc9d2788-c482-45f8-c135-8074fee122c3.gif