サッカー記事の一覧サイトを作った(Gensimを利用して類似記事を表示するまでの記録)
はじめに
個人的に、サッカーのニュースや2chまとめ記事を一覧できるサイトを結構な頻度で見る。
おそらくは、単にRSSでデータ引っこ抜いてきて一覧で表示しているだけのサイトなんだれども、ニュースとまとめBlogとかの記事が別れていたり、自分の好きなニュースソースがなかったりするのがずっと不満だった。
昔みたいにRSSリーダーにRSSを登録して読めばいいんだけれど、なんだかそういう習慣もなくなってしまっていて、RSSリーダーを探すところから始めるのもなーと。
そんなことを思いつつ1年ぐらい過ごしていたんだけれど、なんだか最近プログラムを書く機会もないし、自然言語界隈で新しいライブラリも出てきてるみたいだし、ということで、勉強がてら自分で作って見た。
- 成果物はこのサイト: https://the-football-spot.com/
- ソースコードはgithub: https://github.com/kent013/football (クソコードです)
使われることはないだろうとは思いつつも、ソースコード使ったら教えてもらえると嬉しいです。
なにかの役に立てばと思うので、勉強の成果を残します。
やりたいこと
やったこと
やりたいことが明確だったので、下調べしてフィージビリティとるとかしないで、
という流れで順にやった。
昔書いたスクリプトがあるとはいえ、全体で10日(40時間程度)でできちゃったのがびびった。 ググればライブラリが出てくるわ出てくるわで、人類の進化をマジで感じた。
RSSの収集
今は、どこのサイトもRSSついてるのね。RSSリーダー使っている人まだいるんだろうか。それとも誰も使わないけどなんとなくあるんだろうか。 収集したものをSpreadsheetにまとめて、面倒くさいからSQLを構築できるようにした
記事の取得
Scrapyで取得。
特筆すべきことはないけれど、昔作ったcrawlerのスクリプトが手元にあったので改変して利用。 Python2時代に描いたやつなのでwarningが色々出るけど動くから無視。
基本はDBにあるcrawler_jobを登録していって、crawlerのプロセスがjobを一個とって処理する感じの実装になっている。 新しいサービスとかあるんでしょうけれど、目的は類似記事の抽出なので、追求せず放置。
RSSからの記事の取得周りとか、昔はRSS0.91/1.0/2.0、Atomに対応するために別々のライブラリを使ったり、なんなら自分で記述してたけど、pip install feedparser
して、
rss = feedparser.parse(rawdata) for entry in rss.entries: article = FootballArticleItem() article['title'] = entry.title article['summary'] = entry.summary if 'summary' in entry else "" article['creator'] = entry.author if 'author' in entry else "" article['url'] = entry.link article['hash'] = article_hash article['subject'] = " ".join(map(str, entry.tags)) if 'tags' in entry else "" article['feed_id'] = self.crawler_job.feed_id if 'updated_parsed' in entry: article['published_at'] = datetime.fromtimestamp(mktime(entry.updated_parsed)) elif 'published_parsed' in entry: article['published_at'] = datetime.fromtimestamp(mktime(entry.published_parsed)) article['scraped_at'] = self.crawler_job.started_at if 'published_at' in article and article['published_at'] > datetime.now(): article['published_at'] = datetime.now()
みたいにサクッとかけちゃうのびびった。世の中進化してる。本当にすごい。
本文およびメイン画像抽出
本文抽出
記事が保存できてもHTMLなので、そこから形態素解析するためには本文抽出しないといけない。 自分で書かないといけないのかと思ってたら、色々とライブラリがあった。すごい。
https://moz.com/devblog/benchmarking-python-content-extraction-algorithms-dragnet-readability-goose-and-eatiht/ https://orangain.hatenablog.com/entry/content-extraction-from-html-in-python に書いてある全てのライブラリを試してみた。
- Readability
- goose
- Eatiht
- dragnet
あるわあるわ。すげーね。で、2chのまとめサイトにかけてみたら...どれもダメ。全然ダメ。 英語圏の本文抽出エンジンだから日本語には適用できないんだなー、どうしようもないなーとおもってたら、日本人が書いてるのがあった。 http://labs.cybozu.co.jp/blog/nakatani/2007/09/web_1.html https://github.com/yono/python-extractcontent でも、日付をみたら2007...orz
でも、2015にpython3用に書き直している人がいた。いや、いらっしゃった。
https://github.com/kanjirz50/python-extractcontent3
人類は確実に進化している。動かしたら、他のライブラリよりもずっといい結果(目視です)だったので、こちらを採用。dragnetは英語の記事の形態素解析に利用することにした。ありがたやありがたや。
if feed.language == 'ja': extractor.analyse(content) text, title = extractor.as_text() else: text = extract_content(content)
書いたコード、これだけですよ。びびるわそんなん。俺のためにライブラリ書いてくれてたのかとか錯覚するわ。
用途がはっきりしてるなら、最近だと
とか使えばいいのかもしれないけどね。
メイン画像抽出
メイン画像の抽出はいいライブラリが見つけられなかった(ググり力)ので、適当に書いた。
http://effbot.org/zone/pil-image-size.htm でネット上の画像のヘッダ部分だけをDLしてPillowでサイズ取得みたいなのをやっている人がいたので、それを利用。同じ画像を処理するときにはWebにアクセスしなくていいようにdiskcacheでキャッシュするようにした。
メイン画像の取得部分は以下の通り。アルゴリズム的には
- 画像のURLを取得
- ゴミ除去
- サイズ取得してきて、大きさとアスペクト比を比較。
- 大きくて四角に近い画像をメイン画像とする
みたいなシンプルなもの。なんかちゃんととれないけど、Webサイトにした時の見た目的に必要なだけで、本質とは関係ないのでいいやー、って感じ。本当は、記事上の位置とか調べたほうがいいんだけど、HTMLから取得しようとするの無理があるだろって思うので、もういい。
primary_image_url = None for image_url in bs.find_all('img'): src = image_url.get('src') if not src or re.match('data:image', src): continue if not re.match('http', src): src = urljoin(article.url, src) if re.search('common|share|button|footer|header|head|logo|menu|banner|parts|thumbnail|ranking|icon|copyright|feedly|ico|seesaablog.gif|fan_read.gif|fan_received.gif|captcha|/n.gif|/u.gif|chart.apis.google.com|images-amazon.com|facebook.com|powered_by|rss.rssad.jp|blank|navi|custom.search.yahoo.co.jp|pixel|xrea.com|w=64|i2i|microad.jp|resize.blogsys.jp|b.hatena.ne.jp|accesstrade.net|poweredby|scorecardresearch.com|ssc.api.bbc.com|sa.bbc.co.uk|amazon-adsystem.com|zero-tools.com|clicktrack2.ziyu.net|nakanohito.jp|pv.geki.jp|arrow_left|arrow_right|spacer.gif|spike.png|wp-content/themes', src): continue print(" " + src) width, height = getsizes(src, dc) if not width: continue square = width * height aspect_ratio = width / height if square > max and aspect_ratio > 0.5 and aspect_ratio < 1.8: primary_image_url = src max = square
本文の形態素解析
本文が抽出できたので次は形態素解析だ!とおもって"python 形態素解析"で検索したら、
Python初心者が1時間以内にjanomeで形態素解析できた方法とかいうQiitaの記事が出てきた。janome...mecabから進化したのかーと思い、とりあえずインストールしてつかってみたら、Python janomeのanalyzerが便利とかにあるように、とても便利だった。
自分で書かなきゃいけない分量がかなり減っていて、これまた人類の進化を感じるわけであります。
class FootballCompoundNounFilter(TokenFilter): def apply(self, tokens): _ret = None re_katakana = re.compile(r'[\u30A1-\u30F4]+') for token in tokens: parts = token.part_of_speech.split(',') if _ret: ret_parts = _ret.part_of_speech.split(',') if parts[0] == u'名詞' and not parts[1] == 'u固有名詞' and ret_parts[0] == u'名詞' and not ret_parts[1] == u'接尾': _ret.surface += token.surface _ret.part_of_speech = u'名詞,複合,*,*' _ret.base_form += token.base_form _ret.reading += token.reading _ret.phonetic += token.phonetic else: ret = _ret if parts[0] == u'名詞' and parts[1] == u'固有名詞': yield token else: _ret = token yield ret else: _ret = token if _ret: yield _ret class FootballNounFilter(TokenFilter): def apply(self, tokens): for token in tokens: parts = token.part_of_speech.split(',') if re.match('[0-9]+', token.surface): continue if parts[0] == u'名詞' and parts[1] == u'非自立': continue if parts[0] == u'名詞' and parts[1] == u'接尾': continue if re.search('[0-9]+([年月日分]|ゴール)$', token.surface): continue if re.search('[0-9]+$', token.surface): continue if token.surface in [u'次ページ']: continue yield token
こんな感じのフィルタを書いて、
char_filters = [UnicodeNormalizeCharFilter(), RegexReplaceCharFilter('&[^&]+;', '')] tokenizer = Tokenizer(mmap=True) token_filters = [FootballCompoundNounFilter(), FootballNounFilter(), POSKeepFilter('名詞')] analyzer = Analyzer(char_filters, tokenizer, token_filters)
みたいに初期化したら、
tokens = list(analyzer.analyze(content))
みたいなコードで形態素解析できちゃうんですよ...すごい。なおフィルタの中身は、なんか適当です。
ちなみに、英語はNLTKで、
from nltk.tokenize import word_tokenize words = word_tokenize(content)
ですよ。なんなんだ、この時代は。
類似文書の計算
形態素解析できたら、Doc2Vecに食わせて類似度計算です。
ここまで正味2日(16時間)くらいできたので、マジでやばい感じに脳内麻薬が出てしまっていました。 びびる。
Python と gensim で doc2vec を使うとか読むと、なにやら、TaggedDocumentの配列を用意して、Doc2Vecインスタンス作って、trainingして、類似する文書を取得するだけみたいで、マジかよ感が。
results = session.query(ArticleContents, Articles, Feeds).filter(Articles.hash == ArticleContents.article_hash, Articles.feed_id == Feeds.id, ArticleContents.extracted_content != None).order_by(ArticleContents.id).all() for result in results: try: article_content, article, feed = result if article.hash in trainings: continue if not article_content.extracted_content: continue print(' ' + article.url) content = article_content.extracted_content words = [] if feed.language == "ja": tokens = list(analyzer.analyze(content)) for token in tokens: words.append(token.surface) #print(token) elif feed.language == "en": words = word_tokenize(content) trainings[article.hash] = TaggedDocument(words, tags=[article.hash])
みたいな感じでTaggedDocumentの配列(ここではキャッシュのためにDictionary)作って、
m = Doc2Vec(documents=trainings.values(), dm=1, vector_size=500, window=5, min_count=2, sample=8, alpha=0.1, epochs=55) m.save("doc2vec.model")
ってして、
m = Doc2Vec.load('doc2vec.model') similar_articles = m.docvecs.most_similar(article.hash, topn=20)
これだけで、類似する記事が出てくる。topnは出力する記事数ね。デフォルトだと10出力されます。
Doc2Vecのオプションに関しては、min_countとwindow/sampleを元となる文書群の規模に応じて変える必要がありそう。今の所4000記事とかしかないので、このくらいの数字じゃないといい感じに出てこない。 形態素解析の段階でゴミをどれだけ除去できるかも影響が大きい。
そんなことよりも!!!!! 4000記事のtrainingに10秒とかしかかかんないのなんなの!!!!
人類やばい。 類似度計算まで3日...24時間で終わっちゃうとかなんなんだ。
クローラサーバの構築
ここは、昔使ってたスクリプトを流用して構築した。 AWSを使ってます。
- クローラサーバ2台 (micro)
- 解析用サーバ1台 (small)
- Web用サーバ1台 (micro)
- RDS (small)
で、Route53でドメイン取って、ALB置いて、構築しました。 terraformとか使おうかなって思ったけど、そこ本質じゃないしやめた。
Webサーバはphp71-fpmとnginxで構築した。
Webサイトの構築
Webサイトは、こだわりポイントもあまりないので、PHPで記述した。
なんか適当なルーター(Klein)と適当なテンプレートエンジン(Twig)をつかって、見た目はBootstrapで。
記事ごとに、メイン画像を抽出しておいたので、それを表示したいなとおもったけれど、自前で画像を取得してきてリサイズしてS3に置くとか面倒臭かったので、そういうproxyがないか調べたら、あった。
- rsz.io
- SSLを利用しようとしたところ、元画像のURLに含まれるスキームがhttpだと画像が取得できなかった
- http://rsz.io/
- Images.weserv.nl
- 上記の代替として見つけた。元画像のスキームに関わらず、httpsが使えた。
- https://images.weserv.nl/
- Googleのサービスを適当に使う
- 無理やり使ってる感じだったから却下。
- https://gist.github.com/carlo/5379498
で、上にもあるようにせっかくAWSでSSL使えるのに、画像がhttpだとワーニングがたくさん出るので、Images.weserv.nlを使うことにした。
Faviconを表示したいなーと思ってググったらFaviconを取得するというのを見つけた。
http://www.google.com/s2/favicons?domain=www.yahoo.co.jp
だけとかびびるわ。
所感
昔、hatenaの記事を形態素解析して、トピック抽出しようとしていたことがありました。
もう10年前なのか...このとき、形態素解析してnumpy使って次元縮約して、自分でトピック抽出しようとしていました。あるユーザーがよく見るWebサイトの情報を収集してトピック抽出して、新しいWeb記事をサジェストしてくれるサービスにすれば儲かるんじゃないかっておもって。そのあと、GunosyとかSmartNewsとかでてきたんだけど、あの頃は、類似文書の出力とか、一部のスーパープログラマー達にしか実現することができなかったし、それこそ自然言語についての論文を広く読んでなかったらできなかった。
それが、類似度の計算までたったの24時間で到達できて、適当にWebシステムにして公開するまでに正味40時間ですよ。
もう、なんだか、今のプログラマ達は別の世界に生きているんだなって、痛感した。
とりあえず目的のものはできたので、あとはまた気が向いたら色々やってみようかなーって感じです。
まぁ、何かの役に立てば幸いです。
使ったもの
python系
- python (v3.5.4)
- 昔は2系のpythonつかってたけど、もう状況が整ってきているっぽいので3系
- scrapy
- sqlalchemy
- pythonのORMライブラリ、使い勝手いいのかなー。昔から使ってるから使ってるだけ。好きではない。
- https://www.sqlalchemy.org/
- http://st-hakky.hatenablog.com/entry/2017/08/13/130202
- slackweb
- crawlが終わった時とかにslackに通知するために利用
- https://github.com/satoshi03/slack-python-webhook
- https://qiita.com/satoshi03/items/14495bf431b1932cb90b
- feedparser
- RSSを解析するためのライブラリ。昔は自分で作ってた機能が全部ある感あってすごい
- https://pythonhosted.org/feedparser/
- https://qiita.com/shunsuke227ono/items/da52a290f78924c1f485
- extractcontent3
- 日本語用本文抽出ライブラリ for Python 3
- https://github.com/kanjirz50/python-extractcontent3
- dragnet
- Pillow
- 画像の取扱用ライブラリ
- https://pillow.readthedocs.io/en/5.1.x/
- diskcache
- キャッシュ用ライブラリ(HTMLからメイン画像を取得するために画像から画像サイズを取得する時のキャッシュに利用)
- https://pypi.org/project/diskcache/
- BeautifulSoup4
- スクレーピングに利用される代表的ライブラリ。HTMLからimgタグを抽出するために利用
- https://www.crummy.com/software/BeautifulSoup/bs4/doc/
- https://qiita.com/itkr/items/513318a9b5b92bd56185
- janome
- 日本語の形態素解析に利用。別にmecabでもよかったんだけど、なんとなく新しいのがあるから使って見たら、とても使い勝手がよかった。前処理・後処理を簡単に組み込めるのが良い
- http://mocobeta.github.io/janome/
- https://ohke.hateblo.jp/entry/2017/11/02/230000
- NEologd
- nltk
- 英語の形態素解析に利用
- https://www.nltk.org/
- gensim
- 文書間の類似度計算のためにDoc2Vecを利用
- https://radimrehurek.com/gensim/
- https://deepage.net/machine_learning/2017/01/08/doc2vec.html
PHP系
フロントは適当なので、てなりで書けるPHP。
- Klein
- ルーター。2つしかパスがないので使い勝手はわからないw
- https://github.com/klein/klein.php
- Twig
- テンプレートエンジン。まだSmartyとかあるのかな。わからぬ。
- https://twig.symfony.com/
- php-paginator
- ページネートするためのライブラリ
- https://github.com/jasongrimes/php-paginator
その他
- bootstrap
- なんか4になってた。
- https://getbootstrap.com/
- Images.weserv.nl
- 画像のキャッシュとリサイズをしてくれるプロキシーサービス
- https://images.weserv.nl/
- PlaceIMG
- 画像が抽出できなかった時のダミー画像。
- https://placeimg.com/
- Google Favicon service