Skip to content

Instantly share code, notes, and snippets.

@podhmo
Last active November 2, 2025 14:02
Show Gist options
  • Select an option

  • Save podhmo/2b257330f5258d546b46c40422cd061b to your computer and use it in GitHub Desktop.

Select an option

Save podhmo/2b257330f5258d546b46c40422cd061b to your computer and use it in GitHub Desktop.
カクヨムランキングスクレイパー

カクヨムランキングスクレイパー

カクヨム(https://kakuyomu.jp/)の月間・週間ランキングから作品情報を取得するGoプログラムです。

機能

カクヨムのランキングページから以下の情報を取得します:

  • ランキング順位
  • タイトル
  • サブタイトル(キャッチコピー)
  • 作品URL
  • あらすじ
  • 更新日時
  • スター数
  • タグ一覧
  • 文字数
  • 著者名
  • 著者ページURL
  • ジャンル
  • 連載状況
  • 話数情報

対応ランキング

ランキング種別

  • 月間ランキング (monthly)
  • 週間ランキング (weekly)

ジャンル

  • all: 総合
  • fantasy: 異世界ファンタジー
  • action: 現代ファンタジー
  • sf: SF
  • love_story: 恋愛
  • romance: ラブコメ
  • drama: 現代ドラマ
  • horror: ホラー
  • mystery: ミステリー
  • nonfiction: エッセイ・ノンフィクション
  • history: 歴史・時代・伝奇
  • criticism: 創作論・評論
  • others: 詩・童話・その他

必要な環境

  • Go 1.16以上

インストール

バイナリをビルドして使用

# カレントディレクトリにバイナリを生成
go build

# 実行
./foo --format json

システムにインストール

# $GOPATH/binにインストール
go install

# 実行($GOPATH/binにパスが通っている場合)
foo --format json

使い方

テキスト形式で出力(デフォルト)

go run ./

出力例:

===== 月間ランキング (最初の3件) =====

【順位: 1】
タイトル: 転生先が無法都市だったけど、無口な修理屋やってたら何故か怖がられてる件
サブタイトル: ギャングも企業も警察も──この無口な修理屋には頭が上がらない。
著者: 鳥獣跋扈
URL: https://kakuyomu.jp/works/16818792440656241433
ジャンル: SF
状況: 連載中 44話
文字数: 130,030文字
更新日時: 2025年11月2日 20:00 更新
スター数: ★14,772
タグ: 転生, 現代風未来, 修理屋, ギャング, クラフト&スキル, 裏社会
あらすじ: 目覚めたら、そこはゲームみたいな"現代風異世界"──だけど、やたらと治安が悪すぎた。
...

JSON形式で出力

go run ./ --format json

JSON出力をファイルに保存:

go run ./ --format json > ranking.json 2>/dev/null

JSON形式の例:

{
  "metadata": {
    "genre": "fantasy",
    "period": "weekly",
    "pages": 2,
    "format": "json",
    "limit": 3
  },
  "data": [
    {
      "rank": "1",
      "title": "転生先が無法都市だったけど、無口な修理屋やってたら何故か怖がられてる件",
      "subtitle": "ギャングも企業も警察も──この無口な修理屋には頭が上がらない。",
      "url": "https://kakuyomu.jp/works/16818792440656241433",
      "synopsis": "目覚めたら、そこはゲームみたいな...",
      "updated_at": "2025年11月2日 20:00 更新",
      "stars": "★14,772",
      "tags": ["転生", "現代風未来", "修理屋", "ギャング", "クラフト&スキル", "裏社会"],
      "char_count": "130,030文字",
      "author": "鳥獣跋扈",
      "author_url": "https://kakuyomu.jp/users/tyoujyuubakko",
      "genre": "SF",
      "status": "連載中",
      "episode_info": "44話"
    }
  ]
}

バイナリをビルド

go build -o kakuyomu-scraper
./kakuyomu-scraper --format json

コマンドラインオプション

オプション デフォルト 説明
--format text 出力形式。textまたはjsonを指定
--pages 1 取得するページ数(各ページ約100件)
--genre all ジャンル。all, fantasy, action, sf, love_story, romance, drama, horror, mystery, nonfiction, history, criticism, others
--period monthly 期間。daily, weekly, monthly, yearly, entire
--limit 3 テキスト形式での表示件数(0で全件表示、JSON形式では無視される)

使用例

# 月間ランキングを3件表示(デフォルト)
go run ./

# 週間ランキングを10件表示
go run ./ --period weekly --limit 10

# 異世界ファンタジーの日間ランキングを取得
go run ./ --genre fantasy --period daily

# SFジャンルの年間ランキング2ページ分を全件表示
go run ./ --genre sf --period yearly --pages 2 --limit 0

# ミステリージャンルの月間ランキング3ページ分をJSON形式で出力
go run ./ --genre mystery --period monthly --pages 3 --format json > mystery_ranking.json

開発・テスト時の重要な注意事項

テストやデバッグを行う際は、必ずローカルにダウンロードしたHTMLファイルを使用してください。

カクヨムのサービスに負荷を与えないため、以下の手順を遵守してください:

テストの実行

# 通常のテスト実行(testdata/のHTMLファイルを使用)
go test -v

# テストデータの更新(実際にHTTPリクエストを発行)
go test -update -v

testdata/には以下のゴールデンファイルが含まれています:

  • weekly.html: 週間ランキング
  • monthly.html: 月間ランキング
  • monthly_page2.html: 月間ランキング2ページ目

1. HTMLファイルのダウンロード

# 総合ランキング Page 1
curl -s "https://kakuyomu.jp/rankings/all/monthly?work_variation=all" \
  -H "User-Agent: Mozilla/5.0" > monthly.html

# 総合ランキング Page 2
curl -s "https://kakuyomu.jp/rankings/all/monthly?work_variation=all&page=2" \
  -H "User-Agent: Mozilla/5.0" > monthly_page2.html

# 異世界ファンタジーのランキング
curl -s "https://kakuyomu.jp/rankings/fantasy/monthly?work_variation=all" \
  -H "User-Agent: Mozilla/5.0" > genre_fantasy.html

# SFのランキング
curl -s "https://kakuyomu.jp/rankings/sf/monthly?work_variation=all" \
  -H "User-Agent: Mozilla/5.0" > genre_sf.html

ジャンルURLパターン: https://kakuyomu.jp/rankings/{genre}/{period}?work_variation=all

  • {genre}: all, fantasy, action, sf, love_story, romance, drama, horror, mystery, nonfiction, history, criticism, others
  • {period}: daily, weekly, monthly, yearly, entire

2. ローカルHTMLでのテスト

ダウンロードしたHTMLファイルを使って、HTMLセレクタの確認やデバッグを行います:

# HTMLの構造を確認
grep -A 50 "widget-work" monthly.html | head -100

# 特定の要素を抽出して確認
grep "widget-workCard-title" monthly.html

3. テストコードの実装例

// test_local.go
func parseLocalHTML(filename string) []RankingItem {
    file, err := os.Open(filename)
    if err != nil {
        log.Fatal(err)
    }
    defer file.Close()
    
    doc, err := goquery.NewDocumentFromReader(file)
    if err != nil {
        log.Fatal(err)
    }
    
    // パース処理...
}

func main() {
    items := parseLocalHTML("monthly.html")
    fmt.Printf("取得件数: %d\n", len(items))
    // 結果の確認
}

4. 本番実装

ローカルHTMLで正しく動作することを確認した後、実際のHTTPリクエストに対応します。

開発フロー

1. HTMLダウンロード (curl)
   ↓
2. ローカルファイルでパース処理を実装・テスト
   ↓
3. 動作確認・デバッグ (何度でもOK)
   ↓
4. 本番コードに統合
   ↓
5. 最終確認 (実際のHTTPリクエスト 1回のみ)

この手法により、開発・デバッグ中に何度もサイトにアクセスすることなく、サービスへの負荷を最小限に抑えながら開発できます。

注意事項

  • Rate Limiting: リクエスト間隔は1秒に設定されています
    • レスポンスヘッダーにRetry-Afterがある場合はそれに従います
    • ない場合はデフォルトの1秒待機します
  • ページネーション: 複数ページ取得時は各ページ間で適切に待機します
  • カクヨムの利用規約を遵守してご利用ください
  • スクレイピングの頻度は適切に保ってください
  • 開発・テスト時は必ずローカルHTMLファイルを使用してください

技術スタック

  • colly - Webスクレイピングフレームワーク
  • goquery - HTMLパーサー

テスト

プロジェクトには、HTMLパース機能のテストが含まれています。

# テスト実行(ローカルのtestdata/を使用)
go test -v

# testdata/のHTMLファイルを更新(実際にHTTPリクエストを発行)
go test -update -v

注意: -updateフラグを使用すると、実際にカクヨムにアクセスします。通常のテストではローカルのHTMLファイルのみを使用します。

ライセンス

このプロジェクトはサンプルコードとして提供されています。

関連リンク

module kakuyomu-scraper
go 1.24.3
require github.com/gocolly/colly/v2 v2.2.0
require (
github.com/PuerkitoBio/goquery v1.10.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/antchfx/htmlquery v1.3.4 // indirect
github.com/antchfx/xmlquery v1.4.4 // indirect
github.com/antchfx/xpath v1.3.3 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/nlnwa/whatwg-url v0.6.1 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ=
github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM=
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc=
github.com/antchfx/xpath v1.3.3 h1:tmuPQa1Uye0Ym1Zn65vxPgfltWb/Lxu2jeqIGteJSRs=
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gocolly/colly/v2 v2.2.0 h1:FQGxcqvTdFAvOpMRhk52o20Qsf6KtRU5HSf0bITS38I=
github.com/gocolly/colly/v2 v2.2.0/go.mod h1:YOQwv1ofoQOzJiELnkThDd6ObOfl6odUk2i6Czbx3Ws=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/nlnwa/whatwg-url v0.6.1 h1:Zlefa3aglQFHF/jku45VxbEJwPicDnOz64Ra3F7npqQ=
github.com/nlnwa/whatwg-url v0.6.1/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/gocolly/colly/v2"
)
// RankingItem はランキングの各作品の情報
type RankingItem struct {
Rank string `json:"rank"` // ランキング順位
Title string `json:"title"` // タイトル
Subtitle string `json:"subtitle"` // サブタイトル(キャッチコピー)
URL string `json:"url"` // 作品URL
Synopsis string `json:"synopsis"` // あらすじ
UpdatedAt string `json:"updated_at"` // 更新日時
Stars string `json:"stars"` // スター数
Tags []string `json:"tags"` // タグ一覧
CharCount string `json:"char_count"` // 文字数
Author string `json:"author"` // 著者名
AuthorURL string `json:"author_url"` // 著者ページURL
Genre string `json:"genre"` // ジャンル
Status string `json:"status"` // 連載状況(連載中/完結済)
EpisodeInfo string `json:"episode_info"` // 話数情報
}
// Metadata はランキング取得時のメタデータ
type Metadata struct {
Genre string `json:"genre"` // ジャンル
Period string `json:"period"` // 期間
Pages int `json:"pages"` // 取得ページ数
Format string `json:"format"` // 出力形式
Limit int `json:"limit"` // 表示件数制限
}
// Output は最終的な出力形式
type Output struct {
Metadata Metadata `json:"metadata"` // メタデータ
Data []RankingItem `json:"data"` // ランキングデータ
}
func main() {
// コマンドラインフラグを定義
format := flag.String("format", "text", "Output format: text or json")
pages := flag.Int("pages", 1, "Number of pages to scrape (each page has ~100 items)")
genre := flag.String("genre", "all", "Genre: all, fantasy, action, sf, love_story, romance, drama, horror, mystery, nonfiction, history, criticism, others")
period := flag.String("period", "monthly", "Period: daily, weekly, monthly, yearly, entire")
limit := flag.Int("limit", 3, "Number of items to display in text format (0 = all)")
flag.Parse()
// ランキングURLを生成
baseURL := fmt.Sprintf("https://kakuyomu.jp/rankings/%s/%s?work_variation=all", *genre, *period)
// ランキングを取得
var items []RankingItem
for item := range scrapeRankingPages(baseURL, *period, *pages) {
items = append(items, item)
}
if *format != "json" {
fmt.Fprintf(os.Stderr, "ランキング(%s/%s)取得完了: %d件\n", *genre, *period, len(items))
}
// 結果を表示
if *format == "json" {
outputJSON(items, *genre, *period, *pages, *format, *limit)
} else {
printItems(items, fmt.Sprintf("%sランキング", *period), *limit)
}
}
func outputJSON(items []RankingItem, genre, period string, pages int, format string, limit int) {
output := Output{
Metadata: Metadata{
Genre: genre,
Period: period,
Pages: pages,
Format: format,
Limit: limit,
},
Data: items,
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(output); err != nil {
log.Fatalf("Failed to encode JSON: %v", err)
}
}
// scrapeRankingPages は各ページから取得したアイテムをイテレータとして返す (Go 1.22+)
func scrapeRankingPages(baseURL, rankingType string, pages int) func(yield func(RankingItem) bool) {
return func(yield func(RankingItem) bool) {
for page := 1; page <= pages; page++ {
var url string
if page == 1 {
url = baseURL
} else {
url = fmt.Sprintf("%s&page=%d", baseURL, page)
}
items := scrapeRanking(url, rankingType)
if len(items) == 0 {
break // ページが存在しない場合は終了
}
for _, item := range items {
if !yield(item) {
return // イテレーション中断
}
}
// 次のページがある場合は待機
// Rate limitingのため、デフォルトは1秒待機(collyのDelayで設定済み)
if page < pages {
log.Printf("次のページ取得まで待機中...")
}
}
}
}
func scrapeRanking(url, rankingType string) []RankingItem {
var items []RankingItem
c := colly.NewCollector(
colly.AllowedDomains("kakuyomu.jp"),
colly.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"),
)
// Rate limiting - デフォルトは1秒間隔
c.Limit(&colly.LimitRule{
DomainGlob: "*kakuyomu.jp*",
Parallelism: 1,
Delay: 1 * time.Second,
})
// エラーハンドリング
c.OnError(func(r *colly.Response, err error) {
log.Printf("Error: %v\n", err)
})
// レスポンスヘッダーをチェックしてRetry-Afterに従う
c.OnResponse(func(r *colly.Response) {
retryAfter := r.Headers.Get("Retry-After")
if retryAfter != "" {
// Retry-Afterヘッダーが存在する場合、その秒数待機
if seconds, err := time.ParseDuration(retryAfter + "s"); err == nil {
log.Printf("Retry-After: %s秒 - 待機中...", retryAfter)
time.Sleep(seconds)
}
}
})
// 各ランキングアイテムをパース
c.OnHTML("div.widget-work", func(e *colly.HTMLElement) {
item := parseRankingItem(e.DOM)
items = append(items, item)
})
c.OnRequest(func(r *colly.Request) {
log.Printf("Visiting: %s\n", r.URL)
})
c.Visit(url)
return items
}
// parseRankingItem は goquery.Selection から RankingItem を抽出する
func parseRankingItem(s *goquery.Selection) RankingItem {
item := RankingItem{}
// 順位
item.Rank = strings.TrimSpace(s.Find("p.widget-work-rank").Text())
// タイトル(キャッチコピー的な大きい見出し)
catchphrase := s.Find("h4.widget-catchphrase-title a").First()
item.Subtitle = strings.TrimSpace(catchphrase.Text())
// URL と本タイトル
titleLink := s.Find("h3.widget-workCard-title a.widget-workCard-titleLabel")
item.Title = strings.TrimSpace(titleLink.Text())
if href, exists := titleLink.Attr("href"); exists {
item.URL = "https://kakuyomu.jp" + href
}
// 著者名と著者URL
authorLink := s.Find("a.widget-workCard-authorLabel")
item.Author = strings.TrimSpace(authorLink.Text())
if authorHref, exists := authorLink.Attr("href"); exists {
item.AuthorURL = "https://kakuyomu.jp" + authorHref
}
// あらすじ
item.Synopsis = strings.TrimSpace(s.Find("p.widget-workCard-introduction a").Text())
// スター数
item.Stars = strings.TrimSpace(s.Find("a.widget-workCard-reviewPoints").Text())
// ジャンル
item.Genre = strings.TrimSpace(s.Find("span.widget-workCard-genre a").Text())
// 連載状況と話数
item.Status = strings.TrimSpace(s.Find("span.widget-workCard-statusLabel").Text())
item.EpisodeInfo = strings.TrimSpace(s.Find("span.widget-workCard-episodeCount").Text())
// 文字数
item.CharCount = strings.TrimSpace(s.Find("span.widget-workCard-characterCount").Text())
// 更新日時
item.UpdatedAt = strings.TrimSpace(s.Find("span.widget-workCard-dateUpdated").Text())
// タグ
tags := []string{}
s.Find("span.widget-workCard-tags a span").Each(func(_ int, tag *goquery.Selection) {
tagText := strings.TrimSpace(tag.Text())
if tagText != "" {
tags = append(tags, tagText)
}
})
item.Tags = tags
return item
}
func printItems(items []RankingItem, rankingType string, limit int) {
// limitが0の場合は全件表示
displayCount := len(items)
if limit > 0 && limit < displayCount {
displayCount = limit
}
fmt.Printf("\n===== %s (%d件中%d件表示) =====\n", rankingType, len(items), displayCount)
for i := 0; i < displayCount; i++ {
item := items[i]
fmt.Printf("\n【順位: %s】\n", item.Rank)
fmt.Printf("タイトル: %s\n", item.Title)
fmt.Printf("サブタイトル: %s\n", item.Subtitle)
fmt.Printf("著者: %s\n", item.Author)
fmt.Printf("URL: %s\n", item.URL)
fmt.Printf("ジャンル: %s\n", item.Genre)
fmt.Printf("状況: %s %s\n", item.Status, item.EpisodeInfo)
fmt.Printf("文字数: %s\n", item.CharCount)
fmt.Printf("更新日時: %s\n", item.UpdatedAt)
fmt.Printf("スター数: %s\n", item.Stars)
fmt.Printf("タグ: %s\n", strings.Join(item.Tags, ", "))
fmt.Printf("あらすじ: %s\n", item.Synopsis)
}
}
package main
import (
"flag"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
var update = flag.Bool("update", false, "update golden files")
func TestParseRankingItem(t *testing.T) {
testCases := []struct {
name string
htmlFile string
wantRank string
wantMin int // 最小件数
}{
{
name: "weekly",
htmlFile: "weekly.html",
wantRank: "1",
wantMin: 100,
},
{
name: "monthly",
htmlFile: "monthly.html",
wantRank: "1",
wantMin: 100,
},
{
name: "monthly_page2",
htmlFile: "monthly_page2.html",
wantRank: "101",
wantMin: 100,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// testdataからHTMLを読み込む
htmlPath := filepath.Join("testdata", tc.htmlFile)
// -update フラグが指定された場合、HTMLをダウンロード
if *update {
if err := downloadTestData(tc.name, htmlPath); err != nil {
t.Fatalf("Failed to download test data: %v", err)
}
}
// HTMLファイルを開く
file, err := os.Open(htmlPath)
if err != nil {
t.Fatalf("Failed to open test file %s: %v", htmlPath, err)
}
defer file.Close()
// HTMLをパース
doc, err := goquery.NewDocumentFromReader(file)
if err != nil {
t.Fatalf("Failed to parse HTML: %v", err)
}
// 各ランキングアイテムをパース
var items []RankingItem
doc.Find("div.widget-work").Each(func(i int, s *goquery.Selection) {
item := parseRankingItem(s)
items = append(items, item)
})
// 件数チェック
if len(items) < tc.wantMin {
t.Errorf("Expected at least %d items, got %d", tc.wantMin, len(items))
}
// 最初のアイテムの順位チェック
if len(items) > 0 {
if items[0].Rank != tc.wantRank {
t.Errorf("Expected first rank to be %s, got %s", tc.wantRank, items[0].Rank)
}
// 基本的なフィールドが設定されているかチェック
if items[0].Title == "" {
t.Error("Title should not be empty")
}
if items[0].Author == "" {
t.Error("Author should not be empty")
}
if items[0].URL == "" {
t.Error("URL should not be empty")
}
if !strings.HasPrefix(items[0].URL, "https://kakuyomu.jp/works/") {
t.Errorf("URL should start with https://kakuyomu.jp/works/, got %s", items[0].URL)
}
}
t.Logf("Parsed %d items from %s", len(items), tc.htmlFile)
if len(items) > 0 {
t.Logf("First item: Rank=%s, Title=%s, Author=%s", items[0].Rank, items[0].Title, items[0].Author)
}
})
}
}
func TestPagination(t *testing.T) {
testCases := []struct {
name string
htmlFiles []string
wantTotal int // 期待する合計件数
wantFirst string
wantLast string
}{
{
name: "monthly_2pages",
htmlFiles: []string{
"monthly.html",
"monthly_page2.html",
},
wantTotal: 200,
wantFirst: "1",
wantLast: "200",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var allItems []RankingItem
// 各ページをパース
for _, htmlFile := range tc.htmlFiles {
htmlPath := filepath.Join("testdata", htmlFile)
file, err := os.Open(htmlPath)
if err != nil {
t.Fatalf("Failed to open test file %s: %v", htmlPath, err)
}
defer file.Close()
doc, err := goquery.NewDocumentFromReader(file)
if err != nil {
t.Fatalf("Failed to parse HTML: %v", err)
}
doc.Find("div.widget-work").Each(func(i int, s *goquery.Selection) {
item := parseRankingItem(s)
allItems = append(allItems, item)
})
}
// 合計件数チェック
if len(allItems) < tc.wantTotal {
t.Errorf("Expected at least %d total items, got %d", tc.wantTotal, len(allItems))
}
// 最初と最後の順位チェック
if len(allItems) > 0 {
if allItems[0].Rank != tc.wantFirst {
t.Errorf("Expected first rank to be %s, got %s", tc.wantFirst, allItems[0].Rank)
}
lastIdx := len(allItems) - 1
if allItems[lastIdx].Rank != tc.wantLast {
t.Errorf("Expected last rank to be %s, got %s", tc.wantLast, allItems[lastIdx].Rank)
}
}
// 順位が連続しているかチェック(サンプルチェック)
if len(allItems) >= 2 {
// 1位と2位
if allItems[0].Rank == "1" && allItems[1].Rank != "2" {
t.Errorf("Expected rank 2 after rank 1, got %s", allItems[1].Rank)
}
// 100位と101位の境界
for i := 0; i < len(allItems)-1; i++ {
if allItems[i].Rank == "100" && allItems[i+1].Rank != "101" {
t.Errorf("Expected rank 101 after rank 100, got %s", allItems[i+1].Rank)
}
}
}
t.Logf("Parsed total %d items across %d pages", len(allItems), len(tc.htmlFiles))
if len(allItems) > 0 {
t.Logf("First: Rank=%s, Last: Rank=%s", allItems[0].Rank, allItems[len(allItems)-1].Rank)
}
})
}
}
// downloadTestData はテストデータをダウンロードする
func downloadTestData(name, path string) error {
var url string
switch name {
case "weekly":
url = "https://kakuyomu.jp/rankings/all/weekly?work_variation=all"
case "monthly":
url = "https://kakuyomu.jp/rankings/all/monthly?work_variation=all"
case "monthly_page2":
url = "https://kakuyomu.jp/rankings/all/monthly?work_variation=all&page=2"
default:
log.Printf("Unknown test case: %s", name)
return nil
}
log.Printf("Downloading %s to %s", url, path)
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return os.ErrNotExist
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
return err
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment