Compare commits

...
Author SHA1 Message Date
swaits 154ef4321e flow: Closed <feature> 'cleanup_and_refactoring'. 2015-02-17 10:38:04 -08:00
swaits 3e35fdb488 some cleanup and refactoring
* moved crawlItem into fetch.go
* renamed fetchPage fetchItem
* doCrawl now takes number of workers as a parameter
* command line flag parsing added
2015-02-17 10:36:48 -08:00
swaits 6c9434e39a flow: Created branch 'feature/cleanup_and_refactoring'. 2015-02-17 09:53:28 -08:00
swaits 04e26b214e flow: Merged <feature> 'use_crawler_workers' to <develop> ('develop'). 2015-02-17 09:28:34 -08:00
swaits b502cfd5b1 flow: Closed <feature> 'use_crawler_workers'. 2015-02-17 09:28:34 -08:00
swaits 13b84ee452 completion of crawler worker mode. tests still passing! 2015-02-17 09:28:24 -08:00
swaits 3f85a0e7c0 switch to using a limited number of crawler worker functions 2015-02-17 09:24:52 -08:00
swaits e80ef1b1b9 flow: Created branch 'feature/use_crawler_workers'. 2015-02-17 09:24:26 -08:00
swaits e38595db7e realized we don't need the second itemMap ("results"), just the one ("crawled"). tests still pass. 2015-02-17 08:56:46 -08:00
swaits c13c5cf7dd better usage, command line error handling 2015-02-17 02:10:50 -08:00
swaits 71e0ea4975 flow: Merged <feature> 'make_json_pretty' to <develop> ('develop'). 2015-02-17 01:50:46 -08:00
swaits 1c0512a98d flow: Closed <feature> 'make_json_pretty'. 2015-02-17 01:50:46 -08:00
swaits 1a66b03d0d add indentation to json output. update test. 2015-02-17 01:50:39 -08:00
swaits 21e2acd4a5 flow: Created branch 'feature/make_json_pretty'. 2015-02-17 01:34:30 -08:00
swaits 3a56205fa8 update Design doc status for each item 2015-02-17 01:32:45 -08:00
swaits e2a799f02f flow: Merged <feature> 'fix_anchor_duplication' to <develop> ('develop'). 2015-02-17 01:26:44 -08:00
swaits f72d5d5eb0 flow: Closed <feature> 'fix_anchor_duplication'. 2015-02-17 01:26:44 -08:00
swaits 1b09e6c204 deal with anchors and index.html "page aliasing: proprerly
* so now http://a.com/index.html and http://a.com/ are understood equivalents
* as are http://a.com/x.html#blog and http://a.com/x.html (etc.)
* this meant a little more bookkeeping in docrawler, but not horrible
* stripAnchorFromURL because stripURL because it now handles anchor and path suffix stripping
* and url tests were written to support this
2015-02-17 01:26:20 -08:00
swaits 4e335cfc8a reviewed coverage report. remove some dead code. add a few comments to areas of code unlikely to hit. 2015-02-17 00:44:39 -08:00
swaits db314ec938 flow: Created branch 'feature/fix_anchor_duplication'. 2015-02-17 00:36:58 -08:00
swaits 42a40be62f flow: Merged <feature> 'remove_error_from_parselinks' to <develop> ('develop'). 2015-02-16 23:08:37 -08:00
swaits ee3cac7e29 flow: Closed <feature> 'remove_error_from_parselinks'. 2015-02-16 23:08:37 -08:00
swaits 6edd18e361 remove error from return of parseLinks as it's not needed 2015-02-16 23:08:33 -08:00
swaits f16be33dbd flow: Created branch 'feature/remove_error_from_parselinks'. 2015-02-16 23:06:45 -08:00
swaits 6665729e3f rename docrawl -> doCrawl; add some missing comments 2015-02-16 23:02:03 -08:00
swaits 48be4bc77d flow: Merged <feature> 'unique_links_from_each_page' to <develop> ('develop'). 2015-02-16 22:48:04 -08:00
13 changed files with 214 additions and 101 deletions

No files matched your search

+21 -21
View File
@@ -2,38 +2,38 @@
## Minimum Features
This is the assignment (directly) from DO.
This is the assignment (directly) from **REDACTED**.
* We do ask that you put a heavy emphasis on testing in the assignment.
* written in a modern language
* limited to one domain - so when crawling digitalocean.com it would crawl all pages within the digitalocean.com domain, but not follow the links to our Facebook or Instagram accounts or subdomains like cloud.digitalocean.com.
* It should be Given a URL
* it should output a site map
* it should show which static assets each page depends on, and the links between pages.
* Choose the most appropriate data structure to store & display this site map.
* Build this as you would build something for production - it's fine if you don't finish everything, but focus on code quality and write tests.
* We're interested in how you code and how you test your code.
✓ We do ask that you put a heavy emphasis on testing in the assignment.
written in a modern language
✓ limited to one domain - so when crawling REDACTED.com it would crawl all pages within the REDACTED.com domain, but not follow the links to our Facebook or Instagram accounts or subdomains like cloud.REDACTED.com.
✓ It should be Given a URL
✓ it should output a site map
✓ it should show which static assets each page depends on, and the links between pages.
✓ Choose the most appropriate data structure to store & display this site map.
✓ Build this as you would build something for production - it's fine if you don't finish everything, but focus on code quality and write tests.
✓ We're interested in how you code and how you test your code.
## Desired
These are features I'd like to include.
* efficient, optimal
* handles base URL properly (for root & relative links)
* documented
* uses standard Go practices
* uses Go stdlib (only!)
* thoroughly tested
✓ handles base URL properly (for root & relative links)
✓ documented
✓ uses standard Go practices
✓ uses Go stdlib (only!)
✓ thoroughly tested
* configurable on command line (defaults are for company specs)
* variable output formats: json, dot
* adheres to URL RFC (as far as case sensitivity, acceptable character sets, etc.)
✓ adheres to URL RFC (as far as case sensitivity, acceptable character sets, etc.)
* go gettable
* go fmt'ed, vet'ed, lint'ed
✓ go fmt'ed, vet'ed, lint'ed
* polite - robots.txt support
* robust - detect infinite loops
✓ robust - detect infinite loops
* throttling
* use Go context pattern
* supports http & https
✓ supports http & https
## Architecture
@@ -41,8 +41,8 @@ These are features I'd like to include.
* pipeline chain should be such that when there's no more work to do the job dispatcher closes the next channel in the pipeline, thereby closing the entire chain of channels (and exiting all goroutines)
* pipeline may allows us to scale concurrency at the bottlenecks
* could use Interfaces for things like Parser, Getter, but I think best avoided until needed
* launch goroutine per fetch
* when there's no Jobs left, and nothing being fetched or parsed, convert the data struct into something useful and output it
✓ launch goroutine per fetch
✓ when there's no Jobs left, and nothing being fetched or parsed, convert the data struct into something useful and output it
## Wishlist
-6
View File
@@ -2,12 +2,6 @@
## Bugs
* deal with anchor issue (repeatedly crawling index.html#about, index.html#contact, etc.)
* should only crawl these pages once
* but should be included in the links part
* when sorting links, "uniq" them as well
* be smart about index.html?
## Cleanup
* helper function to do the whole crawl process
+70 -54
View File
@@ -1,22 +1,31 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"time"
)
// docrawl begins crawling the site at "homeurl"
func docrawl(homeurl string) itemSlice {
// set of what we have already crawled
// doCrawl begins crawling the site at "homeurl"
func doCrawl(homeurl string, nWorkers int) itemSlice {
// set of what we have already crawled, our results
crawled := make(itemMap)
// map (of urlString -> httpItem) of our results
results := make(itemMap)
// a set of crawled URLs which have been cleaned
// useful so that we don't crawl http://a.com/index.html#about if we've
// already crawled http://a.com/index.html, or vice versa
crawledStripped := make(itemMap)
// a channel to receive our results on
rchan := make(chan *httpItem)
// channels to send work to and receive our results on
rxchan := make(chan *httpItem)
txchan := make(chan *httpItem)
// spin up our crawler workers
for i := 0; i < nWorkers; i++ {
go crawlWorker(txchan, rxchan)
}
// start a ticker which we'll use to output status and check for completion
ticker := time.Tick(250 * time.Millisecond)
@@ -24,7 +33,7 @@ func docrawl(homeurl string) itemSlice {
// create first page's httpItem
homeitem, err := newHTTPItem(nil, homeurl)
if err != nil {
log.Fatal("unable to create httpItem for homeurl")
return nil
}
// set our number of outstanding pages (initially 1 to account for first page)
@@ -32,14 +41,15 @@ func docrawl(homeurl string) itemSlice {
// start the home page crawl
crawled[homeitem.url.String()] = homeitem
go crawlItem(homeitem, rchan)
crawledStripped[stripURL(homeitem.url)] = homeitem
txchan <- homeitem
// wait for results
for {
select {
case r := <-rchan: // new results?
case r := <-rxchan: // new results?
// add result to our results map
results[r.url.String()] = r
crawled[r.url.String()] = r
// decrease the outstanding page count by 1
crawlingCount--
@@ -48,7 +58,7 @@ func docrawl(homeurl string) itemSlice {
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 {
if existing, ok := crawled[c.url.String()]; ok {
r.children[i] = existing
continue
}
@@ -59,22 +69,39 @@ func docrawl(homeurl string) itemSlice {
r.children[i] = existing
continue
}
if existing, ok := crawledStripped[stripURL(c.url)]; ok {
// we crawled a different version of this same page
// i.e. same page, different anchor. we don't need to crawl it again
// but we do need to keep it in our results, so copy the existing
// struct over for everything except the URLs
r.children[i].title = existing.title
r.children[i].linkType = existing.linkType
r.children[i].children = existing.children
continue
}
// haven't crawled this one yet, do so now
crawlingCount++
crawled[c.url.String()] = c
go crawlItem(c, rchan)
crawledStripped[stripURL(c.url)] = c
go func(newItem *httpItem) {
txchan <- newItem
}(c)
}
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)
log.Printf("Crawled %v links, have %v left.\n", len(crawled), crawlingCount)
// see if we're finished
if crawlingCount == 0 {
// close our channels, signalling any workers to exit
close(txchan)
close(rxchan)
// finished! convert results map to a slice and return it
rslice := itemSlice{}
for _, v := range results {
for _, v := range crawled {
rslice = append(rslice, v)
}
return rslice
@@ -83,50 +110,39 @@ func docrawl(homeurl string) itemSlice {
}
}
// 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
// crawlWorker is a goroutine'ized wrapper around crawlItem that listens
// for new jobs and sends them off to crawlItem, returning the results in rxchan
func crawlWorker(txchan <-chan *httpItem, rxchan chan<- *httpItem) {
// read off the incoming item channel forever
for newJob := range txchan {
// perform the crawl
crawlItem(newJob)
// return the result
rxchan <- newJob
}
// 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
}
// main is our program's entry point
func main() {
// parse our flags
nWorkers := *(flag.Uint("num", 100, "number of workers"))
// see if we've got no arguments
flag.Parse()
if flag.NArg() < 1 {
fmt.Printf("\nerror: Please specify at least one URL to crawl.\n")
fmt.Printf("\nD.O. Crawler 1.0 Copyright (c) 2015 Stephen Waits <steve@waits.net> 2015-02-17\n\n")
fmt.Printf("usage: %v [-num=100] <URLs...>\n\n", os.Args[0])
fmt.Printf(" -num=100: number of workers\n")
fmt.Printf(" URLs: URLs to crawl\n\n")
os.Exit(1)
}
// crawl each URL on the command line
for _, u := range os.Args[1:] {
pages := docrawl(u)
for _, u := range flag.Args() {
pages := doCrawl(u, int(nWorkers))
if pages == nil {
log.Fatalf("unable to crawl %q, may be an invalid URL\n", u)
}
l := sitemapToLocations(pages)
j, _ := locationsToJSON(l)
fmt.Println(j)
+35 -4
View File
@@ -62,7 +62,7 @@ func TestSimpleFetch(t *testing.T) {
if err != nil {
t.Error("problem creating New httpItem struct")
}
body, err := fetchPage(page)
body, err := fetchItem(page)
if err != nil {
t.Fatal(err)
}
@@ -76,7 +76,7 @@ func TestSimpleFetch(t *testing.T) {
// TestSimpleMap figures out the site map for the site in baseURL
func TestSimpleMap(t *testing.T) {
pages := docrawl(baseURL)
pages := doCrawl(baseURL, 10)
// because our crawl is non-deterministic, we have to do a complete
// cycle through every page, counting stuff, finding specific pages
@@ -185,7 +185,7 @@ func TestHeaderFetching(t *testing.T) {
// TestJsonOutput gets a sitemap and then converts it to json
func TestJsonOutput(t *testing.T) {
pages := docrawl(baseURL)
pages := doCrawl(baseURL, 10)
l := sitemapToLocations(pages)
if len(l) != 2 {
t.Error("sitemapToLocations has the wrong number of locations")
@@ -229,7 +229,38 @@ func TestJsonOutput(t *testing.T) {
// this is a cheater test because the output is from a run of the code being
// test itself. but, it's been examined and I think it's right. and, there's not
// many other ways to test this without some significant pain
cheaterTest := `[{"URL":"http://localhost:8765/","Title":"Home","Links":["http://localhost:8765/about.html"],"Assets":["http://localhost:8765/assets/image.png","http://localhost:8765/scripts/blah.js"],"Broken":["http://localhost:8765/zzzbroken.html"],"Remote":null},{"URL":"http://localhost:8765/about.html","Title":"About Test","Links":["http://localhost:8765/"],"Assets":["http://localhost:8765/assets/image.png","http://localhost:8765/scripts/blah.js"],"Broken":null,"Remote":["http://doesntexist23492387492837492374982734.com/"]}]`
cheaterTest := `[
{
"URL": "http://localhost:8765/",
"Title": "Home",
"Links": [
"http://localhost:8765/about.html"
],
"Assets": [
"http://localhost:8765/assets/image.png",
"http://localhost:8765/scripts/blah.js"
],
"Broken": [
"http://localhost:8765/zzzbroken.html"
],
"Remote": null
},
{
"URL": "http://localhost:8765/about.html",
"Title": "About Test",
"Links": [
"http://localhost:8765/"
],
"Assets": [
"http://localhost:8765/assets/image.png",
"http://localhost:8765/scripts/blah.js"
],
"Broken": null,
"Remote": [
"http://doesntexist23492387492837492374982734.com/"
]
}
]`
j, err := locationsToJSON(l)
if err != nil {
t.Error("locationsToJSON failed")
+32 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
)
// custom errors
var (
errContentTypeNotFound = errors.New("no Content-Type header found")
errFetchError = errors.New("couldn't fetch item")
@@ -53,7 +54,7 @@ func fetchFiletype(item *httpItem) error {
}
// fetchPage takes a httpItem, GETs it, and returns the body as a string
func fetchPage(item *httpItem) (string, error) {
func fetchItem(item *httpItem) (string, error) {
// figure out the file type
err := fetchFiletype(item)
if err != nil {
@@ -91,3 +92,33 @@ func fetchPage(item *httpItem) (string, error) {
resp.Body.Close()
return string(body), nil
}
// 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) {
// 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
return
}
// fetch page
text, err := fetchItem(item)
if err != nil {
return
}
// parse links
title, links := parseLinks(text)
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)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ func TestFailedFetching(t *testing.T) {
if err != nil {
t.Error("problem creating New Page struct")
}
if _, err := fetchPage(page); err == nil {
if _, err := fetchItem(page); err == nil {
t.Error("tired fetching bogus page but didn't get nil back from fetchPage")
}
}
+1
View File
@@ -13,6 +13,7 @@ type itemMap map[string]*httpItem
// itemType is an enum so we know how to classify each item
type itemType int
// enums for itemType
const (
tUnknown itemType = iota
tHTMLPage
+2 -1
View File
@@ -77,8 +77,9 @@ func sitemapToLocations(pages itemSlice) []*Location {
return locations
}
// locationsToJSON takes a *Location slice and marshals it into a JSON string
func locationsToJSON(locations []*Location) (string, error) {
b, err := json.Marshal(locations)
b, err := json.MarshalIndent(locations, "", " ")
if err != nil {
return "", err
}
+2 -2
View File
@@ -11,7 +11,7 @@ var reTitle = regexp.MustCompile(`(?i)<\s*title\s*>([^<]*)<\s*\/\s*title`)
// parseLinks takes a string and attempts to parse any html title and all links out of it,
// and returns a slice of the captures found
func parseLinks(s string) (string, []string, error) {
func parseLinks(s string) (string, []string) {
// find the title, or default to empty
title := ""
titleMatches := reTitle.FindStringSubmatch(s)
@@ -25,5 +25,5 @@ func parseLinks(s string) (string, []string, error) {
for _, match := range reURL.FindAllStringSubmatch(s, -1) {
results = append(results, match[2])
}
return title, results, nil
return title, results
}
+1 -4
View File
@@ -16,10 +16,7 @@ func TestSimpleParse(t *testing.T) {
<script href="scripts/blah.js"/>
</body>
</html>`
title, matches, err := parseLinks(doc)
if err != nil {
t.Error(err)
}
title, matches := parseLinks(doc)
if title != "Test Page" {
t.Error("got wrong title")
}
+17 -1
View File
@@ -6,6 +6,7 @@ import (
"strings"
)
// custom errors
var (
errInvalidURL = errors.New("this URL can't be parsed successfully")
)
@@ -50,11 +51,26 @@ func resolveURL(referringURL string, currentURL string) (*url.URL, error) {
return uResolved, checkURL(uResolved)
}
func stripAnchorFromURL(u *url.URL) string {
// stripURL returns a version of the URL without the "Fragment" part,
// which is anything after the '#' character, so:
// http://a.com/blah.html#anchor becomes http://a.com/blah.html
// ... and without files like "index.htm" in the Path (since that's
// equivalent to "/"
func stripURL(u *url.URL) string {
// copy the url locally
ucopy := *u
// blank out the anchor, return URI string
ucopy.Fragment = ""
// remove index.html if it exists
suffixes := []string{"index.html", "index.htm"}
for _, s := range suffixes {
if strings.HasSuffix(strings.ToLower(ucopy.Path), s) {
ucopy.Path = ucopy.Path[0 : len(ucopy.Path)-len(s)]
}
}
// return new stripped down URL string
return ucopy.String()
}
+29 -6
View File
@@ -69,13 +69,36 @@ func TestBadResolve(t* testing.T) {
}
*/
// TestURLStripAnchor tests the stripURL() function
func TestURLStripAnchor(t *testing.T) {
u, err := url.Parse("http://swaits:pass@somehost.com:8765/blah/blah.html?x=y#foo")
if err != nil {
t.Fatal("couldn't parse a URL")
{
u, err := url.Parse("http://swaits:pass@somehost.com:8765/blah/blah.html?x=y#foo")
if err != nil {
t.Fatal("couldn't parse a URL")
}
stripped := stripURL(u)
if stripped != "http://swaits:pass@somehost.com:8765/blah/blah.html?x=y" {
t.Error("stripping anchor #foo from URL failed")
}
}
stripped := stripAnchorFromURL(u)
if stripped != "http://swaits:pass@somehost.com:8765/blah/blah.html?x=y" {
t.Error("stripping anchor #foo from URL failed")
{
u, err := url.Parse("http://swaits:pass@somehost.com:8765/blah/index.html?x=y#foo")
if err != nil {
t.Fatal("couldn't parse a URL")
}
stripped := stripURL(u)
if stripped != "http://swaits:pass@somehost.com:8765/blah/?x=y" {
t.Error("stripping anchor #foo from URL failed")
}
}
{
u, err := url.Parse("http://swaits:pass@somehost.com:8765/blah/index.htm?x=y#foo")
if err != nil {
t.Fatal("couldn't parse a URL")
}
stripped := stripURL(u)
if stripped != "http://swaits:pass@somehost.com:8765/blah/?x=y" {
t.Error("stripping anchor #foo from URL failed")
}
}
}
+3
View File
@@ -5,6 +5,8 @@ import (
"testing"
)
// TestUniqStrings passes a slice of strings to uniqStrings() to see if it removes
// all duplicates
func TestUniqStrings(t *testing.T) {
strs := []string{"a", "a", "b", "a", "b", "c", "d", "a", "b", "e", "d"}
uniq := uniqStrings(strs)
@@ -17,6 +19,7 @@ func TestUniqStrings(t *testing.T) {
}
}
// TestUniqStringsNilParm passes a nil slice to uniqStrings to be sure it returns nil
func TestUniqStringsNilParm(t *testing.T) {
uniq := uniqStrings(nil)
if uniq != nil {