Go言語でのスパイダー/クロール
仕事でGo言語を使うことになったので少し調べた時のメモ。
こんなのが簡単に実装できる。 sokmil.com がターゲット。
ページ取得処理を作成
対象のURLのHTMLをパースできるようにページを取得する処理を作成します。
User-Agentとリファラを設定できるようにしておきます。
//共通設定
const (
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
AuthURL = "https://www.sokmil.com/member/ageauth/"
)
func doReq(cl *http.Client, url, ref string) *goquery.Document {
// 新しいHTTP GETリクエストを作成
req, _ := http.NewRequest("GET", url, nil)
// ヘッダーにユーザーエージェントを設定
req.Header.Set("User-Agent", UA)
// もし参照元のURLが指定されている場合は、Refererヘッダーを設定
if ref != "" {
req.Header.Set("Referer", ref)
}
// リクエストを実行し、レスポンスを取得
res, _ := cl.Do(req)
// 関数終了時にレスポンスのボディを閉じるようにdeferを使う
defer res.Body.Close()
// レスポンスボディからgoquery用のドキュメントを作成
doc, _ := goquery.NewDocumentFromReader(res.Body)
// ドキュメントを返す
return doc
}
年齢認証
まず年齢認証を突破する処理を作成します。
クッキーを設定して HTTPクライアントを作成します。
func getSession() (*http.Client, error) {
// クッキージャーを新規に作成
jar, _ := cookiejar.New(nil)
// HTTPクライアントをクッキージャーと共に作成
client := &http.Client{Jar: jar}
// 認証ページ取得
doc := doReq(client, AuthURL, "")
// リンク抽出
link, _ := doc.Find("a.btn-ageauth-yes").Attr("href")
if link == "" {
// リンクが見つからなかった場合はエラーを返す
return nil, fmt.Errorf("認証ボタンが見つかりませんでした")
}
// 認証実行(Referer付き)
doReq(client, link, AuthURL)
// 認証完了したクライアントを返す
return client, nil
}
商品詳細ページのURLを収集
商品詳細ページのURLは「https://www.sokmil.com/idol/_item/itemXXXXXX.htm」 なので
この形式のURLを収集する処理を作成
func getItemURLs(cl *http.Client, listURL string) []string {
// HTTPリクエストを送信してレスポンスのHTMLドキュメントを取得
doc := doReq(cl, listURL, "")
// アイテムのURLを格納するスライスを宣言
var urls []string
// aタグのhref属性をすべてチェック
doc.Find("a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
// 条件: "/idol/_item/item" を含んでいるものだけを抽出
if exists && strings.Contains(href, "/idol/_item/item") {
// もし相対パス(/idol/..)なら絶対パスに変換
if strings.HasPrefix(href, "/") {
href = "https://www.sokmil.com" + href
}
// URLをスライスに追加
urls = append(urls, href)
}
})
// 重複を排除する(同じリンクが複数ある場合があるため)
return unique(urls)
}
// 重複を消すための便利関数
func unique(slice []string) []string {
m := make(map[string]bool)
var result []string
for _, s := range slice {
if !m[s] {
m[s] = true
result = append(result, s)
}
}
return result
}
商品詳細ページからサンプル動画をダウンロード
タイトルと動画URLを抽出し、ダウンロードする処理を作成
ファイル名は「作品名.mp4」で保存
func crawlAndDownload(cl *http.Client, pageURL string) {
// 1. ページの中身を取得
doc := doReq(cl, pageURL, "") // 以前作った共通関数を使用
html, _ := doc.Html()
// h1タグからタイトルを取得
title := doc.Find("h1").First().Text()
title = strings.TrimSpace(title) // 前後の空白を消す
// ファイル名に使えない禁止文字を置換する
// WindowsやMacでトラブルになる / : * ? " < > | などを "_" に変える
reg := regexp.MustCompile(`[\\/:*?"<>|]`)
safeTitle := reg.ReplaceAllString(title, "_")
// 正規表現で動画URL (video_url) を抜き出す
re := regexp.MustCompile(`video_url:\s*'(https?://[^']+)'`)
match := re.FindStringSubmatch(html)
if len(match) < 2 {
fmt.Printf("動画URLが見つかりませんでした: %s\n", pageURL)
return
}
videoURL := match[1]
// 保存先
saveDir := "mp4"
// mp4フォルダがなければ作成する (0755は一般的な権限設定)
if _, err := os.Stat(saveDir); os.IsNotExist(err) {
os.Mkdir(saveDir, 0755)
}
// タイトルに拡張子を付けて保存パスを作成
fileName := safeTitle + ".mp4"
savePath := filepath.Join(saveDir, fileName)
// ダウンロード実行
out, err := os.Create(savePath)
if err != nil {
return
}
defer out.Close()
// HTTP GETリクエストを作成
req, _ := http.NewRequest("GET", videoURL, nil)
// リクエストヘッダーにUser-Agentを設定
req.Header.Set("User-Agent", UA)
// HTTPクライアント(cl)でリクエストを送信し、レスポンスを取得
resp, err := cl.Do(req)
if err != nil {
// エラーが発生した場合は処理を中断
return
}
// プログラムの終了時にレスポンスボディを必ず閉じるようにす
defer resp.Body.Close()
fmt.Printf("開始: %s\n", fileName)
io.Copy(out, resp.Body)
fmt.Printf("完了: %s\n", fileName)
}
実行部分
WaitGroupを使って動画のダウンロードを並列処理で実行
WaitGroupで覚えるべき操作は3つだけ
| 操作 | 意味 | イメージ |
|---|---|---|
| wg.Add(1) | カウンターを +1 する | 「今から1人、現場に向かわせます!」と報告。 |
| wg.Done() | カウンターを -1 する | 作業員が「仕事終わりました!」と無線で報告。 |
| wg.Wait() | カウンターが 0 になるまで待つ | 「全員からの完了報告が届くまで、ここで待機する」 |
並列処理で行う処理を go func で定義
func main() {
client, err := getSession()
if err != nil {
fmt.Println("エラー:", err)
return
}
fmt.Println("✅ 認証完了。データを取得します...")
// 一覧ページを解析するメッセージの表示
fmt.Println("一覧ページを解析中...")
// アイテムのURLを取得し、pageURLsに格納
pageURLs := getItemURLs(client, "https://www.sokmil.com/idol/")
// 見つかったアイテム数を出力
fmt.Printf("%d 個のアイテムが見つかりました\n", len(pageURLs))
// 並行処理で使用するWaitGroupの準備
var wg sync.WaitGroup
// 同時に実行するgoroutineの数を3つに制限
limit := make(chan struct{}, 3)
// ページURLごとに並行処理を設定
for i, url := range pageURLs {
wg.Add(1) // WaitGroupに1追加
go func(target string, index int) {
// goroutine終了時にWaitGroupから1減少
defer wg.Done()
limit <- struct{}{} // チャンネルに空構造体を送信(枠に入る)
// 処理中のメッセージを出力
fmt.Printf("[%d / %d] 処理中...\n", index+1, len(pageURLs))
// ページを解析してダウンロード
crawlAndDownload(client, target) // ページ解析〜保存まで一気に実行
<-limit // チャンネルから値を取り出し(枠を空ける)
}(url, i)
}
wg.Wait() // すべてのgoroutineが終了するのを待つ
// 全ての処理完了のメッセージを表示
fmt.Println("すべての処理が完了しました!")
}
Ask a question...
実行結果
いつものコマンドで実行
go run main.go
go run main.go
✅ 認証完了。データを取得します...
一覧ページを解析中...
269 個のアイテムが見つかりました
[54 / 269] 処理中...
[36 / 269] 処理中...
[18 / 269] 処理中...
動画URLが見つかりませんでした: https://www.sokmil.com/idol/_item/item499694.htm?ref=top_sale
[45 / 269] 処理中...
開始: 月虹(げっこう) 宮越虹海.mp4
開始: 大好き、だよ。 藤乃ゆりあ.mp4
開始: 昼下がりの誘惑 遠野千夏.mp4
完了: 月虹(げっこう) 宮越虹海.mp4
[37 / 269] 処理中...
# 略
[21 / 269] 処理中...
開始: 僕のあおい 藤乃あおい.mp4
動画URLが見つかりませんでした: https://www.sokmil.com/idol/_item/item334120.htm?ref=top_sale
[22 / 269] 処理中...
動画URLが見つかりませんでした: https://www.sokmil.com/idol/_item/item499692.htm?ref=top_sale
[27 / 269] 処理中...
完了: 真面目な大家さんが実はとってもエッチだった件! 藤田あずさ.mp4
[28 / 269] 処理中...
開始: imagine 井川遥.mp4
動画URLが見つかりませんでした: https://www.sokmil.com/idol/_item/item064664.htm?ref=top_sale
[29 / 269] 処理中...
動画URLが見つかりませんでした: https://www.sokmil.com/idol/_item/item499691.htm?ref=top_sale
[30 / 269] 処理中...
完了: 僕のあおい 藤乃あおい.mp4
[31 / 269] 処理中...
動画がダウンロードできていることを確認してください。
藤乃あおいさんのご冥福をお祈りいたします。