diff --git a/reddit/helpers.go b/reddit/helpers.go new file mode 100644 index 0000000..e9ee431 --- /dev/null +++ b/reddit/helpers.go @@ -0,0 +1,68 @@ +package reddit + +import ( + "sync" +) + +// OrderedMaxSet is intended to be able to check if things have been seen while +// expiring older entries that are unlikely to be seen again. +// This is to avoid memory issues in long-running streams. +type OrderedMaxSet struct { + MaxSize int + set map[string]struct{} + keys []string + mutex *sync.Mutex +} + +// NewOrderedMaxSet instantiates an OrderedMaxSet and returns it for downstream use. +func NewOrderedMaxSet(maxSize int) OrderedMaxSet { + var mutex = &sync.Mutex{} + orderedMaxSet := OrderedMaxSet{ + MaxSize: maxSize, + set: map[string]struct{}{}, + keys: []string{}, + mutex: mutex, + } + + return orderedMaxSet +} + +// Add accepts a string and inserts it into an OrderedMaxSet +func (s *OrderedMaxSet) Add(v string) { + s.mutex.Lock() + defer s.mutex.Unlock() + _, ok := s.set[v] + if !ok { + s.keys = append(s.keys, v) + s.set[v] = struct{}{} + } + if len(s.keys) > s.MaxSize { + for _, id := range s.keys[:len(s.keys)-s.MaxSize] { + delete(s.set, id) + } + s.keys = s.keys[(len(s.keys) - s.MaxSize):] + + } +} + +// Delete accepts a string and deletes it from OrderedMaxSet +func (s *OrderedMaxSet) Delete(v string) { + s.mutex.Lock() + defer s.mutex.Unlock() + delete(s.set, v) +} + +// Len returns the number of elements in OrderedMaxSet +func (s *OrderedMaxSet) Len() int { + s.mutex.Lock() + defer s.mutex.Unlock() + return len(s.set) +} + +// Exists accepts a string and determines if it is present in OrderedMaxSet +func (s *OrderedMaxSet) Exists(v string) bool { + s.mutex.Lock() + defer s.mutex.Unlock() + _, ok := s.set[v] + return ok +} diff --git a/reddit/helpers_test.go b/reddit/helpers_test.go new file mode 100644 index 0000000..babaf48 --- /dev/null +++ b/reddit/helpers_test.go @@ -0,0 +1,34 @@ +package reddit + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewOrderedMaxSet(t *testing.T) { + set := NewOrderedMaxSet(1) + set.Add("foo") + set.Add("bar") + println(len(set.keys)) + require.Equal(t, set.Len(), 1) +} + +func TestOrderedMaxSetCollision(t *testing.T) { + set := NewOrderedMaxSet(2) + set.Add("foo") + set.Add("foo") + + require.Equal(t, set.Len(), 1) +} + +func TestOrderedMaxSet_Delete(t *testing.T) { + set := NewOrderedMaxSet(1) + set.Add("foo") + + require.Equal(t, set.Len(), 1) + + set.Delete("foo") + require.Equal(t, set.Len(), 0) + require.False(t, set.Exists("foo")) +} diff --git a/reddit/listings.go b/reddit/listings.go index a7b8bb1..5a4a342 100644 --- a/reddit/listings.go +++ b/reddit/listings.go @@ -31,10 +31,27 @@ func (s *ListingsService) Get(ctx context.Context, ids ...string) ([]*Post, []*C // GetPosts returns posts from their full IDs. func (s *ListingsService) GetPosts(ctx context.Context, ids ...string) ([]*Post, *Response, error) { - path := fmt.Sprintf("by_id/%s", strings.Join(ids, ",")) + converted_ids := []string{} + for _, id := range ids { + converted_ids = append(converted_ids, "t3_"+id) + } + path := fmt.Sprintf("by_id/%s", strings.Join(converted_ids, ",")) l, resp, err := s.client.getListing(ctx, path, nil) if err != nil { return nil, resp, err } return l.Posts(), resp, nil } + +func (s *ListingsService) GetComments(ctx context.Context, ids ...string) ([]*Comment, *Response, error) { + converted_ids := []string{} + for _, id := range ids { + converted_ids = append(converted_ids, "t1_"+id) + } + path := fmt.Sprintf("api/info?id=%s", strings.Join(converted_ids, ",")) + l, resp, err := s.client.getListing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + return l.Comments(), resp, nil +} diff --git a/reddit/stream.go b/reddit/stream.go index 14f5b85..66329ae 100644 --- a/reddit/stream.go +++ b/reddit/stream.go @@ -31,55 +31,76 @@ func (s *StreamService) Posts(subreddit string, opts ...StreamOpt) (<-chan *Post ticker := time.NewTicker(streamConfig.Interval) postsCh := make(chan *Post) errsCh := make(chan error) - - var once sync.Once - stop := func() { - once.Do(func() { - ticker.Stop() - close(postsCh) - close(errsCh) - }) - } + ctx, cancel := context.WithCancel(context.Background()) // originally used the "before" parameter, but if that post gets deleted, subsequent requests // would just return empty listings; easier to just keep track of all post ids encountered - ids := set{} + ids := NewOrderedMaxSet(2000) go func() { - defer stop() + defer close(postsCh) + defer close(errsCh) + defer cancel() + var wg sync.WaitGroup + defer wg.Wait() + var mutex sync.Mutex var n int infinite := streamConfig.MaxRequests == 0 for ; ; <-ticker.C { - n++ - - posts, err := s.getPosts(subreddit) - if err != nil { - errsCh <- err - if !infinite && n >= streamConfig.MaxRequests { - break - } - continue + select { + case <-ctx.Done(): + ticker.Stop() + return + default: } - for _, post := range posts { - id := post.FullID - - // if this post id is already part of the set, it means that it and the ones - // after it in the list have already been streamed, so break out of the loop - if ids.Exists(id) { - break + n++ + wg.Add(1) + go s.getPosts(ctx, subreddit, func(posts []*Post, err error) { + defer wg.Done() + + if err != nil { + select { + case <-ctx.Done(): + default: + errsCh <- err + } + return } - ids.Add(id) - if streamConfig.DiscardInitial { - streamConfig.DiscardInitial = false - break + for _, post := range posts { + id := post.FullID + + // if this post id is already part of the set, it means that it and the ones + // after it in the list have already been streamed, so break out of the loop + if ids.Exists(id) { + break + } + ids.Add(id) + + if func() bool { + mutex.Lock() + toReturn := false + if streamConfig.DiscardInitial { + streamConfig.DiscardInitial = false + toReturn = true + } + mutex.Unlock() + return toReturn + }() { + break + } + + select { + case <-ctx.Done(): + return + default: + postsCh <- post + } } - - postsCh <- post - } + }) if !infinite && n >= streamConfig.MaxRequests { break @@ -87,29 +108,111 @@ func (s *StreamService) Posts(subreddit string, opts ...StreamOpt) (<-chan *Post } }() - return postsCh, errsCh, stop + return postsCh, errsCh, cancel } -func (s *StreamService) getPosts(subreddit string) ([]*Post, error) { - posts, _, err := s.client.Subreddit.NewPosts(context.Background(), subreddit, &ListOptions{Limit: 100}) - return posts, err -} +// Comments streams comments from the specified subreddit. +// It returns 2 channels and a function: +// - a channel into which new comments will be sent +// - a channel into which any errors will be sent +// - a function that the client can call once to stop the streaming and close the channels +// Because of the 100 result limit imposed by Reddit when fetching posts, some high-traffic +// streams might drop submissions between API requests, such as when streaming r/all. +func (s *StreamService) Comments(subreddit string, opts ...StreamOpt) (<-chan *Comment, <-chan error, func()) { + streamConfig := &streamConfig{ + Interval: defaultStreamInterval, + DiscardInitial: false, + MaxRequests: 0, + } + for _, opt := range opts { + opt(streamConfig) + } -type set map[string]struct{} + ticker := time.NewTicker(streamConfig.Interval) + commentsCh := make(chan *Comment) + errsCh := make(chan error) + ctx, cancel := context.WithCancel(context.Background()) -func (s set) Add(v string) { - s[v] = struct{}{} -} + ids := NewOrderedMaxSet(2000) + + go func() { + defer close(commentsCh) + defer close(errsCh) + defer cancel() + var wg sync.WaitGroup + defer wg.Wait() + var mutex sync.Mutex + + var n int + infinite := streamConfig.MaxRequests == 0 + + for ; ; <-ticker.C { + select { + case <-ctx.Done(): + ticker.Stop() + return + default: + } + n++ + wg.Add(1) + + go s.getComments(ctx, subreddit, func(comments []*Comment, err error) { + defer wg.Done() + if err != nil { + select { + case <-ctx.Done(): + default: + errsCh <- err + } + return + } + + for _, comment := range comments { + id := comment.FullID + + // certain comment streams are inconsistent about the completeness of returned comments + // it's not enough to check if we've seen older comments, but we must check for every comment individually + if !ids.Exists(id) { + ids.Add(id) + + if func() bool { + mutex.Lock() + toReturn := false + if streamConfig.DiscardInitial { + streamConfig.DiscardInitial = false + toReturn = true + } + mutex.Unlock() + return toReturn + }() { + break + } + + select { + case <-ctx.Done(): + return + default: + commentsCh <- comment + } + } + + } + }) + if !infinite && n >= streamConfig.MaxRequests { + break + } + } + }() -func (s set) Delete(v string) { - delete(s, v) + return commentsCh, errsCh, cancel } -func (s set) Len() int { - return len(s) +func (s *StreamService) getPosts(ctx context.Context, subreddit string, cb func([]*Post, error)) { + posts, _, err := s.client.Subreddit.NewPosts(ctx, subreddit, &ListOptions{Limit: 100}) + cb(posts, err) } -func (s set) Exists(v string) bool { - _, ok := s[v] - return ok +func (s *StreamService) getComments(ctx context.Context, subreddit string, cb func([]*Comment, error)) { + comments, _, err := s.client.Subreddit.Comments(ctx, subreddit, &ListOptions{Limit: 100}) + cb(comments, err) } diff --git a/reddit/stream_test.go b/reddit/stream_test.go index 9ae48c6..5b4a6ca 100644 --- a/reddit/stream_test.go +++ b/reddit/stream_test.go @@ -133,8 +133,7 @@ func TestStreamService_Posts(t *testing.T) { } }) - posts, errs, stop := client.Stream.Posts("testsubreddit", StreamInterval(time.Millisecond*10), StreamMaxRequests(4)) - defer stop() + posts, errs, _ := client.Stream.Posts("testsubreddit", StreamInterval(time.Millisecond*10), StreamMaxRequests(4)) expectedPostIDs := []string{"t3_post1", "t3_post2", "t3_post3", "t3_post4", "t3_post5", "t3_post6", "t3_post7", "t3_post8", "t3_post9", "t3_post10", "t3_post11", "t3_post12"} var i int @@ -283,8 +282,7 @@ func TestStreamService_Posts_DiscardInitial(t *testing.T) { } }) - posts, errs, stop := client.Stream.Posts("testsubreddit", StreamInterval(time.Millisecond*10), StreamMaxRequests(4), StreamDiscardInitial) - defer stop() + posts, errs, _ := client.Stream.Posts("testsubreddit", StreamInterval(time.Millisecond*10), StreamMaxRequests(4), StreamDiscardInitial) expectedPostIDs := []string{"t3_post3", "t3_post4", "t3_post5", "t3_post6", "t3_post7", "t3_post8", "t3_post9", "t3_post10", "t3_post11", "t3_post12"} var i int @@ -308,3 +306,301 @@ loop: require.Len(t, expectedPostIDs, i) } + +func TestStreamService_Comments(t *testing.T) { + client, mux := setup(t) + + var counter int + mux.HandleFunc("/r/testsubreddit/comments", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + defer func() { counter++ }() + + switch counter { + case 0: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t1", + "data": { + "name": "t1_comment1" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment2" + } + } + ] + } + }`) + case 1: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t1", + "data": { + "name": "t1_comment3" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment1" + } + } + ] + } + }`) + case 2: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t1", + "data": { + "name": "t1_comment4" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment5" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment6" + } + } + ] + } + }`) + case 3: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t1", + "data": { + "name": "t1_comment7" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment8" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment9" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment10" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment11" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment12" + } + } + ] + } + }`) + default: + fmt.Fprint(w, `{}`) + } + }) + + comments, errs, _ := client.Stream.Comments("testsubreddit", StreamInterval(time.Millisecond*10), StreamMaxRequests(4)) + + expectedCommentIds := []string{"t1_comment1", "t1_comment2", "t1_comment3", "t1_comment4", "t1_comment5", "t1_comment6", "t1_comment7", "t1_comment8", "t1_comment9", "t1_comment10", "t1_comment11", "t1_comment12"} + var i int + +loop: + for i != len(expectedCommentIds) { + select { + case comment, ok := <-comments: + if !ok { + break loop + } + require.Equal(t, expectedCommentIds[i], comment.FullID) + case err, ok := <-errs: + if !ok { + break loop + } + require.NoError(t, err) + } + i++ + } + + require.Len(t, expectedCommentIds, i) +} + +func TestStreamService_CommentsDiscardInitial(t *testing.T) { + client, mux := setup(t) + + var counter int + mux.HandleFunc("/r/testsubreddit/comments", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + defer func() { counter++ }() + + switch counter { + case 0: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t1", + "data": { + "name": "t1_comment1" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment2" + } + } + ] + } + }`) + case 1: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t1", + "data": { + "name": "t1_comment3" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment1" + } + } + ] + } + }`) + case 2: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t1", + "data": { + "name": "t1_comment4" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment5" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment6" + } + } + ] + } + }`) + case 3: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t1", + "data": { + "name": "t1_comment7" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment8" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment9" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment10" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment11" + } + }, + { + "kind": "t1", + "data": { + "name": "t1_comment12" + } + } + ] + } + }`) + default: + fmt.Fprint(w, `{}`) + } + }) + + comments, errs, _ := client.Stream.Comments("testsubreddit", StreamInterval(time.Millisecond*10), StreamMaxRequests(4), StreamDiscardInitial) + + expectedCommentIds := []string{"t1_comment3", "t1_comment4", "t1_comment5", "t1_comment6", "t1_comment7", "t1_comment8", "t1_comment9", "t1_comment10", "t1_comment11", "t1_comment12"} + var i int + +loop: + for i != len(expectedCommentIds) { + select { + case comment, ok := <-comments: + if !ok { + break loop + } + require.Equal(t, expectedCommentIds[i], comment.FullID) + case err, ok := <-errs: + if !ok { + break loop + } + require.NoError(t, err) + } + i++ + } + + require.Len(t, expectedCommentIds, i) +} diff --git a/reddit/subreddit.go b/reddit/subreddit.go index dc94838..948498a 100644 --- a/reddit/subreddit.go +++ b/reddit/subreddit.go @@ -313,6 +313,19 @@ func (s *SubredditService) getPosts(ctx context.Context, sort string, subreddit return l.Posts(), resp, nil } +func (s *SubredditService) getComments(ctx context.Context, subreddit string, opts interface{}) ([]*Comment, *Response, error) { + path := "comments" + if subreddit != "" { + path = fmt.Sprintf("r/%s/comments", subreddit) + } + + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Comments(), resp, nil +} + // HotPosts returns the hottest posts from the specified subreddit. // To search through multiple, separate the names with a plus (+), e.g. "golang+test". // If none are defined, it returns the ones from your subscribed subreddits. @@ -360,6 +373,15 @@ func (s *SubredditService) TopPosts(ctx context.Context, subreddit string, opts return s.getPosts(ctx, "top", subreddit, opts) } +// Comments returns the newest comments from a specific subreddit +// To search through multiple, separate the names with a plus (+), e.g. "golang+test". +// If none are defined, it returns the ones from your subscribed subreddits. +// To search through all, just specify "all". +// To search through all and filter out subreddits, provide "all-name1-name2". +func (s *SubredditService) Comments(ctx context.Context, subreddit string, opts *ListOptions) ([]*Comment, *Response, error) { + return s.getComments(ctx, subreddit, opts) +} + // Get a subreddit by name. func (s *SubredditService) Get(ctx context.Context, name string) (*Subreddit, *Response, error) { if name == "" { diff --git a/reddit/subreddit_test.go b/reddit/subreddit_test.go index 9bf9049..c59ec65 100644 --- a/reddit/subreddit_test.go +++ b/reddit/subreddit_test.go @@ -519,6 +519,22 @@ func TestSubredditService_TopPosts(t *testing.T) { require.Equal(t, "t3_hyhquk", resp.After) } +func TestSubredditService_Comments(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/comments.json") + require.NoError(t, err) + + mux.HandleFunc("/r/golang/comments", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + comments, _, err := client.Subreddit.Comments(ctx, "golang", nil) + require.NoError(t, err) + require.Len(t, comments, 5) +} + func TestSubredditService_Get(t *testing.T) { client, mux := setup(t) diff --git a/testdata/subreddit/comments.json b/testdata/subreddit/comments.json new file mode 100644 index 0000000..bd8efe9 --- /dev/null +++ b/testdata/subreddit/comments.json @@ -0,0 +1,395 @@ +{ + "kind": "Listing", + "data": { + "modhash": null, + "dist": 5, + "children": [{ + "kind": "t1", + "data": { + "total_awards_received": 0, + "approved_at_utc": null, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "removal_reason": null, + "link_id": "t3_lzymk9", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "gq57kcs", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "no_follow": true, + "author": "RyanTheKing", + "num_comments": 4, + "edited": false, + "can_mod_post": false, + "created_utc": 1615154286.0, + "send_replies": true, + "parent_id": "t3_lzymk9", + "score": 1, + "author_fullname": "t2_6wmdw", + "over_18": false, + "treatment_tags": [], + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "subreddit_id": "t5_2rc7j", + "body": "Great article, I hate when people immediately reach for something like React when the front end's requirements really don't call for it.", + "link_title": "How I build web frontends in Go", + "author_flair_css_class": null, + "name": "t1_gq57kcs", + "author_patreon_flair": false, + "downs": 0, + "author_flair_richtext": [], + "is_submitter": false, + "body_html": "<div class=\"md\"><p>Great article, I hate when people immediately reach for something like React when the front end&#39;s requirements really don&#39;t call for it.</p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "top_awarded_type": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/golang/comments/lzymk9/how_i_build_web_frontends_in_go/gq57kcs/", + "num_reports": null, + "link_permalink": "https://www.reddit.com/r/golang/comments/lzymk9/how_i_build_web_frontends_in_go/", + "report_reasons": null, + "link_author": "flippeeer", + "subreddit": "golang", + "author_flair_text": null, + "link_url": "https://philippta.github.io/web-frontends-in-go/", + "created": 1615183086.0, + "collapsed": false, + "subreddit_name_prefixed": "r/golang", + "controversiality": 0, + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "subreddit_type": "public", + "ups": 1 + } + }, { + "kind": "t1", + "data": { + "total_awards_received": 0, + "approved_at_utc": null, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "removal_reason": null, + "link_id": "t3_m002yf", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "gq56rwz", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "no_follow": true, + "author": "JBird_Vegas", + "num_comments": 1, + "edited": false, + "can_mod_post": false, + "created_utc": 1615153950.0, + "send_replies": true, + "parent_id": "t3_m002yf", + "score": 1, + "author_fullname": "t2_7nfcix3f", + "over_18": false, + "treatment_tags": [], + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "subreddit_id": "t5_2rc7j", + "body": "You asked in a GoLang sub of course everyone is going to say Go. If you asked in a node sub people would say node", + "link_title": "Should I build an http server in go?", + "author_flair_css_class": null, + "name": "t1_gq56rwz", + "author_patreon_flair": false, + "downs": 0, + "author_flair_richtext": [], + "is_submitter": false, + "body_html": "<div class=\"md\"><p>You asked in a GoLang sub of course everyone is going to say Go. If you asked in a node sub people would say node</p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "top_awarded_type": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/golang/comments/m002yf/should_i_build_an_http_server_in_go/gq56rwz/", + "num_reports": null, + "link_permalink": "https://www.reddit.com/r/golang/comments/m002yf/should_i_build_an_http_server_in_go/", + "report_reasons": null, + "link_author": "fabr0o", + "subreddit": "golang", + "author_flair_text": null, + "link_url": "https://www.reddit.com/r/golang/comments/m002yf/should_i_build_an_http_server_in_go/", + "created": 1615182750.0, + "collapsed": false, + "subreddit_name_prefixed": "r/golang", + "controversiality": 0, + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "subreddit_type": "public", + "ups": 1 + } + }, { + "kind": "t1", + "data": { + "total_awards_received": 0, + "approved_at_utc": null, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "removal_reason": null, + "link_id": "t3_lzymk9", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "gq55r0o", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "no_follow": true, + "author": "deepguner", + "num_comments": 4, + "edited": false, + "can_mod_post": false, + "created_utc": 1615153516.0, + "send_replies": true, + "parent_id": "t3_lzymk9", + "score": 1, + "author_fullname": "t2_1p5vbtp6", + "over_18": false, + "treatment_tags": [], + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "subreddit_id": "t5_2rc7j", + "body": "Nice, it\u2019s exactly what I was looking for!", + "link_title": "How I build web frontends in Go", + "author_flair_css_class": null, + "name": "t1_gq55r0o", + "author_patreon_flair": false, + "downs": 0, + "author_flair_richtext": [], + "is_submitter": false, + "body_html": "<div class=\"md\"><p>Nice, it\u2019s exactly what I was looking for!</p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "top_awarded_type": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/golang/comments/lzymk9/how_i_build_web_frontends_in_go/gq55r0o/", + "num_reports": null, + "link_permalink": "https://www.reddit.com/r/golang/comments/lzymk9/how_i_build_web_frontends_in_go/", + "report_reasons": null, + "link_author": "flippeeer", + "subreddit": "golang", + "author_flair_text": null, + "link_url": "https://philippta.github.io/web-frontends-in-go/", + "created": 1615182316.0, + "collapsed": false, + "subreddit_name_prefixed": "r/golang", + "controversiality": 0, + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "subreddit_type": "public", + "ups": 1 + } + }, { + "kind": "t1", + "data": { + "total_awards_received": 0, + "approved_at_utc": null, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "removal_reason": null, + "link_id": "t3_lzymk9", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "gq54i00", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "no_follow": true, + "author": "flippeeer", + "num_comments": 4, + "edited": false, + "can_mod_post": false, + "created_utc": 1615152991.0, + "send_replies": true, + "parent_id": "t1_gq4xeic", + "score": 1, + "author_fullname": "t2_ncvfd", + "over_18": false, + "treatment_tags": [], + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "subreddit_id": "t5_2rc7j", + "body": "Thanks", + "link_title": "How I build web frontends in Go", + "author_flair_css_class": null, + "name": "t1_gq54i00", + "author_patreon_flair": false, + "downs": 0, + "author_flair_richtext": [], + "is_submitter": true, + "body_html": "<div class=\"md\"><p>Thanks</p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "top_awarded_type": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/golang/comments/lzymk9/how_i_build_web_frontends_in_go/gq54i00/", + "num_reports": null, + "link_permalink": "https://www.reddit.com/r/golang/comments/lzymk9/how_i_build_web_frontends_in_go/", + "report_reasons": null, + "link_author": "flippeeer", + "subreddit": "golang", + "author_flair_text": null, + "link_url": "https://philippta.github.io/web-frontends-in-go/", + "created": 1615181791.0, + "collapsed": false, + "subreddit_name_prefixed": "r/golang", + "controversiality": 0, + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "subreddit_type": "public", + "ups": 1 + } + }, { + "kind": "t1", + "data": { + "total_awards_received": 0, + "approved_at_utc": null, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "removal_reason": null, + "link_id": "t3_lzufrx", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "gq52s5d", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "no_follow": true, + "author": "quad99", + "num_comments": 5, + "edited": false, + "can_mod_post": false, + "created_utc": 1615152265.0, + "send_replies": true, + "parent_id": "t3_lzufrx", + "score": 1, + "author_fullname": "t2_rglxe", + "over_18": false, + "treatment_tags": [], + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "subreddit_id": "t5_2rc7j", + "body": "Buffer is a type that implements the Reader and Writer interface over a slice of bytes. It lets you treat the byte slice as if it were an i/o device. Maybe you have a program that gets data from different sources, maybe a file or other times a \\[\\]byte. You can treat them the same way for simple reading and writing.\n\nhere is an example [https://pastebin.com/XBW1ShRe](https://pastebin.com/XBW1ShRe)", + "link_title": "What is Buffer?", + "author_flair_css_class": null, + "name": "t1_gq52s5d", + "author_patreon_flair": false, + "downs": 0, + "author_flair_richtext": [], + "is_submitter": false, + "body_html": "<div class=\"md\"><p>Buffer is a type that implements the Reader and Writer interface over a slice of bytes. It lets you treat the byte slice as if it were an i/o device. Maybe you have a program that gets data from different sources, maybe a file or other times a []byte. You can treat them the same way for simple reading and writing.</p>\n\n<p>here is an example <a href=\"https://pastebin.com/XBW1ShRe\">https://pastebin.com/XBW1ShRe</a></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "top_awarded_type": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/golang/comments/lzufrx/what_is_buffer/gq52s5d/", + "num_reports": null, + "link_permalink": "https://www.reddit.com/r/golang/comments/lzufrx/what_is_buffer/", + "report_reasons": null, + "link_author": "rad_akal", + "subreddit": "golang", + "author_flair_text": null, + "link_url": "https://www.reddit.com/r/golang/comments/lzufrx/what_is_buffer/", + "created": 1615181065.0, + "collapsed": false, + "subreddit_name_prefixed": "r/golang", + "controversiality": 0, + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "subreddit_type": "public", + "ups": 1 + } + }], + "after": "t1_gq52s5d", + "before": null + } +}