forked from PuerkitoBio/gocrawl
/
worker.go
348 lines (301 loc) · 9.76 KB
/
worker.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
package gocrawl
import (
"bytes"
"exp/html"
"github.com/PuerkitoBio/goquery"
"github.com/temoto/robotstxt.go"
"io/ioutil"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// The worker is dedicated to fetching and visiting a given host, respecting
// this host's robots.txt crawling policies.
type worker struct {
// Worker identification
host string
index int
// User-agent strings
userAgent string
robotUserAgent string
// Communication channels and sync
push chan<- *workerResponse
pop popChannel
stop chan bool
wg *sync.WaitGroup
wait <-chan time.Time
// Config
crawlDelay time.Duration
idleTTL time.Duration
robotsGroup *robotstxt.Group
// Callbacks
extender Extender
logFunc func(LogFlags, string, ...interface{})
// Implementation fields
lastFetch *FetchInfo
lastCrawlDelay time.Duration
}
// Start crawling the host.
func (this *worker) run() {
defer func() {
this.logFunc(LogInfo, "worker done.")
this.wg.Done()
}()
// Enter loop to process URLs until stop signal is received
for {
var idleChan <-chan time.Time
this.logFunc(LogInfo, "waiting for pop...")
// Initialize the idle timeout channel, if required
if this.idleTTL > 0 {
idleChan = time.After(this.idleTTL)
}
select {
case <-this.stop:
this.logFunc(LogInfo, "stop signal received.")
return
case <-idleChan:
this.logFunc(LogInfo, "idle timeout received.")
this.sendResponse(nil, false, nil, true)
return
case batch := <-this.pop:
// Got a batch of urls to crawl, loop and check at each iteration if a stop
// is received.
for _, cmd := range batch {
this.logFunc(LogInfo, "popped: %s", cmd.u.String())
if isRobotsTxtUrl(cmd.u) {
this.requestRobotsTxt(cmd.u)
} else if this.isAllowedPerRobotsPolicies(cmd.u) {
this.requestUrl(cmd.u, cmd.head)
} else {
// Must still notify Crawler that this URL was processed, although not visited
this.extender.Disallowed(cmd.u)
this.sendResponse(cmd.u, false, nil, false)
}
// No need to check for idle timeout here, no idling while looping through
// a batch of URLs.
select {
case <-this.stop:
this.logFunc(LogInfo, "stop signal received.")
return
default:
// Nothing, just continue...
}
}
}
}
}
// Checks if the given URL can be fetched based on robots.txt policies.
func (this *worker) isAllowedPerRobotsPolicies(u *url.URL) bool {
if this.robotsGroup != nil {
// Is this URL allowed per robots.txt policy?
ok := this.robotsGroup.Test(u.Path)
if !ok {
this.logFunc(LogIgnored, "ignored on robots.txt policy: %s", u.String())
}
return ok
}
// No robots.txt = everything is allowed
return true
}
// Process the specified URL.
func (this *worker) requestUrl(u *url.URL, headRequest bool) {
if res, ok := this.fetchUrl(u, this.userAgent, headRequest); ok {
var harvested []*url.URL
var visited bool
// Close the body on function end
defer res.Body.Close()
// Any 2xx status code is good to go
if res.StatusCode >= 200 && res.StatusCode < 300 {
// Success, visit the URL
harvested = this.visitUrl(res)
visited = true
} else {
// Error based on status code received
this.extender.Error(newCrawlErrorMessage(res.Status, CekHttpStatusCode, u))
this.logFunc(LogError, "ERROR status code for %s: %s", u.String(), res.Status)
}
this.sendResponse(u, visited, harvested, false)
}
}
// Process the robots.txt URL.
func (this *worker) requestRobotsTxt(u *url.URL) {
// Ask if it should be fetched
if reqRob, robData := this.extender.RequestRobots(u, this.robotUserAgent); !reqRob {
this.logFunc(LogInfo, "using robots.txt from cache")
this.robotsGroup = this.getRobotsTxtGroup(robData, nil)
} else {
// Fetch the document, using the robot user agent,
// so that the host admin can see what robots are doing requests.
if res, ok := this.fetchUrl(u, this.robotUserAgent, false); ok {
// Close the body on function end
defer res.Body.Close()
this.robotsGroup = this.getRobotsTxtGroup(nil, res)
}
}
}
// Get the robots.txt group for this crawler.
func (this *worker) getRobotsTxtGroup(b []byte, res *http.Response) (g *robotstxt.Group) {
var data *robotstxt.RobotsData
var e error
if res != nil {
// Get the bytes from the response body
b, e = ioutil.ReadAll(res.Body)
// Rewind the res.Body (by re-creating it from the bytes)
res.Body = ioutil.NopCloser(bytes.NewBuffer(b))
// Error or not, the robots.txt has been fetched, so notify
this.extender.FetchedRobots(res)
}
if e == nil {
data, e = robotstxt.FromBytes(b)
}
// If robots data cannot be parsed, will return nil, which will allow access by default.
// Reasonable, since by default no robots.txt means full access, so invalid
// robots.txt is similar behavior.
if e != nil {
this.extender.Error(newCrawlError(e, CekParseRobots, nil))
this.logFunc(LogError, "ERROR parsing robots.txt for host %s: %s", this.host, e.Error())
} else {
g = data.FindGroup(this.robotUserAgent)
}
return
}
// Set the crawl delay between this request and the next.
func (this *worker) setCrawlDelay() {
var robDelay time.Duration
if this.robotsGroup != nil {
robDelay = this.robotsGroup.CrawlDelay
}
this.lastCrawlDelay = this.extender.ComputeDelay(this.host,
&DelayInfo{this.crawlDelay,
robDelay,
this.lastCrawlDelay},
this.lastFetch)
this.logFunc(LogInfo, "using crawl-delay: %v", this.lastCrawlDelay)
}
// Request the specified URL and return the response.
func (this *worker) fetchUrl(u *url.URL, agent string, headRequest bool) (res *http.Response, ok bool) {
var e error
for {
// Wait for crawl delay, if one is pending.
this.logFunc(LogTrace, "waiting for crawl delay")
if this.wait != nil {
<-this.wait
this.wait = nil
}
// Compute the next delay
this.setCrawlDelay()
// Compute the fetch duration
now := time.Now()
// Request the URL
if res, e = this.extender.Fetch(u, agent, headRequest); e != nil {
// No fetch, so set to nil
this.lastFetch = nil
// Notify error
this.extender.Error(newCrawlError(e, CekFetch, u))
this.logFunc(LogError, "ERROR fetching %s: %s", u.String(), e.Error())
// Return from this URL crawl
this.sendResponse(u, false, nil, false)
return nil, false
} else {
// Get the fetch duration
fetchDuration := now.Sub(time.Now())
// Crawl delay starts now.
this.wait = time.After(this.lastCrawlDelay)
// Keep trace of this last fetch info
this.lastFetch = &FetchInfo{fetchDuration, res.StatusCode, headRequest, isRobotsTxtUrl(u)}
}
if headRequest {
// Close the HEAD request's body
defer res.Body.Close()
// Next up is GET request, maybe
headRequest = false
// Ask caller if we should proceed with a GET
if !this.extender.RequestGet(res) {
this.logFunc(LogIgnored, "ignored on HEAD filter policy: %s", u.String())
this.sendResponse(u, false, nil, false)
ok = false
break
}
} else {
ok = true
break
}
}
return
}
// Send a response to the crawler.
func (this *worker) sendResponse(u *url.URL, visited bool, harvested []*url.URL, idleDeath bool) {
// Push harvested urls back to crawler, even if empty (uses the channel communication
// to decrement reference count of pending URLs)
if !isRobotsTxtUrl(u) {
// If a stop signal has been received, ignore the response, since the push
// channel may be full and could block indefinitely.
select {
case <-this.stop:
this.logFunc(LogInfo, "ignoring send response, will stop.")
// Put the signal back in stop channel, so that it is caught in the run() loop.
this.stop <- true
return
default:
// Nothing, just continue...
}
// No stop signal, send the response
res := &workerResponse{this.host, visited, u, harvested, idleDeath}
this.push <- res
}
}
// Process the response for a URL.
func (this *worker) visitUrl(res *http.Response) []*url.URL {
var doc *goquery.Document
var harvested []*url.URL
var doLinks bool
// Load a goquery document and call the visitor function
if bd, e := ioutil.ReadAll(res.Body); e != nil {
this.extender.Error(newCrawlError(e, CekReadBody, res.Request.URL))
this.logFunc(LogError, "ERROR reading body %s: %s", res.Request.URL.String(), e.Error())
} else {
if node, e := html.Parse(bytes.NewBuffer(bd)); e != nil {
this.extender.Error(newCrawlError(e, CekParseBody, res.Request.URL))
this.logFunc(LogError, "ERROR parsing %s: %s", res.Request.URL.String(), e.Error())
} else {
doc = goquery.NewDocumentFromNode(node)
doc.Url = res.Request.URL
}
// Re-assign the body so it can be consumed by the visitor function
res.Body = ioutil.NopCloser(bytes.NewBuffer(bd))
}
// Visit the document (with nil goquery doc if failed to load)
if harvested, doLinks = this.extender.Visit(res, doc); doLinks {
// Links were not processed by the visitor, so process links
if doc != nil {
harvested = this.processLinks(doc)
} else {
this.extender.Error(newCrawlErrorMessage("No goquery document to process links.", CekProcessLinks, res.Request.URL))
this.logFunc(LogError, "ERROR processing links %s", res.Request.URL.String())
}
}
// Notify that this URL has been visited
this.extender.Visited(res.Request.URL, harvested)
return harvested
}
// Scrape the document's content to gather all links
func (this *worker) processLinks(doc *goquery.Document) (result []*url.URL) {
urls := doc.Find("a[href]").Map(func(_ int, s *goquery.Selection) string {
val, _ := s.Attr("href")
return val
})
for _, s := range urls {
// If href starts with "#", then it points to this same exact URL, ignore (will fail to parse anyway)
if len(s) > 0 && !strings.HasPrefix(s, "#") {
if parsed, e := url.Parse(s); e == nil {
parsed = doc.Url.ResolveReference(parsed)
result = append(result, parsed)
} else {
this.logFunc(LogIgnored, "ignore on unparsable policy %s: %s", s, e.Error())
}
}
}
return
}