feat: add remote jobs filter for JobSpy (#70)

* feat(types): add jobspyIsRemote to TypeScript type definitions

- Add jobspyIsRemote boolean fields to AppSettings interface
- Follow three-field pattern: value, default, override
- Update test fixture with default values (false)

* feat(validation): add jobspyIsRemote validation schema

Add Zod validation schema for jobspyIsRemote boolean setting to ensure type safety in the settings API endpoint.

* feat(db): add jobspyIsRemote to database repository setting keys

* feat(api): add jobspyIsRemote storage to settings API route

* feat(service): add jobspyIsRemote to settings service with environment variable support

* feat(jobspy): add isRemote parameter to JobSpy service interface

* feat(pipeline): pass isRemote setting to JobSpy service

* feat(python): add is_remote parameter to JobSpy scraper script

* feat(ui): add Remote Jobs checkbox to JobSpy settings

* feat(ui): add Remote badge to job display

- Display Remote badge when job.isRemote === true
- Position badge next to Source badge in JobHeader
- Use Badge component with outline variant
- Badge does not display when isRemote is false or null

* docs(env): add JOBSPY_IS_REMOTE environment variable documentation

- Added JobSpy section to .env.example with JOBSPY_IS_REMOTE variable
- Documents remote-only job filtering option with default value of 0 (disabled)
- Follows existing .env.example format with clear section headers and descriptions

* test(remote-jobs): verify end-to-end functionality with comprehensive feedback loops
This commit is contained in:
Devin Collins 2026-01-31 08:48:17 -08:00 committed by GitHub
parent 3be0d25c87
commit 65952259ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 101 additions and 2 deletions

View File

@ -27,3 +27,9 @@ BASIC_AUTH_PASSWORD=
UKVISAJOBS_EMAIL=
UKVISAJOBS_PASSWORD=
UKVISAJOBS_HEADLESS=true
# =============================================================================
# JobSpy - Job search configuration
# =============================================================================
# Filter for remote-only jobs (default: 0 = disabled)
# JOBSPY_IS_REMOTE=0

View File

@ -39,9 +39,12 @@ def main() -> int:
hours_old = _env_int("JOBSPY_HOURS_OLD", 72)
country_indeed = _env_str("JOBSPY_COUNTRY_INDEED", "UK")
linkedin_fetch_description = _env_bool("JOBSPY_LINKEDIN_FETCH_DESCRIPTION", True)
is_remote = _env_bool("JOBSPY_IS_REMOTE", False)
output_csv = Path(_env_str("JOBSPY_OUTPUT_CSV", "jobs.csv"))
output_json = Path(_env_str("JOBSPY_OUTPUT_JSON", str(output_csv.with_suffix(".json"))))
output_json = Path(
_env_str("JOBSPY_OUTPUT_JSON", str(output_csv.with_suffix(".json")))
)
output_csv.parent.mkdir(parents=True, exist_ok=True)
output_json.parent.mkdir(parents=True, exist_ok=True)
@ -55,6 +58,7 @@ def main() -> int:
hours_old=hours_old,
country_indeed=country_indeed,
linkedin_fetch_description=linkedin_fetch_description,
is_remote=is_remote,
)
print(f"Found {len(jobs)} jobs")
@ -75,4 +79,3 @@ def main() -> int:
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -206,6 +206,14 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
>
{sourceLabel[job.source]}
</Badge>
{job.isRemote === true && (
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
>
Remote
</Badge>
)}
{!isJobPage && (
<Button
asChild

View File

@ -97,6 +97,9 @@ const baseSettings: AppSettings = {
jobspyLinkedinFetchDescription: true,
defaultJobspyLinkedinFetchDescription: true,
overrideJobspyLinkedinFetchDescription: null,
jobspyIsRemote: false,
defaultJobspyIsRemote: false,
overrideJobspyIsRemote: null,
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,

View File

@ -56,6 +56,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
jobspyIsRemote: null,
showSponsorInfo: null,
openrouterApiKey: "",
rxresumeEmail: "",
@ -95,6 +96,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
jobspyIsRemote: null,
showSponsorInfo: null,
openrouterApiKey: null,
rxresumeEmail: null,
@ -128,6 +130,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
jobspyCountryIndeed: data.overrideJobspyCountryIndeed,
jobspySites: data.overrideJobspySites,
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
jobspyIsRemote: data.overrideJobspyIsRemote,
showSponsorInfo: data.overrideShowSponsorInfo,
openrouterApiKey: "",
rxresumeEmail: data.rxresumeEmail ?? "",
@ -276,6 +279,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
effective: settings?.jobspyLinkedinFetchDescription ?? true,
default: settings?.defaultJobspyLinkedinFetchDescription ?? true,
},
isRemote: {
effective: settings?.jobspyIsRemote ?? false,
default: settings?.defaultJobspyIsRemote ?? false,
},
},
display: {
effective: settings?.showSponsorInfo ?? true,
@ -577,6 +584,10 @@ export const SettingsPage: React.FC = () => {
data.jobspyLinkedinFetchDescription,
jobspy.linkedinFetchDescription.default,
),
jobspyIsRemote: nullIfSame(
data.jobspyIsRemote,
jobspy.isRemote.default,
),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
...envPayload,
};

View File

@ -14,6 +14,7 @@ const JobspyHarness = () => {
jobspyHoursOld: 72,
jobspyCountryIndeed: "UK",
jobspyLinkedinFetchDescription: true,
jobspyIsRemote: false,
},
});
@ -31,6 +32,7 @@ const JobspyHarness = () => {
hoursOld: { default: 72, effective: 72 },
countryIndeed: { default: "UK", effective: "UK" },
linkedinFetchDescription: { default: true, effective: true },
isRemote: { default: false, effective: false },
}}
isLoading={false}
isSaving={false}

View File

@ -149,6 +149,7 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
hoursOld,
countryIndeed,
linkedinFetchDescription,
isRemote,
} = values;
const {
control,
@ -424,6 +425,36 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Controller
name="jobspyIsRemote"
control={control}
render={({ field }) => (
<Checkbox
id="jobspy-remote"
checked={field.value ?? isRemote.default}
onCheckedChange={(checked) => field.onChange(!!checked)}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="jobspy-remote"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Remote Jobs?
</label>
<p className="text-xs text-muted-foreground">
Only search for remote job listings
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {isRemote.effective ? "Yes" : "No"}</span>
<span>Default: {isRemote.default ? "Yes" : "No"}</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>

View File

@ -24,6 +24,7 @@ export type JobspyValues = {
hoursOld: EffectiveDefault<number>;
countryIndeed: EffectiveDefault<string>;
linkedinFetchDescription: EffectiveDefault<boolean>;
isRemote: EffectiveDefault<boolean>;
};
export type EnvSettingsValues = {

View File

@ -218,6 +218,16 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
);
}
if ("jobspyIsRemote" in input) {
const val = input.jobspyIsRemote ?? null;
promises.push(
settingsRepo.setSetting(
"jobspyIsRemote",
val !== null ? (val ? "1" : "0") : null,
),
);
}
if ("showSponsorInfo" in input) {
const val = input.showSponsorInfo ?? null;
promises.push(

View File

@ -186,6 +186,7 @@ export async function runPipeline(
const jobspyCountryIndeedSetting = settings.jobspyCountryIndeed;
const jobspyLinkedinFetchDescriptionSetting =
settings.jobspyLinkedinFetchDescription;
const jobspyIsRemoteSetting = settings.jobspyIsRemote;
const jobSpyResult = await runJobSpy({
sites: jobSpySites,
@ -203,6 +204,10 @@ export async function runPipeline(
jobspyLinkedinFetchDescriptionSetting !== undefined
? jobspyLinkedinFetchDescriptionSetting === "1"
: undefined,
isRemote:
jobspyIsRemoteSetting !== null && jobspyIsRemoteSetting !== undefined
? jobspyIsRemoteSetting === "1"
: undefined,
});
if (!jobSpyResult.success) {
sourceErrors.push(`jobspy: ${jobSpyResult.error ?? "unknown error"}`);

View File

@ -28,6 +28,7 @@ export type SettingKey =
| "jobspyCountryIndeed"
| "jobspySites"
| "jobspyLinkedinFetchDescription"
| "jobspyIsRemote"
| "showSponsorInfo"
| "openrouterApiKey"
| "rxresumeEmail"

View File

@ -113,6 +113,7 @@ export interface RunJobSpyOptions {
hoursOld?: number;
countryIndeed?: string;
linkedinFetchDescription?: boolean;
isRemote?: boolean;
}
export interface JobSpyResult {
@ -174,6 +175,9 @@ export async function runJobSpy(
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,
},

View File

@ -169,6 +169,13 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
overrideJobspyLinkedinFetchDescription ??
defaultJobspyLinkedinFetchDescription;
const defaultJobspyIsRemote = (process.env.JOBSPY_IS_REMOTE || "0") === "1";
const overrideJobspyIsRemoteRaw = overrides.jobspyIsRemote;
const overrideJobspyIsRemote = overrideJobspyIsRemoteRaw
? overrideJobspyIsRemoteRaw === "true" || overrideJobspyIsRemoteRaw === "1"
: null;
const jobspyIsRemote = overrideJobspyIsRemote ?? defaultJobspyIsRemote;
const defaultShowSponsorInfo = true;
const overrideShowSponsorInfoRaw = overrides.showSponsorInfo;
const overrideShowSponsorInfo = overrideShowSponsorInfoRaw
@ -229,6 +236,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
jobspyLinkedinFetchDescription,
defaultJobspyLinkedinFetchDescription,
overrideJobspyLinkedinFetchDescription,
jobspyIsRemote,
defaultJobspyIsRemote,
overrideJobspyIsRemote,
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,

View File

@ -60,6 +60,7 @@ export const updateSettingsSchema = z
.nullable()
.optional(),
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
jobspyIsRemote: z.boolean().nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
/** @deprecated Use llmApiKey instead. */
openrouterApiKey: z.string().trim().max(2000).nullable().optional(),

View File

@ -528,6 +528,9 @@ export interface AppSettings {
jobspyLinkedinFetchDescription: boolean;
defaultJobspyLinkedinFetchDescription: boolean;
overrideJobspyLinkedinFetchDescription: boolean | null;
jobspyIsRemote: boolean;
defaultJobspyIsRemote: boolean;
overrideJobspyIsRemote: boolean | null;
showSponsorInfo: boolean;
defaultShowSponsorInfo: boolean;
overrideShowSponsorInfo: boolean | null;