Commit Graph
22 Commits
Author SHA1 Message Date
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
bwilliams b0f93fd437 config init now leaves out the test sources unless given --all
A section tagged init:"hidden" isn't written to the file config init
generates; fts_mock and sleeper are. config init --all writes them too.
They still load, and the config commands read and set them as before.
2026-10-21 16:22:45 +01:00
bwilliams 71eca2f194 Terminal logging is now coloured only on an interactive terminal
The terminal logger always coloured its text, so output piped or
redirected to a file, and the systemd journal, got escape codes. It
now colours only an interactive terminal.

The service's logs can therefore go to the journal: logging.file is
now optional for it, and the unit's comments and the README say so.
2026-10-21 15:39:36 +01:00
bwilliams 2aeb2993b0 Added a systemd unit and logrotate configuration, packaged in the release 2026-10-21 15:31:35 +01:00
bwilliams 1e3acaeff3 A .env file in the working directory is now optional 2026-10-21 15:28:52 +01:00
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
bwilliams c09c30ae71 Added a backfill in 7-day windows, and fetches now keep their data on a failure
Backfilling a long window (an early --since, or a long time offline)
hit the API's rate limit, which could still be exceeded after waiting
once, and then everything fetched so far was lost.

Before each Find a Tender source's worker starts, a start point more
than 7 days ago is now backfilled in 7-day windows bounded on both
sides, oldest first, each saved before the next is fetched. A window
that fails is saved as far as it got and fetched again from its start
after the source's interval. The worker then carries on from the end
of the last window.

A 429 is now waited out up to sources.<source>.rate_limit_retries
times (default 5) rather than once; fts-once --ignore-rate-limit uses
0. FetchAll returns what it fetched before an error along with it, and
the workers save it, so a failed run no longer loses its data.
findatender's FetchAll and FetchOnce take RequestOptions, which gains
Until (updatedTo), and RateLimitRetries replaces AllowRateLimit.
2026-10-21 12:40:39 +01:00
bwilliams 0543762330 Added skip rules, which mark new opportunities Skipped when they're fetched
sources.skip_rules_file names a JSON file of filters, each with a
comment, an active flag, a logic mode ("or" by default, or "and") and
rules by buyer, title words or CPV codes. The fetcher applies them
before saving, so only new opportunities are marked; the marks of
stored ones are never touched. CPV rules match when the opportunity's
codes are all in the list, or any of them with exclusive false, and
by prefix with exact false.

The file is read again when its time or size changes: an invalid edit
is logged and the last good rules kept, and a deleted file means no
rules until it's back. fetcher service and serve refuse to start if it
is define but missing or invalid.

job-search skip-rules check validates a rules file, and skip-rules
apply marks the stored opportunities that match Skipped, or only lists
them with --dry-run.
2026-10-20 08:55:19 +01:00
bwilliams 1988d07a29 Ran the installed ocdsgen, and added a script that installs the dev tools
go generate ran ocdsgen with go run, from go.mod, but go-ocds had been
dropped from go.mod (nothing imports it, so go mod tidy removes it), which
broke go generate and so scripts/build.sh. It now runs the installed
ocdsgen from PATH, as it does swag.

scripts/install-dev-tools.sh installs the Go tools (ocdsgen v1.0.0 and swag
v2.0.0-rc6 pinned, as other versions could generate different code;
golangci-lint, goose and cobra-cli at their latest), checks for Node.js 24
or later and runs npm ci in web/. The README's dev tools section now points
to it and lists what each tool is for.
2026-10-16 16:03:48 +01:00
bwilliams 5e6c011ecb Embedded the frontend in the binary, and built it in the build script
`npm run sync` in web/ copies the build output (web/dist, from npm run build)
to internal/frontend/dist, which the binary embeds. Go can't embed from
outside the package's directory (web/ is also outside the Go module), hence
the copy. Only dist/.gitkeep is committed, so a fresh clone still compiles,
and a binary built without syncing first serves no frontend.

job-search serve serves server.frontend.dir if it is set, or else the
embedded build, or warns that there is no frontend. scripts/build.sh builds
and syncs the frontend before building, running npm ci first for a release
or when web/node_modules is missing.
2026-10-16 16:03:15 +01:00
bwilliams 70d62f280e Added the opportunities REST API, read-only apart from their marks, described in OpenAPI 3.1 with swag
GET /api/opportunities lists the stored opportunities, newest release first,
a page at a time (offset, and limit up to 100, default 50); has_more says
whether another page follows. GET /api/opportunities/{uuid} returns one with
its releases. PUT and DELETE /api/opportunities/{uuid}/marks/{mark} set and
clear one mark (hidden, skipped, interested, bid_submitted, won, lost) and
return them all; won and lost exclude each other (409). The API uses the
types in internal/types, and changing a mark is made atomic, as the store
replaces all of an opportunity's marks at once. The marks are found by
reflection: every bool field of types.OpportunityMarks, by its JSON name, so
a new mark needs no API change.

The API has one entry point, api.New, with its dependencies as options
(api.WithStore), and the server only mounts it under /api, keeping the
middleware. New resources are added inside internal/api. job-search serve
passes server.WithStore when database.file is set, opening the store before
the server; without one there are no opportunity routes.

The API is described by swag v2 annotations on the handlers. go generate
writes the OpenAPI 3.1 description (git-ignored, like the OCDS types), which
the server embeds and serves at /api/openapi.json, with Swagger UI at
/api/docs. A test checks the description and the routes match. swag is in
the README's dev tools, pinned to v2.0.0-rc6, and the build now needs it.
2026-10-16 13:13:44 +01:00
bwilliams 284a589627 Made each source start from its newest stored release, with --since as an override
Without --since, fetcher service and job-search serve start each source
from the newest release stored for it (the new store.LatestReleaseDate,
by Source), so a restart carries on where it left off instead of fetching
the last day again; with nothing stored, the client's default still
applies. --since overrides it for every source. The Find a Tender source's
name is now a constant, as the Source its opportunities are stored with.

The README's guide to adding a source is brought up to date with the
current worker, start and config type names.
2026-10-16 11:37:09 +01:00
bwilliams eba6af680f Added config generate-csrf-key, to store a CSRF key that survives restarts and works across instances
Without server.csrf.tokens.key, each server process signs CSRF tokens with
a random key of its own, so tokens are lost on a restart and rejected by
other instances. "job-search config generate-csrf-key" stores a new random
32-byte hex key in the configuration file, without printing it. --print
only prints a key, for JOBSEARCH_SERVER_CSRF_TOKENS_KEY or another
instance. An existing key is only replaced with --force, as that
invalidates every issued token, and no key is stored while
JOBSEARCH_SERVER_CSRF_TOKENS_KEY is set, as it would override the file.
2026-10-16 10:53:13 +01:00
bwilliams f0c171c8a8 Added --http-upgrade and --direct to ws-test dial, so it can connect to job-search serve or ws-test serve
One of the two is required. --http-upgrade upgrades an HTTP connection to
a WebSocket on /ws, as before, for job-search serve; --url and --origin go
with it. --direct connects straight to ws-test serve over plain TCP, one
JSON message per line, at --addr (default from server.host and
server.port). --ping, --message, --listen and stdin work the same in both
modes. The README's ws-test section explains the two modes.
2026-10-16 09:47:38 +01:00
bwilliams ac44b45f06 Split fetch from jobsearch into a separate fetcher command, and renamed fetch once to fts-once
job-search no longer has a fetch command. Its subcommands are now in
cmd/fetcher, which reads the same configuration: "fetcher service" runs
every enabled source's fetcher on its interval until stopped, and
"fetcher fts-once" (was "fetch once") fetches from Find a Tender once and
prints the releases as JSON. The new namea say what the commands do,
because service runs every source while fts-once fetches only from Find a
Tender.

The code stays in internal/cli/fetch, where NewCommands replaces the fetch
parent command. once.go is now fts_once.go and commandOnce is
commandFTSOnce. The README's usage, the config README and the find_a_tender
api_root comment now give the new command names.
2026-10-16 09:13:10 +01:00
bwilliams 5e61c11eb9 Split ws from jobsearch as a separate test utility rather than a feature of jobsearch 2026-10-16 08:58:25 +01:00
bwilliams d6688a6754 File/package naming convention conforming better to go standards - as ugly as it is 2026-10-13 16:31:47 +01:00
bwilliams 045492a148 First pass implementation of an SQLite backing store for fetched opportunities 2026-10-13 12:52:17 +01:00
bwilliams 6e6dec68c0 Fixed lint issues and missing documentation 2026-10-13 12:21:32 +01:00
bwilliams 7fd321df97 Added unit tests, documentation and function descriptions across the board. Increased cyclop complexity threshold to 15 which is a bit more sane than 10 and excluded _test.go files from linters. 2026-10-09 18:27:51 +01:00
bwilliams e43b7a8370 Added fetch script for UK Gov FindATender service which is based on OCDS (with issues...) 2026-10-09 10:08:11 +01:00
bwilliams e715024cbd Initial project 2026-09-23 14:32:50 +01:00