223 lines
7.6 KiB
Go
223 lines
7.6 KiB
Go
package fa
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
|
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
|
)
|
|
|
|
// parseNotifications walks /msg/others/ and fills in every category that
|
|
// the page renders. Sections FA omits (because you have nothing pending of
|
|
// that type) result in nil slices, not errors.
|
|
//
|
|
// Each section uses the same outer shape `section.section_container#messages-X`
|
|
// with a `ul.message-stream > li` body. The per-row content differs, so
|
|
// each category has its own per-row helper below.
|
|
func parseNotifications(doc *goquery.Document) (*Notifications, error) {
|
|
n := &Notifications{}
|
|
|
|
doc.Find("section.section_container#messages-journals ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
|
if j := parseJournalNotification(li); j != nil {
|
|
n.Journals = append(n.Journals, *j)
|
|
}
|
|
})
|
|
doc.Find("section.section_container#messages-watches ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
|
if w := parseWatchNotification(li); w != nil {
|
|
n.Watches = append(n.Watches, *w)
|
|
}
|
|
})
|
|
doc.Find("section.section_container#messages-comments-submission ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
|
if c := parseCommentNotification(li, false); c != nil {
|
|
n.SubmissionComments = append(n.SubmissionComments, *c)
|
|
}
|
|
})
|
|
doc.Find("section.section_container#messages-comments-journal ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
|
if c := parseCommentNotification(li, true); c != nil {
|
|
n.JournalComments = append(n.JournalComments, *c)
|
|
}
|
|
})
|
|
doc.Find("section.section_container#messages-favorites ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
|
if f := parseFavNotification(li); f != nil {
|
|
n.Favorites = append(n.Favorites, *f)
|
|
}
|
|
})
|
|
doc.Find("section.section_container#messages-shouts ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
|
if s := parseShoutNotification(li); s != nil {
|
|
n.Shouts = append(n.Shouts, *s)
|
|
}
|
|
})
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// parseJournalNotification lifts one row from the Journals section. Layout:
|
|
//
|
|
// <li>
|
|
// <div class="table">
|
|
// <div class="cell"><input ... value="{journalID}"></div>
|
|
// <div class="cell">
|
|
// <a href="/journal/{id}/"><em class="journal_subject">Title</em></a>
|
|
// (<span class="c-contentRating--general">G</span>)
|
|
// posted by <span class="c-usernameBlockSimple"><a href="/user/.../"><span class="c-usernameBlockSimple__displayName" title=" name ">Display</span></a></span>
|
|
// <span class="popup_date" data-time="...">timestamp</span>
|
|
// </div>
|
|
// </div>
|
|
// </li>
|
|
func parseJournalNotification(li *goquery.Selection) *JournalNotification {
|
|
link := li.Find("a[href^='/journal/']").First()
|
|
if link.Length() == 0 {
|
|
return nil
|
|
}
|
|
href, _ := link.Attr("href")
|
|
id := JournalID(extractIntFromHref(href))
|
|
if id == 0 {
|
|
return nil
|
|
}
|
|
|
|
j := &JournalNotification{
|
|
JournalID: id,
|
|
Title: trimText(link.Find("em.journal_subject").First()),
|
|
}
|
|
if j.Title == "" {
|
|
j.Title = trimText(link)
|
|
}
|
|
|
|
// Rating: <span class="c-contentRating--general">G</span>
|
|
if r := li.Find("span[class*='c-contentRating--']").First(); r.Length() > 0 {
|
|
j.Rating = ratingFromClass(trimAttr(r, "class"))
|
|
}
|
|
|
|
j.Author = userRefFromUsernameBlock(li)
|
|
j.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
|
|
return j
|
|
}
|
|
|
|
// parseWatchNotification: a user that started watching you. Typical layout
|
|
// is a <li> with the user's avatar link, display-name block, and date.
|
|
func parseWatchNotification(li *goquery.Selection) *WatchNotification {
|
|
w := &WatchNotification{User: userRefFromUsernameBlock(li)}
|
|
if w.User.Name == "" {
|
|
return nil
|
|
}
|
|
// Avatar often lives in the same <li> as a separate <a><img/></a>.
|
|
if av := li.Find("img").First(); av.Length() > 0 {
|
|
w.User.AvatarURL = urls.AbsoluteCDN(trimAttr(av, "src"))
|
|
}
|
|
w.WatchedAt = parsePopupDate(li.Find("span.popup_date").First())
|
|
return w
|
|
}
|
|
|
|
// parseCommentNotification: a comment on one of your submissions or journals.
|
|
// isJournal selects which target field to populate. Comment ID is on the
|
|
// link's #cid:N fragment or in a data attribute on the row.
|
|
func parseCommentNotification(li *goquery.Selection, isJournal bool) *CommentNotification {
|
|
// The link goes to /view/{id}/#cid:N or /journal/{id}/#cid:N
|
|
link := li.Find("a[href*='/view/'], a[href*='/journal/']").First()
|
|
if link.Length() == 0 {
|
|
return nil
|
|
}
|
|
href, _ := link.Attr("href")
|
|
c := &CommentNotification{
|
|
OnTitle: trimText(link),
|
|
Author: userRefFromUsernameBlock(li),
|
|
}
|
|
// Pull the comment ID out of the URL fragment, e.g. "#cid:12345" or "#comment-12345".
|
|
if i := strings.Index(href, "#"); i != -1 {
|
|
frag := href[i+1:]
|
|
frag = strings.TrimPrefix(frag, "cid:")
|
|
frag = strings.TrimPrefix(frag, "comment-")
|
|
if n, err := parseID[CommentID](strings.TrimSpace(frag)); err == nil {
|
|
c.CommentID = n
|
|
}
|
|
href = href[:i] // strip fragment for ID parsing below
|
|
}
|
|
id := extractIntFromHref(href)
|
|
if isJournal {
|
|
c.OnJournal = JournalID(id)
|
|
} else {
|
|
c.OnSubmission = SubmissionID(id)
|
|
}
|
|
c.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
|
|
return c
|
|
}
|
|
|
|
// parseFavNotification: someone favorited one of your submissions.
|
|
func parseFavNotification(li *goquery.Selection) *FavNotification {
|
|
link := li.Find("a[href^='/view/']").First()
|
|
if link.Length() == 0 {
|
|
return nil
|
|
}
|
|
href, _ := link.Attr("href")
|
|
f := &FavNotification{
|
|
SubmissionID: SubmissionID(extractIntFromHref(href)),
|
|
SubmissionTitle: trimText(link),
|
|
Favoriter: userRefFromUsernameBlock(li),
|
|
}
|
|
f.FavoritedAt = parsePopupDate(li.Find("span.popup_date").First())
|
|
return f
|
|
}
|
|
|
|
// parseShoutNotification: someone left a shout on your profile.
|
|
func parseShoutNotification(li *goquery.Selection) *ShoutNotification {
|
|
s := &ShoutNotification{Author: userRefFromUsernameBlock(li)}
|
|
if s.Author.Name == "" {
|
|
return nil
|
|
}
|
|
// Shout id is sometimes carried on a checkbox value or anchor; not load-
|
|
// bearing if absent.
|
|
if v := trimAttr(li.Find("input[type=checkbox]").First(), "value"); v != "" {
|
|
if n, err := parseID[CommentID](v); err == nil {
|
|
s.ShoutID = int64(n)
|
|
}
|
|
}
|
|
s.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
|
|
return s
|
|
}
|
|
|
|
// userRefFromUsernameBlock extracts a UserRef from any descendant
|
|
// c-usernameBlock / c-usernameBlockSimple structure. Returns the zero
|
|
// UserRef when no such block is found, so callers can keep parsing the row.
|
|
func userRefFromUsernameBlock(sel *goquery.Selection) UserRef {
|
|
display := sel.Find(".c-usernameBlockSimple__displayName, .c-usernameBlock__displayName .js-displayName").First()
|
|
if display.Length() == 0 {
|
|
display = sel.Find(".c-usernameBlock__displayName").First()
|
|
}
|
|
if display.Length() == 0 {
|
|
return UserRef{}
|
|
}
|
|
u := UserRef{DisplayName: trimText(display)}
|
|
// URL-safe name lives in the surrounding link's href, or in title attr.
|
|
if t := strings.TrimSpace(trimAttr(display, "title")); t != "" {
|
|
u.Name = strings.ToLower(t)
|
|
}
|
|
if u.Name == "" {
|
|
link := display.ParentsFiltered("a[href^='/user/']").First()
|
|
if link.Length() == 0 {
|
|
link = sel.Find("a[href^='/user/']").First()
|
|
}
|
|
href, _ := link.Attr("href")
|
|
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
|
|
u.Name = strings.ToLower(parts[1])
|
|
}
|
|
}
|
|
return u
|
|
}
|
|
|
|
// ratingFromClass maps a `c-contentRating--general/mature/adult` class
|
|
// token to one of the Rating constants. Returns "" when unrecognised so
|
|
// callers can leave the field empty rather than guess.
|
|
func ratingFromClass(class string) Rating {
|
|
low := strings.ToLower(class)
|
|
switch {
|
|
case strings.Contains(low, "general"):
|
|
return RatingGeneral
|
|
case strings.Contains(low, "mature"):
|
|
return RatingMature
|
|
case strings.Contains(low, "adult"):
|
|
return RatingAdult
|
|
}
|
|
return ""
|
|
}
|