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:
parent
3be0d25c87
commit
65952259ce
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -24,6 +24,7 @@ export type JobspyValues = {
|
||||
hoursOld: EffectiveDefault<number>;
|
||||
countryIndeed: EffectiveDefault<string>;
|
||||
linkedinFetchDescription: EffectiveDefault<boolean>;
|
||||
isRemote: EffectiveDefault<boolean>;
|
||||
};
|
||||
|
||||
export type EnvSettingsValues = {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"}`);
|
||||
|
||||
@ -28,6 +28,7 @@ export type SettingKey =
|
||||
| "jobspyCountryIndeed"
|
||||
| "jobspySites"
|
||||
| "jobspyLinkedinFetchDescription"
|
||||
| "jobspyIsRemote"
|
||||
| "showSponsorInfo"
|
||||
| "openrouterApiKey"
|
||||
| "rxresumeEmail"
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user