Files
job-search/README.md
T
bwilliams b0230e6847 Replaced the Title filter with a general search, run by the backend
GET /api/opportunities/search?q= returns the UUIDs of the matching
opportunities, and the frontend filters its list by them. Words without
a prompt search the title and description; title:, buyer:, buyer_id:
and cpv: search only their own field, and a leading ! leaves out what a
term matches. Repeats of one prompt are OR, everything else AND. CPV
terms are checked per code: cpv:72 !cpv:7221 needs a code starting 72
that doesn't start 7221.

Unknown prompts are reported, and every response carries the help that
the search box's "?" panel shows. Migration 00011 adds the description
and buyer name to the full-text index, and indexes CPV descriptions.
2026-10-22 13:10:25 +01:00

348 lines
17 KiB
Markdown

# 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](https://www.find-tender.service.gov.uk/) service (FTS), which
publishes them in the [OCDS](https://standard.open-contracting.org/) format.
A mock of the FTS API is included for development.
## Building
```bash
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](#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):
```bash
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](#testing)).
## Running as a service
`deploy/job-search.service` is a systemd unit running `job-search serve`
(the server and every enabled source's fetcher) as a `job-search` system
user, from `/usr/local/bin/job-search` with `/etc/job-search/config.yaml`.
Its comments give the install steps. It keeps its data in
`/var/lib/job-search` and any log file in `/var/log/job-search`, the only
places it can write, so set `database.file` there. Logs go to the journal
(`journalctl -u job-search`) unless `logging.file` is set to a file in
`/var/log/job-search`, which then needs rotating (below).
- The database isn't migrated on start: run `job-search migrate up`
(`--create` the first time) as the `job-search` user before the first
start and after each upgrade.
- A failing source stops `serve` on purpose; the unit restarts it after
30s.
### Rotating the logs
`job-search` opens its log file once and has no signal to reopen it, so
logrotate must copy the file and truncate it in place (`copytruncate`); a
line written between the two is lost. `deploy/job-search.logrotate` does
this weekly for every `*.log` in `/var/log/job-search` (the log and, if
`server.access_log` is set there, the access log), keeping 8 weeks,
compressed. `/var/log/job-search` belongs to the service user, so it rotates
as that user (`su job-search job-search`); logrotate refuses otherwise.
```bash
# from the release tarball's directory (deploy/job-search.logrotate in the repo)
sudo install -m 644 job-search.logrotate /etc/logrotate.d/job-search
sudo logrotate --debug /etc/logrotate.d/job-search # dry run: what it would do
sudo logrotate --force /etc/logrotate.d/job-search # rotate now, to check
```
## Configuration
Configuration is read from, in increasing priority:
1. Built-in defaults
2. `config.yaml` in `/etc/job-search` or the working directory, or the file
given with `--config`
3. `JOBSEARCH_*` 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`:
```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](internal/config/README.md) 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 holding the defaults (`--all` also writes the test sources, `fts_mock` and `sleeper`) |
| `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](internal/skiprules/README.md) 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](internal/eusupply/README.md).
`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:
```bash
go run ./cmd/mock-fts --min-gap 5s --max-gap 10s --rate-limit 1000
```
```yaml
sources:
fts_mock:
enabled: true
interval: 1m
api_root: http://localhost:8080/api/1.0
```
See [internal/mock](internal/mock/README.md) for its options and behaviour.
## WebSocket test tool
`ws-test` reads the same configuration as `job-search` for its defaults:
```bash
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/search` | Parsing of the general opportunity search (`title:`, `buyer:`, `buyer_id:`, `cpv:`, `!`) and its help |
| `internal/skiprules` | The rules that mark new opportunities Skipped (see [internal/skiprules](internal/skiprules/README.md)) |
| `internal/store` | SQLite storage of opportunities, and its migrations (see [internal/store](internal/store/README.md)) |
| `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/eusupply/README.md)) |
| `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](web/README.md)) |
| `deploy` | The systemd unit and logrotate configuration (see [Running as a service](#running-as-a-service)) |
## 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`.
```bash
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:
1. **Configuration section:** create `internal/config/conf_source_example.go`,
copying an existing source such as `conf_source_ftsmock.go`:
- Add a `JSCKSourceExample = "example"` constant.
- Define `JSCSourceExample`, embedding `JSCSourcesSource`
(for `enabled` and `interval`), plus any fields of its own with `cfg` and
optional `default` tags, e.g. ``APIRoot string `cfg:"api_root"` ``.
- Implement the `Section` interface (see `internal/config/section.go`):
copy the `Map`, `Flatten`, `TypeOf`, `ValueOf` and `SetValue` methods
from an existing source, under a
`// ---------- Section methods (see Section) ----------` banner, renaming
the receiver type and using the new constant in `Flatten`.
2. **Register the section:** add
``Example JSCSourceExample `cfg:"example"` `` to
`JSCSources` in `internal/config/conf_sources.go`. The `cfg` tag
must match the constant. The source's keys (`sources.example.*`) are then
loaded, validated and available to the `config` commands with no further
code.
3. **Client package:** create a package that fetches from the source and
returns `[]*types.Opportunity` (see `internal/types`). If the source
publishes OCDS, `internal/findatender` may already be suitable, as it is
for `fts_mock`: create it with the source's own API root.
4. **Worker:** create `internal/fetcher/worker_example.go`, following
`worker_ftsmock.go`:
- Add an `exampleSource = "Example"` constant: the worker's name, and the
`Source` its opportunities are stored with (set it on each one if the
client doesn't, as `fetchFTSMock` does).
- `runExample(ctx, logger, conf, save, since)` builds a `util.Worker`
with `exampleSource`, `conf.Interval` and a task, then calls `Run`.
- The task fetches everything since its `since` argument, then returns
`save(ctx, logger, opportunities)`, which stores them when the fetcher
has a store. Wrap blocking calls in `util.AwaitTask` so shutdown isn't
held up, and return `ErrNoNewReleases` when nothing is found, so the
checkpoint still moves forward. Any other error makes the next run
retry from the same point.
5. **Start it:** in `Run` in `internal/fetcher/fetcher.go`, add
```go
if f.conf.Example.Enabled {
start(exampleSource, func(ctx context.Context, since time.Time) error {
return runExample(ctx, logger, &f.conf.Example, f.save, since)
})
}
```
`start` passes the worker its starting point: `--since`, or the newest
release stored with `exampleSource`.
6. **Tests:** `TestSections` checks the new section implements `Section`
and that its keys match its `cfg` tag, with no changes needed. In
`internal/config/conf_sections_test.go`, add the section to
`TestConcreteSourceSections` and update the key count in
`TestSourcesSection`. Test the client and worker using `httptest` servers
rather than the real service.
7. **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.
```bash
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.