docrawl() completely refactored to use goroutine per URL

* outputs status
* crawlStatus() function created (split out of docrawl())
* changed from using bool flags to linkType in single httpItem enum, tests updated to match this
* unified where we set httpItem.linkType (inside fetchType), appropriate errors and returns added
* renamed a lot of things from 'page' to 'item'
* include refurl in httpItem
* []*Location result is now sorted
This commit is contained in:
swaits committed 2015-02-16 22:13:52 -08:00
1 parent d7cc760e5b
commit 72cac47961
6 files changed
+171 -94

No files matched your search

-2
View File
@@ -10,14 +10,12 @@
## Cleanup
* rename Page -> crawlerState
* helper function to do the whole crawl process
* document code better
* test with godoc
## Features
* some sort of status during crawl?
* command line options, flag parsing
* check RFCs on URL character sets, compliance, etc.
* packagize parts of app
+95 -63
View File
@@ -2,88 +2,120 @@ package main
import (
"fmt"
"log"
"os"
"time"
)
type itemSlice []*httpItem
type itemMap map[string]*httpItem
// docrawl begins crawling the site at "homeurl"
func docrawl(homeurl string) itemSlice {
// set of what we have already crawled
crawled := make(map[string]struct{})
// map (of urlString -> httpItem) of our results
results := make(itemMap)
// docrawl begins crawling the site at "url"
func docrawl(url string) itemSlice {
// our result structure
var sitemap itemSlice
// a channel to receive our results on
rchan := make(chan *httpItem)
// what we've already parsed, what we still need to parse
crawled := make(itemMap)
queued := make(itemMap)
// start a ticker which we'll use to output status and check for completion
ticker := time.Tick(250 * time.Millisecond)
// create first page and add to the queue
homepage, _ := newHTTPItem(nil, url) // TODO check error
queued[homepage.url.String()] = homepage
// create first page's httpItem
homeitem, err := newHTTPItem(nil, homeurl)
if err != nil {
log.Fatal("unable to create httpItem for homeurl")
}
// main loop
for len(queued) > 0 {
// pop a page off of queued
var dest *httpItem
var destURL string
for destURL, dest = range queued {
break
}
delete(queued, destURL)
// set our number of outstanding pages (initially 1 to account for first page)
crawlingCount := 1
// mark this page as complete
crawled[dest.url.String()] = dest
// start the home page crawl
crawled[homeitem.url.String()] = struct{}{}
go crawlItem(homeitem, rchan)
// add page to list
sitemap = append(sitemap, dest)
// wait for results
for {
select {
case r := <-rchan: // new results?
// add result to our results map
results[r.url.String()] = r
// make sure this URL has the same hostname as our first page
if dest.url.Host != homepage.url.Host {
// skip URLs associated with other Hosts
dest.skipped = true
continue
}
// decrease the outstanding page count by 1
crawlingCount--
// fetch page
text, err := fetchPage(dest)
if err != nil {
dest.broken = true
continue
}
// start crawly any new child pages we haven't yet crawled
for i, c := range r.children {
// see if we already have a result for this page, if so, point to that result
if existing, ok := results[c.url.String()]; ok {
r.children[i] = existing
continue
}
// parse links
title, links, err := parseLinks(text)
if err != nil {
dest.broken = true
continue
}
dest.title = title
for _, l := range links {
p, err := newHTTPItem(dest, l)
if err != nil {
continue // TODO check error
// see if we're already crawling this page (but maybe don't have results yet)
if _, ok := crawled[c.url.String()]; !ok {
// haven't crawled this one yet, do so now
crawlingCount++
crawled[c.url.String()] = struct{}{}
go crawlItem(c, rchan)
}
}
// see if we already know about this page
pCrawled, haveCrawled := crawled[p.url.String()]
pQueued, haveQueued := queued[p.url.String()]
if haveCrawled {
// add the previously crawled page to children
dest.children = append(dest.children, pCrawled)
} else if haveQueued {
// add the previously queued page to children
dest.children = append(dest.children, pQueued)
} else {
// add this new page (never seen) to children
dest.children = append(dest.children, p)
// .. and queue it up for crawling
queued[p.url.String()] = p
case <-ticker: // our regular ticker. for status output and checking for completion.
// output status to console
log.Printf("Crawled %v links, have %v left.\n", len(results), crawlingCount)
// see if we're finished
if crawlingCount == 0 {
// finished! convert results map to a slice and return it
rslice := itemSlice{}
for _, v := range results {
rslice = append(rslice, v)
}
return rslice
}
}
}
}
return sitemap
// crawlItem crawls a single httpItem, fetching the header, hte page, parsing it,
// and filling out its structure as much as possible
func crawlItem(item *httpItem, rchan chan<- *httpItem) {
// make sure this item is the same domain (i.e. URL "host part") as its referrer
if item.refurl != nil && item.url.Host != item.refurl.Host {
// skip URLs associated with other Hosts
item.linkType = tRemote
rchan <- item
return
}
// fetch page
text, err := fetchPage(item)
if err != nil {
rchan <- item
return
}
// parse links
title, links, _ := parseLinks(text) // TODO remove error from parseLinks, not needed
if err != nil {
item.linkType = tBroken
rchan <- item
return
}
item.title = title
// walk links and add them as children to the current item
for _, l := range links {
newItem, err := newHTTPItem(item, l)
if err != nil {
continue // TODO bad item
}
item.children = append(item.children, newItem)
}
// send back our item struct now that it's all filled out
rchan <- item
}
func main() {
+7 -7
View File
@@ -91,7 +91,7 @@ func TestSimpleMap(t *testing.T) {
} else if p.url.String() == "http://doesntexist23492387492837492374982734.com/" {
skippage = p
}
if p.skipped {
if p.linkType == tRemote {
skipped++
}
children += len(p.children)
@@ -127,11 +127,11 @@ func TestSimpleMap(t *testing.T) {
}
// make sure remote pages were skipped
if homepage.skipped != false {
if homepage.linkType == tRemote {
t.Logf("found a page that should NOT have been skipped %q", homepage.children[0].url.String())
t.Error("child was skipped")
}
if skippage.skipped != true {
if skippage.linkType != tRemote {
t.Logf("found a page that should have been skipped %q", pages[3].children[2].url.String())
t.Error("child wasn't skipped")
}
@@ -161,7 +161,7 @@ func TestHeaderFetching(t *testing.T) {
if err != nil {
t.Error("problem creating New httpItem struct")
}
if err := fetchFiletype(basepage); err != nil || basepage.mediaType != "text/html" {
if err := fetchFiletype(basepage); err != nil || basepage.linkType != tHTMLPage {
t.Error("problem fetching filetype")
}
@@ -169,7 +169,7 @@ func TestHeaderFetching(t *testing.T) {
if err != nil {
t.Error("problem creating New httpItem struct")
}
if err := fetchFiletype(page); err != nil || page.mediaType != "text/html" {
if err := fetchFiletype(page); err != nil || page.linkType != tHTMLPage {
t.Error("problem fetching filetype")
}
@@ -177,8 +177,8 @@ func TestHeaderFetching(t *testing.T) {
if err != nil {
t.Error("problem creating New httpItem struct")
}
if err := fetchFiletype(page); err != nil || page.mediaType != "image/png" {
t.Logf("got %q, wanted %q", page.mediaType, "image/png")
if err := fetchFiletype(page); err != nil || page.linkType != tAsset {
t.Logf("got %q, wanted %q", page.linkType, tAsset)
t.Error("problem fetching filetype")
}
}
+24 -10
View File
@@ -9,19 +9,22 @@ import (
var (
errContentTypeNotFound = errors.New("no Content-Type header found")
errFetchError = errors.New("couldn't fetch page")
errFetchError = errors.New("couldn't fetch item")
errFileTypeUnknown = errors.New("couldn't determine file type")
)
// fetchFiletype performs an http HEAD to get the media type, and sets it
// directly in httpItem.mediaType
func fetchFiletype(page *httpItem) error {
resp, err := http.Head(page.url.String())
func fetchFiletype(item *httpItem) error {
resp, err := http.Head(item.url.String())
if err != nil {
item.linkType = tBroken
return err
}
// check response code
if resp.StatusCode != http.StatusOK {
item.linkType = tBroken
return errFetchError
}
@@ -29,35 +32,46 @@ func fetchFiletype(page *httpItem) error {
contentType, ok := resp.Header["Content-Type"]
if !ok {
// no Content-Type header found
item.linkType = tBroken
return errContentTypeNotFound
}
// parse mime type
mediatype, _, err := mime.ParseMediaType(contentType[0])
if err != nil {
item.linkType = tBroken
return err
}
// set and return
page.mediaType = mediatype
// success, set item type and return
if mediatype == "text/html" {
item.linkType = tHTMLPage
} else {
item.linkType = tAsset
}
return nil
}
// fetchPage takes a httpItem, GETs it, and returns the body as a string
func fetchPage(page *httpItem) (string, error) {
func fetchPage(item *httpItem) (string, error) {
// figure out the file type
err := fetchFiletype(page)
err := fetchFiletype(item)
if err != nil {
return "", err
}
// we only want to fetch html
if page.mediaType != "text/html" {
// make sure we got a file type!
if item.linkType == tUnknown {
return "", errFileTypeUnknown
}
// we only want to fetch html, so return if it's anything else
if item.linkType != tHTMLPage {
return "", nil // but this isn't an error!
}
// GET the url
resp, err := http.Get(page.url.String())
resp, err := http.Get(item.url.String())
if err != nil {
return "", err
}
+29 -7
View File
@@ -4,14 +4,30 @@ import (
"net/url"
)
// itemSlice is a convenience type for a slice of items
type itemSlice []*httpItem
// itemMap convenience type for map of string's (URL) to items
type itemMap map[string]*httpItem
// itemType is an enum so we know how to classify each item
type itemType int
const (
tUnknown itemType = iota
tHTMLPage
tAsset
tRemote
tBroken
)
// httpItem is a struct which defines a single page, which URLs (links and assets) it contains, etc.
type httpItem struct {
url *url.URL
title string
mediaType string
skipped bool
broken bool
children []*httpItem
url *url.URL
refurl *url.URL
title string
linkType itemType
children itemSlice
}
// newHTTPItem takes a referring httpItem + a URL and returns a new &httpItem{}
@@ -28,6 +44,12 @@ func newHTTPItem(referrer *httpItem, rawurl string) (*httpItem, error) {
return nil, err
}
// figure out referrer url
var rurl *url.URL
if referrer != nil {
rurl = referrer.url
}
// create struct and return
return &httpItem{url: u, skipped: false, broken: false}, nil
return &httpItem{url: u, refurl: rurl}, nil
}
+16 -5
View File
@@ -15,7 +15,16 @@ type Location struct {
Remote []string
}
func sitemapToLocations(pages []*httpItem) []*Location {
// implement Location slice sorting (by URL)
type byURL []*Location
// implement Sort interface on byURL (which is a []*Location)
func (l byURL) Len() int { return len(l) }
func (l byURL) Less(i, j int) bool { return l[i].URL < l[j].URL }
func (l byURL) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
// sitemapToLocations converts an itemSlice to []*Location, which is appropriate for marshalling
func sitemapToLocations(pages itemSlice) []*Location {
// build a map of our pages
pageMap := make(map[string]*httpItem)
for _, p := range pages {
@@ -25,7 +34,7 @@ func sitemapToLocations(pages []*httpItem) []*Location {
// build a slice of locations (one per page)
var locations []*Location
for _, p := range pages {
if p.mediaType == "text/html" {
if p.linkType == tHTMLPage {
// create a location for this page
l := &Location{URL: p.url.String(), Title: p.title}
@@ -33,11 +42,11 @@ func sitemapToLocations(pages []*httpItem) []*Location {
for _, c := range p.children {
// look up this child's media type from the root list of pages
//mediaType := pageMap[c.url.String()].mediaType
if c.skipped {
if c.linkType == tRemote {
l.Remote = append(l.Remote, c.url.String())
} else if c.mediaType == "text/html" {
} else if c.linkType == tHTMLPage {
l.Links = append(l.Links, c.url.String())
} else if c.broken {
} else if c.linkType == tBroken {
l.Broken = append(l.Broken, c.url.String())
} else {
l.Assets = append(l.Assets, c.url.String())
@@ -55,6 +64,8 @@ func sitemapToLocations(pages []*httpItem) []*Location {
}
}
// sort the Locations themselves and return
sort.Sort(byURL(locations))
return locations
}