package fa import ( "fmt" "strings" "github.com/PuerkitoBio/goquery" "git.anthrove.art/public/go-fa-api/internal/urls" ) // parseNotesInboxPage parses one page of the /msg/pms/ inbox listing, // returning the previews and the absolute URL of the next page (or "" if // there's no further page). // // Beta theme renders each thread as a
with: // - subject link whose href encodes the note id // (/msg/pms/{folder}/{noteID}/#message) // - sender block
with a c-usernameBlock // - send date
with a popup_date span // - read-state class "note-read" / "note-unread" on the subject link func parseNotesInboxPage(doc *goquery.Document) (items []*NotePreview, nextURL string) { doc.Find("div#notes-list div.c-noteListItem").Each(func(_ int, item *goquery.Selection) { np := parseNoteListItem(item) if np != nil { items = append(items, np) } }) // Pagination control on the message center. if next := doc.Find("div.messagecenter-navigation a.button.more").First(); next.Length() > 0 { href, _ := next.Attr("href") nextURL = urls.AbsoluteCDN(href) } return items, nextURL } // parseNoteListItem lifts one
row. func parseNoteListItem(item *goquery.Selection) *NotePreview { subjectLink := item.Find("a.notelink").First() if subjectLink.Length() == 0 { return nil } href, _ := subjectLink.Attr("href") np := &NotePreview{ Subject: trimText(subjectLink.Find(".c-noteListItem__subject").First()), ThreadURL: urls.AbsoluteCDN(href), } if np.Subject == "" { np.Subject = trimText(subjectLink) } // Note ID lives in the href: /msg/pms/{folder}/{id}/#message. Strip the // fragment first, then take the *last* numeric segment — the folder // number (e.g. 1) appears before the note ID and would otherwise win // the "first numeric segment" race in extractIntFromHref. if i := strings.Index(href, "#"); i != -1 { href = href[:i] } for _, seg := range strings.Split(href, "/") { if n, err := parseID[NoteID](seg); err == nil && n != 0 { np.ID = n } } // Read/unread: classes on the subject link. if class, _ := subjectLink.Attr("class"); strings.Contains(class, "note-unread") || strings.Contains(class, "unread") && !strings.Contains(class, "note-read") { np.Unread = true } senderBox := item.Find("div.note-list-sender") np.Sender = userRefFromUsernameBlock(senderBox) // FA marks notes from removed accounts with . // In that case there is no usernameBlock and Name stays empty by design; // surface the visible "[deleted]" string in DisplayName so callers can // distinguish "no sender info" from "sender's account is gone". if np.Sender.Name == "" && np.Sender.DisplayName == "" { if deleted := senderBox.Find("span.user-name-deleted").First(); deleted.Length() > 0 { np.Sender = UserRef{DisplayName: trimText(deleted)} } } np.SentAt = parsePopupDate(item.Find("div.note-list-senddate span.popup_date").First()) return np } // parseNote lifts a single private-message thread from /viewmessage/{id}/. // // FA renders the note inside a
whose section-header contains the // avatar, subject (

), sender block, sent date, and recipient block. // The body lives in the following
wrapped in a // .user-submitted-links div. func parseNote(id NoteID, doc *goquery.Document) (*Note, error) { n := &Note{ID: id} header := doc.Find("div.message-center-note-information.addresses").First() if header.Length() == 0 { // Older / alternative layout: try the parent block. header = doc.Find("div.message-center-note-information").First() } n.Subject = trimText(header.Find("h2").First()) if n.Subject == "" { return nil, fmt.Errorf("%w: note %d: missing subject", ErrParse, id) } // Sender + recipient: the first and second c-usernameBlock inside the // header, in document order (FA writes "Sent by … To …" sequentially). blocks := header.Find("div.c-usernameBlock") if blocks.Length() >= 1 { n.From = userRefFromUsernameBlock(blocks.Eq(0)) } if blocks.Length() >= 2 { n.To = userRefFromUsernameBlock(blocks.Eq(1)) } // Avatar lives in a sibling div within the surrounding container. avatarSrc := trimAttr(doc.Find("div.message-center-note-information.avatar img.avatar").First(), "src") if avatarSrc == "" { avatarSrc = trimAttr(doc.Find("div.message-center-note-information img").First(), "src") } if avatarSrc != "" && n.From.AvatarURL == "" { n.From.AvatarURL = urls.AbsoluteCDN(avatarSrc) } n.SentAt = parsePopupDate(header.Find("span.popup_date").First()) // Body. FA wraps it in section .section-body > .user-submitted-links and // occasionally prepends a scam-warning div which we strip from the // plaintext convenience field but leave intact in the raw HTML. body := doc.Find("section div.section-body div.user-submitted-links").First() if body.Length() == 0 { body = doc.Find("section div.section-body").First() } n.BodyHTML = htmlOf(body) bodyTextSel := body.Clone() bodyTextSel.Find(".noteWarningMessage").Remove() n.BodyText = strings.TrimSpace(bodyTextSel.Text()) return n, nil }