Skip to content

Commit e762316

Browse files
committed
Stream image sizing, share client, honor context, cap concurrency
getImageSize buffered every image fully into memory via io.ReadAll, built a fresh http.Client per image, ignored the request context, and spawned one unbounded goroutine per <img> on the page. It now streams to io.Discard behind a 10MB io.LimitReader (reading the body rather than trusting Content-Length, so a lying header can't inflate an image's rank), treats non-2xx responses as size 0 so an error page can't be ranked as the lead image, shares a single lazily-built client, and propagates the caller's context. extractPics caps concurrency at 8 probes per page and bounds the whole probe phase with an overall budget (per-image timeout kept below it) so a page full of slow image URLs can't hold the handler past the write timeout; semaphore acquisition is context-aware. Lead-image selection breaks size ties by URL so it's deterministic rather than scheduling-dependent.
1 parent 55ddde3 commit e762316

3 files changed

Lines changed: 115 additions & 32 deletions

File tree

extractor/pics.go

Lines changed: 66 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package extractor
22

33
import (
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
}

extractor/pics_test.go

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func TestExtractPicsDirectly(t *testing.T) {
4949
d, err := goquery.NewDocumentFromReader(strings.NewReader(data))
5050
require.NoError(t, err)
5151
sel := d.Find("img")
52-
im, allImages, ok := lr.extractPics(sel, "url")
52+
im, allImages, ok := lr.extractPics(context.Background(), sel, "url")
5353
assert.True(t, ok)
5454
assert.Len(t, allImages, 1)
5555
assert.Equal(t, "https://cdn1.tnwcdn.com/wp-content/blogs.dir/1/files/2016/01/page-source.jpg", im)
@@ -60,7 +60,7 @@ func TestExtractPicsDirectly(t *testing.T) {
6060
d, err := goquery.NewDocumentFromReader(strings.NewReader(data))
6161
require.NoError(t, err)
6262
sel := d.Find("img")
63-
im, allImages, ok := lr.extractPics(sel, "url")
63+
im, allImages, ok := lr.extractPics(context.Background(), sel, "url")
6464
assert.False(t, ok)
6565
assert.Empty(t, allImages)
6666
assert.Empty(t, im)
@@ -71,12 +71,52 @@ func TestExtractPicsDirectly(t *testing.T) {
7171
d, err := goquery.NewDocumentFromReader(strings.NewReader(data))
7272
require.NoError(t, err)
7373
sel := d.Find("img")
74-
im, allImages, ok := lr.extractPics(sel, "url")
74+
im, allImages, ok := lr.extractPics(context.Background(), sel, "url")
7575
assert.True(t, ok)
7676
assert.Len(t, allImages, 1)
7777
assert.Equal(t, "http://bad_url", im)
7878
})
7979

80+
t.Run("cancelled context returns zero size", func(t *testing.T) {
81+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
82+
_, _ = w.Write([]byte("some-image-bytes"))
83+
}))
84+
defer ts.Close()
85+
ctx, cancel := context.WithCancel(context.Background())
86+
cancel()
87+
assert.Equal(t, 0, lr.getImageSize(ctx, ts.URL))
88+
})
89+
90+
t.Run("measures image size by streamed bytes", func(t *testing.T) {
91+
payload := strings.Repeat("x", 1234)
92+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
93+
_, _ = w.Write([]byte(payload))
94+
}))
95+
defer ts.Close()
96+
assert.Equal(t, len(payload), lr.getImageSize(context.Background(), ts.URL))
97+
})
98+
99+
t.Run("streams and counts bytes when length unknown", func(t *testing.T) {
100+
payload := strings.Repeat("x", 1234)
101+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
102+
fl, isFlusher := w.(http.Flusher)
103+
require.True(t, isFlusher)
104+
_, _ = w.Write([]byte(payload[:600]))
105+
fl.Flush() // forces chunked transfer, so Content-Length is unknown on the client
106+
_, _ = w.Write([]byte(payload[600:]))
107+
}))
108+
defer ts.Close()
109+
assert.Equal(t, len(payload), lr.getImageSize(context.Background(), ts.URL))
110+
})
111+
112+
t.Run("non-2xx response sizes as zero", func(t *testing.T) {
113+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
114+
http.Error(w, "not found", http.StatusNotFound) // error page body must not be ranked as an image
115+
}))
116+
defer ts.Close()
117+
assert.Equal(t, 0, lr.getImageSize(context.Background(), ts.URL))
118+
})
119+
80120
t.Run("bad body of the image", func(t *testing.T) {
81121
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82122
w.Header().Set("Content-Length", "1") // error on reading body
@@ -86,7 +126,7 @@ func TestExtractPicsDirectly(t *testing.T) {
86126
d, err := goquery.NewDocumentFromReader(strings.NewReader(data))
87127
require.NoError(t, err)
88128
sel := d.Find("img")
89-
im, allImages, ok := lr.extractPics(sel, "url")
129+
im, allImages, ok := lr.extractPics(context.Background(), sel, "url")
90130
assert.True(t, ok)
91131
assert.Len(t, allImages, 1)
92132
assert.Equal(t, ts.URL, im)

extractor/readability.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package extractor
44
import (
55
"context"
66
"fmt"
7+
"net/http"
78
"net/url"
89
"regexp"
910
"strings"
@@ -40,6 +41,9 @@ type UReadability struct {
4041

4142
defaultRetrieverOnce sync.Once
4243
defaultRetriever Retriever
44+
45+
imgClientOnce sync.Once
46+
imgClient *http.Client
4347
}
4448

4549
// retriever returns the configured default Retriever, creating a cached HTTPRetriever if nil
@@ -153,7 +157,7 @@ func (f *UReadability) extractWithRules(ctx context.Context, reqURL string, rule
153157
log.Printf("[WARN] failed to create document from reader, error=%v", err)
154158
return nil, err
155159
}
156-
if im, allImages, ok := f.extractPics(darticle.Find("img"), reqURL); ok {
160+
if im, allImages, ok := f.extractPics(ctx, darticle.Find("img"), reqURL); ok {
157161
rb.Image = im
158162
rb.AllImages = allImages
159163
}

0 commit comments

Comments
 (0)