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