Compare commits

...
Author SHA1 Message Date
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
swaits 6d16ebb8a1 flow: Closed <feature> 'unique_links_from_each_page'. 2015-02-16 22:48:04 -08:00
swaits 2c2cfc126c links are now uniq'd before marshaling
* crawled is now a map to httpItem's (instead of struct{}) so we can connect pages mid-crawl
* added duplicate link to html site, test updated to match
* now any httpItem with linkType == tUnknown is listed as "Broken" in the output
2015-02-16 22:47:56 -08:00
swaits 7e18005473 remove page.go (merge miss from /develop) 2015-02-16 22:24:59 -08:00
swaits 81ae0c433b Merge from develop 2015-02-16 22:23:19 -08:00
swaits 9938ae06f6 flow: Merged <feature> 'refactor_state_management' to <develop> ('develop'). 2015-02-16 22:14:23 -08:00
swaits e3c730bdfc flow: Closed <feature> 'refactor_state_management'. 2015-02-16 22:14:23 -08:00
swaits 72cac47961 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
2015-02-16 22:13:52 -08:00
swaits d7cc760e5b rename page.go -> item.go 2015-02-16 18:17:01 -08:00
swaits 58614802b6 renamed Page -> httpItem, and changed its members to unexported as well
* tests all passing
2015-02-16 18:14:12 -08:00
swaits 66f78b562f flow: Created branch 'feature/refactor_state_management'. 2015-02-16 16:34:09 -08:00
swaits d3aeafeba5 update TODO 2015-02-16 16:28:36 -08:00
swaits 2e99708417 won't recrawl pages that are the same after stripping #anchor (blah.html#about, blah.html#contact, etc.)
* but broken
* committing on feature branch and will come back to this
2015-02-16 16:26:16 -08:00
swaits d9009cdbaa flow: Created branch 'feature/unique_links_from_each_page'. 2015-02-16 15:50:30 -08:00
swaits fe46e7af15 add a TODO list 2015-02-16 06:13:59 -08:00
swaits 77b50a22b4 split tests out from everything else in cloc (make stats) 2015-02-15 23:12:01 -08:00
swaits 81e673a6bc flow: Merged <feature> 'url_on_command_line' to <develop> ('develop'). 2015-02-15 22:58:06 -08:00
swaits e867840994 flow: Closed <feature> 'url_on_command_line'. 2015-02-15 22:58:06 -08:00
swaits 5813e948f0 will attempt to crawl any URLs passed in on command line 2015-02-15 22:58:01 -08:00
swaits e3556df1b1 flow: Created branch 'feature/url_on_command_line'. 2015-02-15 22:31:18 -08:00
swaits b159dbf636 design doc extra line removed 2015-02-15 22:30:53 -08:00
swaits bdc75eca4a flow: Merged <feature> 'fix_broken_links' to <develop> ('develop'). 2015-02-15 22:27:04 -08:00
swaits c2b2897f5d flow: Closed <feature> 'fix_broken_links'. 2015-02-15 22:27:04 -08:00
swaits 569037d29f lint and fmt 2015-02-15 22:26:44 -08:00
swaits f3152b6f44 broken links now recorded as such
* Broken field in Page{} now,
* anything that fails to fetch (with http 200) or parse (should not happen) is considered broken
* tests updated appropriately
* discovered my "image.png" file was in the wrong directory because it showed up as broken. fixed!
* bug fix: only fetch if type=text/html (after HEAD response)
2015-02-15 22:25:50 -08:00
swaits f9fd0955e8 flow: Created branch 'feature/fix_broken_links'. 2015-02-15 22:09:25 -08:00
swaits 2f2d1036f6 flow: Merged <feature> 'limit_host_part' to <develop> ('develop'). 2015-02-15 22:09:10 -08:00
swaits 148e833454 flow: Closed <feature> 'limit_host_part'. 2015-02-15 22:09:10 -08:00
swaits 7498b648d7 urls with hosts that don't match the first URL are now skipped and marked as remote in the output
* added Skipped field to Page to remember this state
* marshaled into "Remote" field in JSON
* bug fix: skipped (etc.) sites weren't getting added to the main page list
* tests had to be reworked. because the crawl is non-deterministic, the tests started breaking. tests made a bit more robust, but are slightly weaker now.
* the lists of URLs in the output are now sorted
* ... and the parseLinks is no longer doing any sorting (no point in it)
* parser tests updated accordingly
* added a "broken" link to index.html - not currently supported, will fix next!
2015-02-15 22:07:37 -08:00
swaits 794f586979 flow: Created branch 'feature/limit_host_part'. 2015-02-15 20:44:54 -08:00
swaits 4598e6d8fe flow: Merged <feature> 'output_sitemap' to <develop> ('develop'). 2015-02-15 20:42:55 -08:00
21 changed files with 583 additions and 195 deletions

No files matched your search

+10 -1
View File
@@ -56,7 +56,16 @@ env:
go env go env
stats: stats:
cloc ./src @echo ===============================================================================
@echo Tests
@echo ===============================================================================
@cloc --match-f=_test.go ./src
@echo
@echo
@echo ===============================================================================
@echo Everything Else
@echo ===============================================================================
@cloc --not-match-f=_test.go ./src
# vendor # vendor
# * wipes our vendor directory completely # * wipes our vendor directory completely
+21 -22
View File
@@ -2,38 +2,38 @@
## Minimum Features ## 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. ✓ We do ask that you put a heavy emphasis on testing in the assignment.
* written in a modern language 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. ✓ 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 be Given a URL
* it should output a site map ✓ it should output a site map
* it should show which static assets each page depends on, and the links between pages. ✓ 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. ✓ 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. ✓ 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're interested in how you code and how you test your code.
## Desired ## Desired
These are features I'd like to include. These are features I'd like to include.
* efficient, optimal * efficient, optimal
* handles base URL properly (for root & relative links) ✓ handles base URL properly (for root & relative links)
* documented ✓ documented
* uses standard Go practices ✓ uses standard Go practices
* uses Go stdlib (only!) ✓ uses Go stdlib (only!)
* thoroughly tested ✓ thoroughly tested
* configurable on command line (defaults are for company specs) * configurable on command line (defaults are for company specs)
* variable output formats: json, dot * 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 gettable
* go fmt'ed, vet'ed, lint'ed ✓ go fmt'ed, vet'ed, lint'ed
* polite - robots.txt support * polite - robots.txt support
* robust - detect infinite loops ✓ robust - detect infinite loops
* throttling * throttling
* use Go context pattern * use Go context pattern
* supports http & https ✓ supports http & https
## Architecture ## Architecture
@@ -41,9 +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 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 * 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 * could use Interfaces for things like Parser, Getter, but I think best avoided until needed
* launch goroutine per fetch ✓ 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 ✓ when there's no Jobs left, and nothing being fetched or parsed, convert the data struct into something useful and output it
## Wishlist ## Wishlist
+16
View File
@@ -0,0 +1,16 @@
# To Do
## Bugs
## Cleanup
* helper function to do the whole crawl process
* document code better
* test with godoc
* omit fields in json when nil? (Broken, etc.)
## Features
* command line options, flag parsing
* check RFCs on URL character sets, compliance, etc.
* packagize parts of app
+137 -58
View File
@@ -1,72 +1,151 @@
package main package main
// docrawl begins crawling the site at "url" import (
func docrawl(url string) []*Page { "fmt"
// our result structure "log"
var sitemap []*Page "os"
"time"
)
// what we've already parsed, what we still need to parse // docrawl begins crawling the site at "homeurl"
crawled := make(map[string]*Page) func doCrawl(homeurl string) itemSlice {
queued := make(map[string]*Page) // set of what we have already crawled
crawled := make(itemMap)
// create first page and add to the queue // a set of crawled URLs which have been cleaned
homepage, _ := NewPage(nil, url) // TODO check error // useful so that we don't crawl http://a.com/index.html#about if we've
queued[homepage.URL.String()] = homepage // already crawled http://a.com/index.html, or vice versa
crawledStripped := make(itemMap)
// main loop // map (of urlString -> httpItem) of our results
for len(queued) > 0 { results := make(itemMap)
// pop a page off of queued
var dest *Page
var destURL string
for destURL, dest = range queued {
break
}
delete(queued, destURL)
// mark this page as complete // a channel to receive our results on
crawled[dest.URL.String()] = dest rchan := make(chan *httpItem)
// fetch page // start a ticker which we'll use to output status and check for completion
text, err := fetchPage(dest) ticker := time.Tick(250 * time.Millisecond)
if err != nil {
continue // TODO handle
}
// parse links // create first page's httpItem
title, links, err := parseLinks(text) homeitem, err := newHTTPItem(nil, homeurl)
if err != nil { if err != nil {
continue // TODO handle log.Fatal("unable to create httpItem for homeurl")
}
dest.Title = title
for _, l := range links {
p, err := NewPage(dest, l)
if err != nil {
continue // TODO check error
}
// 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
}
}
// add page to list
sitemap = append(sitemap, dest)
} }
return sitemap // set our number of outstanding pages (initially 1 to account for first page)
crawlingCount := 1
// start the home page crawl
crawled[homeitem.url.String()] = homeitem
crawledStripped[stripURL(homeitem.url)] = homeitem
go crawlItem(homeitem, rchan)
// wait for results
for {
select {
case r := <-rchan: // new results?
// add result to our results map
results[r.url.String()] = r
// decrease the outstanding page count by 1
crawlingCount--
// 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
}
// see if we're already crawling this page (but maybe don't have results yet)
// if so, point to that item (we will have it as a result later!)
if existing, ok := crawled[c.url.String()]; ok {
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
crawledStripped[stripURL(c.url)] = c
go crawlItem(c, rchan)
}
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
}
}
}
}
// 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() { func main() {
// crawl each URL on the command line
for _, u := range os.Args[1:] {
pages := doCrawl(u)
l := sitemapToLocations(pages)
j, _ := locationsToJSON(l)
fmt.Println(j)
}
} }
+126 -34
View File
@@ -58,9 +58,9 @@ func TestBadRequest(t *testing.T) {
// TestSimpleFetch makes sure we can fetch a file and we get exactly what we expect // TestSimpleFetch makes sure we can fetch a file and we get exactly what we expect
func TestSimpleFetch(t *testing.T) { func TestSimpleFetch(t *testing.T) {
page, err := NewPage(nil, baseURL+"fetch_test.html") page, err := newHTTPItem(nil, baseURL+"fetch_test.html")
if err != nil { if err != nil {
t.Error("problem creating New Page struct") t.Error("problem creating New httpItem struct")
} }
body, err := fetchPage(page) body, err := fetchPage(page)
if err != nil { if err != nil {
@@ -76,66 +76,116 @@ func TestSimpleFetch(t *testing.T) {
// TestSimpleMap figures out the site map for the site in baseURL // TestSimpleMap figures out the site map for the site in baseURL
func TestSimpleMap(t *testing.T) { func TestSimpleMap(t *testing.T) {
pages := docrawl(baseURL) pages := doCrawl(baseURL)
if len(pages) != 4 {
t.Logf("got %v, wanted %v\n", len(pages), 1) // because our crawl is non-deterministic, we have to do a complete
// cycle through every page, counting stuff, finding specific pages
// so that we can then verify certain aspects of each page
skipped, children := 0, 0
var homepage, aboutpage, skippage *httpItem
for _, p := range pages {
if p.title == "Home" {
homepage = p
} else if p.title == "About Test" {
aboutpage = p
} else if p.url.String() == "http://doesntexist23492387492837492374982734.com/" {
skippage = p
}
if p.linkType == tRemote {
skipped++
}
children += len(p.children)
}
// check page counts
if len(pages) != 6 {
t.Logf("got %v, wanted %v\n", len(pages), 6)
t.Fatal("got wrong number of pages") t.Fatal("got wrong number of pages")
} }
if pages[0].URL.String() != baseURL { if children != 9 {
t.Logf("got %v, wanted %v\n", children, 8)
t.Fatal("got wrong number of total children")
}
if skipped != 1 {
t.Logf("got %v, wanted %v\n", skipped, 1)
t.Fatal("got wrong number of total skipped")
}
// check first page URL
if homepage.url.String() != baseURL {
t.Error("page URL is invalid") t.Error("page URL is invalid")
} }
if len(pages[0].Children) != 3 {
t.Logf("got %v, wanted %v\n", len(pages[0].Children), 1) // specific page number of children
if len(homepage.children) != 5 {
t.Logf("got %v, wanted %v\n", len(homepage.children), 4)
t.Fatal("got wrong number of links") t.Fatal("got wrong number of links")
} }
if pages[0].Children[0].URL.String() != baseURL+"about.html" { if len(aboutpage.children) != 4 {
t.Logf(" Got: %q\n", pages[0].Children[0].URL.String()) t.Logf("got %v, wanted %v\n", len(aboutpage.children), 4)
t.Logf("Wanted: %q\n", baseURL+"about.html") t.Fatal("got wrong number of links")
t.Error("link name is incorrect")
} }
if pages[0].Children[1].URL.String() != baseURL+"assets/image.png" {
t.Logf(" Got: %q\n", pages[0].Children[1].URL.String()) // make sure remote pages were skipped
t.Logf("Wanted: %q\n", baseURL+"assets/image.png") if homepage.linkType == tRemote {
t.Error("link name is incorrect") t.Logf("found a page that should NOT have been skipped %q", homepage.children[0].url.String())
t.Error("child was skipped")
} }
if pages[0].Children[2].URL.String() != baseURL+"scripts/blah.js" { if skippage.linkType != tRemote {
t.Logf(" Got: %q\n", pages[0].Children[2].URL.String()) t.Logf("found a page that should have been skipped %q", pages[3].children[2].url.String())
t.Logf("Wanted: %q\n", baseURL+"scripts/blah.js") t.Error("child wasn't skipped")
t.Error("link name is incorrect")
} }
// verify page content
// TODO find a better way to do this
//if homepage.children[0].url.String() != baseURL+"about.html" {
//t.Logf(" Got: %q\n", homepage.children[0].url.String())
//t.Logf("Wanted: %q\n", baseURL+"about.html")
//t.Error("link name is incorrect")
//}
//if homepage.children[1].url.String() != baseURL+"assets/image.png" {
//t.Logf(" Got: %q\n", homepage.children[1].url.String())
//t.Logf("Wanted: %q\n", baseURL+"assets/image.png")
//t.Error("link name is incorrect")
//}
//if homepage.children[3].url.String() != baseURL+"scripts/blah.js" {
//t.Logf(" Got: %q\n", homepage.children[3].url.String())
//t.Logf("Wanted: %q\n", baseURL+"scripts/blah.js")
//t.Error("link name is incorrect")
//}
} }
// TestHeaderFetching tests fetchFiletype() to see if we are getting expected results // TestHeaderFetching tests fetchFiletype() to see if we are getting expected results
func TestHeaderFetching(t *testing.T) { func TestHeaderFetching(t *testing.T) {
basepage, err := NewPage(nil, baseURL) basepage, err := newHTTPItem(nil, baseURL)
if err != nil { if err != nil {
t.Error("problem creating New Page struct") 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") t.Error("problem fetching filetype")
} }
page, err := NewPage(basepage, "about.html") page, err := newHTTPItem(basepage, "about.html")
if err != nil { if err != nil {
t.Error("problem creating New Page struct") 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") t.Error("problem fetching filetype")
} }
page, err = NewPage(basepage, "assets/image.png") page, err = newHTTPItem(basepage, "assets/image.png")
if err != nil { if err != nil {
t.Error("problem creating New Page struct") t.Error("problem creating New httpItem struct")
} }
if err := fetchFiletype(page); err != nil || page.MediaType != "text/plain" { if err := fetchFiletype(page); err != nil || page.linkType != tAsset {
t.Logf("got %q, wanted %q", page.MediaType, "text/plain") t.Logf("got %q, wanted %q", page.linkType, tAsset)
t.Error("problem fetching filetype") t.Error("problem fetching filetype")
} }
} }
// TestJsonOutput gets a sitemap and then converts it to json // TestJsonOutput gets a sitemap and then converts it to json
func TestJsonOutput(t *testing.T) { func TestJsonOutput(t *testing.T) {
pages := docrawl(baseURL) pages := doCrawl(baseURL)
l := sitemapToLocations(pages) l := sitemapToLocations(pages)
if len(l) != 2 { if len(l) != 2 {
t.Error("sitemapToLocations has the wrong number of locations") t.Error("sitemapToLocations has the wrong number of locations")
@@ -147,11 +197,17 @@ func TestJsonOutput(t *testing.T) {
t.Error("location 0 has wrong number of links") t.Error("location 0 has wrong number of links")
} }
if len(l[0].Assets) != 2 { if len(l[0].Assets) != 2 {
t.Logf("got %v", len(l[0].Assets))
t.Error("location 0 has wrong number of assets") t.Error("location 0 has wrong number of assets")
} }
if len(l[0].Broken) != 0 { if len(l[0].Broken) != 1 {
t.Logf("got %v", len(l[0].Broken))
t.Error("location 0 has wrong number of broken urls") t.Error("location 0 has wrong number of broken urls")
} }
if len(l[0].Remote) != 0 {
t.Logf("got %v", len(l[1].Remote))
t.Error("location 0 has wrong number of remote urls")
}
if l[1].URL != baseURL+"about.html" { if l[1].URL != baseURL+"about.html" {
t.Error("got an incorrect location") t.Error("got an incorrect location")
} }
@@ -161,14 +217,50 @@ func TestJsonOutput(t *testing.T) {
if len(l[1].Assets) != 2 { if len(l[1].Assets) != 2 {
t.Error("location 1 has wrong number of assets") t.Error("location 1 has wrong number of assets")
} }
if len(l[1].Broken) != 1 { if len(l[1].Broken) != 0 {
t.Logf("got %v", len(l[1].Broken))
t.Error("location 1 has wrong number of broken urls") t.Error("location 1 has wrong number of broken urls")
} }
if len(l[1].Remote) != 1 {
t.Logf("got %v", len(l[1].Remote))
t.Error("location 1 has wrong number of remote urls")
}
// this is a cheater test because the output is from a run of the code being // 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 // 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 // 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":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":["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) j, err := locationsToJSON(l)
if err != nil { if err != nil {
t.Error("locationsToJSON failed") t.Error("locationsToJSON failed")
+40 -9
View File
@@ -7,50 +7,81 @@ import (
"net/http" "net/http"
) )
// custom errors
var ( var (
errContentTypeNotFound = errors.New("no Content-Type header found") errContentTypeNotFound = errors.New("no Content-Type header found")
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 // fetchFiletype performs an http HEAD to get the media type, and sets it
// directly in Page.MediaType // directly in httpItem.mediaType
func fetchFiletype(page *Page) error { func fetchFiletype(item *httpItem) error {
resp, err := http.Head(page.URL.String()) resp, err := http.Head(item.url.String())
if err != nil { if err != nil {
item.linkType = tBroken
return err return err
} }
// check response code
if resp.StatusCode != http.StatusOK {
item.linkType = tBroken
return errFetchError
}
// pull the content type out of the http header // pull the content type out of the http header
contentType, ok := resp.Header["Content-Type"] contentType, ok := resp.Header["Content-Type"]
if !ok { if !ok {
// no Content-Type header found // no Content-Type header found
item.linkType = tBroken
return errContentTypeNotFound return errContentTypeNotFound
} }
// parse mime type // parse mime type
mediatype, _, err := mime.ParseMediaType(contentType[0]) mediatype, _, err := mime.ParseMediaType(contentType[0])
if err != nil { if err != nil {
item.linkType = tBroken
return err return err
} }
// set and return // success, set item type and return
page.MediaType = mediatype if mediatype == "text/html" {
item.linkType = tHTMLPage
} else {
item.linkType = tAsset
}
return nil return nil
} }
// fetchPage takes a Page, GETs it, and returns the body as a string // fetchPage takes a httpItem, GETs it, and returns the body as a string
func fetchPage(page *Page) (string, error) { func fetchPage(item *httpItem) (string, error) {
// figure out the file type // figure out the file type
err := fetchFiletype(page) err := fetchFiletype(item)
if err != nil { if err != nil {
return "", err return "", err
} }
// 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 // GET the url
resp, err := http.Get(page.URL.String()) resp, err := http.Get(item.url.String())
if err != nil { if err != nil {
return "", err return "", err
} }
// check response code
if resp.StatusCode != http.StatusOK {
return "", errFetchError
}
// read the body // read the body
body, err := ioutil.ReadAll(resp.Body) body, err := ioutil.ReadAll(resp.Body)
if err != nil { if err != nil {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
// TestFailedHeaderFetching tests fetchFiletype() to see if we are getting expected failures // TestFailedHeaderFetching tests fetchFiletype() to see if we are getting expected failures
func TestFailedHeaderFetching(t *testing.T) { func TestFailedHeaderFetching(t *testing.T) {
page, err := NewPage(nil, "http://doesntexist23492387492837492374982734.com/") page, err := newHTTPItem(nil, "http://doesntexist23492387492837492374982734.com/")
if err != nil { if err != nil {
t.Error("problem creating New Page struct") t.Error("problem creating New Page struct")
} }
@@ -17,7 +17,7 @@ func TestFailedHeaderFetching(t *testing.T) {
// TestFailedFetching tests fetchFiletype() to see if we are getting expected failures // TestFailedFetching tests fetchFiletype() to see if we are getting expected failures
func TestFailedFetching(t *testing.T) { func TestFailedFetching(t *testing.T) {
page, err := NewPage(nil, "http://doesntexist23492387492837492374982734.com/") page, err := newHTTPItem(nil, "http://doesntexist23492387492837492374982734.com/")
if err != nil { if err != nil {
t.Error("problem creating New Page struct") t.Error("problem creating New Page struct")
} }
+56
View File
@@ -0,0 +1,56 @@
package main
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
// enums for itemType
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
refurl *url.URL
title string
linkType itemType
children itemSlice
}
// newHTTPItem takes a referring httpItem + a URL and returns a new &httpItem{}
func newHTTPItem(referrer *httpItem, rawurl string) (*httpItem, error) {
// determine the base URL so we can resulve this into a full URL
baseURL := ""
if referrer != nil {
baseURL = referrer.url.String()
}
// resolve URL into a full absolute URL (non-relative)
u, err := resolveURL(baseURL, rawurl)
if err != nil {
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, refurl: rurl}, nil
}
+13
View File
@@ -0,0 +1,13 @@
package main
import (
"testing"
)
// TestBadnewHTTPItem tests newHTTPItem to see if we are getting expected failures
func TestBadnewHTTPItem(t *testing.T) {
_, err := newHTTPItem(nil, "h%20t\tp://doesntexist23492387492837492374982734.com/")
if err == nil {
t.Error("tried creating a newHTTPItem with an invalid URL but didn't get an error")
}
}
+43 -13
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"sort"
) )
// Location is a struct which defines a single URL, which URLs (links and assets) it contains, etc. // Location is a struct which defines a single URL, which URLs (links and assets) it contains, etc.
@@ -11,45 +12,74 @@ type Location struct {
Links []string Links []string
Assets []string Assets []string
Broken []string Broken []string
Remote []string
} }
func sitemapToLocations(pages []*Page) []*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 // build a map of our pages
pageMap := make(map[string]*Page) pageMap := make(map[string]*httpItem)
for _, p := range pages { for _, p := range pages {
pageMap[p.URL.String()] = p pageMap[p.url.String()] = p
} }
// build a slice of locations (one per page) // build a slice of locations (one per page)
var locations []*Location var locations []*Location
for _, p := range pages { for _, p := range pages {
if p.MediaType == "text/html" { if p.linkType == tHTMLPage {
// create a location for this page // create a location for this page
l := &Location{URL: p.URL.String(), Title: p.Title} l := &Location{URL: p.url.String(), Title: p.title}
// add its children // add its children
for _, c := range p.Children { for _, c := range p.children {
// look up this child's media type from the root list of pages // look up this child's media type from the root list of pages
//mediaType := pageMap[c.URL.String()].MediaType //mediaType := pageMap[c.url.String()].mediaType
if c.MediaType == "text/html" { if c.linkType == tRemote {
l.Links = append(l.Links, c.URL.String()) l.Remote = append(l.Remote, c.url.String())
} else if c.MediaType == "" { } else if c.linkType == tHTMLPage {
l.Broken = append(l.Broken, c.URL.String()) l.Links = append(l.Links, c.url.String())
} else if c.linkType == tBroken {
l.Broken = append(l.Broken, c.url.String())
} else if c.linkType == tAsset {
l.Assets = append(l.Assets, c.url.String())
} else { } else {
l.Assets = append(l.Assets, c.URL.String()) // unknown link here, which means it failed to crawl, let's call it "broken"
l.Broken = append(l.Broken, c.url.String())
} }
} }
// now uniq & sort the children slices
l.Remote = uniqStrings(l.Remote)
l.Links = uniqStrings(l.Links)
l.Broken = uniqStrings(l.Broken)
l.Assets = uniqStrings(l.Assets)
sort.Strings(l.Remote)
sort.Strings(l.Links)
sort.Strings(l.Broken)
sort.Strings(l.Assets)
// and add this location to our slice // and add this location to our slice
locations = append(locations, l) locations = append(locations, l)
} }
} }
// sort the Locations themselves and return
sort.Sort(byURL(locations))
return locations return locations
} }
// locationsToJSON takes a *Location slice and marshals it into a JSON string
func locationsToJSON(locations []*Location) (string, error) { func locationsToJSON(locations []*Location) (string, error) {
b, err := json.Marshal(locations) b, err := json.MarshalIndent(locations, "", " ")
if err != nil { if err != nil {
return "", err return "", err
} }
-31
View File
@@ -1,31 +0,0 @@
package main
import (
"net/url"
)
// Page is a struct which defines a single page, which URLs (links and assets) it contains, etc.
type Page struct {
URL *url.URL
Title string
MediaType string
Children []*Page
}
// NewPage takes a referring Page + a URL and returns a new &Page{}
func NewPage(referrer *Page, rawurl string) (*Page, error) {
// determine the base URL so we can resulve this into a full URL
baseURL := ""
if referrer != nil {
baseURL = referrer.URL.String()
}
// resolve URL into a full absolute URL (non-relative)
u, err := resolveURL(baseURL, rawurl)
if err != nil {
return nil, err
}
// create struct and return
return &Page{URL: u}, nil
}
-13
View File
@@ -1,13 +0,0 @@
package main
import (
"testing"
)
// TestBadNewPage tests NewPage to see if we are getting expected failures
func TestBadNewPage(t *testing.T) {
_, err := NewPage(nil, "h%20t\tp://doesntexist23492387492837492374982734.com/")
if err == nil {
t.Error("tried creating a NewPage with an invalid URL but didn't get an error")
}
}
+3 -5
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"regexp" "regexp"
"sort"
"strings" "strings"
) )
@@ -11,8 +10,8 @@ var reURL = regexp.MustCompile(`(?i)(src|href|xhref)\s*=\s*"([^"]+)"`) // TODO m
var reTitle = regexp.MustCompile(`(?i)<\s*title\s*>([^<]*)<\s*\/\s*title`) 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, // parseLinks takes a string and attempts to parse any html title and all links out of it,
// and returns a sorted slice of the captures found // 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 // find the title, or default to empty
title := "" title := ""
titleMatches := reTitle.FindStringSubmatch(s) titleMatches := reTitle.FindStringSubmatch(s)
@@ -26,6 +25,5 @@ func parseLinks(s string) (string, []string, error) {
for _, match := range reURL.FindAllStringSubmatch(s, -1) { for _, match := range reURL.FindAllStringSubmatch(s, -1) {
results = append(results, match[2]) results = append(results, match[2])
} }
sort.Strings(results) return title, results
return title, results, nil
} }
+3 -6
View File
@@ -16,20 +16,17 @@ func TestSimpleParse(t *testing.T) {
<script href="scripts/blah.js"/> <script href="scripts/blah.js"/>
</body> </body>
</html>` </html>`
title, matches, err := parseLinks(doc) title, matches := parseLinks(doc)
if err != nil {
t.Error(err)
}
if title != "Test Page" { if title != "Test Page" {
t.Error("got wrong title") t.Error("got wrong title")
} }
if len(matches) != 3 { if len(matches) != 3 {
t.Error("invalid number of matches in parse") t.Error("invalid number of matches in parse")
} }
if matches[0] != "/about.html" { if matches[0] != "/assets/image.png" {
t.Error("match text is invalid") t.Error("match text is invalid")
} }
if matches[1] != "/assets/image.png" { if matches[1] != "/about.html" {
t.Error("match text is invalid") t.Error("match text is invalid")
} }
if matches[2] != "scripts/blah.js" { if matches[2] != "scripts/blah.js" {
File renamed without changes.
+2
View File
@@ -5,6 +5,8 @@
<body> <body>
<img src="/assets/image.png"/> <img src="/assets/image.png"/>
<a href="/about.html">fooey!</a> <a href="/about.html">fooey!</a>
<a href="/about.html">duplicate!</a>
<script src="scripts/blah.js"/> <script src="scripts/blah.js"/>
<a href="/zzzbroken.html">oops!</a>
</body> </body>
</html> </html>
+25
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
) )
// custom errors
var ( var (
errInvalidURL = errors.New("this URL can't be parsed successfully") errInvalidURL = errors.New("this URL can't be parsed successfully")
) )
@@ -49,3 +50,27 @@ func resolveURL(referringURL string, currentURL string) (*url.URL, error) {
// return the URL we ended up with, and the error from checkURL // return the URL we ended up with, and the error from checkURL
return uResolved, checkURL(uResolved) return uResolved, checkURL(uResolved)
} }
// 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()
}
+34
View File
@@ -68,3 +68,37 @@ 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")
}
stripped := stripURL(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")
}
}
}
+23
View File
@@ -0,0 +1,23 @@
package main
// uniqStrings takes a slice of strings and removes any duplicates
// note: does not guarantee any order (or stability of order)
func uniqStrings(strs []string) []string {
// handle special case of a nil parameter
if strs == nil {
return nil
}
// add each string to the slice as keys (used like a "set")
set := make(map[string]struct{})
for _, s := range strs {
set[s] = struct{}{}
}
// now copy all the keys over to a new slice and return it
result := []string{}
for key := range set {
result = append(result, key)
}
return result
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"sort"
"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)
if len(uniq) != 5 {
t.Error("wrong number of strings after uniq'ing")
}
sort.Strings(uniq)
if uniq[0] != "a" || uniq[1] != "b" || uniq[2] != "c" || uniq[3] != "d" || uniq[4] != "e" {
t.Error("uniq values are in correct!")
}
}
// TestUniqStringsNilParm passes a nil slice to uniqStrings to be sure it returns nil
func TestUniqStringsNilParm(t *testing.T) {
uniq := uniqStrings(nil)
if uniq != nil {
t.Error("uniqStrings should return a nil slice when passed a nil slice")
}
}
+1 -1
View File
@@ -2,6 +2,6 @@
# colorize the output of "go test" # colorize the output of "go test"
sed ''/ok/s//$(printf "\033[32;1m&\033[0m")/'' | \ sed ''/^ok/s//$(printf "\033[32;1m&\033[0m")/'' | \
sed ''/PASS/s//$(printf "\033[32;1m&\033[0m")/'' | \ sed ''/PASS/s//$(printf "\033[32;1m&\033[0m")/'' | \
sed ''/FAIL/s//$(printf "\033[31;1m&\033[0m")/'' sed ''/FAIL/s//$(printf "\033[31;1m&\033[0m")/''