xkcd-go

Golang tool to read latest, random, or a specific xkcd comic (and download it too).
git clone http://git.hanabi.in/repos/xkcd-go.git
Log | Files | Refs | README | LICENSE

commit 5e68ef5b2e7e6806dd57586e9b7ed4f97f64dba0
parent 29a18b921839bb2c98ee0caf4b1cc5f2e4cf1707
Author: Agastya Chandrakant <me@hanabi.in>
Date:   Sun, 27 Feb 2022 21:15:42 +0530

+ Major refactoring,
+ Adding explainxkcd link,
+ Refer to xkcd.com instead of just image for interactive comics,
+ Save large version of the image, when larger version is available,
+ `xkcd 1000' now works like `xkcd get 1000',
+ `xkcd explain' added.

Diffstat:
MREADME | 17+++++++++++------
Mmakefile | 2+-
Msrc/main.go | 23++++++++++++++++-------
Asrc/xkcd/default-behaviour.go | 12++++++++++++
Asrc/xkcd/detect-api.go | 10++++++++++
Asrc/xkcd/detect-api_test.go | 24++++++++++++++++++++++++
Asrc/xkcd/explain-xkcd.go | 15+++++++++++++++
Asrc/xkcd/explain-xkcd_test.go | 33+++++++++++++++++++++++++++++++++
Asrc/xkcd/fetch-latest-xkcd.go | 10++++++++++
Asrc/xkcd/fetch-random-xkcd.go | 9+++++++++
Asrc/xkcd/fetch-this-xkcd.go | 18++++++++++++++++++
Msrc/xkcd/fns.go | 125++++++++++---------------------------------------------------------------------
Asrc/xkcd/get-num-of-latest-comic.go | 9+++++++++
Asrc/xkcd/get-options.go | 22++++++++++++++++++++++
Asrc/xkcd/get-options_test.go | 42++++++++++++++++++++++++++++++++++++++++++
Asrc/xkcd/handle-404.go | 7+++++++
Asrc/xkcd/latest-api.go | 7+++++++
Asrc/xkcd/latest-api_test.go | 15+++++++++++++++
Asrc/xkcd/save-xkcd.go | 31+++++++++++++++++++++++++++++++
Asrc/xkcd/show-help.go | 16++++++++++++++++
Asrc/xkcd/specific-api.go | 9+++++++++
Asrc/xkcd/specific-api_test.go | 42++++++++++++++++++++++++++++++++++++++++++
Asrc/xkcd/xkcd-resp.go | 15+++++++++++++++
23 files changed, 389 insertions(+), 124 deletions(-)

diff --git a/README b/README @@ -13,27 +13,31 @@ Assuming you have golang compiler, run the following commands: $ git clone http://git.hanabi.in/repos/xkcd-go.git $ cd xkcd-go -$ make install # or, `go build -o xkcd src/main.go && mv xkcd /usr/local/bin/` +# make install # or, `go build -o xkcd src/main.go && mv xkcd /usr/local/bin/` Usage ===== -`xkcd' can fetch the latest xkcd comic, fetch a random comic, or fetch a -specific comic. +`xkcd' can fetch the latest xkcd comic, fetch a random comic, fetch a specific +comic, or save a specific comic. Run `xkcd help' to show help message. + `xkcd' to show the latest xkcd comic. + `xkcd latest' to show the latest xkcd comic. + `xkcd random' to fetch random xkcd comic. ++ `xkcd get' will function same as `xkcd latest'. + `xkcd get 303', for example, to fetch the comic numbered 303. ++ `xkcd 807' will function same as `xkcd get 807'. + `xkcd save', to save the latest comic to `/tmp', or + `xkcd save 1312', to save the comic numbered 1312 to `/tmp'. ++ `xkcd explain', to explain the latest comic. ++ `xkcd explain 1000', to explain the comic numbered 1000. Source code =========== The source code uses git for version control. Get the source code by running: -$ git clone http://git.hanabi.in/repos/wordle-cli.git +$ git clone http://git.hanabi.in/repos/xkcd-go.git The source code has three branches dev, prod and master. + dev: new features are added committed to dev. @@ -48,7 +52,9 @@ tool) -- tag named as a tribute for suggesting me to build this. + Other tags being semantic versioning (semver) of the software release. The functionality of the software is detailed in the Usage section. I don't -intend to complicate the software. +intend to complicate the software. Genuine feature additions, consistency, +code clean-ups and bug-fixes are welcomed. Consider the UNIX philosophy for +addition of new features. Please email patches to <me+git@hanabi.in> @@ -64,4 +70,3 @@ License This source code is licened under the GNU AGPLv3 license. Read the LICENSE file to see what you can do with it, or read <https://www.gnu.org/licenses/agpl-3.0.en.html> - diff --git a/makefile b/makefile @@ -3,4 +3,4 @@ build: install: make build && mv xkcd /usr/local/bin clean: - rm ./xkcd + rm -f ./xkcd diff --git a/src/main.go b/src/main.go @@ -1,24 +1,33 @@ package main import ( + "fmt" + "log" "os" xkcd "git.hanabi.in/dev/xkcd/src/xkcd" ) func main() { - option, num := xkcd.GetOption() + option, num, err := xkcd.GetOption(os.Args) + if err != nil { + log.Fatal(err) + } + var msg string if option == "help" { - xkcd.ShowHelp(os.Stdout) + msg = xkcd.ShowHelp() } else if option == "random" { - xkcd.FetchRandomXKCD(os.Stdout) + msg = xkcd.FetchRandomXKCD() } else if option == "latest" { - xkcd.FetchLatestXKCD(os.Stdout) + msg = xkcd.FetchLatestXKCD() } else if option == "get" { - xkcd.FetchThisXKCD(num, os.Stdout) + msg = xkcd.FetchThisXKCD(num) } else if option == "save" { - xkcd.SaveXKCD(num, os.Stdout) + msg = xkcd.SaveXKCD(num) + } else if option == "explain" { + msg = xkcd.ExplainXKCD(num) } else { - xkcd.DefaultBehaviour(os.Stdout) + msg = xkcd.DefaultBehaviour(option) } + fmt.Print(msg) } diff --git a/src/xkcd/default-behaviour.go b/src/xkcd/default-behaviour.go @@ -0,0 +1,12 @@ +package xkcd + +import "strconv" + +func DefaultBehaviour(option string) (msg string) { + if maybenum, err := strconv.Atoi(option); err == nil { + msg = FetchThisXKCD(maybenum) + } else { + msg = FetchLatestXKCD() + } + return msg +} diff --git a/src/xkcd/detect-api.go b/src/xkcd/detect-api.go @@ -0,0 +1,10 @@ +package xkcd + +// Detect if a specific API is to be used, or the latest API. +func detectAPI(num int) (api string) { + if num == 0 { + return latestAPI() + } else { + return specificAPI(num) + } +} diff --git a/src/xkcd/detect-api_test.go b/src/xkcd/detect-api_test.go @@ -0,0 +1,24 @@ +package xkcd + +import ( + "fmt" + "testing" +) + +func TestLatestAPI(t *testing.T) { + expected := "https://xkcd.com/info.0.json" + received := detectAPI(0) + if expected != received { + msg := fmt.Sprintf("Expected `%s', received `%s'.\n", expected, received) + t.Error(msg) + } +} + +func TestSpecificAPI(t *testing.T) { + expected := "https://xkcd.com/541/info.0.json" + received := detectAPI(541) + if expected != received { + msg := fmt.Sprintf("Expected `%s', received `%s'.\n", expected, received) + t.Error(msg) + } +} diff --git a/src/xkcd/explain-xkcd.go b/src/xkcd/explain-xkcd.go @@ -0,0 +1,15 @@ +package xkcd + +import ( + "fmt" +) + +// For a given xkcd comic number `num', provide the explainxkcd.com link. +func ExplainXKCD(num int) (link string) { + if num == 0 { + link = fmt.Sprintf("%sMain_Page\n", explainxkcd) + } else { + link = fmt.Sprintf("%s%d\n", explainxkcd, num) + } + return link +} diff --git a/src/xkcd/explain-xkcd_test.go b/src/xkcd/explain-xkcd_test.go @@ -0,0 +1,33 @@ +package xkcd + +import ( + "fmt" + "testing" +) + +func TestExplainLatest(t *testing.T) { + received := ExplainXKCD(0) + expected := "https://www.explainxkcd.com/wiki/index.php/Main_Page\n" + if received != expected { + msg := fmt.Sprintf("Expected `%s', received `%s'.\n", expected, received) + t.Error(msg) + } +} + +func TestExplainSpecific(t *testing.T) { + received := ExplainXKCD(413) + expected := "https://www.explainxkcd.com/wiki/index.php/413\n" + if received != expected { + msg := fmt.Sprintf("Expected `%s', received `%s'.\n", expected, received) + t.Error(msg) + } +} + +func TestExplain404(t *testing.T) { // No reason 404 explain xkcd should not be printed. + received := ExplainXKCD(404) + expected := "https://www.explainxkcd.com/wiki/index.php/404\n" + if received != expected { + msg := fmt.Sprintf("Expected `%s', received `%s'.\n", expected, received) + t.Error(msg) + } +} diff --git a/src/xkcd/fetch-latest-xkcd.go b/src/xkcd/fetch-latest-xkcd.go @@ -0,0 +1,10 @@ +package xkcd + +// Return message about the latest xkcd. +func FetchLatestXKCD() (msg string) { + api := latestAPI() + data := xkcdAPI(api) + num := data.Num + msg = FetchThisXKCD(num) + return msg +} diff --git a/src/xkcd/fetch-random-xkcd.go b/src/xkcd/fetch-random-xkcd.go @@ -0,0 +1,9 @@ +package xkcd + +// return the message about a random xkcd. +func FetchRandomXKCD() (msg string) { + max := getNumOfLatestComic() + rnd_idx := getRndXKCDIdx(max) + msg = FetchThisXKCD(rnd_idx) + return msg +} diff --git a/src/xkcd/fetch-this-xkcd.go b/src/xkcd/fetch-this-xkcd.go @@ -0,0 +1,18 @@ +package xkcd + +import ( + "fmt" +) + +// return message for comic numbered num. +func FetchThisXKCD(num int) (msg string) { + if num == 404 { + msg = handle404() + } else { + api := detectAPI(num) + data := xkcdAPI(api) + comic := fmt.Sprintf("https://xkcd.com/%d", data.Num) + msg = fmt.Sprintf("Comic from %s-%s-%s:\n\"%s (%d)\"\n\n%s\n\n\"%s\"\n\nNeed explainer?\n%s%d\n", data.Year, data.Month, data.Day, data.SafeTitle, data.Num, comic, data.Alt, explainxkcd, data.Num) + } + return msg +} diff --git a/src/xkcd/fns.go b/src/xkcd/fns.go @@ -2,93 +2,26 @@ package xkcd import ( "encoding/json" - "fmt" - "io" "io/ioutil" "log" "math/rand" "net/http" - "os" - "strconv" "strings" "time" ) -type xkcdResp struct { - Month string `json:"month"` - Num int `json:"num"` - Link string `json:"link"` - Year string `json:"year"` - News string `json:"news"` - SafeTitle string `json:"safe_title"` - Transcript string `json:"transcript"` - Alt string `json:"alt"` - Img string `json:"img"` - Title string `json:"title"` - Day string `json:"day"` -} - -func ShowHelp(w io.Writer) { - msg := []byte("Run `xkcd help' to show this message.\n") - w.Write(msg) - msg = []byte("+ `xkcd' to show the latest xkcd comic.\n") - w.Write(msg) - msg = []byte("+ `xkcd latest' to show the latest xkcd comic.\n") - w.Write(msg) - msg = []byte("+ `xkcd random' to fetch random xkcd comic.\n") - w.Write(msg) - msg = []byte("+ `xkcd get 303', for example, to fetch the comic numbered 303.\n") - w.Write(msg) - msg = []byte("+ `xkcd save', to save the latest comic to `/tmp', or\n") - w.Write(msg) - msg = []byte("+ `xkcd save 1312', to save the comic numbered 1312 to `/tmp'.\n") - w.Write(msg) -} - -func GetOption() (option string, num int) { - args := os.Args - if len(args) > 1 { - option = args[1] - } - if len(args) > 2 { - maybenum, err := strconv.Atoi(args[2]) - if err != nil { - log.Fatal("Please specify a valid number.\n") - } - num = maybenum - } - return option, num -} - -func FetchThisXKCD(num int, w io.Writer) { - API := fmt.Sprintf("https://xkcd.com/%d/info.0.json", num) - data := xkcdAPI(API) - msg := []byte(fmt.Sprintf("Comic from %s-%s-%s:\n\"%s (%d)\"\n\n%s\n\n\"%s\"\n", data.Year, data.Month, data.Day, data.SafeTitle, data.Num, data.Img, data.Alt)) - w.Write(msg) -} +const ( + explainxkcd = "https://www.explainxkcd.com/wiki/index.php/" +) -func SaveXKCD(num int, w io.Writer) { - API := fmt.Sprintf("https://xkcd.com/%d/info.0.json", num) - if num == 0 { - API = fmt.Sprint("https://xkcd.com/info.0.json") - } - data := xkcdAPI(API) - img := data.Img - num = data.Num - ext := getExt(img) - img_body := httpGet(img) - defer img_body.Close() - filename := fmt.Sprintf("/tmp/xkcd-%d.%s", num, ext) - file, err := os.Create(filename) - if err != nil { - log.Fatalf("Unable to save file. See %v.\n", err) - } - defer file.Close() - if _, err := io.Copy(file, img_body); err != nil { - log.Fatal(err) +func getImg(img, link, ext string) (res string) { + res = img + if strings.HasPrefix(link, "https://xkcd.com/") { + full_ext := "." + ext + first_part := strings.Replace(img, full_ext, "", -1) + res = first_part + "_large" + full_ext } - msg := []byte("Saved to /tmp.\n") - w.Write(msg) + return res } func getExt(str string) (ext string) { @@ -96,21 +29,17 @@ func getExt(str string) (ext string) { return arr[len(arr)-1] } -func httpGet(url string) io.ReadCloser { +func httpGet(url string) *http.Response { client := &http.Client{} resp, err := client.Get(url) if err != nil { log.Fatalf("Unable to access XKCD, see: %v.\n", err) } - return resp.Body + return resp } -func xkcdAPI(API string) (data xkcdResp) { - client := &http.Client{} - resp, err := client.Get(API) - if err != nil { - log.Fatalf("Unable to access XKCD, see: %v.\n", err) - } +func xkcdAPI(api string) (data xkcdResp) { + resp := httpGet(api) defer resp.Body.Close() respIsJson := resp.Header.Get("Content-Type") == "application/json" if !respIsJson { @@ -126,32 +55,8 @@ func xkcdAPI(API string) (data xkcdResp) { return data } -func DefaultBehaviour(w io.Writer) { - FetchLatestXKCD(w) -} - -func FetchLatestXKCD(w io.Writer) { - API := "https://xkcd.com/info.0.json" - data := xkcdAPI(API) - msg := []byte(fmt.Sprintf("Latest comic (%s-%s-%s):\n\"%s (%d)\"\n\n%s\n\n\"%s\"\n", data.Year, data.Month, data.Day, data.SafeTitle, data.Num, data.Img, data.Alt)) - w.Write(msg) -} - -func FetchRandomXKCD(w io.Writer) { - max := getNumOfLatest() - rnd_idx := getRndXKCDIdx(max) - FetchThisXKCD(rnd_idx, w) -} - -func getNumOfLatest() (latest int) { - API := "https://xkcd.com/info.0.json" - data := xkcdAPI(API) - return data.Num -} - func getRndXKCDIdx(max int) (rnd_idx int) { rand.Seed(time.Now().UnixNano()) - for rnd_idx = rand.Intn(max+1) + 1; rnd_idx == 404; rnd_idx = rand.Intn(max+1) + 1 { - } + rnd_idx = rand.Intn(max+1) + 1 return rnd_idx } diff --git a/src/xkcd/get-num-of-latest-comic.go b/src/xkcd/get-num-of-latest-comic.go @@ -0,0 +1,9 @@ +package xkcd + +// Get the number of the latest xkcd. +func getNumOfLatestComic() (latest int) { + api := latestAPI() + data := xkcdAPI(api) + latest = data.Num + return latest +} diff --git a/src/xkcd/get-options.go b/src/xkcd/get-options.go @@ -0,0 +1,22 @@ +package xkcd + +import ( + "fmt" + "strconv" +) + +// Option is of the type `xkcd action [num]'. +// Return (action, num, err) -- err, if any. +func GetOption(args []string) (option string, num int, err error) { + if len(args) > 1 { + option = args[1] + } + if len(args) > 2 { + maybenum, e := strconv.Atoi(args[2]) + if e != nil { + err = fmt.Errorf("Please specify a valid number.\n") + } + num = maybenum + } + return option, num, err +} diff --git a/src/xkcd/get-options_test.go b/src/xkcd/get-options_test.go @@ -0,0 +1,42 @@ +package xkcd + +import ( + "fmt" + "testing" +) + +func TestNoArgs(t *testing.T) { + input := []string{"bin"} + option, num, err := GetOption(input) + if option != "" || num != 0 || err != nil { + msg := fmt.Sprintf("Expected return empty string, 0 and nil error, got `%s', `%d', `%v'.\n", option, num, err) + t.Error(msg) + } +} + +func TestOneValidArg(t *testing.T) { + var input = []string{"bin", "alif"} + option, num, err := GetOption(input) + if option != "alif" || num != 0 || err != nil { + msg := fmt.Sprintf("Expected return `alif', 0, and nil error, got `%s', `%d', `%v'.\n", option, num, err) + t.Error(msg) + } +} + +func TestTwoValidArgs(t *testing.T) { + var input = []string{"bin", "alif", "465"} + option, num, err := GetOption(input) + if option != "alif" || num != 465 || err != nil { + msg := fmt.Sprintf("Expected return `alif', 465, and nil error, got `%s', `%d', `%v'.\n", option, num, err) + t.Error(msg) + } +} + +func TestOneValidOtherInvalidArg(t *testing.T) { + var input = []string{"bin", "alif", "bet"} + _, _, err := GetOption(input) + if err == nil { + msg := fmt.Sprintf("Expected some err, received `%v'.\n", err) + t.Error(msg) + } +} diff --git a/src/xkcd/handle-404.go b/src/xkcd/handle-404.go @@ -0,0 +1,7 @@ +package xkcd + +// comic number 404 was an easter egg, handle it separately. +func handle404() (msg string) { + msg = "Comic from 2008-4-1:\n\"404 Not Found (404)\"\n\nhttps://xkcd.com/404\n\nNeed explainer?\nhttps://www.explainxkcd.com/wiki/index.php/404\n" + return msg +} diff --git a/src/xkcd/latest-api.go b/src/xkcd/latest-api.go @@ -0,0 +1,7 @@ +package xkcd + +// Return the URL to fetch the latest xkcd comic data. +func latestAPI() (api string) { + api = "https://xkcd.com/info.0.json" + return api +} diff --git a/src/xkcd/latest-api_test.go b/src/xkcd/latest-api_test.go @@ -0,0 +1,15 @@ +package xkcd + +import ( + "fmt" + "testing" +) + +func Test(t *testing.T) { + received := latestAPI() + expected := "https://xkcd.com/info.0.json" + if received != expected { + msg := fmt.Sprintf("Expected `%s', received `%s'.\n", expected, received) + t.Error(msg) + } +} diff --git a/src/xkcd/save-xkcd.go b/src/xkcd/save-xkcd.go @@ -0,0 +1,31 @@ +package xkcd + +import ( + "fmt" + "io" + "log" + "os" +) + +// Save xkcd numbered num in /tmp +func SaveXKCD(num int) (msg string) { + api := detectAPI(num) + data := xkcdAPI(api) + num = data.Num + ext := getExt(data.Img) + img := getImg(data.Img, data.Link, ext) + img_resp := httpGet(img) + img_body := img_resp.Body + defer img_body.Close() + filename := fmt.Sprintf("/tmp/xkcd-%d.%s", num, ext) + file, err := os.Create(filename) + if err != nil { + log.Fatalf("Unable to save file. See %v.\n", err) + } + defer file.Close() + if _, err := io.Copy(file, img_body); err != nil { + log.Fatal(err) + } + msg = fmt.Sprintf("Saved %d to /tmp.\n", num) + return msg +} diff --git a/src/xkcd/show-help.go b/src/xkcd/show-help.go @@ -0,0 +1,16 @@ +package xkcd + +func ShowHelp() (msg string) { + msg = "Run `xkcd help' to show this message.\n" + msg += "+ `xkcd' to show the latest xkcd comic.\n" + msg += "+ `xkcd latest' to show the latest xkcd comic.\n" + msg += "+ `xkcd random' to fetch random xkcd comic.\n" + msg += "+ `xkcd get' will function same as `xkcd latest'.\n" + msg += "+ `xkcd get 303', for example, to fetch the comic numbered 303.\n" + msg += "+ `xkcd 807' will function same as `xkcd get 807'.\n" + msg += "+ `xkcd save', to save the latest comic to `/tmp', or\n" + msg += "+ `xkcd save 1312', to save the comic numbered 1312 to `/tmp'.\n" + msg += "+ `xkcd explain', open link to explainxkcd for the latest comic.\n" + msg += "+ `xkcd explain 1000', open link to explainxkcd for comic numbered 1000.\n" + return msg +} diff --git a/src/xkcd/specific-api.go b/src/xkcd/specific-api.go @@ -0,0 +1,9 @@ +package xkcd + +import "fmt" + +// Return link to specific api, to fetch data about the comic numbered num. +func specificAPI(num int) (api string) { + api = fmt.Sprintf("https://xkcd.com/%d/info.0.json", num) + return api +} diff --git a/src/xkcd/specific-api_test.go b/src/xkcd/specific-api_test.go @@ -0,0 +1,42 @@ +package xkcd + +import ( + "fmt" + "testing" +) + +func Test1(t *testing.T) { + expected := "https://xkcd.com/1000/info.0.json" + received := specificAPI(1000) + if expected != received { + msg := fmt.Sprintf("Expected `%s', received: `%s'.\n", expected, received) + t.Error(msg) + } +} + +func Test2(t *testing.T) { + expected := "https://xkcd.com/404/info.0.json" + received := specificAPI(404) + if expected != received { + msg := fmt.Sprintf("Expected `%s', received: `%s'.\n", expected, received) + t.Error(msg) + } +} + +func Test3(t *testing.T) { + expected := "https://xkcd.com/0/info.0.json" + received := specificAPI(0) // specificAPI does not care about number, it just expects number. + if expected != received { + msg := fmt.Sprintf("Expected `%s', received: `%s'.\n", expected, received) + t.Error(msg) + } +} + +func Test4(t *testing.T) { + expected := "https://xkcd.com/-30/info.0.json" + received := specificAPI(-30) // specificAPI does not care about number, it just expects number. + if expected != received { + msg := fmt.Sprintf("Expected `%s', received: `%s'.\n", expected, received) + t.Error(msg) + } +} diff --git a/src/xkcd/xkcd-resp.go b/src/xkcd/xkcd-resp.go @@ -0,0 +1,15 @@ +package xkcd + +type xkcdResp struct { + Month string `json:"month"` + Num int `json:"num"` + Link string `json:"link"` + Year string `json:"year"` + News string `json:"news"` + SafeTitle string `json:"safe_title"` + Transcript string `json:"transcript"` + Alt string `json:"alt"` + Img string `json:"img"` + Title string `json:"title"` + Day string `json:"day"` +}