Files
job-search/internal/fetcher/worker_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

250 lines
6.9 KiB
Go

package fetcher
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/eusupply"
"git.avsdev.uk/Go/job-search/internal/store"
"git.avsdev.uk/Go/job-search/internal/types"
"git.avsdev.uk/Go/job-search/internal/util"
)
// euSupplySource is the Source of the opportunities the EU Supply source
// fetches.
const euSupplySource = eusupply.Source
// repeatWindow is how far apart an EU Supply tender's publication and
// another source's opportunity's latest release may be for the two to be
// the same (see markRepeats).
const repeatWindow = 30 * 24 * time.Hour
// tenderStore is what the EU Supply source reads from the store, to skip
// tenders already stored and to find the ones another source has.
type tenderStore interface {
ReleaseExists(ctx context.Context, uuid, releaseID string) (bool, error)
ListOtherSourcesTitles(
ctx context.Context, source string, from, before time.Time,
) ([]store.OpportunityTitle, error)
GetOpportunity(ctx context.Context, uuid string) (*types.Opportunity, error)
}
// runEUSupply scrapes EU Supply's UK portal every configured interval until
// ctx is cancelled, passing what each run fetches to save, even when the run
// fails partway. A long window is backfilled first (see backfill). One
// client is used throughout, so its gap between requests holds across runs.
// lookup is nil without a store: every tender is then new, and none is
// checked for repeats.
func runEUSupply(
ctx context.Context,
logger *slog.Logger,
conf *config.JSCSourceEUSupply,
lookup tenderStore,
save saveFunc,
since time.Time,
) error {
client := eusupply.New(
conf.BaseURL,
eusupply.WithLogger(logger),
eusupply.WithRequestGap(conf.RequestMinGap, conf.RequestMaxGap),
)
fetch := func(
ctx context.Context, since, until time.Time,
) ([]*types.Opportunity, error) {
return fetchEUSupply(ctx, logger, client, lookup, since, until)
}
since = backfill(
ctx,
logger,
euSupplySource,
conf.Interval,
fetch,
save,
since,
)
worker := util.Worker{
Logger: tagged(logger),
Name: euSupplySource,
Interval: conf.Interval,
Task: func(ctx context.Context, since time.Time) error {
opportunities, err := fetch(ctx, since, time.Time{})
return saveFetched(ctx, logger, save, opportunities, err)
},
}
return worker.Run(ctx, since)
}
// fetchEUSupply fetches the tenders published from since up to until (zero:
// today) that aren't stored yet, with their own pages read, and marks the
// ones another source already has Hidden and Skipped (see markRepeats). If
// it fails partway, the tenders read in full before the error are returned
// along with it. Finding nothing new returns ErrNoNewReleases.
func fetchEUSupply(
ctx context.Context,
logger *slog.Logger,
client *eusupply.Client,
lookup tenderStore,
since, until time.Time,
) ([]*types.Opportunity, error) {
tenders, fetchErr := client.Search(ctx, since, until)
fresh, err := newTenders(ctx, lookup, tenders)
if err != nil {
return nil, err
}
for i := range fresh {
if err := client.Details(ctx, &fresh[i]); err != nil {
fresh = fresh[:i]
fetchErr = errors.Join(fetchErr, err)
break
}
}
opportunities := eusupply.Convert(fresh)
if err := markRepeats(ctx, logger, lookup, opportunities); err != nil {
// Saved unmarked, a repeat would stay visible, as marks are only
// stored for a new opportunity: fetch them again next run instead.
return nil, errors.Join(fetchErr, err)
}
tagged(logger).Info(
"fetched opportunities",
"source", euSupplySource,
"listed", len(tenders),
"count", len(opportunities),
)
if len(opportunities) == 0 && fetchErr == nil {
return nil, ErrNoNewReleases
}
return opportunities, fetchErr
}
// newTenders returns the tenders whose release isn't stored yet, so only
// their pages are read. Saving a stored one again would replace its row
// with the search's fields alone. With no lookup, every tender is new.
func newTenders(
ctx context.Context, lookup tenderStore, tenders []eusupply.Tender,
) ([]eusupply.Tender, error) {
if lookup == nil {
return tenders, nil
}
var fresh []eusupply.Tender
for _, tender := range tenders {
exists, err := lookup.ReleaseExists(
ctx, eusupply.UUID(tender.ID), eusupply.ReleaseID(tender),
)
if err != nil {
return nil, fmt.Errorf("checking tender %s: %w", tender.ID, err)
}
if !exists {
fresh = append(fresh, tender)
}
}
return fresh, nil
}
// markRepeats marks Hidden and Skipped each opportunity that repeats one
// from another source, most often its Find a Tender notice: its title is at
// least titleMatchThreshold alike (see similarTitles), its latest release
// is within repeatWindow, and its buyer or description matches too (see
// confirmedRepeat). Only the opportunities whose titles match are read in
// full. The store keeps the marks of an opportunity it already has, so only
// new ones are marked. With no lookup it does nothing.
func markRepeats(
ctx context.Context,
logger *slog.Logger,
lookup tenderStore,
opportunities []*types.Opportunity,
) error {
if lookup == nil || len(opportunities) == 0 {
return nil
}
earliest := opportunities[0].LatestReleaseDate
latest := earliest
for _, opportunity := range opportunities {
earliest = minTime(earliest, opportunity.LatestReleaseDate)
latest = maxTime(latest, opportunity.LatestReleaseDate)
}
others, err := lookup.ListOtherSourcesTitles(
ctx,
euSupplySource,
earliest.Add(-repeatWindow),
latest.Add(repeatWindow+time.Nanosecond),
)
if err != nil {
return err
}
normalised := make([]string, len(others))
for i, other := range others {
normalised[i] = normaliseTitle(other.Title)
}
repeats := 0
for _, opportunity := range opportunities {
title := normaliseTitle(opportunity.Title)
for i, other := range others {
gap := other.LatestReleaseDate.Sub(opportunity.LatestReleaseDate)
if gap.Abs() > repeatWindow || !similarTitles(title, normalised[i]) {
continue
}
repeated, err := lookup.GetOpportunity(ctx, other.UUID)
if err != nil {
return fmt.Errorf("reading opportunity %s: %w", other.UUID, err)
}
if !confirmedRepeat(opportunity, repeated) {
tagged(logger).Debug(
"title matched, but not buyer or description",
"uuid", opportunity.UUID,
"title_matches", other.UUID,
)
continue
}
opportunity.Marks.Hidden = true
opportunity.Marks.Skipped = true
repeats++
tagged(logger).Debug(
"hid repeated opportunity",
"uuid", opportunity.UUID,
"repeats", other.UUID,
)
break
}
}
tagged(logger).Info(
"hid repeated opportunities",
"source", euSupplySource,
"count", repeats,
)
return nil
}
// minTime returns the earlier of two times.
func minTime(first, second time.Time) time.Time {
if second.Before(first) {
return second
}
return first
}
// maxTime returns the later of two times.
func maxTime(first, second time.Time) time.Time {
if second.After(first) {
return second
}
return first
}