package core import ( "fmt" "math" "net/http" "sort" "strconv" "codeberg.org/vnpower/pixivfe/v2/i18n" "codeberg.org/vnpower/pixivfe/v2/server/session" "github.com/goccy/go-json" ) // pixivfe internal data type. not used by pixiv. type UserArtCategory string const ( UserArt_Any UserArtCategory = "" UserArt_AnyAlt UserArtCategory = "artworks" UserArt_Illustration UserArtCategory = "illustrations" UserArt_Manga UserArtCategory = "manga" UserArt_Bookmarks UserArtCategory = "bookmarks" // what this user has bookmarked; not art by this user UserArt_Novel UserArtCategory = "novels" ) func (s UserArtCategory) Validate() error { if s != UserArt_Any && s != UserArt_AnyAlt && s != UserArt_Illustration && s != UserArt_Manga && s != UserArt_Bookmarks && s != UserArt_Novel { return i18n.Errorf(`Invalid work category: %#v. Only "%s", "%s", "%s", "%s", "%s" and "%s" are available`, s, UserArt_Any, UserArt_AnyAlt, UserArt_Illustration, UserArt_Manga, UserArt_Bookmarks, UserArt_Novel) } else { return nil } } type FrequentTag struct { Name string `json:"tag"` TranslatedName string `json:"tag_translation"` } type CountInfo struct { All int Illustrations int Manga int Novels int Bookmarks int } type User struct { ID string `json:"userId"` Name string `json:"name"` Avatar string `json:"imageBig"` Following int `json:"following"` MyPixiv int `json:"mypixivCount"` Comment HTML `json:"commentHtml"` Webpage string `json:"webpage"` SocialRaw json.RawMessage `json:"social"` Artworks []ArtworkBrief `json:"artworks"` // this slice includes both illustrations and manga, but not novels Illustrations []ArtworkBrief Manga []ArtworkBrief Novels []NovelBrief `json:"novels"` Background map[string]any `json:"background"` CategoryItemCount int FrequentTags []FrequentTag Social map[string]map[string]string BackgroundImage string NovelSeries []NovelSeries MangaSeries []MangaSeries IsFollowed bool `json:"isFollowed"` // Denotes whether the logged in user currently following the given user // The following fields are internal to PixivFE, used to display the number of works for a given category CountInfo CountInfo } // IntStringMap is a custom type that can handle both JSON objects and empty arrays. // // Required as the Pixiv API returns empty arrays for work types that // don't exist for a user. type IntStringMap map[int]string // UnmarshalJSON implements custom unmarshalling for IntStringMap. func (m *IntStringMap) UnmarshalJSON(data []byte) error { // Check if the data is an empty array. if string(data) == "[]" { *m = make(map[int]string) return nil } // Otherwise, expect an object. var temp map[string]*string if err := json.Unmarshal(data, &temp); err != nil { return err } result := make(map[int]string) for keyStr, value := range temp { key, err := strconv.Atoi(keyStr) if err != nil { return fmt.Errorf("invalid key '%s': %v", keyStr, err) } if value != nil { result[key] = *value } else { result[key] = "" } } *m = result return nil } // GetUserArtworksIDAndSeries retrieves artwork IDs and series information for a user. func GetUserArtworksIDAndSeries(r *http.Request, id string, category UserArtCategory, page int) (idsString string, count int, illustCount int, mangaCount int, novelCount int, series json.RawMessage, err error) { URL := GetUserArtworksURL(id) resp, err := API_GET_UnwrapJson(r.Context(), URL, "", r.Header) if err != nil { return "", -1, -1, -1, -1, nil, err } resp = session.ProxyImageUrl(r, resp) var body struct { // *json.RawMessage allows us to easily check whether // a field exists by verifying if pointer != nil Illusts *json.RawMessage `json:"illusts"` Manga *json.RawMessage `json:"manga"` MangaSeries *json.RawMessage `json:"mangaSeries"` Novels *json.RawMessage `json:"novels"` NovelSeries *json.RawMessage `json:"novelSeries"` } err = json.Unmarshal([]byte(resp), &body) if err != nil { return "", -1, -1, -1, -1, nil, fmt.Errorf("failed to unmarshal response body: %w", err) } var ids []int var illusts IntStringMap var manga IntStringMap var novels IntStringMap if category == UserArt_Illustration || category == UserArt_Any || category == UserArt_AnyAlt { if body.Illusts != nil && len(*body.Illusts) > 0 { if err = json.Unmarshal(*body.Illusts, &illusts); err != nil { fmt.Printf("Error unmarshalling illusts: %v\n", err) illusts = make(IntStringMap) } for k := range illusts { ids = append(ids, k) count++ } illustCount = len(illusts) } } if category == UserArt_Manga || category == UserArt_Any { if body.Manga != nil && len(*body.Manga) > 0 { if err = json.Unmarshal(*body.Manga, &manga); err != nil { fmt.Printf("Error unmarshalling manga: %v\n", err) manga = make(IntStringMap) } for k := range manga { ids = append(ids, k) count++ } mangaCount = len(manga) if body.MangaSeries != nil { series = *body.MangaSeries } } } if category == UserArt_Novel { if body.Novels != nil && len(*body.Novels) > 0 { if err = json.Unmarshal(*body.Novels, &novels); err != nil { fmt.Printf("Error unmarshalling novels: %v\n", err) novels = make(IntStringMap) } for k := range novels { ids = append(ids, k) count++ } novelCount = len(novels) if body.NovelSeries != nil { series = *body.NovelSeries } } } sort.Sort(sort.Reverse(sort.IntSlice(ids))) worksPerPage := 30.0 start, end, err := computeSliceBounds(page, worksPerPage, len(ids)) if err != nil { return "", -1, -1, -1, -1, nil, err } // Build the IDs string for the API request for _, k := range ids[start:end] { idsString += fmt.Sprintf("&ids[]=%d", k) } return idsString, count, illustCount, mangaCount, novelCount, series, nil } func fetchAndProcessArtworks(r *http.Request, id, ids string, category UserArtCategory) ([]ArtworkBrief, []NovelBrief, error) { var artworks []ArtworkBrief var novels []NovelBrief var err error if category == UserArt_Novel { novels, err = GetUserNovels(r, id, ids) } else { artworks, err = GetUserArtworkList(r, id, ids) } if err != nil { return nil, nil, err } // Sort the works if category == UserArt_Novel { sort.Slice(novels[:], func(i, j int) bool { return numberGreaterThan(novels[i].ID, novels[j].ID) }) } else { sort.Slice(artworks[:], func(i, j int) bool { return numberGreaterThan(artworks[i].ID, artworks[j].ID) }) } return artworks, novels, nil } func handleSeriesData(series json.RawMessage, category UserArtCategory) ([]NovelSeries, []MangaSeries) { var novelSeries []NovelSeries var mangaSeries []MangaSeries if series != nil { if category == UserArt_Novel { _ = json.Unmarshal(series, &novelSeries) } else { _ = json.Unmarshal(series, &mangaSeries) } } return novelSeries, mangaSeries } func GetUserProfile(r *http.Request, id string, category UserArtCategory, page int, getTags bool) (User, error) { var user User token := session.GetUserToken(r) URL := GetUserInformationURL(id) resp, err := API_GET_UnwrapJson(r.Context(), URL, token, r.Header) if err != nil { return user, err } resp = session.ProxyImageUrl(r, resp) err = json.Unmarshal([]byte(resp), &user) if err != nil { return user, err } // Get counts for illustrations and manga _, allCount, illustCount, mangaCount, _, _, err := GetUserArtworksIDAndSeries(r, id, UserArt_Any, 1) if err != nil { return user, err } user.CountInfo.All = allCount user.CountInfo.Illustrations = illustCount user.CountInfo.Manga = mangaCount // Get count for novels separately _, _, _, _, novelCount, _, err := GetUserArtworksIDAndSeries(r, id, UserArt_Novel, 1) if err != nil { return user, err } user.CountInfo.Novels = novelCount // Get bookmarks count _, bookmarksCount, err := GetUserBookmarks(r, id, "show", 1) if err != nil { return user, err } user.CountInfo.Bookmarks = bookmarksCount if category == UserArt_Bookmarks { // Bookmarks works, _, err := GetUserBookmarks(r, id, "show", page) if err != nil { return user, err } user.Artworks = works user.CategoryItemCount = bookmarksCount } else { ids, count, _, _, _, series, err := GetUserArtworksIDAndSeries(r, id, category, page) if err != nil { return user, err } if count > 0 { artworks, novels, err := fetchAndProcessArtworks(r, id, ids, category) if err != nil { return user, err } if category == UserArt_Novel { user.Novels = novels } else { user.Artworks = artworks user.Illustrations = make([]ArtworkBrief, 0) user.Manga = make([]ArtworkBrief, 0) for _, artwork := range artworks { if artwork.IllustType == 0 { user.Illustrations = append(user.Illustrations, artwork) } else if artwork.IllustType == 1 { user.Manga = append(user.Manga, artwork) } } } if getTags { user.FrequentTags, err = GetUserFrequentTags(r, ids, category) if err != nil { return user, err } } } user.NovelSeries, user.MangaSeries = handleSeriesData(series, category) user.CategoryItemCount = count } err = user.ParseSocial() if err != nil { return User{}, err } if user.Background != nil { user.BackgroundImage = user.Background["url"].(string) } return user, nil } // Work is a generic type constraint. type Work interface{} // GetUserWorks is a generic helper function to fetch user works. func GetUserWorks[T Work](r *http.Request, url string) ([]T, error) { resp, err := API_GET_UnwrapJson(r.Context(), url, "", r.Header) if err != nil { return nil, err } resp = session.ProxyImageUrl(r, resp) // Define a generic body structure. var body struct { Works map[int]json.RawMessage `json:"works"` } // Unmarshal the response into the body. if err := json.Unmarshal([]byte(resp), &body); err != nil { return nil, err } // Initialize the slice to hold the works. var works []T // Iterate over each work and unmarshal into the specific type. for _, v := range body.Works { var work T if err := json.Unmarshal(v, &work); err != nil { return nil, err } works = append(works, work) } return works, nil } // GetUserFrequentTags retrieves frequent tags for a user based on category. func GetUserFrequentTags(r *http.Request, ids string, category UserArtCategory) ([]FrequentTag, error) { var tags []FrequentTag var URL string if category != "novels" { URL = GetUserFrequentArtworkTagsURL(ids) } else { URL = GetUserFrequentNovelTagsURL(ids) } response, err := API_GET_UnwrapJson(r.Context(), URL, "", r.Header) if err != nil { return nil, err } err = json.Unmarshal([]byte(response), &tags) if err != nil { return nil, err } return tags, nil } // GetUserArtworkList fetches the list of artworks for a user. func GetUserArtworkList(r *http.Request, id, ids string) ([]ArtworkBrief, error) { URL := GetUserFullArtworkURL(id, ids) works, err := GetUserWorks[ArtworkBrief](r, URL) if err != nil { return nil, err } return works, nil } // GetUserNovels fetches the list of novels for a user. func GetUserNovels(r *http.Request, id, ids string) ([]NovelBrief, error) { URL := GetUserFullNovelURL(id, ids) works, err := GetUserWorks[NovelBrief](r, URL) if err != nil { return nil, err } return works, nil } func GetUserBookmarks(r *http.Request, id, mode string, page int) ([]ArtworkBrief, int, error) { page-- URL := GetUserBookmarksURL(id, mode, page) resp, err := API_GET_UnwrapJson(r.Context(), URL, "", r.Header) if err != nil { return nil, -1, err } resp = session.ProxyImageUrl(r, resp) var body struct { Artworks []json.RawMessage `json:"works"` Total int `json:"total"` } err = json.Unmarshal([]byte(resp), &body) if err != nil { return nil, -1, err } artworks := make([]ArtworkBrief, len(body.Artworks)) for index, value := range body.Artworks { var artwork ArtworkBrief err = json.Unmarshal([]byte(value), &artwork) if err != nil { artworks[index] = ArtworkBrief{ ID: "#", Title: "Deleted or Private", Thumbnail: "https://s.pximg.net/common/images/limit_unknown_360.png", } continue } artworks[index] = artwork } return artworks, body.Total, nil } func (s *User) ParseSocial() error { if string(s.SocialRaw[:]) == "[]" { // Fuck Pixiv return nil } err := json.Unmarshal(s.SocialRaw, &s.Social) if err != nil { return err } return nil } // computeSliceBounds is a utility function to compute slice bounds safely func computeSliceBounds(page int, worksPerPage float64, totalItems int) (start, end int, err error) { if totalItems == 0 { return 0, 0, nil } maxPages := int(math.Ceil(float64(totalItems) / worksPerPage)) if page < 1 || page > maxPages { return 0, 0, i18n.Error("Invalid page number.") } start = (page - 1) * int(worksPerPage) end = min(start+int(worksPerPage), totalItems) return start, end, nil } func numberGreaterThan(l, r string) bool { if len(l) > len(r) { return true } if len(l) < len(r) { return false } return l > r }