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: // //
  • //
    //
    //
    // Title // (G) // posted by Display // timestamp //
    //
    //
  • 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: G 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
  • 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
  • as a separate . 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 "" }