JobSearch
JobSearch is a service for scraping for contract opportunities. It is primarily a re-write of the existing nodeJS JobSearch codebase.
It currently fetches releases from the UK Government Find a Tender service (FTS), which publishes them in the OCDS format. A mock of the FTS API is included for development.
Building
scripts/build.sh # build/job-search-<version>
scripts/build.sh --release # also package release/job-search-<version>-<arch>.tar.gz
The build script builds the frontend and copies it into place for embedding
(npm run build and npm run sync in web/, which need Node and npm; it
runs npm ci first for a release, or when web/node_modules is missing). It
then runs go generate ./..., which needs ocdsgen and swag installed
(scripts/install-dev-tools.sh; see
Installation of dev tools), and stamps the
version from the latest git tag. A build from untagged commits is marked as a development build
and isn't packaged unless --force is also given.
The frontend (web/) is embedded in the binary. Without the build script,
build it and copy it into place first, or the binary serves no frontend
(job-search serve warns):
cd web && npm ci && npm run build && npm run sync # web/dist -> internal/frontend/dist
Setting server.frontend.dir serves a build from disk instead.
During development, go run -tags sqlite_fts5 ./cmd/jobsearch works as well.
The sqlite_fts5 tag is needed for the database's title search (see
Testing).
Configuration
Configuration is read from, in increasing priority:
- Built-in defaults
config.yamlin/etc/job-searchor the working directory, or the file given with--configJOBSEARCH_*environment variables, e.g.JOBSEARCH_LOGGING_LEVEL=debug
A .env file in the working directory, if there is one, is loaded into the
environment first.
Example config.yaml:
logging:
level: info
file: /var/log/job-search.log
database:
file: /var/lib/job-search/jobsearch.db
sources:
find_a_tender:
enabled: true
interval: 10m
See internal/config for every key and how the configuration code works.
Usage
Global flags: --config/-c (config file), --debug/-d and
--quiet/-q (errors only).
| Command | Purpose |
|---|---|
job-search config init [path] |
Create a config file |
job-search config show |
Print the configuration (--json or --yaml for those formats) |
job-search config get <key> |
Print one value, e.g. logging.level |
job-search config set <key> <value> |
Validate and write one value to the config file |
job-search config set --unset <key> |
Remove a value from the config file |
job-search config generate-csrf-key |
Store a new random server.csrf.tokens.key in the config file, so CSRF tokens survive restarts and work across instances (--print only prints one; --force replaces an existing key) |
job-search migrate |
Apply every pending database migration (same as migrate up) |
job-search migrate up |
Apply pending migrations (--steps N for only N; --create to create a missing database file without asking) |
job-search migrate down --steps N |
Roll back the last N migrations (--steps 0 rolls back all of them, deleting all data, and needs --yes) |
job-search migrate status |
Show the database version and which migrations are applied or pending |
job-search skip-rules check [file] |
Check a skip rules file (default: sources.skip_rules_file) |
job-search skip-rules apply [file] |
Mark the stored opportunities that match the skip rules Skipped, listing them (--dry-run only lists them) |
job-search version |
Print version information |
job-search serve describes its REST API in OpenAPI 3.1 at
/api/openapi.json, and shows it in Swagger UI at /api/docs. The
description is generated from the handlers' annotations in internal/api by
go generate.
The migrate commands work on the database in database.file. The store
refuses to open a database that isn't at this build's schema version, so run
job-search migrate after installing a new version.
Skip rules
sources.skip_rules_file names a JSON file of rules that mark new
opportunities Skipped as fetcher service and job-search serve fetch them,
by buyer, title words or CPV codes. The file is read again when it changes;
an invalid edit is logged and the last good rules kept. Opportunities already
stored are only changed by job-search skip-rules apply. See
internal/skiprules for the format.
Fetch tool
fetcher reads the same configuration as job-search:
| Command | Purpose |
|---|---|
fetcher service |
Poll every enabled source on its interval until stopped (Ctrl+C or SIGTERM) |
fetcher fts-once |
Fetch from Find a Tender once and print the releases as JSON |
service logs to logging.file if it's set, or to the terminal with
--foreground. Both commands take --since "YYYY-MM-DD[ hh:mm:ss]".
Without it, service (and job-search serve) starts each source from the
newest release stored for it, so a restart carries on where it left off; with
nothing stored, Find a Tender's client fetches the last day. --since
overrides that for every source.
When the start is more than 7 days ago (an early --since, or a long time
offline), the Find a Tender and EU Supply sources first backfill it in 7-day
windows,
oldest first, saving each before fetching the next. A window that fails is
saved as far as it got and fetched again after the source's interval. The
API returns the newest releases first, so if a backfill is stopped partway
through a window, restarting without --since skips the rest of that
window: restart with --since set to the window's start (logged as
backfilling) to fill it.
A rate limit is waited out, as the API asks, up to
sources.<source>.rate_limit_retries times per request. A failed fetch
keeps what it fetched before the failure.
EU Supply
The eu_supply source scrapes the public tenders on EU Supply's UK portal
(uk.eu-supply.com), stored with the source EU Supply - UK. The site has
no API and publishes no rate limit, so the client waits a random time from
request_min_gap to request_max_gap (2s to 10s) between its requests. Each
tender not stored yet has its own page read too, for its description and
CPV codes. Most of its tenders are also published on Find a Tender: a new
one is stored Hidden and Skipped when another source's opportunity released
within 30 days of it has a title at least 90% alike, and its buyer or its
description matches too (80% of the shorter one's words). See
internal/eusupply.
fts-once can be limited with --planning, --opportunities or
--awards, and with --ignore-rate-limit it fails on a rate limit instead
of waiting.
During development, go run ./cmd/fetcher works as well.
Mock Find a Tender server
To develop without using the real API, run the mock and enable the fts_mock
source:
go run ./cmd/mock-fts --min-gap 5s --max-gap 10s --rate-limit 1000
sources:
fts_mock:
enabled: true
interval: 1m
api_root: http://localhost:8080/api/1.0
See internal/mock for its options and behaviour.
WebSocket test tool
ws-test reads the same configuration as job-search for its defaults:
go run ./cmd/ws-test dial --http-upgrade --ping # ping job-search serve's /ws endpoint
go run ./cmd/ws-test dial --direct --ping # ping ws-test serve
go run ./cmd/ws-test dial --http-upgrade # send each line of stdin, print replies
go run ./cmd/ws-test serve # plain TCP server for websocket hub clients
dial needs one of --http-upgrade, to upgrade an HTTP connection to a
WebSocket on /ws as job-search serve expects, or --direct, to connect
straight to ws-test serve over plain TCP with one JSON message per line.
It also takes --message, --listen and --timeout; --url and --origin
(to test the Origin check) with --http-upgrade, and --addr with
--direct. See ws-test dial --help.
ws-test serve is a test server only: don't run it alongside
job-search serve. Both listen on server.host and server.port, so the
second one fails with "address already in use".
Project layout
| Path | Contents |
|---|---|
cmd/jobsearch |
The job-search command |
cmd/fetcher |
The fetcher command (Find a Tender fetch, the polling service) |
cmd/mock-fts |
The mock FTS server |
cmd/ws-test |
The ws-test WebSocket test tool |
internal/cli |
Cobra commands and shared CLI setup |
internal/config |
Configuration loading, validation and editing |
internal/fetcher |
Runs a worker per enabled source |
internal/skiprules |
The rules that mark new opportunities Skipped (see internal/skiprules) |
internal/store |
SQLite storage of opportunities, and its migrations (see internal/store) |
internal/findatender |
FTS API client and conversion from OCDS to JobSearch types |
internal/eusupply |
EU Supply UK portal scraper and conversion to JobSearch types (see internal/eusupply) |
internal/types |
JobSearch's own opportunity types |
internal/util |
The periodic Worker used by the fetcher |
internal/logging |
slog setup and the HTTP access log |
internal/mock |
Mock external services |
internal/version |
Version information set at build time |
internal/frontend |
Serves the frontend's build output; embeds dist/, copied there from web/dist by npm run sync |
web |
The frontend (see web) |
Testing
The store's title search needs SQLite's FTS5 module, which is only compiled in
with the sqlite_fts5 build tag, so tests (and lint) need it. Without it the
store tests fail with no such module: fts5.
go test -race -tags sqlite_fts5 ./internal/...
go test -tags sqlite_fts5 -coverprofile=cover.out ./internal/... && go tool cover -func=cover.out
golangci-lint run --build-tags sqlite_fts5 ./...
Tests don't need network access; HTTP calls go to local httptest servers.
Addition of a new opportunities source
Using a source called example as an illustration:
- Configuration section: create
internal/config/conf_source_example.go, copying an existing source such asconf_source_ftsmock.go:- Add a
JSCKSourceExample = "example"constant. - Define
JSCSourceExample, embeddingJSCSourcesSource(forenabledandinterval), plus any fields of its own withcfgand optionaldefaulttags, e.g.APIRoot string `cfg:"api_root"`. - Implement the
Sectioninterface (seeinternal/config/section.go): copy theMap,Flatten,TypeOf,ValueOfandSetValuemethods from an existing source, under a// ---------- Section methods (see Section) ----------banner, renaming the receiver type and using the new constant inFlatten.
- Add a
- Register the section: add
Example JSCSourceExample `cfg:"example"`toJSCSourcesininternal/config/conf_sources.go. Thecfgtag must match the constant. The source's keys (sources.example.*) are then loaded, validated and available to theconfigcommands with no further code. - Client package: create a package that fetches from the source and
returns
[]*types.Opportunity(seeinternal/types). If the source publishes OCDS,internal/findatendermay already be suitable, as it is forfts_mock: create it with the source's own API root. - Worker: create
internal/fetcher/worker_example.go, followingworker_ftsmock.go:- Add an
exampleSource = "Example"constant: the worker's name, and theSourceits opportunities are stored with (set it on each one if the client doesn't, asfetchFTSMockdoes). runExample(ctx, logger, conf, save, since)builds autil.WorkerwithexampleSource,conf.Intervaland a task, then callsRun.- The task fetches everything since its
sinceargument, then returnssave(ctx, logger, opportunities), which stores them when the fetcher has a store. Wrap blocking calls inutil.AwaitTaskso shutdown isn't held up, and returnErrNoNewReleaseswhen nothing is found, so the checkpoint still moves forward. Any other error makes the next run retry from the same point.
- Add an
- Start it: in
Runininternal/fetcher/fetcher.go, addif f.conf.Example.Enabled { start(exampleSource, func(ctx context.Context, since time.Time) error { return runExample(ctx, logger, &f.conf.Example, f.save, since) }) }startpasses the worker its starting point:--since, or the newest release stored withexampleSource. - Tests:
TestSectionschecks the new section implementsSectionand that its keys match itscfgtag, with no changes needed. Ininternal/config/conf_sections_test.go, add the section toTestConcreteSourceSectionsand update the key count inTestSourcesSection. Test the client and worker usinghttptestservers rather than the real service. - Docs: add the source and its keys to the key table in
internal/config/README.md.
Installation of dev tools
scripts/install-dev-tools.sh installs everything needed to develop and
build the project: the Go tools below, with go install, and the frontend's
npm packages (npm ci in web/). It needs Go and Node.js 24 or later (with
npm), as the frontend's scripts run TypeScript directly. Run it again after
a tool's version in it changes.
scripts/install-dev-tools.sh
| Tool | Version | Used for |
|---|---|---|
ocdsgen (git.avsdev.uk/Go/go-ocds) |
v1.0.0 | Generates the OCDS types in internal/findatender/ocds (ocds_gen.go) from extensions.json, run by go generate |
swag |
v2.0.0-rc6 | Generates the API's OpenAPI 3.1 description from the handlers' comments (swag init --v3.1), run by go generate |
golangci-lint |
latest | Lints the Go code with the project's .golangci.json |
goose |
latest | Creates new migration files (see internal/store); the migrations themselves are applied by job-search migrate |
cobra-cli |
latest | Adds cobra commands |
The generators are pinned, as another version could generate different code.
swag's v2, the version with OpenAPI 3 support, has only had release
candidates so far. go generate ./... and the build script run the
generators from PATH, so ensure that the GOBIN path ($HOME/go/bin by
default) is added to the environment before running any of the tools; the
script says if it isn't.