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:
parent
5ed9069813
commit
19266fe5eb
@ -29,11 +29,17 @@ Adzuna provides stable API discovery for countries that are not covered by UK-on
|
||||
5. In **Pipeline Run** (Automatic tab), select a compatible country and enable **Adzuna** in Sources.
|
||||
6. Start the run; Adzuna progress appears in the existing crawl progress stream.
|
||||
|
||||
City behavior:
|
||||
|
||||
- If **Search cities** are set in Automatic advanced settings, Adzuna runs once per city.
|
||||
- City runs use strict post-filtering (`job.location` contains requested city) to avoid broad country-level spillover.
|
||||
|
||||
Default controls:
|
||||
|
||||
- `ADZUNA_APP_ID`
|
||||
- `ADZUNA_APP_KEY`
|
||||
- `ADZUNA_MAX_JOBS_PER_TERM` (default `50`)
|
||||
- `ADZUNA_LOCATION_QUERY` (optional city/location text)
|
||||
|
||||
Supported countries in this integration:
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ It also supports term-by-term search and country-aware search state using the sa
|
||||
- `searchTerms` drive per-term Hiring Cafe `searchQuery`.
|
||||
- selected country maps into Hiring Cafe location search state.
|
||||
- run budget path (`jobspyResultsWanted`) is reused as the max jobs-per-term cap.
|
||||
- optional **Search cities** narrow results by city.
|
||||
4. Start the run and watch progress in the pipeline progress card.
|
||||
|
||||
Defaults and constraints:
|
||||
@ -40,6 +41,8 @@ Defaults and constraints:
|
||||
- `worldwide` and `usa/ca` run in broad mode without a strict country location filter.
|
||||
- Hiring Cafe is enabled by default in source selection.
|
||||
- `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` controls recency window when running extractor directly (default `7`).
|
||||
- When a city is provided via `searchCities`, Hiring Cafe uses city radius search (default `1` mile) and strict city post-filtering.
|
||||
- City geocoding is resolved through Nominatim (OpenStreetMap data); if you scale extractor traffic, add attribution and cache repeated city lookups.
|
||||
|
||||
Local run example:
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ If values are edited manually, the UI shows **Custom**.
|
||||
- Adzuna is available only for its supported countries and when App ID/App Key are configured in Settings.
|
||||
- Glassdoor can be enabled only when:
|
||||
- selected country supports Glassdoor
|
||||
- a **Glassdoor city** is set in Advanced settings
|
||||
- at least one **Search city** is set in Advanced settings
|
||||
|
||||
Incompatible sources are disabled with explanatory tooltips.
|
||||
|
||||
@ -59,7 +59,7 @@ Incompatible sources are disabled with explanatory tooltips.
|
||||
- **Resumes tailored** (`topN`)
|
||||
- **Min suitability score**
|
||||
- **Max jobs discovered** (run budget cap)
|
||||
- **Glassdoor city** (required only for Glassdoor)
|
||||
- **Search cities** (optional multi-city input; required for Glassdoor)
|
||||
|
||||
#### Search terms
|
||||
|
||||
@ -97,7 +97,7 @@ For accepted input formats, inference behavior, and limits, see [Manual Import E
|
||||
### Glassdoor cannot be enabled
|
||||
|
||||
- Verify selected country supports Glassdoor.
|
||||
- Set a Glassdoor city in Advanced settings.
|
||||
- Set at least one Search city in Advanced settings.
|
||||
|
||||
### Adzuna is not selectable
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ for orchestrator ingestion.
|
||||
- `ADZUNA_APP_KEY` (required)
|
||||
- `ADZUNA_COUNTRY` (default: `gb`)
|
||||
- `ADZUNA_SEARCH_TERMS` (JSON array or `|` / comma / newline-delimited)
|
||||
- `ADZUNA_LOCATION_QUERY` (optional city/location text passed to Adzuna `where`)
|
||||
- `ADZUNA_MAX_JOBS_PER_TERM` (default: `50`)
|
||||
- `ADZUNA_RESULTS_PER_PAGE` (default: `50`, max `50`)
|
||||
- `ADZUNA_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`)
|
||||
|
||||
@ -104,6 +104,7 @@ async function fetchJobsPage(args: {
|
||||
appId: string;
|
||||
appKey: string;
|
||||
what: string;
|
||||
where?: string;
|
||||
resultsPerPage: number;
|
||||
}): Promise<AdzunaJob[]> {
|
||||
const url = new URL(`${API_BASE}/jobs/${args.country}/search/${args.page}`);
|
||||
@ -112,6 +113,9 @@ async function fetchJobsPage(args: {
|
||||
if (args.what) {
|
||||
url.searchParams.set("what", args.what);
|
||||
}
|
||||
if (args.where) {
|
||||
url.searchParams.set("where", args.where);
|
||||
}
|
||||
url.searchParams.set("results_per_page", String(args.resultsPerPage));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
@ -146,6 +150,7 @@ async function run(): Promise<void> {
|
||||
const outputJson =
|
||||
process.env.ADZUNA_OUTPUT_JSON ||
|
||||
join(process.cwd(), "storage/datasets/default/jobs.json");
|
||||
const locationQuery = process.env.ADZUNA_LOCATION_QUERY?.trim() || "";
|
||||
|
||||
const jobs: ExtractedJob[] = [];
|
||||
|
||||
@ -171,6 +176,7 @@ async function run(): Promise<void> {
|
||||
appId,
|
||||
appKey,
|
||||
what: searchTerm,
|
||||
where: locationQuery || undefined,
|
||||
resultsPerPage: take,
|
||||
});
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ Special thanks: initial implementation inspiration came from [umur957/hiring-caf
|
||||
- `HIRING_CAFE_COUNTRY` (default: `united kingdom`)
|
||||
- `HIRING_CAFE_MAX_JOBS_PER_TERM` (default: `200`)
|
||||
- `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` (default: `7`)
|
||||
- `HIRING_CAFE_LOCATION_QUERY` (optional city, e.g. `Leeds`)
|
||||
- `HIRING_CAFE_LOCATION_RADIUS_MILES` (default: `1` when city is set)
|
||||
- `HIRING_CAFE_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`)
|
||||
- `JOBOPS_EMIT_PROGRESS=1` to emit `JOBOPS_PROGRESS` events
|
||||
- `HIRING_CAFE_HEADLESS=false` to run headed
|
||||
@ -18,3 +20,4 @@ Special thanks: initial implementation inspiration came from [umur957/hiring-caf
|
||||
|
||||
- The extractor uses `s = base64(url-encoded JSON search state)`.
|
||||
- `worldwide` and `usa/ca` are treated as broad search modes without hard country location filters.
|
||||
- City geocoding uses [Nominatim](https://nominatim.openstreetmap.org/) (OpenStreetMap data).
|
||||
|
||||
@ -20,6 +20,7 @@ const JOBOPS_PROGRESS_PREFIX = "JOBOPS_PROGRESS ";
|
||||
const DEFAULT_MAX_JOBS_PER_TERM = 200;
|
||||
const DEFAULT_SEARCH_TERM = "web developer";
|
||||
const DEFAULT_DATE_FETCHED_PAST_N_DAYS = 30;
|
||||
const DEFAULT_LOCATION_RADIUS_MILES = 1;
|
||||
const PAGE_LIMIT = 50;
|
||||
|
||||
type RawHiringCafeJob = Record<string, unknown>;
|
||||
@ -46,6 +47,27 @@ interface BrowserApiResponse {
|
||||
responseText: string;
|
||||
}
|
||||
|
||||
interface CityLocationContext {
|
||||
id: string;
|
||||
city: string;
|
||||
regionLong: string;
|
||||
regionShort: string;
|
||||
countryLong: string;
|
||||
countryShort: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
formattedAddress: string;
|
||||
population: number | null;
|
||||
radiusMiles: number;
|
||||
}
|
||||
|
||||
interface NominatimResult {
|
||||
lat?: string;
|
||||
lon?: string;
|
||||
display_name?: string;
|
||||
address?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function emitProgress(payload: Record<string, unknown>): void {
|
||||
if (process.env.JOBOPS_EMIT_PROGRESS !== "1") return;
|
||||
console.log(`${JOBOPS_PROGRESS_PREFIX}${JSON.stringify(payload)}`);
|
||||
@ -191,6 +213,261 @@ function parseTotalCount(payload: unknown): number | null {
|
||||
return toNumberOrNull(payloadRecord.total);
|
||||
}
|
||||
|
||||
function buildCityLocationId(input: string): string {
|
||||
const normalized = input.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
return `city_${normalized}`.slice(0, 32);
|
||||
}
|
||||
|
||||
function toRegionShortName(value: string): string {
|
||||
const compact = value
|
||||
.replace(/[^a-zA-Z\s]/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (compact.length === 0) return "REG";
|
||||
if (compact.length === 1) {
|
||||
return compact[0].slice(0, 3).toUpperCase();
|
||||
}
|
||||
return compact
|
||||
.slice(0, 3)
|
||||
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function resolveCityLocationContext(args: {
|
||||
city: string;
|
||||
countryLong: string;
|
||||
countryShort: string;
|
||||
radiusMiles: number;
|
||||
}): Promise<CityLocationContext | null> {
|
||||
const query = `${args.city}, ${args.countryLong}`;
|
||||
const url = new URL("https://nominatim.openstreetmap.org/search");
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("format", "jsonv2");
|
||||
url.searchParams.set("addressdetails", "1");
|
||||
url.searchParams.set("limit", "1");
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "job-ops-hiringcafe-extractor/1.0",
|
||||
},
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`geocode failed (${response.status})`);
|
||||
}
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (!Array.isArray(payload) || payload.length === 0) {
|
||||
throw new Error("geocode returned no results");
|
||||
}
|
||||
const first = payload[0] as NominatimResult;
|
||||
const lat = Number(first.lat ?? "");
|
||||
const lon = Number(first.lon ?? "");
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
throw new Error("invalid geocode coordinates");
|
||||
}
|
||||
const address = asRecord(first.address);
|
||||
const regionLong =
|
||||
toStringOrNull(address?.state) ??
|
||||
toStringOrNull(address?.county) ??
|
||||
toStringOrNull(address?.region) ??
|
||||
args.countryLong;
|
||||
const displayName =
|
||||
toStringOrNull(first.display_name) ??
|
||||
`${args.city}, ${regionLong}, ${args.countryShort}`;
|
||||
return {
|
||||
id: buildCityLocationId(args.city),
|
||||
city: args.city,
|
||||
regionLong,
|
||||
regionShort: toRegionShortName(regionLong),
|
||||
countryLong: args.countryLong,
|
||||
countryShort: args.countryShort,
|
||||
lat,
|
||||
lon,
|
||||
formattedAddress: displayName,
|
||||
population: null,
|
||||
radiusMiles: args.radiusMiles,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`City geocode failed for '${query}': ${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createCitySearchState(args: {
|
||||
searchQuery: string;
|
||||
dateFetchedPastNDays: number;
|
||||
context: CityLocationContext;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
locations: [
|
||||
{
|
||||
id: args.context.id,
|
||||
types: ["locality"],
|
||||
address_components: [
|
||||
{
|
||||
long_name: args.context.city,
|
||||
short_name: args.context.city,
|
||||
types: ["locality"],
|
||||
},
|
||||
{
|
||||
long_name: args.context.regionLong,
|
||||
short_name: args.context.regionShort,
|
||||
types: ["administrative_area_level_1"],
|
||||
},
|
||||
{
|
||||
long_name: args.context.countryLong,
|
||||
short_name: args.context.countryShort,
|
||||
types: ["country"],
|
||||
},
|
||||
],
|
||||
geometry: {
|
||||
location: {
|
||||
lat: args.context.lat,
|
||||
lon: args.context.lon,
|
||||
},
|
||||
},
|
||||
formatted_address: args.context.formattedAddress,
|
||||
population: args.context.population,
|
||||
workplace_types: [],
|
||||
options: {
|
||||
radius: args.context.radiusMiles,
|
||||
radius_unit: "miles",
|
||||
ignore_radius: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
workplaceTypes: ["Remote", "Hybrid", "Onsite"],
|
||||
defaultToUserLocation: true,
|
||||
userLocation: null,
|
||||
physicalEnvironments: [
|
||||
"Office",
|
||||
"Outdoor",
|
||||
"Vehicle",
|
||||
"Industrial",
|
||||
"Customer-Facing",
|
||||
],
|
||||
physicalLaborIntensity: ["Low", "Medium", "High"],
|
||||
physicalPositions: ["Sitting", "Standing"],
|
||||
oralCommunicationLevels: ["Low", "Medium", "High"],
|
||||
computerUsageLevels: ["Low", "Medium", "High"],
|
||||
cognitiveDemandLevels: ["Low", "Medium", "High"],
|
||||
currency: { label: "Any", value: null },
|
||||
frequency: { label: "Any", value: null },
|
||||
minCompensationLowEnd: null,
|
||||
minCompensationHighEnd: null,
|
||||
maxCompensationLowEnd: null,
|
||||
maxCompensationHighEnd: null,
|
||||
restrictJobsToTransparentSalaries: false,
|
||||
calcFrequency: "Yearly",
|
||||
commitmentTypes: [
|
||||
"Full Time",
|
||||
"Part Time",
|
||||
"Contract",
|
||||
"Internship",
|
||||
"Temporary",
|
||||
"Seasonal",
|
||||
"Volunteer",
|
||||
],
|
||||
jobTitleQuery: "",
|
||||
jobDescriptionQuery: "",
|
||||
associatesDegreeFieldsOfStudy: [],
|
||||
excludedAssociatesDegreeFieldsOfStudy: [],
|
||||
bachelorsDegreeFieldsOfStudy: [],
|
||||
excludedBachelorsDegreeFieldsOfStudy: [],
|
||||
mastersDegreeFieldsOfStudy: [],
|
||||
excludedMastersDegreeFieldsOfStudy: [],
|
||||
doctorateDegreeFieldsOfStudy: [],
|
||||
excludedDoctorateDegreeFieldsOfStudy: [],
|
||||
associatesDegreeRequirements: [],
|
||||
bachelorsDegreeRequirements: [],
|
||||
mastersDegreeRequirements: [],
|
||||
doctorateDegreeRequirements: [],
|
||||
licensesAndCertifications: [],
|
||||
excludedLicensesAndCertifications: [],
|
||||
excludeAllLicensesAndCertifications: false,
|
||||
seniorityLevel: [
|
||||
"No Prior Experience Required",
|
||||
"Entry Level",
|
||||
"Mid Level",
|
||||
"Senior Level",
|
||||
],
|
||||
roleTypes: ["Individual Contributor", "People Manager"],
|
||||
roleYoeRange: [0, 20],
|
||||
excludeIfRoleYoeIsNotSpecified: false,
|
||||
managementYoeRange: [0, 20],
|
||||
excludeIfManagementYoeIsNotSpecified: false,
|
||||
securityClearances: [
|
||||
"None",
|
||||
"Confidential",
|
||||
"Secret",
|
||||
"Top Secret",
|
||||
"Top Secret/SCI",
|
||||
"Public Trust",
|
||||
"Interim Clearances",
|
||||
"Other",
|
||||
],
|
||||
languageRequirements: [],
|
||||
excludedLanguageRequirements: [],
|
||||
languageRequirementsOperator: "OR",
|
||||
excludeJobsWithAdditionalLanguageRequirements: false,
|
||||
airTravelRequirement: ["None", "Minimal", "Moderate", "Extensive"],
|
||||
landTravelRequirement: ["None", "Minimal", "Moderate", "Extensive"],
|
||||
morningShiftWork: [],
|
||||
eveningShiftWork: [],
|
||||
overnightShiftWork: [],
|
||||
weekendAvailabilityRequired: "Doesn't Matter",
|
||||
holidayAvailabilityRequired: "Doesn't Matter",
|
||||
overtimeRequired: "Doesn't Matter",
|
||||
onCallRequirements: [
|
||||
"None",
|
||||
"Occasional (once a month or less)",
|
||||
"Regular (once a week or more)",
|
||||
],
|
||||
benefitsAndPerks: [],
|
||||
applicationFormEase: [],
|
||||
companyNames: [],
|
||||
excludedCompanyNames: [],
|
||||
companyHqCountries: [],
|
||||
excludedCompanyHqCountries: [],
|
||||
usaGovPref: null,
|
||||
industries: [],
|
||||
excludedIndustries: [],
|
||||
companyKeywords: [],
|
||||
companyKeywordsBooleanOperator: "OR",
|
||||
excludedCompanyKeywords: [],
|
||||
hideJobTypes: [],
|
||||
encouragedToApply: [],
|
||||
searchQuery: args.searchQuery,
|
||||
dateFetchedPastNDays: args.dateFetchedPastNDays,
|
||||
hiddenCompanies: [],
|
||||
user: null,
|
||||
searchModeSelectedCompany: null,
|
||||
departments: [],
|
||||
restrictedSearchAttributes: [],
|
||||
sortBy: "default",
|
||||
technologyKeywordsQuery: "",
|
||||
requirementsKeywordsQuery: "",
|
||||
companyPublicOrPrivate: "all",
|
||||
latestInvestmentYearRange: [null, null],
|
||||
latestInvestmentSeries: [],
|
||||
latestInvestmentAmount: null,
|
||||
latestInvestmentCurrency: [],
|
||||
investors: [],
|
||||
excludedInvestors: [],
|
||||
isNonProfit: "all",
|
||||
organizationTypes: [],
|
||||
excludedOrganizationTypes: [],
|
||||
companySizeRanges: [],
|
||||
minYearFounded: null,
|
||||
maxYearFounded: null,
|
||||
excludedLatestInvestmentSeries: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function callHiringCafeApi(
|
||||
page: Page,
|
||||
endpoint: string,
|
||||
@ -267,6 +544,11 @@ async function run(): Promise<void> {
|
||||
process.env.HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS,
|
||||
DEFAULT_DATE_FETCHED_PAST_N_DAYS,
|
||||
);
|
||||
const locationQuery = process.env.HIRING_CAFE_LOCATION_QUERY?.trim() ?? "";
|
||||
const locationRadiusMiles = parsePositiveInt(
|
||||
process.env.HIRING_CAFE_LOCATION_RADIUS_MILES,
|
||||
DEFAULT_LOCATION_RADIUS_MILES,
|
||||
);
|
||||
const outputPath =
|
||||
process.env.HIRING_CAFE_OUTPUT_JSON ||
|
||||
join(__dirname, "../storage/datasets/default/jobs.json");
|
||||
@ -308,6 +590,20 @@ async function run(): Promise<void> {
|
||||
await initializePage();
|
||||
}
|
||||
|
||||
const countryLocation = resolveHiringCafeCountryLocation(country);
|
||||
const countryLong =
|
||||
countryLocation?.address_components[0]?.long_name ?? "United Kingdom";
|
||||
const countryShort =
|
||||
countryLocation?.address_components[0]?.short_name ?? "GB";
|
||||
const cityLocationContext = locationQuery
|
||||
? await resolveCityLocationContext({
|
||||
city: locationQuery,
|
||||
countryLong,
|
||||
countryShort,
|
||||
radiusMiles: locationRadiusMiles,
|
||||
})
|
||||
: null;
|
||||
|
||||
for (let i = 0; i < searchTerms.length; i += 1) {
|
||||
const searchTerm = searchTerms[i];
|
||||
const termIndex = i + 1;
|
||||
@ -319,10 +615,15 @@ async function run(): Promise<void> {
|
||||
searchTerm,
|
||||
});
|
||||
|
||||
const location = resolveHiringCafeCountryLocation(country);
|
||||
const searchState = createDefaultSearchState({
|
||||
const searchState = cityLocationContext
|
||||
? createCitySearchState({
|
||||
searchQuery: searchTerm,
|
||||
location,
|
||||
dateFetchedPastNDays,
|
||||
context: cityLocationContext,
|
||||
})
|
||||
: createDefaultSearchState({
|
||||
searchQuery: searchTerm,
|
||||
location: countryLocation,
|
||||
dateFetchedPastNDays,
|
||||
});
|
||||
const encodedSearchState = encodeSearchState(searchState);
|
||||
|
||||
@ -56,6 +56,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"express": "^4.18.2",
|
||||
"framer-motion": "^12.34.3",
|
||||
"get-tsconfig": "^4.10.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"jsdom": "^25.0.1",
|
||||
|
||||
@ -6,6 +6,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { OrchestratorPage } from "./OrchestratorPage";
|
||||
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
@ -51,14 +52,15 @@ let mockPipelineTerminalEvent: {
|
||||
token: number;
|
||||
} | null = null;
|
||||
let mockPipelineSources = ["linkedin"] as Array<
|
||||
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs"
|
||||
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs" | "adzuna" | "hiringcafe"
|
||||
>;
|
||||
let mockAutomaticRunValues = {
|
||||
let mockAutomaticRunValues: AutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
};
|
||||
|
||||
const jobFixture = createJob({
|
||||
@ -325,13 +327,7 @@ vi.mock("./orchestrator/RunModeModal", () => ({
|
||||
RunModeModal: ({
|
||||
onSaveAndRunAutomatic,
|
||||
}: {
|
||||
onSaveAndRunAutomatic: (values: {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
searchTerms: string[];
|
||||
runBudget: number;
|
||||
country: string;
|
||||
}) => Promise<void>;
|
||||
onSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise<void>;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
@ -386,6 +382,7 @@ describe("OrchestratorPage", () => {
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
};
|
||||
});
|
||||
|
||||
@ -701,7 +698,7 @@ describe("OrchestratorPage", () => {
|
||||
ukvisajobsMaxJobs: 150,
|
||||
adzunaMaxJobsPerTerm: 150,
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "United Kingdom",
|
||||
searchCities: "United Kingdom",
|
||||
});
|
||||
});
|
||||
expect(api.runPipeline).toHaveBeenCalledWith({
|
||||
@ -714,6 +711,108 @@ describe("OrchestratorPage", () => {
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("stores multiple cities for JobSpy sources in automatic mode", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
mockPipelineSources = ["linkedin"];
|
||||
mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["London", "Manchester"],
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchCities: "London|Manchester",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stores multiple cities when only adzuna is selected", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
mockPipelineSources = ["adzuna"];
|
||||
mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["Leeds", "Manchester"],
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchCities: "Leeds|Manchester",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stores multiple cities when only hiringcafe is selected", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
mockPipelineSources = ["hiringcafe"];
|
||||
mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["Leeds", "Manchester"],
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchCities: "Leeds|Manchester",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows completion toast from hook terminal state", async () => {
|
||||
mockPipelineTerminalEvent = {
|
||||
status: "completed",
|
||||
@ -797,6 +896,7 @@ describe("OrchestratorPage", () => {
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united states",
|
||||
cityLocations: [],
|
||||
};
|
||||
|
||||
render(
|
||||
|
||||
@ -22,7 +22,10 @@ import * as api from "../api";
|
||||
import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar";
|
||||
import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog";
|
||||
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
|
||||
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
|
||||
import {
|
||||
deriveExtractorLimits,
|
||||
serializeCityLocationsSetting,
|
||||
} from "./orchestrator/automatic-run";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
import { tabs } from "./orchestrator/constants";
|
||||
import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar";
|
||||
@ -291,9 +294,20 @@ export const OrchestratorPage: React.FC = () => {
|
||||
searchTerms: values.searchTerms,
|
||||
sources: compatibleSources,
|
||||
});
|
||||
const jobspyLocation = compatibleSources.includes("glassdoor")
|
||||
? (values.glassdoorLocation ?? "").trim() ||
|
||||
formatCountryLabel(values.country)
|
||||
const hasJobSpySite = compatibleSources.some(
|
||||
(source) =>
|
||||
source === "indeed" ||
|
||||
source === "linkedin" ||
|
||||
source === "glassdoor",
|
||||
);
|
||||
const hasAdzuna = compatibleSources.includes("adzuna");
|
||||
const hasHiringCafe = compatibleSources.includes("hiringcafe");
|
||||
const serializedCities = serializeCityLocationsSetting(
|
||||
values.cityLocations,
|
||||
);
|
||||
const searchCities =
|
||||
(hasJobSpySite || hasAdzuna || hasHiringCafe) && serializedCities
|
||||
? serializedCities
|
||||
: formatCountryLabel(values.country);
|
||||
await api.updateSettings({
|
||||
searchTerms: values.searchTerms,
|
||||
@ -302,7 +316,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
||||
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
|
||||
jobspyCountryIndeed: values.country,
|
||||
jobspyLocation,
|
||||
searchCities,
|
||||
});
|
||||
await refreshSettings();
|
||||
await startPipelineRun({
|
||||
|
||||
@ -70,8 +70,8 @@ const baseSettings = createAppSettings({
|
||||
defaultJobspyResultsWanted: 200,
|
||||
jobspyCountryIndeed: "UK",
|
||||
defaultJobspyCountryIndeed: "UK",
|
||||
jobspyLocation: "UK",
|
||||
defaultJobspyLocation: "UK",
|
||||
searchCities: "London",
|
||||
defaultSearchCities: "London",
|
||||
searchTerms: ["engineer"],
|
||||
defaultSearchTerms: ["engineer"],
|
||||
});
|
||||
|
||||
@ -25,7 +25,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "us",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@ -41,6 +41,29 @@ describe("AutomaticRunTab", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps legacy usa/ca country to United States in the picker", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "usa/ca",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "United States" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables and prunes UK-only sources for non-UK country", async () => {
|
||||
const onSetPipelineSources = vi.fn();
|
||||
|
||||
@ -50,7 +73,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
@ -76,7 +99,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@ -103,7 +126,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "japan",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
@ -134,7 +157,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "United Kingdom",
|
||||
searchCities: "United Kingdom",
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
@ -152,7 +175,7 @@ describe("AutomaticRunTab", () => {
|
||||
const glassdoorButton = screen.getByRole("button", { name: "Glassdoor" });
|
||||
expect(glassdoorButton).toBeDisabled();
|
||||
expect(glassdoorButton.getAttribute("title")).toContain(
|
||||
"Set a Glassdoor city in Advanced settings to enable Glassdoor.",
|
||||
"Add at least one city in Advanced settings to enable Glassdoor.",
|
||||
);
|
||||
});
|
||||
|
||||
@ -163,7 +186,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer", "frontend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@ -175,6 +198,7 @@ describe("AutomaticRunTab", () => {
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type and press Enter");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "Backspace" });
|
||||
|
||||
expect(
|
||||
@ -184,4 +208,34 @@ describe("AutomaticRunTab", () => {
|
||||
screen.getByRole("button", { name: "Remove frontend engineer" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads multiple saved cities and keeps glassdoor enabled", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "London|Manchester",
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||
fireEvent.focus(screen.getByLabelText("Cities"));
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove city London" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove city Manchester" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "@shared/location-support.js";
|
||||
import type { AppSettings, JobSource } from "@shared/types";
|
||||
import { Loader2, Sparkles, X } from "lucide-react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
@ -33,9 +33,12 @@ import {
|
||||
type AutomaticRunValues,
|
||||
calculateAutomaticEstimate,
|
||||
loadAutomaticRunMemory,
|
||||
parseCityLocationsInput,
|
||||
parseCityLocationsSetting,
|
||||
parseSearchTermsInput,
|
||||
saveAutomaticRunMemory,
|
||||
} from "./automatic-run";
|
||||
import { TokenizedInput } from "./TokenizedInput";
|
||||
|
||||
interface AutomaticRunTabProps {
|
||||
open: boolean;
|
||||
@ -54,6 +57,7 @@ const DEFAULT_VALUES: AutomaticRunValues = {
|
||||
searchTerms: ["web developer"],
|
||||
runBudget: 200,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
};
|
||||
|
||||
interface AutomaticRunFormValues {
|
||||
@ -61,7 +65,8 @@ interface AutomaticRunFormValues {
|
||||
minSuitabilityScore: string;
|
||||
runBudget: string;
|
||||
country: string;
|
||||
glassdoorLocation: string;
|
||||
cityLocations: string[];
|
||||
cityLocationDraft: string;
|
||||
searchTerms: string[];
|
||||
searchTermDraft: string;
|
||||
}
|
||||
@ -71,8 +76,15 @@ type AutomaticPresetSelection = AutomaticPresetId | "custom";
|
||||
const GLASSDOOR_COUNTRY_REASON =
|
||||
"Glassdoor is not available for the selected country.";
|
||||
const GLASSDOOR_LOCATION_REASON =
|
||||
"Set a Glassdoor city in Advanced settings to enable Glassdoor.";
|
||||
"Add at least one city in Advanced settings to enable Glassdoor.";
|
||||
const UK_ONLY_SOURCES = new Set<JobSource>(["gradcracker", "ukvisajobs"]);
|
||||
const HIDDEN_COUNTRY_KEYS = new Set(["usa/ca"]);
|
||||
|
||||
function normalizeUiCountryKey(value: string): string {
|
||||
const normalized = normalizeCountryKey(value);
|
||||
if (normalized === "usa/ca") return "united states";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getSourceDisabledReason(
|
||||
source: JobSource,
|
||||
@ -138,25 +150,25 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
|
||||
{
|
||||
const { watch, reset, setValue } = useForm<AutomaticRunFormValues>({
|
||||
defaultValues: {
|
||||
topN: String(DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
|
||||
runBudget: String(DEFAULT_VALUES.runBudget),
|
||||
country: DEFAULT_VALUES.country,
|
||||
glassdoorLocation: "",
|
||||
cityLocations: [],
|
||||
cityLocationDraft: "",
|
||||
searchTerms: DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const topNInput = watch("topN");
|
||||
const minScoreInput = watch("minSuitabilityScore");
|
||||
const runBudgetInput = watch("runBudget");
|
||||
const countryInput = watch("country");
|
||||
const glassdoorLocationInput = watch("glassdoorLocation");
|
||||
const cityLocations = watch("cityLocations");
|
||||
const cityLocationDraft = watch("cityLocationDraft");
|
||||
const searchTerms = watch("searchTerms");
|
||||
const searchTermDraft = watch("searchTermDraft");
|
||||
|
||||
@ -173,48 +185,35 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
settings?.gradcrackerMaxJobsPerTerm ??
|
||||
settings?.ukvisajobsMaxJobs ??
|
||||
DEFAULT_VALUES.runBudget;
|
||||
const rememberedCountry = normalizeCountryKey(
|
||||
const rememberedCountry = normalizeUiCountryKey(
|
||||
settings?.jobspyCountryIndeed ??
|
||||
settings?.jobspyLocation ??
|
||||
settings?.searchCities ??
|
||||
DEFAULT_VALUES.country,
|
||||
);
|
||||
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
|
||||
const rememberedLocationRaw = settings?.jobspyLocation?.trim() ?? "";
|
||||
const rememberedLocationNormalized = normalizeCountryKey(
|
||||
rememberedLocationRaw,
|
||||
const rememberedLocations = parseCityLocationsSetting(
|
||||
settings?.searchCities,
|
||||
).filter(
|
||||
(location) =>
|
||||
normalizeCountryKey(location) !==
|
||||
normalizeCountryKey(rememberedCountryKey),
|
||||
);
|
||||
const rememberedGlassdoorLocation =
|
||||
rememberedLocationRaw &&
|
||||
rememberedLocationNormalized &&
|
||||
rememberedLocationNormalized !== normalizeCountryKey(rememberedCountryKey)
|
||||
? rememberedLocationRaw
|
||||
: "";
|
||||
|
||||
reset({
|
||||
topN: String(topN),
|
||||
minSuitabilityScore: String(minSuitabilityScore),
|
||||
runBudget: String(rememberedRunBudget),
|
||||
country: rememberedCountry || DEFAULT_VALUES.country,
|
||||
glassdoorLocation: rememberedGlassdoorLocation,
|
||||
cityLocations: rememberedLocations,
|
||||
cityLocationDraft: "",
|
||||
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
});
|
||||
setAdvancedOpen(false);
|
||||
}, [open, settings, reset]);
|
||||
|
||||
const addSearchTerms = (input: string) => {
|
||||
const parsed = parseSearchTermsInput(input);
|
||||
if (parsed.length === 0) return;
|
||||
const current = getValues("searchTerms");
|
||||
const next = [...current];
|
||||
for (const term of parsed) {
|
||||
if (!next.includes(term)) next.push(term);
|
||||
}
|
||||
setValue("searchTerms", next, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const values = useMemo<AutomaticRunValues>(() => {
|
||||
const normalizedCountry = normalizeCountryKey(countryInput);
|
||||
const normalizedCountry = normalizeUiCountryKey(countryInput);
|
||||
return {
|
||||
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: toNumber(
|
||||
@ -225,7 +224,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
),
|
||||
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
|
||||
country: normalizedCountry || DEFAULT_VALUES.country,
|
||||
glassdoorLocation: glassdoorLocationInput.trim() || undefined,
|
||||
cityLocations,
|
||||
searchTerms,
|
||||
};
|
||||
}, [
|
||||
@ -233,17 +232,18 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
minScoreInput,
|
||||
runBudgetInput,
|
||||
countryInput,
|
||||
glassdoorLocationInput,
|
||||
cityLocations,
|
||||
searchTerms,
|
||||
]);
|
||||
|
||||
const isSourceAvailableForRun = useCallback(
|
||||
(source: JobSource) => {
|
||||
if (!isSourceAllowedForCountry(source, values.country)) return false;
|
||||
if (source === "glassdoor" && !values.glassdoorLocation) return false;
|
||||
if (source === "glassdoor" && values.cityLocations.length === 0)
|
||||
return false;
|
||||
return true;
|
||||
},
|
||||
[values.country, values.glassdoorLocation],
|
||||
[values.country, values.cityLocations.length],
|
||||
);
|
||||
|
||||
const compatibleEnabledSources = useMemo(
|
||||
@ -319,7 +319,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
|
||||
const countryOptions = useMemo(
|
||||
() =>
|
||||
SUPPORTED_COUNTRY_KEYS.map((country) => ({
|
||||
SUPPORTED_COUNTRY_KEYS.filter(
|
||||
(country) => !HIDDEN_COUNTRY_KEYS.has(country),
|
||||
).map((country) => ({
|
||||
value: country,
|
||||
label: formatCountryLabel(country),
|
||||
})),
|
||||
@ -389,7 +391,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={advancedOpen ? "advanced" : undefined}
|
||||
value={advancedOpen ? "advanced" : ""}
|
||||
onValueChange={(value) => setAdvancedOpen(value === "advanced")}
|
||||
>
|
||||
<AccordionItem value="advanced" className="border-b-0">
|
||||
@ -438,21 +440,24 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<Label htmlFor="glassdoor-location">Glassdoor city</Label>
|
||||
<Input
|
||||
id="glassdoor-location"
|
||||
value={glassdoorLocationInput}
|
||||
onChange={(event) =>
|
||||
setValue("glassdoorLocation", event.target.value, {
|
||||
<Label htmlFor="city-locations-input">Cities</Label>
|
||||
<TokenizedInput
|
||||
id="city-locations-input"
|
||||
values={cityLocations}
|
||||
draft={cityLocationDraft}
|
||||
parseInput={parseCityLocationsInput}
|
||||
onDraftChange={(value) =>
|
||||
setValue("cityLocationDraft", value)
|
||||
}
|
||||
onValuesChange={(value) =>
|
||||
setValue("cityLocations", value, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
placeholder='e.g. "London"'
|
||||
helperText="Optional for all sources, required when Glassdoor is selected."
|
||||
removeLabelPrefix="Remove city"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required only for Glassdoor. Use a city (not country) to
|
||||
keep results localized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
@ -465,58 +470,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Search terms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
<CardContent>
|
||||
<TokenizedInput
|
||||
id="search-terms-input"
|
||||
value={searchTermDraft}
|
||||
onChange={(event) =>
|
||||
setValue("searchTermDraft", event.target.value)
|
||||
values={searchTerms}
|
||||
draft={searchTermDraft}
|
||||
parseInput={parseSearchTermsInput}
|
||||
onDraftChange={(value) => setValue("searchTermDraft", value)}
|
||||
onValuesChange={(value) =>
|
||||
setValue("searchTerms", value, { shouldDirty: true })
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
addSearchTerms(searchTermDraft);
|
||||
setValue("searchTermDraft", "");
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
addSearchTerms(searchTermDraft);
|
||||
setValue("searchTermDraft", "");
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const pasted = event.clipboardData.getData("text");
|
||||
const parsed = parseSearchTermsInput(pasted);
|
||||
if (parsed.length > 1) {
|
||||
event.preventDefault();
|
||||
addSearchTerms(pasted);
|
||||
}
|
||||
}}
|
||||
placeholder="Type and press Enter"
|
||||
helperText="Add multiple terms by separating with commas or pressing Enter."
|
||||
removeLabelPrefix="Remove"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add multiple terms by separating with commas or pressing Enter.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{searchTerms.map((term) => (
|
||||
<button
|
||||
type="button"
|
||||
key={term}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/20 px-3 py-1 text-sm transition-all duration-150 hover:border-primary/50 hover:bg-primary/40 hover:text-primary-foreground hover:shadow-sm"
|
||||
aria-label={`Remove ${term}`}
|
||||
onClick={() =>
|
||||
setValue(
|
||||
"searchTerms",
|
||||
searchTerms.filter((value) => value !== term),
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
{term}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FloatingJobActionsBarProps {
|
||||
selectedCount: number;
|
||||
@ -26,32 +25,17 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
|
||||
onRescoreSelected,
|
||||
onClear,
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCount > 0) {
|
||||
setIsMounted(true);
|
||||
const enterTimer = window.setTimeout(() => setIsVisible(true), 10);
|
||||
return () => window.clearTimeout(enterTimer);
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
const exitTimer = window.setTimeout(() => setIsMounted(false), 180);
|
||||
return () => window.clearTimeout(exitTimer);
|
||||
}, [selectedCount]);
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center",
|
||||
"transition-all duration-200 ease-out",
|
||||
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{selectedCount > 0 ? (
|
||||
<motion.div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 16 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
>
|
||||
<div className="pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
@ -104,6 +88,8 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
186
orchestrator/src/client/pages/orchestrator/TokenizedInput.tsx
Normal file
186
orchestrator/src/client/pages/orchestrator/TokenizedInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -27,6 +27,7 @@ describe("automatic-run utilities", () => {
|
||||
searchTerms: ["backend", "platform"],
|
||||
runBudget: 100,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
@ -59,6 +60,7 @@ describe("automatic-run utilities", () => {
|
||||
searchTerms: [],
|
||||
runBudget: 750,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
@ -85,6 +87,7 @@ describe("automatic-run utilities", () => {
|
||||
searchTerms: ["backend", "platform"],
|
||||
runBudget: 120,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
},
|
||||
sources: ["adzuna"],
|
||||
});
|
||||
@ -101,6 +104,7 @@ describe("automatic-run utilities", () => {
|
||||
searchTerms: ["backend", "platform"],
|
||||
runBudget: 120,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
},
|
||||
sources: ["hiringcafe"],
|
||||
});
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
parseSearchCitiesSetting,
|
||||
serializeSearchCitiesSetting,
|
||||
} from "@shared/search-cities.js";
|
||||
import type { JobSource } from "@shared/types";
|
||||
|
||||
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
|
||||
@ -8,7 +12,7 @@ export interface AutomaticRunValues {
|
||||
searchTerms: string[];
|
||||
runBudget: number;
|
||||
country: string;
|
||||
glassdoorLocation?: string;
|
||||
cityLocations: string[];
|
||||
}
|
||||
|
||||
export interface AutomaticPresetValues {
|
||||
@ -115,6 +119,29 @@ export function parseSearchTermsInput(input: string): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseCityLocationsInput(input: string): string[] {
|
||||
const parsed = parseSearchTermsInput(input);
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const city of parsed) {
|
||||
const key = city.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(city);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseCityLocationsSetting(
|
||||
location: string | null | undefined,
|
||||
): string[] {
|
||||
return parseSearchCitiesSetting(location);
|
||||
}
|
||||
|
||||
export function serializeCityLocationsSetting(cities: string[]): string | null {
|
||||
return serializeSearchCitiesSetting(cities);
|
||||
}
|
||||
|
||||
export function stringifySearchTerms(terms: string[]): string {
|
||||
return terms.join("\n");
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
|
||||
backupEnabled: "0",
|
||||
backupHour: "2",
|
||||
backupMaxCount: "5",
|
||||
jobspyLocation: "United States",
|
||||
searchCities: "United States",
|
||||
jobspyResultsWanted: "25",
|
||||
jobspyCountryIndeed: "US",
|
||||
resumeProjects: JSON.stringify({
|
||||
|
||||
@ -116,6 +116,35 @@ describe("discoverJobsStep", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes serialized multi-city locations to JobSpy", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "London|Manchester",
|
||||
} as any);
|
||||
|
||||
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [],
|
||||
} as any);
|
||||
|
||||
await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["linkedin"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
location: "London|Manchester",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out glassdoor for unsupported countries", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
@ -201,6 +230,37 @@ describe("discoverJobsStep", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes configured city locations to adzuna", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const adzuna = await import("../../services/adzuna");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "Leeds|Manchester",
|
||||
} as any);
|
||||
|
||||
vi.mocked(adzuna.runAdzuna).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [],
|
||||
} as any);
|
||||
|
||||
await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["adzuna"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vi.mocked(adzuna.runAdzuna)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
country: "gb",
|
||||
countryKey: "united kingdom",
|
||||
locations: ["Leeds", "Manchester"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips adzuna for unsupported countries", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const adzuna = await import("../../services/adzuna");
|
||||
@ -257,12 +317,46 @@ describe("discoverJobsStep", () => {
|
||||
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
country: "united states",
|
||||
countryKey: "united states",
|
||||
locations: [],
|
||||
searchTerms: ["engineer"],
|
||||
maxJobsPerTerm: 25,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes configured city locations to hiringcafe", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const hiringCafe = await import("../../services/hiring-cafe");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyResultsWanted: "25",
|
||||
searchCities: "Leeds|Manchester",
|
||||
} as any);
|
||||
|
||||
vi.mocked(hiringCafe.runHiringCafe).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [],
|
||||
} as any);
|
||||
|
||||
await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["hiringcafe"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
country: "united kingdom",
|
||||
countryKey: "united kingdom",
|
||||
locations: ["Leeds", "Manchester"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates Hiring Cafe terms and pages via progress callbacks", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const hiringCafe = await import("../../services/hiring-cafe");
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
isSourceAllowedForCountry,
|
||||
normalizeCountryKey,
|
||||
} from "@shared/location-support.js";
|
||||
import { parseSearchCitiesSetting } from "@shared/search-cities.js";
|
||||
import type { CreateJobInput, PipelineConfig } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import * as settingsRepo from "../../repositories/settings";
|
||||
@ -59,7 +60,10 @@ export async function discoverJobsStep(args: {
|
||||
}
|
||||
|
||||
const selectedCountry = normalizeCountryKey(
|
||||
settings.jobspyCountryIndeed ?? settings.jobspyLocation ?? "united kingdom",
|
||||
settings.jobspyCountryIndeed ??
|
||||
settings.searchCities ??
|
||||
settings.jobspyLocation ??
|
||||
"united kingdom",
|
||||
);
|
||||
const compatibleSources = args.mergedConfig.sources.filter((source) =>
|
||||
isSourceAllowedForCountry(source, selectedCountry),
|
||||
@ -100,7 +104,8 @@ export async function discoverJobsStep(args: {
|
||||
const jobSpyResult = await runJobSpy({
|
||||
sites: jobSpySites,
|
||||
searchTerms,
|
||||
location: settings.jobspyLocation ?? undefined,
|
||||
location:
|
||||
settings.searchCities ?? settings.jobspyLocation ?? undefined,
|
||||
resultsWanted: settings.jobspyResultsWanted
|
||||
? parseInt(settings.jobspyResultsWanted, 10)
|
||||
: undefined,
|
||||
@ -172,6 +177,10 @@ export async function discoverJobsStep(args: {
|
||||
|
||||
const adzunaResult = await runAdzuna({
|
||||
country: adzunaCountryCode,
|
||||
countryKey: selectedCountry,
|
||||
locations: parseSearchCitiesSetting(
|
||||
settings.searchCities ?? settings.jobspyLocation,
|
||||
),
|
||||
searchTerms,
|
||||
maxJobsPerTerm: adzunaMaxJobsPerTerm,
|
||||
onProgress: (event) => {
|
||||
@ -249,6 +258,10 @@ export async function discoverJobsStep(args: {
|
||||
|
||||
const hiringCafeResult = await runHiringCafe({
|
||||
country: selectedCountry,
|
||||
countryKey: selectedCountry,
|
||||
locations: parseSearchCitiesSetting(
|
||||
settings.searchCities ?? settings.jobspyLocation,
|
||||
),
|
||||
searchTerms,
|
||||
maxJobsPerTerm: hiringCafeMaxJobsPerTerm,
|
||||
onProgress: (event) => {
|
||||
|
||||
@ -23,6 +23,7 @@ export type SettingKey =
|
||||
| "adzunaMaxJobsPerTerm"
|
||||
| "gradcrackerMaxJobsPerTerm"
|
||||
| "searchTerms"
|
||||
| "searchCities"
|
||||
| "jobspyLocation"
|
||||
| "jobspyResultsWanted"
|
||||
| "jobspyCountryIndeed"
|
||||
|
||||
26
orchestrator/src/server/services/adzuna.location.test.ts
Normal file
26
orchestrator/src/server/services/adzuna.location.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -5,6 +5,12 @@ import { dirname, join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { logger } from "@infra/logger";
|
||||
import { normalizeCountryKey } from "@shared/location-support.js";
|
||||
import {
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
shouldApplyStrictCityFilter,
|
||||
} from "@shared/search-cities.js";
|
||||
import type { CreateJobInput } from "@shared/types";
|
||||
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
|
||||
|
||||
@ -44,6 +50,8 @@ export type AdzunaProgressEvent =
|
||||
export interface RunAdzunaOptions {
|
||||
searchTerms?: string[];
|
||||
country?: string;
|
||||
countryKey?: string;
|
||||
locations?: string[];
|
||||
maxJobsPerTerm?: number;
|
||||
onProgress?: (event: AdzunaProgressEvent) => void;
|
||||
}
|
||||
@ -54,6 +62,27 @@ export interface AdzunaResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function shouldApplyStrictLocationFilter(
|
||||
location: string,
|
||||
countryKey: string,
|
||||
): boolean {
|
||||
return shouldApplyStrictCityFilter(location, countryKey);
|
||||
}
|
||||
|
||||
export function matchesRequestedLocation(
|
||||
jobLocation: string | undefined,
|
||||
requestedLocation: string,
|
||||
): boolean {
|
||||
return matchesRequestedCity(jobLocation, requestedLocation);
|
||||
}
|
||||
|
||||
function resolveLocations(options: RunAdzunaOptions): string[] {
|
||||
const raw = options.locations?.length
|
||||
? options.locations
|
||||
: parseSearchCitiesSetting(process.env.ADZUNA_LOCATION_QUERY ?? "");
|
||||
return raw.map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveTsxCliPath(): string | null {
|
||||
try {
|
||||
return require.resolve("tsx/dist/cli.mjs");
|
||||
@ -170,11 +199,15 @@ export async function runAdzuna(
|
||||
}
|
||||
|
||||
const country = (options.country || "gb").trim().toLowerCase();
|
||||
const countryKey = normalizeCountryKey(options.countryKey ?? "");
|
||||
const maxJobsPerTerm = options.maxJobsPerTerm ?? 50;
|
||||
const searchTerms =
|
||||
options.searchTerms && options.searchTerms.length > 0
|
||||
? options.searchTerms
|
||||
: ["web developer"];
|
||||
const locations = resolveLocations(options);
|
||||
const runLocations = locations.length > 0 ? locations : [null];
|
||||
const termTotal = searchTerms.length * runLocations.length;
|
||||
const useNpmCommand = canRunNpmCommand();
|
||||
if (!useNpmCommand && !TSX_CLI_PATH) {
|
||||
return {
|
||||
@ -185,6 +218,15 @@ export async function runAdzuna(
|
||||
}
|
||||
|
||||
try {
|
||||
const jobs: CreateJobInput[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
|
||||
const location = runLocations[runIndex];
|
||||
const strictLocationFilter =
|
||||
location !== null &&
|
||||
shouldApplyStrictLocationFilter(location, countryKey);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const extractorEnv = {
|
||||
...process.env,
|
||||
@ -195,6 +237,7 @@ export async function runAdzuna(
|
||||
ADZUNA_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
|
||||
ADZUNA_SEARCH_TERMS: JSON.stringify(searchTerms),
|
||||
ADZUNA_OUTPUT_JSON: DATASET_PATH,
|
||||
ADZUNA_LOCATION_QUERY: strictLocationFilter ? location : "",
|
||||
};
|
||||
const child = useNpmCommand
|
||||
? spawn("npm", ["run", "start"], {
|
||||
@ -219,7 +262,12 @@ export async function runAdzuna(
|
||||
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
|
||||
const progressEvent = parseAdzunaProgressLine(line);
|
||||
if (progressEvent) {
|
||||
options.onProgress?.(progressEvent);
|
||||
const termOffset = runIndex * searchTerms.length;
|
||||
options.onProgress?.({
|
||||
...progressEvent,
|
||||
termIndex: termOffset + progressEvent.termIndex,
|
||||
termTotal,
|
||||
});
|
||||
return;
|
||||
}
|
||||
stream.write(`${line}\n`);
|
||||
@ -244,7 +292,21 @@ export async function runAdzuna(
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
const jobs = await readDataset();
|
||||
const runJobs = await readDataset();
|
||||
const filtered = strictLocationFilter
|
||||
? runJobs.filter((job) =>
|
||||
matchesRequestedLocation(job.location, location),
|
||||
)
|
||||
: runJobs;
|
||||
|
||||
for (const job of filtered) {
|
||||
const key = job.sourceJobId || job.jobUrl;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
jobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, jobs };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -6,6 +6,12 @@ import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { logger } from "@infra/logger";
|
||||
import { sanitizeUnknown } from "@infra/sanitize";
|
||||
import { normalizeCountryKey } from "@shared/location-support.js";
|
||||
import {
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
shouldApplyStrictCityFilter,
|
||||
} from "@shared/search-cities.js";
|
||||
import type { CreateJobInput } from "@shared/types";
|
||||
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
|
||||
|
||||
@ -50,6 +56,9 @@ export type HiringCafeProgressEvent =
|
||||
export interface RunHiringCafeOptions {
|
||||
searchTerms?: string[];
|
||||
country?: string;
|
||||
countryKey?: string;
|
||||
locations?: string[];
|
||||
locationRadiusMiles?: number;
|
||||
maxJobsPerTerm?: number;
|
||||
onProgress?: (event: HiringCafeProgressEvent) => void;
|
||||
}
|
||||
@ -60,6 +69,27 @@ export interface HiringCafeResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function shouldApplyStrictLocationFilter(
|
||||
location: string,
|
||||
countryKey: string,
|
||||
): boolean {
|
||||
return shouldApplyStrictCityFilter(location, countryKey);
|
||||
}
|
||||
|
||||
export function matchesRequestedLocation(
|
||||
jobLocation: string | undefined,
|
||||
requestedLocation: string,
|
||||
): boolean {
|
||||
return matchesRequestedCity(jobLocation, requestedLocation);
|
||||
}
|
||||
|
||||
function resolveLocations(options: RunHiringCafeOptions): string[] {
|
||||
const raw = options.locations?.length
|
||||
? options.locations
|
||||
: parseSearchCitiesSetting(process.env.HIRING_CAFE_LOCATION_QUERY ?? "");
|
||||
return raw.map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveTsxCliPath(): string | null {
|
||||
try {
|
||||
return require.resolve("tsx/dist/cli.mjs");
|
||||
@ -182,7 +212,15 @@ export async function runHiringCafe(
|
||||
? options.searchTerms
|
||||
: ["web developer"];
|
||||
const country = (options.country || "united kingdom").trim().toLowerCase();
|
||||
const countryKey = normalizeCountryKey(options.countryKey ?? "");
|
||||
const maxJobsPerTerm = options.maxJobsPerTerm ?? 200;
|
||||
const locationRadiusMiles = Math.max(
|
||||
1,
|
||||
Math.floor(options.locationRadiusMiles ?? 1),
|
||||
);
|
||||
const locations = resolveLocations(options);
|
||||
const runLocations = locations.length > 0 ? locations : [null];
|
||||
const termTotal = searchTerms.length * runLocations.length;
|
||||
|
||||
const useNpmCommand = canRunNpmCommand();
|
||||
if (!useNpmCommand && !TSX_CLI_PATH) {
|
||||
@ -194,6 +232,15 @@ export async function runHiringCafe(
|
||||
}
|
||||
|
||||
try {
|
||||
const jobs: CreateJobInput[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
|
||||
const location = runLocations[runIndex];
|
||||
const strictLocationFilter =
|
||||
location !== null &&
|
||||
shouldApplyStrictLocationFilter(location, countryKey);
|
||||
|
||||
await clearStorageDataset();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@ -204,6 +251,10 @@ export async function runHiringCafe(
|
||||
HIRING_CAFE_COUNTRY: country,
|
||||
HIRING_CAFE_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
|
||||
HIRING_CAFE_OUTPUT_JSON: DATASET_PATH,
|
||||
HIRING_CAFE_LOCATION_QUERY: strictLocationFilter ? location : "",
|
||||
HIRING_CAFE_LOCATION_RADIUS_MILES: strictLocationFilter
|
||||
? String(locationRadiusMiles)
|
||||
: "",
|
||||
};
|
||||
|
||||
const child = useNpmCommand
|
||||
@ -230,7 +281,12 @@ export async function runHiringCafe(
|
||||
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
|
||||
const progressEvent = parseProgressLine(line);
|
||||
if (progressEvent) {
|
||||
options.onProgress?.(progressEvent);
|
||||
const termOffset = runIndex * searchTerms.length;
|
||||
options.onProgress?.({
|
||||
...progressEvent,
|
||||
termIndex: termOffset + progressEvent.termIndex,
|
||||
termTotal,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -257,7 +313,21 @@ export async function runHiringCafe(
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
const jobs = await readDataset();
|
||||
const runJobs = await readDataset();
|
||||
const filtered = strictLocationFilter
|
||||
? runJobs.filter((job) =>
|
||||
matchesRequestedLocation(job.location, location),
|
||||
)
|
||||
: runJobs;
|
||||
|
||||
for (const job of filtered) {
|
||||
const key = job.sourceJobId || job.jobUrl;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
jobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, jobs };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseJobSpyProgressLine } from "./jobspy";
|
||||
import {
|
||||
matchesRequestedLocation,
|
||||
parseJobSpyProgressLine,
|
||||
shouldApplyStrictLocationFilter,
|
||||
} from "./jobspy";
|
||||
|
||||
describe("parseJobSpyProgressLine", () => {
|
||||
it("parses term_start progress lines", () => {
|
||||
@ -38,3 +42,24 @@ describe("parseJobSpyProgressLine", () => {
|
||||
expect(parseJobSpyProgressLine("Found 20 jobs")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("strict location filtering", () => {
|
||||
it("enables strict filtering when location differs from country", () => {
|
||||
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables strict filtering when location is country-level", () => {
|
||||
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
|
||||
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches location using case-insensitive contains checks", () => {
|
||||
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
|
||||
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,11 @@ import { mkdir, readFile, unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
shouldApplyStrictCityFilter,
|
||||
} from "@shared/search-cities.js";
|
||||
import type { CreateJobInput, JobSource } from "@shared/types";
|
||||
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
|
||||
import { getDataDir } from "../config/dataDir";
|
||||
@ -144,6 +149,7 @@ export interface RunJobSpyOptions {
|
||||
sites?: Array<JobSource>;
|
||||
searchTerms?: string[];
|
||||
location?: string;
|
||||
locations?: string[];
|
||||
resultsWanted?: number;
|
||||
hoursOld?: number;
|
||||
countryIndeed?: string;
|
||||
@ -158,6 +164,20 @@ export interface JobSpyResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function shouldApplyStrictLocationFilter(
|
||||
location: string,
|
||||
countryIndeed: string,
|
||||
): boolean {
|
||||
return shouldApplyStrictCityFilter(location, countryIndeed);
|
||||
}
|
||||
|
||||
export function matchesRequestedLocation(
|
||||
jobLocation: string | undefined,
|
||||
requestedLocation: string,
|
||||
): boolean {
|
||||
return matchesRequestedCity(jobLocation, requestedLocation);
|
||||
}
|
||||
|
||||
export async function runJobSpy(
|
||||
options: RunJobSpyOptions = {},
|
||||
): Promise<JobSpyResult> {
|
||||
@ -170,6 +190,9 @@ export async function runJobSpy(
|
||||
.join(",");
|
||||
|
||||
const searchTerms = resolveSearchTerms(options);
|
||||
const locations = resolveLocations(options);
|
||||
const countryIndeed =
|
||||
options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? "UK";
|
||||
if (searchTerms.length === 0) {
|
||||
return { success: true, jobs: [] };
|
||||
}
|
||||
@ -178,9 +201,13 @@ export async function runJobSpy(
|
||||
const jobs: CreateJobInput[] = [];
|
||||
const seenJobUrls = new Set<string>();
|
||||
|
||||
for (let i = 0; i < searchTerms.length; i++) {
|
||||
const searchTerm = searchTerms[i];
|
||||
const suffix = `${i + 1}_${slugForFilename(searchTerm)}`;
|
||||
const totalRuns = searchTerms.length * locations.length;
|
||||
let runIndex = 0;
|
||||
|
||||
for (const searchTerm of searchTerms) {
|
||||
for (const location of locations) {
|
||||
runIndex += 1;
|
||||
const suffix = `${runIndex}_${slugForFilename(searchTerm)}_${slugForFilename(location)}`;
|
||||
const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
|
||||
const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
|
||||
|
||||
@ -194,20 +221,18 @@ export async function runJobSpy(
|
||||
...process.env,
|
||||
JOBSPY_SITES: sites || "indeed,linkedin,glassdoor",
|
||||
JOBSPY_SEARCH_TERM: searchTerm,
|
||||
JOBSPY_TERM_INDEX: String(i + 1),
|
||||
JOBSPY_TERM_TOTAL: String(searchTerms.length),
|
||||
JOBSPY_LOCATION:
|
||||
options.location ?? process.env.JOBSPY_LOCATION ?? "UK",
|
||||
JOBSPY_TERM_INDEX: String(runIndex),
|
||||
JOBSPY_TERM_TOTAL: String(totalRuns),
|
||||
JOBSPY_LOCATION: location,
|
||||
JOBSPY_RESULTS_WANTED: String(
|
||||
options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200,
|
||||
options.resultsWanted ??
|
||||
process.env.JOBSPY_RESULTS_WANTED ??
|
||||
200,
|
||||
),
|
||||
JOBSPY_HOURS_OLD: String(
|
||||
options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72,
|
||||
),
|
||||
JOBSPY_COUNTRY_INDEED:
|
||||
options.countryIndeed ??
|
||||
process.env.JOBSPY_COUNTRY_INDEED ??
|
||||
"UK",
|
||||
JOBSPY_COUNTRY_INDEED: countryIndeed,
|
||||
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
|
||||
options.linkedinFetchDescription ??
|
||||
process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ??
|
||||
@ -252,8 +277,17 @@ export async function runJobSpy(
|
||||
const raw = await readFile(outputJson, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
const mapped = mapJobSpyRows(parsed);
|
||||
const strictLocationFilter = shouldApplyStrictLocationFilter(
|
||||
location,
|
||||
countryIndeed,
|
||||
);
|
||||
const filtered = strictLocationFilter
|
||||
? mapped.filter((job) =>
|
||||
matchesRequestedLocation(job.location, location),
|
||||
)
|
||||
: mapped;
|
||||
|
||||
for (const job of mapped) {
|
||||
for (const job of filtered) {
|
||||
const url = job.jobUrl;
|
||||
if (seenJobUrls.has(url)) continue;
|
||||
seenJobUrls.add(url);
|
||||
@ -267,6 +301,7 @@ export async function runJobSpy(
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, jobs };
|
||||
} catch (error) {
|
||||
@ -275,6 +310,16 @@ export async function runJobSpy(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLocations(options: RunJobSpyOptions): string[] {
|
||||
const fromOptions = options.locations?.length ? options.locations : null;
|
||||
const fromSingle = options.location?.trim();
|
||||
const fromEnv = process.env.JOBSPY_LOCATION?.trim();
|
||||
const raw =
|
||||
fromOptions ?? parseSearchCitiesSetting(fromSingle ?? fromEnv ?? "UK");
|
||||
const out = raw.map((value) => value.trim()).filter(Boolean);
|
||||
return out.length > 0 ? out : ["UK"];
|
||||
}
|
||||
|
||||
function resolveSearchTerms(options: RunJobSpyOptions): string[] {
|
||||
const fromOptions = options.searchTerms?.length ? options.searchTerms : null;
|
||||
const fromEnv = parseSearchTermsEnv(process.env.JOBSPY_SEARCH_TERMS);
|
||||
|
||||
@ -66,7 +66,7 @@ describe("settings-conversion", () => {
|
||||
|
||||
it("uses string defaults when override is empty", () => {
|
||||
process.env.JOBSPY_LOCATION = "Remote";
|
||||
const resolved = resolveSettingValue("jobspyLocation", "");
|
||||
const resolved = resolveSettingValue("searchCities", "");
|
||||
expect(resolved.defaultValue).toBe("Remote");
|
||||
expect(resolved.overrideValue).toBe("");
|
||||
expect(resolved.value).toBe("Remote");
|
||||
|
||||
@ -10,7 +10,7 @@ type SettingsConversionValueMap = {
|
||||
adzunaMaxJobsPerTerm: number;
|
||||
gradcrackerMaxJobsPerTerm: number;
|
||||
searchTerms: string[];
|
||||
jobspyLocation: string;
|
||||
searchCities: string;
|
||||
jobspyResultsWanted: number;
|
||||
jobspyCountryIndeed: string;
|
||||
showSponsorInfo: boolean;
|
||||
@ -124,8 +124,9 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
|
||||
serialize: serializeNullableJsonArray,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyLocation: {
|
||||
defaultValue: () => process.env.JOBSPY_LOCATION || "UK",
|
||||
searchCities: {
|
||||
defaultValue: () =>
|
||||
process.env.SEARCH_CITIES || process.env.JOBSPY_LOCATION || "UK",
|
||||
parseOverride: (raw) => raw ?? null,
|
||||
serialize: (value) => value ?? null,
|
||||
resolve: resolveWithEmptyStringFallback,
|
||||
|
||||
@ -177,8 +177,12 @@ export const settingsUpdateRegistry: Partial<{
|
||||
searchTerms: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("searchTerms", value)] }),
|
||||
),
|
||||
searchCities: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("searchCities", value)] }),
|
||||
),
|
||||
// Deprecated legacy key; persist into canonical searchCities setting.
|
||||
jobspyLocation: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("jobspyLocation", value)] }),
|
||||
result({ actions: [metadataPersistAction("searchCities", value)] }),
|
||||
),
|
||||
jobspyResultsWanted: singleAction(({ value }) =>
|
||||
result({
|
||||
|
||||
@ -123,13 +123,13 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
const overrideSearchTerms = searchTermsSetting.overrideValue;
|
||||
const searchTerms = searchTermsSetting.value;
|
||||
|
||||
const jobspyLocationSetting = resolveSettingValue(
|
||||
"jobspyLocation",
|
||||
overrides.jobspyLocation,
|
||||
const searchCitiesSetting = resolveSettingValue(
|
||||
"searchCities",
|
||||
overrides.searchCities ?? overrides.jobspyLocation,
|
||||
);
|
||||
const defaultJobspyLocation = jobspyLocationSetting.defaultValue;
|
||||
const overrideJobspyLocation = jobspyLocationSetting.overrideValue;
|
||||
const jobspyLocation = jobspyLocationSetting.value;
|
||||
const defaultSearchCities = searchCitiesSetting.defaultValue;
|
||||
const overrideSearchCities = searchCitiesSetting.overrideValue;
|
||||
const searchCities = searchCitiesSetting.value;
|
||||
|
||||
const jobspyResultsWantedSetting = resolveSettingValue(
|
||||
"jobspyResultsWanted",
|
||||
@ -278,9 +278,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
searchTerms,
|
||||
defaultSearchTerms,
|
||||
overrideSearchTerms,
|
||||
jobspyLocation,
|
||||
defaultJobspyLocation,
|
||||
overrideJobspyLocation,
|
||||
searchCities,
|
||||
defaultSearchCities,
|
||||
overrideSearchCities,
|
||||
jobspyResultsWanted,
|
||||
defaultJobspyResultsWanted,
|
||||
overrideJobspyResultsWanted,
|
||||
|
||||
101
package-lock.json
generated
101
package-lock.json
generated
@ -44,6 +44,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -348,6 +349,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz",
|
||||
"integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.48.1",
|
||||
"@algolia/requester-browser-xhr": "5.48.1",
|
||||
@ -548,6 +550,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -2801,6 +2804,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -2823,6 +2827,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -2932,6 +2937,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@ -3353,6 +3359,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@ -4342,6 +4349,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz",
|
||||
"integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/logger": "3.9.2",
|
||||
@ -5915,6 +5923,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
|
||||
"integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/mdx": "^2.0.0"
|
||||
},
|
||||
@ -7816,6 +7825,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
|
||||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
@ -8268,6 +8278,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
|
||||
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@ -8301,6 +8312,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
||||
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@ -8312,6 +8324,7 @@
|
||||
"integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
@ -8648,6 +8661,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -8737,6 +8751,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@ -8782,6 +8797,7 @@
|
||||
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz",
|
||||
"integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/abtesting": "1.14.1",
|
||||
"@algolia/client-abtesting": "5.48.1",
|
||||
@ -9434,6 +9450,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@ -10588,6 +10605,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@ -12153,6 +12171,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@ -12515,6 +12534,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
|
||||
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.34.3",
|
||||
"motion-utils": "^12.29.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@ -17745,6 +17791,21 @@
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
|
||||
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.29.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
|
||||
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@ -17949,6 +18010,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@ -18757,6 +18819,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -18810,6 +18873,7 @@
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
|
||||
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.1"
|
||||
},
|
||||
@ -18828,6 +18892,7 @@
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
|
||||
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@ -18868,6 +18933,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -19780,6 +19846,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@ -20663,6 +20730,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@ -20675,6 +20743,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@ -20731,6 +20800,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
|
||||
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
},
|
||||
@ -20806,6 +20876,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
|
||||
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"history": "^4.9.0",
|
||||
@ -22737,6 +22808,7 @@
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
|
||||
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@ -23019,13 +23091,15 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@ -23110,6 +23184,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -23615,6 +23690,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@ -23884,6 +23960,7 @@
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz",
|
||||
"integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
@ -24682,7 +24759,6 @@
|
||||
"name": "job-ops-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@biomejs/cli-linux-x64": "2.3.12",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@paralleldrive/cuid2": "^3.0.6",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@ -24713,6 +24789,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"express": "^4.18.2",
|
||||
"framer-motion": "^12.34.3",
|
||||
"get-tsconfig": "^4.10.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"jsdom": "^25.0.1",
|
||||
@ -25984,8 +26061,7 @@
|
||||
"orchestrator/node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"orchestrator/node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@ -26028,6 +26104,7 @@
|
||||
"version": "7.6.13",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@ -26269,6 +26346,7 @@
|
||||
"version": "11.10.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@ -26462,8 +26540,7 @@
|
||||
"orchestrator/node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"orchestrator/node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
@ -27168,6 +27245,7 @@
|
||||
"orchestrator/node_modules/jsdom": {
|
||||
"version": "25.0.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"data-urls": "^5.0.0",
|
||||
@ -27279,7 +27357,6 @@
|
||||
"version": "1.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@ -27325,7 +27402,6 @@
|
||||
"version": "27.5.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@ -27339,7 +27415,6 @@
|
||||
"version": "5.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@ -27350,6 +27425,7 @@
|
||||
"orchestrator/node_modules/react-hook-form": {
|
||||
"version": "7.71.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@ -27364,8 +27440,7 @@
|
||||
"orchestrator/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"orchestrator/node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
@ -27620,7 +27695,8 @@
|
||||
},
|
||||
"orchestrator/node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"orchestrator/node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
@ -27743,6 +27819,7 @@
|
||||
"orchestrator/node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
44
shared/src/search-cities.test.ts
Normal file
44
shared/src/search-cities.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
shared/src/search-cities.ts
Normal file
83
shared/src/search-cities.ts
Normal 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;
|
||||
}
|
||||
@ -51,6 +51,8 @@ export const updateSettingsSchema = z
|
||||
.max(100)
|
||||
.nullable()
|
||||
.optional(),
|
||||
searchCities: z.string().trim().max(100).nullable().optional(),
|
||||
// Deprecated legacy key; accepted for backward compatibility.
|
||||
jobspyLocation: z.string().trim().max(100).nullable().optional(),
|
||||
jobspyResultsWanted: z
|
||||
.number()
|
||||
|
||||
@ -171,9 +171,9 @@ export const createAppSettings = (
|
||||
searchTerms: ["Software Engineer"],
|
||||
defaultSearchTerms: ["Software Engineer"],
|
||||
overrideSearchTerms: null,
|
||||
jobspyLocation: "United Kingdom",
|
||||
defaultJobspyLocation: "United Kingdom",
|
||||
overrideJobspyLocation: null,
|
||||
searchCities: "United Kingdom",
|
||||
defaultSearchCities: "United Kingdom",
|
||||
overrideSearchCities: null,
|
||||
jobspyResultsWanted: 20,
|
||||
defaultJobspyResultsWanted: 20,
|
||||
overrideJobspyResultsWanted: null,
|
||||
|
||||
@ -1052,9 +1052,9 @@ export interface AppSettings {
|
||||
searchTerms: string[];
|
||||
defaultSearchTerms: string[];
|
||||
overrideSearchTerms: string[] | null;
|
||||
jobspyLocation: string;
|
||||
defaultJobspyLocation: string;
|
||||
overrideJobspyLocation: string | null;
|
||||
searchCities: string;
|
||||
defaultSearchCities: string;
|
||||
overrideSearchCities: string | null;
|
||||
jobspyResultsWanted: number;
|
||||
defaultJobspyResultsWanted: number;
|
||||
overrideJobspyResultsWanted: number | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user