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 &#8217; 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}