A => Makefile +5 -0
@@ 1,5 @@
+# SRC := $(shell find . -type f -name "*.go")
+BIN := ./gochan
+
+all:
+ go build -o $(BIN) main.go
A => README.md +19 -0
@@ 1,19 @@
+# gochan
+
+A simple unix-like tool for browsing 4chan posts in text-only.
+
+### Build
+
+```
+git clone https://git.sr.ht/~theorytoe/gochan
+cd ./gochan
+go mod tidy
+go run main.go -h
+```
+
+Standalone binary
+```
+make
+./gochan
+```
+
A => go.mod +11 -0
@@ 1,11 @@
+module gochan
+
+go 1.17
+
+require github.com/JohannesKaufmann/html-to-markdown v1.3.3
+
+require (
+ github.com/PuerkitoBio/goquery v1.5.1 // indirect
+ github.com/andybalholm/cascadia v1.1.0 // indirect
+ golang.org/x/net v0.0.0-20200320220750-118fecf932d8 // indirect
+)
A => go.sum +37 -0
@@ 1,37 @@
+github.com/JohannesKaufmann/html-to-markdown v1.3.3 h1:T2A3aYmbGokj0LeRVLr3sWXtpNlM9jWq06qPxcXOK4Y=
+github.com/JohannesKaufmann/html-to-markdown v1.3.3/go.mod h1:JNSClIRYICFDiFhw6RBhBeWGnMSSKVZ6sPQA+TK4tyM=
+github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
+github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
+github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
+github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sebdah/goldie/v2 v2.5.1 h1:hh70HvG4n3T3MNRJN2z/baxPR8xutxo7JVxyi2svl+s=
+github.com/sebdah/goldie/v2 v2.5.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
+github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/yuin/goldmark v1.2.0 h1:WOOcyaJPlzb8fZ8TloxFe8QZkhOOJx87leDa9MIT9dc=
+github.com/yuin/goldmark v1.2.0/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200320220750-118fecf932d8 h1:1+zQlQqEEhUeStBTi653GZAnAuivZq/2hz+Iz+OP7rg=
+golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
A => lib/apiHandler/BoardThreads.go +48 -0
@@ 1,48 @@
+package apiHandler
+
+import (
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+)
+
+// a recreation of the json data from the api request.
+// []Page referes to the page of the post
+// []Threads refers to the threads on each page
+// attributes No ... Replies are from a []Thread
+type ReqBoardThreads []struct {
+ Page int `json:"page"`
+ Threads []struct {
+ No int `json:"no"`
+ LastModified int `json:"last_modified"`
+ Replies int `json:"replies"`
+ } `json:"threads"`
+}
+
+// Simple wrapper for creating a http req, and returning
+// a struct with a board listing.
+// 'b' is the board to fetch data from
+func GetBoardThreads(b string) ReqBoardThreads {
+
+ // reqest data from api with 't' being the board
+ req, err := http.Get("https://a.4cdn.org/" + b + "/threads.json")
+ if err != nil {
+ panic(err)
+ }
+
+ // defer closing
+ defer req.Body.Close()
+
+ // read data to []byte
+ body, err := ioutil.ReadAll(req.Body)
+ if err != nil {
+ panic(err)
+ }
+
+ // create a struct with the data
+ data := ReqBoardThreads{}
+
+ // interpert + return
+ json.Unmarshal([]byte(body), &data)
+ return data
+}
A => lib/apiHandler/Thread.go +66 -0
@@ 1,66 @@
+package apiHandler
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+)
+
+// a recreation of a thread from an api reqest
+type ReqThread struct {
+ Posts []struct {
+ No int `json:"no"`
+ Sticky int `json:"sticky"`
+ Closed int `json:"closed"`
+ Now string `json:"now"`
+ Name string `json:"name"`
+ Sub string `json:"sub"`
+ Com string `json:"com"`
+ Filename string `json:"filename"`
+ Ext string `json:"ext"`
+ W int `json:"w"`
+ H int `json:"h"`
+ TnW int `json:"tn_w"`
+ TnH int `json:"tn_h"`
+ Tim int64 `json:"tim"`
+ Time int `json:"time"`
+ Md5 string `json:"md5"`
+ Fsize int `json:"fsize"`
+ Resto int `json:"resto"`
+ Capcode string `json:"capcode"`
+ SemanticURL string `json:"semantic_url,omitempty"`
+ Replies int `json:"replies,omitempty"`
+ Images int `json:"images,omitempty"`
+ UniqueIps int `json:"unique_ips,omitempty"`
+ } `json:"posts"`
+}
+
+// Simple wrapper for creating a http req, and returning
+// a struct with a thread listing.
+// 'b' is the board to fetch data from
+// 't' is the id of the thread
+func GetThread(b string, t int) ReqThread {
+
+ // reqest the content of given thread
+ req, err := http.Get("https://a.4cdn.org/" + b + "/thread/" + fmt.Sprint(t) + ".json")
+ if err != nil {
+ panic(err)
+ }
+
+ // defer closing
+ defer req.Body.Close()
+
+ // read data to []byte
+ body, err := ioutil.ReadAll(req.Body)
+ if err != nil {
+ panic(err)
+ }
+
+ // create data struct
+ data := ReqThread{}
+
+ // write + return
+ json.Unmarshal([]byte(body), &data)
+ return data
+}
A => main.go +158 -0
@@ 1,158 @@
+package main
+
+import (
+ "os"
+ "flag"
+ "fmt"
+ md "github.com/JohannesKaufmann/html-to-markdown"
+ "gochan/lib/apiHandler"
+)
+
+// command line flags
+var (
+ fBoard string // the board being picked
+ fPageNum int // the page number of threads in that board
+ fThreadId int
+ fPostId int // the post id
+ fPostOP int // used with -i, the original thread to search for a comment via id
+
+ fHelp bool // print help dialouge
+
+ MaxPages int = 10 // the maxium of pages in a request, this is the hard limit
+)
+
+func getPage(request apiHandler.ReqBoardThreads, converter md.Converter) {
+ // length of request
+ reqLen := len(request[fPageNum].Threads)
+ for i := 0; i < reqLen; i++ {
+ // get the content of a thread, including
+ // title, author, date, and content (in markdown)
+ fmt.Print("-------------BEGIN CONTENT #")
+ fmt.Println(i)
+ thread := apiHandler.GetThread(fBoard, int(request[fPageNum].Threads[i].No))
+ for j := 0; j < len(thread.Posts); j++ {
+
+ // if the thread title is empty, just print
+ // the post title
+ threadTitle := thread.Posts[j].Sub
+ if threadTitle == "" {
+ fmt.Print("Post #")
+ fmt.Println(j)
+ } else {
+ fmt.Println(threadTitle)
+ }
+
+ fmt.Println(thread.Posts[j].Name) // print author name (90% of the time anonymous)
+ fmt.Println(thread.Posts[j].Now) // print date of post
+ fmt.Println(thread.Posts[j].No) // print ID of post (for comment links)
+
+ // convert the html markup of the post into
+ // markdown for easier reading
+ fmt.Println("Post Content:")
+ content, err := converter.ConvertString(thread.Posts[j].Com)
+ if err != nil {
+ panic("Issue with converting post" + string(j) + "'s content into markdown")
+ }
+ fmt.Println(content)
+
+ // bar for cleanliness
+ fmt.Println("---------------------")
+
+ }
+ // bar for cleanliness
+ fmt.Print("-------------END CONTENT #")
+ fmt.Println(i)
+ }
+
+}
+
+// search a thread for a certain post/comment (by id)
+func getPostById(op int, id int, converter md.Converter) {
+
+ // if the op is the id, then just print the entire thread
+ if op == id {
+ thread := apiHandler.GetThread(fBoard, op)
+ for i := 0; i < len(thread.Posts); i++ {
+
+ // print title and date of post
+ fmt.Println(thread.Posts[i].Name) // print author name (90% of the time anonymous)
+ fmt.Println(thread.Posts[i].Now) // print date of post
+ fmt.Println(thread.Posts[i].No) // print ID of post (for comment links)
+
+ // convert html markup to markdown for post body
+ content, err := converter.ConvertString(thread.Posts[i].Com)
+ if err != nil {
+ panic("Issue with converting post" + string(i) + "'s content into markdown")
+ }
+ fmt.Println(content)
+ }
+ // search for a post of id <id> in op thread
+ } else {
+ thread := apiHandler.GetThread(fBoard, op)
+ for i := 0; i < len(thread.Posts); i++ {
+ if thread.Posts[i].No == id {
+
+ // print title and date of post
+ fmt.Println(thread.Posts[i].Name) // print author name (90% of the time anonymous)
+ fmt.Println(thread.Posts[i].Now) // print date of post
+ fmt.Println(thread.Posts[i].No) // print ID of post (for comment links)
+
+ // convert html markup to markdown for post body
+ content, err := converter.ConvertString(thread.Posts[i].Com)
+ if err != nil {
+ panic("Issue with converting post" + string(i) + "'s content into markdown")
+ }
+ fmt.Println(content)
+ }
+ }
+ }
+}
+
+func main() {
+ // get and parse command line flags
+ flag.StringVar(&fBoard, "b", "g", "Board to grab results from")
+ flag.IntVar(&fPageNum, "p", 1, "Page of threads")
+ flag.IntVar(&fPostId, "i", 0, "Id of individual thread")
+ flag.IntVar(&fPostOP, "op", 0, "Id of OP to search for a comment")
+ flag.BoolVar(&fHelp, "h", false, "Print help dialouge")
+ flag.Parse()
+
+ // print help dialouge
+ if fHelp == true {
+ flag.PrintDefaults()
+ os.Exit(0)
+ }
+
+ // panic if the fPageNum is above the actual returned amount of
+ // boards
+ if fPageNum > MaxPages {
+ panic("Too large of a page number! (range: 1-10)")
+ }
+
+ // make struct from board request
+ request := apiHandler.GetBoardThreads(fBoard)
+
+ // decalre a html -> markdown converter for
+ // displaying content
+ mdcov := md.NewConverter("", true, nil)
+
+ // if a postid query is passed
+ if fPostId != 0 {
+ // check if an opriginal post id is passed
+ if fPostOP != 0 {
+ getPostById(fPostOP, fPostId, *mdcov)
+ } else {
+ fmt.Println("-i requires specifing an OP post with --op!")
+ }
+ } else if fThreadId != 0 {
+ getPage(request, *mdcov)
+
+ // fetch latest threads
+ } else {
+ getPage(request, *mdcov)
+ }
+
+ // list the threads of the specified page number
+ // fmt.Println(request[fPageNum].Threads)
+
+}