krz/devianter
A DeviantArt guest API library for Go.
clone: git clone https://gitbay.org/krz/devianter.git
v0.3.3: comments.go · raw
1package devianter
2
3import (
4 "encoding/json"
5 "html"
6 "net/url"
7 "strconv"
8 "strings"
9)
10
11// Thread is a single comment, despite the name. Replies are not nested inside
12// it: a thread arrives flattened into [Comments].Thread, and the shape is
13// recovered through Parent, which holds the ID of the comment being replied to
14// and is 0 for a top-level comment.
15type Thread struct {
16 Replies, Likes int
17 ID int `json:"commentId"`
18 Parent int `json:"parentId"`
19
20 Posted timeStamp
21 // Author reports whether the commenter is the author of the deviation being
22 // commented on.
23 Author bool `json:"isAuthorHighlited"`
24
25 Desctiption string
26
27 // Comment is the comment's plain text, which [GetComments] extracts from
28 // TextContent. Prefer it; TextContent is the unprocessed original.
29 //
30 // Text is all it holds: a comment is a rich document, and its images,
31 // emotes, mentions, and link targets are dropped in the flattening. Read
32 // TextContent for those.
33 Comment string
34
35 TextContent Text
36
37 User struct {
38 Username string
39 Banned bool `json:"isBanned"`
40 }
41}
42
43// Comments is one page of comments. Thread holds the comments themselves,
44// flattened rather than nested; Total counts every comment on the item, not just
45// this page. Cursor resumes from the end of this page and HasMore reports
46// whether anything remains.
47type Comments struct {
48 Cursor string
49 PrevOffset int
50 HasMore, HasLess bool
51
52 Total int
53 Thread []Thread
54}
55
56// GetComments retrieves comments on an item, 50 per page, with each comment's
57// plain text extracted into [Thread].Comment.
58//
59// typ selects what postid refers to: 1 for comments on a deviation, 4 for those
60// on a user's or group's profile wall. cursor resumes from a previous call's
61// [Comments].Cursor; pass an empty string to start from the newest comment.
62//
63// page is an offset from cursor rather than an absolute page number, and it is
64// walked one request at a time: page 5 costs six round-trips and returns only
65// the sixth page. Paginating by feeding each result's Cursor back in with page 0
66// costs one request per page, and is the cheaper way to walk a long thread.
67func GetComments(postid string, cursor string, page int, typ int) (cmmts Comments, err Error) {
68 for x := 0; x <= page; x++ {
69 err = ujson(
70 "dashared/comments/thread?typeid="+strconv.Itoa(typ)+
71 "&itemid="+postid+"&maxdepth=1000&order=newest"+
72 "&limit=50&cursor="+url.QueryEscape(cursor),
73 &cmmts,
74 )
75
76 cursor = cmmts.Cursor
77
78 for i := 0; i < len(cmmts.Thread); i++ {
79 cmmts.Thread[i].Comment = flattenMarkup(cmmts.Thread[i].TextContent.Html.Markup)
80 }
81 }
82
83 return
84}
85
86// flattenMarkup renders a body of user-written markup as plain text, be it a
87// comment or a deviation's description. Bodies are JSON inside JSON, and
88// DeviantArt still serves all three formats it has used over the years:
89//
90// - tiptap, current: {"version":1,"document":{"type":"doc","content":[...]}}
91// - Draft.js, legacy: {"blocks":[{"text":"..."}]}
92// - plain HTML, oldest, which passes through unchanged
93//
94// Block-level elements (paragraphs, headings) are joined with newlines, one per
95// line, and a hard break inside one becomes a newline too. HTML entities in the
96// text are decoded, so a body reads as ’ on the wire but an apostrophe
97// here. Markup matching no known format passes through unchanged rather than
98// being replaced by an empty string.
99func flattenMarkup(m string) string {
100 l := len(m)
101 if l == 0 || m[0] != '{' || m[l-1] != '}' {
102 return m
103 }
104
105 if text, ok := flattenTiptap(m); ok {
106 return text
107 }
108 if text, ok := flattenDraftJS(m); ok {
109 return text
110 }
111 return m
112}
113
114// tiptapNode is one node of a tiptap (ProseMirror) document tree.
115//
116// The document's "version" field is deliberately not modelled: DeviantArt sends
117// it as a number on some bodies and a string on others, so any typed field for
118// it fails to unmarshal on half of them.
119type tiptapNode struct {
120 Type string `json:"type"`
121 Text string `json:"text"`
122 Content []tiptapNode `json:"content"`
123}
124
125// flattenTiptap renders a tiptap document, reporting false if the markup is not
126// one.
127func flattenTiptap(m string) (string, bool) {
128 var doc struct {
129 Document tiptapNode `json:"document"`
130 }
131 if json.Unmarshal([]byte(m), &doc) != nil || doc.Document.Type != "doc" {
132 return "", false
133 }
134
135 lines := make([]string, 0, len(doc.Document.Content))
136 for _, block := range doc.Document.Content {
137 var b strings.Builder
138 writeTiptapText(block, &b)
139 lines = append(lines, b.String())
140 }
141
142 return html.UnescapeString(strings.Join(lines, "\n")), true
143}
144
145// writeTiptapText collects the text of a node and everything nested inside it.
146// Nodes carrying no text of their own — images, galleries, emotes — contribute
147// nothing.
148func writeTiptapText(n tiptapNode, b *strings.Builder) {
149 switch n.Type {
150 case "text":
151 b.WriteString(n.Text)
152 return
153 case "hardBreak":
154 b.WriteString("\n")
155 return
156 }
157 for _, c := range n.Content {
158 writeTiptapText(c, b)
159 }
160}
161
162// flattenDraftJS renders a legacy Draft.js document, reporting false if the
163// markup is not one.
164func flattenDraftJS(m string) (string, bool) {
165 var content struct {
166 Blocks []struct {
167 Text string
168 }
169 }
170 if json.Unmarshal([]byte(m), &content) != nil || len(content.Blocks) == 0 {
171 return "", false
172 }
173
174 lines := make([]string, 0, len(content.Blocks))
175 for _, blk := range content.Blocks {
176 lines = append(lines, blk.Text)
177 }
178
179 return html.UnescapeString(strings.Join(lines, "\n")), true
180}