Go でスクレイピングを書くなら、1ページから要素を抜き出すのは goquery、複数ページを巡回するのは Colly という分担です。Colly は既定値のまま走らせると robots.txt を読まず、待機時間を設定したつもりでも効いていないことがあります。この記事では goquery と Colly の実装コードに加えて、既定値の落とし穴を Go 1.27.2・colly v2.3.0・goquery v1.13.0 で実際に動かして測った結果(2026-11-01)を載せます。掲載コードはすべてローカルのテストサーバーで実行済みです。
まとめ:goqueryとCollyで押さえる要点
- goquery は HTML パーサ、Colly はクローラの枠組み。1ページなら
net/http+goquery、リンクを辿って巡回するなら Colly を使います。Colly は内部で goquery を使うので、両者は競合しません。 - Colly の
IgnoreRobotsTxtは既定で true。robots.txt が Disallow したページもエラーなしで取得します。c.IgnoreRobotsTxt = falseを必ず書いてください。 LimitRuleが効かない条件が2つある。Visitを1つのゴルーチンから順に呼ぶ場合、colly.Async(true)が無いとParallelismは効きません。またDomainGlob: "example.com"と書くと、www 付きやポート付きの URL には待機がかかりません(実測0.00秒)。Retry()はRetry-Afterを待たず、CacheDirは429応答も保存する。待機は自分で書く必要があります。キャッシュが有効なうちは、再試行しても保存された429が返ります。- 文字化けの原因は charset の宣言場所で決まる。Colly の既定はヘッダしか見ません。サーバーがヘッダで UTF-8 と誤って宣言しているページは、
DetectCharset()を付けても化けました。
goqueryとCollyの役割分担と対応バージョン
| 項目 | goquery | Colly |
|---|---|---|
| 役割 | HTMLの解析・要素抽出 | 巡回・並行制御・解析の枠組み |
| 最新版(2026-11-01) | v1.13.0(2026-08-27) | v2.3.0(2025-12-04) |
| モジュールパス | github.com/PuerkitoBio/goquery | github.com/gocolly/colly/v2 |
| go.mod の go 行 | 1.25.0 | 1.24.0 |
| HTTP取得 | 自分で書く | フレームワークが担う |
| robots.txt・待機・並行数 | なし | あり(robots.txtは既定で無効) |
goquery は README の更新履歴で v1.12.0(2026-03-15)から Go 1.25 以上を必須にしています。Colly v2.3.0 自体は Go 1.24 で動きますが、同じモジュールに goquery の最新版を入れると go.mod の go 行は 1.25.0 以上になります。Go 1.24 のまま固定したいなら goquery は v1.11.0 までにとどめてください。導入は、go mod init でモジュールを作ってから次の3行です。検証に使った golang.org/x/net v0.62.0 は Go 1.26 以上を要求します。Colly の /v2 を落とすと v1 系が入り、v2 向けのコードはコンパイルが通りません。
go get github.com/PuerkitoBio/[email protected]
go get github.com/gocolly/colly/[email protected]
go get golang.org/x/[email protected]
goqueryによる1ページの取得と要素抽出
1ページだけなら、標準の net/http で取得して goquery に渡します。タイムアウト付きの http.Client、ステータスコードの確認、文字コードの変換までを最初から入れておきます。
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/PuerkitoBio/goquery"
"golang.org/x/net/html/charset"
)
func main() {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", "https://example.com/", nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("User-Agent", "issoh-crawler/1.0 (+https://example.com/contact)")
res, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
log.Fatalf("status %d", res.StatusCode)
}
// Content-Type ヘッダと meta の charset を見て UTF-8 に変換する
body, err := charset.NewReader(res.Body, res.Header.Get("Content-Type"))
if err != nil {
log.Fatal(err)
}
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
log.Fatal(err)
}
fmt.Println("title:", doc.Find("title").Text())
doc.Find("h2").Each(func(i int, s *goquery.Selection) {
href, _ := s.Find("a").Attr("href")
fmt.Println(i, s.Text(), href)
})
}
http.DefaultClient はタイムアウトが0(無制限)なので、応答しないサーバーに当たるとそこで止まります。404のページも HTML としては解析できてしまうため、ステータスコードを確かめないと空振りに気づけません。goquery の NewDocument(url) はこの2点を指定できず、type.go で Deprecated とされています。代わりに「net/http でリクエストし、応答を検証してから NewDocumentFromReader に渡す」よう書かれており、上のコードはその形です。
goquery.Textによる区切り文字付きのテキスト取得
表の1行から Selection.Text() でテキストを取ると、セルの境目が消えて改行や空白がそのまま残ります。v1.13.0 で追加されたトップレベルの goquery.Text 関数は、BeautifulSoup の get_text に近いオプションを持っています。
package main
import (
"fmt"
"strings"
"github.com/PuerkitoBio/goquery"
)
func main() {
html := "<table><tr><td>東京</td><td>\n 晴れ\n</td><td>18℃</td></tr></table>"
doc, _ := goquery.NewDocumentFromReader(strings.NewReader(html))
row := doc.Find("tr")
fmt.Printf("%q\n", row.Text())
fmt.Printf("%q\n", goquery.Text(row, &goquery.TextOptions{Separator: ",", Trim: true}))
}
"東京\n 晴れ\n18℃"
"東京,晴れ,18℃"
Separator がテキストノードの間に区切りを入れ、Trim が各ノードの前後の空白を落とします。v1.12.0 以前にはこの関数が無いので、go.mod の版を確かめてから使ってください。
Collyで複数ページを巡回する設定のひな形
以下は robots.txt の判定、待機と並行数、許可ドメイン、meta にだけ宣言された文字コード、429・503の再試行までを入れた設定です。10MB超のページとヘッダの文字コード誤宣言への対処は、それぞれの章で別に示します。各行が必要な理由は後の章で実測とともに説明します。
package main
import (
"errors"
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/gocolly/colly/v2"
)
const maxRetry = 3
// Retry-After は秒数と HTTP-date の2形式がある(RFC 9110 10.2.3)
func retryWait(v string, fallback time.Duration) time.Duration {
if sec, err := strconv.Atoi(v); err == nil && sec >= 0 {
return time.Duration(sec) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
return max(time.Until(t), 0)
}
return fallback
}
func main() {
c := colly.NewCollector(
colly.AllowedDomains("example.com", "www.example.com"),
colly.Async(true),
colly.DetectCharset(), // meta にしか charset が無いページ向け
colly.UserAgent("issoh-crawler/1.0 (+https://example.com/contact)"),
)
c.IgnoreRobotsTxt = false // 既定は true(robots.txt を見ない)
c.Limit(&colly.LimitRule{
DomainGlob: "*", // "example.com" と書くと www やポート付きに当たらない
Parallelism: 2,
Delay: 1 * time.Second,
RandomDelay: 500 * time.Millisecond,
})
c.OnHTML("a[href]", func(e *colly.HTMLElement) {
err := e.Request.Visit(e.Attr("href"))
var visited *colly.AlreadyVisitedError
if err != nil && !errors.As(err, &visited) {
log.Printf("skip href=%q: %v", e.Attr("href"), err) // ドメイン外・robots.txt など
}
})
c.OnHTML("h1", func(e *colly.HTMLElement) {
fmt.Println(e.Request.URL, e.Text)
})
c.OnError(func(r *colly.Response, err error) {
if r.StatusCode != http.StatusTooManyRequests && r.StatusCode != http.StatusServiceUnavailable {
log.Printf("error %s: %v", r.Request.URL, err)
return
}
// e.Request.Visit で辿った子ページは親と同じ Ctx を共有するので URL ごとに数える
key := "retry:" + r.Request.URL.String()
n, _ := r.Request.Ctx.GetAny(key).(int)
if n >= maxRetry {
log.Printf("giving up: %s", r.Request.URL)
return
}
r.Request.Ctx.Put(key, n+1)
time.Sleep(retryWait(r.Headers.Get("Retry-After"), 5*time.Second))
r.Request.Retry()
})
if err := c.Visit("https://example.com/"); err != nil {
log.Fatal(err)
}
c.Wait() // Async(true) のときは必須
}
テストサーバーで動かすと、robots.txt で Disallow したパスと AllowedDomains の外のリンクは取得されず、skip href="/private/x": URL blocked by robots.txt と Forbidden domain がログに残りました。1回目に429と Retry-After: 2 を返すページは、2秒待ってから取り直しています。User-Agent に連絡先を入れているのは、既定値の colly - https://github.com/gocolly/colly では運用者が誰なのか相手に伝わらないためです。データ収集を業務システムとして組み込む場合は、フルスクラッチ開発で取得・保存・監視まで含めて設計する方法もあります。
Collyのrobots.txt判定は既定で無効
colly.go の Init() には c.IgnoreRobotsTxt = true と書かれています。つまり colly.NewCollector() が返す Collector は robots.txt を見ません。Disallow を返すテストサーバーで確かめた結果が次のとおりです。
c := colly.NewCollector()
fmt.Println("既定の IgnoreRobotsTxt =", c.IgnoreRobotsTxt)
got := false
c.OnHTML("h1.t", func(e *colly.HTMLElement) { got = true })
err := c.Visit("http://example.test/private/page") // robots.txt で Disallow 済み
fmt.Printf("既定: err=%v 取得=%v\n", err, got)
c2 := colly.NewCollector()
c2.IgnoreRobotsTxt = false
fmt.Printf("false: err=%v\n", c2.Visit("http://example.test/private/page"))
既定の IgnoreRobotsTxt = true
既定: err=<nil> 取得=true
false: err=URL blocked by robots.txt
既定の Collector は Disallow されたページを読み取り、Visit は nil を返しました。ログにも戻り値にも手がかりが残りません。User-Agent と Limit を設定すればクロールポリシーを守れると説明する解説もありますが、それだけでは robots.txt は読まれません。
CollyのCrawl-delay非適用と待機の設定
IgnoreRobotsTxt = false にしても、robots.txt の Crawl-delay: 2 は効きません。3ページを連続で取得した所要時間は0.00秒でした。robots.txt の標準である RFC 9309 は Crawl-delay を定義しておらず、Colly が判定に使うのは Allow と Disallow だけです。指定があるサイトでは、その秒数を LimitRule の Delay に写し、Parallelism を1にしてください。Delay は並行枠ごとに応答を受け取ったあとに待つ仕組みなので、Parallelism が2のままだと2件がほぼ同時に送られることがあります。
LimitRuleが効かなくなる2つの条件
http_backend.go の LimitRule では、Delay が固定の待機、RandomDelay が Delay に足す乱数の待機、Parallelism が同時リクエスト数の上限と定義されています。実際に測った値は次のとおりです。
| 設定 | 対象 | 所要時間 |
|---|---|---|
| Delay 1秒 / Parallelism 1 | 2ページ | 2.02秒 |
| RandomDelay 1秒 / Parallelism 1 | 4ページ | 2.10秒(乱数) |
| Asyncなし / Parallelism 4 | 0.3秒のページ4件 | 1.21秒 |
| Async(true) / Parallelism 4 | 0.3秒のページ4件 | 0.30秒 |
2ページで2.02秒かかるのは、Colly が応答を受け取ったあとに枠を握ったまま Delay だけ眠る実装で、最後の1件のあとにも待つためです。
逐次のVisit呼び出しとAsync(true)の関係
Async なしの1.21秒は、0.3秒×4件を直列に処理した時間そのものです。Visit を1つのゴルーチンから順に呼ぶ場合、Parallelism を4にしても、colly.Async(true) を付けない Collector は1件ずつ処理します(自前のゴルーチンから並行して呼べば Parallelism の上限は働きます)。逆に Async(true) にしたときは c.Wait() が必須です。Wait を呼ばずに戻った時点では、4件とも OnHTML が1回も呼ばれていませんでした(0件)。
DomainGlobの照合対象とポート番号
Colly は DomainGlob を、リクエスト URL の Host(ポートを含む)と照合します。照合に失敗したリクエストには、待機も並行数の上限もかかりません。エラーにもならないので、気づかないまま全速で相手を叩くことになります。Delay を1秒にして3ページを取得した結果です。
| DomainGlob | アクセス先 | 所要時間 |
|---|---|---|
| example.test | www.example.test | 0.00秒(無効) |
| *.example.test | www.example.test | 3.01秒 |
| *.example.test | example.test | 0.00秒(無効) |
| example.test | example.test:8080 | 0.00秒(無効) |
*.example.test はサブドメインには当たりますが、www の付かない apex には当たりません。対象が1サイトなら DomainGlob: "*" で全リクエストを縛るのが確実です。ドメインごとに間隔を変えたいときだけ、apex 用と *. 付きのルールを両方登録してください。
AllowedDomainsはホスト名の完全一致
AllowedDomains も文字列の完全一致で判定されます。ただしこちらは Host でなくホスト名と照合するので、ポートは関係しません。colly.AllowedDomains("example.test") で試した結果です。
| Visit先 | 結果 |
|---|---|
| http://example.test/ | 取得 |
| http://www.example.test/ | Forbidden domain |
| http://EXAMPLE.test/ | 取得(URL側は小文字化される) |
| http://example.test:8080/ | 取得 |
リンクに www 付きと無しが混在すると、片方だけが黙って捨てられるので両方を列挙します。また v2.3.0 では設定値に大文字を書くと一致せず、AllowedDomains("Example.test") は example.test を Forbidden domain で拒否しました。大文字小文字を区別しない照合は 2026-10-05 のコミット 5740023 で master に入りましたが、2026-11-01 時点でリリースされていません。設定値は小文字で書いておけば、どちらの版でも同じ結果になります。
429・503応答のリトライとキャッシュ
Retry()の即時再送とRetry-Afterの待機処理
Colly は既定で、最終応答のステータスコードが203以上だと OnError を呼ぶので、そこで r.Request.Retry() を呼べば同じ URL を再送できます。ただし待機はしません。Retry-After: 3 を返すサーバーに Retry() だけを呼ぶと、0.00秒で2回目を送りました。制限をかけた直後に、もう一度リクエストが届く状態です。
RFC 9110 の10.2.3節では、Retry-After の値は秒数(delay-seconds)と日時(HTTP-date)のどちらでもよいとされています。秒数しか想定せず strconv.Atoi だけで読むと、日時形式のときに解釈に失敗します。ひな形の retryWait は両方を読む形にしました。3秒後の日時を返す503も、日時を解釈して待ってから取り直せることを確認しています。再試行の回数は Request.Ctx に URL ごとのキーで持たせています。e.Request.Visit で辿った子ページは親と同じ Ctx を共有するので、キーが1つだと親の再試行回数が子に引き継がれます。URL ごとに数えると、親が1回再試行したあとでも子ページはそれぞれ4回(初回+再試行3回)で打ち切られました。自前の map は Async(true) でデータ競合になるため使いません(go run -race で警告なし)。
CacheDirによる429応答の保存と再試行への影響
colly.CacheDir() を設定すると、GET の応答がファイルに保存されます。ソースでは、保存の対象はステータスコードが500未満の応答です。429と404も保存されます。キャッシュを有効にしたまま、OnError で最大2回まで再試行した結果が次のとおりです。
OnError try=1 status=429
OnError try=2 status=429
OnError try=3 status=429
サーバーが受けたリクエスト = 1
次回の実行: OnError status=429(サーバーへのリクエストは増えず)
CacheExpiration を設定した実行: OnResponse status=200
初回の429のあと、2回の再試行はどちらもキャッシュの429を受け取っただけで、サーバーへは1回しか届いていません。次の実行でも同じ429が返り続けます。503は保存されないので、2回目のリクエストで200が返りました。v2.3.0 で追加された CacheExpiration を設定すると、指定時間より古いキャッシュは捨てられます。リクエストヘッダに Cache-Control: no-cache を付けて再試行するとキャッシュを迂回して200を取得できましたが、保存済みの429は消えず、次の実行でも429が返りました。期限までは429が残るので、本番の巡回では CacheDir を使わず、開発中に同じ HTML を取り直さないための機能と割り切るのが安全です。
10MBを超えるページの切り詰め
Collector の MaxBodySize は既定で10MB(10,485,760バイト)です。11MB のページはちょうどこのサイズで切られ、Visit は nil を返しました。末尾にある要素は OnHTML で見つかりません。巨大な一覧ページやサイトマップを扱うときは、想定サイズと利用可能なメモリに合わせて上限を増やしてください。Colly は応答本文をすべてメモリに読み込むので、colly.MaxBodySize(0)(無制限、試験では末尾の要素も取得できた)は取得サイズを別途管理できる場合に限って使います。
Shift_JIS・EUC-JPページの文字化け
Shift_JIS のページを charset の宣言場所ごとに取得し、日本語が正しく読めたかを比べました。
| charsetの宣言 | Colly既定 | +DetectCharset() | charset.NewReader |
|---|---|---|---|
| Content-Typeヘッダ | ○ | ○ | ○ |
| meta charsetのみ | × | ○ | ○ |
| meta http-equivのみ | × | ○ | ○ |
| 宣言なし | × | ○ | × |
| ヘッダがUTF-8と誤宣言 | × | × | × |
Colly の既定はレスポンスヘッダの charset しか見ません。colly.DetectCharset() を付けると、ヘッダに charset が無いページでバイト列から推定します。charset.NewReader は BOM、Content-Type ヘッダ、meta の宣言、UTF-8 として妥当かの順に判定し、どれでも決まらなければ Windows-1252 とみなします。宣言の無い Shift_JIS のページはこの最後の扱いになって化けました。
厄介なのは最後の行です。中身は Shift_JIS なのにサーバーが charset=utf-8 を返すと、3つとも化けました。NewReader はヘッダを meta より優先し、Colly はヘッダに charset があると推定に回らないためです。この場合は NewReader の第2引数に空文字を渡すと、meta の Shift_JIS が使われて正しく読めました。Colly なら OnRequest の中で r.ResponseCharacterEncoding = "Shift_JIS" を設定すると、ヘッダを無視してその文字コードで変換し、正しく読めました。goquery 単体で文字コードが分かっているなら、デコーダを直接指定するのが最も確実です(golang.org/x/text/transform と golang.org/x/text/encoding/japanese を import)。
res, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
// 文字コードが分かっているときは推定に任せず直接指定する
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 に差し替えます。
短いページでのDetectCharsetの誤判定
DetectCharset() は宣言を読まずに推定するので、判定材料が少ないと外れます。meta にだけ charset を書いた Shift_JIS のページ(<meta charset="Shift_JIS"> と <h1>日本語の見出しです</h1> の後ろに <p>本文です。</p> を0〜20回繰り返したもの)を取得しました。
| HTML全体のサイズ | DetectCharset() | charset.NewReader |
|---|---|---|
| 92バイト | × | ○ |
| 109バイト | ○ | ○ |
| 432バイト | ○ | ○ |
今回は109バイト以上で正しく判定しましたが、中身からの推定なので長ければ必ず当たるわけではありません。誤判定しやすいのはエラーページや空の一覧ページのような中身の薄い応答で、そこだけ化けた文字列が抽出結果に紛れ込みます。文字コードが分かっているサイトは推定に頼らず指定してください。
JavaScriptで描画されるページの取得
goquery も Colly も JavaScript は実行しません。ブラウザで見える要素が最初の HTML 応答に無ければ、ヘッドレスの Chrome を操作する chromedp(v0.21.0・2026-10-11)を使います。描画の完了を待つ書き方はchromedpとは?GoでChromeを自動操作する使い方【v0.21対応・新API】で扱っています。ページが裏で JSON の API を呼んでいるなら、その API を net/http で直接取るほうが軽く済みます。
ディレクトリ構成と取得結果の保存
Go のプロジェクト構成として cmd/・pkg/・internal/ の3点セットがよく紹介されますが、go.dev のモジュール構成ガイドに pkg/ は出てきません。ガイドが挙げるのは、他のモジュールから import させない internal/ と、コマンドをまとめる cmd/ です。リポジトリがコマンドだけで構成されるなら cmd/ は厳密には必要ないとされています。
#(1)単発のスクリプト
scraper/
go.mod
main.go
#(2)取得・抽出・保存を分ける
scraper/
go.mod
main.go
internal/
fetch/ # Collector の設定・リトライ
parse/ # goquery での抽出(io.Reader を受け取る)
store/ # CSV・DB への書き出し
#(3)実行ファイルが増えたら
scraper/
go.mod
cmd/
crawl/main.go
export/main.go
internal/
fetch/ parse/ store/
スクレイパならではの分け方は、parse パッケージに HTTP を持ち込まないことです。抽出関数が io.Reader を受け取る形なら、保存しておいた HTML ファイルを渡すだけでテストが書けます。相手のサイトに接続しないと動かない構造だと、HTML が変わって壊れたときに、原因が相手側の変更か自分のバグかを切り分けられません。
保存では encoding/csv の Flush を忘れやすいので注意してください。csv.Writer はバッファを持つため、下のような数行の書き込みでは、Flush せずにファイルを閉じると0バイトのファイルが残ります(Flush ありは53バイト)。書き込みがバッファを超えれば途中で書き出されますが、最後の端数は Flush しないと失われます。Writer.Error はそれまでの Write または Flush で起きたエラーを返すので、Flush の直後に確認します。
package main
import (
"encoding/csv"
"log"
"os"
)
func main() {
f, err := os.Create("result.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
w := csv.NewWriter(f)
w.Write([]string{"title", "url"})
w.Write([]string{"日本語の見出し", "https://example.com/"})
w.Flush() // これが無いと 0 バイトのファイルが残る
if err := w.Error(); err != nil {
log.Fatal(err)
}
}
長時間の巡回では、1件ずつ書き出せる JSON Lines 形式にして都度 Flush しておくと、途中で止まっても取得済みの分が残ります。
よくある質問
GoのスクレイピングでgoqueryとCollyはどう使い分けますか?
URL が決まっている数ページなら net/http と goquery で足ります。リンクを辿る巡回、同時接続数や間隔の制御、robots.txt の判定が要るなら Colly です。Colly は内部で goquery を使うので、併用も普通の構成です。
Collyはrobots.txtを自動で守りますか?
守りません。v2.3.0 の既定は IgnoreRobotsTxt が true で、Disallow されたパスもエラーなく取得します。c.IgnoreRobotsTxt = false にすると URL blocked by robots.txt で止まります。Crawl-delay は適用されないので、Delay に秒数を写して Parallelism を1にしてください。
CollyのDelayを設定したのに間隔が空かないのはなぜですか?
DomainGlob がアクセス先と一致していない可能性が高いです。ポートを含む Host と照合されるため、"example.com" では www.example.com やポート付きの URL に当たらず、待機なしで送信されます。1サイトだけなら DomainGlob: "*" にしてください。並行数が効かない場合は、Visit を順に呼んでいるのに colly.Async(true) が抜けていないかも確認します。
429が返るときはどう再試行すればよいですか?
OnError の中で Retry-After(秒数か HTTP-date)の分だけ待ってから r.Request.Retry() を呼び、上限回数を URL ごとに Request.Ctx へ記録します。CacheDir を有効にしていると429がキャッシュから返り続けるため、再試行する巡回ではキャッシュを外します。
スクレイピングを防止しているサイトかどうかはどう判断しますか?
robots.txt の対象パスに対する Disallow と、利用規約の自動取得禁止条項を確認します。403はリクエストの拒否、429は一定時間内のリクエスト過多を示しますが、ステータスコードだけではスクレイピング対策かどうかは断定できません。応答本文や運営者の案内も確認してください。Colly が判定できるのは robots.txt だけ(IgnoreRobotsTxt = false のとき)です。法的な論点はスクレイピングとは?クローリング・APIとの違いと実装・法務の判断を解説で整理しています。