City search (#217)

* wave 1, jobspy only

* combine usa/ca to united states

* strict city location filter

* hide and show based on focus

* UI changes

* allow clicking cross!

* pill animate in

* animate out, uggo fix

* animate out

* framer motion

* animate component height

* adzuna

* hiring cafe implementation

* refactor: centralize shared search-city parsing and matching

* feat: migrate city setting to searchCities with legacy fallback

* docs: update pipeline and extractor city-search wording

* fix(orchestrator): normalize tokenized paste behavior

* fix(shared): tighten city matching semantics

* docs(extractors): document city-location knobs and geocoding note
This commit is contained in:
Shaheer Sarfaraz 2026-02-21 00:42:09 +00:00 committed by GitHub
parent 5ed9069813
commit 19266fe5eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1756 additions and 439 deletions

View File

@ -29,11 +29,17 @@ Adzuna provides stable API discovery for countries that are not covered by UK-on
5. In **Pipeline Run** (Automatic tab), select a compatible country and enable **Adzuna** in Sources.
6. Start the run; Adzuna progress appears in the existing crawl progress stream.
City behavior:
- If **Search cities** are set in Automatic advanced settings, Adzuna runs once per city.
- City runs use strict post-filtering (`job.location` contains requested city) to avoid broad country-level spillover.
Default controls:
- `ADZUNA_APP_ID`
- `ADZUNA_APP_KEY`
- `ADZUNA_MAX_JOBS_PER_TERM` (default `50`)
- `ADZUNA_LOCATION_QUERY` (optional city/location text)
Supported countries in this integration:

View File

@ -32,6 +32,7 @@ It also supports term-by-term search and country-aware search state using the sa
- `searchTerms` drive per-term Hiring Cafe `searchQuery`.
- selected country maps into Hiring Cafe location search state.
- run budget path (`jobspyResultsWanted`) is reused as the max jobs-per-term cap.
- optional **Search cities** narrow results by city.
4. Start the run and watch progress in the pipeline progress card.
Defaults and constraints:
@ -40,6 +41,8 @@ Defaults and constraints:
- `worldwide` and `usa/ca` run in broad mode without a strict country location filter.
- Hiring Cafe is enabled by default in source selection.
- `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` controls recency window when running extractor directly (default `7`).
- When a city is provided via `searchCities`, Hiring Cafe uses city radius search (default `1` mile) and strict city post-filtering.
- City geocoding is resolved through Nominatim (OpenStreetMap data); if you scale extractor traffic, add attribution and cache repeated city lookups.
Local run example:

View File

@ -50,7 +50,7 @@ If values are edited manually, the UI shows **Custom**.
- Adzuna is available only for its supported countries and when App ID/App Key are configured in Settings.
- Glassdoor can be enabled only when:
- selected country supports Glassdoor
- a **Glassdoor city** is set in Advanced settings
- at least one **Search city** is set in Advanced settings
Incompatible sources are disabled with explanatory tooltips.
@ -59,7 +59,7 @@ Incompatible sources are disabled with explanatory tooltips.
- **Resumes tailored** (`topN`)
- **Min suitability score**
- **Max jobs discovered** (run budget cap)
- **Glassdoor city** (required only for Glassdoor)
- **Search cities** (optional multi-city input; required for Glassdoor)
#### Search terms
@ -97,7 +97,7 @@ For accepted input formats, inference behavior, and limits, see [Manual Import E
### Glassdoor cannot be enabled
- Verify selected country supports Glassdoor.
- Set a Glassdoor city in Advanced settings.
- Set at least one Search city in Advanced settings.
### Adzuna is not selectable

View File

@ -9,6 +9,7 @@ for orchestrator ingestion.
- `ADZUNA_APP_KEY` (required)
- `ADZUNA_COUNTRY` (default: `gb`)
- `ADZUNA_SEARCH_TERMS` (JSON array or `|` / comma / newline-delimited)
- `ADZUNA_LOCATION_QUERY` (optional city/location text passed to Adzuna `where`)
- `ADZUNA_MAX_JOBS_PER_TERM` (default: `50`)
- `ADZUNA_RESULTS_PER_PAGE` (default: `50`, max `50`)
- `ADZUNA_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`)

View File

@ -104,6 +104,7 @@ async function fetchJobsPage(args: {
appId: string;
appKey: string;
what: string;
where?: string;
resultsPerPage: number;
}): Promise<AdzunaJob[]> {
const url = new URL(`${API_BASE}/jobs/${args.country}/search/${args.page}`);
@ -112,6 +113,9 @@ async function fetchJobsPage(args: {
if (args.what) {
url.searchParams.set("what", args.what);
}
if (args.where) {
url.searchParams.set("where", args.where);
}
url.searchParams.set("results_per_page", String(args.resultsPerPage));
const response = await fetch(url.toString(), {
@ -146,6 +150,7 @@ async function run(): Promise<void> {
const outputJson =
process.env.ADZUNA_OUTPUT_JSON ||
join(process.cwd(), "storage/datasets/default/jobs.json");
const locationQuery = process.env.ADZUNA_LOCATION_QUERY?.trim() || "";
const jobs: ExtractedJob[] = [];
@ -171,6 +176,7 @@ async function run(): Promise<void> {
appId,
appKey,
what: searchTerm,
where: locationQuery || undefined,
resultsPerPage: take,
});

View File

@ -10,6 +10,8 @@ Special thanks: initial implementation inspiration came from [umur957/hiring-caf
- `HIRING_CAFE_COUNTRY` (default: `united kingdom`)
- `HIRING_CAFE_MAX_JOBS_PER_TERM` (default: `200`)
- `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` (default: `7`)
- `HIRING_CAFE_LOCATION_QUERY` (optional city, e.g. `Leeds`)
- `HIRING_CAFE_LOCATION_RADIUS_MILES` (default: `1` when city is set)
- `HIRING_CAFE_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`)
- `JOBOPS_EMIT_PROGRESS=1` to emit `JOBOPS_PROGRESS` events
- `HIRING_CAFE_HEADLESS=false` to run headed
@ -18,3 +20,4 @@ Special thanks: initial implementation inspiration came from [umur957/hiring-caf
- The extractor uses `s = base64(url-encoded JSON search state)`.
- `worldwide` and `usa/ca` are treated as broad search modes without hard country location filters.
- City geocoding uses [Nominatim](https://nominatim.openstreetmap.org/) (OpenStreetMap data).

View File

@ -20,6 +20,7 @@ const JOBOPS_PROGRESS_PREFIX = "JOBOPS_PROGRESS ";
const DEFAULT_MAX_JOBS_PER_TERM = 200;
const DEFAULT_SEARCH_TERM = "web developer";
const DEFAULT_DATE_FETCHED_PAST_N_DAYS = 30;
const DEFAULT_LOCATION_RADIUS_MILES = 1;
const PAGE_LIMIT = 50;
type RawHiringCafeJob = Record<string, unknown>;
@ -46,6 +47,27 @@ interface BrowserApiResponse {
responseText: string;
}
interface CityLocationContext {
id: string;
city: string;
regionLong: string;
regionShort: string;
countryLong: string;
countryShort: string;
lat: number;
lon: number;
formattedAddress: string;
population: number | null;
radiusMiles: number;
}
interface NominatimResult {
lat?: string;
lon?: string;
display_name?: string;
address?: Record<string, unknown>;
}
function emitProgress(payload: Record<string, unknown>): void {
if (process.env.JOBOPS_EMIT_PROGRESS !== "1") return;
console.log(`${JOBOPS_PROGRESS_PREFIX}${JSON.stringify(payload)}`);
@ -191,6 +213,261 @@ function parseTotalCount(payload: unknown): number | null {
return toNumberOrNull(payloadRecord.total);
}
function buildCityLocationId(input: string): string {
const normalized = input.trim().toLowerCase().replace(/\s+/g, "_");
return `city_${normalized}`.slice(0, 32);
}
function toRegionShortName(value: string): string {
const compact = value
.replace(/[^a-zA-Z\s]/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean);
if (compact.length === 0) return "REG";
if (compact.length === 1) {
return compact[0].slice(0, 3).toUpperCase();
}
return compact
.slice(0, 3)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
async function resolveCityLocationContext(args: {
city: string;
countryLong: string;
countryShort: string;
radiusMiles: number;
}): Promise<CityLocationContext | null> {
const query = `${args.city}, ${args.countryLong}`;
const url = new URL("https://nominatim.openstreetmap.org/search");
url.searchParams.set("q", query);
url.searchParams.set("format", "jsonv2");
url.searchParams.set("addressdetails", "1");
url.searchParams.set("limit", "1");
try {
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "job-ops-hiringcafe-extractor/1.0",
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
throw new Error(`geocode failed (${response.status})`);
}
const payload = (await response.json()) as unknown;
if (!Array.isArray(payload) || payload.length === 0) {
throw new Error("geocode returned no results");
}
const first = payload[0] as NominatimResult;
const lat = Number(first.lat ?? "");
const lon = Number(first.lon ?? "");
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new Error("invalid geocode coordinates");
}
const address = asRecord(first.address);
const regionLong =
toStringOrNull(address?.state) ??
toStringOrNull(address?.county) ??
toStringOrNull(address?.region) ??
args.countryLong;
const displayName =
toStringOrNull(first.display_name) ??
`${args.city}, ${regionLong}, ${args.countryShort}`;
return {
id: buildCityLocationId(args.city),
city: args.city,
regionLong,
regionShort: toRegionShortName(regionLong),
countryLong: args.countryLong,
countryShort: args.countryShort,
lat,
lon,
formattedAddress: displayName,
population: null,
radiusMiles: args.radiusMiles,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`City geocode failed for '${query}': ${message}`);
return null;
}
}
function createCitySearchState(args: {
searchQuery: string;
dateFetchedPastNDays: number;
context: CityLocationContext;
}): Record<string, unknown> {
return {
locations: [
{
id: args.context.id,
types: ["locality"],
address_components: [
{
long_name: args.context.city,
short_name: args.context.city,
types: ["locality"],
},
{
long_name: args.context.regionLong,
short_name: args.context.regionShort,
types: ["administrative_area_level_1"],
},
{
long_name: args.context.countryLong,
short_name: args.context.countryShort,
types: ["country"],
},
],
geometry: {
location: {
lat: args.context.lat,
lon: args.context.lon,
},
},
formatted_address: args.context.formattedAddress,
population: args.context.population,
workplace_types: [],
options: {
radius: args.context.radiusMiles,
radius_unit: "miles",
ignore_radius: false,
},
},
],
workplaceTypes: ["Remote", "Hybrid", "Onsite"],
defaultToUserLocation: true,
userLocation: null,
physicalEnvironments: [
"Office",
"Outdoor",
"Vehicle",
"Industrial",
"Customer-Facing",
],
physicalLaborIntensity: ["Low", "Medium", "High"],
physicalPositions: ["Sitting", "Standing"],
oralCommunicationLevels: ["Low", "Medium", "High"],
computerUsageLevels: ["Low", "Medium", "High"],
cognitiveDemandLevels: ["Low", "Medium", "High"],
currency: { label: "Any", value: null },
frequency: { label: "Any", value: null },
minCompensationLowEnd: null,
minCompensationHighEnd: null,
maxCompensationLowEnd: null,
maxCompensationHighEnd: null,
restrictJobsToTransparentSalaries: false,
calcFrequency: "Yearly",
commitmentTypes: [
"Full Time",
"Part Time",
"Contract",
"Internship",
"Temporary",
"Seasonal",
"Volunteer",
],
jobTitleQuery: "",
jobDescriptionQuery: "",
associatesDegreeFieldsOfStudy: [],
excludedAssociatesDegreeFieldsOfStudy: [],
bachelorsDegreeFieldsOfStudy: [],
excludedBachelorsDegreeFieldsOfStudy: [],
mastersDegreeFieldsOfStudy: [],
excludedMastersDegreeFieldsOfStudy: [],
doctorateDegreeFieldsOfStudy: [],
excludedDoctorateDegreeFieldsOfStudy: [],
associatesDegreeRequirements: [],
bachelorsDegreeRequirements: [],
mastersDegreeRequirements: [],
doctorateDegreeRequirements: [],
licensesAndCertifications: [],
excludedLicensesAndCertifications: [],
excludeAllLicensesAndCertifications: false,
seniorityLevel: [
"No Prior Experience Required",
"Entry Level",
"Mid Level",
"Senior Level",
],
roleTypes: ["Individual Contributor", "People Manager"],
roleYoeRange: [0, 20],
excludeIfRoleYoeIsNotSpecified: false,
managementYoeRange: [0, 20],
excludeIfManagementYoeIsNotSpecified: false,
securityClearances: [
"None",
"Confidential",
"Secret",
"Top Secret",
"Top Secret/SCI",
"Public Trust",
"Interim Clearances",
"Other",
],
languageRequirements: [],
excludedLanguageRequirements: [],
languageRequirementsOperator: "OR",
excludeJobsWithAdditionalLanguageRequirements: false,
airTravelRequirement: ["None", "Minimal", "Moderate", "Extensive"],
landTravelRequirement: ["None", "Minimal", "Moderate", "Extensive"],
morningShiftWork: [],
eveningShiftWork: [],
overnightShiftWork: [],
weekendAvailabilityRequired: "Doesn't Matter",
holidayAvailabilityRequired: "Doesn't Matter",
overtimeRequired: "Doesn't Matter",
onCallRequirements: [
"None",
"Occasional (once a month or less)",
"Regular (once a week or more)",
],
benefitsAndPerks: [],
applicationFormEase: [],
companyNames: [],
excludedCompanyNames: [],
companyHqCountries: [],
excludedCompanyHqCountries: [],
usaGovPref: null,
industries: [],
excludedIndustries: [],
companyKeywords: [],
companyKeywordsBooleanOperator: "OR",
excludedCompanyKeywords: [],
hideJobTypes: [],
encouragedToApply: [],
searchQuery: args.searchQuery,
dateFetchedPastNDays: args.dateFetchedPastNDays,
hiddenCompanies: [],
user: null,
searchModeSelectedCompany: null,
departments: [],
restrictedSearchAttributes: [],
sortBy: "default",
technologyKeywordsQuery: "",
requirementsKeywordsQuery: "",
companyPublicOrPrivate: "all",
latestInvestmentYearRange: [null, null],
latestInvestmentSeries: [],
latestInvestmentAmount: null,
latestInvestmentCurrency: [],
investors: [],
excludedInvestors: [],
isNonProfit: "all",
organizationTypes: [],
excludedOrganizationTypes: [],
companySizeRanges: [],
minYearFounded: null,
maxYearFounded: null,
excludedLatestInvestmentSeries: [],
};
}
async function callHiringCafeApi(
page: Page,
endpoint: string,
@ -267,6 +544,11 @@ async function run(): Promise<void> {
process.env.HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS,
DEFAULT_DATE_FETCHED_PAST_N_DAYS,
);
const locationQuery = process.env.HIRING_CAFE_LOCATION_QUERY?.trim() ?? "";
const locationRadiusMiles = parsePositiveInt(
process.env.HIRING_CAFE_LOCATION_RADIUS_MILES,
DEFAULT_LOCATION_RADIUS_MILES,
);
const outputPath =
process.env.HIRING_CAFE_OUTPUT_JSON ||
join(__dirname, "../storage/datasets/default/jobs.json");
@ -308,6 +590,20 @@ async function run(): Promise<void> {
await initializePage();
}
const countryLocation = resolveHiringCafeCountryLocation(country);
const countryLong =
countryLocation?.address_components[0]?.long_name ?? "United Kingdom";
const countryShort =
countryLocation?.address_components[0]?.short_name ?? "GB";
const cityLocationContext = locationQuery
? await resolveCityLocationContext({
city: locationQuery,
countryLong,
countryShort,
radiusMiles: locationRadiusMiles,
})
: null;
for (let i = 0; i < searchTerms.length; i += 1) {
const searchTerm = searchTerms[i];
const termIndex = i + 1;
@ -319,10 +615,15 @@ async function run(): Promise<void> {
searchTerm,
});
const location = resolveHiringCafeCountryLocation(country);
const searchState = createDefaultSearchState({
const searchState = cityLocationContext
? createCitySearchState({
searchQuery: searchTerm,
location,
dateFetchedPastNDays,
context: cityLocationContext,
})
: createDefaultSearchState({
searchQuery: searchTerm,
location: countryLocation,
dateFetchedPastNDays,
});
const encodedSearchState = encodeSearchState(searchState);

View File

@ -56,6 +56,7 @@
"dotenv": "^17.2.3",
"drizzle-orm": "^0.38.2",
"express": "^4.18.2",
"framer-motion": "^12.34.3",
"get-tsconfig": "^4.10.0",
"html-to-text": "^9.0.5",
"jsdom": "^25.0.1",

View File

@ -6,6 +6,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { OrchestratorPage } from "./OrchestratorPage";
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
import type { FilterTab } from "./orchestrator/constants";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
@ -51,14 +52,15 @@ let mockPipelineTerminalEvent: {
token: number;
} | null = null;
let mockPipelineSources = ["linkedin"] as Array<
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs"
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs" | "adzuna" | "hiringcafe"
>;
let mockAutomaticRunValues = {
let mockAutomaticRunValues: AutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: [],
};
const jobFixture = createJob({
@ -325,13 +327,7 @@ vi.mock("./orchestrator/RunModeModal", () => ({
RunModeModal: ({
onSaveAndRunAutomatic,
}: {
onSaveAndRunAutomatic: (values: {
topN: number;
minSuitabilityScore: number;
searchTerms: string[];
runBudget: number;
country: string;
}) => Promise<void>;
onSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise<void>;
}) => (
<button
type="button"
@ -386,6 +382,7 @@ describe("OrchestratorPage", () => {
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: [],
};
});
@ -701,7 +698,7 @@ describe("OrchestratorPage", () => {
ukvisajobsMaxJobs: 150,
adzunaMaxJobsPerTerm: 150,
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom",
searchCities: "United Kingdom",
});
});
expect(api.runPipeline).toHaveBeenCalledWith({
@ -714,6 +711,108 @@ describe("OrchestratorPage", () => {
setIntervalSpy.mockRestore();
});
it("stores multiple cities for JobSpy sources in automatic mode", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
mockPipelineSources = ["linkedin"];
mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: ["London", "Manchester"],
};
render(
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
searchCities: "London|Manchester",
}),
);
});
});
it("stores multiple cities when only adzuna is selected", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
mockPipelineSources = ["adzuna"];
mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: ["Leeds", "Manchester"],
};
render(
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
searchCities: "Leeds|Manchester",
}),
);
});
});
it("stores multiple cities when only hiringcafe is selected", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
mockPipelineSources = ["hiringcafe"];
mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: ["Leeds", "Manchester"],
};
render(
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
searchCities: "Leeds|Manchester",
}),
);
});
});
it("shows completion toast from hook terminal state", async () => {
mockPipelineTerminalEvent = {
status: "completed",
@ -797,6 +896,7 @@ describe("OrchestratorPage", () => {
searchTerms: ["backend"],
runBudget: 150,
country: "united states",
cityLocations: [],
};
render(

View File

@ -22,7 +22,10 @@ import * as api from "../api";
import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar";
import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog";
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
import {
deriveExtractorLimits,
serializeCityLocationsSetting,
} from "./orchestrator/automatic-run";
import type { FilterTab } from "./orchestrator/constants";
import { tabs } from "./orchestrator/constants";
import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar";
@ -291,9 +294,20 @@ export const OrchestratorPage: React.FC = () => {
searchTerms: values.searchTerms,
sources: compatibleSources,
});
const jobspyLocation = compatibleSources.includes("glassdoor")
? (values.glassdoorLocation ?? "").trim() ||
formatCountryLabel(values.country)
const hasJobSpySite = compatibleSources.some(
(source) =>
source === "indeed" ||
source === "linkedin" ||
source === "glassdoor",
);
const hasAdzuna = compatibleSources.includes("adzuna");
const hasHiringCafe = compatibleSources.includes("hiringcafe");
const serializedCities = serializeCityLocationsSetting(
values.cityLocations,
);
const searchCities =
(hasJobSpySite || hasAdzuna || hasHiringCafe) && serializedCities
? serializedCities
: formatCountryLabel(values.country);
await api.updateSettings({
searchTerms: values.searchTerms,
@ -302,7 +316,7 @@ export const OrchestratorPage: React.FC = () => {
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
jobspyCountryIndeed: values.country,
jobspyLocation,
searchCities,
});
await refreshSettings();
await startPipelineRun({

View File

@ -70,8 +70,8 @@ const baseSettings = createAppSettings({
defaultJobspyResultsWanted: 200,
jobspyCountryIndeed: "UK",
defaultJobspyCountryIndeed: "UK",
jobspyLocation: "UK",
defaultJobspyLocation: "UK",
searchCities: "London",
defaultSearchCities: "London",
searchTerms: ["engineer"],
defaultSearchTerms: ["engineer"],
});

View File

@ -25,7 +25,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "us",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
@ -41,6 +41,29 @@ describe("AutomaticRunTab", () => {
).toBeInTheDocument();
});
it("maps legacy usa/ca country to United States in the picker", () => {
render(
<AutomaticRunTab
open
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "usa/ca",
searchCities: "",
})}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(
screen.getByRole("combobox", { name: "United States" }),
).toBeInTheDocument();
});
it("disables and prunes UK-only sources for non-UK country", async () => {
const onSetPipelineSources = vi.fn();
@ -50,7 +73,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
@ -76,7 +99,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
@ -103,7 +126,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "japan",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]}
@ -134,7 +157,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom",
searchCities: "United Kingdom",
})}
enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]}
@ -152,7 +175,7 @@ describe("AutomaticRunTab", () => {
const glassdoorButton = screen.getByRole("button", { name: "Glassdoor" });
expect(glassdoorButton).toBeDisabled();
expect(glassdoorButton.getAttribute("title")).toContain(
"Set a Glassdoor city in Advanced settings to enable Glassdoor.",
"Add at least one city in Advanced settings to enable Glassdoor.",
);
});
@ -163,7 +186,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer", "frontend engineer"],
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
@ -175,6 +198,7 @@ describe("AutomaticRunTab", () => {
);
const input = screen.getByPlaceholderText("Type and press Enter");
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "Backspace" });
expect(
@ -184,4 +208,34 @@ describe("AutomaticRunTab", () => {
screen.getByRole("button", { name: "Remove frontend engineer" }),
).toBeInTheDocument();
});
it("loads multiple saved cities and keeps glassdoor enabled", () => {
render(
<AutomaticRunTab
open
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united kingdom",
searchCities: "London|Manchester",
})}
enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
fireEvent.focus(screen.getByLabelText("Cities"));
expect(
screen.getByRole("button", { name: "Remove city London" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Remove city Manchester" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled();
});
});

View File

@ -5,7 +5,7 @@ import {
SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support.js";
import type { AppSettings, JobSource } from "@shared/types";
import { Loader2, Sparkles, X } from "lucide-react";
import { Loader2, Sparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import {
@ -33,9 +33,12 @@ import {
type AutomaticRunValues,
calculateAutomaticEstimate,
loadAutomaticRunMemory,
parseCityLocationsInput,
parseCityLocationsSetting,
parseSearchTermsInput,
saveAutomaticRunMemory,
} from "./automatic-run";
import { TokenizedInput } from "./TokenizedInput";
interface AutomaticRunTabProps {
open: boolean;
@ -54,6 +57,7 @@ const DEFAULT_VALUES: AutomaticRunValues = {
searchTerms: ["web developer"],
runBudget: 200,
country: "united kingdom",
cityLocations: [],
};
interface AutomaticRunFormValues {
@ -61,7 +65,8 @@ interface AutomaticRunFormValues {
minSuitabilityScore: string;
runBudget: string;
country: string;
glassdoorLocation: string;
cityLocations: string[];
cityLocationDraft: string;
searchTerms: string[];
searchTermDraft: string;
}
@ -71,8 +76,15 @@ type AutomaticPresetSelection = AutomaticPresetId | "custom";
const GLASSDOOR_COUNTRY_REASON =
"Glassdoor is not available for the selected country.";
const GLASSDOOR_LOCATION_REASON =
"Set a Glassdoor city in Advanced settings to enable Glassdoor.";
"Add at least one city in Advanced settings to enable Glassdoor.";
const UK_ONLY_SOURCES = new Set<JobSource>(["gradcracker", "ukvisajobs"]);
const HIDDEN_COUNTRY_KEYS = new Set(["usa/ca"]);
function normalizeUiCountryKey(value: string): string {
const normalized = normalizeCountryKey(value);
if (normalized === "usa/ca") return "united states";
return normalized;
}
function getSourceDisabledReason(
source: JobSource,
@ -138,25 +150,25 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
}) => {
const [isSaving, setIsSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
{
const { watch, reset, setValue } = useForm<AutomaticRunFormValues>({
defaultValues: {
topN: String(DEFAULT_VALUES.topN),
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
runBudget: String(DEFAULT_VALUES.runBudget),
country: DEFAULT_VALUES.country,
glassdoorLocation: "",
cityLocations: [],
cityLocationDraft: "",
searchTerms: DEFAULT_VALUES.searchTerms,
searchTermDraft: "",
},
},
);
});
const topNInput = watch("topN");
const minScoreInput = watch("minSuitabilityScore");
const runBudgetInput = watch("runBudget");
const countryInput = watch("country");
const glassdoorLocationInput = watch("glassdoorLocation");
const cityLocations = watch("cityLocations");
const cityLocationDraft = watch("cityLocationDraft");
const searchTerms = watch("searchTerms");
const searchTermDraft = watch("searchTermDraft");
@ -173,48 +185,35 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
settings?.gradcrackerMaxJobsPerTerm ??
settings?.ukvisajobsMaxJobs ??
DEFAULT_VALUES.runBudget;
const rememberedCountry = normalizeCountryKey(
const rememberedCountry = normalizeUiCountryKey(
settings?.jobspyCountryIndeed ??
settings?.jobspyLocation ??
settings?.searchCities ??
DEFAULT_VALUES.country,
);
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
const rememberedLocationRaw = settings?.jobspyLocation?.trim() ?? "";
const rememberedLocationNormalized = normalizeCountryKey(
rememberedLocationRaw,
const rememberedLocations = parseCityLocationsSetting(
settings?.searchCities,
).filter(
(location) =>
normalizeCountryKey(location) !==
normalizeCountryKey(rememberedCountryKey),
);
const rememberedGlassdoorLocation =
rememberedLocationRaw &&
rememberedLocationNormalized &&
rememberedLocationNormalized !== normalizeCountryKey(rememberedCountryKey)
? rememberedLocationRaw
: "";
reset({
topN: String(topN),
minSuitabilityScore: String(minSuitabilityScore),
runBudget: String(rememberedRunBudget),
country: rememberedCountry || DEFAULT_VALUES.country,
glassdoorLocation: rememberedGlassdoorLocation,
cityLocations: rememberedLocations,
cityLocationDraft: "",
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
searchTermDraft: "",
});
setAdvancedOpen(false);
}, [open, settings, reset]);
const addSearchTerms = (input: string) => {
const parsed = parseSearchTermsInput(input);
if (parsed.length === 0) return;
const current = getValues("searchTerms");
const next = [...current];
for (const term of parsed) {
if (!next.includes(term)) next.push(term);
}
setValue("searchTerms", next, { shouldDirty: true });
};
const values = useMemo<AutomaticRunValues>(() => {
const normalizedCountry = normalizeCountryKey(countryInput);
const normalizedCountry = normalizeUiCountryKey(countryInput);
return {
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
minSuitabilityScore: toNumber(
@ -225,7 +224,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
),
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
country: normalizedCountry || DEFAULT_VALUES.country,
glassdoorLocation: glassdoorLocationInput.trim() || undefined,
cityLocations,
searchTerms,
};
}, [
@ -233,17 +232,18 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
minScoreInput,
runBudgetInput,
countryInput,
glassdoorLocationInput,
cityLocations,
searchTerms,
]);
const isSourceAvailableForRun = useCallback(
(source: JobSource) => {
if (!isSourceAllowedForCountry(source, values.country)) return false;
if (source === "glassdoor" && !values.glassdoorLocation) return false;
if (source === "glassdoor" && values.cityLocations.length === 0)
return false;
return true;
},
[values.country, values.glassdoorLocation],
[values.country, values.cityLocations.length],
);
const compatibleEnabledSources = useMemo(
@ -319,7 +319,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
const countryOptions = useMemo(
() =>
SUPPORTED_COUNTRY_KEYS.map((country) => ({
SUPPORTED_COUNTRY_KEYS.filter(
(country) => !HIDDEN_COUNTRY_KEYS.has(country),
).map((country) => ({
value: country,
label: formatCountryLabel(country),
})),
@ -389,7 +391,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<Accordion
type="single"
collapsible
value={advancedOpen ? "advanced" : undefined}
value={advancedOpen ? "advanced" : ""}
onValueChange={(value) => setAdvancedOpen(value === "advanced")}
>
<AccordionItem value="advanced" className="border-b-0">
@ -438,21 +440,24 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
/>
</div>
<div className="space-y-2 md:col-span-3">
<Label htmlFor="glassdoor-location">Glassdoor city</Label>
<Input
id="glassdoor-location"
value={glassdoorLocationInput}
onChange={(event) =>
setValue("glassdoorLocation", event.target.value, {
<Label htmlFor="city-locations-input">Cities</Label>
<TokenizedInput
id="city-locations-input"
values={cityLocations}
draft={cityLocationDraft}
parseInput={parseCityLocationsInput}
onDraftChange={(value) =>
setValue("cityLocationDraft", value)
}
onValuesChange={(value) =>
setValue("cityLocations", value, {
shouldDirty: true,
})
}
placeholder='e.g. "London"'
helperText="Optional for all sources, required when Glassdoor is selected."
removeLabelPrefix="Remove city"
/>
<p className="text-xs text-muted-foreground">
Required only for Glassdoor. Use a city (not country) to
keep results localized.
</p>
</div>
</div>
</AccordionContent>
@ -465,58 +470,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<CardHeader className="pb-3">
<CardTitle>Search terms</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Input
<CardContent>
<TokenizedInput
id="search-terms-input"
value={searchTermDraft}
onChange={(event) =>
setValue("searchTermDraft", event.target.value)
values={searchTerms}
draft={searchTermDraft}
parseInput={parseSearchTermsInput}
onDraftChange={(value) => setValue("searchTermDraft", value)}
onValuesChange={(value) =>
setValue("searchTerms", value, { shouldDirty: true })
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
addSearchTerms(searchTermDraft);
setValue("searchTermDraft", "");
return;
}
}}
onBlur={() => {
addSearchTerms(searchTermDraft);
setValue("searchTermDraft", "");
}}
onPaste={(event) => {
const pasted = event.clipboardData.getData("text");
const parsed = parseSearchTermsInput(pasted);
if (parsed.length > 1) {
event.preventDefault();
addSearchTerms(pasted);
}
}}
placeholder="Type and press Enter"
helperText="Add multiple terms by separating with commas or pressing Enter."
removeLabelPrefix="Remove"
/>
<p className="text-xs text-muted-foreground">
Add multiple terms by separating with commas or pressing Enter.
</p>
<div className="flex flex-wrap gap-2">
{searchTerms.map((term) => (
<button
type="button"
key={term}
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/20 px-3 py-1 text-sm transition-all duration-150 hover:border-primary/50 hover:bg-primary/40 hover:text-primary-foreground hover:shadow-sm"
aria-label={`Remove ${term}`}
onClick={() =>
setValue(
"searchTerms",
searchTerms.filter((value) => value !== term),
{ shouldDirty: true },
)
}
>
{term}
<X className="h-3 w-3" />
</button>
))}
</div>
</CardContent>
</Card>

View File

@ -1,7 +1,6 @@
import { AnimatePresence, motion } from "framer-motion";
import type React from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FloatingJobActionsBarProps {
selectedCount: number;
@ -26,32 +25,17 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
onRescoreSelected,
onClear,
}) => {
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (selectedCount > 0) {
setIsMounted(true);
const enterTimer = window.setTimeout(() => setIsVisible(true), 10);
return () => window.clearTimeout(enterTimer);
}
setIsVisible(false);
const exitTimer = window.setTimeout(() => setIsMounted(false), 180);
return () => window.clearTimeout(exitTimer);
}, [selectedCount]);
if (!isMounted) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4">
<div
className={cn(
"pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center",
"transition-all duration-200 ease-out",
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
)}
<AnimatePresence initial={false}>
{selectedCount > 0 ? (
<motion.div
className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.18, ease: "easeOut" }}
>
<div className="pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center">
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
{selectedCount} selected
</div>
@ -104,6 +88,8 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
</Button>
</div>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
};

View File

@ -0,0 +1,85 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { parseCityLocationsInput } from "./automatic-run";
import { TokenizedInput } from "./TokenizedInput";
function buildClipboardData(text: string): DataTransfer {
return {
getData: (type: string) => (type === "text" ? text : ""),
} as DataTransfer;
}
function renderCityInput() {
let values: string[] = [];
let draft = "";
const setValues = (next: string[]) => {
values = next;
rerenderInput();
};
const setDraft = (next: string) => {
draft = next;
rerenderInput();
};
const renderInput = () => (
<TokenizedInput
id="cities"
values={values}
draft={draft}
parseInput={parseCityLocationsInput}
onDraftChange={setDraft}
onValuesChange={setValues}
placeholder='e.g. "London"'
helperText="City helper"
removeLabelPrefix="Remove city"
/>
);
const { rerender } = render(renderInput());
const rerenderInput = () => {
rerender(renderInput());
};
return {
getInput: () =>
screen.getByPlaceholderText('e.g. "London"') as HTMLInputElement,
};
}
describe("TokenizedInput", () => {
it("tokenizes single-value paste and clears draft", () => {
const { getInput } = renderCityInput();
const input = getInput();
fireEvent.change(input, { target: { value: "foo" } });
fireEvent.paste(input, {
clipboardData: buildClipboardData("Leeds"),
});
expect(input.value).toBe("");
expect(screen.getByText("Currently selected: Leeds")).toBeInTheDocument();
});
it("tokenizes multi-value paste and removes duplicates", () => {
const { getInput } = renderCityInput();
const input = getInput();
fireEvent.paste(input, {
clipboardData: buildClipboardData("Leeds, London, leeds"),
});
fireEvent.focus(input);
expect(input.value).toBe("");
expect(
screen.getByRole("button", { name: "Remove city Leeds" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Remove city London" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Remove city leeds" }),
).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,186 @@
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import type React from "react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface TokenizedInputProps {
id: string;
values: string[];
draft: string;
parseInput: (input: string) => string[];
onDraftChange: (value: string) => void;
onValuesChange: (values: string[]) => void;
placeholder: string;
helperText: string;
removeLabelPrefix: string;
collapsedTextLimit?: number;
}
function mergeUnique(values: string[], nextValues: string[]): string[] {
const seen = new Set(values.map((value) => value.toLowerCase()));
const out = [...values];
for (const value of nextValues) {
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
}
return out;
}
export const TokenizedInput: React.FC<TokenizedInputProps> = ({
id,
values,
draft,
parseInput,
onDraftChange,
onValuesChange,
placeholder,
helperText,
removeLabelPrefix,
collapsedTextLimit = 3,
}) => {
const [isFocused, setIsFocused] = useState(false);
const tokensRef = useRef<HTMLDivElement | null>(null);
const summaryRef = useRef<HTMLParagraphElement | null>(null);
const [tokensHeight, setTokensHeight] = useState(20);
const [summaryHeight, setSummaryHeight] = useState(20);
const updateHeights = useCallback(() => {
if (tokensRef.current) {
setTokensHeight(Math.max(20, tokensRef.current.scrollHeight));
}
if (summaryRef.current) {
setSummaryHeight(Math.max(20, summaryRef.current.scrollHeight));
}
}, []);
const collapsedSummary = useMemo(() => {
if (values.length === 0) return "";
const visibleCount = Math.max(0, Math.floor(collapsedTextLimit));
if (visibleCount === 0) return `and ${values.length} more`;
const visibleValues = values.slice(0, visibleCount);
const hiddenCount = values.length - visibleValues.length;
if (hiddenCount <= 0) return visibleValues.join(", ");
return `${visibleValues.join(", ")} and ${hiddenCount} more`;
}, [collapsedTextLimit, values]);
const addValues = (input: string) => {
const parsed = parseInput(input);
if (parsed.length === 0) return;
onValuesChange(mergeUnique(values, parsed));
};
useLayoutEffect(() => {
updateHeights();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(updateHeights);
if (tokensRef.current) observer.observe(tokensRef.current);
if (summaryRef.current) observer.observe(summaryRef.current);
return () => observer.disconnect();
}, [updateHeights]);
useLayoutEffect(() => {
updateHeights();
});
return (
<div className="space-y-3">
<Input
id={id}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onFocus={() => setIsFocused(true)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
addValues(draft);
onDraftChange("");
return;
}
}}
onBlur={() => {
setIsFocused(false);
addValues(draft);
onDraftChange("");
}}
onPaste={(event) => {
const pasted = event.clipboardData.getData("text");
const parsed = parseInput(pasted);
if (parsed.length > 0) {
event.preventDefault();
addValues(pasted);
onDraftChange("");
}
}}
placeholder={placeholder}
/>
<p className="text-xs text-muted-foreground">{helperText}</p>
{values.length > 0 ? (
<motion.div
className="relative overflow-hidden"
animate={{ height: isFocused ? tokensHeight : summaryHeight }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<motion.div
aria-hidden={!isFocused}
ref={tokensRef}
className="absolute inset-x-0 top-0 flex flex-wrap gap-2"
animate={{
opacity: isFocused ? 1 : 0,
y: isFocused ? 0 : -4,
}}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ pointerEvents: isFocused ? "auto" : "none" }}
>
<AnimatePresence initial={false} mode="popLayout">
{values.map((value) => (
<motion.div
key={value}
layout
initial={{ opacity: 0, scale: 0.96, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: -4 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<Button
type="button"
variant="outline"
className="h-auto rounded-full px-2 py-1 text-xs text-muted-foreground"
aria-label={`${removeLabelPrefix} ${value}`}
onPointerDown={(event) => event.preventDefault()}
onClick={() =>
onValuesChange(
values.filter((existing) => existing !== value),
)
}
>
{value}
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</AnimatePresence>
</motion.div>
<motion.p
aria-hidden={isFocused}
ref={summaryRef}
className="absolute inset-x-0 top-0 text-xs text-muted-foreground"
animate={{
opacity: isFocused ? 0 : 1,
y: isFocused ? 4 : 0,
}}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ pointerEvents: isFocused ? "none" : "auto" }}
>
Currently selected: {collapsedSummary}
</motion.p>
</motion.div>
) : null}
</div>
);
};

View File

@ -27,6 +27,7 @@ describe("automatic-run utilities", () => {
searchTerms: ["backend", "platform"],
runBudget: 100,
country: "united kingdom",
cityLocations: [],
},
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
});
@ -59,6 +60,7 @@ describe("automatic-run utilities", () => {
searchTerms: [],
runBudget: 750,
country: "united kingdom",
cityLocations: [],
},
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
});
@ -85,6 +87,7 @@ describe("automatic-run utilities", () => {
searchTerms: ["backend", "platform"],
runBudget: 120,
country: "united kingdom",
cityLocations: [],
},
sources: ["adzuna"],
});
@ -101,6 +104,7 @@ describe("automatic-run utilities", () => {
searchTerms: ["backend", "platform"],
runBudget: 120,
country: "united kingdom",
cityLocations: [],
},
sources: ["hiringcafe"],
});

View File

@ -1,3 +1,7 @@
import {
parseSearchCitiesSetting,
serializeSearchCitiesSetting,
} from "@shared/search-cities.js";
import type { JobSource } from "@shared/types";
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
@ -8,7 +12,7 @@ export interface AutomaticRunValues {
searchTerms: string[];
runBudget: number;
country: string;
glassdoorLocation?: string;
cityLocations: string[];
}
export interface AutomaticPresetValues {
@ -115,6 +119,29 @@ export function parseSearchTermsInput(input: string): string[] {
.filter(Boolean);
}
export function parseCityLocationsInput(input: string): string[] {
const parsed = parseSearchTermsInput(input);
const seen = new Set<string>();
const out: string[] = [];
for (const city of parsed) {
const key = city.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(city);
}
return out;
}
export function parseCityLocationsSetting(
location: string | null | undefined,
): string[] {
return parseSearchCitiesSetting(location);
}
export function serializeCityLocationsSetting(cities: string[]): string | null {
return serializeSearchCitiesSetting(cities);
}
export function stringifySearchTerms(terms: string[]): string {
return terms.join("\n");
}

View File

@ -24,7 +24,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
backupEnabled: "0",
backupHour: "2",
backupMaxCount: "5",
jobspyLocation: "United States",
searchCities: "United States",
jobspyResultsWanted: "25",
jobspyCountryIndeed: "US",
resumeProjects: JSON.stringify({

View File

@ -116,6 +116,35 @@ describe("discoverJobsStep", () => {
);
});
it("passes serialized multi-city locations to JobSpy", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united kingdom",
searchCities: "London|Manchester",
} as any);
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
success: true,
jobs: [],
} as any);
await discoverJobsStep({
mergedConfig: {
...config,
sources: ["linkedin"],
},
});
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
expect.objectContaining({
location: "London|Manchester",
}),
);
});
it("filters out glassdoor for unsupported countries", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
@ -201,6 +230,37 @@ describe("discoverJobsStep", () => {
);
});
it("passes configured city locations to adzuna", async () => {
const settingsRepo = await import("../../repositories/settings");
const adzuna = await import("../../services/adzuna");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united kingdom",
searchCities: "Leeds|Manchester",
} as any);
vi.mocked(adzuna.runAdzuna).mockResolvedValue({
success: true,
jobs: [],
} as any);
await discoverJobsStep({
mergedConfig: {
...config,
sources: ["adzuna"],
},
});
expect(vi.mocked(adzuna.runAdzuna)).toHaveBeenCalledWith(
expect.objectContaining({
country: "gb",
countryKey: "united kingdom",
locations: ["Leeds", "Manchester"],
}),
);
});
it("skips adzuna for unsupported countries", async () => {
const settingsRepo = await import("../../repositories/settings");
const adzuna = await import("../../services/adzuna");
@ -257,12 +317,46 @@ describe("discoverJobsStep", () => {
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
expect.objectContaining({
country: "united states",
countryKey: "united states",
locations: [],
searchTerms: ["engineer"],
maxJobsPerTerm: 25,
}),
);
});
it("passes configured city locations to hiringcafe", async () => {
const settingsRepo = await import("../../repositories/settings");
const hiringCafe = await import("../../services/hiring-cafe");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united kingdom",
jobspyResultsWanted: "25",
searchCities: "Leeds|Manchester",
} as any);
vi.mocked(hiringCafe.runHiringCafe).mockResolvedValue({
success: true,
jobs: [],
} as any);
await discoverJobsStep({
mergedConfig: {
...config,
sources: ["hiringcafe"],
},
});
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
expect.objectContaining({
country: "united kingdom",
countryKey: "united kingdom",
locations: ["Leeds", "Manchester"],
}),
);
});
it("updates Hiring Cafe terms and pages via progress callbacks", async () => {
const settingsRepo = await import("../../repositories/settings");
const hiringCafe = await import("../../services/hiring-cafe");

View File

@ -5,6 +5,7 @@ import {
isSourceAllowedForCountry,
normalizeCountryKey,
} from "@shared/location-support.js";
import { parseSearchCitiesSetting } from "@shared/search-cities.js";
import type { CreateJobInput, PipelineConfig } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings";
@ -59,7 +60,10 @@ export async function discoverJobsStep(args: {
}
const selectedCountry = normalizeCountryKey(
settings.jobspyCountryIndeed ?? settings.jobspyLocation ?? "united kingdom",
settings.jobspyCountryIndeed ??
settings.searchCities ??
settings.jobspyLocation ??
"united kingdom",
);
const compatibleSources = args.mergedConfig.sources.filter((source) =>
isSourceAllowedForCountry(source, selectedCountry),
@ -100,7 +104,8 @@ export async function discoverJobsStep(args: {
const jobSpyResult = await runJobSpy({
sites: jobSpySites,
searchTerms,
location: settings.jobspyLocation ?? undefined,
location:
settings.searchCities ?? settings.jobspyLocation ?? undefined,
resultsWanted: settings.jobspyResultsWanted
? parseInt(settings.jobspyResultsWanted, 10)
: undefined,
@ -172,6 +177,10 @@ export async function discoverJobsStep(args: {
const adzunaResult = await runAdzuna({
country: adzunaCountryCode,
countryKey: selectedCountry,
locations: parseSearchCitiesSetting(
settings.searchCities ?? settings.jobspyLocation,
),
searchTerms,
maxJobsPerTerm: adzunaMaxJobsPerTerm,
onProgress: (event) => {
@ -249,6 +258,10 @@ export async function discoverJobsStep(args: {
const hiringCafeResult = await runHiringCafe({
country: selectedCountry,
countryKey: selectedCountry,
locations: parseSearchCitiesSetting(
settings.searchCities ?? settings.jobspyLocation,
),
searchTerms,
maxJobsPerTerm: hiringCafeMaxJobsPerTerm,
onProgress: (event) => {

View File

@ -23,6 +23,7 @@ export type SettingKey =
| "adzunaMaxJobsPerTerm"
| "gradcrackerMaxJobsPerTerm"
| "searchTerms"
| "searchCities"
| "jobspyLocation"
| "jobspyResultsWanted"
| "jobspyCountryIndeed"

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
matchesRequestedLocation,
shouldApplyStrictLocationFilter,
} from "./adzuna";
describe("adzuna strict location filtering", () => {
it("enables strict filtering when city differs from country", () => {
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
true,
);
});
it("disables strict filtering when location is country-level", () => {
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
});
it("matches requested location by case-insensitive contains", () => {
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
false,
);
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
});
});

View File

@ -5,6 +5,12 @@ import { dirname, join } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { logger } from "@infra/logger";
import { normalizeCountryKey } from "@shared/location-support.js";
import {
matchesRequestedCity,
parseSearchCitiesSetting,
shouldApplyStrictCityFilter,
} from "@shared/search-cities.js";
import type { CreateJobInput } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
@ -44,6 +50,8 @@ export type AdzunaProgressEvent =
export interface RunAdzunaOptions {
searchTerms?: string[];
country?: string;
countryKey?: string;
locations?: string[];
maxJobsPerTerm?: number;
onProgress?: (event: AdzunaProgressEvent) => void;
}
@ -54,6 +62,27 @@ export interface AdzunaResult {
error?: string;
}
export function shouldApplyStrictLocationFilter(
location: string,
countryKey: string,
): boolean {
return shouldApplyStrictCityFilter(location, countryKey);
}
export function matchesRequestedLocation(
jobLocation: string | undefined,
requestedLocation: string,
): boolean {
return matchesRequestedCity(jobLocation, requestedLocation);
}
function resolveLocations(options: RunAdzunaOptions): string[] {
const raw = options.locations?.length
? options.locations
: parseSearchCitiesSetting(process.env.ADZUNA_LOCATION_QUERY ?? "");
return raw.map((value) => value.trim()).filter(Boolean);
}
function resolveTsxCliPath(): string | null {
try {
return require.resolve("tsx/dist/cli.mjs");
@ -170,11 +199,15 @@ export async function runAdzuna(
}
const country = (options.country || "gb").trim().toLowerCase();
const countryKey = normalizeCountryKey(options.countryKey ?? "");
const maxJobsPerTerm = options.maxJobsPerTerm ?? 50;
const searchTerms =
options.searchTerms && options.searchTerms.length > 0
? options.searchTerms
: ["web developer"];
const locations = resolveLocations(options);
const runLocations = locations.length > 0 ? locations : [null];
const termTotal = searchTerms.length * runLocations.length;
const useNpmCommand = canRunNpmCommand();
if (!useNpmCommand && !TSX_CLI_PATH) {
return {
@ -185,6 +218,15 @@ export async function runAdzuna(
}
try {
const jobs: CreateJobInput[] = [];
const seen = new Set<string>();
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
const location = runLocations[runIndex];
const strictLocationFilter =
location !== null &&
shouldApplyStrictLocationFilter(location, countryKey);
await new Promise<void>((resolve, reject) => {
const extractorEnv = {
...process.env,
@ -195,6 +237,7 @@ export async function runAdzuna(
ADZUNA_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
ADZUNA_SEARCH_TERMS: JSON.stringify(searchTerms),
ADZUNA_OUTPUT_JSON: DATASET_PATH,
ADZUNA_LOCATION_QUERY: strictLocationFilter ? location : "",
};
const child = useNpmCommand
? spawn("npm", ["run", "start"], {
@ -219,7 +262,12 @@ export async function runAdzuna(
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
const progressEvent = parseAdzunaProgressLine(line);
if (progressEvent) {
options.onProgress?.(progressEvent);
const termOffset = runIndex * searchTerms.length;
options.onProgress?.({
...progressEvent,
termIndex: termOffset + progressEvent.termIndex,
termTotal,
});
return;
}
stream.write(`${line}\n`);
@ -244,7 +292,21 @@ export async function runAdzuna(
child.on("error", reject);
});
const jobs = await readDataset();
const runJobs = await readDataset();
const filtered = strictLocationFilter
? runJobs.filter((job) =>
matchesRequestedLocation(job.location, location),
)
: runJobs;
for (const job of filtered) {
const key = job.sourceJobId || job.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
jobs.push(job);
}
}
return { success: true, jobs };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
matchesRequestedLocation,
shouldApplyStrictLocationFilter,
} from "./hiring-cafe";
describe("hiringcafe strict location filtering", () => {
it("enables strict filtering when city differs from country", () => {
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
true,
);
});
it("disables strict filtering when location is country-level", () => {
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
});
it("matches requested location by case-insensitive contains", () => {
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
false,
);
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
});
});

View File

@ -6,6 +6,12 @@ import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize";
import { normalizeCountryKey } from "@shared/location-support.js";
import {
matchesRequestedCity,
parseSearchCitiesSetting,
shouldApplyStrictCityFilter,
} from "@shared/search-cities.js";
import type { CreateJobInput } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
@ -50,6 +56,9 @@ export type HiringCafeProgressEvent =
export interface RunHiringCafeOptions {
searchTerms?: string[];
country?: string;
countryKey?: string;
locations?: string[];
locationRadiusMiles?: number;
maxJobsPerTerm?: number;
onProgress?: (event: HiringCafeProgressEvent) => void;
}
@ -60,6 +69,27 @@ export interface HiringCafeResult {
error?: string;
}
export function shouldApplyStrictLocationFilter(
location: string,
countryKey: string,
): boolean {
return shouldApplyStrictCityFilter(location, countryKey);
}
export function matchesRequestedLocation(
jobLocation: string | undefined,
requestedLocation: string,
): boolean {
return matchesRequestedCity(jobLocation, requestedLocation);
}
function resolveLocations(options: RunHiringCafeOptions): string[] {
const raw = options.locations?.length
? options.locations
: parseSearchCitiesSetting(process.env.HIRING_CAFE_LOCATION_QUERY ?? "");
return raw.map((value) => value.trim()).filter(Boolean);
}
function resolveTsxCliPath(): string | null {
try {
return require.resolve("tsx/dist/cli.mjs");
@ -182,7 +212,15 @@ export async function runHiringCafe(
? options.searchTerms
: ["web developer"];
const country = (options.country || "united kingdom").trim().toLowerCase();
const countryKey = normalizeCountryKey(options.countryKey ?? "");
const maxJobsPerTerm = options.maxJobsPerTerm ?? 200;
const locationRadiusMiles = Math.max(
1,
Math.floor(options.locationRadiusMiles ?? 1),
);
const locations = resolveLocations(options);
const runLocations = locations.length > 0 ? locations : [null];
const termTotal = searchTerms.length * runLocations.length;
const useNpmCommand = canRunNpmCommand();
if (!useNpmCommand && !TSX_CLI_PATH) {
@ -194,6 +232,15 @@ export async function runHiringCafe(
}
try {
const jobs: CreateJobInput[] = [];
const seen = new Set<string>();
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
const location = runLocations[runIndex];
const strictLocationFilter =
location !== null &&
shouldApplyStrictLocationFilter(location, countryKey);
await clearStorageDataset();
await new Promise<void>((resolve, reject) => {
@ -204,6 +251,10 @@ export async function runHiringCafe(
HIRING_CAFE_COUNTRY: country,
HIRING_CAFE_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
HIRING_CAFE_OUTPUT_JSON: DATASET_PATH,
HIRING_CAFE_LOCATION_QUERY: strictLocationFilter ? location : "",
HIRING_CAFE_LOCATION_RADIUS_MILES: strictLocationFilter
? String(locationRadiusMiles)
: "",
};
const child = useNpmCommand
@ -230,7 +281,12 @@ export async function runHiringCafe(
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
const progressEvent = parseProgressLine(line);
if (progressEvent) {
options.onProgress?.(progressEvent);
const termOffset = runIndex * searchTerms.length;
options.onProgress?.({
...progressEvent,
termIndex: termOffset + progressEvent.termIndex,
termTotal,
});
return;
}
@ -257,7 +313,21 @@ export async function runHiringCafe(
child.on("error", reject);
});
const jobs = await readDataset();
const runJobs = await readDataset();
const filtered = strictLocationFilter
? runJobs.filter((job) =>
matchesRequestedLocation(job.location, location),
)
: runJobs;
for (const job of filtered) {
const key = job.sourceJobId || job.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
jobs.push(job);
}
}
return { success: true, jobs };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { parseJobSpyProgressLine } from "./jobspy";
import {
matchesRequestedLocation,
parseJobSpyProgressLine,
shouldApplyStrictLocationFilter,
} from "./jobspy";
describe("parseJobSpyProgressLine", () => {
it("parses term_start progress lines", () => {
@ -38,3 +42,24 @@ describe("parseJobSpyProgressLine", () => {
expect(parseJobSpyProgressLine("Found 20 jobs")).toBeNull();
});
});
describe("strict location filtering", () => {
it("enables strict filtering when location differs from country", () => {
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
true,
);
});
it("disables strict filtering when location is country-level", () => {
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
});
it("matches location using case-insensitive contains checks", () => {
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
false,
);
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
});
});

View File

@ -9,6 +9,11 @@ import { mkdir, readFile, unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import {
matchesRequestedCity,
parseSearchCitiesSetting,
shouldApplyStrictCityFilter,
} from "@shared/search-cities.js";
import type { CreateJobInput, JobSource } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
import { getDataDir } from "../config/dataDir";
@ -144,6 +149,7 @@ export interface RunJobSpyOptions {
sites?: Array<JobSource>;
searchTerms?: string[];
location?: string;
locations?: string[];
resultsWanted?: number;
hoursOld?: number;
countryIndeed?: string;
@ -158,6 +164,20 @@ export interface JobSpyResult {
error?: string;
}
export function shouldApplyStrictLocationFilter(
location: string,
countryIndeed: string,
): boolean {
return shouldApplyStrictCityFilter(location, countryIndeed);
}
export function matchesRequestedLocation(
jobLocation: string | undefined,
requestedLocation: string,
): boolean {
return matchesRequestedCity(jobLocation, requestedLocation);
}
export async function runJobSpy(
options: RunJobSpyOptions = {},
): Promise<JobSpyResult> {
@ -170,6 +190,9 @@ export async function runJobSpy(
.join(",");
const searchTerms = resolveSearchTerms(options);
const locations = resolveLocations(options);
const countryIndeed =
options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? "UK";
if (searchTerms.length === 0) {
return { success: true, jobs: [] };
}
@ -178,9 +201,13 @@ export async function runJobSpy(
const jobs: CreateJobInput[] = [];
const seenJobUrls = new Set<string>();
for (let i = 0; i < searchTerms.length; i++) {
const searchTerm = searchTerms[i];
const suffix = `${i + 1}_${slugForFilename(searchTerm)}`;
const totalRuns = searchTerms.length * locations.length;
let runIndex = 0;
for (const searchTerm of searchTerms) {
for (const location of locations) {
runIndex += 1;
const suffix = `${runIndex}_${slugForFilename(searchTerm)}_${slugForFilename(location)}`;
const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
@ -194,20 +221,18 @@ export async function runJobSpy(
...process.env,
JOBSPY_SITES: sites || "indeed,linkedin,glassdoor",
JOBSPY_SEARCH_TERM: searchTerm,
JOBSPY_TERM_INDEX: String(i + 1),
JOBSPY_TERM_TOTAL: String(searchTerms.length),
JOBSPY_LOCATION:
options.location ?? process.env.JOBSPY_LOCATION ?? "UK",
JOBSPY_TERM_INDEX: String(runIndex),
JOBSPY_TERM_TOTAL: String(totalRuns),
JOBSPY_LOCATION: location,
JOBSPY_RESULTS_WANTED: String(
options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200,
options.resultsWanted ??
process.env.JOBSPY_RESULTS_WANTED ??
200,
),
JOBSPY_HOURS_OLD: String(
options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72,
),
JOBSPY_COUNTRY_INDEED:
options.countryIndeed ??
process.env.JOBSPY_COUNTRY_INDEED ??
"UK",
JOBSPY_COUNTRY_INDEED: countryIndeed,
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
options.linkedinFetchDescription ??
process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ??
@ -252,8 +277,17 @@ export async function runJobSpy(
const raw = await readFile(outputJson, "utf-8");
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
const mapped = mapJobSpyRows(parsed);
const strictLocationFilter = shouldApplyStrictLocationFilter(
location,
countryIndeed,
);
const filtered = strictLocationFilter
? mapped.filter((job) =>
matchesRequestedLocation(job.location, location),
)
: mapped;
for (const job of mapped) {
for (const job of filtered) {
const url = job.jobUrl;
if (seenJobUrls.has(url)) continue;
seenJobUrls.add(url);
@ -267,6 +301,7 @@ export async function runJobSpy(
// Ignore cleanup errors
}
}
}
return { success: true, jobs };
} catch (error) {
@ -275,6 +310,16 @@ export async function runJobSpy(
}
}
function resolveLocations(options: RunJobSpyOptions): string[] {
const fromOptions = options.locations?.length ? options.locations : null;
const fromSingle = options.location?.trim();
const fromEnv = process.env.JOBSPY_LOCATION?.trim();
const raw =
fromOptions ?? parseSearchCitiesSetting(fromSingle ?? fromEnv ?? "UK");
const out = raw.map((value) => value.trim()).filter(Boolean);
return out.length > 0 ? out : ["UK"];
}
function resolveSearchTerms(options: RunJobSpyOptions): string[] {
const fromOptions = options.searchTerms?.length ? options.searchTerms : null;
const fromEnv = parseSearchTermsEnv(process.env.JOBSPY_SEARCH_TERMS);

View File

@ -66,7 +66,7 @@ describe("settings-conversion", () => {
it("uses string defaults when override is empty", () => {
process.env.JOBSPY_LOCATION = "Remote";
const resolved = resolveSettingValue("jobspyLocation", "");
const resolved = resolveSettingValue("searchCities", "");
expect(resolved.defaultValue).toBe("Remote");
expect(resolved.overrideValue).toBe("");
expect(resolved.value).toBe("Remote");

View File

@ -10,7 +10,7 @@ type SettingsConversionValueMap = {
adzunaMaxJobsPerTerm: number;
gradcrackerMaxJobsPerTerm: number;
searchTerms: string[];
jobspyLocation: string;
searchCities: string;
jobspyResultsWanted: number;
jobspyCountryIndeed: string;
showSponsorInfo: boolean;
@ -124,8 +124,9 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
serialize: serializeNullableJsonArray,
resolve: resolveWithNullishFallback,
},
jobspyLocation: {
defaultValue: () => process.env.JOBSPY_LOCATION || "UK",
searchCities: {
defaultValue: () =>
process.env.SEARCH_CITIES || process.env.JOBSPY_LOCATION || "UK",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,

View File

@ -177,8 +177,12 @@ export const settingsUpdateRegistry: Partial<{
searchTerms: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("searchTerms", value)] }),
),
searchCities: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("searchCities", value)] }),
),
// Deprecated legacy key; persist into canonical searchCities setting.
jobspyLocation: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("jobspyLocation", value)] }),
result({ actions: [metadataPersistAction("searchCities", value)] }),
),
jobspyResultsWanted: singleAction(({ value }) =>
result({

View File

@ -123,13 +123,13 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const overrideSearchTerms = searchTermsSetting.overrideValue;
const searchTerms = searchTermsSetting.value;
const jobspyLocationSetting = resolveSettingValue(
"jobspyLocation",
overrides.jobspyLocation,
const searchCitiesSetting = resolveSettingValue(
"searchCities",
overrides.searchCities ?? overrides.jobspyLocation,
);
const defaultJobspyLocation = jobspyLocationSetting.defaultValue;
const overrideJobspyLocation = jobspyLocationSetting.overrideValue;
const jobspyLocation = jobspyLocationSetting.value;
const defaultSearchCities = searchCitiesSetting.defaultValue;
const overrideSearchCities = searchCitiesSetting.overrideValue;
const searchCities = searchCitiesSetting.value;
const jobspyResultsWantedSetting = resolveSettingValue(
"jobspyResultsWanted",
@ -278,9 +278,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
searchTerms,
defaultSearchTerms,
overrideSearchTerms,
jobspyLocation,
defaultJobspyLocation,
overrideJobspyLocation,
searchCities,
defaultSearchCities,
overrideSearchCities,
jobspyResultsWanted,
defaultJobspyResultsWanted,
overrideJobspyResultsWanted,

101
package-lock.json generated
View File

@ -44,6 +44,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -348,6 +349,7 @@
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz",
"integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.48.1",
"@algolia/requester-browser-xhr": "5.48.1",
@ -548,6 +550,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -2801,6 +2804,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -2823,6 +2827,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -2932,6 +2937,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -3353,6 +3359,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -4342,6 +4349,7 @@
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz",
"integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@docusaurus/core": "3.9.2",
"@docusaurus/logger": "3.9.2",
@ -5915,6 +5923,7 @@
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
"integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/mdx": "^2.0.0"
},
@ -7816,6 +7825,7 @@
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@ -8268,6 +8278,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -8301,6 +8312,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -8312,6 +8324,7 @@
"integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react": "*"
}
@ -8648,6 +8661,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8737,6 +8751,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -8782,6 +8797,7 @@
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz",
"integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.14.1",
"@algolia/client-abtesting": "5.48.1",
@ -9434,6 +9450,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -10588,6 +10605,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -12153,6 +12171,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -12515,6 +12534,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.34.3",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -17745,6 +17791,21 @@
"npm": ">=6"
}
},
"node_modules/motion-dom": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@ -17949,6 +18010,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -18757,6 +18819,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -18810,6 +18873,7 @@
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright-core": "1.58.1"
},
@ -18828,6 +18892,7 @@
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"playwright-core": "cli.js"
},
@ -18868,6 +18933,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -19780,6 +19846,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -20663,6 +20730,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -20675,6 +20743,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -20731,6 +20800,7 @@
"resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react": "*"
},
@ -20806,6 +20876,7 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@ -22737,6 +22808,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@ -23019,13 +23091,15 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@ -23110,6 +23184,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -23615,6 +23690,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -23884,6 +23960,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz",
"integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@ -24682,7 +24759,6 @@
"name": "job-ops-orchestrator",
"version": "1.0.0",
"dependencies": {
"@biomejs/cli-linux-x64": "2.3.12",
"@hookform/resolvers": "^5.2.2",
"@paralleldrive/cuid2": "^3.0.6",
"@radix-ui/react-accordion": "^1.2.12",
@ -24713,6 +24789,7 @@
"dotenv": "^17.2.3",
"drizzle-orm": "^0.38.2",
"express": "^4.18.2",
"framer-motion": "^12.34.3",
"get-tsconfig": "^4.10.0",
"html-to-text": "^9.0.5",
"jsdom": "^25.0.1",
@ -25984,8 +26061,7 @@
"orchestrator/node_modules/@types/aria-query": {
"version": "5.0.4",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"orchestrator/node_modules/@types/babel__core": {
"version": "7.20.5",
@ -26028,6 +26104,7 @@
"version": "7.6.13",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@ -26269,6 +26346,7 @@
"version": "11.10.0",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@ -26462,8 +26540,7 @@
"orchestrator/node_modules/dom-accessibility-api": {
"version": "0.5.16",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"orchestrator/node_modules/dom-helpers": {
"version": "5.2.1",
@ -27168,6 +27245,7 @@
"orchestrator/node_modules/jsdom": {
"version": "25.0.1",
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
@ -27279,7 +27357,6 @@
"version": "1.5.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@ -27325,7 +27402,6 @@
"version": "27.5.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@ -27339,7 +27415,6 @@
"version": "5.2.0",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@ -27350,6 +27425,7 @@
"orchestrator/node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -27364,8 +27440,7 @@
"orchestrator/node_modules/react-is": {
"version": "17.0.2",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"orchestrator/node_modules/react-markdown": {
"version": "10.1.0",
@ -27620,7 +27695,8 @@
},
"orchestrator/node_modules/tailwindcss": {
"version": "4.1.18",
"license": "MIT"
"license": "MIT",
"peer": true
},
"orchestrator/node_modules/tailwindcss-animate": {
"version": "1.0.7",
@ -27743,6 +27819,7 @@
"orchestrator/node_modules/vite": {
"version": "6.4.1",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
matchesRequestedCity,
parseSearchCitiesSetting,
serializeSearchCitiesSetting,
shouldApplyStrictCityFilter,
} from "./search-cities";
describe("search-cities", () => {
it("parses and deduplicates search cities", () => {
expect(parseSearchCitiesSetting("Leeds|london|Leeds")).toEqual([
"Leeds",
"london",
]);
expect(parseSearchCitiesSetting("Leeds\nLondon\nleeds")).toEqual([
"Leeds",
"London",
]);
expect(parseSearchCitiesSetting("")).toEqual([]);
});
it("serializes search cities", () => {
expect(serializeSearchCitiesSetting(["Leeds", "London"])).toBe(
"Leeds|London",
);
expect(serializeSearchCitiesSetting([])).toBeNull();
});
it("applies strict filter only when city differs from country", () => {
expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);
expect(shouldApplyStrictCityFilter("usa", "united states")).toBe(false);
});
it("matches by whole location tokens and avoids substring false positives", () => {
expect(matchesRequestedCity("Leeds, England, UK", "Leeds")).toBe(true);
expect(matchesRequestedCity("Manchester, England, UK", "Chester")).toBe(
false,
);
expect(
matchesRequestedCity("New York, NY, United States", "new york"),
).toBe(true);
});
});

View File

@ -0,0 +1,83 @@
import { normalizeCountryKey } from "./location-support.js";
const LOCATION_ALIASES: Record<string, string> = {
uk: "united kingdom",
us: "united states",
usa: "united states",
};
export function normalizeLocationToken(
value: string | null | undefined,
): string {
const normalized = value?.trim().toLowerCase().replace(/\s+/g, " ") ?? "";
if (!normalized) return "";
return LOCATION_ALIASES[normalized] ?? normalized;
}
export function parseSearchCitiesSetting(
value: string | null | undefined,
): string[] {
const trimmed = value?.trim();
if (!trimmed) return [];
const split = trimmed.includes("|")
? trimmed.split("|")
: trimmed.includes("\n")
? trimmed.split("\n")
: [trimmed];
const seen = new Set<string>();
const out: string[] = [];
for (const raw of split) {
const normalized = raw.trim();
if (!normalized) continue;
const key = normalized.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(normalized);
}
return out;
}
export function serializeSearchCitiesSetting(cities: string[]): string | null {
if (cities.length === 0) return null;
return cities.join("|");
}
export function shouldApplyStrictCityFilter(
city: string,
country: string,
): boolean {
const normalizedCity = normalizeLocationToken(city);
const normalizedCountry = normalizeCountryKey(country);
if (!normalizedCity || !normalizedCountry) return false;
return normalizedCity !== normalizedCountry;
}
export function matchesRequestedCity(
jobLocation: string | undefined,
requestedCity: string,
): boolean {
const normalizedJobLocation = normalizeLocationToken(jobLocation)
.replace(/[^a-z0-9]+/g, " ")
.trim();
const normalizedRequestedLocation = normalizeLocationToken(requestedCity)
.replace(/[^a-z0-9]+/g, " ")
.trim();
if (!normalizedJobLocation || !normalizedRequestedLocation) return false;
const jobTokens = normalizedJobLocation.split(" ");
const requestedTokens = normalizedRequestedLocation.split(" ");
if (requestedTokens.length > jobTokens.length) return false;
for (let i = 0; i <= jobTokens.length - requestedTokens.length; i += 1) {
let matches = true;
for (let j = 0; j < requestedTokens.length; j += 1) {
if (jobTokens[i + j] !== requestedTokens[j]) {
matches = false;
break;
}
}
if (matches) return true;
}
return false;
}

View File

@ -51,6 +51,8 @@ export const updateSettingsSchema = z
.max(100)
.nullable()
.optional(),
searchCities: z.string().trim().max(100).nullable().optional(),
// Deprecated legacy key; accepted for backward compatibility.
jobspyLocation: z.string().trim().max(100).nullable().optional(),
jobspyResultsWanted: z
.number()

View File

@ -171,9 +171,9 @@ export const createAppSettings = (
searchTerms: ["Software Engineer"],
defaultSearchTerms: ["Software Engineer"],
overrideSearchTerms: null,
jobspyLocation: "United Kingdom",
defaultJobspyLocation: "United Kingdom",
overrideJobspyLocation: null,
searchCities: "United Kingdom",
defaultSearchCities: "United Kingdom",
overrideSearchCities: null,
jobspyResultsWanted: 20,
defaultJobspyResultsWanted: 20,
overrideJobspyResultsWanted: null,

View File

@ -1052,9 +1052,9 @@ export interface AppSettings {
searchTerms: string[];
defaultSearchTerms: string[];
overrideSearchTerms: string[] | null;
jobspyLocation: string;
defaultJobspyLocation: string;
overrideJobspyLocation: string | null;
searchCities: string;
defaultSearchCities: string;
overrideSearchCities: string | null;
jobspyResultsWanted: number;
defaultJobspyResultsWanted: number;
overrideJobspyResultsWanted: number | null;