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. 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. 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: Default controls:
- `ADZUNA_APP_ID` - `ADZUNA_APP_ID`
- `ADZUNA_APP_KEY` - `ADZUNA_APP_KEY`
- `ADZUNA_MAX_JOBS_PER_TERM` (default `50`) - `ADZUNA_MAX_JOBS_PER_TERM` (default `50`)
- `ADZUNA_LOCATION_QUERY` (optional city/location text)
Supported countries in this integration: 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`. - `searchTerms` drive per-term Hiring Cafe `searchQuery`.
- selected country maps into Hiring Cafe location search state. - selected country maps into Hiring Cafe location search state.
- run budget path (`jobspyResultsWanted`) is reused as the max jobs-per-term cap. - 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. 4. Start the run and watch progress in the pipeline progress card.
Defaults and constraints: Defaults and constraints:
@ -40,6 +41,8 @@ Defaults and constraints:
- `worldwide` and `usa/ca` run in broad mode without a strict country location filter. - `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 is enabled by default in source selection.
- `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` controls recency window when running extractor directly (default `7`). - `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: 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. - Adzuna is available only for its supported countries and when App ID/App Key are configured in Settings.
- Glassdoor can be enabled only when: - Glassdoor can be enabled only when:
- selected country supports Glassdoor - 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. Incompatible sources are disabled with explanatory tooltips.
@ -59,7 +59,7 @@ Incompatible sources are disabled with explanatory tooltips.
- **Resumes tailored** (`topN`) - **Resumes tailored** (`topN`)
- **Min suitability score** - **Min suitability score**
- **Max jobs discovered** (run budget cap) - **Max jobs discovered** (run budget cap)
- **Glassdoor city** (required only for Glassdoor) - **Search cities** (optional multi-city input; required for Glassdoor)
#### Search terms #### Search terms
@ -97,7 +97,7 @@ For accepted input formats, inference behavior, and limits, see [Manual Import E
### Glassdoor cannot be enabled ### Glassdoor cannot be enabled
- Verify selected country supports Glassdoor. - 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 ### Adzuna is not selectable

View File

@ -9,6 +9,7 @@ for orchestrator ingestion.
- `ADZUNA_APP_KEY` (required) - `ADZUNA_APP_KEY` (required)
- `ADZUNA_COUNTRY` (default: `gb`) - `ADZUNA_COUNTRY` (default: `gb`)
- `ADZUNA_SEARCH_TERMS` (JSON array or `|` / comma / newline-delimited) - `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_MAX_JOBS_PER_TERM` (default: `50`)
- `ADZUNA_RESULTS_PER_PAGE` (default: `50`, max `50`) - `ADZUNA_RESULTS_PER_PAGE` (default: `50`, max `50`)
- `ADZUNA_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`) - `ADZUNA_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`)

View File

@ -104,6 +104,7 @@ async function fetchJobsPage(args: {
appId: string; appId: string;
appKey: string; appKey: string;
what: string; what: string;
where?: string;
resultsPerPage: number; resultsPerPage: number;
}): Promise<AdzunaJob[]> { }): Promise<AdzunaJob[]> {
const url = new URL(`${API_BASE}/jobs/${args.country}/search/${args.page}`); const url = new URL(`${API_BASE}/jobs/${args.country}/search/${args.page}`);
@ -112,6 +113,9 @@ async function fetchJobsPage(args: {
if (args.what) { if (args.what) {
url.searchParams.set("what", 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)); url.searchParams.set("results_per_page", String(args.resultsPerPage));
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
@ -146,6 +150,7 @@ async function run(): Promise<void> {
const outputJson = const outputJson =
process.env.ADZUNA_OUTPUT_JSON || process.env.ADZUNA_OUTPUT_JSON ||
join(process.cwd(), "storage/datasets/default/jobs.json"); join(process.cwd(), "storage/datasets/default/jobs.json");
const locationQuery = process.env.ADZUNA_LOCATION_QUERY?.trim() || "";
const jobs: ExtractedJob[] = []; const jobs: ExtractedJob[] = [];
@ -171,6 +176,7 @@ async function run(): Promise<void> {
appId, appId,
appKey, appKey,
what: searchTerm, what: searchTerm,
where: locationQuery || undefined,
resultsPerPage: take, 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_COUNTRY` (default: `united kingdom`)
- `HIRING_CAFE_MAX_JOBS_PER_TERM` (default: `200`) - `HIRING_CAFE_MAX_JOBS_PER_TERM` (default: `200`)
- `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` (default: `7`) - `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`) - `HIRING_CAFE_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`)
- `JOBOPS_EMIT_PROGRESS=1` to emit `JOBOPS_PROGRESS` events - `JOBOPS_EMIT_PROGRESS=1` to emit `JOBOPS_PROGRESS` events
- `HIRING_CAFE_HEADLESS=false` to run headed - `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)`. - 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. - `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_MAX_JOBS_PER_TERM = 200;
const DEFAULT_SEARCH_TERM = "web developer"; const DEFAULT_SEARCH_TERM = "web developer";
const DEFAULT_DATE_FETCHED_PAST_N_DAYS = 30; const DEFAULT_DATE_FETCHED_PAST_N_DAYS = 30;
const DEFAULT_LOCATION_RADIUS_MILES = 1;
const PAGE_LIMIT = 50; const PAGE_LIMIT = 50;
type RawHiringCafeJob = Record<string, unknown>; type RawHiringCafeJob = Record<string, unknown>;
@ -46,6 +47,27 @@ interface BrowserApiResponse {
responseText: string; 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 { function emitProgress(payload: Record<string, unknown>): void {
if (process.env.JOBOPS_EMIT_PROGRESS !== "1") return; if (process.env.JOBOPS_EMIT_PROGRESS !== "1") return;
console.log(`${JOBOPS_PROGRESS_PREFIX}${JSON.stringify(payload)}`); console.log(`${JOBOPS_PROGRESS_PREFIX}${JSON.stringify(payload)}`);
@ -191,6 +213,261 @@ function parseTotalCount(payload: unknown): number | null {
return toNumberOrNull(payloadRecord.total); 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( async function callHiringCafeApi(
page: Page, page: Page,
endpoint: string, endpoint: string,
@ -267,6 +544,11 @@ async function run(): Promise<void> {
process.env.HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS, process.env.HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS,
DEFAULT_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 = const outputPath =
process.env.HIRING_CAFE_OUTPUT_JSON || process.env.HIRING_CAFE_OUTPUT_JSON ||
join(__dirname, "../storage/datasets/default/jobs.json"); join(__dirname, "../storage/datasets/default/jobs.json");
@ -308,6 +590,20 @@ async function run(): Promise<void> {
await initializePage(); 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) { for (let i = 0; i < searchTerms.length; i += 1) {
const searchTerm = searchTerms[i]; const searchTerm = searchTerms[i];
const termIndex = i + 1; const termIndex = i + 1;
@ -319,12 +615,17 @@ async function run(): Promise<void> {
searchTerm, searchTerm,
}); });
const location = resolveHiringCafeCountryLocation(country); const searchState = cityLocationContext
const searchState = createDefaultSearchState({ ? createCitySearchState({
searchQuery: searchTerm, searchQuery: searchTerm,
location, dateFetchedPastNDays,
dateFetchedPastNDays, context: cityLocationContext,
}); })
: createDefaultSearchState({
searchQuery: searchTerm,
location: countryLocation,
dateFetchedPastNDays,
});
const encodedSearchState = encodeSearchState(searchState); const encodedSearchState = encodeSearchState(searchState);
let totalAvailable: number | null = null; let totalAvailable: number | null = null;

View File

@ -56,6 +56,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.38.2", "drizzle-orm": "^0.38.2",
"express": "^4.18.2", "express": "^4.18.2",
"framer-motion": "^12.34.3",
"get-tsconfig": "^4.10.0", "get-tsconfig": "^4.10.0",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"jsdom": "^25.0.1", "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 * as api from "../api";
import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { OrchestratorPage } from "./OrchestratorPage"; import { OrchestratorPage } from "./OrchestratorPage";
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
import type { FilterTab } from "./orchestrator/constants"; import type { FilterTab } from "./orchestrator/constants";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) => const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
@ -51,14 +52,15 @@ let mockPipelineTerminalEvent: {
token: number; token: number;
} | null = null; } | null = null;
let mockPipelineSources = ["linkedin"] as Array< let mockPipelineSources = ["linkedin"] as Array<
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs" "gradcracker" | "indeed" | "linkedin" | "ukvisajobs" | "adzuna" | "hiringcafe"
>; >;
let mockAutomaticRunValues = { let mockAutomaticRunValues: AutomaticRunValues = {
topN: 12, topN: 12,
minSuitabilityScore: 55, minSuitabilityScore: 55,
searchTerms: ["backend"], searchTerms: ["backend"],
runBudget: 150, runBudget: 150,
country: "united kingdom", country: "united kingdom",
cityLocations: [],
}; };
const jobFixture = createJob({ const jobFixture = createJob({
@ -325,13 +327,7 @@ vi.mock("./orchestrator/RunModeModal", () => ({
RunModeModal: ({ RunModeModal: ({
onSaveAndRunAutomatic, onSaveAndRunAutomatic,
}: { }: {
onSaveAndRunAutomatic: (values: { onSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise<void>;
topN: number;
minSuitabilityScore: number;
searchTerms: string[];
runBudget: number;
country: string;
}) => Promise<void>;
}) => ( }) => (
<button <button
type="button" type="button"
@ -386,6 +382,7 @@ describe("OrchestratorPage", () => {
searchTerms: ["backend"], searchTerms: ["backend"],
runBudget: 150, runBudget: 150,
country: "united kingdom", country: "united kingdom",
cityLocations: [],
}; };
}); });
@ -701,7 +698,7 @@ describe("OrchestratorPage", () => {
ukvisajobsMaxJobs: 150, ukvisajobsMaxJobs: 150,
adzunaMaxJobsPerTerm: 150, adzunaMaxJobsPerTerm: 150,
jobspyCountryIndeed: "united kingdom", jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom", searchCities: "United Kingdom",
}); });
}); });
expect(api.runPipeline).toHaveBeenCalledWith({ expect(api.runPipeline).toHaveBeenCalledWith({
@ -714,6 +711,108 @@ describe("OrchestratorPage", () => {
setIntervalSpy.mockRestore(); 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 () => { it("shows completion toast from hook terminal state", async () => {
mockPipelineTerminalEvent = { mockPipelineTerminalEvent = {
status: "completed", status: "completed",
@ -797,6 +896,7 @@ describe("OrchestratorPage", () => {
searchTerms: ["backend"], searchTerms: ["backend"],
runBudget: 150, runBudget: 150,
country: "united states", country: "united states",
cityLocations: [],
}; };
render( render(

View File

@ -22,7 +22,10 @@ import * as api from "../api";
import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar"; import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar";
import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog"; import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog";
import type { AutomaticRunValues } from "./orchestrator/automatic-run"; 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 type { FilterTab } from "./orchestrator/constants";
import { tabs } from "./orchestrator/constants"; import { tabs } from "./orchestrator/constants";
import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar"; import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar";
@ -291,10 +294,21 @@ export const OrchestratorPage: React.FC = () => {
searchTerms: values.searchTerms, searchTerms: values.searchTerms,
sources: compatibleSources, sources: compatibleSources,
}); });
const jobspyLocation = compatibleSources.includes("glassdoor") const hasJobSpySite = compatibleSources.some(
? (values.glassdoorLocation ?? "").trim() || (source) =>
formatCountryLabel(values.country) source === "indeed" ||
: formatCountryLabel(values.country); 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({ await api.updateSettings({
searchTerms: values.searchTerms, searchTerms: values.searchTerms,
jobspyResultsWanted: limits.jobspyResultsWanted, jobspyResultsWanted: limits.jobspyResultsWanted,
@ -302,7 +316,7 @@ export const OrchestratorPage: React.FC = () => {
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs, ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm, adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
jobspyCountryIndeed: values.country, jobspyCountryIndeed: values.country,
jobspyLocation, searchCities,
}); });
await refreshSettings(); await refreshSettings();
await startPipelineRun({ await startPipelineRun({

View File

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

View File

@ -25,7 +25,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: ["backend engineer"],
jobspyCountryIndeed: "us", jobspyCountryIndeed: "us",
jobspyLocation: "", searchCities: "",
})} })}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]} enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]} pipelineSources={["linkedin"]}
@ -41,6 +41,29 @@ describe("AutomaticRunTab", () => {
).toBeInTheDocument(); ).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 () => { it("disables and prunes UK-only sources for non-UK country", async () => {
const onSetPipelineSources = vi.fn(); const onSetPipelineSources = vi.fn();
@ -50,7 +73,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states", jobspyCountryIndeed: "united states",
jobspyLocation: "", searchCities: "",
})} })}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]} enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]} pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
@ -76,7 +99,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states", jobspyCountryIndeed: "united states",
jobspyLocation: "", searchCities: "",
})} })}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]} enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]} pipelineSources={["linkedin"]}
@ -103,7 +126,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: ["backend engineer"],
jobspyCountryIndeed: "japan", jobspyCountryIndeed: "japan",
jobspyLocation: "", searchCities: "",
})} })}
enabledSources={["linkedin", "glassdoor"]} enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]} pipelineSources={["linkedin", "glassdoor"]}
@ -134,7 +157,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united kingdom", jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom", searchCities: "United Kingdom",
})} })}
enabledSources={["linkedin", "glassdoor"]} enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]} pipelineSources={["linkedin", "glassdoor"]}
@ -152,7 +175,7 @@ describe("AutomaticRunTab", () => {
const glassdoorButton = screen.getByRole("button", { name: "Glassdoor" }); const glassdoorButton = screen.getByRole("button", { name: "Glassdoor" });
expect(glassdoorButton).toBeDisabled(); expect(glassdoorButton).toBeDisabled();
expect(glassdoorButton.getAttribute("title")).toContain( 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({ settings={createAppSettings({
searchTerms: ["backend engineer", "frontend engineer"], searchTerms: ["backend engineer", "frontend engineer"],
jobspyCountryIndeed: "united kingdom", jobspyCountryIndeed: "united kingdom",
jobspyLocation: "", searchCities: "",
})} })}
enabledSources={["linkedin"]} enabledSources={["linkedin"]}
pipelineSources={["linkedin"]} pipelineSources={["linkedin"]}
@ -175,6 +198,7 @@ describe("AutomaticRunTab", () => {
); );
const input = screen.getByPlaceholderText("Type and press Enter"); const input = screen.getByPlaceholderText("Type and press Enter");
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "Backspace" }); fireEvent.keyDown(input, { key: "Backspace" });
expect( expect(
@ -184,4 +208,34 @@ describe("AutomaticRunTab", () => {
screen.getByRole("button", { name: "Remove frontend engineer" }), screen.getByRole("button", { name: "Remove frontend engineer" }),
).toBeInTheDocument(); ).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, SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support.js"; } from "@shared/location-support.js";
import type { AppSettings, JobSource } from "@shared/types"; 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 { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { import {
@ -33,9 +33,12 @@ import {
type AutomaticRunValues, type AutomaticRunValues,
calculateAutomaticEstimate, calculateAutomaticEstimate,
loadAutomaticRunMemory, loadAutomaticRunMemory,
parseCityLocationsInput,
parseCityLocationsSetting,
parseSearchTermsInput, parseSearchTermsInput,
saveAutomaticRunMemory, saveAutomaticRunMemory,
} from "./automatic-run"; } from "./automatic-run";
import { TokenizedInput } from "./TokenizedInput";
interface AutomaticRunTabProps { interface AutomaticRunTabProps {
open: boolean; open: boolean;
@ -54,6 +57,7 @@ const DEFAULT_VALUES: AutomaticRunValues = {
searchTerms: ["web developer"], searchTerms: ["web developer"],
runBudget: 200, runBudget: 200,
country: "united kingdom", country: "united kingdom",
cityLocations: [],
}; };
interface AutomaticRunFormValues { interface AutomaticRunFormValues {
@ -61,7 +65,8 @@ interface AutomaticRunFormValues {
minSuitabilityScore: string; minSuitabilityScore: string;
runBudget: string; runBudget: string;
country: string; country: string;
glassdoorLocation: string; cityLocations: string[];
cityLocationDraft: string;
searchTerms: string[]; searchTerms: string[];
searchTermDraft: string; searchTermDraft: string;
} }
@ -71,8 +76,15 @@ type AutomaticPresetSelection = AutomaticPresetId | "custom";
const GLASSDOOR_COUNTRY_REASON = const GLASSDOOR_COUNTRY_REASON =
"Glassdoor is not available for the selected country."; "Glassdoor is not available for the selected country.";
const GLASSDOOR_LOCATION_REASON = 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 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( function getSourceDisabledReason(
source: JobSource, source: JobSource,
@ -138,25 +150,25 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
}) => { }) => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>( const { watch, reset, setValue } = useForm<AutomaticRunFormValues>({
{ defaultValues: {
defaultValues: { topN: String(DEFAULT_VALUES.topN),
topN: String(DEFAULT_VALUES.topN), minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore), runBudget: String(DEFAULT_VALUES.runBudget),
runBudget: String(DEFAULT_VALUES.runBudget), country: DEFAULT_VALUES.country,
country: DEFAULT_VALUES.country, cityLocations: [],
glassdoorLocation: "", cityLocationDraft: "",
searchTerms: DEFAULT_VALUES.searchTerms, searchTerms: DEFAULT_VALUES.searchTerms,
searchTermDraft: "", searchTermDraft: "",
},
}, },
); });
const topNInput = watch("topN"); const topNInput = watch("topN");
const minScoreInput = watch("minSuitabilityScore"); const minScoreInput = watch("minSuitabilityScore");
const runBudgetInput = watch("runBudget"); const runBudgetInput = watch("runBudget");
const countryInput = watch("country"); const countryInput = watch("country");
const glassdoorLocationInput = watch("glassdoorLocation"); const cityLocations = watch("cityLocations");
const cityLocationDraft = watch("cityLocationDraft");
const searchTerms = watch("searchTerms"); const searchTerms = watch("searchTerms");
const searchTermDraft = watch("searchTermDraft"); const searchTermDraft = watch("searchTermDraft");
@ -173,48 +185,35 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
settings?.gradcrackerMaxJobsPerTerm ?? settings?.gradcrackerMaxJobsPerTerm ??
settings?.ukvisajobsMaxJobs ?? settings?.ukvisajobsMaxJobs ??
DEFAULT_VALUES.runBudget; DEFAULT_VALUES.runBudget;
const rememberedCountry = normalizeCountryKey( const rememberedCountry = normalizeUiCountryKey(
settings?.jobspyCountryIndeed ?? settings?.jobspyCountryIndeed ??
settings?.jobspyLocation ?? settings?.searchCities ??
DEFAULT_VALUES.country, DEFAULT_VALUES.country,
); );
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country; const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
const rememberedLocationRaw = settings?.jobspyLocation?.trim() ?? ""; const rememberedLocations = parseCityLocationsSetting(
const rememberedLocationNormalized = normalizeCountryKey( settings?.searchCities,
rememberedLocationRaw, ).filter(
(location) =>
normalizeCountryKey(location) !==
normalizeCountryKey(rememberedCountryKey),
); );
const rememberedGlassdoorLocation =
rememberedLocationRaw &&
rememberedLocationNormalized &&
rememberedLocationNormalized !== normalizeCountryKey(rememberedCountryKey)
? rememberedLocationRaw
: "";
reset({ reset({
topN: String(topN), topN: String(topN),
minSuitabilityScore: String(minSuitabilityScore), minSuitabilityScore: String(minSuitabilityScore),
runBudget: String(rememberedRunBudget), runBudget: String(rememberedRunBudget),
country: rememberedCountry || DEFAULT_VALUES.country, country: rememberedCountry || DEFAULT_VALUES.country,
glassdoorLocation: rememberedGlassdoorLocation, cityLocations: rememberedLocations,
cityLocationDraft: "",
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms, searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
searchTermDraft: "", searchTermDraft: "",
}); });
setAdvancedOpen(false); setAdvancedOpen(false);
}, [open, settings, reset]); }, [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 values = useMemo<AutomaticRunValues>(() => {
const normalizedCountry = normalizeCountryKey(countryInput); const normalizedCountry = normalizeUiCountryKey(countryInput);
return { return {
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN), topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
minSuitabilityScore: toNumber( minSuitabilityScore: toNumber(
@ -225,7 +224,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
), ),
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget), runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
country: normalizedCountry || DEFAULT_VALUES.country, country: normalizedCountry || DEFAULT_VALUES.country,
glassdoorLocation: glassdoorLocationInput.trim() || undefined, cityLocations,
searchTerms, searchTerms,
}; };
}, [ }, [
@ -233,17 +232,18 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
minScoreInput, minScoreInput,
runBudgetInput, runBudgetInput,
countryInput, countryInput,
glassdoorLocationInput, cityLocations,
searchTerms, searchTerms,
]); ]);
const isSourceAvailableForRun = useCallback( const isSourceAvailableForRun = useCallback(
(source: JobSource) => { (source: JobSource) => {
if (!isSourceAllowedForCountry(source, values.country)) return false; 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; return true;
}, },
[values.country, values.glassdoorLocation], [values.country, values.cityLocations.length],
); );
const compatibleEnabledSources = useMemo( const compatibleEnabledSources = useMemo(
@ -319,7 +319,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
const countryOptions = useMemo( const countryOptions = useMemo(
() => () =>
SUPPORTED_COUNTRY_KEYS.map((country) => ({ SUPPORTED_COUNTRY_KEYS.filter(
(country) => !HIDDEN_COUNTRY_KEYS.has(country),
).map((country) => ({
value: country, value: country,
label: formatCountryLabel(country), label: formatCountryLabel(country),
})), })),
@ -389,7 +391,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<Accordion <Accordion
type="single" type="single"
collapsible collapsible
value={advancedOpen ? "advanced" : undefined} value={advancedOpen ? "advanced" : ""}
onValueChange={(value) => setAdvancedOpen(value === "advanced")} onValueChange={(value) => setAdvancedOpen(value === "advanced")}
> >
<AccordionItem value="advanced" className="border-b-0"> <AccordionItem value="advanced" className="border-b-0">
@ -438,21 +440,24 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
/> />
</div> </div>
<div className="space-y-2 md:col-span-3"> <div className="space-y-2 md:col-span-3">
<Label htmlFor="glassdoor-location">Glassdoor city</Label> <Label htmlFor="city-locations-input">Cities</Label>
<Input <TokenizedInput
id="glassdoor-location" id="city-locations-input"
value={glassdoorLocationInput} values={cityLocations}
onChange={(event) => draft={cityLocationDraft}
setValue("glassdoorLocation", event.target.value, { parseInput={parseCityLocationsInput}
onDraftChange={(value) =>
setValue("cityLocationDraft", value)
}
onValuesChange={(value) =>
setValue("cityLocations", value, {
shouldDirty: true, shouldDirty: true,
}) })
} }
placeholder='e.g. "London"' 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>
</div> </div>
</AccordionContent> </AccordionContent>
@ -465,58 +470,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>Search terms</CardTitle> <CardTitle>Search terms</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent>
<Input <TokenizedInput
id="search-terms-input" id="search-terms-input"
value={searchTermDraft} values={searchTerms}
onChange={(event) => draft={searchTermDraft}
setValue("searchTermDraft", event.target.value) 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" 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> </CardContent>
</Card> </Card>

View File

@ -1,7 +1,6 @@
import { AnimatePresence, motion } from "framer-motion";
import type React from "react"; import type React from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FloatingJobActionsBarProps { interface FloatingJobActionsBarProps {
selectedCount: number; selectedCount: number;
@ -26,84 +25,71 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
onRescoreSelected, onRescoreSelected,
onClear, 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 ( 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"> <AnimatePresence initial={false}>
<div {selectedCount > 0 ? (
className={cn( <motion.div
"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", 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"
"transition-all duration-200 ease-out", initial={{ opacity: 0, y: 16 }}
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0", animate={{ opacity: 1, y: 0 }}
)} exit={{ opacity: 0, y: 16 }}
> transition={{ duration: 0.18, ease: "easeOut" }}
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1"> >
{selectedCount} selected <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> <div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center"> {selectedCount} selected
{canMoveSelected && ( </div>
<Button <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
type="button" {canMoveSelected && (
size="sm" <Button
variant="outline" type="button"
className="w-full sm:w-auto" size="sm"
disabled={jobActionInFlight} variant="outline"
onClick={onMoveToReady} className="w-full sm:w-auto"
> disabled={jobActionInFlight}
Move to Ready onClick={onMoveToReady}
</Button> >
)} Move to Ready
{canSkipSelected && ( </Button>
<Button )}
type="button" {canSkipSelected && (
size="sm" <Button
variant="outline" type="button"
className="w-full sm:w-auto" size="sm"
disabled={jobActionInFlight} variant="outline"
onClick={onSkipSelected} className="w-full sm:w-auto"
> disabled={jobActionInFlight}
Skip selected onClick={onSkipSelected}
</Button> >
)} Skip selected
{canRescoreSelected && ( </Button>
<Button )}
type="button" {canRescoreSelected && (
size="sm" <Button
variant="outline" type="button"
className="w-full sm:w-auto" size="sm"
disabled={jobActionInFlight} variant="outline"
onClick={onRescoreSelected} className="w-full sm:w-auto"
> disabled={jobActionInFlight}
Recalculate match onClick={onRescoreSelected}
</Button> >
)} Recalculate match
<Button </Button>
type="button" )}
size="sm" <Button
variant="ghost" type="button"
className="w-full sm:w-auto" size="sm"
onClick={onClear} variant="ghost"
disabled={jobActionInFlight} className="w-full sm:w-auto"
> onClick={onClear}
Clear disabled={jobActionInFlight}
</Button> >
</div> Clear
</div> </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"], searchTerms: ["backend", "platform"],
runBudget: 100, runBudget: 100,
country: "united kingdom", country: "united kingdom",
cityLocations: [],
}, },
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"], sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
}); });
@ -59,6 +60,7 @@ describe("automatic-run utilities", () => {
searchTerms: [], searchTerms: [],
runBudget: 750, runBudget: 750,
country: "united kingdom", country: "united kingdom",
cityLocations: [],
}, },
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"], sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
}); });
@ -85,6 +87,7 @@ describe("automatic-run utilities", () => {
searchTerms: ["backend", "platform"], searchTerms: ["backend", "platform"],
runBudget: 120, runBudget: 120,
country: "united kingdom", country: "united kingdom",
cityLocations: [],
}, },
sources: ["adzuna"], sources: ["adzuna"],
}); });
@ -101,6 +104,7 @@ describe("automatic-run utilities", () => {
searchTerms: ["backend", "platform"], searchTerms: ["backend", "platform"],
runBudget: 120, runBudget: 120,
country: "united kingdom", country: "united kingdom",
cityLocations: [],
}, },
sources: ["hiringcafe"], sources: ["hiringcafe"],
}); });

View File

@ -1,3 +1,7 @@
import {
parseSearchCitiesSetting,
serializeSearchCitiesSetting,
} from "@shared/search-cities.js";
import type { JobSource } from "@shared/types"; import type { JobSource } from "@shared/types";
export type AutomaticPresetId = "fast" | "balanced" | "detailed"; export type AutomaticPresetId = "fast" | "balanced" | "detailed";
@ -8,7 +12,7 @@ export interface AutomaticRunValues {
searchTerms: string[]; searchTerms: string[];
runBudget: number; runBudget: number;
country: string; country: string;
glassdoorLocation?: string; cityLocations: string[];
} }
export interface AutomaticPresetValues { export interface AutomaticPresetValues {
@ -115,6 +119,29 @@ export function parseSearchTermsInput(input: string): string[] {
.filter(Boolean); .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 { export function stringifySearchTerms(terms: string[]): string {
return terms.join("\n"); return terms.join("\n");
} }

View File

@ -24,7 +24,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
backupEnabled: "0", backupEnabled: "0",
backupHour: "2", backupHour: "2",
backupMaxCount: "5", backupMaxCount: "5",
jobspyLocation: "United States", searchCities: "United States",
jobspyResultsWanted: "25", jobspyResultsWanted: "25",
jobspyCountryIndeed: "US", jobspyCountryIndeed: "US",
resumeProjects: JSON.stringify({ 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 () => { it("filters out glassdoor for unsupported countries", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy"); 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 () => { it("skips adzuna for unsupported countries", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("../../repositories/settings");
const adzuna = await import("../../services/adzuna"); const adzuna = await import("../../services/adzuna");
@ -257,12 +317,46 @@ describe("discoverJobsStep", () => {
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith( expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
country: "united states", country: "united states",
countryKey: "united states",
locations: [],
searchTerms: ["engineer"], searchTerms: ["engineer"],
maxJobsPerTerm: 25, 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 () => { it("updates Hiring Cafe terms and pages via progress callbacks", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("../../repositories/settings");
const hiringCafe = await import("../../services/hiring-cafe"); const hiringCafe = await import("../../services/hiring-cafe");

View File

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

View File

@ -23,6 +23,7 @@ export type SettingKey =
| "adzunaMaxJobsPerTerm" | "adzunaMaxJobsPerTerm"
| "gradcrackerMaxJobsPerTerm" | "gradcrackerMaxJobsPerTerm"
| "searchTerms" | "searchTerms"
| "searchCities"
| "jobspyLocation" | "jobspyLocation"
| "jobspyResultsWanted" | "jobspyResultsWanted"
| "jobspyCountryIndeed" | "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 { createInterface } from "node:readline";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { logger } from "@infra/logger"; 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 type { CreateJobInput } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion"; import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
@ -44,6 +50,8 @@ export type AdzunaProgressEvent =
export interface RunAdzunaOptions { export interface RunAdzunaOptions {
searchTerms?: string[]; searchTerms?: string[];
country?: string; country?: string;
countryKey?: string;
locations?: string[];
maxJobsPerTerm?: number; maxJobsPerTerm?: number;
onProgress?: (event: AdzunaProgressEvent) => void; onProgress?: (event: AdzunaProgressEvent) => void;
} }
@ -54,6 +62,27 @@ export interface AdzunaResult {
error?: string; 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 { function resolveTsxCliPath(): string | null {
try { try {
return require.resolve("tsx/dist/cli.mjs"); return require.resolve("tsx/dist/cli.mjs");
@ -170,11 +199,15 @@ export async function runAdzuna(
} }
const country = (options.country || "gb").trim().toLowerCase(); const country = (options.country || "gb").trim().toLowerCase();
const countryKey = normalizeCountryKey(options.countryKey ?? "");
const maxJobsPerTerm = options.maxJobsPerTerm ?? 50; const maxJobsPerTerm = options.maxJobsPerTerm ?? 50;
const searchTerms = const searchTerms =
options.searchTerms && options.searchTerms.length > 0 options.searchTerms && options.searchTerms.length > 0
? options.searchTerms ? options.searchTerms
: ["web developer"]; : ["web developer"];
const locations = resolveLocations(options);
const runLocations = locations.length > 0 ? locations : [null];
const termTotal = searchTerms.length * runLocations.length;
const useNpmCommand = canRunNpmCommand(); const useNpmCommand = canRunNpmCommand();
if (!useNpmCommand && !TSX_CLI_PATH) { if (!useNpmCommand && !TSX_CLI_PATH) {
return { return {
@ -185,66 +218,95 @@ export async function runAdzuna(
} }
try { try {
await new Promise<void>((resolve, reject) => { const jobs: CreateJobInput[] = [];
const extractorEnv = { const seen = new Set<string>();
...process.env,
JOBOPS_EMIT_PROGRESS: "1", for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
ADZUNA_APP_ID: appId, const location = runLocations[runIndex];
ADZUNA_APP_KEY: appKey, const strictLocationFilter =
ADZUNA_COUNTRY: country, location !== null &&
ADZUNA_MAX_JOBS_PER_TERM: String(maxJobsPerTerm), shouldApplyStrictLocationFilter(location, countryKey);
ADZUNA_SEARCH_TERMS: JSON.stringify(searchTerms),
ADZUNA_OUTPUT_JSON: DATASET_PATH, await new Promise<void>((resolve, reject) => {
}; const extractorEnv = {
const child = useNpmCommand ...process.env,
? spawn("npm", ["run", "start"], { JOBOPS_EMIT_PROGRESS: "1",
cwd: ADZUNA_DIR, ADZUNA_APP_ID: appId,
stdio: ["ignore", "pipe", "pipe"], ADZUNA_APP_KEY: appKey,
env: extractorEnv, ADZUNA_COUNTRY: country,
}) ADZUNA_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
: (() => { ADZUNA_SEARCH_TERMS: JSON.stringify(searchTerms),
const tsxCliPath = TSX_CLI_PATH; ADZUNA_OUTPUT_JSON: DATASET_PATH,
if (!tsxCliPath) { ADZUNA_LOCATION_QUERY: strictLocationFilter ? location : "",
throw new Error( };
"Unable to execute Adzuna extractor (npm/tsx unavailable)", const child = useNpmCommand
); ? spawn("npm", ["run", "start"], {
}
return spawn(process.execPath, [tsxCliPath, "src/main.ts"], {
cwd: ADZUNA_DIR, cwd: ADZUNA_DIR,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: extractorEnv, env: extractorEnv,
})
: (() => {
const tsxCliPath = TSX_CLI_PATH;
if (!tsxCliPath) {
throw new Error(
"Unable to execute Adzuna extractor (npm/tsx unavailable)",
);
}
return spawn(process.execPath, [tsxCliPath, "src/main.ts"], {
cwd: ADZUNA_DIR,
stdio: ["ignore", "pipe", "pipe"],
env: extractorEnv,
});
})();
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
const progressEvent = parseAdzunaProgressLine(line);
if (progressEvent) {
const termOffset = runIndex * searchTerms.length;
options.onProgress?.({
...progressEvent,
termIndex: termOffset + progressEvent.termIndex,
termTotal,
}); });
})(); return;
}
stream.write(`${line}\n`);
};
const handleLine = (line: string, stream: NodeJS.WriteStream) => { const stdoutRl = child.stdout
const progressEvent = parseAdzunaProgressLine(line); ? createInterface({ input: child.stdout })
if (progressEvent) { : null;
options.onProgress?.(progressEvent); const stderrRl = child.stderr
return; ? createInterface({ input: child.stderr })
} : null;
stream.write(`${line}\n`);
};
const stdoutRl = child.stdout stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
? createInterface({ input: child.stdout }) stderrRl?.on("line", (line) => handleLine(line, process.stderr));
: null;
const stderrRl = child.stderr
? createInterface({ input: child.stderr })
: null;
stdoutRl?.on("line", (line) => handleLine(line, process.stdout)); child.on("close", (code) => {
stderrRl?.on("line", (line) => handleLine(line, process.stderr)); stdoutRl?.close();
stderrRl?.close();
child.on("close", (code) => { if (code === 0) resolve();
stdoutRl?.close(); else reject(new Error(`Adzuna extractor exited with code ${code}`));
stderrRl?.close(); });
if (code === 0) resolve(); child.on("error", reject);
else reject(new Error(`Adzuna extractor exited with code ${code}`));
}); });
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 }; return { success: true, jobs };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown 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 { fileURLToPath } from "node:url";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize"; 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 type { CreateJobInput } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion"; import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
@ -50,6 +56,9 @@ export type HiringCafeProgressEvent =
export interface RunHiringCafeOptions { export interface RunHiringCafeOptions {
searchTerms?: string[]; searchTerms?: string[];
country?: string; country?: string;
countryKey?: string;
locations?: string[];
locationRadiusMiles?: number;
maxJobsPerTerm?: number; maxJobsPerTerm?: number;
onProgress?: (event: HiringCafeProgressEvent) => void; onProgress?: (event: HiringCafeProgressEvent) => void;
} }
@ -60,6 +69,27 @@ export interface HiringCafeResult {
error?: string; 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 { function resolveTsxCliPath(): string | null {
try { try {
return require.resolve("tsx/dist/cli.mjs"); return require.resolve("tsx/dist/cli.mjs");
@ -182,7 +212,15 @@ export async function runHiringCafe(
? options.searchTerms ? options.searchTerms
: ["web developer"]; : ["web developer"];
const country = (options.country || "united kingdom").trim().toLowerCase(); const country = (options.country || "united kingdom").trim().toLowerCase();
const countryKey = normalizeCountryKey(options.countryKey ?? "");
const maxJobsPerTerm = options.maxJobsPerTerm ?? 200; 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(); const useNpmCommand = canRunNpmCommand();
if (!useNpmCommand && !TSX_CLI_PATH) { if (!useNpmCommand && !TSX_CLI_PATH) {
@ -194,70 +232,102 @@ export async function runHiringCafe(
} }
try { try {
await clearStorageDataset(); const jobs: CreateJobInput[] = [];
const seen = new Set<string>();
await new Promise<void>((resolve, reject) => { for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
const extractorEnv = { const location = runLocations[runIndex];
...process.env, const strictLocationFilter =
JOBOPS_EMIT_PROGRESS: "1", location !== null &&
HIRING_CAFE_SEARCH_TERMS: JSON.stringify(searchTerms), shouldApplyStrictLocationFilter(location, countryKey);
HIRING_CAFE_COUNTRY: country,
HIRING_CAFE_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
HIRING_CAFE_OUTPUT_JSON: DATASET_PATH,
};
const child = useNpmCommand await clearStorageDataset();
? spawn("npm", ["run", "start"], {
cwd: HIRING_CAFE_DIR,
stdio: ["ignore", "pipe", "pipe"],
env: extractorEnv,
})
: (() => {
const tsxCliPath = TSX_CLI_PATH;
if (!tsxCliPath) {
throw new Error(
"Unable to execute Hiring Cafe extractor (npm/tsx unavailable)",
);
}
return spawn(process.execPath, [tsxCliPath, "src/main.ts"], { await new Promise<void>((resolve, reject) => {
const extractorEnv = {
...process.env,
JOBOPS_EMIT_PROGRESS: "1",
HIRING_CAFE_SEARCH_TERMS: JSON.stringify(searchTerms),
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
? spawn("npm", ["run", "start"], {
cwd: HIRING_CAFE_DIR, cwd: HIRING_CAFE_DIR,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: extractorEnv, env: extractorEnv,
})
: (() => {
const tsxCliPath = TSX_CLI_PATH;
if (!tsxCliPath) {
throw new Error(
"Unable to execute Hiring Cafe extractor (npm/tsx unavailable)",
);
}
return spawn(process.execPath, [tsxCliPath, "src/main.ts"], {
cwd: HIRING_CAFE_DIR,
stdio: ["ignore", "pipe", "pipe"],
env: extractorEnv,
});
})();
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
const progressEvent = parseProgressLine(line);
if (progressEvent) {
const termOffset = runIndex * searchTerms.length;
options.onProgress?.({
...progressEvent,
termIndex: termOffset + progressEvent.termIndex,
termTotal,
}); });
})(); return;
}
const handleLine = (line: string, stream: NodeJS.WriteStream) => { stream.write(`${line}\n`);
const progressEvent = parseProgressLine(line); };
if (progressEvent) {
options.onProgress?.(progressEvent);
return;
}
stream.write(`${line}\n`); const stdoutRl = child.stdout
}; ? createInterface({ input: child.stdout })
: null;
const stderrRl = child.stderr
? createInterface({ input: child.stderr })
: null;
const stdoutRl = child.stdout stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
? createInterface({ input: child.stdout }) stderrRl?.on("line", (line) => handleLine(line, process.stderr));
: null;
const stderrRl = child.stderr
? createInterface({ input: child.stderr })
: null;
stdoutRl?.on("line", (line) => handleLine(line, process.stdout)); child.on("close", (code) => {
stderrRl?.on("line", (line) => handleLine(line, process.stderr)); stdoutRl?.close();
stderrRl?.close();
child.on("close", (code) => { if (code === 0) resolve();
stdoutRl?.close(); else
stderrRl?.close(); reject(new Error(`Hiring Cafe extractor exited with code ${code}`));
if (code === 0) resolve(); });
else child.on("error", reject);
reject(new Error(`Hiring Cafe extractor exited with code ${code}`));
}); });
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 }; return { success: true, jobs };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseJobSpyProgressLine } from "./jobspy"; import {
matchesRequestedLocation,
parseJobSpyProgressLine,
shouldApplyStrictLocationFilter,
} from "./jobspy";
describe("parseJobSpyProgressLine", () => { describe("parseJobSpyProgressLine", () => {
it("parses term_start progress lines", () => { it("parses term_start progress lines", () => {
@ -38,3 +42,24 @@ describe("parseJobSpyProgressLine", () => {
expect(parseJobSpyProgressLine("Found 20 jobs")).toBeNull(); 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 { dirname, join } from "node:path";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import {
matchesRequestedCity,
parseSearchCitiesSetting,
shouldApplyStrictCityFilter,
} from "@shared/search-cities.js";
import type { CreateJobInput, JobSource } from "@shared/types"; import type { CreateJobInput, JobSource } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion"; import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
import { getDataDir } from "../config/dataDir"; import { getDataDir } from "../config/dataDir";
@ -144,6 +149,7 @@ export interface RunJobSpyOptions {
sites?: Array<JobSource>; sites?: Array<JobSource>;
searchTerms?: string[]; searchTerms?: string[];
location?: string; location?: string;
locations?: string[];
resultsWanted?: number; resultsWanted?: number;
hoursOld?: number; hoursOld?: number;
countryIndeed?: string; countryIndeed?: string;
@ -158,6 +164,20 @@ export interface JobSpyResult {
error?: string; 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( export async function runJobSpy(
options: RunJobSpyOptions = {}, options: RunJobSpyOptions = {},
): Promise<JobSpyResult> { ): Promise<JobSpyResult> {
@ -170,6 +190,9 @@ export async function runJobSpy(
.join(","); .join(",");
const searchTerms = resolveSearchTerms(options); const searchTerms = resolveSearchTerms(options);
const locations = resolveLocations(options);
const countryIndeed =
options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? "UK";
if (searchTerms.length === 0) { if (searchTerms.length === 0) {
return { success: true, jobs: [] }; return { success: true, jobs: [] };
} }
@ -178,93 +201,105 @@ export async function runJobSpy(
const jobs: CreateJobInput[] = []; const jobs: CreateJobInput[] = [];
const seenJobUrls = new Set<string>(); const seenJobUrls = new Set<string>();
for (let i = 0; i < searchTerms.length; i++) { const totalRuns = searchTerms.length * locations.length;
const searchTerm = searchTerms[i]; let runIndex = 0;
const suffix = `${i + 1}_${slugForFilename(searchTerm)}`;
const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
await new Promise<void>((resolve, reject) => { for (const searchTerm of searchTerms) {
const pythonPath = getPythonPath(); for (const location of locations) {
const child = spawn(pythonPath, [JOBSPY_SCRIPT], { runIndex += 1;
cwd: JOBSPY_DIR, const suffix = `${runIndex}_${slugForFilename(searchTerm)}_${slugForFilename(location)}`;
shell: false, const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
stdio: ["ignore", "pipe", "pipe"], const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
env: {
...process.env, await new Promise<void>((resolve, reject) => {
JOBSPY_SITES: sites || "indeed,linkedin,glassdoor", const pythonPath = getPythonPath();
JOBSPY_SEARCH_TERM: searchTerm, const child = spawn(pythonPath, [JOBSPY_SCRIPT], {
JOBSPY_TERM_INDEX: String(i + 1), cwd: JOBSPY_DIR,
JOBSPY_TERM_TOTAL: String(searchTerms.length), shell: false,
JOBSPY_LOCATION: stdio: ["ignore", "pipe", "pipe"],
options.location ?? process.env.JOBSPY_LOCATION ?? "UK", env: {
JOBSPY_RESULTS_WANTED: String( ...process.env,
options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200, JOBSPY_SITES: sites || "indeed,linkedin,glassdoor",
), JOBSPY_SEARCH_TERM: searchTerm,
JOBSPY_HOURS_OLD: String( JOBSPY_TERM_INDEX: String(runIndex),
options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72, JOBSPY_TERM_TOTAL: String(totalRuns),
), JOBSPY_LOCATION: location,
JOBSPY_COUNTRY_INDEED: JOBSPY_RESULTS_WANTED: String(
options.countryIndeed ?? options.resultsWanted ??
process.env.JOBSPY_COUNTRY_INDEED ?? process.env.JOBSPY_RESULTS_WANTED ??
"UK", 200,
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String( ),
options.linkedinFetchDescription ?? JOBSPY_HOURS_OLD: String(
process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ?? options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72,
"1", ),
), JOBSPY_COUNTRY_INDEED: countryIndeed,
JOBSPY_IS_REMOTE: String( JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
options.isRemote ?? process.env.JOBSPY_IS_REMOTE ?? "0", options.linkedinFetchDescription ??
), process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ??
JOBSPY_OUTPUT_CSV: outputCsv, "1",
JOBSPY_OUTPUT_JSON: outputJson, ),
}, JOBSPY_IS_REMOTE: String(
options.isRemote ?? process.env.JOBSPY_IS_REMOTE ?? "0",
),
JOBSPY_OUTPUT_CSV: outputCsv,
JOBSPY_OUTPUT_JSON: outputJson,
},
});
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
const event = parseJobSpyProgressLine(line);
if (event) {
options.onProgress?.(event);
return;
}
stream.write(`${line}\n`);
};
const stdoutRl = child.stdout
? createInterface({ input: child.stdout })
: null;
const stderrRl = child.stderr
? createInterface({ input: child.stderr })
: null;
stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
stderrRl?.on("line", (line) => handleLine(line, process.stderr));
child.on("close", (code) => {
stdoutRl?.close();
stderrRl?.close();
if (code === 0) resolve();
else reject(new Error(`JobSpy exited with code ${code}`));
});
child.on("error", reject);
}); });
const handleLine = (line: string, stream: NodeJS.WriteStream) => { const raw = await readFile(outputJson, "utf-8");
const event = parseJobSpyProgressLine(line); const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
if (event) { const mapped = mapJobSpyRows(parsed);
options.onProgress?.(event); const strictLocationFilter = shouldApplyStrictLocationFilter(
return; location,
} countryIndeed,
stream.write(`${line}\n`); );
}; const filtered = strictLocationFilter
? mapped.filter((job) =>
matchesRequestedLocation(job.location, location),
)
: mapped;
const stdoutRl = child.stdout for (const job of filtered) {
? createInterface({ input: child.stdout }) const url = job.jobUrl;
: null; if (seenJobUrls.has(url)) continue;
const stderrRl = child.stderr seenJobUrls.add(url);
? createInterface({ input: child.stderr }) jobs.push(job);
: null; }
stdoutRl?.on("line", (line) => handleLine(line, process.stdout)); try {
stderrRl?.on("line", (line) => handleLine(line, process.stderr)); await unlink(outputJson);
await unlink(outputCsv);
child.on("close", (code) => { } catch {
stdoutRl?.close(); // Ignore cleanup errors
stderrRl?.close(); }
if (code === 0) resolve();
else reject(new Error(`JobSpy exited with code ${code}`));
});
child.on("error", reject);
});
const raw = await readFile(outputJson, "utf-8");
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
const mapped = mapJobSpyRows(parsed);
for (const job of mapped) {
const url = job.jobUrl;
if (seenJobUrls.has(url)) continue;
seenJobUrls.add(url);
jobs.push(job);
}
try {
await unlink(outputJson);
await unlink(outputCsv);
} catch {
// Ignore cleanup errors
} }
} }
@ -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[] { function resolveSearchTerms(options: RunJobSpyOptions): string[] {
const fromOptions = options.searchTerms?.length ? options.searchTerms : null; const fromOptions = options.searchTerms?.length ? options.searchTerms : null;
const fromEnv = parseSearchTermsEnv(process.env.JOBSPY_SEARCH_TERMS); 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", () => { it("uses string defaults when override is empty", () => {
process.env.JOBSPY_LOCATION = "Remote"; process.env.JOBSPY_LOCATION = "Remote";
const resolved = resolveSettingValue("jobspyLocation", ""); const resolved = resolveSettingValue("searchCities", "");
expect(resolved.defaultValue).toBe("Remote"); expect(resolved.defaultValue).toBe("Remote");
expect(resolved.overrideValue).toBe(""); expect(resolved.overrideValue).toBe("");
expect(resolved.value).toBe("Remote"); expect(resolved.value).toBe("Remote");

View File

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

View File

@ -177,8 +177,12 @@ export const settingsUpdateRegistry: Partial<{
searchTerms: singleAction(({ value }) => searchTerms: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("searchTerms", 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 }) => jobspyLocation: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("jobspyLocation", value)] }), result({ actions: [metadataPersistAction("searchCities", value)] }),
), ),
jobspyResultsWanted: singleAction(({ value }) => jobspyResultsWanted: singleAction(({ value }) =>
result({ result({

View File

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

101
package-lock.json generated
View File

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

View File

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

View File

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