329 lines
8.8 KiB
Go
329 lines
8.8 KiB
Go
package reddit
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/mitchellh/mapstructure"
|
|
)
|
|
|
|
const (
|
|
listingKind = "Listing"
|
|
postKind = "t3"
|
|
commentKind = "t1"
|
|
messageKind = "t4"
|
|
moreKind = "more"
|
|
)
|
|
|
|
// author fields and body fields are set to the deletedKey if the user deletes
|
|
// their post.
|
|
const deletedKey = "[deleted]"
|
|
|
|
// thing is a Reddit type that holds all of their subtypes.
|
|
type thing struct {
|
|
Kind string `json:"kind"`
|
|
Data map[string]interface{} `json:"data"`
|
|
}
|
|
|
|
type listing struct {
|
|
Children []thing `json:"children,omitempty"`
|
|
}
|
|
|
|
type more struct {
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
Data []thing `json:"data"`
|
|
}
|
|
|
|
// comment wraps the user facing Comment type with a Replies field for
|
|
// intermediate parsing.
|
|
type comment struct {
|
|
Comment `mapstructure:",squash"`
|
|
Replies thing `mapstructure:"replies"`
|
|
}
|
|
|
|
// parser parses Reddit responses..
|
|
type parser interface {
|
|
// parse parses any Reddit response and provides the elements in it.
|
|
parse(blob json.RawMessage) ([]*Comment, []*Post, []*Message, []*More, error)
|
|
parse_submitted(blob json.RawMessage) (Submission, error)
|
|
}
|
|
|
|
type parserImpl struct{}
|
|
|
|
func newParser() parser {
|
|
return &parserImpl{}
|
|
}
|
|
|
|
// parse parses any Reddit response and provides the elements in it.
|
|
func (p *parserImpl) parse(
|
|
blob json.RawMessage,
|
|
) ([]*Comment, []*Post, []*Message, []*More, error) {
|
|
comments, posts, msgs, mores, listingErr := parseRawListing(blob)
|
|
if listingErr == nil {
|
|
return comments, posts, msgs, mores, nil
|
|
}
|
|
|
|
post, threadErr := parseThread(blob)
|
|
if threadErr == nil {
|
|
return nil, []*Post{post}, nil, nil, nil
|
|
}
|
|
|
|
comments, mores, moreErr := parseMoreChildren(blob)
|
|
if moreErr == nil {
|
|
return comments, nil, nil, mores, nil
|
|
}
|
|
|
|
return nil, nil, nil, nil, fmt.Errorf(
|
|
"failed to parse as listing [%v], thread [%v], or more [%v]",
|
|
listingErr, threadErr, moreErr,
|
|
)
|
|
}
|
|
|
|
// parse_submitted parses a response from reddit describing
|
|
// the status of some resource that was submitted
|
|
func (p *parserImpl) parse_submitted(blob json.RawMessage) (Submission, error) {
|
|
var wrapped map[string]interface{}
|
|
err := json.Unmarshal(blob, &wrapped)
|
|
if err != nil {
|
|
return Submission{}, err
|
|
}
|
|
|
|
wrapped = wrapped["json"].(map[string]interface{})
|
|
if len(wrapped["errors"].([]interface{})) != 0 {
|
|
return Submission{}, fmt.Errorf("API errors were returned: %v", wrapped["errors"])
|
|
}
|
|
|
|
data := wrapped["data"].(map[string]interface{})
|
|
|
|
// Comment submissions are further wrapped in a things block
|
|
// because of ... something? There only appears to be a single thing
|
|
// This transformes var data to be the data of the single thing
|
|
// This also mirrors https://reddit.com/ + permalink -> url
|
|
things, has_things := data["things"].([]interface{})
|
|
if has_things && len(things) == 1 {
|
|
data = things[0].(map[string]interface{})["data"].(map[string]interface{})
|
|
data["url"] = fmt.Sprintf("https://reddit.com%s", data["permalink"])
|
|
}
|
|
|
|
var submission Submission
|
|
err = mapstructure.Decode(data, &submission)
|
|
return submission, err
|
|
}
|
|
|
|
// parseRawListing parses a listing json blob and returns the elements in it.
|
|
func parseRawListing(
|
|
blob json.RawMessage,
|
|
) ([]*Comment, []*Post, []*Message, []*More, error) {
|
|
var activityListing thing
|
|
if err := json.Unmarshal(blob, &activityListing); err != nil {
|
|
return nil, nil, nil, nil, err
|
|
}
|
|
|
|
return parseListing(&activityListing)
|
|
}
|
|
|
|
// parseMoreChildren parses the json blob from /api/morechildren calls and returns the elements in it.
|
|
func parseMoreChildren(
|
|
blob json.RawMessage,
|
|
) ([]*Comment, []*More, error) {
|
|
var wrapped map[string]interface{}
|
|
err := json.Unmarshal(blob, &wrapped)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
wrapped = wrapped["json"].(map[string]interface{})
|
|
if len(wrapped["errors"].([]interface{})) != 0 {
|
|
return nil, nil, fmt.Errorf("API errors were returned: %v", wrapped["errors"])
|
|
}
|
|
|
|
data := wrapped["data"].(map[string]interface{})
|
|
// More submissions are further wrapped in a things block,
|
|
// so reorganize data so that it makes sense
|
|
things, hasThings := data["things"].([]interface{})
|
|
if hasThings {
|
|
data["data"] = things
|
|
delete(data, "things")
|
|
} else {
|
|
return nil, nil, fmt.Errorf("No thing types returned")
|
|
}
|
|
|
|
var m more
|
|
err = mapstructure.Decode(data, &m)
|
|
|
|
if err != nil {
|
|
return nil, nil, err
|
|
} else if m.Errors != nil {
|
|
return nil, nil, fmt.Errorf("%v", m.Errors)
|
|
}
|
|
|
|
comments, _, _, mores, err := parseChildren(m.Data)
|
|
return comments, mores, err
|
|
}
|
|
|
|
// parseThread parses a post from a thread json blob returned by Reddit.
|
|
//
|
|
// Reddit structures this as two things in an array, the first thing being a
|
|
// listing with only the post and the second thing being a listing of comments.
|
|
func parseThread(blob json.RawMessage) (*Post, error) {
|
|
var listings [2]thing
|
|
if err := json.Unmarshal(blob, &listings); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, posts, _, _, err := parseListing(&listings[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(posts) != 1 {
|
|
return nil, fmt.Errorf("expected 1 post; found %d", len(posts))
|
|
}
|
|
|
|
comments, _, _, mores, err := parseListing(&listings[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// a submission should only have one more object
|
|
if len(mores) == 1 {
|
|
posts[0].More = mores[0]
|
|
} else if len(mores) > 1 {
|
|
return nil, fmt.Errorf("expected 1 more; found %d", len(mores))
|
|
}
|
|
|
|
posts[0].Replies = comments
|
|
return posts[0], nil
|
|
}
|
|
|
|
// parseListing parses a Reddit listing type and returns the elements inside it.
|
|
func parseListing(t *thing) ([]*Comment, []*Post, []*Message, []*More, error) {
|
|
if t.Kind != listingKind {
|
|
return nil, nil, nil, nil, fmt.Errorf("thing is not listing")
|
|
}
|
|
|
|
l := &listing{}
|
|
if err := mapstructure.Decode(t.Data, l); err != nil {
|
|
return nil, nil, nil, nil, mapDecodeError(err, t.Data)
|
|
}
|
|
|
|
return parseChildren(l.Children)
|
|
}
|
|
|
|
// parseChildren returns a list of parsed objects from the given list of things
|
|
func parseChildren(children []thing) ([]*Comment, []*Post, []*Message, []*More, error) {
|
|
comments := []*Comment{}
|
|
posts := []*Post{}
|
|
msgs := []*Message{}
|
|
mores := []*More{}
|
|
err := error(nil)
|
|
|
|
for _, c := range children {
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
var comment *Comment
|
|
var post *Post
|
|
var msg *Message
|
|
var more *More
|
|
|
|
// Reddit sets the "Kind" field of comments in the inbox, which
|
|
// have only Message and not Comment fields, to commentKind. The
|
|
// give away in this case is that comments in message form have
|
|
// a field called "was_comment". Reddit does this because they
|
|
// hate programmers.
|
|
if c.Kind == messageKind || c.Data["was_comment"] != nil {
|
|
msg, err = parseMessage(&c)
|
|
msgs = append(msgs, msg)
|
|
} else if c.Kind == commentKind {
|
|
comment, err = parseComment(&c)
|
|
comments = append(comments, comment)
|
|
} else if c.Kind == postKind {
|
|
post, err = parsePost(&c)
|
|
posts = append(posts, post)
|
|
} else if c.Kind == moreKind {
|
|
more, err = parseMore(&c)
|
|
mores = append(mores, more)
|
|
}
|
|
}
|
|
|
|
return comments, posts, msgs, mores, err
|
|
}
|
|
|
|
// parseComment parses a comment into the user facing Comment struct.
|
|
func parseComment(t *thing) (*Comment, error) {
|
|
// Reddit makes the replies field a string if it is empty, just to make
|
|
// it harder for programmers who like static type systems.
|
|
value, present := t.Data["replies"]
|
|
if present {
|
|
if str, ok := value.(string); ok && str == "" {
|
|
delete(t.Data, "replies")
|
|
}
|
|
}
|
|
// Similarly, edited is false if a post hasn't been edited and a timestamp
|
|
// otherwise.
|
|
value, present = t.Data["edited"]
|
|
if present {
|
|
if _, ok := value.(bool); ok {
|
|
delete(t.Data, "edited")
|
|
}
|
|
}
|
|
|
|
c := &comment{}
|
|
if err := mapstructure.Decode(t.Data, c); err != nil {
|
|
return nil, mapDecodeError(err, t.Data)
|
|
}
|
|
|
|
var err error
|
|
var mores []*More
|
|
if c.Replies.Kind == listingKind {
|
|
c.Comment.Replies, _, _, mores, err = parseListing(&c.Replies)
|
|
// a commment branch should only have one more object
|
|
if len(mores) == 1 {
|
|
c.Comment.More = mores[0]
|
|
} else if len(mores) > 1 {
|
|
return nil, fmt.Errorf("expected 1 more; found %d", len(mores))
|
|
}
|
|
}
|
|
|
|
c.Comment.Deleted = c.Comment.Body == deletedKey
|
|
|
|
return &c.Comment, err
|
|
|
|
}
|
|
|
|
// parsePost parses a post into the user facing Post struct.
|
|
func parsePost(t *thing) (*Post, error) {
|
|
p := &Post{}
|
|
if err := mapstructure.Decode(t.Data, p); err != nil {
|
|
return nil, mapDecodeError(err, t.Data)
|
|
}
|
|
|
|
p.Deleted = p.SelfText == deletedKey
|
|
return p, nil
|
|
}
|
|
|
|
// parseMessage parses a message into the user facing Message struct.
|
|
func parseMessage(t *thing) (*Message, error) {
|
|
m := &Message{}
|
|
return m, mapstructure.Decode(t.Data, m)
|
|
}
|
|
|
|
// parseMore parses a more comment list into the user facing More struct.
|
|
func parseMore(t *thing) (*More, error) {
|
|
m := &More{}
|
|
if err := mapstructure.Decode(t.Data, m); err != nil {
|
|
return nil, mapDecodeError(err, t.Data)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func mapDecodeError(err error, val interface{}) error {
|
|
return fmt.Errorf(
|
|
"failed to decode json map into struct: %v; value: %v",
|
|
err, val,
|
|
)
|
|
}
|