蛇ノ目の記

技術のことも。そうでないことも。

アイドルで理解するTF-IDF データ準備編 - アメブロを攻略する

ども。

一度もブログを書くことなく8月が過ぎてた。

もう9月ってやばくないですか。やばいです。

今回は9/11(水)のみんなのPython勉強会 #49でLTする予定の「アイドルで理解するTF-IDF」のデータ準備編。

TL;DR

今回やりたいこと

sora tob sakana official blogから記事のタイトル, 投稿日時, 記事本文をスクレイピングする。

スクレイピングした情報はCSVにまとめる。

ブログ記事の見た目はこんな感じ。

f:id:Nao_Y:20190907173221p:plain

※ 風花ちゃんのブログを例に出したけど今回は山崎愛ちゃんのブログ記事を対象にする。

ぱっと見、Requestsでレスポンスボディを取ってきてBeautifulSoupに喰わせてやればよさそうに見えるけど、実際にそれで取得できるのはタイトルと記事本文のみ。なぜ投稿日時がJavaScriptで描画されているのか。わからない。

山崎愛ちゃんのブログを選んだ理由

「基本的に1記事につき1つの話題」だから。TF-IDFで記事の特徴語を取ったときに、特徴語が記事のテーマと一致しているかの判断が簡単なように思えた。

あとオサカナのオタクとして、毎日更新されるのを楽しみにしてるから。愛ちゃんはここ数ヶ月毎日更新している。その原動力は知らんけど。

風花ちゃん最近割と更新が多いのでとても嬉しい。

ヘッドレスブラウザを導入する

ヘッドレスブラウザを使ってJavaScriptが実行された後のソースコードを使えばよさそう、ということでその方法を探す。今回はHeadless Chromeを使う。

ブラウザを実際に動かすということでまず必要になるのはSelenium。さくっと入れる。

$ pip install selenium

Chromeのドライバーのパスを指定してやる必要があるけど、chromedriver_binaryを使うとパスを通してくれる。便利かよ。

To use chromedriver just import chromedriver_binary. This will add the executable to your PATH so it will be found.

pypi.org

注意したいのは手元のChromeのバージョンに合ったchromedriver_binaryのバージョンをインストールする必要があるということ。

自分の手元では76.0.3809.132だったのでこれに合ったバージョンをインストールする。

$ pip install chromedriver_binary==76.0.3809.126.0

Chromeをヘッドレスモードで使うときはChromeのオプションに--headlessを指定する。

コードを書く

import chromedriver_binary # 手元の環境に合わせて76.0.3809.126.0を使う
from bs4 import BeautifulSoup as bs
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

def init_driver() -> webdriver.Chrome:
    options = Options()
    options.add_argument('--headless')
    driver = webdriver.Chrome(options=options)
    return driver


def get_soup(driver: webdriver.Chrome, url: str) -> bs:
    driver.get(url)
    html = driver.page_source
    return bs(html, 'html.parser')

def main():
    # 2019/09/06の山崎愛ちゃんの記事
    url = 'https://ameblo.jp/soratobsakana/entry-12521726090.html' 
    driver = init_driver()
    soup = get_soup(driver, url)

基本形としてはこれで以上。どうでもいいけど今回からType Hintingを導入してみた。

後はいい感じに前処理してCSVで保存する。

詳細は https://github.com/NaoY-2501/tf_idf_with_idol/blob/master/blog_scraper.py を参照のこと。

アイドルで理解するTF-IDFのリポジトリは以下。

github.com

↑を見られるとまだTF-IDFが実装できてないことがバレる。

次回は環境構築編を書く予定。

Notebookを見やすくするCSSを書いた

sora tob sakana東名阪ツアー大阪公演に遠征した@大阪

どういうわけかWiFiが入らない梅田のスタバで書いている。なにが悲しくてスタバでテザリングしないといけないのか…。

というのは置いておいて、近頃Colaboratoryが便利だなぁと思ってるんですが、どうにもフォントサイズが小さかったり行間が狭かったりしてコードが書きづらい、読みづらい。 そういうことを嘆いていたら、kashewさんからStylusを使うのがよさそうと助言を受けたのでChromeに入れてColaboratoryの見た目をいじることにした。kashew++。

chrome.google.com

というわけでCSS

gist.github.com

これを入れるとこうなるというのが以下のツイート。

ちなみにJupyter Notebookにも対応しているので、Colaboratory使ってないけどNotebookのコードが読みづらいなと感じてる人もこのCSSを試してみてくれよな。

iframeになっているアウトプット部分も調整しようと試行錯誤中。なにかいい方法あったら教えてください。

ダウンロードリンクからCSVをスクレイピングしてDataFrameにする - Spotify Charts編

少し前にこのブログでも取り上げたSpotify Charts。各国のヒットチャート・バイラルチャートが公開されている見ていて飽きないWebサービス

Spotify ChartsではチャートをCSV形式でダウンロードできる。そのCSVスクレイピングしてDataFrameにしてみる。ついでに順位の推移ごとにグルーピングして可視化めいたこともやってみた。

Notebook

今回のコードはColaboratoryで閲覧できるようにしている。Colaboratory便利かよ……。

colab.research.google.com

DataFrame作成

今回は日本のSpotify最新ヒットチャートのダウンロードリンク(https://spotifycharts.com/regional/jp/daily/latest/download)を扱う。Notebookの実行では2016/06/02のチャートになっている。

import io

import pandas as pd
import requests

from bs4 import BeautifulSoup as bs

res = requests.get('https://spotifycharts.com/regional/jp/daily/latest/download')

content = res.content

df = pd.read_csv(io.BytesIO(content))

バイト列で取得されるCSVの中身をBytesIOを使ってファイルオブジェクトの代わりにしている。この手法は以下のブログを参考にした。

www.haya-programming.com

1行目には Note that these figures are generated using a formula that protects against any artificial inflation of chart positions. というメッセージが入っているので、スキップする。

f:id:Nao_Y:20190604191620p:plain

df = pd.read_csv(io.BytesIO(content), skiprows=[0])

これでチャートのDataFrame化は完了。簡単。

f:id:Nao_Y:20190604191733p:plain

順位の推移をDataFrameに加える

ここからは作成したDataFrameに情報を足してゆく。

Spotify Chartsでは順位の推移を表す記号が表示されているが、この情報はCSVに含まれていないのでスクレイピングしてDataFrameに追加する。

記号はSVGで描画されているので、BeautifulSoupでHTMLをパースして順位の推移を取得する。

res = requests.get('https://spotifycharts.com/regional/jp/daily/latest/')
soup = bs(res.content)

svg_tags = soup.find_all('svg')

get_status関数でSVGタグのブロックから上昇、下降、維持、新規を判定する。 polygon属性の値などはブラウザのDeveloperToolでのぞき見た。

def get_status(svg):
  up = '0 9 12 9 6 3 '
  down = '12 3 0 3 6 9 '
  if svg.find('polygon'):
    polygon = svg.find('polygon')['points']
    if polygon == up:
      return 'up'
    elif polygon == down:
      return 'down'
  elif svg.find('rect'):
    return 'keep'
  elif svg.find('circle'):
    return 'new'

status = []
for svg in svg_tags:
  status.append(get_status(svg))

そしてDataFrameに列を追加。

df['status'] = status

f:id:Nao_Y:20190604192528p:plain

Spotify IDをDataFrameに追加

Simple-Spoitfyを使って曲の特徴情報を取得したくなるかもしれないので楽曲ごとに固有のSpotify IDをDataFrameに追加する。

df['Spotify ID'] = df['URL'].apply(lambda x: x.split('/')[-1])

f:id:Nao_Y:20190604192912p:plain

順位の推移をグルーピングして可視化してみる

順位の推移(status)の値でグルーピングして、推移の仕方による順位のQuantileをまとめる。

グルーピングしてからQuantileを求める手法は以下を参考にした。

stackoverflow.com

status_group = df.groupby(by='status')
positions = status_group['Position'].quantile([0.05, 0.25, 0.5, 0.75, 0.9]).unstack()

最後に推移ごと(上昇, 下降, 維持, 新規)による順位の分布を折れ線グラフにする。

positions.T.plot(figsize=(15, 10), title='Distribution of position change at 2019/06/02');

f:id:Nao_Y:20190604193827p:plain

雑可視化からなんとなくわかること。

  • 順位の維持は100位以下の順位では起きていない

  • ランキング初登場の曲は175~200位の間にしかランクインしていない

  • 初登場で上位に食い込む曲は無い

  • 順位の上昇・下降の約25%は20~70位内で起きている

Spotify Charts面白い

2017/01/01からのチャートが蓄積されているので、その時々の流行りや根強く聴かれている曲を見つけることができ面白い。 ここ半年くらいの日本のチャートを見ると、あいみょん人気がやばいことがわかる。聴いたことないけど。

チャートは2種類あり、TOP 200 はストリーミング数の蓄積によるランキング。一方、VIRAL 50SNSの分析で集計しているランキングらしい。 VIRAL 50の集計方法については以下のフォーラムで議論されている。

community.spotify.com

Spotify Web APIを使えば楽曲の特徴情報が得られるので、人気のある曲が持つ特徴を可視化することもできる。 そこで活きてくるのがSpotify Web APIPythonで使いやすくすることを目的にしているSimple-Spotify(ダイマ)。

PyCon mini Sapporo 2019で登壇した - Simple-Spotify: Pythonでシンプルに楽曲データを扱うライブラリ

5月11日に札幌で開催されたPyCon mini Sapporo 2019で登壇した。

写真撮る人がめっちゃ上手くて影とかいい感じになっててすごい。正装(オサカナTシャツ)もばっちり写っている。

テーマは3ヶ月くらい前から作っていたSpotify Web APIのラッパーライブラリSimple-Spotify。懇親会ではUKロック好きな@shinyorkeとSimple-Spotifyのいい感じな使い方の話で盛り上がった。興味を持ってもらえてとても嬉しかった。

github.com

developer.spotify.com

Spotify Web APIのラッパーライブラリとしてはSpotipyがあるが、requestsに依存しているそれとは異なりSimple-SpotifyはPure Pythonで実装している。 また、SpotipyがAPIのレスポンスを辞書形式で扱っているのに対してSimple-Spotifyではクラスとして扱っている。

Simple-SpotifyとSpotipyの違いを見比べてみる。ここからは発表にはない話になる。

Spotipy

import spotipy
sp = spotipy.Spotify()

results = sp.search(q='weezer', limit=20)
for i, t in enumerate(results['tracks']['items']):
    print ' ', i, t['name']

Simple-Spotify

from simple_spotify.api import Spotify
from simple_spotify.authorization import ClientCredentialsFlow

res = ClientCredentialsFlow.token_request(CLIENT ID, CLIENT_SECRET)
auth = ClientCredentialsFlow(**res)
sp = Spotify(auth)

results = sp.search(q='sora tob sakana', market='JP')
for album in results.albums.items:
    print(album.name)
sora tob sakana
World Fragment Tour
New Stranger
alight ep
cocoon ep
魔法の言葉
夜空を全部
アルファルド

sora tob sakanaはいいぞ。

Spotipyでは認証を行っていないんだけど、Web APIのドキュメントにはすべてのリクエストは認証が必要、と書いてあるのでサンプルコードが古い可能性がある。

Authentication

All requests to Web API require authentication.

https://developer.spotify.com/documentation/web-api/

認証を含めなければ行数はほぼ同じ。 検索結果(results)オブジェクトにアルバム(albums)属性があって、さらにそれぞれのアルバム(items)がある、というのはそこそこ直感的でないかと思っている。

認証について言えば、認証用のエンドポイントからのレスポンスを一度受け取ってから、それをアンパックして認証クラスに渡すというのはちょっとわかりにくい流れになってしまった。 本当のところを言えば、認証用クラスにClient IDとClient Secretを渡せばOKという風にしたかったのだけど、もう一つの認証をWebアプリ上で使えるようにする関係でそれと同じ流れにした。

これがFlaskで作成したSimple-Spotifyを使った簡単なWebアプリ。

@app.route('/')
def top():
    if 'response' in session.keys():
        auth = AuthorizationCodeFlow(**session['response'])
        sp = Spotify(auth)
        user = sp.get_current_user_profile()
        top_artists = sp.get_users_top('artists', time_range='medium_term')
        artists = top_artists.items
        top_tracks = sp.get_users_top('tracks', time_range='medium_term')
        tracks = top_tracks.items

        return render_template('index.html', name=user.display_name, artists=artists, tracks=tracks)
    else:
        seed = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
        url = access_authorize_page(CLIENT_ID, REDIRECT_URI, SCOPES, seed)
        return render_template('index.html', auth_url=url)


@app.route('/callback/')
def callback():
    """
    callback
    """
    session['response'] = AuthorizationCodeFlow.token_request(
        CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, request.args['code'])
    return redirect(url_for('top'))

Spotifyへのログインをしてからアクセストークンを受け取るのだけど、アクセストークンの取得をtop()で行ってしまうとリロードやページ遷移の度にトークンを取りに行こうとしてAPIからエラーが返ってきてしまった。これを避けるためにコールバックでアクセストークンなどを受け取って、セッションに格納。top()でそれを使うという流れにした。 これには認証方法によってSimple-Spotifyの使い方に違いが出てしまうのを避けたかったという思いがある。

と言い訳めいたことを書いてから、実際にSimple-Spotifyの使い方などを書きたいと思うのだけど寒いのでしばらくしてから続きを書く。 なにしろこんなところで書いているので風が冷たい。

みんなのPython勉強会#44を企画・登壇した - 正規表現の話

4/10に開催されたみんなのPython勉強会#44(stapy)を企画・登壇した。

平成が終わる前に書かないと、と思いつつここまで先延ばしにしてしまった。でもまだ平成なのでギリギリセーフということでどうかひとつ。

startpython.connpass.com

平成最後のstapyということで平成生まれである自分が主導し、自分を含めた6人の平成生まれPythonistaによるショートトーク会を企画した。

登壇してくれた皆さんありがとうございました。

今回の内容はPythonにおける正規表現\w (any word character) の振る舞い。

GitPitch Presents: github/NaoY-2501/GitPitch-Slides

Modern Slide Decks for Developers on Linux, OSX, and Windows 10. Preview and present offline. Publish and share online.

以下のリポジトリ\wUnicodeのLetter, Numberカテゴリのうちどれだけの文字にマッチするか、Punctuationカテゴリではアンダースコア以外にマッチしないかを確認した。

GitHub - NaoY-2501/stapy44_re_unicode_checker

Letterカテゴリの中でもマッチしない文字がある。

https://www.fileformat.info/info/unicode/category/ ではLetterカテゴリに分類されているが、unicodedataモジュールで確認するとCn(Other or not assigned)となっている。

>>> import unicodedata
>>> unicodedata.category('\ua7ba')
'Cn'

つまり、公式ドキュメントにある通りであることがわかる。

ユニコードパターンに対しては、 \w は unicodedata モジュールで提供されている Unicode データベースで letters としてマークされている全ての文字とマッチします。

正規表現 HOWTO — Python 3.7.3 ドキュメント

Special Thanks

Thank you for speakers !!

nikkie(@ftnext): P(ython)&I 〜最初の落とし穴を避け、成功体験を積むために〜

山下 卓将(@xxx_boy): Python と挑んだtitanic ~101回目のsubmit~

大島和輝(@shimakaze_soft): FlaskとDjango以外のAPI開発の選択肢

みずき(@mizzsugar0425): DjangoとPyramidで同じアプリ作った話

清原 弘貴(@hirokiky): 無題 (僕の平成について話します)

pyhack冬山合宿に行ってきた - SpotifyチャートにTF-IDFを使ってみた話(概要編)

1/19(金)~1/21(日)にかけてpyhack冬山合宿に行ってきた。

気づいたらもう3月。記事を書くのがかなり遅れてしまった。

pyhack.connpass.com

@takanoryによるTogetterまとめはこちら。Python mini Hack-a-thon 雪山合宿 2019 - Togetter

昨年はsiraphのライブがあったので2日目の昼に帰ってきたんだけど、今年は3日間通しで参加した。

合宿でやったこと

Spotifyのチャートに対して、文書の特徴量を求めるのに使われるTF-IDFを使って国ごとに特徴的な曲を分析してみる、ということをやってみた。

グローバルチャートに対する国ごとにユニークな曲を抽出するなら、グローバルチャートと各国チャートの差集合を取るだけでいいのでは、という向きもあると思うけど、まあ思いつきでやったことなので多めに見てほしい。

Spotify Charts

Spotify Chartsでグローバル・各国の人気曲チャートが公開されている。チャートには以下の2種類がある。

  • Top 200: ストリーミング数による集計。上位200曲が公開されている。

  • Viral 50: SNS上での人気を基にした集計。上位50曲が公開されている。

Viral 50の集計に関しては以下のフォーラムで議論されているが、おそらくソーシャルメディアを分析したものとのこと。

https://community.spotify.com/t5/Content-Questions/Viral-50-Playlists-How-do-they-work/td-p/1627778

今回は2種類のチャートそれぞれを対象とした。

データの取得

前回の記事で紹介したSpotify APIではチャートの取得ができないため、Spotify Chartsを使うことにした。

Spotify Charts Top200, Viral 50のWebページをスクレイピングしてチャートを取得した。

URLはTop 200ならhttps://spotifycharts.com/{{ country code }}/, Viral 50ならhttps://spotifycharts.com/viral/{{ country_code }}/とシンプルな作りになっているのでスクレイピングが容易。

{{ country_code }}ISO 3166-1 alpha-2 - Wikipediaに基づいている。ただし、グローバルチャートの場合はglobalとなる。

国によってはTop 200やViral 50が取得できない場合がある。このとき、レスポンスヘッダに'Content-Encoding'が含まれる。これを考慮しておかないとチャートの取得でコケるので注意が必要。

TF-IDF

TF-IDFは文書の特徴量として使われる指標。文書中に出現する単語の重要度を評価できる。

TFとIDFは以下のように説明できる。

  • TF(Term Frequency): 文書dにおける単語tの出現頻度

  • IDF(Inverse Document Frquency): 逆文書頻度。この値が高ければ単語tの出現頻度が低いということになる。

TF-ICF

TF-IDFが文書の特徴量なら、今回考えるのはトラックの特徴量だろうということで、TF(Track Frequency), ICF(Inverse Chart Frequency)と呼び替えることにする。


実際のコードの話は別の記事で。

SpotifyのAPIを使ってみた

最近Spotifyを契約し始めたのだけど、Spotifyには曲やアルバム、プレイリスト、アーティストの情報を取得したりできるAPIがあるとのことなので試してみた。

Spotipyというラッパーライブラリがあるようだけど、今回はそれを使わずにrequestsを使ってAPIを使ってみる。

アプリの登録

APIを使うにはFreeかPremiumのユーザーである必要がある。登録はhttps://www.spotify.com/jp/から。

ユーザー登録したらダッシュボードページにログインして、アプリ登録をする。

f:id:Nao_Y:20190117234242p:plain

作成したアプリをクリックする。

f:id:Nao_Y:20190117234313p:plain

Client IDSHOW CLIENT SECRETを押して表示されるClient Secretを控えておく。

認証フロー

Spotify APIには3つの認証フローがある。

  • Refreshable user authorization: Authorization Code Flow

長期間稼働するアプリ向けの認証。エンドユーザーは一度だけアプリがリソースにアクセスすることを許可する。アプリにはリフレッシュ可能なアクセストークンが提供される。バックエンドなど安全な場所から実行する。

  • Temporary user authorization: Implicit Grant

JavaScriptを使用するため、リソース所有者のブラウザ上で動作するアプリ向けの認証。

  • Refreshable app authorization: Client Credentials Flow

サーバー間認証で使用される。エンドユーザーのリソースにアクセスしないエンドポイントのみ使用可能。

参考:

アクセストークンの取得

今回はClient Credentials Flowを使う。この認証方法ではClient IDとSecret Keyを使ってAccess Tokenを取得する。

  • Endpoints: POST https://accounts.spotify.com/api/token

  • Request Body Parameter: grant_type(client_credentialsを設定する)

  • Header Parameter: Basic <base64 encoded client_id:client_secret>

import requests

CLIENT_ID = 'YOUR CLIENT ID'

CLIENT_SECRET = 'YOUR CLIENT SECRET'

GRANT_TYPE = 'client_credentials'

TOKEN_URL = 'https://accounts.spotify.com/api/token'

body_params = {'grant_type': GRANT_TYPE}

auth = requests.post(TOKEN_URL, data=body_params, auth=(CLIENT_ID, CLIENT_SECRET)).json()

requestsでHTTP Basic auth(Basic認証)のあるURLにアクセスするときはキーワード引数auth(User, Password)とする。

この後、他のAPIで認証をするときにアクセストークンを使うので辞書形式でレスポンスを取得している。

参考:

アルバムを検索する

  • Endpoints: GET https://api.spotify.com/v1/search

  • Header Fields: Authorization

    • Value: 有効なアクセストークン。Bearer {{access token}}の形式。
  • Query Parameter(必須のものだけ抜粋)

    • q : 検索クエリ

    • type: 検索タイプ。有効な値はalbum, artist, playlist, trackでリストで指定可能

参考:

sora tob sakanaのアルバム・シングルを検索してみる。

header_params = {'Authorization': 'Bearer {}'.format(auth['access_token'])}

query_params = {'q': 'sora tob sakana', 'type': ['album']}

ENDPOINT = 'https://api.spotify.com/v1/search'

res = requests.get(ENDPOINT, headers=header_params, params=query_params)

for each in res.json()['albums']['items']:
    artist = each['artists'][0]
    url = each['external_urls']['spotify']
    print('{}: {} {} ({})'.format(artist['name'], each['name'], each['release_date'], url))
sora tob sakana: sora tob sakana 2016-07-26 (https://open.spotify.com/album/3cJxQS1oi8u2oBu9uhVyEk)
sora tob sakana: cocoon ep 2017-04-11 (https://open.spotify.com/album/467R6azC01pl67nSrLS55x)
sora tob sakana: mahou no kotoba 2016-02-16 (https://open.spotify.com/album/6x0xVz6hdHerGMf2yaMqLj)
sora tob sakana: yozora wo zenbu 2015-10-27 (https://open.spotify.com/album/2FezsFEuQFferFLy4syK4T)

どうしたことか。2017-04-11以降の作品が取得されない。よく見るとタイトルがアルファベット表記になっている。日本国内の設定になっていないっぽい。marketパラメータを指定してみる。このパラメータはISO 3166-1 alpha-2 country codeで表記する。日本はJP

header_params = {'Authorization': 'Bearer {}'.format(auth['access_token'])}

query_params = {'q': 'sora tob sakana', 'type': ['album']}

ENDPOINT = 'https://api.spotify.com/v1/search'

res = requests.get(ENDPOINT, headers=header_params, params=query_params)

for each in res.json()['albums']['items']:
    artist = each['artists'][0]
    url = each['external_urls']['spotify']
    print('{}: {} {} ({})'.format(artist['name'], each['name'], each['release_date'], url))

2017-04-11以降の作品が表示されて、タイトルも日本語表記になった。日本のアーティストを検索するならmarketJPを指定するのがよさそう。海外勢はalight epとかNew Stranger聴けないのかー。

sora tob sakana: sora tob sakana 2016-07-26 (https://open.spotify.com/album/6wy5MYR1ThaC7IuQu3VHHG)
sora tob sakana: New Stranger 2018-07-25 (https://open.spotify.com/album/1ATJgjPZ7Qz0LAUZtMW2Sp)
sora tob sakana: alight ep 2018-05-16 (https://open.spotify.com/album/36w9htd6tgXmNs0iPWte2P)
sora tob sakana: cocoon ep 2017-04-11 (https://open.spotify.com/album/3nN8No2a5ZhzK2xw9RIYQp)
sora tob sakana: アルファルド 2018-11-23 (https://open.spotify.com/album/3nA6Sd5gDIY8xHiVQQ3kvE)
sora tob sakana: 魔法の言葉 2016-02-16 (https://open.spotify.com/album/0UejaOY3nZq4h3fZ2be8lA)
sora tob sakana: 夜空を全部 2015-10-27 (https://open.spotify.com/album/03JuPAwApUfvx61cHvUmOJ)

APIを使ってアルバムを検索することができたので、この辺りで一旦区切りをつけておく。

今後はGET https://api.spotify.com/v1/searchにおけるワイルドカードの使い方(例えば、SawanoHiroyuki[nZk]:~をワイルドカードで検索)やプレイリストの検索を調べてみたい。