From ce2b19ebd088630ab5b75604077dab8d4a437cce Mon Sep 17 00:00:00 2001 From: Etzelia Date: Tue, 4 May 2021 22:05:20 -0500 Subject: [PATCH] Initial commit Signed-off-by: Etzelia --- .gitignore | 7 + LICENSE | 19 ++ Makefile | 17 ++ README.md | 27 +++ client.go | 29 +++ cmd/falseknees/main.go | 102 +++++++++ falseknees.go | 130 +++++++++++ falseknees_test.go | 483 +++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + 9 files changed, 817 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 client.go create mode 100644 cmd/falseknees/main.go create mode 100644 falseknees.go create mode 100644 falseknees_test.go create mode 100644 go.mod diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f98666 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# GoLand +.idea/ + + +# Binaries +/falseknees +/falseknees.exe diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b3a688b --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Etzelia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3168e1b --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +GO ?= go + +.PHONY: build +build: + $(GO) build ./cmd/falseknees + +.PHONY: vet +vet: + $(GO) vet ./... + +.PHONY: fmt +fmt: + $(GO) fmt ./... + +.PHONY: test +test: + $(GO) test -race ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..dee928e --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# FalseKnees + +This is definitely not real API for [FalseKnees](https://www.falseknees.com). + +By that I mean to say FK does not *have* an API, so this is a glorified HTML parser. + +Please be considerate of the fact that this is not a real API and so it is pulling entire HTML pages +to parse. + + +## Current Comic + +[FalseKnees](https://www.falseknees.com) `/index.html` page contains a redirect to get the latest +comic number. This library leverages that page to get the current comic, **however** in an effort +to reduce HTTP calls against full HTML pages, the library has a built-in interval of 30 minutes. + +This means that if you get the current comic at 12:00 and FK adds a new comic at 12:05, you +won't get the new current comic until 12:30. + +Currently the following funcs retrieve the current comic number: + +* `Random` +* `Current` + +## License + +[MIT](LICENSE) \ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..b4545af --- /dev/null +++ b/client.go @@ -0,0 +1,29 @@ +package falseknees + +import "net/http" + +// Client is a FalseKnees client +type Client struct { + http *http.Client +} + +// New returns a new Client +func New(opts ...ClientOption) *Client { + c := &Client{ + http: http.DefaultClient, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// ClientOption is options for a Client +type ClientOption func(*Client) + +// WithHTTP is a ClientOption for using a different http.Client +func WithHTTP(client *http.Client) ClientOption { + return func(c *Client) { + c.http = client + } +} diff --git a/cmd/falseknees/main.go b/cmd/falseknees/main.go new file mode 100644 index 0000000..43db0b8 --- /dev/null +++ b/cmd/falseknees/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "net/http" + "os" + "strconv" + + "git.birbmc.com/Etzelia/falseknees" +) + +var ( + randomFlag bool + imageFlag bool + outFlag string +) + +func main() { + flag.BoolVar(&randomFlag, "random", false, "get a random comic") + flag.BoolVar(&imageFlag, "image", false, "get the image response for a comic") + flag.StringVar(&outFlag, "out", "", "where to write the response (default: stdout)") + flag.Parse() + + comicNum := 0 + if flag.NArg() > 0 { + arg := flag.Arg(0) + i, err := strconv.Atoi(arg) + if err != nil { + fmt.Printf("Specific comic must be a number: %v\n", err) + return + } + comicNum = i + } + out := os.Stdout + if outFlag != "" { + fi, err := os.Create(outFlag) + if err != nil { + fmt.Printf("Could not create file %s: %v\n", outFlag, err) + return + } + defer fi.Close() + out = fi + } + + if err := run(comicNum, out); err != nil { + fmt.Println(err) + } +} + +func run(comicNum int, out io.WriteCloser) error { + + client := falseknees.New() + var comic *falseknees.Comic + var err error + if randomFlag { + comic, err = client.Random(context.Background()) + } else if comicNum == 0 { + comic, err = client.Current(context.Background()) + } else { + comic, err = client.Comic(context.Background(), comicNum) + } + if err != nil { + return err + } + + if imageFlag { + return image(comic.Img, out) + } + + format := `Comic: %d +Title: %s +Image: %s +` + if _, err := fmt.Fprintf(out, format, + comic.Num, + comic.Title, + comic.Img); err != nil { + return err + } + return nil +} + +func image(img string, out io.WriteCloser) error { + req, err := http.NewRequest(http.MethodGet, img, nil) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return err + } + return nil +} diff --git a/falseknees.go b/falseknees.go new file mode 100644 index 0000000..59b0e84 --- /dev/null +++ b/falseknees.go @@ -0,0 +1,130 @@ +package falseknees + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "math/rand" + "net/http" + "regexp" + "strconv" + "time" +) + +var ( + updateInterval = time.Minute * 30 + baseURL = "https://www.falseknees.com/" + currentRe = regexp.MustCompile(`window\.location\.href.+"(.+)\.html"`) + imageRe = regexp.MustCompile(`src="(imgs.+\.png)".+title="(.+)"`) + + current int + lastUpdate time.Time +) + +// Comic is a FalseKnees comic +type Comic struct { + Num int + Title string + Img string +} + +// Comic returns a specific Comic +func (c *Client) Comic(ctx context.Context, num int) (*Comic, error) { + return c.comic(ctx, num) +} + +// Current returns the current Comic +func (c *Client) Current(ctx context.Context) (*Comic, error) { + if err := c.updateCurrent(ctx); err != nil { + return nil, err + } + return c.Comic(ctx, current) +} + +// Random returns a random Comic +func (c *Client) Random(ctx context.Context) (*Comic, error) { + if err := c.updateCurrent(ctx); err != nil { + return nil, err + } + rand.Seed(time.Now().UnixNano()) + return c.Comic(ctx, rand.Intn(current)+1) +} + +func (c *Client) updateCurrent(ctx context.Context) error { + now := time.Now() + if !lastUpdate.IsZero() && lastUpdate.After(now.Add(-updateInterval)) { + return nil + } + lastUpdate = now + + u := fmt.Sprintf("%sindex.html", baseURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + + resp, err := c.http.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("could not get page for index: %s", resp.Status) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + defer resp.Body.Close() + + match := currentRe.FindStringSubmatch(string(body)) + if len(match) == 0 { + return errors.New("could not find current comic") + } + + curr, err := strconv.Atoi(match[1]) + if err != nil { + return err + } + + current = curr + return nil +} + +func (c *Client) comic(ctx context.Context, num int) (*Comic, error) { + u := fmt.Sprintf("%s%d.html", baseURL, num) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("could not get page for comic %d: %s", num, resp.Status) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + match := imageRe.FindStringSubmatch(string(body)) + if len(match) == 0 { + return nil, fmt.Errorf("could not find comic #%d", num) + } + + return &Comic{ + Num: num, + Title: match[2], + Img: fmt.Sprintf("%s%s", baseURL, match[1]), + }, nil +} diff --git a/falseknees_test.go b/falseknees_test.go new file mode 100644 index 0000000..2e01d41 --- /dev/null +++ b/falseknees_test.go @@ -0,0 +1,483 @@ +package falseknees + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +var ( + server *httptest.Server + client *Client +) + +func TestMain(m *testing.M) { + handler := func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/index.html" { + _, _ = w.Write(indexHTML) + return + } + if r.URL.Path == "/389.html" { + _, _ = w.Write(currentHTML) + return + } + if r.URL.Path == "/252.html" { + _, _ = w.Write(bunnyHTML) + return + } + w.WriteHeader(http.StatusNotFound) + } + + server = httptest.NewServer(http.HandlerFunc(handler)) + baseURL = server.URL + "/" + currentComic.Img = fmt.Sprintf("%simgs/389.png", baseURL) + bunnyComic.Img = fmt.Sprintf("%simgs/252.png", baseURL) + client = New() + + os.Exit(m.Run()) +} + +func TestCurrent(t *testing.T) { + t.Parallel() + + comic, err := client.Current(context.Background()) + if err != nil { + t.Logf("could not get current comic: %v\n", err) + t.FailNow() + } + + if *comic != currentComic { + t.Log("comic does not match test data") + t.FailNow() + } +} + +func TestComic(t *testing.T) { + t.Parallel() + + comic, err := client.Comic(context.Background(), 252) + if err != nil { + t.Logf("could not get comic 252: %v\n", err) + t.FailNow() + } + + if *comic != bunnyComic { + t.Log("comic does not match test data") + t.FailNow() + } +} + +var ( + currentComic = Comic{ + Num: 389, + Title: "that's the good stuff", + } + bunnyComic = Comic{ + Num: 252, + Title: "Spring is the fucking greatest shit", + } + + indexHTML = []byte(` + + + + + + + + + + + + + + + + + + + + + + Page Redirection + + + + + + +
+ + + + + + + +
+
+ +
+
+ + +
+

+ + + + + + + + + + +
About + Store + + About +
+

+
+ + +
+ +
+ + + +
+
+ + +
+

+ + + + + + + + +
First + Previous + Archive + Next + Last +
+

+
+ + + + + + + + + + + + + + + +
+

False Knees © 2013-whenever Joshua Barkman

+
+ + +`) + + currentHTML = []byte(` + + + + + + + + + + + + + + + + + False Knees + + + + + +
+ + + + + + + +
+ +
+ + +
+

+ + + + + + + + + + +
AboutStoreAbout

+
+ + +
+ +
+ + + +
+
+ + +
+

+ + + + + + + + +
FirstPreviousArchiveNextLast

+
+ + + + + + + + + + + + + + + +
+

False Knees © 2013-whenever Joshua Barkman

+
+ + +`) + + bunnyHTML = []byte(` + + + + + + + + + + + + + + + + + False Knees + + + + + +
+ + + + + + + +
+ +
+ + +
+

+ + + + + + + + + + +
AboutStoreAbout

+
+ + +
+ +
+ + + +
+
+ + +
+

+ + + + + + + + +
FirstPreviousArchiveNextLast

+
+ + + + + + + + + + + + + + + +
+

False Knees © 2013-whenever Joshua Barkman

+
+ + +`) +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a52b2c6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.birbmc.com/Etzelia/falseknees + +go 1.15