11package extractor
22
33import (
4+ "context"
45 "io"
56 "net/http"
67 "sort"
@@ -11,21 +12,49 @@ import (
1112 log "github.com/go-pkgz/lgr"
1213)
1314
14- func (f * UReadability ) extractPics (iselect * goquery.Selection , url string ) (mainImage string , allImages []string , ok bool ) {
15- images := make (map [int ]string )
15+ const (
16+ imageFetchTimeout = 15 * time .Second // per-image probe timeout, kept below imageProbeBudget
17+ imageProbeBudget = 30 * time .Second // overall budget for probing every image on a page
18+ maxImageBytes = 10 << 20 // cap when streaming to measure, avoids buffering huge files
19+ maxConcurrentImageFetch = 8 // limit parallel image probes per page
20+ )
1621
17- type imgInfo struct {
18- url string
19- size int
20- }
21- var resCh = make (chan imgInfo )
22+ // imageClient returns a lazily-built HTTP client for image size probes, shared across all fetches
23+ // instead of building a fresh client per image.
24+ func (f * UReadability ) imageClient () * http.Client {
25+ f .imgClientOnce .Do (func () {
26+ f .imgClient = & http.Client {Timeout : imageFetchTimeout }
27+ })
28+ return f .imgClient
29+ }
30+
31+ type imgInfo struct {
32+ url string
33+ size int
34+ }
35+
36+ func (f * UReadability ) extractPics (ctx context.Context , iselect * goquery.Selection , url string ) (mainImage string , allImages []string , ok bool ) {
37+ // bound total image probing so a page full of slow image URLs can't hold the handler open for
38+ // ceil(n/maxConcurrentImageFetch) * imageFetchTimeout and blow past the server write timeout.
39+ ctx , cancel := context .WithTimeout (ctx , imageProbeBudget )
40+ defer cancel ()
41+
42+ resCh := make (chan imgInfo )
2243 var wg sync.WaitGroup
44+ sem := make (chan struct {}, maxConcurrentImageFetch )
2345
2446 iselect .Each (func (_ int , s * goquery.Selection ) {
25- if im , ok := s .Attr ("src" ); ok {
47+ if im , exists := s .Attr ("src" ); exists {
2648 wg .Go (func () {
27- size := f .getImageSize (im )
28- resCh <- imgInfo {url : im , size : size }
49+ // acquire a slot, but give up if the overall budget is already spent
50+ select {
51+ case sem <- struct {}{}:
52+ defer func () { <- sem }()
53+ case <- ctx .Done ():
54+ resCh <- imgInfo {url : im , size : 0 }
55+ return
56+ }
57+ resCh <- imgInfo {url : im , size : f .getImageSize (ctx , im )}
2958 })
3059 }
3160 })
@@ -35,37 +64,41 @@ func (f *UReadability) extractPics(iselect *goquery.Selection, url string) (main
3564 close (resCh )
3665 }()
3766
67+ var results []imgInfo
3868 for r := range resCh {
39- images [ r . size ] = r . url
69+ results = append ( results , r )
4070 allImages = append (allImages , r .url )
4171 }
4272 sort .Strings (allImages )
43- if len (images ) == 0 {
73+ if len (results ) == 0 {
4474 return "" , nil , false
4575 }
4676
47- // get the biggest picture
48- keys := make ([]int , 0 , len (images ))
49- for k := range images {
50- keys = append (keys , k )
77+ // pick the largest image; break ties by URL so lead-image selection is deterministic rather
78+ // than dependent on goroutine scheduling / map iteration order.
79+ best := results [0 ]
80+ for _ , r := range results [1 :] {
81+ if r .size > best .size || (r .size == best .size && r .url < best .url ) {
82+ best = r
83+ }
5184 }
52- sort .Sort (sort .Reverse (sort .IntSlice (keys )))
53- mainImage = images [keys [0 ]]
54- log .Printf ("[DEBUG] total images from %s = %d, main=%s (%d)" , url , len (images ), mainImage , keys [0 ])
55- return mainImage , allImages , true
85+ log .Printf ("[DEBUG] total images from %s = %d, main=%s (%d)" , url , len (results ), best .url , best .size )
86+ return best .url , allImages , true
5687}
5788
58- // getImageSize loads image to get size
59- func (f * UReadability ) getImageSize (url string ) (size int ) {
60- httpClient := & http.Client {Timeout : 30 * time .Second }
61- req , err := http .NewRequest ("GET" , url , http .NoBody )
89+ // getImageSize measures an image's byte size by streaming it to io.Discard behind a maxImageBytes
90+ // limit, without buffering it whole and honoring the caller's context. The body is actually read
91+ // (rather than trusting Content-Length) so a broken endpoint or a lying header can't inflate an
92+ // image's rank; an unreadable body sizes as 0, matching the previous behavior.
93+ func (f * UReadability ) getImageSize (ctx context.Context , url string ) (size int ) {
94+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , url , http .NoBody )
6295 if err != nil {
6396 log .Printf ("[WARN] can't create request to get pic from %s" , url )
6497 return 0
6598 }
6699 req .Close = true
67100 req .Header .Set ("User-Agent" , userAgent )
68- resp , err := httpClient .Do (req )
101+ resp , err := f . imageClient () .Do (req )
69102 if err != nil {
70103 log .Printf ("[WARN] can't get %s, error=%v" , url , err )
71104 return 0
@@ -76,10 +109,16 @@ func (f *UReadability) getImageSize(url string) (size int) {
76109 }
77110 }()
78111
79- data , err := io .ReadAll (resp .Body )
112+ // treat non-2xx as size 0 so an error page (404/500 HTML) can't be ranked as the lead image
113+ if resp .StatusCode < http .StatusOK || resp .StatusCode >= http .StatusMultipleChoices {
114+ log .Printf ("[DEBUG] non-2xx (%d) for image %s, sizing as 0" , resp .StatusCode , url )
115+ return 0
116+ }
117+
118+ n , err := io .Copy (io .Discard , io .LimitReader (resp .Body , maxImageBytes ))
80119 if err != nil {
81120 log .Printf ("[WARN] failed to get %s, err=%v" , url , err )
82121 return 0
83122 }
84- return len ( data )
123+ return int ( n )
85124}
0 commit comments