feat: add support for indicating workplaceTypes (#296)
This commit is contained in:
parent
8274ec4e14
commit
0b22c08d7d
@ -33,6 +33,7 @@ It also supports term-by-term search and country-aware search state using the sa
|
|||||||
- 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.
|
- optional **Search cities** narrow results by city.
|
||||||
|
- workplace type is forwarded from the automatic run modal as a global run filter.
|
||||||
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:
|
||||||
@ -42,6 +43,7 @@ Defaults and constraints:
|
|||||||
- 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.
|
- When a city is provided via `searchCities`, Hiring Cafe uses city radius search (default `1` mile) and strict city post-filtering.
|
||||||
|
- Workplace type is global to the run and is not configured separately per city in this integration.
|
||||||
- City geocoding is resolved through Nominatim (OpenStreetMap data); if you scale extractor traffic, add attribution and cache repeated city lookups.
|
- 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:
|
||||||
|
|||||||
@ -27,6 +27,7 @@ Key environment variables:
|
|||||||
- `JOBSPY_HOURS_OLD` (default: `72`)
|
- `JOBSPY_HOURS_OLD` (default: `72`)
|
||||||
- `JOBSPY_COUNTRY_INDEED` (default: `UK`)
|
- `JOBSPY_COUNTRY_INDEED` (default: `UK`)
|
||||||
- `JOBSPY_LINKEDIN_FETCH_DESCRIPTION` (default: `true`)
|
- `JOBSPY_LINKEDIN_FETCH_DESCRIPTION` (default: `true`)
|
||||||
|
- `JOBSPY_IS_REMOTE` (unset by default)
|
||||||
|
|
||||||
## 2) Orchestrator flow
|
## 2) Orchestrator flow
|
||||||
|
|
||||||
@ -50,3 +51,14 @@ The service in `orchestrator/src/server/services/jobspy.ts`:
|
|||||||
- `JOBSPY_SEARCH_TERMS` can be JSON array or `|`, comma, newline-delimited text.
|
- `JOBSPY_SEARCH_TERMS` can be JSON array or `|`, comma, newline-delimited text.
|
||||||
- Set `JOBSPY_LINKEDIN_FETCH_DESCRIPTION=0` to speed runs.
|
- Set `JOBSPY_LINKEDIN_FETCH_DESCRIPTION=0` to speed runs.
|
||||||
- Temp output files are stored under `data/imports/`.
|
- Temp output files are stored under `data/imports/`.
|
||||||
|
- If workplace type is only `Remote`, JobSpy runs with `JOBSPY_IS_REMOTE=true`.
|
||||||
|
- If workplace type includes `Hybrid` or `Onsite`, JobSpy cannot enforce those filters precisely, so the JobSpy-backed sources run without a workplace-type filter and may return broader results.
|
||||||
|
|
||||||
|
## Common Problems
|
||||||
|
|
||||||
|
- `Hybrid` or `Onsite` was selected, but Indeed, LinkedIn, or Glassdoor still returned remote jobs.
|
||||||
|
JobSpy only supports a strict remote toggle. Any workplace-type selection that includes `Hybrid` or `Onsite` broadens those source results.
|
||||||
|
- A run returned fewer LinkedIn descriptions than expected.
|
||||||
|
`JOBSPY_LINKEDIN_FETCH_DESCRIPTION=0` disables description fetching to speed up runs.
|
||||||
|
- Different cities need different workplace-type filters.
|
||||||
|
This is not supported in the current automatic-run flow. JobSpy receives one global workplace-type selection per run/query invocation.
|
||||||
@ -29,6 +29,7 @@ Using the published package also keeps the integration small and makes it easier
|
|||||||
3. Set your usual automatic run controls:
|
3. Set your usual automatic run controls:
|
||||||
- `searchTerms` are sent as `query`.
|
- `searchTerms` are sent as `query`.
|
||||||
- country or city filters are reused as the package `location` option.
|
- country or city filters are reused as the package `location` option.
|
||||||
|
- workplace type is passed through as the package `workplaceType` option.
|
||||||
- run budget path (`jobspyResultsWanted`) is reused as `requestedCount` per term.
|
- run budget path (`jobspyResultsWanted`) is reused as `requestedCount` per term.
|
||||||
4. Start the run and monitor progress in the pipeline progress card.
|
4. Start the run and monitor progress in the pipeline progress card.
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ Defaults and constraints:
|
|||||||
- The integration runs with `enrichDetails: true`, so it opens job detail pages for richer records.
|
- The integration runs with `enrichDetails: true`, so it opens job detail pages for richer records.
|
||||||
- Browser binaries are not downloaded automatically with the package. Install them with `npx playwright install` before using this extractor in a fresh environment.
|
- Browser binaries are not downloaded automatically with the package. Install them with `npx playwright install` before using this extractor in a fresh environment.
|
||||||
- When **Search cities** is set, the extractor runs once per city and once per search term.
|
- When **Search cities** is set, the extractor runs once per city and once per search term.
|
||||||
|
- Workplace type is a global run filter, not a per-city override.
|
||||||
- Without explicit cities, the selected country is used as the location filter except for broad modes such as `worldwide` and `usa/ca`.
|
- Without explicit cities, the selected country is used as the location filter except for broad modes such as `worldwide` and `usa/ca`.
|
||||||
|
|
||||||
## Common problems
|
## Common problems
|
||||||
|
|||||||
@ -60,6 +60,16 @@ Incompatible sources are disabled with explanatory tooltips.
|
|||||||
- **Min suitability score**
|
- **Min suitability score**
|
||||||
- **Max jobs discovered** (run budget cap)
|
- **Max jobs discovered** (run budget cap)
|
||||||
- **Search cities** (optional multi-city input; required for Glassdoor)
|
- **Search cities** (optional multi-city input; required for Glassdoor)
|
||||||
|
- **Workplace type** (`Remote`, `Hybrid`, `Onsite`)
|
||||||
|
|
||||||
|
Workplace type applies globally to the run across all search terms and locations.
|
||||||
|
|
||||||
|
Source behavior differs:
|
||||||
|
|
||||||
|
- Hiring Cafe and startup.jobs support all three workplace types directly.
|
||||||
|
- Indeed, LinkedIn, and Glassdoor are backed by JobSpy and only support strict remote filtering.
|
||||||
|
- If workplace type is set to `Remote` only, JobSpy runs with a remote-only filter.
|
||||||
|
- If `Hybrid` or `Onsite` is included, JobSpy sources remain enabled but may return broader results.
|
||||||
|
|
||||||
#### Search terms
|
#### Search terms
|
||||||
|
|
||||||
@ -110,6 +120,12 @@ For accepted input formats, inference behavior, and limits, see [Manual Import E
|
|||||||
- Use `Fast` preset or lower `Max jobs discovered`.
|
- Use `Fast` preset or lower `Max jobs discovered`.
|
||||||
- Disable high-cost source combinations where acceptable.
|
- Disable high-cost source combinations where acceptable.
|
||||||
|
|
||||||
|
### JobSpy results are broader than the selected workplace type
|
||||||
|
|
||||||
|
- Indeed, LinkedIn, and Glassdoor only support strict remote filtering in this flow.
|
||||||
|
- Use `Remote` only when you need JobSpy sources filtered tightly.
|
||||||
|
- Hybrid or onsite selections are honored by Hiring Cafe and startup.jobs, but JobSpy-backed sources may still include broader results.
|
||||||
|
|
||||||
## Related pages
|
## Related pages
|
||||||
|
|
||||||
- [Find Jobs and Apply Workflow](/docs/next/workflows/find-jobs-and-apply-workflow)
|
- [Find Jobs and Apply Workflow](/docs/next/workflows/find-jobs-and-apply-workflow)
|
||||||
|
|||||||
@ -68,6 +68,9 @@ export const manifest: ExtractorManifest = {
|
|||||||
single:
|
single:
|
||||||
context.settings.searchCities ?? context.settings.jobspyLocation,
|
context.settings.searchCities ?? context.settings.jobspyLocation,
|
||||||
}),
|
}),
|
||||||
|
workplaceTypes: context.settings.workplaceTypes
|
||||||
|
? JSON.parse(context.settings.workplaceTypes)
|
||||||
|
: undefined,
|
||||||
maxJobsPerTerm,
|
maxJobsPerTerm,
|
||||||
onProgress: (event) => {
|
onProgress: (event) => {
|
||||||
if (context.shouldCancel?.()) return;
|
if (context.shouldCancel?.()) return;
|
||||||
|
|||||||
@ -35,10 +35,11 @@ export function createDefaultSearchState(args: {
|
|||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
location: HiringCafeCountryLocation | null;
|
location: HiringCafeCountryLocation | null;
|
||||||
dateFetchedPastNDays: number;
|
dateFetchedPastNDays: number;
|
||||||
|
workplaceTypes?: Array<"Remote" | "Hybrid" | "Onsite">;
|
||||||
}): HiringCafeSearchState {
|
}): HiringCafeSearchState {
|
||||||
return {
|
return {
|
||||||
locations: args.location ? [args.location] : [],
|
locations: args.location ? [args.location] : [],
|
||||||
workplaceTypes: ["Remote", "Hybrid", "Onsite"],
|
workplaceTypes: args.workplaceTypes ?? ["Remote", "Hybrid", "Onsite"],
|
||||||
defaultToUserLocation: false,
|
defaultToUserLocation: false,
|
||||||
userLocation: null,
|
userLocation: null,
|
||||||
commitmentTypes: [
|
commitmentTypes: [
|
||||||
|
|||||||
@ -68,6 +68,8 @@ interface NominatimResult {
|
|||||||
address?: Record<string, unknown>;
|
address?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HiringCafeWorkplaceType = "Remote" | "Hybrid" | "Onsite";
|
||||||
|
|
||||||
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)}`);
|
||||||
@ -79,6 +81,36 @@ function parsePositiveInt(input: string | undefined, fallback: number): number {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseWorkplaceTypes(
|
||||||
|
raw: string | undefined,
|
||||||
|
): HiringCafeWorkplaceType[] {
|
||||||
|
if (!raw) return ["Remote", "Hybrid", "Onsite"];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return ["Remote", "Hybrid", "Onsite"];
|
||||||
|
|
||||||
|
const out: HiringCafeWorkplaceType[] = [];
|
||||||
|
const seen = new Set<HiringCafeWorkplaceType>();
|
||||||
|
for (const value of parsed) {
|
||||||
|
const mapped =
|
||||||
|
value === "remote"
|
||||||
|
? "Remote"
|
||||||
|
: value === "hybrid"
|
||||||
|
? "Hybrid"
|
||||||
|
: value === "onsite"
|
||||||
|
? "Onsite"
|
||||||
|
: null;
|
||||||
|
if (!mapped || seen.has(mapped)) continue;
|
||||||
|
seen.add(mapped);
|
||||||
|
out.push(mapped);
|
||||||
|
}
|
||||||
|
return out.length > 0 ? out : ["Remote", "Hybrid", "Onsite"];
|
||||||
|
} catch {
|
||||||
|
return ["Remote", "Hybrid", "Onsite"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function encodeSearchState(searchState: unknown): string {
|
function encodeSearchState(searchState: unknown): string {
|
||||||
const json = JSON.stringify(searchState);
|
const json = JSON.stringify(searchState);
|
||||||
const urlEncodedJson = encodeURIComponent(json);
|
const urlEncodedJson = encodeURIComponent(json);
|
||||||
@ -301,6 +333,7 @@ function createCitySearchState(args: {
|
|||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
dateFetchedPastNDays: number;
|
dateFetchedPastNDays: number;
|
||||||
context: CityLocationContext;
|
context: CityLocationContext;
|
||||||
|
workplaceTypes: HiringCafeWorkplaceType[];
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
locations: [
|
locations: [
|
||||||
@ -340,7 +373,7 @@ function createCitySearchState(args: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
workplaceTypes: ["Remote", "Hybrid", "Onsite"],
|
workplaceTypes: args.workplaceTypes,
|
||||||
defaultToUserLocation: true,
|
defaultToUserLocation: true,
|
||||||
userLocation: null,
|
userLocation: null,
|
||||||
physicalEnvironments: [
|
physicalEnvironments: [
|
||||||
@ -553,6 +586,9 @@ async function run(): Promise<void> {
|
|||||||
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");
|
||||||
const headless = process.env.HIRING_CAFE_HEADLESS !== "false";
|
const headless = process.env.HIRING_CAFE_HEADLESS !== "false";
|
||||||
|
const workplaceTypes = parseWorkplaceTypes(
|
||||||
|
process.env.HIRING_CAFE_WORKPLACE_TYPES,
|
||||||
|
);
|
||||||
|
|
||||||
let browser = await firefox.launch(
|
let browser = await firefox.launch(
|
||||||
await launchOptions({
|
await launchOptions({
|
||||||
@ -620,11 +656,13 @@ async function run(): Promise<void> {
|
|||||||
searchQuery: searchTerm,
|
searchQuery: searchTerm,
|
||||||
dateFetchedPastNDays,
|
dateFetchedPastNDays,
|
||||||
context: cityLocationContext,
|
context: cityLocationContext,
|
||||||
|
workplaceTypes,
|
||||||
})
|
})
|
||||||
: createDefaultSearchState({
|
: createDefaultSearchState({
|
||||||
searchQuery: searchTerm,
|
searchQuery: searchTerm,
|
||||||
location: countryLocation,
|
location: countryLocation,
|
||||||
dateFetchedPastNDays,
|
dateFetchedPastNDays,
|
||||||
|
workplaceTypes,
|
||||||
});
|
});
|
||||||
const encodedSearchState = encodeSearchState(searchState);
|
const encodedSearchState = encodeSearchState(searchState);
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export interface RunHiringCafeOptions {
|
|||||||
country?: string;
|
country?: string;
|
||||||
countryKey?: string;
|
countryKey?: string;
|
||||||
locations?: string[];
|
locations?: string[];
|
||||||
|
workplaceTypes?: Array<"remote" | "hybrid" | "onsite">;
|
||||||
locationRadiusMiles?: number;
|
locationRadiusMiles?: number;
|
||||||
maxJobsPerTerm?: number;
|
maxJobsPerTerm?: number;
|
||||||
onProgress?: (event: HiringCafeProgressEvent) => void;
|
onProgress?: (event: HiringCafeProgressEvent) => void;
|
||||||
@ -216,6 +217,9 @@ export async function runHiringCafe(
|
|||||||
JOBOPS_EMIT_PROGRESS: "1",
|
JOBOPS_EMIT_PROGRESS: "1",
|
||||||
HIRING_CAFE_SEARCH_TERMS: JSON.stringify(searchTerms),
|
HIRING_CAFE_SEARCH_TERMS: JSON.stringify(searchTerms),
|
||||||
HIRING_CAFE_COUNTRY: country,
|
HIRING_CAFE_COUNTRY: country,
|
||||||
|
HIRING_CAFE_WORKPLACE_TYPES: JSON.stringify(
|
||||||
|
options.workplaceTypes ?? ["remote", "hybrid", "onsite"],
|
||||||
|
),
|
||||||
HIRING_CAFE_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
|
HIRING_CAFE_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
|
||||||
HIRING_CAFE_OUTPUT_JSON: DATASET_PATH,
|
HIRING_CAFE_OUTPUT_JSON: DATASET_PATH,
|
||||||
HIRING_CAFE_LOCATION_QUERY: strictLocationFilter ? location : "",
|
HIRING_CAFE_LOCATION_QUERY: strictLocationFilter ? location : "",
|
||||||
|
|||||||
@ -32,6 +32,9 @@ export const manifest: ExtractorManifest = {
|
|||||||
? parseInt(context.settings.jobspyResultsWanted, 10)
|
? parseInt(context.settings.jobspyResultsWanted, 10)
|
||||||
: undefined,
|
: undefined,
|
||||||
countryIndeed: context.settings.jobspyCountryIndeed,
|
countryIndeed: context.settings.jobspyCountryIndeed,
|
||||||
|
workplaceTypes: context.settings.workplaceTypes
|
||||||
|
? JSON.parse(context.settings.workplaceTypes)
|
||||||
|
: undefined,
|
||||||
onProgress: (event) => {
|
onProgress: (event) => {
|
||||||
if (context.shouldCancel?.()) return;
|
if (context.shouldCancel?.()) return;
|
||||||
|
|
||||||
|
|||||||
@ -128,6 +128,7 @@ export interface RunJobSpyOptions {
|
|||||||
searchTerms?: string[];
|
searchTerms?: string[];
|
||||||
location?: string;
|
location?: string;
|
||||||
locations?: string[];
|
locations?: string[];
|
||||||
|
workplaceTypes?: Array<"remote" | "hybrid" | "onsite">;
|
||||||
resultsWanted?: number;
|
resultsWanted?: number;
|
||||||
hoursOld?: number;
|
hoursOld?: number;
|
||||||
countryIndeed?: string;
|
countryIndeed?: string;
|
||||||
@ -142,6 +143,14 @@ export interface JobSpyResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deriveIsRemoteFlag(
|
||||||
|
workplaceTypes: Array<"remote" | "hybrid" | "onsite"> | undefined,
|
||||||
|
): boolean | undefined {
|
||||||
|
return workplaceTypes?.length === 1 && workplaceTypes[0] === "remote"
|
||||||
|
? true
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runJobSpy(
|
export async function runJobSpy(
|
||||||
options: RunJobSpyOptions = {},
|
options: RunJobSpyOptions = {},
|
||||||
): Promise<JobSpyResult> {
|
): Promise<JobSpyResult> {
|
||||||
@ -224,7 +233,10 @@ export async function runJobSpy(
|
|||||||
"1",
|
"1",
|
||||||
),
|
),
|
||||||
JOBSPY_IS_REMOTE: String(
|
JOBSPY_IS_REMOTE: String(
|
||||||
options.isRemote ?? process.env.JOBSPY_IS_REMOTE ?? "0",
|
options.isRemote ??
|
||||||
|
deriveIsRemoteFlag(options.workplaceTypes) ??
|
||||||
|
process.env.JOBSPY_IS_REMOTE ??
|
||||||
|
"0",
|
||||||
),
|
),
|
||||||
JOBSPY_OUTPUT_CSV: outputCsv,
|
JOBSPY_OUTPUT_CSV: outputCsv,
|
||||||
JOBSPY_OUTPUT_JSON: outputJson,
|
JOBSPY_OUTPUT_JSON: outputJson,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseJobSpyProgressLine } from "../src/run";
|
import { deriveIsRemoteFlag, parseJobSpyProgressLine } from "../src/run";
|
||||||
|
|
||||||
describe("parseJobSpyProgressLine", () => {
|
describe("parseJobSpyProgressLine", () => {
|
||||||
it("parses term_start progress lines", () => {
|
it("parses term_start progress lines", () => {
|
||||||
@ -37,4 +37,15 @@ describe("parseJobSpyProgressLine", () => {
|
|||||||
it("returns null for non-progress lines", () => {
|
it("returns null for non-progress lines", () => {
|
||||||
expect(parseJobSpyProgressLine("Found 20 jobs")).toBeNull();
|
expect(parseJobSpyProgressLine("Found 20 jobs")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps remote-only workplace types to isRemote", () => {
|
||||||
|
expect(deriveIsRemoteFlag(["remote"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not force JobSpy remote filtering for hybrid or onsite selections", () => {
|
||||||
|
expect(deriveIsRemoteFlag(["hybrid"])).toBeUndefined();
|
||||||
|
expect(deriveIsRemoteFlag(["onsite"])).toBeUndefined();
|
||||||
|
expect(deriveIsRemoteFlag(["remote", "hybrid"])).toBeUndefined();
|
||||||
|
expect(deriveIsRemoteFlag(["remote", "hybrid", "onsite"])).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -63,6 +63,9 @@ export const manifest: ExtractorManifest = {
|
|||||||
single:
|
single:
|
||||||
context.settings.searchCities ?? context.settings.jobspyLocation,
|
context.settings.searchCities ?? context.settings.jobspyLocation,
|
||||||
}),
|
}),
|
||||||
|
workplaceTypes: context.settings.workplaceTypes
|
||||||
|
? JSON.parse(context.settings.workplaceTypes)
|
||||||
|
: undefined,
|
||||||
maxJobsPerTerm,
|
maxJobsPerTerm,
|
||||||
shouldCancel: context.shouldCancel,
|
shouldCancel: context.shouldCancel,
|
||||||
onProgress: (event) => {
|
onProgress: (event) => {
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export interface RunStartupJobsOptions {
|
|||||||
searchTerms?: string[];
|
searchTerms?: string[];
|
||||||
selectedCountry?: string;
|
selectedCountry?: string;
|
||||||
locations?: string[];
|
locations?: string[];
|
||||||
|
workplaceTypes?: Array<"remote" | "hybrid" | "onsite">;
|
||||||
maxJobsPerTerm?: number;
|
maxJobsPerTerm?: number;
|
||||||
onProgress?: (event: StartupJobsProgressEvent) => void;
|
onProgress?: (event: StartupJobsProgressEvent) => void;
|
||||||
shouldCancel?: () => boolean;
|
shouldCancel?: () => boolean;
|
||||||
@ -41,6 +42,18 @@ export interface StartupJobsResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StartupJobsWorkplaceType = "remote" | "hybrid" | "on-site";
|
||||||
|
|
||||||
|
function mapWorkplaceTypes(
|
||||||
|
workplaceTypes: Array<"remote" | "hybrid" | "onsite"> | undefined,
|
||||||
|
): StartupJobsWorkplaceType[] | undefined {
|
||||||
|
if (!workplaceTypes || workplaceTypes.length === 0) return undefined;
|
||||||
|
|
||||||
|
return workplaceTypes.map((workplaceType) =>
|
||||||
|
workplaceType === "onsite" ? "on-site" : workplaceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toPositiveIntOrFallback(
|
function toPositiveIntOrFallback(
|
||||||
value: number | string | undefined,
|
value: number | string | undefined,
|
||||||
fallback: number,
|
fallback: number,
|
||||||
@ -123,6 +136,7 @@ export async function runStartupJobs(
|
|||||||
locations: options.locations,
|
locations: options.locations,
|
||||||
});
|
});
|
||||||
const maxJobsPerTerm = toPositiveIntOrFallback(options.maxJobsPerTerm, 50);
|
const maxJobsPerTerm = toPositiveIntOrFallback(options.maxJobsPerTerm, 50);
|
||||||
|
const workplaceType = mapWorkplaceTypes(options.workplaceTypes);
|
||||||
const termTotal = searchTerms.length * runLocations.length;
|
const termTotal = searchTerms.length * runLocations.length;
|
||||||
const jobs: CreateJobInput[] = [];
|
const jobs: CreateJobInput[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -149,6 +163,7 @@ export async function runStartupJobs(
|
|||||||
requestedCount: maxJobsPerTerm,
|
requestedCount: maxJobsPerTerm,
|
||||||
enrichDetails: true,
|
enrichDetails: true,
|
||||||
location: location ?? undefined,
|
location: location ?? undefined,
|
||||||
|
workplaceType,
|
||||||
});
|
});
|
||||||
|
|
||||||
let jobsFoundTerm = 0;
|
let jobsFoundTerm = 0;
|
||||||
|
|||||||
@ -35,4 +35,30 @@ describe("startupjobs manifest", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards workplace types to the runner", async () => {
|
||||||
|
const { manifest } = await import("../src/manifest");
|
||||||
|
const { runStartupJobs } = await import("../src/run");
|
||||||
|
const runStartupJobsMock = vi.mocked(runStartupJobs);
|
||||||
|
runStartupJobsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
jobs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await manifest.run({
|
||||||
|
source: "startupjobs",
|
||||||
|
selectedSources: ["startupjobs"],
|
||||||
|
settings: {
|
||||||
|
workplaceTypes: '["remote","onsite"]',
|
||||||
|
},
|
||||||
|
searchTerms: ["software engineer"],
|
||||||
|
selectedCountry: "united kingdom",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runStartupJobsMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workplaceTypes: ["remote", "onsite"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -72,4 +72,46 @@ describe("runStartupJobs", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes workplaceType to the scraper", async () => {
|
||||||
|
const { scrapeStartupJobsViaAlgolia } = await import(
|
||||||
|
"startup-jobs-scraper"
|
||||||
|
);
|
||||||
|
const scrapeMock = vi.mocked(scrapeStartupJobsViaAlgolia);
|
||||||
|
scrapeMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const { runStartupJobs } = await import("../src/run");
|
||||||
|
|
||||||
|
await runStartupJobs({
|
||||||
|
searchTerms: ["software engineer"],
|
||||||
|
workplaceTypes: ["remote", "hybrid"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrapeMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workplaceType: ["remote", "hybrid"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps onsite workplaceType to the scraper's on-site value", async () => {
|
||||||
|
const { scrapeStartupJobsViaAlgolia } = await import(
|
||||||
|
"startup-jobs-scraper"
|
||||||
|
);
|
||||||
|
const scrapeMock = vi.mocked(scrapeStartupJobsViaAlgolia);
|
||||||
|
scrapeMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const { runStartupJobs } = await import("../src/run");
|
||||||
|
|
||||||
|
await runStartupJobs({
|
||||||
|
searchTerms: ["software engineer"],
|
||||||
|
workplaceTypes: ["onsite"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrapeMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workplaceType: ["on-site"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -63,6 +63,7 @@ let mockAutomaticRunValues: AutomaticRunValues = {
|
|||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const jobFixture = createJob({
|
const jobFixture = createJob({
|
||||||
@ -402,6 +403,7 @@ describe("OrchestratorPage", () => {
|
|||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -749,6 +751,7 @@ describe("OrchestratorPage", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(api.updateSettings).toHaveBeenCalledWith({
|
expect(api.updateSettings).toHaveBeenCalledWith({
|
||||||
searchTerms: ["backend"],
|
searchTerms: ["backend"],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
jobspyResultsWanted: 150,
|
jobspyResultsWanted: 150,
|
||||||
gradcrackerMaxJobsPerTerm: 150,
|
gradcrackerMaxJobsPerTerm: 150,
|
||||||
ukvisajobsMaxJobs: 150,
|
ukvisajobsMaxJobs: 150,
|
||||||
@ -780,6 +783,7 @@ describe("OrchestratorPage", () => {
|
|||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: ["London", "Manchester"],
|
cityLocations: ["London", "Manchester"],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -814,6 +818,7 @@ describe("OrchestratorPage", () => {
|
|||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: ["Leeds", "Manchester"],
|
cityLocations: ["Leeds", "Manchester"],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -848,6 +853,7 @@ describe("OrchestratorPage", () => {
|
|||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: ["Leeds", "Manchester"],
|
cityLocations: ["Leeds", "Manchester"],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -954,6 +960,7 @@ describe("OrchestratorPage", () => {
|
|||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united states",
|
country: "united states",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|||||||
@ -344,4 +344,117 @@ describe("AutomaticRunTab", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads saved workplace types from settings", () => {
|
||||||
|
render(
|
||||||
|
<AutomaticRunTab
|
||||||
|
open
|
||||||
|
settings={createAppSettings({
|
||||||
|
workplaceTypes: {
|
||||||
|
value: ["remote", "onsite"],
|
||||||
|
default: ["remote", "hybrid", "onsite"],
|
||||||
|
override: ["remote", "onsite"],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
enabledSources={["linkedin"]}
|
||||||
|
pipelineSources={["linkedin"]}
|
||||||
|
onToggleSource={vi.fn()}
|
||||||
|
onSetPipelineSources={vi.fn()}
|
||||||
|
isPipelineRunning={false}
|
||||||
|
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Remote")).toBeChecked();
|
||||||
|
expect(screen.getByLabelText("Onsite")).toBeChecked();
|
||||||
|
expect(screen.getByLabelText("Hybrid")).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires at least one workplace type", async () => {
|
||||||
|
render(
|
||||||
|
<AutomaticRunTab
|
||||||
|
open
|
||||||
|
settings={createAppSettings()}
|
||||||
|
enabledSources={["linkedin"]}
|
||||||
|
pipelineSources={["linkedin"]}
|
||||||
|
onToggleSource={vi.fn()}
|
||||||
|
onSetPipelineSources={vi.fn()}
|
||||||
|
isPipelineRunning={false}
|
||||||
|
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||||
|
fireEvent.click(screen.getByLabelText("Remote"));
|
||||||
|
fireEvent.click(screen.getByLabelText("Hybrid"));
|
||||||
|
fireEvent.click(screen.getByLabelText("Onsite"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Select at least one workplace type."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Start run now" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows JobSpy guidance when non-remote workplace types are selected", () => {
|
||||||
|
render(
|
||||||
|
<AutomaticRunTab
|
||||||
|
open
|
||||||
|
settings={createAppSettings({
|
||||||
|
workplaceTypes: {
|
||||||
|
value: ["remote", "hybrid"],
|
||||||
|
default: ["remote", "hybrid", "onsite"],
|
||||||
|
override: ["remote", "hybrid"],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
enabledSources={["linkedin"]}
|
||||||
|
pipelineSources={["linkedin"]}
|
||||||
|
onToggleSource={vi.fn()}
|
||||||
|
onSetPipelineSources={vi.fn()}
|
||||||
|
isPipelineRunning={false}
|
||||||
|
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Indeed, LinkedIn, and Glassdoor only support strict remote filtering\./i,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits workplace types in onSaveAndRun values", async () => {
|
||||||
|
const onSaveAndRun = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AutomaticRunTab
|
||||||
|
open
|
||||||
|
settings={createAppSettings()}
|
||||||
|
enabledSources={["linkedin"]}
|
||||||
|
pipelineSources={["linkedin"]}
|
||||||
|
onToggleSource={vi.fn()}
|
||||||
|
onSetPipelineSources={vi.fn()}
|
||||||
|
isPipelineRunning={false}
|
||||||
|
onSaveAndRun={onSaveAndRun}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||||
|
fireEvent.click(screen.getByLabelText("Hybrid"));
|
||||||
|
fireEvent.click(screen.getByLabelText("Onsite"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Start run now" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSaveAndRun).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workplaceTypes: ["remote"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { SearchableDropdown } from "@/components/ui/searchable-dropdown";
|
import { SearchableDropdown } from "@/components/ui/searchable-dropdown";
|
||||||
@ -35,10 +36,13 @@ import {
|
|||||||
type AutomaticRunValues,
|
type AutomaticRunValues,
|
||||||
calculateAutomaticEstimate,
|
calculateAutomaticEstimate,
|
||||||
loadAutomaticRunMemory,
|
loadAutomaticRunMemory,
|
||||||
|
normalizeWorkplaceTypes,
|
||||||
parseCityLocationsInput,
|
parseCityLocationsInput,
|
||||||
parseCityLocationsSetting,
|
parseCityLocationsSetting,
|
||||||
parseSearchTermsInput,
|
parseSearchTermsInput,
|
||||||
saveAutomaticRunMemory,
|
saveAutomaticRunMemory,
|
||||||
|
WORKPLACE_TYPE_OPTIONS,
|
||||||
|
type WorkplaceType,
|
||||||
} from "./automatic-run";
|
} from "./automatic-run";
|
||||||
import { TokenizedInput } from "./TokenizedInput";
|
import { TokenizedInput } from "./TokenizedInput";
|
||||||
|
|
||||||
@ -60,6 +64,7 @@ const DEFAULT_VALUES: AutomaticRunValues = {
|
|||||||
runBudget: 200,
|
runBudget: 200,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AutomaticRunFormValues {
|
interface AutomaticRunFormValues {
|
||||||
@ -69,6 +74,7 @@ interface AutomaticRunFormValues {
|
|||||||
country: string;
|
country: string;
|
||||||
cityLocations: string[];
|
cityLocations: string[];
|
||||||
cityLocationDraft: string;
|
cityLocationDraft: string;
|
||||||
|
workplaceTypes: WorkplaceType[];
|
||||||
searchTerms: string[];
|
searchTerms: string[];
|
||||||
searchTermDraft: string;
|
searchTermDraft: string;
|
||||||
}
|
}
|
||||||
@ -108,6 +114,11 @@ function toNumber(input: string, min: number, max: number, fallback: number) {
|
|||||||
return Math.min(max, Math.max(min, parsed));
|
return Math.min(max, Math.max(min, parsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatWorkplaceTypeLabel(workplaceType: WorkplaceType): string {
|
||||||
|
if (workplaceType === "onsite") return "Onsite";
|
||||||
|
return workplaceType.charAt(0).toUpperCase() + workplaceType.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
function getPresetSelection(values: {
|
function getPresetSelection(values: {
|
||||||
topN: number;
|
topN: number;
|
||||||
minSuitabilityScore: number;
|
minSuitabilityScore: number;
|
||||||
@ -159,6 +170,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
country: DEFAULT_VALUES.country,
|
country: DEFAULT_VALUES.country,
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
cityLocationDraft: "",
|
cityLocationDraft: "",
|
||||||
|
workplaceTypes: DEFAULT_VALUES.workplaceTypes,
|
||||||
searchTerms: DEFAULT_VALUES.searchTerms,
|
searchTerms: DEFAULT_VALUES.searchTerms,
|
||||||
searchTermDraft: "",
|
searchTermDraft: "",
|
||||||
},
|
},
|
||||||
@ -170,6 +182,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
const countryInput = watch("country");
|
const countryInput = watch("country");
|
||||||
const cityLocations = watch("cityLocations");
|
const cityLocations = watch("cityLocations");
|
||||||
const cityLocationDraft = watch("cityLocationDraft");
|
const cityLocationDraft = watch("cityLocationDraft");
|
||||||
|
const workplaceTypes = watch("workplaceTypes");
|
||||||
const searchTerms = watch("searchTerms");
|
const searchTerms = watch("searchTerms");
|
||||||
const searchTermDraft = watch("searchTermDraft");
|
const searchTermDraft = watch("searchTermDraft");
|
||||||
|
|
||||||
@ -212,6 +225,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
normalizeCountryKey(location) !==
|
normalizeCountryKey(location) !==
|
||||||
normalizeCountryKey(rememberedCountryKey),
|
normalizeCountryKey(rememberedCountryKey),
|
||||||
);
|
);
|
||||||
|
const rememberedWorkplaceTypes = normalizeWorkplaceTypes(
|
||||||
|
settings?.workplaceTypes?.value,
|
||||||
|
);
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
topN: String(topN),
|
topN: String(topN),
|
||||||
@ -220,6 +236,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
country: rememberedCountry || DEFAULT_VALUES.country,
|
country: rememberedCountry || DEFAULT_VALUES.country,
|
||||||
cityLocations: rememberedLocations,
|
cityLocations: rememberedLocations,
|
||||||
cityLocationDraft: "",
|
cityLocationDraft: "",
|
||||||
|
workplaceTypes: rememberedWorkplaceTypes,
|
||||||
searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms,
|
searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms,
|
||||||
searchTermDraft: "",
|
searchTermDraft: "",
|
||||||
});
|
});
|
||||||
@ -239,6 +256,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,
|
||||||
cityLocations,
|
cityLocations,
|
||||||
|
workplaceTypes: normalizeWorkplaceTypes(workplaceTypes),
|
||||||
searchTerms,
|
searchTerms,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@ -247,9 +265,12 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
runBudgetInput,
|
runBudgetInput,
|
||||||
countryInput,
|
countryInput,
|
||||||
cityLocations,
|
cityLocations,
|
||||||
|
workplaceTypes,
|
||||||
searchTerms,
|
searchTerms,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const workplaceTypeSelectionInvalid = workplaceTypes.length === 0;
|
||||||
|
|
||||||
const isSourceAvailableForRun = useCallback(
|
const isSourceAvailableForRun = useCallback(
|
||||||
(source: JobSource) => {
|
(source: JobSource) => {
|
||||||
if (!isSourceAllowedForCountry(source, values.country)) return false;
|
if (!isSourceAllowedForCountry(source, values.country)) return false;
|
||||||
@ -270,6 +291,15 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
[pipelineSources, isSourceAvailableForRun],
|
[pipelineSources, isSourceAvailableForRun],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasOnlyRemoteWorkplaceType =
|
||||||
|
workplaceTypes.length === 1 && workplaceTypes[0] === "remote";
|
||||||
|
const hasJobSpySourceSelected = compatiblePipelineSources.some(
|
||||||
|
(source) =>
|
||||||
|
source === "indeed" || source === "linkedin" || source === "glassdoor",
|
||||||
|
);
|
||||||
|
const showJobSpyWorkplaceWarning =
|
||||||
|
hasJobSpySourceSelected && !hasOnlyRemoteWorkplaceType;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filtered = pipelineSources.filter((source) =>
|
const filtered = pipelineSources.filter((source) =>
|
||||||
isSourceAvailableForRun(source),
|
isSourceAvailableForRun(source),
|
||||||
@ -307,7 +337,19 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
isPipelineRunning ||
|
isPipelineRunning ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
compatiblePipelineSources.length === 0 ||
|
compatiblePipelineSources.length === 0 ||
|
||||||
values.searchTerms.length === 0;
|
values.searchTerms.length === 0 ||
|
||||||
|
workplaceTypeSelectionInvalid;
|
||||||
|
|
||||||
|
const toggleWorkplaceType = (
|
||||||
|
workplaceType: WorkplaceType,
|
||||||
|
checked: boolean,
|
||||||
|
) => {
|
||||||
|
const next = checked
|
||||||
|
? normalizeWorkplaceTypes([...workplaceTypes, workplaceType])
|
||||||
|
: workplaceTypes.filter((value) => value !== workplaceType);
|
||||||
|
|
||||||
|
setValue("workplaceTypes", next, { shouldDirty: true });
|
||||||
|
};
|
||||||
|
|
||||||
const applyPreset = (presetId: AutomaticPresetId) => {
|
const applyPreset = (presetId: AutomaticPresetId) => {
|
||||||
const preset = AUTOMATIC_PRESETS[presetId];
|
const preset = AUTOMATIC_PRESETS[presetId];
|
||||||
@ -473,6 +515,55 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
removeLabelPrefix="Remove city"
|
removeLabelPrefix="Remove city"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-3">
|
||||||
|
<Label>Workplace type</Label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{WORKPLACE_TYPE_OPTIONS.map((workplaceType) => {
|
||||||
|
const checkboxId = `workplace-type-${workplaceType}`;
|
||||||
|
const checked =
|
||||||
|
workplaceTypes.includes(workplaceType);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={workplaceType}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={checkboxId}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(nextChecked) => {
|
||||||
|
toggleWorkplaceType(
|
||||||
|
workplaceType,
|
||||||
|
nextChecked === true,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={checkboxId}
|
||||||
|
className="cursor-pointer text-sm font-medium"
|
||||||
|
>
|
||||||
|
{formatWorkplaceTypeLabel(workplaceType)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Applies globally to all search terms and locations in
|
||||||
|
this run.
|
||||||
|
</p>
|
||||||
|
{workplaceTypeSelectionInvalid ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Select at least one workplace type.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{showJobSpyWorkplaceWarning ? (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Indeed, LinkedIn, and Glassdoor only support strict
|
||||||
|
remote filtering. Hybrid or Onsite selections will
|
||||||
|
broaden those source results.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@ -28,6 +28,7 @@ describe("automatic-run utilities", () => {
|
|||||||
runBudget: 100,
|
runBudget: 100,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
},
|
},
|
||||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||||
});
|
});
|
||||||
@ -72,6 +73,7 @@ describe("automatic-run utilities", () => {
|
|||||||
runBudget: 750,
|
runBudget: 750,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
},
|
},
|
||||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||||
});
|
});
|
||||||
@ -99,6 +101,7 @@ describe("automatic-run utilities", () => {
|
|||||||
runBudget: 120,
|
runBudget: 120,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
},
|
},
|
||||||
sources: ["adzuna"],
|
sources: ["adzuna"],
|
||||||
});
|
});
|
||||||
@ -116,6 +119,7 @@ describe("automatic-run utilities", () => {
|
|||||||
runBudget: 120,
|
runBudget: 120,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
},
|
},
|
||||||
sources: ["hiringcafe"],
|
sources: ["hiringcafe"],
|
||||||
});
|
});
|
||||||
@ -133,6 +137,7 @@ describe("automatic-run utilities", () => {
|
|||||||
runBudget: 120,
|
runBudget: 120,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
},
|
},
|
||||||
sources: ["startupjobs"],
|
sources: ["startupjobs"],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,12 @@ import {
|
|||||||
import type { JobSource } from "@shared/types";
|
import type { JobSource } from "@shared/types";
|
||||||
|
|
||||||
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
|
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
|
||||||
|
export type WorkplaceType = "remote" | "hybrid" | "onsite";
|
||||||
|
export const WORKPLACE_TYPE_OPTIONS: WorkplaceType[] = [
|
||||||
|
"remote",
|
||||||
|
"hybrid",
|
||||||
|
"onsite",
|
||||||
|
];
|
||||||
|
|
||||||
export interface AutomaticRunValues {
|
export interface AutomaticRunValues {
|
||||||
topN: number;
|
topN: number;
|
||||||
@ -13,6 +19,7 @@ export interface AutomaticRunValues {
|
|||||||
runBudget: number;
|
runBudget: number;
|
||||||
country: string;
|
country: string;
|
||||||
cityLocations: string[];
|
cityLocations: string[];
|
||||||
|
workplaceTypes: WorkplaceType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutomaticPresetValues {
|
export interface AutomaticPresetValues {
|
||||||
@ -61,6 +68,22 @@ export interface AutomaticRunMemory {
|
|||||||
minSuitabilityScore: number;
|
minSuitabilityScore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkplaceTypes(
|
||||||
|
workplaceTypes: WorkplaceType[] | null | undefined,
|
||||||
|
): WorkplaceType[] {
|
||||||
|
const seen = new Set<WorkplaceType>();
|
||||||
|
const out: WorkplaceType[] = [];
|
||||||
|
|
||||||
|
for (const workplaceType of workplaceTypes ?? []) {
|
||||||
|
if (!WORKPLACE_TYPE_OPTIONS.includes(workplaceType)) continue;
|
||||||
|
if (seen.has(workplaceType)) continue;
|
||||||
|
seen.add(workplaceType);
|
||||||
|
out.push(workplaceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length > 0 ? out : [...WORKPLACE_TYPE_OPTIONS];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExtractorLimits {
|
export interface ExtractorLimits {
|
||||||
jobspyResultsWanted: number;
|
jobspyResultsWanted: number;
|
||||||
gradcrackerMaxJobsPerTerm: number;
|
gradcrackerMaxJobsPerTerm: number;
|
||||||
|
|||||||
@ -192,6 +192,7 @@ export function usePipelineControls(
|
|||||||
: formatCountryLabel(values.country);
|
: formatCountryLabel(values.country);
|
||||||
await api.updateSettings({
|
await api.updateSettings({
|
||||||
searchTerms: values.searchTerms,
|
searchTerms: values.searchTerms,
|
||||||
|
workplaceTypes: values.workplaceTypes,
|
||||||
jobspyResultsWanted: limits.jobspyResultsWanted,
|
jobspyResultsWanted: limits.jobspyResultsWanted,
|
||||||
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
|
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
|
||||||
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
||||||
|
|||||||
@ -87,6 +87,19 @@ describe("settingsRegistry helpers", () => {
|
|||||||
);
|
);
|
||||||
expect(settingsRegistry.searchTerms.serialize(null)).toBeNull();
|
expect(settingsRegistry.searchTerms.serialize(null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses valid workplace type arrays", () => {
|
||||||
|
expect(
|
||||||
|
settingsRegistry.workplaceTypes.parse('["remote","onsite"]'),
|
||||||
|
).toEqual(["remote", "onsite"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid workplace type arrays", () => {
|
||||||
|
expect(
|
||||||
|
settingsRegistry.workplaceTypes.parse('["remote","satellite"]'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(settingsRegistry.workplaceTypes.parse("[]")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Resume projects settings", () => {
|
describe("Resume projects settings", () => {
|
||||||
|
|||||||
@ -90,6 +90,35 @@ function createEnumParser<const TValues extends readonly [string, ...string[]]>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEnumArrayParser<
|
||||||
|
const TValues extends readonly [string, ...string[]],
|
||||||
|
>(values: TValues): (raw: string | undefined) => TValues[number][] | null {
|
||||||
|
const allowedValues = new Set<string>(values);
|
||||||
|
|
||||||
|
return (raw: string | undefined): TValues[number][] | null => {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
|
||||||
|
const out: TValues[number][] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const value of parsed) {
|
||||||
|
if (typeof value !== "string" || !allowedValues.has(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (seen.has(value)) continue;
|
||||||
|
seen.add(value);
|
||||||
|
out.push(value as TValues[number]);
|
||||||
|
}
|
||||||
|
if (out.length === 0) return null;
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const parseChatStyleLanguageModeOrNull = createEnumParser(
|
const parseChatStyleLanguageModeOrNull = createEnumParser(
|
||||||
CHAT_STYLE_LANGUAGE_MODE_VALUES,
|
CHAT_STYLE_LANGUAGE_MODE_VALUES,
|
||||||
);
|
);
|
||||||
@ -98,6 +127,9 @@ const parseChatStyleManualLanguageOrNull = createEnumParser(
|
|||||||
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const WORKPLACE_TYPE_VALUES = ["remote", "hybrid", "onsite"] as const;
|
||||||
|
const parseWorkplaceTypesOrNull = createEnumArrayParser(WORKPLACE_TYPE_VALUES);
|
||||||
|
|
||||||
export const resumeProjectsSchema = z.object({
|
export const resumeProjectsSchema = z.object({
|
||||||
maxProjects: z.number().int().min(0).max(100),
|
maxProjects: z.number().int().min(0).max(100),
|
||||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||||
@ -271,6 +303,17 @@ export const settingsRegistry = {
|
|||||||
parse: parseJsonArrayOrNull,
|
parse: parseJsonArrayOrNull,
|
||||||
serialize: serializeNullableJsonArray,
|
serialize: serializeNullableJsonArray,
|
||||||
},
|
},
|
||||||
|
workplaceTypes: {
|
||||||
|
kind: "typed" as const,
|
||||||
|
schema: z.array(z.enum(WORKPLACE_TYPE_VALUES)).min(1).max(3),
|
||||||
|
default: (): Array<(typeof WORKPLACE_TYPE_VALUES)[number]> => [
|
||||||
|
"remote",
|
||||||
|
"hybrid",
|
||||||
|
"onsite",
|
||||||
|
],
|
||||||
|
parse: parseWorkplaceTypesOrNull,
|
||||||
|
serialize: serializeNullableJsonArray,
|
||||||
|
},
|
||||||
blockedCompanyKeywords: {
|
blockedCompanyKeywords: {
|
||||||
kind: "typed" as const,
|
kind: "typed" as const,
|
||||||
schema: z.array(z.string().trim().min(1).max(200)).max(200),
|
schema: z.array(z.string().trim().min(1).max(200)).max(200),
|
||||||
|
|||||||
@ -159,6 +159,11 @@ export const createAppSettings = (
|
|||||||
default: ["Software Engineer"],
|
default: ["Software Engineer"],
|
||||||
override: null,
|
override: null,
|
||||||
},
|
},
|
||||||
|
workplaceTypes: {
|
||||||
|
value: ["remote", "hybrid", "onsite"],
|
||||||
|
default: ["remote", "hybrid", "onsite"],
|
||||||
|
override: null,
|
||||||
|
},
|
||||||
blockedCompanyKeywords: {
|
blockedCompanyKeywords: {
|
||||||
value: [],
|
value: [],
|
||||||
default: [],
|
default: [],
|
||||||
|
|||||||
@ -155,6 +155,7 @@ export interface AppSettings {
|
|||||||
gradcrackerMaxJobsPerTerm: Resolved<number>;
|
gradcrackerMaxJobsPerTerm: Resolved<number>;
|
||||||
startupjobsMaxJobsPerTerm: Resolved<number>;
|
startupjobsMaxJobsPerTerm: Resolved<number>;
|
||||||
searchTerms: Resolved<string[]>;
|
searchTerms: Resolved<string[]>;
|
||||||
|
workplaceTypes: Resolved<Array<"remote" | "hybrid" | "onsite">>;
|
||||||
blockedCompanyKeywords: Resolved<string[]>;
|
blockedCompanyKeywords: Resolved<string[]>;
|
||||||
scoringInstructions: Resolved<string>;
|
scoringInstructions: Resolved<string>;
|
||||||
searchCities: Resolved<string>;
|
searchCities: Resolved<string>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user