diff --git a/.env.example b/.env.example
index 46e9a931..204a4955 100644
--- a/.env.example
+++ b/.env.example
@@ -48,14 +48,17 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dossier"
# Defaults for Settings → Job search; a choice saved in the app overrides them.
# Source: jsearch (LinkedIn, Indeed and more through Google for Jobs; needs a
# key from OpenWeb Ninja or RapidAPI), adzuna (needs an app ID and key),
-# arbeitsagentur (Germany only, no key; unofficial, see below) or off. Off
-# until you choose.
+# arbeitsagentur (Germany only, no key; unofficial, see below), freehire
+# (company career pages, no key) or off. Off until you choose.
# DOSSIER_JOBS_SOURCE=jsearch
# DOSSIER_JSEARCH_KEY=
# Where the JSearch key is from: openwebninja (default) or rapidapi.
# DOSSIER_JSEARCH_HOST=openwebninja
# DOSSIER_ADZUNA_APP_ID=
# DOSSIER_ADZUNA_KEY=
+# FreeHire origin; unset uses https://freehire.me. Point at a self-hosted
+# FreeHire when you run your own.
+# DOSSIER_FREEHIRE_URL=
# The Bundesagentur für Arbeit has no official API: Dossier uses the access
# its own job search uses, without its permission, and its terms of use don't
# allow automated access. Whoever chooses it does so on their own
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 7fe7f609..424bfe37 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -160,19 +160,20 @@ docker compose exec -e AI_LIVE=ollama app yarn vitest run src/lib/ai/providers.l
## Job search
-Find jobs (`/jobs`) searches the job source chosen in Settings → Job search: JSearch, Adzuna or the Bundesagentur für Arbeit. Until a choice is saved, `DOSSIER_JOBS_SOURCE` and the key variables decide (see `.env.example`). In the code, "job" already means a background task (`Job`, `src/lib/jobs`), so a job ad is a "posting", and job search lives in `src/lib/job-search`:
+Find jobs (`/jobs`) searches the job source chosen in Settings → Job search: JSearch, Adzuna, FreeHire or the Bundesagentur für Arbeit. Until a choice is saved, `DOSSIER_JOBS_SOURCE` and the key variables decide (see `.env.example`). In the code, "job" already means a background task (`Job`, `src/lib/jobs`), so a job ad is a "posting", and job search lives in `src/lib/job-search`:
- `search.ts` and `sources.ts`: the query and posting shapes every source maps to, the URL format, and what each source covers. Shared with the browser.
-- One adapter per source (`jsearch.ts`, `adzuna.ts`, `arbeitsagentur.ts`), behind `index.ts`, which checks the country, caches answers in memory for 30 minutes and logs a source that changed or isn't answering, without the search. Failures are a `JobSearchError` with a message for the screen.
-- `config.ts`: which source and key are in effect. Job source keys are encrypted like the AI key, each kind of secret under its own derived key (`src/lib/secrets.ts`).
+- One adapter per source (`jsearch.ts`, `adzuna.ts`, `arbeitsagentur.ts`, `freehire.ts`), behind `index.ts`, which checks the country, caches answers in memory for 30 minutes and logs a source that changed or isn't answering, without the search. Failures are a `JobSearchError` with a message for the screen.
+- `config.ts`: which source and key are in effect. Job source keys are encrypted like the AI key, each kind of secret under its own derived key (`src/lib/secrets.ts`). FreeHire needs no key; `DOSSIER_FREEHIRE_URL` points at a self-hosted catalogue when set.
- `src/lib/postings.ts`: the `Posting` table holds what the user saved, dismissed or started an application from. `src/lib/posting-searches.ts`: every search, kept with what it found (`PostingSearch`) until the user removes it, so opening one again asks the source nothing.
-The tests use answers in each source's shape (`src/test/job-search-fixtures.ts`). The live tests check the real services, and are skipped otherwise. JSearch and Adzuna spend searches from your plan; the Arbeitsagentur is free:
+The tests use answers in each source's shape (`src/test/job-search-fixtures.ts`). The live tests check the real services, and are skipped otherwise. JSearch and Adzuna spend searches from your plan; the Arbeitsagentur and FreeHire are free:
```bash
docker compose exec -e JOBS_LIVE=jsearch -e JSEARCH_TEST_KEY=… app yarn vitest run src/lib/job-search/jsearch.live.test.ts
docker compose exec -e JOBS_LIVE=arbeitsagentur app yarn vitest run src/lib/job-search/sources.live.test.ts
docker compose exec -e JOBS_LIVE=adzuna -e ADZUNA_TEST_APP_ID=… -e ADZUNA_TEST_KEY=… app yarn vitest run src/lib/job-search/sources.live.test.ts
+docker compose exec -e JOBS_LIVE=freehire app yarn vitest run src/lib/job-search/sources.live.test.ts
```
## Your data
diff --git a/PRIVACY.md b/PRIVACY.md
index 9c935150..4e40de0c 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -40,7 +40,7 @@ Exports you download and database dumps you take are copies outside Dossier's re
|---|---|---|
| A hosted AI provider (OpenAI-compatible, Anthropic or Mistral) | Each AI task, while one is chosen in Settings → AI provider | See [below](#what-a-hosted-ai-provider-receives) |
| Google | While a Google account is connected (Settings → Google) | Signing in: your OAuth client's ID and secret, and the tokens. **Gmail:** the application emails and answers you send, with their PDFs, and a request for new mail on each check (every 10 minutes by default). **Calendar:** the next 14 days, to find your free times; each interview you add, change or remove: its time and a title with the role and company; and requests for the events in the months you look at in the dashboard's calendar, which Dossier shows and doesn't keep. Google already holds your mail and calendar; Dossier only asks for the permissions you give |
-| The job source you chose (JSearch, Adzuna or the Bundesagentur für Arbeit) | Each search in Find jobs, which is off until you choose a source, and the job agent's search, at most three times in 24 hours while it's on (never on the Bundesagentur für Arbeit) | What you search for: keywords, place, country, radius and filters. Nothing from your profile: it only fills in the form. The Bundesagentur für Arbeit has no official API: Dossier uses the access its own job search uses, without its permission, so it's searched only after you tick "I understand" under that note, and `DOSSIER_JOBS_ARBEITSAGENTUR=off` takes it out |
+| The job source you chose (JSearch, Adzuna, FreeHire or the Bundesagentur für Arbeit) | Each search in Find jobs, which is off until you choose a source, and the job agent's search, at most three times in 24 hours while it's on (never on the Bundesagentur für Arbeit) | What you search for: keywords, place, country, radius and filters. Nothing from your profile: it only fills in the form. FreeHire is a public catalogue (or your self-hosted FreeHire via `DOSSIER_FREEHIRE_URL`) and needs no key. The Bundesagentur für Arbeit has no official API: Dossier uses the access its own job search uses, without its permission, so it's searched only after you tick "I understand" under that note, and `DOSSIER_JOBS_ARBEITSAGENTUR=off` takes it out |
| Hacker News (its official API, and Algolia's search of it) | When you press **Refresh** in Startups, then once a day while you open Startups at least every 14 days | Requests for YC startups' job posts and this month's “Who is hiring?” thread. Nothing about you: which posts suit your city and country is worked out on your server |
| Startups' own sites | Each Startups refresh, for the 12 best new matches with a site of their own, and when you press **Find addresses on their site** or **Look for people's addresses** | Requests for the site's robots.txt and a few public pages. See [Startups](#startups) |
| Anthropic, through Claude | Only while the Claude connector is on (Settings → Claude connector, off by default), and only for the Claude you signed in and allowed: each time Claude uses one of Dossier's tools | What that tool answers. See [Claude connector](#claude-connector) |
diff --git a/README.md b/README.md
index 502e44fc..1f71bae7 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ It's for one person on a small server: one password, your data in your own Postg
- **Profile:** basics, skills and languages, experience, projects, education, publications and references, in the order you choose, with a strength meter that counts only what a profile needs. Projects (side, open-source, course or thesis work) show what you can do where your job titles don't yet. Publications are cited in your own style, with bold and italics, and go on resumes word for word. Start it from an existing resume (PDF, Word or Markdown) if you like.
- **Base resume:** twelve themes and a theme builder, previewed live and printed to PDF on the server. Duplicate themes, or share them as files.
-- **Find jobs:** search postings near your city, or anywhere you choose, through JSearch (LinkedIn, Indeed and more) or Adzuna. Save the good ones, dismiss the rest, score any of them against your profile, and start an application from a posting with one click.
+- **Find jobs:** search postings near your city, or anywhere you choose, through JSearch (LinkedIn, Indeed and more), Adzuna or FreeHire (company career pages). Save the good ones, dismiss the rest, score any of them against your profile, and start an application from a posting with one click.
- **Startups:** startups that are hiring, from YC startups' job posts and Hacker News' monthly “Who is hiring?” thread, kept to the ones that hire where you live or remotely from your country and ranked against your profile, with the address each one publishes for applications. Dossier reads their sites politely and follows their robots.txt. Start an application to one with a click; you decide whom to write to.
- **Job agent (optional):** once a day it runs one search for you, skips what you've seen, scores the new postings against your profile and keeps the best on the Agent screen, with its reasons. It can prepare tailored drafts too, which wait for your review. It never sends or applies.
- **New application:** paste a posting; Dossier pulls out the details and requirements, matches them against your profile, and writes a tailored resume and cover letter in the tone you pick.
@@ -112,7 +112,7 @@ To put Dossier on a server, on your own domain with HTTPS, follow the [installat
The [wiki](https://github.com/AienTech/dossier/wiki) has what comes after installing:
- [Choosing an AI provider](https://github.com/AienTech/dossier/wiki/Choosing-an-AI-provider): a hosted model, or a local one with Ollama or LM Studio.
-- [Choosing a job source](https://github.com/AienTech/dossier/wiki/Choosing-a-job-source): JSearch or Adzuna (and an unofficial one for Germany), and the job agent.
+- [Choosing a job source](https://github.com/AienTech/dossier/wiki/Choosing-a-job-source): JSearch, Adzuna or FreeHire (and an unofficial one for Germany), and the job agent.
- [Connecting Gmail and Calendar](https://github.com/AienTech/dossier/wiki/Connecting-Gmail-and-Calendar): your own Google OAuth client, step by step.
- [Connecting Claude](https://github.com/AienTech/dossier/wiki/Connecting-Claude): the Claude connector, in Claude's apps and on the command line.
- [Backups and restore](https://github.com/AienTech/dossier/wiki/Backups-and-restore): exports, nightly backups and database dumps.
diff --git a/docs/pr-proof-freehire/find-jobs-freehire.png b/docs/pr-proof-freehire/find-jobs-freehire.png
new file mode 100644
index 00000000..0a542e80
Binary files /dev/null and b/docs/pr-proof-freehire/find-jobs-freehire.png differ
diff --git a/docs/pr-proof-freehire/settings-freehire.png b/docs/pr-proof-freehire/settings-freehire.png
new file mode 100644
index 00000000..fc950f24
Binary files /dev/null and b/docs/pr-proof-freehire/settings-freehire.png differ
diff --git a/docs/pr-proof-freehire/vitest-freehire.png b/docs/pr-proof-freehire/vitest-freehire.png
new file mode 100644
index 00000000..18fb56b6
Binary files /dev/null and b/docs/pr-proof-freehire/vitest-freehire.png differ
diff --git a/e2e/fake-jobs.mts b/e2e/fake-jobs.mts
index 91822122..88242c9e 100644
--- a/e2e/fake-jobs.mts
+++ b/e2e/fake-jobs.mts
@@ -124,5 +124,25 @@ createServer((request, response) => {
return send(response, 200, { ergebnisliste: [{ referenznummer: "e2e-ba", stellenangebotsTitel: "Produktdesigner (m/w/d)", firma: "Hafenlogik GmbH" }], maxErgebnisse: 1 });
}
+ if (url.pathname === "/freehire/api/v1/agent/jobs/search") {
+ return send(response, 200, {
+ data: [
+ {
+ public_slug: "e2e-ledgerly-designer",
+ source: "greenhouse",
+ title: "Senior Product Designer, Payments",
+ company: "Ledgerly",
+ location: "Rotterdam",
+ url: "https://jobs.example/e2e-ledgerly",
+ work_mode: "onsite",
+ posted_at: new Date(Date.now() - 2 * 86_400_000).toISOString(),
+ description: "Ledgerly is hiring a Senior Product Designer for its payments team.",
+ enrichment: { salary_min: 75000, salary_max: 90000, salary_currency: "EUR", salary_period: "year" },
+ },
+ ],
+ meta: { total: 1, limit: 20, offset: 0 },
+ });
+ }
+
send(response, 404, { error: "not found" });
}).listen(port, "127.0.0.1", () => console.log(`Fake job sources on http://localhost:${port}`));
diff --git a/prisma/migrations/20261029120000_freehire_source/migration.sql b/prisma/migrations/20261029120000_freehire_source/migration.sql
new file mode 100644
index 00000000..38ca5228
--- /dev/null
+++ b/prisma/migrations/20261029120000_freehire_source/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "PostingSource" ADD VALUE 'FREEHIRE';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 12c5fba5..b86c1f0f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -580,6 +580,7 @@ enum PostingSource {
JSEARCH
ADZUNA
ARBEITSAGENTUR
+ FREEHIRE
}
/// A posting from Find jobs that the user kept (E14-5): saved, dismissed, or
diff --git a/src/app/(app)/settings/job-search-section.tsx b/src/app/(app)/settings/job-search-section.tsx
index 49d3f9a8..b85daf12 100644
--- a/src/app/(app)/settings/job-search-section.tsx
+++ b/src/app/(app)/settings/job-search-section.tsx
@@ -171,6 +171,9 @@ export function JobSearchSection({ initial }: { initial: JobSearchSettingsView }
No key needed. The job agent doesn't search this source; choose JSearch or Adzuna for it.
>
)}
+ {source === "FREEHIRE" && (
+ No key needed. Searches go to FreeHire's public catalogue (or DOSSIER_FREEHIRE_URL when set).
+ )}
{testable && (
diff --git a/src/components/source-attribution.test.tsx b/src/components/source-attribution.test.tsx
index 634a58f1..100f1ae7 100644
--- a/src/components/source-attribution.test.tsx
+++ b/src/components/source-attribution.test.tsx
@@ -18,10 +18,15 @@ describe("attributionHref", () => {
expect(attributionHref("ADZUNA", "")).toBe("https://www.adzuna.co.uk");
});
- it("has no link for the other sources", () => {
+ it("has no link for sources without an attribution link", () => {
expect(attributionHref("JSEARCH", "https://www.adzuna.de/land/ad/1")).toBeNull();
expect(attributionHref("ARBEITSAGENTUR", "https://www.arbeitsagentur.de/jobsuche/jobdetail/1")).toBeNull();
});
+
+ it("links FreeHire to freehire.me, or keeps the origin when the posting is on it", () => {
+ expect(attributionHref("FREEHIRE", "https://boards.greenhouse.io/acme/jobs/1")).toBe("https://freehire.me");
+ expect(attributionHref("FREEHIRE", "https://freehire.me/jobs/senior-go-engineer-acme-1a2b")).toBe("https://freehire.me");
+ });
});
describe("SourceAttribution", () => {
diff --git a/src/lib/agent/run.ts b/src/lib/agent/run.ts
index 54424b7d..ba3e617e 100644
--- a/src/lib/agent/run.ts
+++ b/src/lib/agent/run.ts
@@ -56,7 +56,7 @@ export async function agentSetupProblem(query: SearchQuery): Promise {
DOSSIER_JSEARCH_HOST: "rapidapi",
DOSSIER_ADZUNA_APP_ID: "app",
DOSSIER_ADZUNA_KEY: "adz",
+ DOSSIER_FREEHIRE_URL: "https://jobs.example/",
}),
).toMatchObject({
DOSSIER_JOBS_SOURCE: "jsearch",
@@ -151,7 +152,9 @@ describe("job search variables (E14-4)", () => {
DOSSIER_JSEARCH_HOST: "rapidapi",
DOSSIER_ADZUNA_APP_ID: "app",
DOSSIER_ADZUNA_KEY: "adz",
+ DOSSIER_FREEHIRE_URL: "https://jobs.example",
});
+ expect(parseEnv({ ...valid, DOSSIER_JOBS_SOURCE: "freehire" }).DOSSIER_JOBS_SOURCE).toBe("freehire");
});
it("reads whether the Bundesagentur für Arbeit is offered, and refuses choosing it while it's off (#223)", () => {
@@ -165,7 +168,7 @@ describe("job search variables (E14-4)", () => {
it("rejects sources and hosts it doesn't know, without echoing keys", () => {
expect(problems({ ...valid, DOSSIER_JOBS_SOURCE: "linkedin", DOSSIER_JSEARCH_HOST: "google", DOSSIER_JSEARCH_KEY: "ak_secret" })).toEqual([
- 'DOSSIER_JOBS_SOURCE must be one of jsearch, adzuna, arbeitsagentur, off (got "linkedin").',
+ 'DOSSIER_JOBS_SOURCE must be one of jsearch, adzuna, arbeitsagentur, freehire, off (got "linkedin").',
'DOSSIER_JSEARCH_HOST must be openwebninja or rapidapi (got "google").',
]);
});
diff --git a/src/lib/env.ts b/src/lib/env.ts
index 7bd9a88e..945d7f3c 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -11,7 +11,7 @@ const GENERATE_SECRET = "for example the output of `openssl rand -base64 32`";
const HASH_PASSWORD = "Make one with `docker run --rm -it ghcr.io/aientech/dossier hash-password`";
export const AI_PROVIDERS = ["local", "openai", "anthropic", "mistral", "lmstudio"] as const;
-export const JOB_SOURCE_NAMES = ["jsearch", "adzuna", "arbeitsagentur", "off"] as const;
+export const JOB_SOURCE_NAMES = ["jsearch", "adzuna", "arbeitsagentur", "freehire", "off"] as const;
// An empty value (`DOSSIER_AI_KEY=` in .env) counts as unset.
const unsetIfEmpty = (value: unknown) =>
@@ -122,6 +122,11 @@ const schema = z.object({
),
DOSSIER_ADZUNA_APP_ID: optional(z.string()),
DOSSIER_ADZUNA_KEY: optional(z.string()),
+ // FreeHire origin; unset uses https://freehire.me. Point at a self-hosted
+ // FreeHire, or leave unset for the public catalogue.
+ DOSSIER_FREEHIRE_URL: optional(
+ z.url({ protocol: /^https?$/, error: "DOSSIER_FREEHIRE_URL must be an http:// or https:// URL." }).transform((url) => url.replace(/\/+$/, "")),
+ ),
// Off takes the Bundesagentur für Arbeit out everywhere (#223): it isn't an
// official API, so a server can choose not to offer it. Unset means on.
DOSSIER_JOBS_ARBEITSAGENTUR: optional(
diff --git a/src/lib/job-search/adapter.ts b/src/lib/job-search/adapter.ts
index 3813a208..cfd401ce 100644
--- a/src/lib/job-search/adapter.ts
+++ b/src/lib/job-search/adapter.ts
@@ -13,6 +13,7 @@ export type SourceCredentials = {
JSEARCH: { key: string; host: JSearchHost };
ADZUNA: { appId: string; key: string };
ARBEITSAGENTUR: Record;
+ FREEHIRE: Record;
};
export type RequestContext = {
diff --git a/src/lib/job-search/config.test.ts b/src/lib/job-search/config.test.ts
index 713477ce..47dd9375 100644
--- a/src/lib/job-search/config.test.ts
+++ b/src/lib/job-search/config.test.ts
@@ -84,6 +84,7 @@ describe("credentialsFor", () => {
it("gives each source its own key", () => {
expect(credentialsFor(config, "JSEARCH")).toEqual({ key: "ak_saved", host: "rapidapi" });
expect(credentialsFor({ ...config, arbeitsagentur: { offered: true, acceptedAt: ACCEPTED } }, "ARBEITSAGENTUR")).toEqual({});
+ expect(credentialsFor(config, "FREEHIRE")).toEqual({});
});
it("says what's missing", () => {
@@ -134,5 +135,15 @@ describe("requestContext", () => {
it("leaves sources at their own address, unless the end-to-end tests point them at a fake", () => {
expect(requestContext("JSEARCH", undefined)).toEqual({});
expect(requestContext("ADZUNA", "http://127.0.0.1:3998/")).toEqual({ baseUrl: "http://127.0.0.1:3998/adzuna" });
+ expect(requestContext("FREEHIRE", undefined, "https://jobs.example")).toEqual({ baseUrl: "https://jobs.example" });
+ expect(requestContext("FREEHIRE", "http://127.0.0.1:3998/", "https://jobs.example")).toEqual({
+ baseUrl: "http://127.0.0.1:3998/freehire",
+ });
+ });
+});
+
+describe("FreeHire from the environment", () => {
+ it("chooses FreeHire when DOSSIER_JOBS_SOURCE says so", () => {
+ expect(resolveJobSearchConfig(nothingSaved, { DOSSIER_JOBS_SOURCE: "freehire" }).source).toBe("FREEHIRE");
});
});
diff --git a/src/lib/job-search/config.ts b/src/lib/job-search/config.ts
index b9e9d71d..cd3531fe 100644
--- a/src/lib/job-search/config.ts
+++ b/src/lib/job-search/config.ts
@@ -31,13 +31,21 @@ export type JobSearchConfig = {
type SavedJobSettings = Pick;
type JobEnv = Pick<
Env,
- "DOSSIER_JOBS_SOURCE" | "DOSSIER_JSEARCH_KEY" | "DOSSIER_JSEARCH_HOST" | "DOSSIER_ADZUNA_APP_ID" | "DOSSIER_ADZUNA_KEY" | "DOSSIER_JOBS_ARBEITSAGENTUR"
+ | "DOSSIER_JOBS_SOURCE"
+ | "DOSSIER_JSEARCH_KEY"
+ | "DOSSIER_JSEARCH_HOST"
+ | "DOSSIER_ADZUNA_APP_ID"
+ | "DOSSIER_ADZUNA_KEY"
+ | "DOSSIER_FREEHIRE_URL"
+ | "DOSSIER_JOBS_ARBEITSAGENTUR"
+ | "DOSSIER_JOBS_TEST_BASE_URL"
>;
const FROM_ENV: Record, JobSource | null> = {
jsearch: "JSEARCH",
adzuna: "ADZUNA",
arbeitsagentur: "ARBEITSAGENTUR",
+ freehire: "FREEHIRE",
off: null,
};
@@ -102,10 +110,17 @@ export function jobSearchView(config: JobSearchConfig): JobSearchSettingsView {
/**
* Where a source's requests go: its own address, unless the end-to-end
- * tests point every source at their fake one (DOSSIER_JOBS_TEST_BASE_URL).
+ * tests point every source at their fake one (DOSSIER_JOBS_TEST_BASE_URL),
+ * or FreeHire is pointed at a self-hosted origin (DOSSIER_FREEHIRE_URL).
*/
-export function requestContext(source: JobSource, base = env.DOSSIER_JOBS_TEST_BASE_URL): RequestContext {
- return base ? { baseUrl: `${base.replace(/\/+$/, "")}/${source.toLowerCase()}` } : {};
+export function requestContext(
+ source: JobSource,
+ base = env.DOSSIER_JOBS_TEST_BASE_URL,
+ freehireUrl = env.DOSSIER_FREEHIRE_URL,
+): RequestContext {
+ if (base) return { baseUrl: `${base.replace(/\/+$/, "")}/${source.toLowerCase()}` };
+ if (source === "FREEHIRE" && freehireUrl) return { baseUrl: freehireUrl };
+ return {};
}
/**
@@ -134,5 +149,7 @@ export function credentialsFor(config: JobSearchConfig, sou
if (!config.arbeitsagentur.acceptedAt) throw jobSearchError("not-accepted", source);
return {} as SourceCredentials[S];
}
+ case "FREEHIRE":
+ return {} as SourceCredentials[S];
}
}
diff --git a/src/lib/job-search/freehire.test.ts b/src/lib/job-search/freehire.test.ts
new file mode 100644
index 00000000..d65ed7a1
--- /dev/null
+++ b/src/lib/job-search/freehire.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { json, stubFetch } from "@/lib/ai/test-http";
+import { freehireLastPage, freehirePage } from "@/test/job-search-fixtures";
+
+import { freehire } from "./freehire";
+import { EMPTY_QUERY } from "./search";
+
+const query = { ...EMPTY_QUERY, keywords: "product designer", place: "Hamburg", country: "de", radiusKm: 50 as const, postedWithin: "week" as const };
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("FreeHire search", () => {
+ it("asks the agent search with plain text, country and place in q", async () => {
+ const requests = stubFetch(() => json(freehirePage));
+ await freehire.search(query, {}, null);
+ const [{ url }] = requests;
+ expect(`${url.origin}${url.pathname}`).toBe("https://freehire.me/api/v1/agent/jobs/search");
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ q: "product designer in Hamburg",
+ description_format: "text",
+ limit: "20",
+ offset: "0",
+ countries: "DE",
+ });
+ });
+
+ it("sets work_mode when remote-only, and uses the test base URL", async () => {
+ const requests = stubFetch(() => json(freehirePage));
+ await freehire.search({ ...query, place: "", remoteOnly: true }, {}, "offset:20", { baseUrl: "http://127.0.0.1:3998/freehire" });
+ const [{ url }] = requests;
+ expect(url.href).toMatch(/^http:\/\/127\.0\.0\.1:3998\/freehire\/api\/v1\/agent\/jobs\/search\?/);
+ expect(url.searchParams.get("q")).toBe("product designer");
+ expect(url.searchParams.get("work_mode")).toBe("remote");
+ expect(url.searchParams.get("offset")).toBe("20");
+ });
+
+ it("maps results to the shared shape, with full descriptions marked complete", async () => {
+ stubFetch(() => json(freehirePage));
+ const page = await freehire.search(query, {}, null);
+ expect(page.next).toBe("offset:20");
+ expect(page.postings).toEqual([
+ {
+ source: "FREEHIRE",
+ externalId: "senior-go-engineer-acme-1a2b",
+ title: "Senior Go Engineer",
+ company: "Acme",
+ place: "Remote — EU",
+ remote: true,
+ salary: "EUR 90,000–130,000 a year",
+ postedAt: "2026-06-18T00:00:00.000Z",
+ url: "https://boards.greenhouse.io/acme/jobs/123",
+ via: "greenhouse",
+ description: "Acme is hiring a Senior Go Engineer.\n\nYou'll build APIs.",
+ complete: true,
+ },
+ {
+ source: "FREEHIRE",
+ externalId: "ux-researcher-northwind",
+ title: "UX Researcher",
+ company: "Northwind Studio",
+ place: "Berlin",
+ remote: false,
+ salary: "",
+ postedAt: null,
+ url: "",
+ via: "lever",
+ description: "",
+ complete: false,
+ },
+ ]);
+ });
+
+ it("stops at the last page", async () => {
+ stubFetch(() => json(freehireLastPage));
+ expect((await freehire.search(query, {}, "offset:20")).next).toBeNull();
+ stubFetch(() => json({ data: [], meta: { total: 0, limit: 20, offset: 0 } }));
+ expect((await freehire.search(query, {}, null)).next).toBeNull();
+ });
+
+ it("writes a salary without a range as one amount", async () => {
+ stubFetch(() => json(freehireLastPage));
+ const [posting] = (await freehire.search(query, {}, null)).postings;
+ expect(posting.salary).toBe("EUR 52,000 a year");
+ expect(posting.remote).toBeNull();
+ });
+
+ it("says the source changed when the answer has an unknown shape", async () => {
+ stubFetch(() => json({ jobs: [] }));
+ await expect(freehire.search(query, {}, null)).rejects.toMatchObject({ problem: "changed" });
+ });
+
+ it("says when FreeHire isn't answering", async () => {
+ stubFetch(() => json({ error: "unavailable" }, { status: 503 }));
+ await expect(freehire.search(query, {}, null)).rejects.toMatchObject({
+ problem: "unavailable",
+ message: "FreeHire isn't answering right now. Try again in a minute.",
+ });
+ });
+});
+
+describe("FreeHire details and test", () => {
+ it("returns the description already on the posting", async () => {
+ stubFetch(() => json(freehirePage));
+ const [posting] = (await freehire.search(query, {}, null)).postings;
+ expect(await freehire.details(posting, {})).toBe(posting.description);
+ expect(await freehire.details({ ...posting, description: "" }, {})).toBeNull();
+ });
+
+ it("tests with one small search", async () => {
+ const requests = stubFetch(() => json(freehirePage));
+ await freehire.test({});
+ expect(requests[0].url.pathname).toBe("/api/v1/agent/jobs/search");
+ expect(requests[0].url.searchParams.get("q")).toBe("developer");
+ expect(requests[0].url.searchParams.get("countries")).toBe("US");
+ });
+});
diff --git a/src/lib/job-search/freehire.ts b/src/lib/job-search/freehire.ts
new file mode 100644
index 00000000..73b40564
--- /dev/null
+++ b/src/lib/job-search/freehire.ts
@@ -0,0 +1,149 @@
+import "server-only";
+
+import { z } from "zod";
+
+import type { JobSourceAdapter, RequestContext } from "./adapter";
+import { jobSearchError } from "./errors";
+import { requestJson } from "./http";
+import { safeLink, type FoundPosting, type SearchPage, type SearchQuery } from "./search";
+
+// FreeHire: ATS-direct catalogue over a public HTTP API (no key). Search
+// uses /agent/jobs/search so each hit already carries the full plain-text
+// description. Radius isn't supported; place goes into `q` like JSearch.
+// Self-host / e2e override the origin via RequestContext.baseUrl.
+
+const DEFAULT_BASE = "https://freehire.me";
+const SEARCH_PATH = "/api/v1/agent/jobs/search";
+const PAGE_SIZE = 20;
+
+const maybeText = z.string().nullish().catch(null);
+const maybeNumber = z.number().nullish().catch(null);
+
+const enrichmentSchema = z
+ .object({
+ salary_min: maybeNumber,
+ salary_max: maybeNumber,
+ salary_currency: maybeText,
+ salary_period: maybeText,
+ })
+ .nullish()
+ .catch(null);
+
+const jobSchema = z.object({
+ public_slug: z.string().min(1),
+ title: maybeText,
+ company: maybeText,
+ location: maybeText,
+ url: maybeText,
+ source: maybeText,
+ work_mode: maybeText,
+ posted_at: maybeText,
+ description: maybeText,
+ enrichment: enrichmentSchema,
+});
+type Job = z.output;
+
+const responseSchema = z.object({
+ data: z.array(z.unknown()),
+ meta: z
+ .object({
+ total: z.number().nullish().catch(null),
+ limit: z.number().nullish().catch(null),
+ offset: z.number().nullish().catch(null),
+ })
+ .nullish()
+ .catch(null),
+});
+
+const PERIODS: Record = { year: "a year", month: "a month", day: "a day", hour: "an hour" };
+
+function salaryOf(job: Job): string {
+ const enrichment = job.enrichment;
+ if (!enrichment) return "";
+ const { salary_min: min, salary_max: max } = enrichment;
+ if (min == null && max == null) return "";
+ const amount = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 0 });
+ const range = min != null && max != null && min !== max ? `${amount(min)}–${amount(max)}` : amount((min ?? max)!);
+ return [enrichment.salary_currency, range, PERIODS[enrichment.salary_period?.toLowerCase() ?? ""]].filter(Boolean).join(" ");
+}
+
+function remoteOf(workMode: string | null | undefined): boolean | null {
+ if (workMode === "remote") return true;
+ if (workMode === "onsite") return false;
+ return null;
+}
+
+function postedAtOf(value: string | null | undefined): string | null {
+ if (!value) return null;
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
+}
+
+export function postingFromJob(job: Job): FoundPosting {
+ const description = (job.description ?? "").trim().slice(0, 100_000);
+ return {
+ source: "FREEHIRE",
+ externalId: job.public_slug.slice(0, 500),
+ title: (job.title ?? "").trim().slice(0, 500),
+ company: (job.company ?? "").trim().slice(0, 300),
+ place: (job.location ?? "").trim().slice(0, 300),
+ remote: remoteOf(job.work_mode),
+ salary: salaryOf(job).slice(0, 200),
+ postedAt: postedAtOf(job.posted_at),
+ url: safeLink(job.url).slice(0, 2000),
+ via: (job.source ?? "").trim().slice(0, 100),
+ description,
+ complete: description.length > 0,
+ };
+}
+
+/** "designer in Hamburg": FreeHire has no radius; place rides in the query text. */
+function queryText({ keywords, place }: SearchQuery): string {
+ return place ? `${keywords} in ${place}` : keywords;
+}
+
+function searchUrl(query: SearchQuery, offset: number, baseUrl?: string): URL {
+ const url = new URL(`${(baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "")}${SEARCH_PATH}`);
+ url.searchParams.set("q", queryText(query));
+ url.searchParams.set("description_format", "text");
+ url.searchParams.set("limit", String(PAGE_SIZE));
+ url.searchParams.set("offset", String(offset));
+ if (query.country) url.searchParams.set("countries", query.country.toUpperCase());
+ if (query.remoteOnly) url.searchParams.set("work_mode", "remote");
+ return url;
+}
+
+function pageOffset(next: string | null): number {
+ if (!next?.startsWith("offset:")) return 0;
+ return Math.max(0, Number(next.slice("offset:".length)) || 0);
+}
+
+async function searchPage(query: SearchQuery, _credentials: Record, next: string | null, context: RequestContext = {}): Promise {
+ const offset = pageOffset(next);
+ const url = searchUrl(query, offset, context.baseUrl);
+ const answer = responseSchema.safeParse(await requestJson("FREEHIRE", url, { signal: context.signal }));
+ if (!answer.success) throw jobSearchError("changed", "FREEHIRE", { detail: `${url.pathname}: unexpected answer` });
+
+ const seen = new Set();
+ const postings: FoundPosting[] = [];
+ for (const item of answer.data.data) {
+ const job = jobSchema.safeParse(item);
+ if (!job.success || seen.has(job.data.public_slug)) continue;
+ seen.add(job.data.public_slug);
+ postings.push(postingFromJob(job.data));
+ }
+
+ const total = answer.data.meta?.total ?? null;
+ const nextOffset = offset + PAGE_SIZE;
+ const more = total != null ? nextOffset < total : answer.data.data.length >= PAGE_SIZE;
+ return { postings, next: more ? `offset:${nextOffset}` : null };
+}
+
+export const freehire: JobSourceAdapter<"FREEHIRE"> = {
+ source: "FREEHIRE",
+ search: searchPage,
+ details: async (posting) => (posting.description ? posting.description : null),
+ test: async (_credentials, context) => {
+ await searchPage({ keywords: "developer", place: "", country: "us", radiusKm: 25, remoteOnly: false, postedWithin: "any" }, {}, null, context);
+ },
+};
diff --git a/src/lib/job-search/index.ts b/src/lib/job-search/index.ts
index 61901f21..cd60f937 100644
--- a/src/lib/job-search/index.ts
+++ b/src/lib/job-search/index.ts
@@ -7,6 +7,7 @@ import { cached, remember } from "./cache";
import { JobSearchError, jobSearchError } from "./errors";
import { adzuna } from "./adzuna";
import { arbeitsagentur } from "./arbeitsagentur";
+import { freehire } from "./freehire";
import { jsearch } from "./jsearch";
import { postingKey, type FoundPosting, type JobSource, type SearchPage, type SearchQuery } from "./search";
import { sourceCovers } from "./sources";
@@ -23,6 +24,7 @@ const ADAPTERS: { [S in JobSource]: JobSourceAdapter } = {
JSEARCH: jsearch,
ADZUNA: adzuna,
ARBEITSAGENTUR: arbeitsagentur,
+ FREEHIRE: freehire,
};
const adapterFor = (source: S) => ADAPTERS[source] as JobSourceAdapter;
diff --git a/src/lib/job-search/search.ts b/src/lib/job-search/search.ts
index 5a50b6ee..f7260d19 100644
--- a/src/lib/job-search/search.ts
+++ b/src/lib/job-search/search.ts
@@ -12,7 +12,7 @@ import { countryCodeFor } from "./countries";
// job ad is a "posting".
/** The job sources Dossier can search (E14-1 to E14-3), in the order Settings lists them; one is chosen there. */
-export const JOB_SOURCES = [PostingSource.JSEARCH, PostingSource.ADZUNA, PostingSource.ARBEITSAGENTUR] as const;
+export const JOB_SOURCES = [PostingSource.JSEARCH, PostingSource.ADZUNA, PostingSource.ARBEITSAGENTUR, PostingSource.FREEHIRE] as const;
export type JobSource = PostingSource;
/** Where a JSearch key comes from: OpenWeb Ninja directly, or the RapidAPI marketplace. */
diff --git a/src/lib/job-search/sources.live.test.ts b/src/lib/job-search/sources.live.test.ts
index 0754637a..760a408c 100644
--- a/src/lib/job-search/sources.live.test.ts
+++ b/src/lib/job-search/sources.live.test.ts
@@ -2,18 +2,21 @@ import { describe, expect, it } from "vitest";
import { adzuna } from "./adzuna";
import { arbeitsagentur } from "./arbeitsagentur";
+import { freehire } from "./freehire";
import { EMPTY_QUERY } from "./search";
-// Checks against the real Adzuna and Arbeitsagentur services, skipped unless
-// asked for. The Arbeitsagentur needs no key and costs nothing; Adzuna
-// spends two calls from the day's allowance:
+// Checks against the real Adzuna, Arbeitsagentur and FreeHire services,
+// skipped unless asked for. The Arbeitsagentur and FreeHire need no key;
+// Adzuna spends two calls from the day's allowance:
//
// JOBS_LIVE=arbeitsagentur docker/bin/yarn vitest run src/lib/job-search/sources.live.test.ts
// JOBS_LIVE=adzuna ADZUNA_TEST_APP_ID=… ADZUNA_TEST_KEY=… docker/bin/yarn vitest run src/lib/job-search/sources.live.test.ts
+// JOBS_LIVE=freehire docker/bin/yarn vitest run src/lib/job-search/sources.live.test.ts
const live = new Set((process.env.JOBS_LIVE ?? "").split(",").map((name) => name.trim()));
const adzunaKey = { appId: process.env.ADZUNA_TEST_APP_ID ?? "", key: process.env.ADZUNA_TEST_KEY ?? "" };
const query = { ...EMPTY_QUERY, keywords: "Softwareentwickler", place: "Hamburg", country: "de", radiusKm: 25 as const };
+const freehireBase = process.env.FREEHIRE_TEST_BASE_URL;
describe.runIf(live.has("arbeitsagentur"))("Arbeitsagentur (live)", { timeout: 30_000 }, () => {
it("finds postings near a place, a second page, and the whole text of one", async () => {
@@ -34,3 +37,18 @@ describe.runIf(live.has("adzuna") && adzunaKey.appId && adzunaKey.key)("Adzuna (
expect((await adzuna.search({ ...query, keywords: "software developer" }, adzunaKey, page.next)).postings.length).toBeGreaterThan(0);
});
});
+
+describe.runIf(live.has("freehire"))("FreeHire (live)", { timeout: 30_000 }, () => {
+ it("finds postings, with their text and a link, and a second page", async () => {
+ const context = freehireBase ? { baseUrl: freehireBase } : {};
+ const page = await freehire.search({ ...query, keywords: "software engineer", place: "" }, {}, null, context);
+ expect(page.postings.length).toBeGreaterThan(0);
+ for (const posting of page.postings) {
+ expect(posting.title).not.toBe("");
+ expect(posting.url).toMatch(/^https?:\/\//);
+ }
+ expect(page.postings.some((posting) => posting.complete)).toBe(true);
+ expect(page.next).not.toBeNull();
+ expect((await freehire.search({ ...query, keywords: "software engineer", place: "" }, {}, page.next, context)).postings.length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/lib/job-search/sources.ts b/src/lib/job-search/sources.ts
index 037ce461..24d0a429 100644
--- a/src/lib/job-search/sources.ts
+++ b/src/lib/job-search/sources.ts
@@ -75,6 +75,16 @@ export const SOURCE_INFO: Record = {
attributionLink: null,
countries: ["de"],
},
+ FREEHIRE: {
+ name: "FreeHire",
+ description: "IT postings crawled from company career pages (Greenhouse, Lever, Workday and more). No recruiter reposts.",
+ tag: "Most countries · free, no key · no city radius",
+ keyUrl: null,
+ quotaPeriod: null,
+ attribution: "Jobs by FreeHire",
+ attributionLink: { home: "https://freehire.me", local: /^freehire\.me$/ },
+ countries: null,
+ },
};
/** Whether a source lists jobs in a country ("de"). */
diff --git a/src/test/job-search-fixtures.ts b/src/test/job-search-fixtures.ts
index 0c301ce8..10b12cdb 100644
--- a/src/test/job-search-fixtures.ts
+++ b/src/test/job-search-fixtures.ts
@@ -162,3 +162,67 @@ export const arbeitsagenturDetails = {
stellenangebotsBeschreibung: "**Wer wir sind**\r\nHafenlogik baut Software für den Hafen.\r\n\r\n\r\n**Was du machst**\n- APIs bauen\n- Tests schreiben",
referenznummer: "10001-1003486534-S",
};
+
+/** FreeHire's agent search: full plain-text descriptions in the list. */
+export const freehirePage = {
+ data: [
+ {
+ public_slug: "senior-go-engineer-acme-1a2b",
+ source: "greenhouse",
+ title: "Senior Go Engineer",
+ company: "Acme",
+ location: "Remote — EU",
+ url: "https://boards.greenhouse.io/acme/jobs/123",
+ work_mode: "remote",
+ posted_at: "2026-06-18T00:00:00Z",
+ description: "Acme is hiring a Senior Go Engineer.\n\nYou'll build APIs.",
+ enrichment: {
+ salary_min: 90000,
+ salary_max: 130000,
+ salary_currency: "EUR",
+ salary_period: "year",
+ },
+ },
+ {
+ public_slug: "ux-researcher-northwind",
+ source: "lever",
+ title: "UX Researcher",
+ company: "Northwind Studio",
+ location: "Berlin",
+ url: "javascript:alert(1)",
+ work_mode: "onsite",
+ posted_at: "not a date",
+ description: null,
+ enrichment: null,
+ },
+ // Repeated slug: kept once.
+ {
+ public_slug: "senior-go-engineer-acme-1a2b",
+ title: "Duplicate",
+ company: "Acme",
+ url: "https://boards.greenhouse.io/acme/jobs/123",
+ },
+ // Without a slug: skipped.
+ { title: "No slug" },
+ ],
+ meta: { total: 45, limit: 20, offset: 0 },
+};
+
+/** FreeHire's last page: fewer than a full page of results. */
+export const freehireLastPage = {
+ data: [
+ {
+ public_slug: "design-systems-canal",
+ source: "ashby",
+ title: "Design Systems Designer",
+ company: "Canal Software",
+ location: "Amsterdam",
+ url: "https://jobs.ashbyhq.com/canal/abc",
+ work_mode: "hybrid",
+ posted_at: "2026-09-01T12:00:00Z",
+ description: "Canal Software needs a design-systems designer.",
+ enrichment: { salary_min: 52000, salary_max: 52000, salary_currency: "EUR", salary_period: "year" },
+ },
+ ],
+ meta: { total: 21, limit: 20, offset: 20 },
+};