{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# サナララツイート自動収集の基礎的検討(1)\n", "\n", "このドキュメントは、[ぴょこりんクラスタ Advent Calendar 2015](http://www.adventar.org/calendars/960)のために書いたものです。\n", "\n", "## 要約\n", "* twitter公式が用意してくれた枠組みでサナララツイートを収集してみた\n", "* 神の用意したお試しサナララツイート収集は1週間限定だった\n", "* 別の方法でもっと古いツイートを収集する(神の作りしルールへの挑戦)\n", "* 負けた\n", " \n", "## はじめに\n", " 皆さん師走はどうお過ごしでしょうか。なかなか忙しく、ツイッターに張り付いていられない日々を送っているのではないかと思います。自分が見ていないうちに興味のある内容のツイートが流れてしまった・・・なんてこともしばしば。実際のところ僕もすべてのサナララツイートを追うことが出来ず、日々心を痛めています。そこで、多くのサナララツイートを自動的に収集、僕に見やすいように可視化する枠組みを構築することを決意、今回はツイート収集に関する基礎的な検討を行いました。\n", " \n", "\n", "## 公式が提供している検索の仕様確認(お試し)\n", " とりあえずサクッと叩いてみます。以下のを参考に。\n", " * [Python で Twitter API にアクセス](http://qiita.com/yubais/items/dd143fe608ccad8e9f85)\n", " * [GET search/tweets](https://dev.twitter.com/rest/reference/get/search/tweets)" ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "https://api.twitter.com/1.1/search/tweets.json?q=\"サナララ\"&count=100\n" ] } ], "source": [ "from requests_oauthlib import OAuth1Session\n", "\n", "CKey,CSecret, AToken, ASecret=open('./TWOauth','rb').read().strip().split('\\n')\n", "knatsuno=OAuth1Session(CKey,CSecret, AToken, ASecret)\n", "query=u'\"サナララ\"'\n", "uri = 'https://api.twitter.com/1.1/search/tweets.json?q=%s&count=100'%query\n", "req = knatsuno.get(uri)\n", "print uri" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "諸々のキーでOAuth認証キメて、上記URLをgetすると結果が返ってくる感じ。ちなみに認証しないと\n", ">{\"errors\":[{\"code\":215,\"message\":\"Bad Authentication data.\"}]} \n", "\n", "って感じで返ってくる。\n", "少しだけ注釈。\n", "* twitterから提供されるConsumerKey, ConsumerSecretなどなど計4つの文字列が各行に記述されたファイルが'./TWOauth'に置いてあるという前提で書いてます。ベタ書きダメ絶対! \n", "* サナララの部分について、普通のクオテーション('')の中にさらにダブルクオテーション(\"\")を入れていますが、これは完全一致するものを見つけてくるための検索クエリ用のアレです。\n", " - サナララなら特に類似する全然関係ない単語ってあんまりないような気もするので、ここでは完全一致でなくても良さげ。\n", " - 気を付けるべき例として、例えばu'椎名希未'で検索を書けると全然関係ない椎名さんも引っかかる。具体的には椎名ゆなさんって名前がひっかった(AV女優さんでした)。u'\"椎名希未\"'とすると、このような問題が解決する、という感じ。\n", "* count=100は100個ツイート取ってくるよ、の意味。デフォルトだと15個とのこと。\n", " \n", "中身はjson形式で格納されていて、以下のような感じで色々見れる。100個並べるとドキュメントが長くなりすぎてつらいので3個だけ。" ] }, { "cell_type": "code", "execution_count": 2, "metadata": { "collapsed": false, "scrolled": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Mon Dec 07 09:01:45 +0000 2015\n", "美少女ゲーム関連速報(@GameTwN)さんは言いました。\n", "『【サナララ】 それは…誰にでも起こるかもしれない、少し不思議な物語…\n", "\n", "一生に一度だけ全ての人に訪れる「チャンス」\n", "それを告げに来たの...https://t.co/nPJZU8cKL8 https://t.co/6cZGfRXv4l 』と。\n", "\n", "Mon Dec 07 08:30:03 +0000 2015\n", "ひだまりスケッチ コピペbot(@hds_cpp_bot)さんは言いました。\n", "『宮子:この前、「ここがサナララの世界か…」って首からトイカメラをぶら下げたお兄さんがいたから、多分違うよ~?っていったら「そうか、だいたいわかった」って言ってバイクでどっか行っちゃった 』と。\n", "\n", "Sun Dec 06 21:01:57 +0000 2015\n", "ゆみづか(@yumiduka2015)さんは言いました。\n", "『そう考えるとやっぱサナララは神なんだよなぁ〜〜〜〜〜 』と。\n", "\n" ] } ], "source": [ "for i in req.json()[u'statuses'][:3]:\n", " print i[u'created_at']\n", " print i['user'][u'name'] + '(@' + i['user'][u'screen_name'] + u')さんは' + u'言いました。'\n", " print u'『'+i['text']+u' 』と。\\n'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " と、こんな感じ。さて、100個取ったはずなので本当に100個あるのか確認してみましょう。" ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "collapsed": false }, "outputs": [ { "data": { "text/plain": [ "78" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "len(req.json()[u'statuses'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "なるほど78個、なるほど。 \n", "\n", "\n", " 先ほどリンクを張った公式ドキュメントを再確認。クエリのパラメータとしてqやcountの他にツイートの時期を指定するuntilなんてのがあるのですが、そこの説明にこんな注意書きがありました。\n", ">Keep in mind that the search index has a 7-day limit. In other words, no tweets will be found for a date older than one week.\n", "\n", " 過去一週間分しか検索出来ない仕様だと。使えねぇな。検索結果のうしろの方見てみましょう。" ] }, { "cell_type": "code", "execution_count": 4, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Sat Nov 28 17:24:13 +0000 2015\n", "キウイ風呂入りたい(@punch_subaru)さんは言いました。\n", "『RT @yumiduka2015: 補正込込込込込でサナララがナンバーワン 』と。\n", "\n", "Sat Nov 28 20:57:36 +0000 2015\n", "ひだまりスケッチ コピペbot(@hds_cpp_bot)さんは言いました。\n", "『宮子:この前、「ここがサナララの世界か…」って首からトイカメラをぶら下げたお兄さんがいたから、多分違うよ~?っていったら「そうか、だいたいわかった」って言ってバイクでどっか行っちゃった 』と。\n", "\n", "Sat Nov 28 23:24:06 +0000 2015\n", "そうだ エロゲー、やろう!(@pcgame_anime)さんは言いました。\n", "『【美少女ゲーム】サナララR【視聴は→https://t.co/ihbqw3UAK2】エロゲー #エロゲー https://t.co/UJo0fJCHTP 』と。\n", "\n" ] } ], "source": [ "for i in req.json()[u'statuses'][:-4:-1]:\n", " print i[u'created_at']\n", " print i['user'][u'name'] + '(@' + i['user'][u'screen_name'] + u')さんは' + u'言いました。'\n", " print u'『'+i['text']+u' 』と。\\n'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " 確かに一週間前っぽい。そうか、だいたいわかった。\n", " \n", " ## 神のシステムに挑む\n", " \n", " 神(twitter)によれば一週間しかダメと言うことでした。どこかで聞いたことがある話です。でも一般的にこういうのには抜け道があるはずです。例えばほら、[ここ](https://twitter.com/search-home)で検索するともっと昔のツイートが見つけられる。こんなの納得いかないよ。『観測できれば干渉できる』って[胡散臭い小動物的な何か](https://www.google.com/search?q=QB&safe=off&espv=2&biw=2560&bih=993&source=lnms&tbm=isch&sa=X&ved=0ahUKEwjhxaa6o8nJAhWHoYMKHa4MAf0Q_AUIBigB)も言ってました。僕は観測できれば何でも干渉できるとは思わないのですが、今回のケースはきっと何とかなります。 \n", "\n", "### 検索結果のスクレイピング\n", " サクッと落としてサクッとスクレイピングしてみましょう。これだけなら簡単。" ] }, { "cell_type": "code", "execution_count": 5, "metadata": { "collapsed": false }, "outputs": [], "source": [ "req2 = knatsuno.get(u'https://twitter.com/search?f=tweets&vertical=default&q=%s&src=typd'%query)" ] }, { "cell_type": "code", "execution_count": 6, "metadata": { "collapsed": false, "scrolled": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "GameTwN\n", "美少女ゲーム関連速報\n", "1:01 - 2015年12月7日\n", "【サナララ】 それは…誰にでも起こるかもしれない、少し不思議な物語…\n", "\n", "一生に一度だけ全ての人に訪れる「チャンス」\n", "それを告げに来たの... pic.twitter.com/6cZGfRXv4l\n", "\n", "hds_cpp_bot\n", "ひだまりスケッチ コピペbot\n", "0:30 - 2015年12月7日\n", "宮子:この前、「ここがサナララの世界か…」って首からトイカメラをぶら下げたお兄さんがいたから、多分違うよ~?っていったら「そうか、だいたいわかった」って言ってバイクでどっか行っちゃった\n", "\n", "yumiduka2015\n", "ゆみづか\n", "13:01 - 2015年12月6日\n", "そう考えるとやっぱサナララは神なんだよなぁ〜〜〜〜〜\n", "\n" ] } ], "source": [ "from bs4 import BeautifulSoup as bs\n", "import bs4\n", "def strongstrip(s):\n", " if type(s)!=bs4.element.NavigableString and type(s)!=str:\n", " if len(s.contents)==0: s=''\n", " else: \n", " s= s.contents[0]\n", " s=strongstrip(s)\n", " return s\n", "\n", "t=bs(req2.content, \"lxml\").findAll(\"div\", { \"class\" : \"content\" })\n", "for text in t[1:4]:\n", " print text.findAll('a')[0]['href'].strip('/')\n", " print text.findAll('strong')[0].contents[0]\n", " print text.findAll('a')[1]['title']\n", " print ''.join([strongstrip(i) for i in text.findAll('p')[0]])\n", " print ''" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "先ほどと同じように上位3ツイートを拾ってみる。スクリーンネーム、大体似たものが取れてる。微妙に時刻が違うんだけどさっきのはGMTで今回表示されてるのはGMT-8だからちょっと注意。ちなみに取れたツイートの個数は、" ] }, { "cell_type": "code", "execution_count": 7, "metadata": { "collapsed": false }, "outputs": [ { "data": { "text/plain": [ "20" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "len(t)-1" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " 20個、さっきより得られた結果が減ってる。Web上で表示されるときは最初はちょこっと出てて、スクロールしていくと検索結果が増えてくからまぁこれくらいかもしれない。さすが神のシステム、隙が無いぜ。ただ、仕様上はこっちなら1週間縛りは無いはずなので、何とかここで出た検索結果を出さずに、古い結果だけを出すような検索クエリが投げられれば、古いツイートも手に入るはず。\n", " \n", "### あるツイートより過去のツイートを探す\n", "\n", " 多分上位20件が表示されるという話だから、それより古いツイートを検索するって機能があれば話は簡単。で、さっきの1週間限定のヤツにはそれがある。\n", " >max_id(Optional): Returns results with an ID less than (that is, older than) or equal to the specified ID.\n", " \n", " これって実はブラウザ上での検索でも使えるんじゃないの?という話。[高度な検索](https://twitter.com/search-advanced)なんかを見てもそんな機能は見当たらないんだけど、もしもこれが使えたら、検索結果のツイートよりさらに古いツイートを検索するのを結果が出なくなるまで続ければかなり古くまでツイートを漁るコトが出きるはず。" ] }, { "cell_type": "code", "execution_count": 8, "metadata": { "collapsed": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "hds_cpp_bot\n", "ひだまりスケッチ コピペbot\n", "12:57 - 2015年11月28日\n", "宮子:この前、「ここがサナララの世界か…」って首からトイカメラをぶら下げたお兄さんがいたから、多分違うよ~?っていったら「そうか、だいたいわかった」って言ってバイクでどっか行っちゃった\n", "670708141898645504\n" ] } ], "source": [ "results=[]\n", "\n", "t=bs(req2.content, \"lxml\").findAll(\"div\", { \"class\" : \"content\" })\n", "for i in range(10):\n", " for text in t[1:]:\n", " results.append([text.findAll('a')[0]['href'].strip('/'),text.findAll('strong')[0].contents[0], \\\n", " text.findAll('a')[1]['title'], ''.join([strongstrip(i) for i in text.findAll('p')[0]]), \\\n", " text.findAll('a')[1]['href'].split('/')[-1]])\n", " req3= knatsuno.get(u'https://twitter.com/search?f=tweets&q=%s max_id%%3A%s&src=typd'%(query,str(int(results[-1][-1])-1)))\n", " t=bs(req3.content, \"lxml\").findAll(\"div\", { \"class\" : \"content\" })\n", " if len(t)==1:\n", " break\n", "\n", "for i in results[-1][:len(results[-1])]:\n", " print i" ] }, { "cell_type": "markdown", "metadata": { "collapsed": false }, "source": [ "表示してるのは得られたツイートの中で一番古いツイート。max_idを使ったクエリが投げれた・・・のはいいんだけど何で一番古いツイートが一週間以内なの・・・。個数も、" ] }, { "cell_type": "code", "execution_count": 10, "metadata": { "collapsed": false }, "outputs": [ { "data": { "text/plain": [ "66" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "len(results)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "たった66個、ブラウザで検索してるときのネットワークのやりとり見てるとなんとなくhttps://twitter.com/i/toast_poll?oldest_unread_id=0 にもgetしてるっぽくて怪しいんだけど今日は眠いからここまで。悔しい。" ] } ], "metadata": { "kernelspec": { "display_name": "Python 2", "language": "python", "name": "python2" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 2 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython2", "version": "2.7.6" } }, "nbformat": 4, "nbformat_minor": 0 }