Opportunities from Find a Tender have a link icon before their title. Clicking it fetches the opportunity with its releases (the list leaves them out) and opens its newest release's notice, www.find-tender.service.gov.uk/notice/<release_id>, in a new tab. The tab is opened on the click and pointed at the notice when the request returns, so it isn't blocked as a popup; if the request fails, or there are no releases, it's closed and a toast says why. Opportunities from other sources have no link for now, but keep its space so the titles line up. The icon is lucide-solid's, added for it at 1.52.0.
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 is loaded into the environment first,
and must exist.
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 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.
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.
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/store |
SQLite storage of opportunities, and its migrations (see internal/store) |
internal/findatender |
FTS API client and conversion from OCDS to JobSearch types |
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.