inital commit

This commit is contained in:
2026-05-26 20:21:55 +02:00
parent 965f9d6ad4
commit 0ae20aa68d
21 changed files with 651 additions and 111 deletions

View File

@@ -69,55 +69,41 @@ type ShoutNotification struct {
PostedAt time.Time
}
// NotificationsOption tunes a single [Client.Notifications] call.
type NotificationsOption func(*notificationsConfig)
// NotificationsOptions tunes a single [Client.Notifications] call. Match
// the pattern used by [BrowseOptions] / [SearchOptions] / [ListOptions]: a
// plain struct of zero-default fields, kept distinct from the per-request
// [Option] values that swap creds.
type NotificationsOptions struct {
// ResolveAvatars 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 is
// set, the SDK resolves authors by fetching each one's profile page.
//
// Each distinct author costs one extra HTTP request, deduplicated
// within the call and serialized through the client's rate limiter.
ResolveAvatars bool
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
}
// AvatarLimit caps how many distinct authors are resolved when
// ResolveAvatars is true. Authors are visited journals-first; any
// author past the limit keeps AvatarURL == "". Zero or negative
// means unlimited. Has no effect when ResolveAvatars is false.
AvatarLimit int
}
// 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)
}
// All categories are returned in a single fetch there is no pagination
// on this page. Set [NotificationsOptions.ResolveAvatars] to additionally
// backfill author avatars that FA omits from the page.
//
// reqOpts are per-request overrides (typically [WithCookies] etc. for the
// multi-tenant case where many users share one client and one rate limiter).
func (c *Client) Notifications(ctx context.Context, opts NotificationsOptions, reqOpts ...Option) (*Notifications, error) {
var out *Notifications
err := c.fetch(ctx, urls.MsgOthers(), func(doc *goquery.Document) error {
n, err := parseNotifications(doc)
@@ -126,13 +112,13 @@ func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption)
}
out = n
return nil
})
}, reqOpts...)
if err != nil {
return nil, err
}
if cfg.resolveAvatars {
c.resolveNotificationAvatars(ctx, out, cfg.avatarLimit)
if opts.ResolveAvatars {
c.resolveNotificationAvatars(ctx, out, opts.AvatarLimit, reqOpts)
}
return out, nil
}
@@ -152,7 +138,7 @@ func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption)
// 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) {
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int, reqOpts []Option) {
if n == nil {
return
}
@@ -166,7 +152,7 @@ func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notification
if limit > 0 && len(cache) >= limit {
return // fetch budget exhausted leave AvatarURL empty
}
avatar = c.fetchUserAvatar(ctx, ref.Name)
avatar = c.fetchUserAvatar(ctx, ref.Name, reqOpts)
cache[ref.Name] = avatar
}
ref.AvatarURL = avatar
@@ -194,7 +180,7 @@ func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notification
// 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 {
func (c *Client) fetchUserAvatar(ctx context.Context, name string, reqOpts []Option) string {
var avatar string
_ = c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
avatar = urls.AbsoluteCDN(firstNonEmpty(
@@ -202,6 +188,6 @@ func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
))
return nil
})
}, reqOpts...)
return avatar
}