🏆

JuliaでZennをスクレイピングしてランキングを作ったよ

2022/10/07に公開約10,700字1件のコメント

2022年10月7日現在のJuliaにタグ付けされた記事に限定して

  • ユーザー別の記事数ランキング
  • ユーザー別のいいね数ランキング
  • 記事別のいいね数ランキング

を可視化します. 目的は

  • スクレイピングの勉強
  • Juliaの記事をよく書いているユーザーを見つけたい
  • 申請書や面接などで自分の記事がどれだけ評価されているか定量的にアピールするため

などです. この記事の想定読者は

  • HTMLはわかる
  • Juliaもわかる
  • スクレイピングはやったことがない

という方です. 下記の記事を参考にZennをスクレイピングしていきます.

https://julialang.jp/2022/03/26/scraping_zenn/

Zennの管理者様へ

スクレイピングが利用規約に反しておりましたら直ちに停止し, この記事も削除いたします. 今回の記事では最終版では6リクエストに抑えました. サーバーに大きな負荷をかけないよう心掛けておりますが, ご迷惑をおかけしてしまった際にはお詫び申し上げます.

パッケージ・実行環境

インストールし, usingを宣言しておきます. 上記の参考記事とは違い, using DataStructuresusing Plots; Plots.plotly()を加えていますが, これはデータの分析と可視化のためです. 今回はPlots.jlのバックエンドにplotlyを使いました. 手動ではありますが, ノートブック上で保存ボタンを押せばPNGで保存できます.

# using Pkg
# Pkg.add("HTTP")
# Pkg.add("Gumbo")
# Pkg.add("Cascadia")
# Pkg.add("DataStructures")
# Pkg.add("Plots")

using HTTP
using Gumbo
using Cascadia
using DataStructures
using Plots; Plots.plotly()

versioninfo()

Julia Version 1.7.1
Commit ac5cc99908 (2021-12-22 19:35 UTC)
Platform Info:
OS: Windows (x86_64-w64-mingw32)
CPU: Intel(R) Core(TM) i7-4650U CPU @ 1.70GHz
WORD_SIZE: 64
LIBM: libopenlibm
LLVM: libLLVM-12.0.1 (ORCJIT, haswell)

ZennのHTML

ZennでJuliaのタグが付けられた記事リストはこんな風になってます.

1つの記事だけ抜き出したHTMLがこちらです.

<div class="ArticleList_itemContainer__xlBMc">
  <article class="ArticleList_container__JDK24"><a class="ArticleList_emoji__l2Rso" href="/ohno/articles/78c194cab16fe1" style="background: var(--c-primary-bg);"><span class="Emoji_twemoji__mFta9"><span class="Emoji_twemojiImg__Imjtw" style="background-image: url(&quot;https://asia-northeast1-zenn-dev-production.cloudfunctions.net/twemoji/🍎.svg&quot;);"></span></span><span class="Emoji_nativeEmoji__JRjFi">🍎</span></a>
    <div class="ArticleList_content__i6AQy">
      <a class="ArticleList_link__vf_6E" href="/ohno/articles/78c194cab16fe1">
        <h2 class="ArticleList_title__P6X2G">Juliaで学ぶLangevin方程式</h2>
      </a>
      <div class="ArticleList_user__FR8ks">
        <div class="ArticleList_avatar__oc2gR"><a href="/ohno"><img src="https://res.cloudinary.com/zenn/image/fetch/s--qfJZVnsg--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/7968fb22f1.jpeg" alt="大野 周平" class="AvatarImage_plain__BCJNs " width="26" height="26" loading="lazy" referrerpolicy="no-referrer"></a></div>
        <div class="ArticleList_userInfo__uTs5A ">
          <div class="ArticleList_userName__GWXDx"><a href="/ohno">大野 周平</a></div>
          <div class="ArticleList_meta__E1zr4"><time class="ArticleList_date__L543S" datetime="2022-09-30T21:59:10+09:00">6日前</time><span class="ArticleList_like__c4148"><svg viewBox="0 0 110 110" width="14" height="14" aria-label="いいねされた数"><path fill="currentColor" d="M77.5 12.2c-7.3 0-14.4 2.8-19.8 7.7-1.5 1.3-3.7 1.3-5.2 0-5.4-5-12.5-7.7-19.9-7.7C17.3 12.2 5 24.5 5 39.7c0 20.8 24.5 41 31.4 47.4 3.5 3.3 7.7 7 11.1 10.2 4.2 3.8 10.6 3.8 14.9 0 3.5-3.1 7.6-6.8 11.1-10.2 6.9-6.4 31.4-26.6 31.4-47.4.1-15.2-12.2-27.5-27.4-27.5zm-8.4 65.4c-.9.8-1.6 1.5-2.3 2.1-3.1 2.9-6.7 6.2-9.9 9.1-1.1 1-2.7 1-3.7 0-3.1-2.9-6.7-6.1-9.9-9.1-.6-.6-1.4-1.2-2.3-2.1-7.1-6.4-26-23.4-26-37.9 0-9.7 7.8-17.5 17.5-17.5 5.5.1 10.8 2.4 14.6 6.5l5.9 6.2c1 1.1 2.8 1.2 3.9.2.1 0 .1-.1.2-.2l5.9-6.2c3.7-4.1 9-6.5 14.6-6.5C87.1 22.2 95 30 95 39.7c0 14.5-18.9 31.5-25.9 37.9z"></path></svg> 8</span></div>
        </div>
      </div>
    </div>
  </article>
</div>

HTMLは特に問題なく解析できそうですが, 記事の一覧が複数のページに分かれている点だけ気を付けないといけませんね.

1つのページの記事リストを取得・情報を抽出する流れ

まず1つのページの記事リストを取得する一連の流れは以下のようになります. いいね数が0の記事だけ例外処理をしておきます.

url = "https://zenn.dev/topics/julia?page=4"

response = HTTP.request("GET", url) # HTTPリクエスト(GETメソッド)
@show response.status               # リクエストの状態

code = String(response.body) # Byte列を文字列に変換
doc  = Gumbo.parsehtml(code) # HTMLのパース
head = doc.root[1]           # <head>~</head>
body = doc.root[2]           # <body>~</body>

articles = eachmatch(Selector(".ArticleList_itemContainer__xlBMc"), body) # 記事の一覧の取得
@show length(articles)                                                    # 記事の数の表示
for article in articles
    title  = eachmatch(Selector(".ArticleList_title__P6X2G"), article)[1][1].text                                  # タイトルの<h2>この部分</h2>
    author = eachmatch(Selector(".ArticleList_userName__GWXDx"), article)[1][1][1].text                            # 作者の<div><a>この部分</a></div>
    time   = eachmatch(Selector("time"), article)[1][1].text                                                       # 投稿日の<time>この部分</time>
    good   = try parse(Int,eachmatch(Selector(".ArticleList_meta__E1zr4"), article)[1][2][2].text); catch; 0 end # いいね数の<div><time>~</time><span><svg>~</svg>この部分</span></div>
    println(title)
    println(author)
    println(time)
    println(good)
end

response.status = 200
length(articles) = 28
Scala,・Julia・R が使えるJupyter lab のテンプレを作った.
110416
2021/07/20
3
Juliaで線形代数:固有値問題
大野 周平
2022/01/29
1
Plots.jlで凡例のデザインを変更する
大野 周平
2021/08/25
2
Juliaで文字列の幅を取得する
大野 周平
28日前
3

全てのページの記事を取得する

2022年10月7日現在, https://zenn.dev/topics/julia?page=5 には記事リストはありません. 以下のように, 最初のページからスタートして, そのページの記事の数が0でなければ次のページの記事も取得し, 今までの結果と結合して返すような再帰構文を書きます. 記事の数が0の場合は, 空の結果を返します.

function getArticles(; page=1)
    url = "https://zenn.dev/topics/julia?page=$page"
    response = HTTP.request("GET", url)                                    # HTTPリクエスト(GETメソッド)
    code = String(response.body)                                           # Byte列を文字列に変換
    doc  = Gumbo.parsehtml(code)                                           # HTMLのパース
    head = doc.root[1]                                                     # <head>~</head>
    body = doc.root[2]                                                     # <body>~</body>
    items = eachmatch(Selector(".ArticleList_itemContainer__xlBMc"), body) # 記事の一覧の取得
    if length(items) == 0
        return items
    else
        return append!(items, getArticles(page=page+1))
    end
end

こちらの関数を呼び出せば全件取得できます. たしかに172件, 現在の全記事数と一致しています.

articles = getArticles()

172-element Vector{HTMLNode}:
HTMLElement{:div}:<div class="ArticleList_itemContainer__xlBMc">

記事数ランキング

まずは著者を抜き出して配列に格納しましょう.

# articles = getArticles()
authors = []
for article in articles
    author = eachmatch(Selector(".ArticleList_userName__GWXDx"), article)[1][1][1].text                 # 作者の<div><a>この部分</a></div>
    push!(authors, author)
end

@show authors
172-element Vector{Any}:
 "大野 周平"
 …
 "Hiroshi Shinaoka"

次にこれを数えてランキング化しましょう.

# using DataStructures
authors = counter(authors)
authors = sort(collect(authors), by=x->-x[2])
println("位\t記事\tユーザー")
for i in axes(authors,1)
    author = authors[i]
    println(i, "\t", author[2], "\t", author[1])
end

位 記事 ユーザー
1 27 大野 周平
2 24 たきろぐ
3 21 清水団
4 15 ごまふあざらし
5 13 Hyrodium
6 7 hessihan
7 4 hoxo_m
8 4 mk83
9 3 富谷昭夫
10 3 Hiroshi Shinaoka
11 3 KB砂糖
12 3 hctaw_srp
13 3 mao
14 3 Jasmine
15 3 yng87
16 2 ピクリン酸
17 2 みぽ
18 2 piruty
19 2 I_ppp
20 2 りぐん
21 2 こーた
22 2 Shuhei Kadowaki
23 1 Lirimy
24 1 uchkw
25 1 matsutakk
26 1 110416
27 1 SGThr7
28 1 ogty
29 1 ho-oto
30 1 YukiSato
31 1 HIMURA Tomohiko
32 1 Yuki Sato
33 1 Mizuto Kadowaki
34 1 watosar
35 1 suisoisu
36 1 Takanori Fukuyama
37 1 indigo13love
38 1 toratti_chanteur
39 1 tenfu2tea
40 1 otwn
41 1 DS管理栄養士
42 1 unkown_yuser
43 1 skypenguins
44 1 regonn

これを可視化します.

# using Plots; Plots.plotly()
x = []
y = []
others = 0
for i in axes(authors,1)
    author = authors[i]
    push!(x, author[1])
    push!(y, author[2])
end
bar(y, orientation=:h, yticks=(axes(y,1), x), ylims=(-0.2,length(y)+0.8), yflip=true, label="", xlabel="記事数", title="記事数ランキング", size=(1000,1000))

総いいね数ランキング

まずユーザー名の一覧を作ります. まず, 先ほどのプログラムの, 記事数を除去して純粋にユーザー名だけの配列を作ります. 次に, 全ての記事におけるいいね数をユーザーごとに振り分けて足していきます.

function sortDict(dict)
    dict = sort(collect(dict), by=x->-x[2])
    x = []
    y = []
    others = 0
    for i in axes(dict,1)
        item = dict[i]
        push!(x, item[1])
        push!(y, item[2])
    end
    return x, y
end

function getSortedAuthors(articles)
    authors = []
    for article in articles
        author = eachmatch(Selector(".ArticleList_userName__GWXDx"), article)[1][1][1].text                 # 作者の<div><a>この部分</a></div>
        push!(authors, author)
    end
    authors = counter(authors)
    x, y = sortDict(authors)
    return x
end
# articles = getArticles()
authors = getSortedAuthors(articles)
dict = Dict()
for i in axes(authors,1)
    dict[authors[i]] = 0
end
for article in articles
    author = eachmatch(Selector(".ArticleList_userName__GWXDx"), article)[1][1][1].text                            # 作者の<div><a>この部分</a></div>
    good   = try parse(Int,eachmatch(Selector(".ArticleList_meta__E1zr4"), article)[1][2][2].text); catch; 0 end # いいね数の<div><time>~</time><span><svg>~</svg>この部分</span></div>
    dict[author] += good
end
x, y = sortDict(dict)
bar(y, orientation=:h, yticks=(axes(y,1), x), ylims=(-0.2,length(y)+0.8), yflip=true, label="", xlabel="いいね数", title="総いいね数ランキング",  size=(1000,1000))

人気記事ランキング

以上の2つはユーザー別のランキングでしたが, 最も読まれている記事を可視化しましょう. 127記事はグラフに入りきらないので, 上位40記事に限定します.

# articles = getArticles()
dict = Dict()
for article in articles
    title  = eachmatch(Selector(".ArticleList_title__P6X2G"), article)[1][1].text                                  # タイトルの<h2>この部分</h2>
    good   = try parse(Int,eachmatch(Selector(".ArticleList_meta__E1zr4"), article)[1][2][2].text); catch; 0 end # いいね数の<div><time>~</time><span><svg>~</svg>この部分</span></div>
    dict[title] = good
end
x, y = sortDict(dict)
bar(y, orientation=:h, yticks=(axes(y,1), x), ylims=(-0.2,40+0.8), yflip=true, label="", xlabel="いいね数", title="人気記事ランキング", size=(1000,1000))

参考文献

この記事の元になったノートはGistに上げておきました.

https://gist.github.com/ohno/f65044ba68a1006fd2cdbb7f821f4a40

Discussion

スクレイピングについて、よく参考になりました。ありがとうございます。

ログインするとコメントできます