Go

Collyの運用設定|robots.txt・レート制限・文字化けを実測で解説

Colly でクローラを書くとき、コンパイルが通って要素も取れているのに、相手サイトから見ると問題のある動きをしていることがあります。原因の多くは既定値です。ライブラリの選び方と基本実装はGoでスクレイピングする方法|goqueryとCollyの使い分けと実装にまとめてあるので、この記事は動かした後の設定に絞ります。robots.txt の扱い、アクセス間隔、429 で止められたときのリトライ、日本語ページの文字化け、そしてコードの置き場所です。挙動はすべて Go 1.26.5・colly v2.3.0 で実際に走らせて確認しました(2026-08-13)。

まとめ:既定値のままだと外れる4点

  • colly.NewCollector()IgnoreRobotsTxt は true で初期化される。robots.txt が Disallow したパスも、エラーなく取得できてしまう。
  • Parallelismcolly.Async(true) と組でなければ効かない。1件0.3秒のページ4件で、Async なしは1.21秒、ありは0.30秒だった。
  • Request.Retry()Retry-After を待たないRetry-After: 3 を返すサーバーに対し、待機を書かないと0.00秒で再送した。
  • colly.DetectCharset() は文字コードの推定であって宣言の解釈ではない。meta にしか charset が無いページでも当たったが、88バイトのページでは外した。

robots.txtを無視するCollyの既定値

ここが最も誤解されている箇所です。colly.NewCollector() が返す Collector は、IgnoreRobotsTxt フィールドが true の状態で作られます。「Colly は robots.txt を見てくれる」という前提でコードを書くと、拒否されている領域へ黙って入り込みます。

User-agent: *Disallow: / を返す robots.txt を置いたテストサーバーへ、既定の Collector と IgnoreRobotsTxt = false の Collector で同じ URL を取りに行かせました。

c := colly.NewCollector()
fmt.Printf("既定の IgnoreRobotsTxt = %v\n", c.IgnoreRobotsTxt)

got := false
c.OnHTML("h1.t", func(e *colly.HTMLElement) { got = true })
err := c.Visit(ts.URL + "/page")
fmt.Printf("既定: err=%v 取得=%v\n", err, got)

c2 := colly.NewCollector()
c2.IgnoreRobotsTxt = false
err2 := c2.Visit(ts.URL + "/page")
fmt.Printf("false: err=%v\n", err2)
既定の IgnoreRobotsTxt = true
既定: err=<nil> 取得=true
false: err=URL blocked by robots.txt

既定の Collector は Disallow されたページの中身を読み取り、Visit は nil を返しました。ログにも戻り値にも手がかりが残りません。一方 IgnoreRobotsTxt = false にすると OnHTML は呼ばれず、URL blocked by robots.txt というエラーで止まります。Collector を作った次の行にこの1行を書くところまでを定型にしてください。

robots.txtのCrawl-delayと待機の自前実装

IgnoreRobotsTxt = false にすれば robots.txt の Crawl-delay も効く、とは考えないでください。Crawl-delay: 2 を書いた robots.txt を返すサーバーへ3ページ連続でアクセスしたところ、所要時間は0.00秒でした。Colly が解釈するのは Allow と Disallow で、待ち時間の指定は読み取っても適用されません。robots.txt に Crawl-delay があるサイトを対象にするなら、その秒数を次の Limit に自分で写す必要があります。

アクセス間隔と並行数が効く条件

間隔と同時接続数は Limit で決めます。Delay は各リクエストの前に必ず待つ固定値、RandomDelay は0からその値までの乱数、Parallelism は同時実行数の上限です。実際に測った値を並べます。

設定 対象 実測
Delay: 1秒 / Parallelism: 1 2ページ巡回 2.00秒
RandomDelay: 1秒 / Parallelism: 1 4ページ取得 2.49秒
Async なし / Parallelism: 4 0.3秒のページ4件 1.21秒
Async(true) / Parallelism: 4 0.3秒のページ4件 0.30秒

読み取れるのは、Parallelism を上げても colly.Async(true) が無ければ直列のままだということです。Async なしの1.21秒は0.3秒×4件の直列そのもので、Parallelism を4にしたことは所要時間に現れていません。逆に Async(true) を付けたときは c.Wait() を呼ばないと main が先に終わってページを取りこぼします。「並行数を2に落としたつもりだったが、そもそも並行になっていなかった」も「4並行にしたはずが取得結果が欠ける」も、この対応関係を外したときに起きます。

c := colly.NewCollector(
	colly.AllowedDomains("example.com"),
	colly.Async(true),
	colly.UserAgent("issoh-crawler/1.0 (+https://example.com/contact)"),
)

c.Limit(&colly.LimitRule{
	DomainGlob:  "*",
	Parallelism: 2,
	Delay:       1 * time.Second,
	RandomDelay: 500 * time.Millisecond,
})

// Async(true) のときは Wait() が必須
c.Visit("https://example.com/")
c.Wait()

User-Agent も既定のままにしないでください。colly v2.3.0 の既定値は colly - https://github.com/gocolly/colly という文字列で、運用主体も連絡先も相手に伝わりません。相手のサーバー管理者が問い合わせ先を持てる形にしておくほうが、ブロックされたときに話が早く進みます。

429応答時のリトライ設計

レート制限に触れると 429 が返ります。Colly はステータスコードが 2xx 以外だと OnError を呼ぶので、ここで Request.Retry() を呼べば同じ URL を再送できます。ただし待ってはくれませんRetry-After: 3 を返すサーバーに対し、待機を入れずに Retry() だけ呼んだ結果は次のとおりです。

Retry-After:3 に対し待機なしRetry(): 所要 0.00s リクエスト数=2

3秒待つべき場面で即座に2回目を投げています。相手から見れば、制限をかけた直後にもう一度叩かれた状態です。Retry-After を読んで自分で待つ処理と、再試行の上限を必ず書いてください。

c.OnError(func(r *colly.Response, err error) {
	if r.StatusCode != http.StatusTooManyRequests {
		log.Printf("error %s: %v", r.Request.URL, err)
		return
	}
	wait := 5 * time.Second
	if v := r.Headers.Get("Retry-After"); v != "" {
		if sec, err := strconv.Atoi(v); err == nil {
			wait = time.Duration(sec) * time.Second
		}
	}
	if r.Request.Ctx.GetAny("retried") != nil {
		log.Printf("giving up: %s", r.Request.URL)
		return
	}
	r.Request.Ctx.Put("retried", true)
	time.Sleep(wait)
	r.Request.Retry()
})

再試行の記録に Request.Ctx を使うと、URL ごとの状態を Collector 全体の共有マップに持たずに済みます。Async(true) で走らせる場合、自前の mapOnError から触ると複数ゴルーチンから同時に書き込まれるため、素の map ではデータ競合になります。

Shift_JIS・EUC-JPページの文字化け

日本語サイトを相手にすると、いまだに Shift_JIS や EUC-JP で配信されるページに当たります。ここは方法ごとに結果が分かれるので、Shift_JIS のページを4条件で取得して比べました。○が「日本語が正しく読めた」です。

charsetの宣言場所 Colly 既定 Colly + DetectCharset() charset.NewReader + goquery
Content-Typeヘッダ
meta要素のみ ×
meta(http-equiv形式)のみ ×
宣言なし × ×

Colly の既定はレスポンスヘッダの charset しか見ません。HTML の meta にしか書かれていないページは、追加設定なしでは化けます。colly.DetectCharset() を付けると meta しか無いページでも宣言がまったく無いページでも正しく読めました。逆に golang.org/x/net/html/charsetNewReader はヘッダと meta を解釈する仕組みなので、宣言が無いページでは Windows-1252 とみなして化けます。この2つは守備範囲が違うのであって、どちらかが上位互換ではありません。

短いページでのDetectCharsetの誤判定

DetectCharset() は宣言を読むのではなく、バイト列から文字コードを推定します。判定材料が足りなければ外れます。meta にだけ charset を書いた Shift_JIS ページを、本文の量だけ変えて測りました。

HTML全体のサイズ DetectCharset() charset.NewReader
88バイト ×
133バイト
538バイト

実記事のページでこのサイズになることはまずないので、通常の巡回では DetectCharset() で足ります。問題になるのは、エラーページや空の一覧ページのように中身がほとんど無い応答が混ざったときです。そこだけ推定が外れて別の文字コードとして読まれ、後段の抽出結果に化けた文字列が紛れ込みます。取得対象の文字コードが分かっているなら、推定に任せず明示的にデコーダを噛ませるほうが結果が安定します。

res, err := http.Get("https://example.com/sjis")
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

// 文字コードが確定しているとき: japanese.ShiftJIS を直接指定する
doc, err := goquery.NewDocumentFromReader(
	transform.NewReader(res.Body, japanese.ShiftJIS.NewDecoder()))
if err != nil {
	log.Fatal(err)
}
fmt.Println(doc.Find("h1").Text())

EUC-JP なら japanese.EUCJP、ISO-2022-JP なら japanese.ISO2022JP に差し替えるだけです。goquery 側の文字化け対策はgoqueryの使い方|title取得・文字化け対策まで実行済みコードで解説で個別に扱っています。

ディレクトリ構成と取得結果の保存

「Go プロジェクト構成」で検索すると cmd/pkg/internal/ の3点セットがよく出てきます。ただしpkg/ は go.dev のモジュール構成ガイドには登場しません。公式が挙げているのは internal/cmd/ で、internal/ は「外部へ公開・サポートしたくないパッケージに他のモジュールが依存することを防ぐ」ために最初に置く場所とされ、cmd/ は「コマンドだけで構成されるリポジトリでは厳密には必要ない」と明記されています。実行ファイル1本のスクレイパなら cmd/ も要りません。

#(1)単発スクリプト
scraper/
  go.mod
  main.go

#(2)取得と抽出と保存を分ける
scraper/
  go.mod
  main.go
  internal/
    fetch/    # Collector の設定・リトライ
    parse/    # goquery での抽出
    store/    # CSV・DB 書き出し

#(3)実行ファイルが増えたら
scraper/
  go.mod
  cmd/
    crawl/main.go
    export/main.go
  internal/
    fetch/ parse/ store/

スクレイパ固有の観点を1つ挙げると、parse パッケージに HTTP を持ち込まないことです。抽出関数が io.Reader を受け取る形なら、保存しておいた HTML ファイルを渡すだけでテストが書けます。相手サイトへ接続しないと動かない構造にすると、HTML が変わって壊れたときに「向こうの変更か、こちらのバグか」を切り分けられません。

保存で足をすくわれやすいのが encoding/csv です。csv.Writer はバッファを持つため、Flush() を呼ばずにファイルを閉じると0バイトのファイルが残ります。2行書き込んで Flush の有無だけを変えた結果は 0バイトと33バイトでした。長時間の巡回では、途中経過を1行ずつ書き出せる JSON Lines 形式にして Flush を都度呼ぶほうが、中断したときの取り返しが利きます。

よくある質問

Collyはrobots.txtを自動で守りますか?

守りません。colly.NewCollector() が返す Collector は IgnoreRobotsTxt が true の状態で作られ、Disallow されたパスもエラーなく取得します(colly v2.3.0 で実測)。尊重させるには c.IgnoreRobotsTxt = false を明示してください。設定すると、拒否された URL に対して VisitURL blocked by robots.txt を返します。

Parallelismを上げても速くならないのはなぜですか?

colly.Async(true) を付けていないためです。Async を指定しない Collector は Parallelism の値にかかわらず直列に実行します。1件0.3秒のページ4件で、Async なし Parallelism 4 は1.21秒、Async(true) では0.30秒でした。Async(true) にした場合は c.Wait() を呼ばないと main が先に終了して取りこぼします。

429が返るときはどう再試行すればよいですか?

OnError の中で Retry-After ヘッダの秒数だけ待ってから r.Request.Retry() を呼びます。Retry() 自体は待機しないため、待たずに呼ぶと制限をかけられた直後に再送します(実測0.00秒)。再試行回数の上限も必ず設けてください。回数の記録には Request.Ctx を使うと、Async 実行時のデータ競合を避けられます。

取得したページが文字化けします。どう直せばよいですか?

まず charset がどこで宣言されているかを確認します。レスポンスヘッダの Content-Type にあれば Colly は既定で変換します。HTML の meta にしか無い場合は既定では化けるため、colly.DetectCharset() を付けるか、golang.org/x/net/html/charsetNewReader を通してください。文字コードが分かっているなら japanese.ShiftJIS.NewDecoder() のように明示指定するのが最も安定します。

スクレイピングを防止しているサイトかどうかはどう判断しますか?

robots.txt の Disallow、利用規約の自動取得禁止条項、そして 403 や 429 の応答で判断します。robots.txt は IgnoreRobotsTxt = false にすれば Colly が自動で判定しますが、Crawl-delay は読み取っても適用されないため、指定があれば LimitRuleDelay に自分で写してください。403 が返る場合は User-Agent の既定値やアクセス間隔が原因のこともあります。

関連記事

資料請求

RELATED POSTS 関連記事