208 lines
6.8 KiB
Go
208 lines
6.8 KiB
Go
package fa
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/PuerkitoBio/goquery"
|
||
|
||
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||
)
|
||
|
||
// Notifications is the parsed contents of /msg/others/ FA's catch-all
|
||
// notification page. Each category surfaces as a separate slice so callers
|
||
// don't have to type-switch over a heterogeneous list.
|
||
//
|
||
// Sections that aren't currently rendered (because you have none of that
|
||
// type pending) come back as nil/empty rather than as an error.
|
||
type Notifications struct {
|
||
Journals []JournalNotification
|
||
Watches []WatchNotification
|
||
SubmissionComments []CommentNotification
|
||
JournalComments []CommentNotification
|
||
Favorites []FavNotification
|
||
Shouts []ShoutNotification
|
||
}
|
||
|
||
// JournalNotification represents one entry in the "Journals" notification
|
||
// section a journal posted by someone you watch.
|
||
type JournalNotification struct {
|
||
JournalID JournalID
|
||
Title string
|
||
Author UserRef
|
||
Rating Rating
|
||
PostedAt time.Time
|
||
}
|
||
|
||
// WatchNotification is a user that started watching you.
|
||
type WatchNotification struct {
|
||
User UserRef
|
||
WatchedAt time.Time
|
||
}
|
||
|
||
// CommentNotification is a comment posted on one of your submissions or
|
||
// journals. OnSubmission is non-zero for submission comments;
|
||
// OnJournal is non-zero for journal comments. Only one is set per item;
|
||
// they're segregated into the two slices on [Notifications] but share this
|
||
// type for callers that want to merge.
|
||
type CommentNotification struct {
|
||
CommentID CommentID
|
||
OnSubmission SubmissionID
|
||
OnJournal JournalID
|
||
OnTitle string // title of the submission/journal that was commented on
|
||
Author UserRef
|
||
PostedAt time.Time
|
||
}
|
||
|
||
// FavNotification is someone favoriting one of your submissions.
|
||
type FavNotification struct {
|
||
SubmissionID SubmissionID
|
||
SubmissionTitle string
|
||
Favoriter UserRef
|
||
FavoritedAt time.Time
|
||
}
|
||
|
||
// ShoutNotification is a new shout left on your profile.
|
||
type ShoutNotification struct {
|
||
ShoutID int64 // FA's internal shout id (matches the anchor id="shout-N" on your profile)
|
||
Author UserRef
|
||
PostedAt time.Time
|
||
}
|
||
|
||
// NotificationsOption tunes a single [Client.Notifications] call.
|
||
type NotificationsOption func(*notificationsConfig)
|
||
|
||
type notificationsConfig struct {
|
||
resolveAvatars bool
|
||
avatarLimit int
|
||
}
|
||
|
||
// WithResolvedAvatars makes [Client.Notifications] fill in author avatar
|
||
// URLs that FurAffinity omits from the /msg/others/ markup.
|
||
//
|
||
// FA does not render an avatar image on every notification row journal
|
||
// notifications in particular are a purely textual list, so their author
|
||
// UserRef comes back with AvatarURL == "". When this option is set, the
|
||
// SDK resolves authors by fetching each one's profile page and reading the
|
||
// avatar from it.
|
||
//
|
||
// Each distinct author costs one extra HTTP request, deduplicated within
|
||
// the call and serialized through the client's rate limiter. Because that
|
||
// limiter is global, total wall time is roughly (distinct authors) ×
|
||
// (rate interval) on a busy account, dozens of seconds. limit caps how
|
||
// many distinct authors are resolved: pass a small value (e.g. 12) to
|
||
// bound a cold load. Authors are resolved in notification order with
|
||
// journals first, so a Home "recent journals" feed still gets real
|
||
// avatars; any author past the limit keeps AvatarURL == "" (callers
|
||
// typically render an initials fallback). A limit <= 0 means unlimited.
|
||
//
|
||
// Per-author failures are ignored: a user whose page can't be fetched
|
||
// simply keeps an empty AvatarURL.
|
||
func WithResolvedAvatars(limit int) NotificationsOption {
|
||
return func(c *notificationsConfig) {
|
||
c.resolveAvatars = true
|
||
c.avatarLimit = limit
|
||
}
|
||
}
|
||
|
||
// Notifications fetches /msg/others/ and returns the parsed notification
|
||
// page. Requires a logged-in client; anonymous calls surface as
|
||
// [ErrUnauthorized].
|
||
//
|
||
// All categories are returned in a single fetch there is no pagination
|
||
// on this page. Pass [WithResolvedAvatars] to additionally backfill author
|
||
// avatars that FA omits from the page (see that option's docs).
|
||
func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption) (*Notifications, error) {
|
||
var cfg notificationsConfig
|
||
for _, o := range opts {
|
||
o(&cfg)
|
||
}
|
||
|
||
var out *Notifications
|
||
err := c.fetch(ctx, urls.MsgOthers(), func(doc *goquery.Document) error {
|
||
n, err := parseNotifications(doc)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out = n
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if cfg.resolveAvatars {
|
||
c.resolveNotificationAvatars(ctx, out, cfg.avatarLimit)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// resolveNotificationAvatars backfills empty author AvatarURLs on a parsed
|
||
// [Notifications] by fetching each distinct author's profile page. Authors
|
||
// that already carry an avatar (FA does render one on, e.g., watch rows)
|
||
// are left untouched. Lookups are deduplicated by URL-safe name including
|
||
// negative results so each author costs at most one request.
|
||
//
|
||
// limit caps the number of distinct authors actually fetched; a limit <= 0
|
||
// is unlimited. Authors are visited journals-first, so when the budget is
|
||
// small the Home "recent journals" feed is the part that gets real avatars.
|
||
// An already-fetched author (cache hit) is still applied past the limit —
|
||
// that costs nothing only *new* fetches are gated.
|
||
//
|
||
// Failures are deliberately swallowed: a single unreachable profile must
|
||
// not fail the whole notifications call, and a stale ctx simply leaves the
|
||
// remaining avatars empty.
|
||
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int) {
|
||
if n == nil {
|
||
return
|
||
}
|
||
cache := make(map[string]string)
|
||
fill := func(ref *UserRef) {
|
||
if ref.Name == "" || ref.AvatarURL != "" {
|
||
return
|
||
}
|
||
avatar, ok := cache[ref.Name]
|
||
if !ok {
|
||
if limit > 0 && len(cache) >= limit {
|
||
return // fetch budget exhausted leave AvatarURL empty
|
||
}
|
||
avatar = c.fetchUserAvatar(ctx, ref.Name)
|
||
cache[ref.Name] = avatar
|
||
}
|
||
ref.AvatarURL = avatar
|
||
}
|
||
for i := range n.Journals {
|
||
fill(&n.Journals[i].Author)
|
||
}
|
||
for i := range n.Watches {
|
||
fill(&n.Watches[i].User)
|
||
}
|
||
for i := range n.SubmissionComments {
|
||
fill(&n.SubmissionComments[i].Author)
|
||
}
|
||
for i := range n.JournalComments {
|
||
fill(&n.JournalComments[i].Author)
|
||
}
|
||
for i := range n.Favorites {
|
||
fill(&n.Favorites[i].Favoriter)
|
||
}
|
||
for i := range n.Shouts {
|
||
fill(&n.Shouts[i].Author)
|
||
}
|
||
}
|
||
|
||
// fetchUserAvatar fetches /user/{name}/ and returns just the profile
|
||
// owner's avatar URL. It returns "" on any failure callers treat a
|
||
// missing avatar as non-fatal.
|
||
func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
|
||
var avatar string
|
||
_ = c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
|
||
avatar = urls.AbsoluteCDN(firstNonEmpty(
|
||
trimAttr(doc.Find("userpage-nav-avatar img").First(), "src"),
|
||
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
|
||
))
|
||
return nil
|
||
})
|
||
return avatar
|
||
}
|