Files
go-fa-api/notifications_parser.go
2026-05-25 22:27:18 +02:00

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 ""
}