City search (#217)

* wave 1, jobspy only

* combine usa/ca to united states

* strict city location filter

* hide and show based on focus

* UI changes

* allow clicking cross!

* pill animate in

* animate out, uggo fix

* animate out

* framer motion

* animate component height

* adzuna

* hiring cafe implementation

* refactor: centralize shared search-city parsing and matching

* feat: migrate city setting to searchCities with legacy fallback

* docs: update pipeline and extractor city-search wording

* fix(orchestrator): normalize tokenized paste behavior

* fix(shared): tighten city matching semantics

* docs(extractors): document city-location knobs and geocoding note
This commit is contained in:
Shaheer Sarfaraz
2026-02-21 00:42:09 +00:00
committed by GitHub
parent 5ed9069813
commit 19266fe5eb
38 changed files with 1756 additions and 439 deletions
+1
View File
@@ -56,6 +56,7 @@
"dotenv": "^17.2.3",
"drizzle-orm": "^0.38.2",
"express": "^4.18.2",
"framer-motion": "^12.34.3",
"get-tsconfig": "^4.10.0",
"html-to-text": "^9.0.5",
"jsdom": "^25.0.1",
@@ -6,6 +6,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { OrchestratorPage } from "./OrchestratorPage";
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
import type { FilterTab } from "./orchestrator/constants";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
@@ -51,14 +52,15 @@ let mockPipelineTerminalEvent: {
token: number;
} | null = null;
let mockPipelineSources = ["linkedin"] as Array<
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs"
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs" | "adzuna" | "hiringcafe"
>;
let mockAutomaticRunValues = {
let mockAutomaticRunValues: AutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: [],
};
const jobFixture = createJob({
@@ -325,13 +327,7 @@ vi.mock("./orchestrator/RunModeModal", () => ({
RunModeModal: ({
onSaveAndRunAutomatic,
}: {
onSaveAndRunAutomatic: (values: {
topN: number;
minSuitabilityScore: number;
searchTerms: string[];
runBudget: number;
country: string;
}) => Promise<void>;
onSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise<void>;
}) => (
<button
type="button"
@@ -386,6 +382,7 @@ describe("OrchestratorPage", () => {
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: [],
};
});
@@ -701,7 +698,7 @@ describe("OrchestratorPage", () => {
ukvisajobsMaxJobs: 150,
adzunaMaxJobsPerTerm: 150,
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom",
searchCities: "United Kingdom",
});
});
expect(api.runPipeline).toHaveBeenCalledWith({
@@ -714,6 +711,108 @@ describe("OrchestratorPage", () => {
setIntervalSpy.mockRestore();
});
it("stores multiple cities for JobSpy sources in automatic mode", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
mockPipelineSources = ["linkedin"];
mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: ["London", "Manchester"],
};
render(
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
searchCities: "London|Manchester",
}),
);
});
});
it("stores multiple cities when only adzuna is selected", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
mockPipelineSources = ["adzuna"];
mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: ["Leeds", "Manchester"],
};
render(
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
searchCities: "Leeds|Manchester",
}),
);
});
});
it("stores multiple cities when only hiringcafe is selected", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
mockPipelineSources = ["hiringcafe"];
mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
cityLocations: ["Leeds", "Manchester"],
};
render(
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
searchCities: "Leeds|Manchester",
}),
);
});
});
it("shows completion toast from hook terminal state", async () => {
mockPipelineTerminalEvent = {
status: "completed",
@@ -797,6 +896,7 @@ describe("OrchestratorPage", () => {
searchTerms: ["backend"],
runBudget: 150,
country: "united states",
cityLocations: [],
};
render(
@@ -22,7 +22,10 @@ import * as api from "../api";
import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar";
import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog";
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
import {
deriveExtractorLimits,
serializeCityLocationsSetting,
} from "./orchestrator/automatic-run";
import type { FilterTab } from "./orchestrator/constants";
import { tabs } from "./orchestrator/constants";
import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar";
@@ -291,10 +294,21 @@ export const OrchestratorPage: React.FC = () => {
searchTerms: values.searchTerms,
sources: compatibleSources,
});
const jobspyLocation = compatibleSources.includes("glassdoor")
? (values.glassdoorLocation ?? "").trim() ||
formatCountryLabel(values.country)
: formatCountryLabel(values.country);
const hasJobSpySite = compatibleSources.some(
(source) =>
source === "indeed" ||
source === "linkedin" ||
source === "glassdoor",
);
const hasAdzuna = compatibleSources.includes("adzuna");
const hasHiringCafe = compatibleSources.includes("hiringcafe");
const serializedCities = serializeCityLocationsSetting(
values.cityLocations,
);
const searchCities =
(hasJobSpySite || hasAdzuna || hasHiringCafe) && serializedCities
? serializedCities
: formatCountryLabel(values.country);
await api.updateSettings({
searchTerms: values.searchTerms,
jobspyResultsWanted: limits.jobspyResultsWanted,
@@ -302,7 +316,7 @@ export const OrchestratorPage: React.FC = () => {
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
jobspyCountryIndeed: values.country,
jobspyLocation,
searchCities,
});
await refreshSettings();
await startPipelineRun({
@@ -70,8 +70,8 @@ const baseSettings = createAppSettings({
defaultJobspyResultsWanted: 200,
jobspyCountryIndeed: "UK",
defaultJobspyCountryIndeed: "UK",
jobspyLocation: "UK",
defaultJobspyLocation: "UK",
searchCities: "London",
defaultSearchCities: "London",
searchTerms: ["engineer"],
defaultSearchTerms: ["engineer"],
});
@@ -25,7 +25,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "us",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
@@ -41,6 +41,29 @@ describe("AutomaticRunTab", () => {
).toBeInTheDocument();
});
it("maps legacy usa/ca country to United States in the picker", () => {
render(
<AutomaticRunTab
open
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "usa/ca",
searchCities: "",
})}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(
screen.getByRole("combobox", { name: "United States" }),
).toBeInTheDocument();
});
it("disables and prunes UK-only sources for non-UK country", async () => {
const onSetPipelineSources = vi.fn();
@@ -50,7 +73,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
@@ -76,7 +99,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
@@ -103,7 +126,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "japan",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]}
@@ -134,7 +157,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom",
searchCities: "United Kingdom",
})}
enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]}
@@ -152,7 +175,7 @@ describe("AutomaticRunTab", () => {
const glassdoorButton = screen.getByRole("button", { name: "Glassdoor" });
expect(glassdoorButton).toBeDisabled();
expect(glassdoorButton.getAttribute("title")).toContain(
"Set a Glassdoor city in Advanced settings to enable Glassdoor.",
"Add at least one city in Advanced settings to enable Glassdoor.",
);
});
@@ -163,7 +186,7 @@ describe("AutomaticRunTab", () => {
settings={createAppSettings({
searchTerms: ["backend engineer", "frontend engineer"],
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "",
searchCities: "",
})}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
@@ -175,6 +198,7 @@ describe("AutomaticRunTab", () => {
);
const input = screen.getByPlaceholderText("Type and press Enter");
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "Backspace" });
expect(
@@ -184,4 +208,34 @@ describe("AutomaticRunTab", () => {
screen.getByRole("button", { name: "Remove frontend engineer" }),
).toBeInTheDocument();
});
it("loads multiple saved cities and keeps glassdoor enabled", () => {
render(
<AutomaticRunTab
open
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united kingdom",
searchCities: "London|Manchester",
})}
enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
fireEvent.focus(screen.getByLabelText("Cities"));
expect(
screen.getByRole("button", { name: "Remove city London" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Remove city Manchester" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled();
});
});
@@ -5,7 +5,7 @@ import {
SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support.js";
import type { AppSettings, JobSource } from "@shared/types";
import { Loader2, Sparkles, X } from "lucide-react";
import { Loader2, Sparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import {
@@ -33,9 +33,12 @@ import {
type AutomaticRunValues,
calculateAutomaticEstimate,
loadAutomaticRunMemory,
parseCityLocationsInput,
parseCityLocationsSetting,
parseSearchTermsInput,
saveAutomaticRunMemory,
} from "./automatic-run";
import { TokenizedInput } from "./TokenizedInput";
interface AutomaticRunTabProps {
open: boolean;
@@ -54,6 +57,7 @@ const DEFAULT_VALUES: AutomaticRunValues = {
searchTerms: ["web developer"],
runBudget: 200,
country: "united kingdom",
cityLocations: [],
};
interface AutomaticRunFormValues {
@@ -61,7 +65,8 @@ interface AutomaticRunFormValues {
minSuitabilityScore: string;
runBudget: string;
country: string;
glassdoorLocation: string;
cityLocations: string[];
cityLocationDraft: string;
searchTerms: string[];
searchTermDraft: string;
}
@@ -71,8 +76,15 @@ type AutomaticPresetSelection = AutomaticPresetId | "custom";
const GLASSDOOR_COUNTRY_REASON =
"Glassdoor is not available for the selected country.";
const GLASSDOOR_LOCATION_REASON =
"Set a Glassdoor city in Advanced settings to enable Glassdoor.";
"Add at least one city in Advanced settings to enable Glassdoor.";
const UK_ONLY_SOURCES = new Set<JobSource>(["gradcracker", "ukvisajobs"]);
const HIDDEN_COUNTRY_KEYS = new Set(["usa/ca"]);
function normalizeUiCountryKey(value: string): string {
const normalized = normalizeCountryKey(value);
if (normalized === "usa/ca") return "united states";
return normalized;
}
function getSourceDisabledReason(
source: JobSource,
@@ -138,25 +150,25 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
}) => {
const [isSaving, setIsSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
{
defaultValues: {
topN: String(DEFAULT_VALUES.topN),
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
runBudget: String(DEFAULT_VALUES.runBudget),
country: DEFAULT_VALUES.country,
glassdoorLocation: "",
searchTerms: DEFAULT_VALUES.searchTerms,
searchTermDraft: "",
},
const { watch, reset, setValue } = useForm<AutomaticRunFormValues>({
defaultValues: {
topN: String(DEFAULT_VALUES.topN),
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
runBudget: String(DEFAULT_VALUES.runBudget),
country: DEFAULT_VALUES.country,
cityLocations: [],
cityLocationDraft: "",
searchTerms: DEFAULT_VALUES.searchTerms,
searchTermDraft: "",
},
);
});
const topNInput = watch("topN");
const minScoreInput = watch("minSuitabilityScore");
const runBudgetInput = watch("runBudget");
const countryInput = watch("country");
const glassdoorLocationInput = watch("glassdoorLocation");
const cityLocations = watch("cityLocations");
const cityLocationDraft = watch("cityLocationDraft");
const searchTerms = watch("searchTerms");
const searchTermDraft = watch("searchTermDraft");
@@ -173,48 +185,35 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
settings?.gradcrackerMaxJobsPerTerm ??
settings?.ukvisajobsMaxJobs ??
DEFAULT_VALUES.runBudget;
const rememberedCountry = normalizeCountryKey(
const rememberedCountry = normalizeUiCountryKey(
settings?.jobspyCountryIndeed ??
settings?.jobspyLocation ??
settings?.searchCities ??
DEFAULT_VALUES.country,
);
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
const rememberedLocationRaw = settings?.jobspyLocation?.trim() ?? "";
const rememberedLocationNormalized = normalizeCountryKey(
rememberedLocationRaw,
const rememberedLocations = parseCityLocationsSetting(
settings?.searchCities,
).filter(
(location) =>
normalizeCountryKey(location) !==
normalizeCountryKey(rememberedCountryKey),
);
const rememberedGlassdoorLocation =
rememberedLocationRaw &&
rememberedLocationNormalized &&
rememberedLocationNormalized !== normalizeCountryKey(rememberedCountryKey)
? rememberedLocationRaw
: "";
reset({
topN: String(topN),
minSuitabilityScore: String(minSuitabilityScore),
runBudget: String(rememberedRunBudget),
country: rememberedCountry || DEFAULT_VALUES.country,
glassdoorLocation: rememberedGlassdoorLocation,
cityLocations: rememberedLocations,
cityLocationDraft: "",
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
searchTermDraft: "",
});
setAdvancedOpen(false);
}, [open, settings, reset]);
const addSearchTerms = (input: string) => {
const parsed = parseSearchTermsInput(input);
if (parsed.length === 0) return;
const current = getValues("searchTerms");
const next = [...current];
for (const term of parsed) {
if (!next.includes(term)) next.push(term);
}
setValue("searchTerms", next, { shouldDirty: true });
};
const values = useMemo<AutomaticRunValues>(() => {
const normalizedCountry = normalizeCountryKey(countryInput);
const normalizedCountry = normalizeUiCountryKey(countryInput);
return {
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
minSuitabilityScore: toNumber(
@@ -225,7 +224,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
),
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
country: normalizedCountry || DEFAULT_VALUES.country,
glassdoorLocation: glassdoorLocationInput.trim() || undefined,
cityLocations,
searchTerms,
};
}, [
@@ -233,17 +232,18 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
minScoreInput,
runBudgetInput,
countryInput,
glassdoorLocationInput,
cityLocations,
searchTerms,
]);
const isSourceAvailableForRun = useCallback(
(source: JobSource) => {
if (!isSourceAllowedForCountry(source, values.country)) return false;
if (source === "glassdoor" && !values.glassdoorLocation) return false;
if (source === "glassdoor" && values.cityLocations.length === 0)
return false;
return true;
},
[values.country, values.glassdoorLocation],
[values.country, values.cityLocations.length],
);
const compatibleEnabledSources = useMemo(
@@ -319,7 +319,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
const countryOptions = useMemo(
() =>
SUPPORTED_COUNTRY_KEYS.map((country) => ({
SUPPORTED_COUNTRY_KEYS.filter(
(country) => !HIDDEN_COUNTRY_KEYS.has(country),
).map((country) => ({
value: country,
label: formatCountryLabel(country),
})),
@@ -389,7 +391,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<Accordion
type="single"
collapsible
value={advancedOpen ? "advanced" : undefined}
value={advancedOpen ? "advanced" : ""}
onValueChange={(value) => setAdvancedOpen(value === "advanced")}
>
<AccordionItem value="advanced" className="border-b-0">
@@ -438,21 +440,24 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
/>
</div>
<div className="space-y-2 md:col-span-3">
<Label htmlFor="glassdoor-location">Glassdoor city</Label>
<Input
id="glassdoor-location"
value={glassdoorLocationInput}
onChange={(event) =>
setValue("glassdoorLocation", event.target.value, {
<Label htmlFor="city-locations-input">Cities</Label>
<TokenizedInput
id="city-locations-input"
values={cityLocations}
draft={cityLocationDraft}
parseInput={parseCityLocationsInput}
onDraftChange={(value) =>
setValue("cityLocationDraft", value)
}
onValuesChange={(value) =>
setValue("cityLocations", value, {
shouldDirty: true,
})
}
placeholder='e.g. "London"'
helperText="Optional for all sources, required when Glassdoor is selected."
removeLabelPrefix="Remove city"
/>
<p className="text-xs text-muted-foreground">
Required only for Glassdoor. Use a city (not country) to
keep results localized.
</p>
</div>
</div>
</AccordionContent>
@@ -465,58 +470,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<CardHeader className="pb-3">
<CardTitle>Search terms</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Input
<CardContent>
<TokenizedInput
id="search-terms-input"
value={searchTermDraft}
onChange={(event) =>
setValue("searchTermDraft", event.target.value)
values={searchTerms}
draft={searchTermDraft}
parseInput={parseSearchTermsInput}
onDraftChange={(value) => setValue("searchTermDraft", value)}
onValuesChange={(value) =>
setValue("searchTerms", value, { shouldDirty: true })
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
addSearchTerms(searchTermDraft);
setValue("searchTermDraft", "");
return;
}
}}
onBlur={() => {
addSearchTerms(searchTermDraft);
setValue("searchTermDraft", "");
}}
onPaste={(event) => {
const pasted = event.clipboardData.getData("text");
const parsed = parseSearchTermsInput(pasted);
if (parsed.length > 1) {
event.preventDefault();
addSearchTerms(pasted);
}
}}
placeholder="Type and press Enter"
helperText="Add multiple terms by separating with commas or pressing Enter."
removeLabelPrefix="Remove"
/>
<p className="text-xs text-muted-foreground">
Add multiple terms by separating with commas or pressing Enter.
</p>
<div className="flex flex-wrap gap-2">
{searchTerms.map((term) => (
<button
type="button"
key={term}
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/20 px-3 py-1 text-sm transition-all duration-150 hover:border-primary/50 hover:bg-primary/40 hover:text-primary-foreground hover:shadow-sm"
aria-label={`Remove ${term}`}
onClick={() =>
setValue(
"searchTerms",
searchTerms.filter((value) => value !== term),
{ shouldDirty: true },
)
}
>
{term}
<X className="h-3 w-3" />
</button>
))}
</div>
</CardContent>
</Card>
@@ -1,7 +1,6 @@
import { AnimatePresence, motion } from "framer-motion";
import type React from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FloatingJobActionsBarProps {
selectedCount: number;
@@ -26,84 +25,71 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
onRescoreSelected,
onClear,
}) => {
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (selectedCount > 0) {
setIsMounted(true);
const enterTimer = window.setTimeout(() => setIsVisible(true), 10);
return () => window.clearTimeout(enterTimer);
}
setIsVisible(false);
const exitTimer = window.setTimeout(() => setIsMounted(false), 180);
return () => window.clearTimeout(exitTimer);
}, [selectedCount]);
if (!isMounted) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4">
<div
className={cn(
"pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center",
"transition-all duration-200 ease-out",
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
)}
>
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
{selectedCount} selected
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
{canMoveSelected && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={jobActionInFlight}
onClick={onMoveToReady}
>
Move to Ready
</Button>
)}
{canSkipSelected && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={jobActionInFlight}
onClick={onSkipSelected}
>
Skip selected
</Button>
)}
{canRescoreSelected && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={jobActionInFlight}
onClick={onRescoreSelected}
>
Recalculate match
</Button>
)}
<Button
type="button"
size="sm"
variant="ghost"
className="w-full sm:w-auto"
onClick={onClear}
disabled={jobActionInFlight}
>
Clear
</Button>
</div>
</div>
</div>
<AnimatePresence initial={false}>
{selectedCount > 0 ? (
<motion.div
className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.18, ease: "easeOut" }}
>
<div className="pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center">
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
{selectedCount} selected
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
{canMoveSelected && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={jobActionInFlight}
onClick={onMoveToReady}
>
Move to Ready
</Button>
)}
{canSkipSelected && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={jobActionInFlight}
onClick={onSkipSelected}
>
Skip selected
</Button>
)}
{canRescoreSelected && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={jobActionInFlight}
onClick={onRescoreSelected}
>
Recalculate match
</Button>
)}
<Button
type="button"
size="sm"
variant="ghost"
className="w-full sm:w-auto"
onClick={onClear}
disabled={jobActionInFlight}
>
Clear
</Button>
</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();
});
});
@@ -0,0 +1,186 @@
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import type React from "react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface TokenizedInputProps {
id: string;
values: string[];
draft: string;
parseInput: (input: string) => string[];
onDraftChange: (value: string) => void;
onValuesChange: (values: string[]) => void;
placeholder: string;
helperText: string;
removeLabelPrefix: string;
collapsedTextLimit?: number;
}
function mergeUnique(values: string[], nextValues: string[]): string[] {
const seen = new Set(values.map((value) => value.toLowerCase()));
const out = [...values];
for (const value of nextValues) {
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
}
return out;
}
export const TokenizedInput: React.FC<TokenizedInputProps> = ({
id,
values,
draft,
parseInput,
onDraftChange,
onValuesChange,
placeholder,
helperText,
removeLabelPrefix,
collapsedTextLimit = 3,
}) => {
const [isFocused, setIsFocused] = useState(false);
const tokensRef = useRef<HTMLDivElement | null>(null);
const summaryRef = useRef<HTMLParagraphElement | null>(null);
const [tokensHeight, setTokensHeight] = useState(20);
const [summaryHeight, setSummaryHeight] = useState(20);
const updateHeights = useCallback(() => {
if (tokensRef.current) {
setTokensHeight(Math.max(20, tokensRef.current.scrollHeight));
}
if (summaryRef.current) {
setSummaryHeight(Math.max(20, summaryRef.current.scrollHeight));
}
}, []);
const collapsedSummary = useMemo(() => {
if (values.length === 0) return "";
const visibleCount = Math.max(0, Math.floor(collapsedTextLimit));
if (visibleCount === 0) return `and ${values.length} more`;
const visibleValues = values.slice(0, visibleCount);
const hiddenCount = values.length - visibleValues.length;
if (hiddenCount <= 0) return visibleValues.join(", ");
return `${visibleValues.join(", ")} and ${hiddenCount} more`;
}, [collapsedTextLimit, values]);
const addValues = (input: string) => {
const parsed = parseInput(input);
if (parsed.length === 0) return;
onValuesChange(mergeUnique(values, parsed));
};
useLayoutEffect(() => {
updateHeights();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(updateHeights);
if (tokensRef.current) observer.observe(tokensRef.current);
if (summaryRef.current) observer.observe(summaryRef.current);
return () => observer.disconnect();
}, [updateHeights]);
useLayoutEffect(() => {
updateHeights();
});
return (
<div className="space-y-3">
<Input
id={id}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onFocus={() => setIsFocused(true)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
addValues(draft);
onDraftChange("");
return;
}
}}
onBlur={() => {
setIsFocused(false);
addValues(draft);
onDraftChange("");
}}
onPaste={(event) => {
const pasted = event.clipboardData.getData("text");
const parsed = parseInput(pasted);
if (parsed.length > 0) {
event.preventDefault();
addValues(pasted);
onDraftChange("");
}
}}
placeholder={placeholder}
/>
<p className="text-xs text-muted-foreground">{helperText}</p>
{values.length > 0 ? (
<motion.div
className="relative overflow-hidden"
animate={{ height: isFocused ? tokensHeight : summaryHeight }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<motion.div
aria-hidden={!isFocused}
ref={tokensRef}
className="absolute inset-x-0 top-0 flex flex-wrap gap-2"
animate={{
opacity: isFocused ? 1 : 0,
y: isFocused ? 0 : -4,
}}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ pointerEvents: isFocused ? "auto" : "none" }}
>
<AnimatePresence initial={false} mode="popLayout">
{values.map((value) => (
<motion.div
key={value}
layout
initial={{ opacity: 0, scale: 0.96, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: -4 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<Button
type="button"
variant="outline"
className="h-auto rounded-full px-2 py-1 text-xs text-muted-foreground"
aria-label={`${removeLabelPrefix} ${value}`}
onPointerDown={(event) => event.preventDefault()}
onClick={() =>
onValuesChange(
values.filter((existing) => existing !== value),
)
}
>
{value}
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</AnimatePresence>
</motion.div>
<motion.p
aria-hidden={isFocused}
ref={summaryRef}
className="absolute inset-x-0 top-0 text-xs text-muted-foreground"
animate={{
opacity: isFocused ? 0 : 1,
y: isFocused ? 4 : 0,
}}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ pointerEvents: isFocused ? "none" : "auto" }}
>
Currently selected: {collapsedSummary}
</motion.p>
</motion.div>
) : null}
</div>
);
};
@@ -27,6 +27,7 @@ describe("automatic-run utilities", () => {
searchTerms: ["backend", "platform"],
runBudget: 100,
country: "united kingdom",
cityLocations: [],
},
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
});
@@ -59,6 +60,7 @@ describe("automatic-run utilities", () => {
searchTerms: [],
runBudget: 750,
country: "united kingdom",
cityLocations: [],
},
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
});
@@ -85,6 +87,7 @@ describe("automatic-run utilities", () => {
searchTerms: ["backend", "platform"],
runBudget: 120,
country: "united kingdom",
cityLocations: [],
},
sources: ["adzuna"],
});
@@ -101,6 +104,7 @@ describe("automatic-run utilities", () => {
searchTerms: ["backend", "platform"],
runBudget: 120,
country: "united kingdom",
cityLocations: [],
},
sources: ["hiringcafe"],
});
@@ -1,3 +1,7 @@
import {
parseSearchCitiesSetting,
serializeSearchCitiesSetting,
} from "@shared/search-cities.js";
import type { JobSource } from "@shared/types";
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
@@ -8,7 +12,7 @@ export interface AutomaticRunValues {
searchTerms: string[];
runBudget: number;
country: string;
glassdoorLocation?: string;
cityLocations: string[];
}
export interface AutomaticPresetValues {
@@ -115,6 +119,29 @@ export function parseSearchTermsInput(input: string): string[] {
.filter(Boolean);
}
export function parseCityLocationsInput(input: string): string[] {
const parsed = parseSearchTermsInput(input);
const seen = new Set<string>();
const out: string[] = [];
for (const city of parsed) {
const key = city.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(city);
}
return out;
}
export function parseCityLocationsSetting(
location: string | null | undefined,
): string[] {
return parseSearchCitiesSetting(location);
}
export function serializeCityLocationsSetting(cities: string[]): string | null {
return serializeSearchCitiesSetting(cities);
}
export function stringifySearchTerms(terms: string[]): string {
return terms.join("\n");
}
@@ -24,7 +24,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
backupEnabled: "0",
backupHour: "2",
backupMaxCount: "5",
jobspyLocation: "United States",
searchCities: "United States",
jobspyResultsWanted: "25",
jobspyCountryIndeed: "US",
resumeProjects: JSON.stringify({
@@ -116,6 +116,35 @@ describe("discoverJobsStep", () => {
);
});
it("passes serialized multi-city locations to JobSpy", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united kingdom",
searchCities: "London|Manchester",
} as any);
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
success: true,
jobs: [],
} as any);
await discoverJobsStep({
mergedConfig: {
...config,
sources: ["linkedin"],
},
});
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
expect.objectContaining({
location: "London|Manchester",
}),
);
});
it("filters out glassdoor for unsupported countries", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
@@ -201,6 +230,37 @@ describe("discoverJobsStep", () => {
);
});
it("passes configured city locations to adzuna", async () => {
const settingsRepo = await import("../../repositories/settings");
const adzuna = await import("../../services/adzuna");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united kingdom",
searchCities: "Leeds|Manchester",
} as any);
vi.mocked(adzuna.runAdzuna).mockResolvedValue({
success: true,
jobs: [],
} as any);
await discoverJobsStep({
mergedConfig: {
...config,
sources: ["adzuna"],
},
});
expect(vi.mocked(adzuna.runAdzuna)).toHaveBeenCalledWith(
expect.objectContaining({
country: "gb",
countryKey: "united kingdom",
locations: ["Leeds", "Manchester"],
}),
);
});
it("skips adzuna for unsupported countries", async () => {
const settingsRepo = await import("../../repositories/settings");
const adzuna = await import("../../services/adzuna");
@@ -257,12 +317,46 @@ describe("discoverJobsStep", () => {
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
expect.objectContaining({
country: "united states",
countryKey: "united states",
locations: [],
searchTerms: ["engineer"],
maxJobsPerTerm: 25,
}),
);
});
it("passes configured city locations to hiringcafe", async () => {
const settingsRepo = await import("../../repositories/settings");
const hiringCafe = await import("../../services/hiring-cafe");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united kingdom",
jobspyResultsWanted: "25",
searchCities: "Leeds|Manchester",
} as any);
vi.mocked(hiringCafe.runHiringCafe).mockResolvedValue({
success: true,
jobs: [],
} as any);
await discoverJobsStep({
mergedConfig: {
...config,
sources: ["hiringcafe"],
},
});
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
expect.objectContaining({
country: "united kingdom",
countryKey: "united kingdom",
locations: ["Leeds", "Manchester"],
}),
);
});
it("updates Hiring Cafe terms and pages via progress callbacks", async () => {
const settingsRepo = await import("../../repositories/settings");
const hiringCafe = await import("../../services/hiring-cafe");
@@ -5,6 +5,7 @@ import {
isSourceAllowedForCountry,
normalizeCountryKey,
} from "@shared/location-support.js";
import { parseSearchCitiesSetting } from "@shared/search-cities.js";
import type { CreateJobInput, PipelineConfig } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings";
@@ -59,7 +60,10 @@ export async function discoverJobsStep(args: {
}
const selectedCountry = normalizeCountryKey(
settings.jobspyCountryIndeed ?? settings.jobspyLocation ?? "united kingdom",
settings.jobspyCountryIndeed ??
settings.searchCities ??
settings.jobspyLocation ??
"united kingdom",
);
const compatibleSources = args.mergedConfig.sources.filter((source) =>
isSourceAllowedForCountry(source, selectedCountry),
@@ -100,7 +104,8 @@ export async function discoverJobsStep(args: {
const jobSpyResult = await runJobSpy({
sites: jobSpySites,
searchTerms,
location: settings.jobspyLocation ?? undefined,
location:
settings.searchCities ?? settings.jobspyLocation ?? undefined,
resultsWanted: settings.jobspyResultsWanted
? parseInt(settings.jobspyResultsWanted, 10)
: undefined,
@@ -172,6 +177,10 @@ export async function discoverJobsStep(args: {
const adzunaResult = await runAdzuna({
country: adzunaCountryCode,
countryKey: selectedCountry,
locations: parseSearchCitiesSetting(
settings.searchCities ?? settings.jobspyLocation,
),
searchTerms,
maxJobsPerTerm: adzunaMaxJobsPerTerm,
onProgress: (event) => {
@@ -249,6 +258,10 @@ export async function discoverJobsStep(args: {
const hiringCafeResult = await runHiringCafe({
country: selectedCountry,
countryKey: selectedCountry,
locations: parseSearchCitiesSetting(
settings.searchCities ?? settings.jobspyLocation,
),
searchTerms,
maxJobsPerTerm: hiringCafeMaxJobsPerTerm,
onProgress: (event) => {
@@ -23,6 +23,7 @@ export type SettingKey =
| "adzunaMaxJobsPerTerm"
| "gradcrackerMaxJobsPerTerm"
| "searchTerms"
| "searchCities"
| "jobspyLocation"
| "jobspyResultsWanted"
| "jobspyCountryIndeed"
@@ -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);
});
});
+113 -51
View File
@@ -5,6 +5,12 @@ import { dirname, join } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { logger } from "@infra/logger";
import { normalizeCountryKey } from "@shared/location-support.js";
import {
matchesRequestedCity,
parseSearchCitiesSetting,
shouldApplyStrictCityFilter,
} from "@shared/search-cities.js";
import type { CreateJobInput } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
@@ -44,6 +50,8 @@ export type AdzunaProgressEvent =
export interface RunAdzunaOptions {
searchTerms?: string[];
country?: string;
countryKey?: string;
locations?: string[];
maxJobsPerTerm?: number;
onProgress?: (event: AdzunaProgressEvent) => void;
}
@@ -54,6 +62,27 @@ export interface AdzunaResult {
error?: string;
}
export function shouldApplyStrictLocationFilter(
location: string,
countryKey: string,
): boolean {
return shouldApplyStrictCityFilter(location, countryKey);
}
export function matchesRequestedLocation(
jobLocation: string | undefined,
requestedLocation: string,
): boolean {
return matchesRequestedCity(jobLocation, requestedLocation);
}
function resolveLocations(options: RunAdzunaOptions): string[] {
const raw = options.locations?.length
? options.locations
: parseSearchCitiesSetting(process.env.ADZUNA_LOCATION_QUERY ?? "");
return raw.map((value) => value.trim()).filter(Boolean);
}
function resolveTsxCliPath(): string | null {
try {
return require.resolve("tsx/dist/cli.mjs");
@@ -170,11 +199,15 @@ export async function runAdzuna(
}
const country = (options.country || "gb").trim().toLowerCase();
const countryKey = normalizeCountryKey(options.countryKey ?? "");
const maxJobsPerTerm = options.maxJobsPerTerm ?? 50;
const searchTerms =
options.searchTerms && options.searchTerms.length > 0
? options.searchTerms
: ["web developer"];
const locations = resolveLocations(options);
const runLocations = locations.length > 0 ? locations : [null];
const termTotal = searchTerms.length * runLocations.length;
const useNpmCommand = canRunNpmCommand();
if (!useNpmCommand && !TSX_CLI_PATH) {
return {
@@ -185,66 +218,95 @@ export async function runAdzuna(
}
try {
await new Promise<void>((resolve, reject) => {
const extractorEnv = {
...process.env,
JOBOPS_EMIT_PROGRESS: "1",
ADZUNA_APP_ID: appId,
ADZUNA_APP_KEY: appKey,
ADZUNA_COUNTRY: country,
ADZUNA_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
ADZUNA_SEARCH_TERMS: JSON.stringify(searchTerms),
ADZUNA_OUTPUT_JSON: DATASET_PATH,
};
const child = useNpmCommand
? spawn("npm", ["run", "start"], {
cwd: ADZUNA_DIR,
stdio: ["ignore", "pipe", "pipe"],
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"], {
const jobs: CreateJobInput[] = [];
const seen = new Set<string>();
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
const location = runLocations[runIndex];
const strictLocationFilter =
location !== null &&
shouldApplyStrictLocationFilter(location, countryKey);
await new Promise<void>((resolve, reject) => {
const extractorEnv = {
...process.env,
JOBOPS_EMIT_PROGRESS: "1",
ADZUNA_APP_ID: appId,
ADZUNA_APP_KEY: appKey,
ADZUNA_COUNTRY: country,
ADZUNA_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
ADZUNA_SEARCH_TERMS: JSON.stringify(searchTerms),
ADZUNA_OUTPUT_JSON: DATASET_PATH,
ADZUNA_LOCATION_QUERY: strictLocationFilter ? location : "",
};
const child = useNpmCommand
? spawn("npm", ["run", "start"], {
cwd: ADZUNA_DIR,
stdio: ["ignore", "pipe", "pipe"],
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 progressEvent = parseAdzunaProgressLine(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
? 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));
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(`Adzuna extractor exited with code ${code}`));
child.on("close", (code) => {
stdoutRl?.close();
stderrRl?.close();
if (code === 0) resolve();
else reject(new Error(`Adzuna extractor exited with code ${code}`));
});
child.on("error", reject);
});
child.on("error", reject);
});
const jobs = await readDataset();
const runJobs = await readDataset();
const filtered = strictLocationFilter
? runJobs.filter((job) =>
matchesRequestedLocation(job.location, location),
)
: runJobs;
for (const job of filtered) {
const key = job.sourceJobId || job.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
jobs.push(job);
}
}
return { success: true, jobs };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
matchesRequestedLocation,
shouldApplyStrictLocationFilter,
} from "./hiring-cafe";
describe("hiringcafe strict location filtering", () => {
it("enables strict filtering when city differs from country", () => {
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
true,
);
});
it("disables strict filtering when location is country-level", () => {
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
});
it("matches requested location by case-insensitive contains", () => {
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
false,
);
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
});
});
+121 -51
View File
@@ -6,6 +6,12 @@ import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize";
import { normalizeCountryKey } from "@shared/location-support.js";
import {
matchesRequestedCity,
parseSearchCitiesSetting,
shouldApplyStrictCityFilter,
} from "@shared/search-cities.js";
import type { CreateJobInput } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
@@ -50,6 +56,9 @@ export type HiringCafeProgressEvent =
export interface RunHiringCafeOptions {
searchTerms?: string[];
country?: string;
countryKey?: string;
locations?: string[];
locationRadiusMiles?: number;
maxJobsPerTerm?: number;
onProgress?: (event: HiringCafeProgressEvent) => void;
}
@@ -60,6 +69,27 @@ export interface HiringCafeResult {
error?: string;
}
export function shouldApplyStrictLocationFilter(
location: string,
countryKey: string,
): boolean {
return shouldApplyStrictCityFilter(location, countryKey);
}
export function matchesRequestedLocation(
jobLocation: string | undefined,
requestedLocation: string,
): boolean {
return matchesRequestedCity(jobLocation, requestedLocation);
}
function resolveLocations(options: RunHiringCafeOptions): string[] {
const raw = options.locations?.length
? options.locations
: parseSearchCitiesSetting(process.env.HIRING_CAFE_LOCATION_QUERY ?? "");
return raw.map((value) => value.trim()).filter(Boolean);
}
function resolveTsxCliPath(): string | null {
try {
return require.resolve("tsx/dist/cli.mjs");
@@ -182,7 +212,15 @@ export async function runHiringCafe(
? options.searchTerms
: ["web developer"];
const country = (options.country || "united kingdom").trim().toLowerCase();
const countryKey = normalizeCountryKey(options.countryKey ?? "");
const maxJobsPerTerm = options.maxJobsPerTerm ?? 200;
const locationRadiusMiles = Math.max(
1,
Math.floor(options.locationRadiusMiles ?? 1),
);
const locations = resolveLocations(options);
const runLocations = locations.length > 0 ? locations : [null];
const termTotal = searchTerms.length * runLocations.length;
const useNpmCommand = canRunNpmCommand();
if (!useNpmCommand && !TSX_CLI_PATH) {
@@ -194,70 +232,102 @@ export async function runHiringCafe(
}
try {
await clearStorageDataset();
const jobs: CreateJobInput[] = [];
const seen = new Set<string>();
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,
};
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
const location = runLocations[runIndex];
const strictLocationFilter =
location !== null &&
shouldApplyStrictLocationFilter(location, countryKey);
const child = useNpmCommand
? 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)",
);
}
await clearStorageDataset();
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,
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"], {
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) => {
const progressEvent = parseProgressLine(line);
if (progressEvent) {
options.onProgress?.(progressEvent);
return;
}
stream.write(`${line}\n`);
};
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
? 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));
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(`Hiring Cafe extractor exited with code ${code}`));
child.on("close", (code) => {
stdoutRl?.close();
stderrRl?.close();
if (code === 0) resolve();
else
reject(new Error(`Hiring Cafe extractor exited with code ${code}`));
});
child.on("error", reject);
});
child.on("error", reject);
});
const jobs = await readDataset();
const runJobs = await readDataset();
const filtered = strictLocationFilter
? runJobs.filter((job) =>
matchesRequestedLocation(job.location, location),
)
: runJobs;
for (const job of filtered) {
const key = job.sourceJobId || job.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
jobs.push(job);
}
}
return { success: true, jobs };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { parseJobSpyProgressLine } from "./jobspy";
import {
matchesRequestedLocation,
parseJobSpyProgressLine,
shouldApplyStrictLocationFilter,
} from "./jobspy";
describe("parseJobSpyProgressLine", () => {
it("parses term_start progress lines", () => {
@@ -38,3 +42,24 @@ describe("parseJobSpyProgressLine", () => {
expect(parseJobSpyProgressLine("Found 20 jobs")).toBeNull();
});
});
describe("strict location filtering", () => {
it("enables strict filtering when location differs from country", () => {
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
true,
);
});
it("disables strict filtering when location is country-level", () => {
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
});
it("matches location using case-insensitive contains checks", () => {
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
false,
);
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
});
});
+127 -82
View File
@@ -9,6 +9,11 @@ import { mkdir, readFile, unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import {
matchesRequestedCity,
parseSearchCitiesSetting,
shouldApplyStrictCityFilter,
} from "@shared/search-cities.js";
import type { CreateJobInput, JobSource } from "@shared/types";
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
import { getDataDir } from "../config/dataDir";
@@ -144,6 +149,7 @@ export interface RunJobSpyOptions {
sites?: Array<JobSource>;
searchTerms?: string[];
location?: string;
locations?: string[];
resultsWanted?: number;
hoursOld?: number;
countryIndeed?: string;
@@ -158,6 +164,20 @@ export interface JobSpyResult {
error?: string;
}
export function shouldApplyStrictLocationFilter(
location: string,
countryIndeed: string,
): boolean {
return shouldApplyStrictCityFilter(location, countryIndeed);
}
export function matchesRequestedLocation(
jobLocation: string | undefined,
requestedLocation: string,
): boolean {
return matchesRequestedCity(jobLocation, requestedLocation);
}
export async function runJobSpy(
options: RunJobSpyOptions = {},
): Promise<JobSpyResult> {
@@ -170,6 +190,9 @@ export async function runJobSpy(
.join(",");
const searchTerms = resolveSearchTerms(options);
const locations = resolveLocations(options);
const countryIndeed =
options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? "UK";
if (searchTerms.length === 0) {
return { success: true, jobs: [] };
}
@@ -178,93 +201,105 @@ export async function runJobSpy(
const jobs: CreateJobInput[] = [];
const seenJobUrls = new Set<string>();
for (let i = 0; i < searchTerms.length; i++) {
const searchTerm = searchTerms[i];
const suffix = `${i + 1}_${slugForFilename(searchTerm)}`;
const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
const totalRuns = searchTerms.length * locations.length;
let runIndex = 0;
await new Promise<void>((resolve, reject) => {
const pythonPath = getPythonPath();
const child = spawn(pythonPath, [JOBSPY_SCRIPT], {
cwd: JOBSPY_DIR,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
JOBSPY_SITES: sites || "indeed,linkedin,glassdoor",
JOBSPY_SEARCH_TERM: searchTerm,
JOBSPY_TERM_INDEX: String(i + 1),
JOBSPY_TERM_TOTAL: String(searchTerms.length),
JOBSPY_LOCATION:
options.location ?? process.env.JOBSPY_LOCATION ?? "UK",
JOBSPY_RESULTS_WANTED: String(
options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200,
),
JOBSPY_HOURS_OLD: String(
options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72,
),
JOBSPY_COUNTRY_INDEED:
options.countryIndeed ??
process.env.JOBSPY_COUNTRY_INDEED ??
"UK",
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
options.linkedinFetchDescription ??
process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ??
"1",
),
JOBSPY_IS_REMOTE: String(
options.isRemote ?? process.env.JOBSPY_IS_REMOTE ?? "0",
),
JOBSPY_OUTPUT_CSV: outputCsv,
JOBSPY_OUTPUT_JSON: outputJson,
},
for (const searchTerm of searchTerms) {
for (const location of locations) {
runIndex += 1;
const suffix = `${runIndex}_${slugForFilename(searchTerm)}_${slugForFilename(location)}`;
const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
await new Promise<void>((resolve, reject) => {
const pythonPath = getPythonPath();
const child = spawn(pythonPath, [JOBSPY_SCRIPT], {
cwd: JOBSPY_DIR,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
JOBSPY_SITES: sites || "indeed,linkedin,glassdoor",
JOBSPY_SEARCH_TERM: searchTerm,
JOBSPY_TERM_INDEX: String(runIndex),
JOBSPY_TERM_TOTAL: String(totalRuns),
JOBSPY_LOCATION: location,
JOBSPY_RESULTS_WANTED: String(
options.resultsWanted ??
process.env.JOBSPY_RESULTS_WANTED ??
200,
),
JOBSPY_HOURS_OLD: String(
options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72,
),
JOBSPY_COUNTRY_INDEED: countryIndeed,
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
options.linkedinFetchDescription ??
process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ??
"1",
),
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 event = parseJobSpyProgressLine(line);
if (event) {
options.onProgress?.(event);
return;
}
stream.write(`${line}\n`);
};
const raw = await readFile(outputJson, "utf-8");
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
const mapped = mapJobSpyRows(parsed);
const strictLocationFilter = shouldApplyStrictLocationFilter(
location,
countryIndeed,
);
const filtered = strictLocationFilter
? mapped.filter((job) =>
matchesRequestedLocation(job.location, location),
)
: mapped;
const stdoutRl = child.stdout
? createInterface({ input: child.stdout })
: null;
const stderrRl = child.stderr
? createInterface({ input: child.stderr })
: null;
for (const job of filtered) {
const url = job.jobUrl;
if (seenJobUrls.has(url)) continue;
seenJobUrls.add(url);
jobs.push(job);
}
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 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
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[] {
const fromOptions = options.searchTerms?.length ? options.searchTerms : null;
const fromEnv = parseSearchTermsEnv(process.env.JOBSPY_SEARCH_TERMS);
@@ -66,7 +66,7 @@ describe("settings-conversion", () => {
it("uses string defaults when override is empty", () => {
process.env.JOBSPY_LOCATION = "Remote";
const resolved = resolveSettingValue("jobspyLocation", "");
const resolved = resolveSettingValue("searchCities", "");
expect(resolved.defaultValue).toBe("Remote");
expect(resolved.overrideValue).toBe("");
expect(resolved.value).toBe("Remote");
@@ -10,7 +10,7 @@ type SettingsConversionValueMap = {
adzunaMaxJobsPerTerm: number;
gradcrackerMaxJobsPerTerm: number;
searchTerms: string[];
jobspyLocation: string;
searchCities: string;
jobspyResultsWanted: number;
jobspyCountryIndeed: string;
showSponsorInfo: boolean;
@@ -124,8 +124,9 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
serialize: serializeNullableJsonArray,
resolve: resolveWithNullishFallback,
},
jobspyLocation: {
defaultValue: () => process.env.JOBSPY_LOCATION || "UK",
searchCities: {
defaultValue: () =>
process.env.SEARCH_CITIES || process.env.JOBSPY_LOCATION || "UK",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
@@ -177,8 +177,12 @@ export const settingsUpdateRegistry: Partial<{
searchTerms: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("searchTerms", value)] }),
),
searchCities: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("searchCities", value)] }),
),
// Deprecated legacy key; persist into canonical searchCities setting.
jobspyLocation: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("jobspyLocation", value)] }),
result({ actions: [metadataPersistAction("searchCities", value)] }),
),
jobspyResultsWanted: singleAction(({ value }) =>
result({
+9 -9
View File
@@ -123,13 +123,13 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const overrideSearchTerms = searchTermsSetting.overrideValue;
const searchTerms = searchTermsSetting.value;
const jobspyLocationSetting = resolveSettingValue(
"jobspyLocation",
overrides.jobspyLocation,
const searchCitiesSetting = resolveSettingValue(
"searchCities",
overrides.searchCities ?? overrides.jobspyLocation,
);
const defaultJobspyLocation = jobspyLocationSetting.defaultValue;
const overrideJobspyLocation = jobspyLocationSetting.overrideValue;
const jobspyLocation = jobspyLocationSetting.value;
const defaultSearchCities = searchCitiesSetting.defaultValue;
const overrideSearchCities = searchCitiesSetting.overrideValue;
const searchCities = searchCitiesSetting.value;
const jobspyResultsWantedSetting = resolveSettingValue(
"jobspyResultsWanted",
@@ -278,9 +278,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
searchTerms,
defaultSearchTerms,
overrideSearchTerms,
jobspyLocation,
defaultJobspyLocation,
overrideJobspyLocation,
searchCities,
defaultSearchCities,
overrideSearchCities,
jobspyResultsWanted,
defaultJobspyResultsWanted,
overrideJobspyResultsWanted,