diff --git a/docs-site/docs/extractors/adzuna.md b/docs-site/docs/extractors/adzuna.md index 43e77c2..e11e215 100644 --- a/docs-site/docs/extractors/adzuna.md +++ b/docs-site/docs/extractors/adzuna.md @@ -29,11 +29,17 @@ Adzuna provides stable API discovery for countries that are not covered by UK-on 5. In **Pipeline Run** (Automatic tab), select a compatible country and enable **Adzuna** in Sources. 6. Start the run; Adzuna progress appears in the existing crawl progress stream. +City behavior: + +- If **Search cities** are set in Automatic advanced settings, Adzuna runs once per city. +- City runs use strict post-filtering (`job.location` contains requested city) to avoid broad country-level spillover. + Default controls: - `ADZUNA_APP_ID` - `ADZUNA_APP_KEY` - `ADZUNA_MAX_JOBS_PER_TERM` (default `50`) +- `ADZUNA_LOCATION_QUERY` (optional city/location text) Supported countries in this integration: diff --git a/docs-site/docs/extractors/hiring-cafe.md b/docs-site/docs/extractors/hiring-cafe.md index c4e4975..99c7e31 100644 --- a/docs-site/docs/extractors/hiring-cafe.md +++ b/docs-site/docs/extractors/hiring-cafe.md @@ -32,6 +32,7 @@ It also supports term-by-term search and country-aware search state using the sa - `searchTerms` drive per-term Hiring Cafe `searchQuery`. - selected country maps into Hiring Cafe location search state. - run budget path (`jobspyResultsWanted`) is reused as the max jobs-per-term cap. + - optional **Search cities** narrow results by city. 4. Start the run and watch progress in the pipeline progress card. Defaults and constraints: @@ -40,6 +41,8 @@ Defaults and constraints: - `worldwide` and `usa/ca` run in broad mode without a strict country location filter. - Hiring Cafe is enabled by default in source selection. - `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` controls recency window when running extractor directly (default `7`). +- When a city is provided via `searchCities`, Hiring Cafe uses city radius search (default `1` mile) and strict city post-filtering. +- City geocoding is resolved through Nominatim (OpenStreetMap data); if you scale extractor traffic, add attribution and cache repeated city lookups. Local run example: diff --git a/docs-site/docs/features/pipeline-run.md b/docs-site/docs/features/pipeline-run.md index 2660906..b6f028e 100644 --- a/docs-site/docs/features/pipeline-run.md +++ b/docs-site/docs/features/pipeline-run.md @@ -50,7 +50,7 @@ If values are edited manually, the UI shows **Custom**. - Adzuna is available only for its supported countries and when App ID/App Key are configured in Settings. - Glassdoor can be enabled only when: - selected country supports Glassdoor - - a **Glassdoor city** is set in Advanced settings + - at least one **Search city** is set in Advanced settings Incompatible sources are disabled with explanatory tooltips. @@ -59,7 +59,7 @@ Incompatible sources are disabled with explanatory tooltips. - **Resumes tailored** (`topN`) - **Min suitability score** - **Max jobs discovered** (run budget cap) -- **Glassdoor city** (required only for Glassdoor) +- **Search cities** (optional multi-city input; required for Glassdoor) #### Search terms @@ -97,7 +97,7 @@ For accepted input formats, inference behavior, and limits, see [Manual Import E ### Glassdoor cannot be enabled - Verify selected country supports Glassdoor. -- Set a Glassdoor city in Advanced settings. +- Set at least one Search city in Advanced settings. ### Adzuna is not selectable diff --git a/extractors/adzuna/README.md b/extractors/adzuna/README.md index 3d74dd0..cafdad0 100644 --- a/extractors/adzuna/README.md +++ b/extractors/adzuna/README.md @@ -9,6 +9,7 @@ for orchestrator ingestion. - `ADZUNA_APP_KEY` (required) - `ADZUNA_COUNTRY` (default: `gb`) - `ADZUNA_SEARCH_TERMS` (JSON array or `|` / comma / newline-delimited) +- `ADZUNA_LOCATION_QUERY` (optional city/location text passed to Adzuna `where`) - `ADZUNA_MAX_JOBS_PER_TERM` (default: `50`) - `ADZUNA_RESULTS_PER_PAGE` (default: `50`, max `50`) - `ADZUNA_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`) diff --git a/extractors/adzuna/src/main.ts b/extractors/adzuna/src/main.ts index 0845b5a..c0e5a97 100644 --- a/extractors/adzuna/src/main.ts +++ b/extractors/adzuna/src/main.ts @@ -104,6 +104,7 @@ async function fetchJobsPage(args: { appId: string; appKey: string; what: string; + where?: string; resultsPerPage: number; }): Promise { const url = new URL(`${API_BASE}/jobs/${args.country}/search/${args.page}`); @@ -112,6 +113,9 @@ async function fetchJobsPage(args: { if (args.what) { url.searchParams.set("what", args.what); } + if (args.where) { + url.searchParams.set("where", args.where); + } url.searchParams.set("results_per_page", String(args.resultsPerPage)); const response = await fetch(url.toString(), { @@ -146,6 +150,7 @@ async function run(): Promise { const outputJson = process.env.ADZUNA_OUTPUT_JSON || join(process.cwd(), "storage/datasets/default/jobs.json"); + const locationQuery = process.env.ADZUNA_LOCATION_QUERY?.trim() || ""; const jobs: ExtractedJob[] = []; @@ -171,6 +176,7 @@ async function run(): Promise { appId, appKey, what: searchTerm, + where: locationQuery || undefined, resultsPerPage: take, }); diff --git a/extractors/hiringcafe/README.md b/extractors/hiringcafe/README.md index 8298cca..02938e8 100644 --- a/extractors/hiringcafe/README.md +++ b/extractors/hiringcafe/README.md @@ -10,6 +10,8 @@ Special thanks: initial implementation inspiration came from [umur957/hiring-caf - `HIRING_CAFE_COUNTRY` (default: `united kingdom`) - `HIRING_CAFE_MAX_JOBS_PER_TERM` (default: `200`) - `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` (default: `7`) +- `HIRING_CAFE_LOCATION_QUERY` (optional city, e.g. `Leeds`) +- `HIRING_CAFE_LOCATION_RADIUS_MILES` (default: `1` when city is set) - `HIRING_CAFE_OUTPUT_JSON` (default: `storage/datasets/default/jobs.json`) - `JOBOPS_EMIT_PROGRESS=1` to emit `JOBOPS_PROGRESS` events - `HIRING_CAFE_HEADLESS=false` to run headed @@ -18,3 +20,4 @@ Special thanks: initial implementation inspiration came from [umur957/hiring-caf - The extractor uses `s = base64(url-encoded JSON search state)`. - `worldwide` and `usa/ca` are treated as broad search modes without hard country location filters. +- City geocoding uses [Nominatim](https://nominatim.openstreetmap.org/) (OpenStreetMap data). diff --git a/extractors/hiringcafe/src/main.ts b/extractors/hiringcafe/src/main.ts index 27ab118..30cad2c 100644 --- a/extractors/hiringcafe/src/main.ts +++ b/extractors/hiringcafe/src/main.ts @@ -20,6 +20,7 @@ const JOBOPS_PROGRESS_PREFIX = "JOBOPS_PROGRESS "; const DEFAULT_MAX_JOBS_PER_TERM = 200; const DEFAULT_SEARCH_TERM = "web developer"; const DEFAULT_DATE_FETCHED_PAST_N_DAYS = 30; +const DEFAULT_LOCATION_RADIUS_MILES = 1; const PAGE_LIMIT = 50; type RawHiringCafeJob = Record; @@ -46,6 +47,27 @@ interface BrowserApiResponse { responseText: string; } +interface CityLocationContext { + id: string; + city: string; + regionLong: string; + regionShort: string; + countryLong: string; + countryShort: string; + lat: number; + lon: number; + formattedAddress: string; + population: number | null; + radiusMiles: number; +} + +interface NominatimResult { + lat?: string; + lon?: string; + display_name?: string; + address?: Record; +} + function emitProgress(payload: Record): void { if (process.env.JOBOPS_EMIT_PROGRESS !== "1") return; console.log(`${JOBOPS_PROGRESS_PREFIX}${JSON.stringify(payload)}`); @@ -191,6 +213,261 @@ function parseTotalCount(payload: unknown): number | null { return toNumberOrNull(payloadRecord.total); } +function buildCityLocationId(input: string): string { + const normalized = input.trim().toLowerCase().replace(/\s+/g, "_"); + return `city_${normalized}`.slice(0, 32); +} + +function toRegionShortName(value: string): string { + const compact = value + .replace(/[^a-zA-Z\s]/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean); + if (compact.length === 0) return "REG"; + if (compact.length === 1) { + return compact[0].slice(0, 3).toUpperCase(); + } + return compact + .slice(0, 3) + .map((part) => part[0]?.toUpperCase() ?? "") + .join(""); +} + +async function resolveCityLocationContext(args: { + city: string; + countryLong: string; + countryShort: string; + radiusMiles: number; +}): Promise { + const query = `${args.city}, ${args.countryLong}`; + const url = new URL("https://nominatim.openstreetmap.org/search"); + url.searchParams.set("q", query); + url.searchParams.set("format", "jsonv2"); + url.searchParams.set("addressdetails", "1"); + url.searchParams.set("limit", "1"); + + try { + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "job-ops-hiringcafe-extractor/1.0", + }, + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) { + throw new Error(`geocode failed (${response.status})`); + } + const payload = (await response.json()) as unknown; + if (!Array.isArray(payload) || payload.length === 0) { + throw new Error("geocode returned no results"); + } + const first = payload[0] as NominatimResult; + const lat = Number(first.lat ?? ""); + const lon = Number(first.lon ?? ""); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + throw new Error("invalid geocode coordinates"); + } + const address = asRecord(first.address); + const regionLong = + toStringOrNull(address?.state) ?? + toStringOrNull(address?.county) ?? + toStringOrNull(address?.region) ?? + args.countryLong; + const displayName = + toStringOrNull(first.display_name) ?? + `${args.city}, ${regionLong}, ${args.countryShort}`; + return { + id: buildCityLocationId(args.city), + city: args.city, + regionLong, + regionShort: toRegionShortName(regionLong), + countryLong: args.countryLong, + countryShort: args.countryShort, + lat, + lon, + formattedAddress: displayName, + population: null, + radiusMiles: args.radiusMiles, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`City geocode failed for '${query}': ${message}`); + return null; + } +} + +function createCitySearchState(args: { + searchQuery: string; + dateFetchedPastNDays: number; + context: CityLocationContext; +}): Record { + return { + locations: [ + { + id: args.context.id, + types: ["locality"], + address_components: [ + { + long_name: args.context.city, + short_name: args.context.city, + types: ["locality"], + }, + { + long_name: args.context.regionLong, + short_name: args.context.regionShort, + types: ["administrative_area_level_1"], + }, + { + long_name: args.context.countryLong, + short_name: args.context.countryShort, + types: ["country"], + }, + ], + geometry: { + location: { + lat: args.context.lat, + lon: args.context.lon, + }, + }, + formatted_address: args.context.formattedAddress, + population: args.context.population, + workplace_types: [], + options: { + radius: args.context.radiusMiles, + radius_unit: "miles", + ignore_radius: false, + }, + }, + ], + workplaceTypes: ["Remote", "Hybrid", "Onsite"], + defaultToUserLocation: true, + userLocation: null, + physicalEnvironments: [ + "Office", + "Outdoor", + "Vehicle", + "Industrial", + "Customer-Facing", + ], + physicalLaborIntensity: ["Low", "Medium", "High"], + physicalPositions: ["Sitting", "Standing"], + oralCommunicationLevels: ["Low", "Medium", "High"], + computerUsageLevels: ["Low", "Medium", "High"], + cognitiveDemandLevels: ["Low", "Medium", "High"], + currency: { label: "Any", value: null }, + frequency: { label: "Any", value: null }, + minCompensationLowEnd: null, + minCompensationHighEnd: null, + maxCompensationLowEnd: null, + maxCompensationHighEnd: null, + restrictJobsToTransparentSalaries: false, + calcFrequency: "Yearly", + commitmentTypes: [ + "Full Time", + "Part Time", + "Contract", + "Internship", + "Temporary", + "Seasonal", + "Volunteer", + ], + jobTitleQuery: "", + jobDescriptionQuery: "", + associatesDegreeFieldsOfStudy: [], + excludedAssociatesDegreeFieldsOfStudy: [], + bachelorsDegreeFieldsOfStudy: [], + excludedBachelorsDegreeFieldsOfStudy: [], + mastersDegreeFieldsOfStudy: [], + excludedMastersDegreeFieldsOfStudy: [], + doctorateDegreeFieldsOfStudy: [], + excludedDoctorateDegreeFieldsOfStudy: [], + associatesDegreeRequirements: [], + bachelorsDegreeRequirements: [], + mastersDegreeRequirements: [], + doctorateDegreeRequirements: [], + licensesAndCertifications: [], + excludedLicensesAndCertifications: [], + excludeAllLicensesAndCertifications: false, + seniorityLevel: [ + "No Prior Experience Required", + "Entry Level", + "Mid Level", + "Senior Level", + ], + roleTypes: ["Individual Contributor", "People Manager"], + roleYoeRange: [0, 20], + excludeIfRoleYoeIsNotSpecified: false, + managementYoeRange: [0, 20], + excludeIfManagementYoeIsNotSpecified: false, + securityClearances: [ + "None", + "Confidential", + "Secret", + "Top Secret", + "Top Secret/SCI", + "Public Trust", + "Interim Clearances", + "Other", + ], + languageRequirements: [], + excludedLanguageRequirements: [], + languageRequirementsOperator: "OR", + excludeJobsWithAdditionalLanguageRequirements: false, + airTravelRequirement: ["None", "Minimal", "Moderate", "Extensive"], + landTravelRequirement: ["None", "Minimal", "Moderate", "Extensive"], + morningShiftWork: [], + eveningShiftWork: [], + overnightShiftWork: [], + weekendAvailabilityRequired: "Doesn't Matter", + holidayAvailabilityRequired: "Doesn't Matter", + overtimeRequired: "Doesn't Matter", + onCallRequirements: [ + "None", + "Occasional (once a month or less)", + "Regular (once a week or more)", + ], + benefitsAndPerks: [], + applicationFormEase: [], + companyNames: [], + excludedCompanyNames: [], + companyHqCountries: [], + excludedCompanyHqCountries: [], + usaGovPref: null, + industries: [], + excludedIndustries: [], + companyKeywords: [], + companyKeywordsBooleanOperator: "OR", + excludedCompanyKeywords: [], + hideJobTypes: [], + encouragedToApply: [], + searchQuery: args.searchQuery, + dateFetchedPastNDays: args.dateFetchedPastNDays, + hiddenCompanies: [], + user: null, + searchModeSelectedCompany: null, + departments: [], + restrictedSearchAttributes: [], + sortBy: "default", + technologyKeywordsQuery: "", + requirementsKeywordsQuery: "", + companyPublicOrPrivate: "all", + latestInvestmentYearRange: [null, null], + latestInvestmentSeries: [], + latestInvestmentAmount: null, + latestInvestmentCurrency: [], + investors: [], + excludedInvestors: [], + isNonProfit: "all", + organizationTypes: [], + excludedOrganizationTypes: [], + companySizeRanges: [], + minYearFounded: null, + maxYearFounded: null, + excludedLatestInvestmentSeries: [], + }; +} + async function callHiringCafeApi( page: Page, endpoint: string, @@ -267,6 +544,11 @@ async function run(): Promise { process.env.HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS, DEFAULT_DATE_FETCHED_PAST_N_DAYS, ); + const locationQuery = process.env.HIRING_CAFE_LOCATION_QUERY?.trim() ?? ""; + const locationRadiusMiles = parsePositiveInt( + process.env.HIRING_CAFE_LOCATION_RADIUS_MILES, + DEFAULT_LOCATION_RADIUS_MILES, + ); const outputPath = process.env.HIRING_CAFE_OUTPUT_JSON || join(__dirname, "../storage/datasets/default/jobs.json"); @@ -308,6 +590,20 @@ async function run(): Promise { await initializePage(); } + const countryLocation = resolveHiringCafeCountryLocation(country); + const countryLong = + countryLocation?.address_components[0]?.long_name ?? "United Kingdom"; + const countryShort = + countryLocation?.address_components[0]?.short_name ?? "GB"; + const cityLocationContext = locationQuery + ? await resolveCityLocationContext({ + city: locationQuery, + countryLong, + countryShort, + radiusMiles: locationRadiusMiles, + }) + : null; + for (let i = 0; i < searchTerms.length; i += 1) { const searchTerm = searchTerms[i]; const termIndex = i + 1; @@ -319,12 +615,17 @@ async function run(): Promise { searchTerm, }); - const location = resolveHiringCafeCountryLocation(country); - const searchState = createDefaultSearchState({ - searchQuery: searchTerm, - location, - dateFetchedPastNDays, - }); + const searchState = cityLocationContext + ? createCitySearchState({ + searchQuery: searchTerm, + dateFetchedPastNDays, + context: cityLocationContext, + }) + : createDefaultSearchState({ + searchQuery: searchTerm, + location: countryLocation, + dateFetchedPastNDays, + }); const encodedSearchState = encodeSearchState(searchState); let totalAvailable: number | null = null; diff --git a/orchestrator/package.json b/orchestrator/package.json index 26873a8..5d28302 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -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", diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index a857fad..f9ebd4d 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -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[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; + onSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise; }) => ( - ))} - diff --git a/orchestrator/src/client/pages/orchestrator/FloatingJobActionsBar.tsx b/orchestrator/src/client/pages/orchestrator/FloatingJobActionsBar.tsx index f018b93..c25024b 100644 --- a/orchestrator/src/client/pages/orchestrator/FloatingJobActionsBar.tsx +++ b/orchestrator/src/client/pages/orchestrator/FloatingJobActionsBar.tsx @@ -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 = ({ 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 ( -
-
-
- {selectedCount} selected -
-
- {canMoveSelected && ( - - )} - {canSkipSelected && ( - - )} - {canRescoreSelected && ( - - )} - -
-
-
+ + {selectedCount > 0 ? ( + +
+
+ {selectedCount} selected +
+
+ {canMoveSelected && ( + + )} + {canSkipSelected && ( + + )} + {canRescoreSelected && ( + + )} + +
+
+
+ ) : null} +
); }; diff --git a/orchestrator/src/client/pages/orchestrator/TokenizedInput.test.tsx b/orchestrator/src/client/pages/orchestrator/TokenizedInput.test.tsx new file mode 100644 index 0000000..4607f0d --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/TokenizedInput.test.tsx @@ -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 = () => ( + + ); + + 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(); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/TokenizedInput.tsx b/orchestrator/src/client/pages/orchestrator/TokenizedInput.tsx new file mode 100644 index 0000000..02c395d --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/TokenizedInput.tsx @@ -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 = ({ + id, + values, + draft, + parseInput, + onDraftChange, + onValuesChange, + placeholder, + helperText, + removeLabelPrefix, + collapsedTextLimit = 3, +}) => { + const [isFocused, setIsFocused] = useState(false); + const tokensRef = useRef(null); + const summaryRef = useRef(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 ( +
+ 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} + /> +

{helperText}

+ {values.length > 0 ? ( + + + + {values.map((value) => ( + + + + ))} + + + + Currently selected: {collapsedSummary} + + + ) : null} +
+ ); +}; diff --git a/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts b/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts index 7ecf508..7109e17 100644 --- a/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts +++ b/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts @@ -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"], }); diff --git a/orchestrator/src/client/pages/orchestrator/automatic-run.ts b/orchestrator/src/client/pages/orchestrator/automatic-run.ts index f94b6a2..22d6edc 100644 --- a/orchestrator/src/client/pages/orchestrator/automatic-run.ts +++ b/orchestrator/src/client/pages/orchestrator/automatic-run.ts @@ -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(); + 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"); } diff --git a/orchestrator/src/server/config/demo-defaults.data.ts b/orchestrator/src/server/config/demo-defaults.data.ts index 2bb9275..19388a2 100644 --- a/orchestrator/src/server/config/demo-defaults.data.ts +++ b/orchestrator/src/server/config/demo-defaults.data.ts @@ -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({ diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts index 149ec24..638a9e5 100644 --- a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts @@ -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"); diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.ts index acf8572..df136d1 100644 --- a/orchestrator/src/server/pipeline/steps/discover-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.ts @@ -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) => { diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index be88c69..7332e06 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -23,6 +23,7 @@ export type SettingKey = | "adzunaMaxJobsPerTerm" | "gradcrackerMaxJobsPerTerm" | "searchTerms" + | "searchCities" | "jobspyLocation" | "jobspyResultsWanted" | "jobspyCountryIndeed" diff --git a/orchestrator/src/server/services/adzuna.location.test.ts b/orchestrator/src/server/services/adzuna.location.test.ts new file mode 100644 index 0000000..7250c2f --- /dev/null +++ b/orchestrator/src/server/services/adzuna.location.test.ts @@ -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); + }); +}); diff --git a/orchestrator/src/server/services/adzuna.ts b/orchestrator/src/server/services/adzuna.ts index aaf5311..d47abba 100644 --- a/orchestrator/src/server/services/adzuna.ts +++ b/orchestrator/src/server/services/adzuna.ts @@ -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((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(); + + for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) { + const location = runLocations[runIndex]; + const strictLocationFilter = + location !== null && + shouldApplyStrictLocationFilter(location, countryKey); + + await new Promise((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"; diff --git a/orchestrator/src/server/services/hiring-cafe.location.test.ts b/orchestrator/src/server/services/hiring-cafe.location.test.ts new file mode 100644 index 0000000..c543eb5 --- /dev/null +++ b/orchestrator/src/server/services/hiring-cafe.location.test.ts @@ -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); + }); +}); diff --git a/orchestrator/src/server/services/hiring-cafe.ts b/orchestrator/src/server/services/hiring-cafe.ts index 2060963..da2cf82 100644 --- a/orchestrator/src/server/services/hiring-cafe.ts +++ b/orchestrator/src/server/services/hiring-cafe.ts @@ -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(); - await new Promise((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((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"; diff --git a/orchestrator/src/server/services/jobspy.test.ts b/orchestrator/src/server/services/jobspy.test.ts index 4dca5d6..dd5cb38 100644 --- a/orchestrator/src/server/services/jobspy.test.ts +++ b/orchestrator/src/server/services/jobspy.test.ts @@ -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); + }); +}); diff --git a/orchestrator/src/server/services/jobspy.ts b/orchestrator/src/server/services/jobspy.ts index 8b7a415..753bc8c 100644 --- a/orchestrator/src/server/services/jobspy.ts +++ b/orchestrator/src/server/services/jobspy.ts @@ -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; 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 { @@ -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(); - 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((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((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>; + 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>; - 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); diff --git a/orchestrator/src/server/services/settings-conversion.test.ts b/orchestrator/src/server/services/settings-conversion.test.ts index 34d57e7..12ffb3f 100644 --- a/orchestrator/src/server/services/settings-conversion.test.ts +++ b/orchestrator/src/server/services/settings-conversion.test.ts @@ -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"); diff --git a/orchestrator/src/server/services/settings-conversion.ts b/orchestrator/src/server/services/settings-conversion.ts index 56ae6f0..2c25f4e 100644 --- a/orchestrator/src/server/services/settings-conversion.ts +++ b/orchestrator/src/server/services/settings-conversion.ts @@ -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, diff --git a/orchestrator/src/server/services/settings-update/registry.ts b/orchestrator/src/server/services/settings-update/registry.ts index c864f43..b4c844f 100644 --- a/orchestrator/src/server/services/settings-update/registry.ts +++ b/orchestrator/src/server/services/settings-update/registry.ts @@ -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({ diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index 6250826..fd2240e 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -123,13 +123,13 @@ export async function getEffectiveSettings(): Promise { 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 { searchTerms, defaultSearchTerms, overrideSearchTerms, - jobspyLocation, - defaultJobspyLocation, - overrideJobspyLocation, + searchCities, + defaultSearchCities, + overrideSearchCities, jobspyResultsWanted, defaultJobspyResultsWanted, overrideJobspyResultsWanted, diff --git a/package-lock.json b/package-lock.json index 4795158..a9e8ab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -348,6 +349,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", @@ -548,6 +550,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2801,6 +2804,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2823,6 +2827,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2932,6 +2937,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3353,6 +3359,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -4342,6 +4349,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -5915,6 +5923,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -7816,6 +7825,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -8268,6 +8278,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -8301,6 +8312,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -8312,6 +8324,7 @@ "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" } @@ -8648,6 +8661,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8737,6 +8751,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8782,6 +8797,7 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.14.1", "@algolia/client-abtesting": "5.48.1", @@ -9434,6 +9450,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10588,6 +10605,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12153,6 +12171,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12515,6 +12534,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", + "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.34.3", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -17745,6 +17791,21 @@ "npm": ">=6" } }, + "node_modules/motion-dom": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", + "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -17949,6 +18010,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -18757,6 +18819,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18810,6 +18873,7 @@ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.58.1" }, @@ -18828,6 +18892,7 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -18868,6 +18933,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19780,6 +19846,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -20663,6 +20730,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -20675,6 +20743,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -20731,6 +20800,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -20806,6 +20876,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -22737,6 +22808,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -23019,13 +23091,15 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -23110,6 +23184,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23615,6 +23690,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -23884,6 +23960,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -24682,7 +24759,6 @@ "name": "job-ops-orchestrator", "version": "1.0.0", "dependencies": { - "@biomejs/cli-linux-x64": "2.3.12", "@hookform/resolvers": "^5.2.2", "@paralleldrive/cuid2": "^3.0.6", "@radix-ui/react-accordion": "^1.2.12", @@ -24713,6 +24789,7 @@ "dotenv": "^17.2.3", "drizzle-orm": "^0.38.2", "express": "^4.18.2", + "framer-motion": "^12.34.3", "get-tsconfig": "^4.10.0", "html-to-text": "^9.0.5", "jsdom": "^25.0.1", @@ -25984,8 +26061,7 @@ "orchestrator/node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "orchestrator/node_modules/@types/babel__core": { "version": "7.20.5", @@ -26028,6 +26104,7 @@ "version": "7.6.13", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -26269,6 +26346,7 @@ "version": "11.10.0", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -26462,8 +26540,7 @@ "orchestrator/node_modules/dom-accessibility-api": { "version": "0.5.16", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "orchestrator/node_modules/dom-helpers": { "version": "5.2.1", @@ -27168,6 +27245,7 @@ "orchestrator/node_modules/jsdom": { "version": "25.0.1", "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -27279,7 +27357,6 @@ "version": "1.5.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -27325,7 +27402,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -27339,7 +27415,6 @@ "version": "5.2.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -27350,6 +27425,7 @@ "orchestrator/node_modules/react-hook-form": { "version": "7.71.1", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -27364,8 +27440,7 @@ "orchestrator/node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "orchestrator/node_modules/react-markdown": { "version": "10.1.0", @@ -27620,7 +27695,8 @@ }, "orchestrator/node_modules/tailwindcss": { "version": "4.1.18", - "license": "MIT" + "license": "MIT", + "peer": true }, "orchestrator/node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -27743,6 +27819,7 @@ "orchestrator/node_modules/vite": { "version": "6.4.1", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/shared/src/search-cities.test.ts b/shared/src/search-cities.test.ts new file mode 100644 index 0000000..941f4f4 --- /dev/null +++ b/shared/src/search-cities.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + matchesRequestedCity, + parseSearchCitiesSetting, + serializeSearchCitiesSetting, + shouldApplyStrictCityFilter, +} from "./search-cities"; + +describe("search-cities", () => { + it("parses and deduplicates search cities", () => { + expect(parseSearchCitiesSetting("Leeds|london|Leeds")).toEqual([ + "Leeds", + "london", + ]); + expect(parseSearchCitiesSetting("Leeds\nLondon\nleeds")).toEqual([ + "Leeds", + "London", + ]); + expect(parseSearchCitiesSetting("")).toEqual([]); + }); + + it("serializes search cities", () => { + expect(serializeSearchCitiesSetting(["Leeds", "London"])).toBe( + "Leeds|London", + ); + expect(serializeSearchCitiesSetting([])).toBeNull(); + }); + + it("applies strict filter only when city differs from country", () => { + expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true); + expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false); + expect(shouldApplyStrictCityFilter("usa", "united states")).toBe(false); + }); + + it("matches by whole location tokens and avoids substring false positives", () => { + expect(matchesRequestedCity("Leeds, England, UK", "Leeds")).toBe(true); + expect(matchesRequestedCity("Manchester, England, UK", "Chester")).toBe( + false, + ); + expect( + matchesRequestedCity("New York, NY, United States", "new york"), + ).toBe(true); + }); +}); diff --git a/shared/src/search-cities.ts b/shared/src/search-cities.ts new file mode 100644 index 0000000..4fcbfd4 --- /dev/null +++ b/shared/src/search-cities.ts @@ -0,0 +1,83 @@ +import { normalizeCountryKey } from "./location-support.js"; + +const LOCATION_ALIASES: Record = { + uk: "united kingdom", + us: "united states", + usa: "united states", +}; + +export function normalizeLocationToken( + value: string | null | undefined, +): string { + const normalized = value?.trim().toLowerCase().replace(/\s+/g, " ") ?? ""; + if (!normalized) return ""; + return LOCATION_ALIASES[normalized] ?? normalized; +} + +export function parseSearchCitiesSetting( + value: string | null | undefined, +): string[] { + const trimmed = value?.trim(); + if (!trimmed) return []; + const split = trimmed.includes("|") + ? trimmed.split("|") + : trimmed.includes("\n") + ? trimmed.split("\n") + : [trimmed]; + const seen = new Set(); + const out: string[] = []; + for (const raw of split) { + const normalized = raw.trim(); + if (!normalized) continue; + const key = normalized.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(normalized); + } + return out; +} + +export function serializeSearchCitiesSetting(cities: string[]): string | null { + if (cities.length === 0) return null; + return cities.join("|"); +} + +export function shouldApplyStrictCityFilter( + city: string, + country: string, +): boolean { + const normalizedCity = normalizeLocationToken(city); + const normalizedCountry = normalizeCountryKey(country); + if (!normalizedCity || !normalizedCountry) return false; + return normalizedCity !== normalizedCountry; +} + +export function matchesRequestedCity( + jobLocation: string | undefined, + requestedCity: string, +): boolean { + const normalizedJobLocation = normalizeLocationToken(jobLocation) + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const normalizedRequestedLocation = normalizeLocationToken(requestedCity) + .replace(/[^a-z0-9]+/g, " ") + .trim(); + if (!normalizedJobLocation || !normalizedRequestedLocation) return false; + + const jobTokens = normalizedJobLocation.split(" "); + const requestedTokens = normalizedRequestedLocation.split(" "); + if (requestedTokens.length > jobTokens.length) return false; + + for (let i = 0; i <= jobTokens.length - requestedTokens.length; i += 1) { + let matches = true; + for (let j = 0; j < requestedTokens.length; j += 1) { + if (jobTokens[i + j] !== requestedTokens[j]) { + matches = false; + break; + } + } + if (matches) return true; + } + + return false; +} diff --git a/shared/src/settings-schema.ts b/shared/src/settings-schema.ts index 6c86072..fbdfc6b 100644 --- a/shared/src/settings-schema.ts +++ b/shared/src/settings-schema.ts @@ -51,6 +51,8 @@ export const updateSettingsSchema = z .max(100) .nullable() .optional(), + searchCities: z.string().trim().max(100).nullable().optional(), + // Deprecated legacy key; accepted for backward compatibility. jobspyLocation: z.string().trim().max(100).nullable().optional(), jobspyResultsWanted: z .number() diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index d2e175c..fe16f18 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -171,9 +171,9 @@ export const createAppSettings = ( searchTerms: ["Software Engineer"], defaultSearchTerms: ["Software Engineer"], overrideSearchTerms: null, - jobspyLocation: "United Kingdom", - defaultJobspyLocation: "United Kingdom", - overrideJobspyLocation: null, + searchCities: "United Kingdom", + defaultSearchCities: "United Kingdom", + overrideSearchCities: null, jobspyResultsWanted: 20, defaultJobspyResultsWanted: 20, overrideJobspyResultsWanted: null, diff --git a/shared/src/types.ts b/shared/src/types.ts index 900a601..d9db13b 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -1052,9 +1052,9 @@ export interface AppSettings { searchTerms: string[]; defaultSearchTerms: string[]; overrideSearchTerms: string[] | null; - jobspyLocation: string; - defaultJobspyLocation: string; - overrideJobspyLocation: string | null; + searchCities: string; + defaultSearchCities: string; + overrideSearchCities: string | null; jobspyResultsWanted: number; defaultJobspyResultsWanted: number; overrideJobspyResultsWanted: number | null;