Files
PixivFE/core/user.go
T

787 lines
24 KiB
Go

package core
import (
"fmt"
"math"
"net/http"
"sort"
"strconv"
"strings"
"codeberg.org/vnpower/pixivfe/v2/audit"
"codeberg.org/vnpower/pixivfe/v2/i18n"
"codeberg.org/vnpower/pixivfe/v2/server/session"
"github.com/goccy/go-json"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
// UserWorkCategory represents a unified structure for handling different work categories.
type UserWorkCategory struct {
Value string // Category identifier (e.g., "illustrations", "manga")
PageLimit int // Maximum number of pages for pagination
FrequentTags []FrequentTag // Frequently used tags within the category
IllustWorks []ArtworkBrief // Populated if the work type requires ArtworkBrief
NovelWorks []NovelBrief // Populated if the work type requires NovelBrief
WorkIDs string // Concatenated string of work IDs
WorkCount int // Number of works for the category
MangaSeries []MangaSeries // Populated for the "manga" category
NovelSeries []NovelSeries // Populated for the "novels" category
}
var (
CategoryAny = &UserWorkCategory{Value: ""}
CategoryAnyAlt = &UserWorkCategory{Value: "artworks"}
CategoryIllustration = &UserWorkCategory{Value: "illustrations"}
CategoryManga = &UserWorkCategory{Value: "manga"}
CategoryBookmarks = &UserWorkCategory{Value: "bookmarks"}
CategoryNovels = &UserWorkCategory{Value: "novels"}
)
// NewUserWorkCategory creates a new UserWorkCategory with the given value
func NewUserWorkCategory(value string) UserWorkCategory {
return UserWorkCategory{
Value: value,
IllustWorks: nil,
WorkCount: 0,
}
}
func (s UserWorkCategory) Validate() error {
validValues := []string{
CategoryAny.Value,
CategoryAnyAlt.Value,
CategoryIllustration.Value,
CategoryManga.Value,
CategoryBookmarks.Value,
CategoryNovels.Value,
}
for _, val := range validValues {
if s.Value == val {
return nil
}
}
return i18n.Errorf(`Invalid work category: %#v. Only "%s", "%s", "%s", "%s", "%s" and "%s" are available`,
s.Value,
CategoryAny.Value,
CategoryAnyAlt.Value,
CategoryIllustration.Value,
CategoryManga.Value,
CategoryBookmarks.Value,
CategoryNovels.Value,
)
}
func (c *UserWorkCategory) SetPageLimit(limit int) {
c.PageLimit = limit
}
type FrequentTag struct {
Name string `json:"tag"`
TranslatedName string `json:"tag_translation"`
}
// User represents a user.
type User struct {
ID string `json:"userId"`
Name string `json:"name"`
Image string `json:"image"` // Unimplemented
Avatar string `json:"imageBig"` // Higher resolution avatar
Premium bool `json:"premium"` // Unimplemented
IsFollowed bool `json:"isFollowed"` // Whether the logged-in user is following this user
IsMyPixiv bool `json:"isMypixiv"` // Unimplemented
IsBlocking bool `json:"isBlocking"` // Unimplemented
Background map[string]any `json:"background"`
SketchLiveID string `json:"sketchLiveId"` // Unimplemented
Partial int `json:"partial"` // Unimplemented
SketchLives []any `json:"sketchLives"` // Unimplemented
Commission any `json:"commission"` // Unimplemented
Following int `json:"following"`
MyPixiv int `json:"mypixivCount"`
FollowedBack bool `json:"followedBack"` // Unimplemented
Comment string `json:"comment"` // Biography
CommentHTML HTML `json:"commentHtml"` // HTML-formatted biography
Webpage string `json:"webpage"`
SocialRaw json.RawMessage `json:"social"`
// The following fields are currently unimplemented in PixivFE
CanSendMessage bool `json:"canSendMessage"`
Region struct {
Name string
Region string
Prefecture string
PrivacyLevel string
} `json:"region"`
Age struct {
Name string
PrivacyLevel string
} `json:"age"`
BirthDay struct {
Name string
PrivacyLevel string
} `json:"birthDay"`
Gender struct {
Name string
PrivacyLevel string
} `json:"gender"`
Job struct {
Name string
PrivacyLevel string
} `json:"job"`
Workspace struct {
UserWorkspaceTool string
UserWorkspaceTablet string
} `json:"workspace"`
Official bool `json:"official"`
Group any `json:"group"`
// The following fields are internal to PixivFE
Social map[string]map[string]string
BackgroundImage string
Categories map[string]*UserWorkCategory // Holds Artworks, Illustrations, Manga, Novels, NovelSeries, and MangaSeries
Artworks []ArtworkBrief // Used on user discovery page only
Novels []NovelBrief // Used on user discovery page only
}
// GetUserProfile retrieves the user profile, including counts, artworks/bookmarks, and social data.
//
// Goroutines are used to avoid blocking on network requests.
func GetUserProfile(auditor *audit.Auditor, r *http.Request, id string, currentCategory *UserWorkCategory, page int, getTags bool, mode string) (User, error) {
auditor.Logger.Debug("GetUserProfile called",
zap.String("userID", id),
zap.String("currentCategory", currentCategory.Value),
zap.Int("page", page),
zap.Bool("getTags", getTags),
)
var userInfo User
var categories map[string]*UserWorkCategory
var g errgroup.Group
// Goroutine to fetch user information
g.Go(func() error {
// Fetch user information
userInfoURL := GetUserInformationURL(id)
auditor.Logger.Debug("Fetching user information", zap.String("URL", userInfoURL))
token := session.GetUserToken(r)
resp, err := API_GET_UnwrapJson(r.Context(), auditor, userInfoURL, token, r.Header)
if err != nil {
auditor.SugaredLogger.Error("Failed to fetch user information", zap.Error(err))
return err
}
resp, err = session.ProxyImageUrl(r, resp)
if err != nil {
auditor.SugaredLogger.Error("Failed to proxy image URL", zap.Error(err))
return err
}
if err := json.Unmarshal([]byte(resp), &userInfo); err != nil {
auditor.SugaredLogger.Error("Failed to unmarshal user information", zap.Error(err))
return err
}
// Parse social data
auditor.Logger.Debug("Parsing social data")
if err := userInfo.parseSocial(); err != nil {
auditor.SugaredLogger.Error("Failed to parse social data", zap.Error(err))
return err
}
auditor.Logger.Debug("Social data parsed", zap.Any("social", userInfo.Social))
// Set background image if available
if userInfo.Background != nil {
if url, ok := userInfo.Background["url"].(string); ok {
userInfo.BackgroundImage = url
auditor.Logger.Debug("Background image set", zap.String("BackgroundImage", url))
} else {
auditor.SugaredLogger.Warn("Background URL not found or invalid")
}
}
return nil
})
// Goroutine to fetch works and populate categories
g.Go(func() error {
var err error
auditor.Logger.Debug("Getting populated works")
categories, err = getPopulatedWorks(auditor, r, id, currentCategory, page, getTags, mode)
if err != nil {
auditor.SugaredLogger.Error("Failed to get populated works", zap.Error(err))
return err
}
return nil
})
// Wait for both goroutines to finish
if err := g.Wait(); err != nil {
return User{}, err
}
// Merge userInfo and categories into the final user struct
user := userInfo
user.Categories = categories
return user, nil
}
// getPopulatedWorks fetches then populates information for a user's works, as well as handling their
// frequently used tags and series data.
//
// Goroutines are used to avoid blocking on network requests.
func getPopulatedWorks(auditor *audit.Auditor, r *http.Request, id string, currentCategory *UserWorkCategory, page int, getTags bool, mode string) (map[string]*UserWorkCategory, error) {
auditor.Logger.Debug("getPopulatedWorks called",
zap.String("userID", id),
zap.String("currentCategory", currentCategory.Value),
zap.Int("page", page),
zap.Bool("getTags", getTags),
)
// Fetch work IDs and series data
auditor.Logger.Debug("Fetching work IDs and series data for works")
categoriesData, err := fetchWorkIDsAndSeriesData(auditor, r, id, currentCategory, page)
if err != nil {
auditor.SugaredLogger.Error("Failed to fetch work IDs and series data", zap.Error(err))
return nil, err
}
// Create an errgroup
var g errgroup.Group
// Fetch and populate works for each category in parallel
for _, cat := range categoriesData {
// Capture cat in local variable
cat := cat
// Skip categories with no work IDs (except bookmarks)
if cat.WorkIDs == "" && cat.Value != "bookmarks" {
continue
}
g.Go(func() error {
switch cat.Value {
case "illustrations", "manga":
auditor.Logger.Debug("Fetching and populating ArtworkBriefs", zap.String("category", cat.Value))
artworks, safeArtworks, err := populateArtworkIDs(auditor, r, id, cat.WorkIDs)
if err != nil {
auditor.SugaredLogger.Error("Failed to populate artwork IDs", zap.Error(err))
return err
}
if mode == "all" {
cat.IllustWorks = artworks
} else if mode == "safe" {
cat.IllustWorks = safeArtworks
}
case "novels":
auditor.Logger.Debug("Fetching and populating NovelBriefs", zap.String("category", cat.Value))
novels, safeNovels, err := populateNovelIDs(auditor, r, id, cat.WorkIDs)
if err != nil {
auditor.SugaredLogger.Error("Failed to populate novel IDs", zap.Error(err))
return err
}
if mode == "all" {
cat.NovelWorks = novels
} else if mode == "safe" {
cat.NovelWorks = safeNovels
}
case "bookmarks":
auditor.Logger.Debug("Fetching and populating Bookmarks", zap.String("category", cat.Value))
bookmarks, count, err := populateBookmarks(auditor, r, id, "show", page)
if err != nil {
auditor.SugaredLogger.Error("Failed to populate bookmarked work IDs", zap.Error(err))
return err
}
cat.IllustWorks = bookmarks
cat.WorkCount = count
worksPerPage := 48.0
cat.PageLimit = int(math.Ceil(float64(count) / worksPerPage))
}
return nil
})
g.Go(func() error {
// Fetch frequent tags if requested
if getTags && cat.Value != "bookmarks" {
auditor.Logger.Debug("Fetching frequent tags for category", zap.String("category", cat.Value))
tags, err := fetchFrequentTags(auditor, r, cat.WorkIDs, cat)
if err != nil {
auditor.SugaredLogger.Error("Failed to fetch frequent tags", zap.String("category", cat.Value), zap.Error(err))
return err
}
cat.FrequentTags = tags
}
return nil
})
}
// Wait for all goroutines to finish
if err := g.Wait(); err != nil {
return nil, err
}
// Set PageLimit for the current category
if currentCategory != nil {
if cat, ok := categoriesData[currentCategory.Value]; ok {
currentCategory.PageLimit = cat.PageLimit
}
}
return categoriesData, nil
}
// fetchWorkIDsAndSeriesData retrieves artwork IDs and series information for a user.
//
// It populates UserWorkCategory structs for each category.
func fetchWorkIDsAndSeriesData(auditor *audit.Auditor, r *http.Request, id string, currentCategory *UserWorkCategory, page int) (map[string]*UserWorkCategory, error) {
auditor.Logger.Debug("fetchWorkIDsAndSeriesData called",
zap.String("userID", id),
zap.String("currentCategory", currentCategory.Value),
zap.Int("page", page),
)
URL := GetUserWorksURL(id)
auditor.Logger.Debug("Fetching user works", zap.String("URL", URL))
resp, err := API_GET_UnwrapJson(r.Context(), auditor, URL, "", r.Header)
if err != nil {
auditor.SugaredLogger.Error("API_GET_UnwrapJson failed", zap.Error(err))
return nil, err
}
auditor.Logger.Debug("User works fetched", zap.String("response", resp))
resp, err = session.ProxyImageUrl(r, resp)
if err != nil {
auditor.SugaredLogger.Error("Failed to proxy image URL", zap.Error(err))
return nil, err
}
var body struct {
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 {
auditor.SugaredLogger.Error("Failed to unmarshal user works response", zap.Error(err))
return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
}
auditor.Logger.Debug("User works response unmarshalled")
categories := make(map[string]*UserWorkCategory)
// Process illustrations
illustCat := &UserWorkCategory{Value: "illustrations"}
illustIDs, count, err := extractIDs(body.Illusts, "illusts")
if err != nil {
auditor.SugaredLogger.Error("Failed to extract illustration IDs", zap.Error(err))
return nil, err
}
illustCat.WorkCount = count
illustCat.WorkIDs = buildIDString(auditor, illustIDs, page, currentCategory, illustCat)
categories["illustrations"] = illustCat
// Process manga
mangaCat := &UserWorkCategory{Value: "manga"}
mangaIDs, count, err := extractIDs(body.Manga, "manga")
if err != nil {
auditor.SugaredLogger.Error("Failed to extract manga IDs", zap.Error(err))
return nil, err
}
mangaCat.WorkCount = count
mangaCat.WorkIDs = buildIDString(auditor, mangaIDs, page, currentCategory, mangaCat)
// Handle MangaSeries
if body.MangaSeries != nil && len(*body.MangaSeries) > 0 {
err := json.Unmarshal(*body.MangaSeries, &mangaCat.MangaSeries)
if err != nil {
auditor.SugaredLogger.Error("Failed to unmarshal MangaSeries", zap.Error(err))
}
}
categories["manga"] = mangaCat
// Process novels
novelCat := &UserWorkCategory{Value: "novels"}
novelIDs, count, err := extractIDs(body.Novels, "novels")
if err != nil {
auditor.SugaredLogger.Error("Failed to extract novel IDs", zap.Error(err))
return nil, err
}
novelCat.WorkCount = count
novelCat.WorkIDs = buildIDString(auditor, novelIDs, page, currentCategory, novelCat)
// Handle NovelSeries
if body.NovelSeries != nil && len(*body.NovelSeries) > 0 {
err := json.Unmarshal(*body.NovelSeries, &novelCat.NovelSeries)
if err != nil {
auditor.SugaredLogger.Error("Failed to unmarshal NovelSeries", zap.Error(err))
}
}
categories["novels"] = novelCat
// Create an empty bookmarks category
//
// We don't build an ID string here as fetchBookmarks populates IllustWorks without the need for a WorkIDs string
// (which is also why we can't call fetchFrequentTags for the "bookmarks" category, though extracting the IDs from
// ArtworkBrief would be possible)
categories["bookmarks"] = &UserWorkCategory{Value: "bookmarks"}
return categories, nil
}
// buildIDString builds the ID string for API requests and sets the PageLimit.
func buildIDString(auditor *audit.Auditor, ids []int, page int, currentCategory, cat *UserWorkCategory) string {
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
worksPerPage := 30.0
totalItems := len(ids)
// We only use the actual page number for the current category being viewed
// and default the page to 1 for other categories.
//
// This is so that we don't attempt to paginate categories that don't have enough
// items, which would raise a spurious error from computeSliceBounds regarding an
// invalid page number.
//
// NOTE: A different approach will be needed if we require pageLimits for inactive categories.
effectivePage := page
if currentCategory.Value != cat.Value {
effectivePage = 1
}
start, end, pageLimit, err := computeSliceBounds(auditor, effectivePage, worksPerPage, totalItems)
if err != nil {
auditor.SugaredLogger.Error("Error computing slice bounds", zap.Error(err))
return ""
}
if currentCategory.Value == cat.Value {
cat.SetPageLimit(pageLimit)
}
idsToUse := ids[start:end]
var idsBuilder strings.Builder
for _, k := range idsToUse {
idsBuilder.WriteString(fmt.Sprintf("&ids[]=%d", k))
}
return idsBuilder.String()
}
// extractIDs is a helper function to extract IDs and count from a raw JSON message
func extractIDs(rawMessage *json.RawMessage, categoryName string) ([]int, int, error) {
var dataMap IntStringMap
if rawMessage != nil && len(*rawMessage) > 0 {
if err := json.Unmarshal(*rawMessage, &dataMap); err != nil {
fmt.Printf("Error unmarshalling %s: %v\n", categoryName, err)
dataMap = make(IntStringMap)
}
} else {
dataMap = make(IntStringMap)
}
ids := make([]int, 0, len(dataMap))
for k := range dataMap {
ids = append(ids, k)
}
return ids, len(dataMap), nil
}
// fetchFrequentTags fetches a user's frequently used tags, based on category.
func fetchFrequentTags(auditor *audit.Auditor, r *http.Request, ids string, workCategory *UserWorkCategory) ([]FrequentTag, error) {
var tags []FrequentTag
var URL string
switch workCategory.Value {
case "illustrations", "manga":
URL = GetUserFrequentArtworkTagsURL(ids)
case "novels":
URL = GetUserFrequentNovelTagsURL(ids)
default:
return tags, fmt.Errorf("unsupported category: %s", workCategory.Value)
}
// Return early if there are no IDs
//
// NOTE: theoretically, this check should never evaluate as true since fetchUserWorks doesn't call
// getUserFrequentTags when len(tagsIDs) == 0, instead returning an empty []FrequentTag
if ids == "" {
return tags, nil
}
response, err := API_GET_UnwrapJson(r.Context(), auditor, URL, "", r.Header)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(response), &tags)
if err != nil {
return nil, err
}
return tags, nil
}
// Work is a generic type constraint.
type Work interface{}
// populateWorkIDs is a generic helper function to populate a []Work for a given set of work IDs.
//
// Each work ID should be in the format `&ids[]=123456`.
func populateWorkIDs[T Work](auditor *audit.Auditor, r *http.Request, url string) ([]T, error) {
resp, err := API_GET_UnwrapJson(r.Context(), auditor, url, "", r.Header)
if err != nil {
return nil, err
}
resp, err = session.ProxyImageUrl(r, resp)
if err != nil {
return nil, err
}
// 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
}
// populateArtworkIDs populates a []ArtworkBrief and a []ArtworkBrief containing only safe works (XRestrict == 0) for a given set of artwork IDs.
func populateArtworkIDs(auditor *audit.Auditor, r *http.Request, id, ids string) ([]ArtworkBrief, []ArtworkBrief, error) {
works, err := populateWorkIDs[ArtworkBrief](auditor, r, GetUserFullArtworkURL(id, ids))
if err != nil {
return nil, nil, err
}
// Sort the works based on ID in descending order
sort.Slice(works, func(i, j int) bool {
return numberGreaterThan(works[i].ID, works[j].ID)
})
// Populate thumbnails for each artwork
for idx := range works {
artwork := &works[idx]
if err := artwork.PopulateThumbnails(auditor); err != nil {
auditor.SugaredLogger.Errorf("Failed to populate thumbnails for artwork ID %s: %v", artwork.ID, err)
return nil, nil, fmt.Errorf("failed to populate thumbnails for artwork ID %s: %w", artwork.ID, err)
}
}
// Filter works to create safeWorks where XRestrict == 0
safeWorks := make([]ArtworkBrief, 0, len(works))
for _, artwork := range works {
if artwork.XRestrict == 0 {
safeWorks = append(safeWorks, artwork)
}
}
return works, safeWorks, nil
}
// populateNovelIDs populates a []NovelBrief and a []NovelBrief containing only safe works (XRestrict == 0) for a given set of novel IDs.
func populateNovelIDs(auditor *audit.Auditor, r *http.Request, id, ids string) ([]NovelBrief, []NovelBrief, error) {
// Fetch the novels using the existing populateWorkIDs function
works, err := populateWorkIDs[NovelBrief](auditor, r, GetUserFullNovelURL(id, ids))
if err != nil {
return nil, nil, err
}
// Sort the works based on ID in descending order
sort.Slice(works, func(i, j int) bool {
return numberGreaterThan(works[i].ID, works[j].ID)
})
// Filter works to create safeWorks where XRestrict == 0
safeWorks := make([]NovelBrief, 0, len(works))
for _, novel := range works {
if novel.XRestrict == 0 {
safeWorks = append(safeWorks, novel)
}
}
return works, safeWorks, nil
}
// populateBookmarks populates a []ArtworkBrief for a given set of bookmarked work IDs.
//
// This function cannot be neatly refactored to use getWorkIDs due to having
// a different API response structure.
func populateBookmarks(auditor *audit.Auditor, r *http.Request, id, mode string, page int) ([]ArtworkBrief, int, error) {
page--
URL := GetUserBookmarksURL(id, mode, page)
resp, err := API_GET_UnwrapJson(r.Context(), auditor, URL, "", r.Header)
if err != nil {
return nil, -1, err
}
resp, err = session.ProxyImageUrl(r, resp)
if err != nil {
return nil, -1, err
}
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
}
// Populate thumbnails
if err := artwork.PopulateThumbnails(auditor); err != nil {
auditor.SugaredLogger.Errorf("Failed to populate thumbnails for artwork ID %s: %v", id, err)
return nil, -1, fmt.Errorf("failed to populate thumbnails for artwork ID %s: %w", id, err)
}
artworks[index] = artwork
}
return artworks, body.Total, nil
}
// computeSliceBounds is a utility function to compute slice bounds safely
func computeSliceBounds(auditor *audit.Auditor, page int, worksPerPage float64, totalItems int) (start, end, pageLimit int, err error) {
auditor.Logger.Debug("Entered computeSliceBounds",
zap.Int("page", page),
zap.Float64("worksPerPage", worksPerPage),
zap.Int("totalItems", totalItems),
)
if totalItems == 0 {
auditor.Logger.Debug("totalItems is 0, returning 0, 0, nil")
return 0, 0, 0, nil
}
pageLimit = int(math.Ceil(float64(totalItems) / worksPerPage))
auditor.Logger.Debug("Calculated pageLimit",
zap.Int("pageLimit", pageLimit),
)
if page < 1 || page > pageLimit {
auditor.Logger.Debug("Invalid page number",
zap.Int("page", page),
zap.Int("pageLimit", pageLimit),
)
return 0, 0, 0, fmt.Errorf("invalid page number")
}
start = (page - 1) * int(worksPerPage)
end = min(start+int(worksPerPage), totalItems)
auditor.Logger.Debug("Calculated slice bounds",
zap.Int("start", start),
zap.Int("end", end),
)
auditor.Logger.Debug("Set pageLimit",
zap.Int("pageLimit", pageLimit),
)
auditor.Logger.Debug("Exited computeSliceBounds successfully")
return start, end, pageLimit, nil
}
// parseSocial parses the social data for a user.
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
}
// IntStringMap is a custom type that can handle both JSON objects and empty arrays.
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
}
func numberGreaterThan(l, r string) bool {
if len(l) > len(r) {
return true
}
if len(l) < len(r) {
return false
}
return l > r
}
func min(a, b int) int {
if a < b {
return a
}
return b
}