diff --git a/extractors/adzuna/src/main.ts b/extractors/adzuna/src/main.ts index b03d686..0845b5a 100644 --- a/extractors/adzuna/src/main.ts +++ b/extractors/adzuna/src/main.ts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { parseSearchTerms } from "job-ops-shared/utils/search-terms"; import { toNumberOrNull, toStringOrNull, @@ -7,6 +8,7 @@ import { const API_BASE = "https://api.adzuna.com/v1/api"; const JOBOPS_PROGRESS_PREFIX = "JOBOPS_PROGRESS "; +const DEFAULT_SEARCH_TERM = "web developer"; type AdzunaCompany = { display_name?: unknown }; type AdzunaLocation = { display_name?: unknown }; @@ -44,36 +46,6 @@ function parsePositiveInt(input: string | undefined, fallback: number): number { return parsed; } -function parseSearchTerms(raw: string | undefined): string[] { - if (!raw || raw.trim().length === 0) return ["web developer"]; - - const trimmed = raw.trim(); - if (trimmed.startsWith("[")) { - try { - const parsed = JSON.parse(trimmed) as unknown; - if (Array.isArray(parsed)) { - const terms = parsed - .map((value) => toStringOrNull(value)) - .filter((value): value is string => value !== null); - if (terms.length > 0) return terms; - } - } catch { - // Fall through to delimiter parsing. - } - } - - const delimiter = trimmed.includes("|") - ? "|" - : trimmed.includes("\n") - ? "\n" - : ","; - const terms = trimmed - .split(delimiter) - .map((value) => value.trim()) - .filter(Boolean); - return terms.length > 0 ? terms : ["web developer"]; -} - function requireEnv(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`Missing required environment variable: ${name}`); @@ -167,7 +139,10 @@ async function run(): Promise { 50, ); const resultsPerPage = Math.min(50, configuredResultsPerPage); - const searchTerms = parseSearchTerms(process.env.ADZUNA_SEARCH_TERMS); + const searchTerms = parseSearchTerms( + process.env.ADZUNA_SEARCH_TERMS, + DEFAULT_SEARCH_TERM, + ); const outputJson = process.env.ADZUNA_OUTPUT_JSON || join(process.cwd(), "storage/datasets/default/jobs.json"); diff --git a/extractors/hiringcafe/src/main.ts b/extractors/hiringcafe/src/main.ts index c4b9e0b..27ab118 100644 --- a/extractors/hiringcafe/src/main.ts +++ b/extractors/hiringcafe/src/main.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { launchOptions } from "camoufox-js"; +import { parseSearchTerms } from "job-ops-shared/utils/search-terms"; import { toNumberOrNull, toStringOrNull, @@ -56,38 +57,6 @@ function parsePositiveInt(input: string | undefined, fallback: number): number { return parsed; } -function parseSearchTerms(raw: string | undefined): string[] { - if (!raw || raw.trim().length === 0) return [DEFAULT_SEARCH_TERM]; - - const trimmed = raw.trim(); - if (trimmed.startsWith("[")) { - try { - const parsed = JSON.parse(trimmed) as unknown; - if (Array.isArray(parsed)) { - const terms = parsed - .map((value) => toStringOrNull(value)) - .filter((value): value is string => Boolean(value)); - if (terms.length > 0) return terms; - } - } catch { - // Fall through to delimiter parsing. - } - } - - const delimiter = trimmed.includes("|") - ? "|" - : trimmed.includes("\n") - ? "\n" - : ","; - - const terms = trimmed - .split(delimiter) - .map((value) => value.trim()) - .filter(Boolean); - - return terms.length > 0 ? terms : [DEFAULT_SEARCH_TERM]; -} - function encodeSearchState(searchState: unknown): string { const json = JSON.stringify(searchState); const urlEncodedJson = encodeURIComponent(json); @@ -126,12 +95,7 @@ function formatCompensation( const frequency = toStringOrNull(processedJobData.listed_compensation_frequency) ?? "Yearly"; - const amount = - min !== null && max !== null - ? `${Math.round(min)}-${Math.round(max)}` - : min !== null - ? `${Math.round(min)}+` - : `${Math.round(max ?? 0)}`; + const amount = formatCompensationAmount(min, max); const parts = [currency, amount, frequency ? `/ ${frequency}` : ""] .filter(Boolean) @@ -141,6 +105,17 @@ function formatCompensation( return parts || undefined; } +function formatCompensationAmount( + min: number | null, + max: number | null, +): string { + if (min !== null && max !== null) { + return `${Math.round(min)}-${Math.round(max)}`; + } + if (min !== null) return `${Math.round(min)}+`; + return `${Math.round(max ?? 0)}`; +} + function mapHiringCafeJob(raw: RawHiringCafeJob): ExtractedJob | null { const jobInformation = asRecord(raw.job_information); const processed = asRecord(raw.v5_processed_job_data); @@ -277,7 +252,10 @@ async function callHiringCafeApi( } async function run(): Promise { - const searchTerms = parseSearchTerms(process.env.HIRING_CAFE_SEARCH_TERMS); + const searchTerms = parseSearchTerms( + process.env.HIRING_CAFE_SEARCH_TERMS, + DEFAULT_SEARCH_TERM, + ); const country = normalizeCountryKey( process.env.HIRING_CAFE_COUNTRY ?? "united kingdom", ); diff --git a/orchestrator/src/client/components/ManualImportFlow.tsx b/orchestrator/src/client/components/ManualImportFlow.tsx index 5d637b4..21d573e 100644 --- a/orchestrator/src/client/components/ManualImportFlow.tsx +++ b/orchestrator/src/client/components/ManualImportFlow.tsx @@ -51,6 +51,18 @@ const emptyDraft: ManualJobDraftState = { starting: "", }; +const STEP_INDEX_BY_ID: Record = { + paste: 0, + loading: 1, + review: 2, +}; + +const STEP_LABEL_BY_ID: Record = { + paste: "Paste JD", + loading: "Infer details", + review: "Review & import", +}; + const normalizeDraft = ( draft?: ManualJobDraft | null, jd?: string, @@ -128,8 +140,8 @@ export const ManualImportFlow: React.FC = ({ setIsImporting(false); }, [active]); - const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2; - const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex]; + const stepIndex = STEP_INDEX_BY_ID[step]; + const stepLabel = STEP_LABEL_BY_ID[step]; const canAnalyze = rawDescription.trim().length > 0 && step !== "loading"; const canFetch = diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 7edd77e..8967199 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -54,6 +54,22 @@ type OnboardingFormData = { rxresumeBaseResumeId: string | null; }; +function getStepPrimaryLabel(input: { + currentStep: string | null; + llmValidated: boolean; + rxresumeValidated: boolean; + baseResumeValidated: boolean; +}): string { + const toLabel = (isValidated: boolean): string => + isValidated ? "Revalidate" : "Validate"; + + if (input.currentStep === "llm") return toLabel(input.llmValidated); + if (input.currentStep === "rxresume") return toLabel(input.rxresumeValidated); + if (input.currentStep === "baseresume") + return toLabel(input.baseResumeValidated); + return "Validate"; +} + export const OnboardingGate: React.FC = () => { const { settings, @@ -107,13 +123,15 @@ export const OnboardingGate: React.FC = () => { values.llmProvider || settings?.llmProvider || "openrouter", ); const providerConfig = getLlmProviderConfig(selectedProvider); - const { requiresApiKey } = providerConfig; + const { requiresApiKey, showBaseUrl } = providerConfig; setIsValidatingLlm(true); try { const result = await api.validateLlm({ provider: selectedProvider, - baseUrl: values.llmBaseUrl.trim() || undefined, + baseUrl: showBaseUrl + ? values.llmBaseUrl.trim() || undefined + : undefined, apiKey: requiresApiKey ? values.llmApiKey.trim() || undefined : undefined, @@ -198,7 +216,6 @@ export const OnboardingGate: React.FC = () => { hasCheckedValidations && !(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid); - const llmKeyCurrent = llmKeyHint ? formatSecretHint(llmKeyHint) : undefined; const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim() ? settings.rxresumeEmail : undefined; @@ -471,20 +488,12 @@ export const OnboardingGate: React.FC = () => { isValidatingRxresume || isValidatingBaseResume; const canGoBack = stepIndex > 0; - const primaryLabel = - currentStep === "llm" - ? llmValidated - ? "Revalidate" - : "Validate" - : currentStep === "rxresume" - ? rxresumeValidation.valid - ? "Revalidate" - : "Validate" - : currentStep === "baseresume" - ? baseResumeValidation.valid - ? "Revalidate" - : "Validate" - : "Validate"; + const primaryLabel = getStepPrimaryLabel({ + currentStep, + llmValidated, + rxresumeValidated: rxresumeValidation.valid, + baseResumeValidated: baseResumeValidation.valid, + }); const handlePrimaryAction = async () => { if (!currentStep) return; @@ -648,8 +657,11 @@ export const OnboardingGate: React.FC = () => { }} type="password" placeholder="Enter key" - current={llmKeyCurrent} - helper={providerConfig.keyHelper} + helper={ + llmKeyHint + ? `${providerConfig.keyHelper}. Leave blank to use the saved key.` + : providerConfig.keyHelper + } disabled={isSavingEnv} /> )} diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx index f0779b2..720e66a 100644 --- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx +++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx @@ -72,6 +72,22 @@ 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."; +const UK_ONLY_SOURCES = new Set(["gradcracker", "ukvisajobs"]); + +function getSourceDisabledReason( + source: JobSource, + countryAllowed: boolean, +): string { + if (source === "glassdoor") { + return countryAllowed + ? GLASSDOOR_LOCATION_REASON + : GLASSDOOR_COUNTRY_REASON; + } + if (UK_ONLY_SOURCES.has(source)) { + return `${sourceLabel[source]} is available only when country is United Kingdom.`; + } + return `${sourceLabel[source]} is not available for the selected country.`; +} function toNumber(input: string, min: number, max: number, fallback: number) { const parsed = Number.parseInt(input, 10); @@ -529,14 +545,10 @@ export const AutomaticRunTab: React.FC = ({ ); const allowed = isSourceAvailableForRun(source); const selected = compatiblePipelineSources.includes(source); - const disabledReason = - source === "glassdoor" - ? countryAllowed - ? GLASSDOOR_LOCATION_REASON - : GLASSDOOR_COUNTRY_REASON - : source === "gradcracker" || source === "ukvisajobs" - ? `${sourceLabel[source]} is available only when country is United Kingdom.` - : `${sourceLabel[source]} is not available for the selected country.`; + const disabledReason = getSourceDisabledReason( + source, + countryAllowed, + ); const button = (