// Package comicfury implements the Comic Fury webcomic hosting source. // Multi-language factory. Search-based popular (sort=popularity) and latest (sort=lastupdate). // Chapter list scraped from /read/{comicUrl}/archive; supports hierarchical chapters-in-chapters. // FlareSolverr used (matches Kotlin cloudflareClient). package comicfury import ( "context" "fmt" "net/http" "net/url" "regexp" "strconv" "strings" "time" "github.com/PuerkitoBio/goquery" "goyomi/internal/httpclient/flare" "goyomi/internal/registry" "goyomi/internal/source" ) const siteURL = "https://comicfury.com" type Source struct { name string lang string siteLang string // used in search query client *flare.Client id int64 } func newSource(name, lang, siteLang string) *Source { return &Source{ name: name, lang: lang, siteLang: siteLang, client: flare.NewClient(flare.WithRateLimit(1, 2)), id: source.GenerateSourceID(name, lang), } } func (s *Source) ID() int64 { return s.id } func (s *Source) Name() string { return s.name } func (s *Source) Lang() string { return s.lang } func (s *Source) SupportsLatest() bool { return true } func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } req.Header.Set("Referer", siteURL+"/") resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("comicfury: HTTP %d", resp.StatusCode) } return goquery.NewDocumentFromReader(resp.Body) } func (s *Source) searchURL(page int, query, sort string, filters []source.Filter) string { params := url.Values{ "query": {query}, "page": {strconv.Itoa(page)}, "language": {s.siteLang}, "sort": {sort}, } for _, f := range filters { switch sf := f.(type) { case *source.TextFilter: if sf.FilterName == "Tags" && sf.Text != "" { params.Set("tags", sf.Text) } case *source.SelectFilter: switch sf.FilterName { case "Sort By": params.Set("sort", strconv.Itoa(sf.Selected)) case "Last Updated": params.Set("lastupdate", strconv.Itoa(sf.Selected)) case "Violence": params.Set("fv", strconv.Itoa(sf.Selected)) case "Frontal Nudity": params.Set("fn", strconv.Itoa(sf.Selected)) case "Strong Language": params.Set("fl", strconv.Itoa(sf.Selected)) case "Sexual Content": params.Set("fs", strconv.Itoa(sf.Selected)) } case *source.CheckboxFilter: if sf.FilterName == "Comic Completed" { completed := 1 if sf.State { completed = 0 } params.Set("completed", strconv.Itoa(completed)) } } } return siteURL + "/search.php?" + params.Encode() } func (s *Source) parseSearch(doc *goquery.Document) source.MangasPage { var mangas []source.SManga doc.Find("div.webcomic-result").Each(func(_ int, el *goquery.Selection) { link := el.Find("div.webcomic-result-avatar a").First() href := link.AttrOr("href", "") title := el.Find("div.webcomic-result-title").First().AttrOr("title", "") thumb := el.Find("div.webcomic-result-avatar a img").First().AttrOr("src", "") if href == "" || title == "" { return } mangas = append(mangas, source.SManga{URL: href, Title: title, ThumbnailURL: thumb}) }) hasNext := doc.Find("div.search-next-page").Length() > 0 return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} } func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { doc, err := s.get(context.Background(), s.searchURL(page, "", "1", nil)) if err != nil { return source.MangasPage{}, err } return s.parseSearch(doc), nil } func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { doc, err := s.get(context.Background(), s.searchURL(page, "", "2", nil)) if err != nil { return source.MangasPage{}, err } return s.parseSearch(doc), nil } func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { doc, err := s.get(context.Background(), s.searchURL(page, query, "0", filters)) if err != nil { return source.MangasPage{}, err } return s.parseSearch(doc), nil } func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { rawURL := manga.URL if !strings.HasPrefix(rawURL, "http") { rawURL = siteURL + rawURL } doc, err := s.get(context.Background(), rawURL) if err != nil { return manga, err } result := source.SManga{URL: manga.URL} desDiv := doc.Find("div.description-tags") result.Description = strings.TrimSpace(desDiv.Parent().Clone().Find("*").Remove().End().Text()) var genres []string desDiv.Children().Each(func(_ int, el *goquery.Selection) { if t := strings.TrimSpace(el.Text()); t != "" { genres = append(genres, t) } }) result.Genre = strings.Join(genres, ", ") var authors []string doc.Find("a.authorname").Each(func(_ int, el *goquery.Selection) { if t := strings.TrimSpace(el.Text()); t != "" { authors = append(authors, t) } }) result.Author = strings.Join(authors, ", ") if result.Title == "" { result.Title = manga.Title } return result, nil } // comicURL extracts the comic slug from a manga URL like /comicprofile.php?url=slug. func comicURL(mangaURL string) string { parsed, err := url.Parse(mangaURL) if err != nil { return mangaURL } if u := parsed.Query().Get("url"); u != "" { return u } // Fallback: use last path segment. segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") if len(segments) > 0 { return segments[len(segments)-1] } return mangaURL } // parseDate handles formats like "4th March 2023 12:00 PM", "4 March 2023", "March 4 2023". var dateOrdinalRe = regexp.MustCompile(`(?i)(\d+)(st|nd|rd|th)`) func parseDate(s string) int64 { s = dateOrdinalRe.ReplaceAllString(s, "$1") s = strings.ReplaceAll(s, ",", "") s = strings.TrimSpace(s) formats := []string{ "2 January 2006 3:04 PM", "2 January 2006", "January 2 2006", } for _, f := range formats { if t, err := time.Parse(f, s); err == nil { return t.UnixMilli() } } return 0 } // collectChapters follows pagination from a given archive page. func (s *Source) collectChapters(startDoc *goquery.Document) ([]source.SChapter, error) { var chapters []source.SChapter doc := startDoc for { doc.Find("a:has(div.archive-comic)").Each(func(_ int, a *goquery.Selection) { href := a.AttrOr("href", "") if href == "" { return } parsed, _ := url.Parse(href) chURL := parsed.Path if parsed.RawQuery != "" { chURL += "?" + parsed.RawQuery } name := strings.TrimSpace(a.Find(".archive-comic-title").Text()) if name == "" { name = "Chapter" } date := parseDate(strings.TrimSpace(a.Find(".archive-comic-date").Text())) chapters = append(chapters, source.SChapter{URL: chURL, Name: name, DateUpload: date}) }) nextPage := doc.Find("span.vfpagecurrent + a.vfpage").First() nextHref := nextPage.AttrOr("href", "") if nextHref == "" { break } nextURL := nextHref if !strings.HasPrefix(nextURL, "http") { nextURL = siteURL + nextURL } next, err := s.get(context.Background(), nextURL) if err != nil { break } doc = next } return chapters, nil } func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { slug := comicURL(manga.URL) archiveURL := fmt.Sprintf("%s/read/%s/archive", siteURL, slug) doc, err := s.get(context.Background(), archiveURL) if err != nil { return nil, err } var chapters []source.SChapter archiveLinks := doc.Find("a:has(div.archive-chapter)") if archiveLinks.Length() > 0 { // Has parent chapters — fetch each sub-archive. var fetchErr error archiveLinks.Each(func(_ int, a *goquery.Selection) { if fetchErr != nil { return } href := a.AttrOr("href", "") if href == "" { return } if !strings.HasPrefix(href, "http") { href = siteURL + href } subDoc, err := s.get(context.Background(), href) if err != nil { fetchErr = err return } sub, err := s.collectChapters(subDoc) if err != nil { fetchErr = err return } chapters = append(chapters, sub...) }) if fetchErr != nil { return nil, fetchErr } } else { chapters, err = s.collectChapters(doc) if err != nil { return nil, err } } // Number and reverse (oldest first → chapter_number ascending). for i := range chapters { chapters[i].ChapterNumber = float32(i) } // Reverse so newest is first in list. for i, j := 0, len(chapters)-1; i < j; i, j = i+1, j-1 { chapters[i], chapters[j] = chapters[j], chapters[i] } return chapters, nil } func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { rawURL := chapter.URL if !strings.HasPrefix(rawURL, "http") { rawURL = siteURL + rawURL } doc, err := s.get(context.Background(), rawURL) if err != nil { return nil, err } var pages []source.Page doc.Find("div.is--comic-page div.is--image-segment div img").Each(func(i int, img *goquery.Selection) { if src := img.AttrOr("src", ""); src != "" { pages = append(pages, source.Page{Index: i, URL: rawURL, ImageURL: src}) } }) return pages, nil } func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } func (s *Source) GetFilterList() []source.Filter { return []source.Filter{ &source.TextFilter{FilterName: "Tags"}, &source.SelectFilter{FilterName: "Sort By", Values: []string{"Relevance", "Popularity", "Last Update"}}, &source.SelectFilter{FilterName: "Last Updated", Values: []string{"All Time", "This Week", "This Month", "This Year", "Completed Only"}}, &source.CheckboxFilter{FilterName: "Comic Completed"}, &source.SelectFilter{FilterName: "Violence", Values: []string{"None / Minimal", "Violent Content", "Gore / Graphic"}, Selected: 2}, &source.SelectFilter{FilterName: "Frontal Nudity", Values: []string{"None", "Occasional", "Frequent"}, Selected: 2}, &source.SelectFilter{FilterName: "Strong Language", Values: []string{"None", "Occasional", "Frequent"}, Selected: 2}, &source.SelectFilter{FilterName: "Sexual Content", Values: []string{"No Sexual Content", "Sexual Situations", "Strong Sexual Themes"}, Selected: 2}, } } func init() { instances := []struct{ name, lang, siteLang string }{ {"Comic Fury", "all", "all"}, {"Comic Fury", "en", "en"}, {"Comic Fury", "es", "es"}, {"Comic Fury", "pt-BR", "pt"}, {"Comic Fury", "de", "de"}, {"Comic Fury", "fr", "fr"}, {"Comic Fury", "it", "it"}, {"Comic Fury", "pl", "pl"}, {"Comic Fury", "ja", "ja"}, {"Comic Fury", "zh", "zh"}, {"Comic Fury", "ru", "ru"}, {"Comic Fury", "fi", "fi"}, {"Comic Fury", "other", "other"}, {"Comic Fury (No Text)", "other", "notext"}, } for _, inst := range instances { registry.Register(newSource(inst.name, inst.lang, inst.siteLang)) } }