Files
job-search/internal/eusupply/eusupply.go
T
bwilliams 3a06132071 Added an EU Supply source, scraping the UK portal's public tenders
internal/eusupply replaces the old Node scraper (eu-supply.js) for
uk.eu-supply.com. The site has no API, so it posts the public search
form, which now needs the form's current defaults and reads the HTML
with golang.org/x/net/html. Each tender not stored yet has its own
page read too, for its description, contract type, CPV codes and
buyer address. Tenders are stored with the source "EU Supply - UK"
as eusupply-uk-<tender id>, with one release holding the tender as
read.

The site publishes no rate limit, so the client sets its own: one
request at a time, with a random wait between
sources.eu_supply.request_min_gap and request_max_gap (2s to 10s).
The source has the same 7-day backfill as Find a Tender's.

Most of its tenders are also on Find a Tender, so a new one is stored
Hidden and Skipped when another source's opportunity released within
30 days has a title at least 90% alike, and its buyer or description
matches too (80% of the shorter one's significant words).
2026-10-21 13:57:54 +01:00

209 lines
5.5 KiB
Go

// Package eusupply scrapes the public tenders listed on EU Supply's UK
// portal (uk.eu-supply.com), and converts them to opportunities.
//
// The site has no API: the client posts the portal's search form and reads
// the HTML it returns, then reads each tender's own page for its
// description and CPV codes. The client limits its own request rate (see
// WithRequestGap), as the site publishes no limit to wait out.
package eusupply
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"time"
_ "time/tzdata" // the portal's times are UK time, wherever this runs
)
// The defaults of the client's options.
const (
// DefaultBaseURL is the UK portal's root.
DefaultBaseURL = "https://uk.eu-supply.com"
// DefaultMinRequestGap and DefaultMaxRequestGap bound the random wait
// between two requests (see WithRequestGap).
DefaultMinRequestGap = 2 * time.Second
DefaultMaxRequestGap = 10 * time.Second
// requestTimeout is how long one request may take.
requestTimeout = 60 * time.Second
)
// Source is the Source of the opportunities Convert makes.
const Source = "EU Supply - UK"
// ErrUnexpectedStatus wraps a response status other than 200 OK.
var ErrUnexpectedStatus = errors.New("eusupply: unexpected response status")
// ukTime is the zone of the portal's dates and times.
var ukTime = mustLoadLocation("Europe/London")
// Client scrapes one EU Supply portal. Create it with New; it is safe for
// concurrent use, though its requests are made one at a time.
type Client struct {
baseURL string
httpClient *http.Client
log *slog.Logger
minGap, maxGap time.Duration
// mu serialises requests, so the gap between them holds; lastRequest is
// when the previous one finished (zero: none yet).
mu sync.Mutex
lastRequest time.Time
}
// Option configures a Client.
type Option func(*Client)
// WithLogger makes the client log to logger instead of slog.Default().
func WithLogger(logger *slog.Logger) Option {
return func(c *Client) {
c.log = logger
}
}
// WithRequestGap makes the client wait a random time from minGap to maxGap
// after each request before making the next. A maxGap less than minGap is
// read as minGap; 0 or less for both means no wait, for tests.
func WithRequestGap(minGap, maxGap time.Duration) Option {
return func(c *Client) {
c.minGap = minGap
c.maxGap = max(minGap, maxGap)
}
}
// New returns a client for the portal at baseURL (DefaultBaseURL if empty).
// It waits DefaultMinRequestGap to DefaultMaxRequestGap between requests
// unless given WithRequestGap, and logs to slog.Default() unless given
// WithLogger.
func New(baseURL string, opts ...Option) *Client {
jar, _ := cookiejar.New(nil) // cookiejar.New never returns an error
client := &Client{
baseURL: strings.TrimSuffix(cmp.Or(baseURL, DefaultBaseURL), "/"),
httpClient: &http.Client{
Timeout: requestTimeout,
Jar: jar,
},
minGap: DefaultMinRequestGap,
maxGap: DefaultMaxRequestGap,
}
for _, opt := range opts {
opt(client)
}
return client
}
// logger returns the logger the client logs to, tagged with its component.
func (c *Client) logger() *slog.Logger {
log := c.log
if log == nil {
log = slog.Default()
}
return log.With("component", "eusupply")
}
// do makes one request, once the gap since the previous request has passed,
// and returns its body. A status other than 200 OK fails with
// ErrUnexpectedStatus. It returns the request context's error if that is
// cancelled while it waits.
func (c *Client) do(req *http.Request) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.wait(req.Context()); err != nil {
return nil, err
}
defer func() {
c.lastRequest = time.Now()
}()
c.logger().Info("fetching", "method", req.Method, "uri", req.URL.String())
res, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close() //nolint:errcheck // read only
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", req.URL, err)
}
return body, nil
}
// wait sleeps until a random gap (see WithRequestGap) has passed since the
// previous request, or ctx is cancelled. c.mu must be held.
func (c *Client) wait(ctx context.Context) error {
if c.lastRequest.IsZero() || c.maxGap <= 0 {
return ctx.Err()
}
gap := c.minGap
if c.maxGap > c.minGap {
//nolint:gosec // G404: a jitter, not a secret
gap += rand.N(c.maxGap - c.minGap + 1)
}
timer := time.NewTimer(time.Until(c.lastRequest.Add(gap)))
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// get makes a GET request for the portal's path.
func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, c.baseURL+path, nil,
)
if err != nil {
return nil, err
}
return c.do(req)
}
// post posts form to the portal's path.
func (c *Client) post(
ctx context.Context, path string, form url.Values,
) ([]byte, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+path,
strings.NewReader(form.Encode()),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return c.do(req)
}
// mustLoadLocation loads a time zone known to be in the embedded tzdata.
func mustLoadLocation(name string) *time.Location {
location, err := time.LoadLocation(name)
if err != nil {
panic(err)
}
return location
}