job pagination

This commit is contained in:
DaKheera47 2026-01-10 23:52:36 +00:00
parent f41609bd45
commit 0ec38773b5
7 changed files with 917 additions and 2 deletions

View File

@ -8,14 +8,16 @@ import { Route, Routes } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner";
import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage";
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
export const App: React.FC = () => (
<>
<Routes>
<Route path="/" element={<OrchestratorPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
</Routes>
<Toaster position="bottom-right" richColors closeButton />
</>
);
);

View File

@ -11,6 +11,9 @@ import type {
PipelineRun,
AppSettings,
ResumeProjectsSettings,
UkVisaJobsSearchResponse,
UkVisaJobsImportResponse,
CreateJobInput,
} from '../../shared/types';
const API_BASE = '/api';
@ -108,6 +111,26 @@ export async function runPipeline(config?: {
});
}
// UK Visa Jobs API
export async function searchUkVisaJobs(input: {
searchTerm?: string;
page?: number;
}): Promise<UkVisaJobsSearchResponse> {
return fetchApi<UkVisaJobsSearchResponse>('/ukvisajobs/search', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function importUkVisaJobs(input: {
jobs: CreateJobInput[];
}): Promise<UkVisaJobsImportResponse> {
return fetchApi<UkVisaJobsImportResponse>('/ukvisajobs/import', {
method: 'POST',
body: JSON.stringify(input),
});
}
// Settings & Profile API
export async function getSettings(): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings');

View File

@ -639,6 +639,11 @@ export const OrchestratorPage: React.FC = () => {
<Button variant="ghost" size="icon" onClick={loadJobs} disabled={isLoading} aria-label="Refresh jobs">
<RefreshCcw className="h-4 w-4" />
</Button>
<Button asChild variant="ghost" size="icon" aria-label="UK Visa Jobs search">
<Link to="/ukvisajobs">
<Search className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="ghost" size="icon" aria-label="Settings">
<Link to="/settings">
<Settings className="h-4 w-4" />

View File

@ -0,0 +1,552 @@
/**
* UK Visa Jobs search page.
*/
import React, { useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
Briefcase,
Calendar,
ChevronLeft,
ChevronRight,
Clock,
Database,
DollarSign,
ExternalLink,
GraduationCap,
Loader2,
MapPin,
Search,
Settings,
} from "lucide-react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import * as api from "../api";
import type { CreateJobInput } from "../../shared/types";
const formatDate = (dateStr?: string | null) => {
if (!dateStr) return null;
try {
return new Date(dateStr).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return dateStr;
}
};
const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return null;
try {
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) return dateStr;
const date = parsed.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
const time = parsed.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
});
return `${date} ${time}`;
} catch {
return dateStr;
}
};
const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const clampText = (value: string, max = 160) => (value.length > max ? `${value.slice(0, max).trim()}...` : value);
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
export const UkVisaJobsPage: React.FC = () => {
const [searchTermInput, setSearchTermInput] = useState("");
const [activeSearchTerm, setActiveSearchTerm] = useState<string | null>(null);
const [results, setResults] = useState<CreateJobInput[]>([]);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(new Set());
const [isSearching, setIsSearching] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [lastSearchTerm, setLastSearchTerm] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(15);
const [totalJobs, setTotalJobs] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (results.length === 0) {
setSelectedJobId(null);
return;
}
const firstKey = jobKey(results[0]);
if (!selectedJobId || !results.some((job) => jobKey(job) === selectedJobId)) {
setSelectedJobId(firstKey);
}
}, [results, selectedJobId]);
useEffect(() => {
setSelectedJobIds(new Set());
}, [results]);
const selectedJob = useMemo(
() => (selectedJobId ? results.find((job) => jobKey(job) === selectedJobId) ?? null : null),
[results, selectedJobId],
);
const summaryCounts = useMemo(() => {
const startIndex = totalJobs === 0 ? 0 : (page - 1) * pageSize + 1;
const endIndex = totalJobs === 0 ? 0 : Math.min(page * pageSize, totalJobs);
return {
startIndex,
endIndex,
};
}, [page, pageSize, totalJobs]);
const runSearch = async ({ term, pageNumber }: { term: string | null; pageNumber: number }) => {
try {
setIsSearching(true);
setErrorMessage(null);
const response = await api.searchUkVisaJobs({
searchTerm: term ?? undefined,
page: pageNumber,
});
setResults(response.jobs);
setPage(response.page);
setPageSize(response.pageSize);
setTotalJobs(response.totalJobs);
setTotalPages(response.totalPages);
setLastRunAt(new Date().toISOString());
if (response.jobs.length === 0) {
toast.message("No UK Visa Jobs found for this search.");
}
} catch (error) {
const message = error instanceof Error ? error.message : "UK Visa Jobs search failed";
setErrorMessage(message);
toast.error(message);
} finally {
setIsSearching(false);
}
};
const handleSearch = async (event: React.FormEvent) => {
event.preventDefault();
setErrorMessage(null);
const terms = searchTermInput
.split(/[\n,]+/)
.map((term) => term.trim())
.filter(Boolean);
const term = terms[0] || null;
if (terms.length > 1) {
toast.message("Using the first term for pagination.");
}
setActiveSearchTerm(term);
setLastSearchTerm(term);
setPage(1);
await runSearch({ term, pageNumber: 1 });
};
const handlePageChange = (nextPage: number) => {
if (isSearching) return;
if (nextPage < 1 || nextPage > totalPages) return;
setPage(nextPage);
void runSearch({ term: activeSearchTerm, pageNumber: nextPage });
};
const handleImportSelected = async () => {
const selectedJobs = results.filter((job) => selectedJobIds.has(jobKey(job)));
if (selectedJobs.length === 0) return;
try {
setIsImporting(true);
const response = await api.importUkVisaJobs({ jobs: selectedJobs });
toast.success(`Imported ${response.created} jobs`, {
description: response.skipped ? `${response.skipped} skipped (duplicates)` : undefined,
});
setSelectedJobIds(new Set());
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to import jobs";
toast.error(message);
} finally {
setIsImporting(false);
}
};
const selectedDescription = useMemo(() => {
if (!selectedJob?.jobDescription) return "No description available.";
const cleaned = stripHtml(selectedJob.jobDescription);
return cleaned || "No description available.";
}, [selectedJob]);
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null;
const selectedPosted = selectedJob ? formatDate(selectedJob.datePosted) : null;
const selectedCount = selectedJobIds.size;
const allSelected = results.length > 0 && results.every((job) => selectedJobIds.has(jobKey(job)));
const selectAllState = allSelected ? true : selectedCount > 0 ? "indeterminate" : false;
const canGoPrev = page > 1;
const canGoNext = page < totalPages;
return (
<>
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Briefcase className="h-4 w-4 text-muted-foreground" />
</div>
<div className="leading-tight">
<div className="text-sm font-semibold tracking-tight">UK Visa Jobs</div>
<div className="text-xs text-muted-foreground">Live search console</div>
</div>
<Badge variant="outline" className="uppercase tracking-wide">
API search
</Badge>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon" aria-label="Back to orchestrator">
<Link to="/">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="ghost" size="icon" aria-label="Settings">
<Link to="/settings">
<Settings className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</header>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<section className="rounded-xl border border-border/60 bg-card/40 p-4">
<form className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]" onSubmit={handleSearch}>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Search term
</label>
<Input
value={searchTermInput}
onChange={(event) => setSearchTermInput(event.target.value)}
placeholder="e.g. data analyst"
className="h-10"
/>
<p className="text-xs text-muted-foreground">
Single keyword or phrase. Leave blank to fetch the newest jobs.
</p>
</div>
<div className="flex items-end">
<Button type="submit" className="h-10 w-full gap-2" disabled={isSearching}>
{isSearching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{isSearching ? "Searching..." : "Search"}
</Button>
</div>
</form>
{errorMessage && (
<div className="mt-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<Separator className="my-4" />
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted-foreground">
<div>
Last run: {lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{totalJobs} total
</Badge>
<Badge variant="outline" className="text-xs">
{results.length} on page
</Badge>
<span>
Page {page} of {totalPages}
</span>
{lastSearchTerm && <span className="truncate">Term: {lastSearchTerm}</span>}
</div>
</div>
</section>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]">
<div className="rounded-xl border border-border/60 bg-card/40">
{results.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
<div className="text-base font-semibold">No results yet</div>
<p className="max-w-md text-sm text-muted-foreground">
Run a search to fetch fresh UK Visa Jobs listings.
</p>
</div>
) : (
<>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-4 py-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Checkbox
checked={selectAllState}
onCheckedChange={(checked) => {
if (checked === true) {
setSelectedJobIds(new Set(results.map((job) => jobKey(job))));
} else {
setSelectedJobIds(new Set());
}
}}
aria-label="Select all jobs on this page"
/>
<span>Select page</span>
<Separator orientation="vertical" className="h-4" />
<span>{selectedCount} selected</span>
</div>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleImportSelected}
disabled={selectedCount === 0 || isImporting}
>
{isImporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
{isImporting ? "Importing..." : "Import to DB"}
</Button>
</div>
<div className="divide-y divide-border/60">
{results.map((job) => {
const key = jobKey(job);
const isSelected = key === selectedJobId;
const isChecked = selectedJobIds.has(key);
const description = job.jobDescription ? clampText(stripHtml(job.jobDescription)) : "No description.";
return (
<div
key={key}
role="button"
tabIndex={0}
onClick={() => setSelectedJobId(key)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedJobId(key);
}
}}
className={cn(
"flex w-full items-start gap-4 px-4 py-3 text-left transition-colors",
isSelected ? "bg-muted/40" : "hover:bg-muted/30",
)}
aria-pressed={isSelected}
>
<div
className="mt-1"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
role="presentation"
>
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
setSelectedJobIds((current) => {
const next = new Set(current);
if (checked) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
}}
aria-label={`Select ${job.title}`}
/>
</div>
<span className="mt-1 flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Briefcase className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<div className="truncate text-sm font-semibold">{job.title}</div>
<div className="text-xs text-muted-foreground">{job.employer}</div>
</div>
<div className="text-xs text-muted-foreground">{description}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{job.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{job.location}
</span>
)}
{job.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
{job.salary}
</span>
)}
{job.deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(job.deadline)}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{job.jobType && (
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
{job.jobType}
</Badge>
)}
{job.jobLevel && (
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
{job.jobLevel}
</Badge>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground">
<span>
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex} of {totalJobs}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={() => handlePageChange(page - 1)}
disabled={!canGoPrev || isSearching}
>
<ChevronLeft className="h-4 w-4" />
Prev
</Button>
<span>
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={() => handlePageChange(page + 1)}
disabled={!canGoNext || isSearching}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</div>
<div className="rounded-xl border border-border/60 bg-card/40 p-4 lg:sticky lg:top-24 lg:self-start">
{!selectedJob ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<div className="text-base font-semibold">Select a job</div>
<p className="text-sm text-muted-foreground">Pick a job from the list to inspect details.</p>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-base font-semibold">{selectedJob.title}</div>
<div className="text-sm text-muted-foreground">{selectedJob.employer}</div>
</div>
<Badge variant="outline" className="uppercase tracking-wide">
UK Visa Jobs
</Badge>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{selectedJob.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{selectedJob.location}
</span>
)}
{selectedDeadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{selectedDeadline}
</span>
)}
{selectedPosted && (
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
Posted {selectedPosted}
</span>
)}
{selectedJob.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
{selectedJob.salary}
</span>
)}
{selectedJob.degreeRequired && (
<span className="flex items-center gap-1">
<GraduationCap className="h-3.5 w-3.5" />
{selectedJob.degreeRequired}
</span>
)}
</div>
<div className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Job type</div>
<div className="font-medium">{selectedJob.jobType || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Job level</div>
<div className="font-medium">{selectedJob.jobLevel || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Location</div>
<div className="font-medium">{selectedJob.location || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Deadline</div>
<div className="font-medium">{selectedDeadline || "Not set"}</div>
</div>
</div>
<Separator />
<Button asChild size="sm" variant="outline" className="gap-2">
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
View job
</a>
</Button>
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Description
</div>
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground whitespace-pre-wrap">
{selectedDescription}
</div>
</div>
</div>
)}
</div>
</section>
</main>
</>
);
};

View File

@ -9,6 +9,7 @@ import * as pipelineRepo from '../repositories/pipeline.js';
import * as settingsRepo from '../repositories/settings.js';
import { runPipeline, processJob, summarizeJob, generateFinalPdf, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
import { createNotionEntry } from '../services/notion.js';
import { fetchUkVisaJobsPage } from '../services/ukvisajobs.js';
import { clearDatabase } from '../db/clear.js';
import {
extractProjectsFromProfile,
@ -16,9 +17,10 @@ import {
normalizeResumeProjectsSettings,
resolveResumeProjectsSettings,
} from '../services/resumeProjects.js';
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse, UkVisaJobsSearchResponse, UkVisaJobsImportResponse } from '../../shared/types.js';
export const apiRouter = Router();
let isUkVisaJobsSearchRunning = false;
async function notifyJobCompleteWebhook(job: Job) {
const overrideWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl')
@ -642,6 +644,129 @@ apiRouter.post('/pipeline/run', async (req: Request, res: Response) => {
}
});
// ============================================================================
// UK Visa Jobs API
// ============================================================================
const ukVisaJobsSearchSchema = z.object({
query: z.string().trim().min(1).max(200).optional(),
searchTerm: z.string().trim().min(1).max(200).optional(),
searchTerms: z.array(z.string().trim().min(1).max(200)).max(20).optional(),
page: z.number().int().min(1).optional(),
});
/**
* POST /api/ukvisajobs/search - Run a UKVisaJobs search without importing into the DB
*/
apiRouter.post('/ukvisajobs/search', async (req: Request, res: Response) => {
let lockAcquired = false;
try {
const input = ukVisaJobsSearchSchema.parse(req.body ?? {});
if (isUkVisaJobsSearchRunning) {
return res.status(409).json({ success: false, error: 'UK Visa Jobs search is already running' });
}
const { isRunning } = getPipelineStatus();
if (isRunning) {
return res.status(409).json({ success: false, error: 'Pipeline is running. Stop it before running UK Visa Jobs search.' });
}
isUkVisaJobsSearchRunning = true;
lockAcquired = true;
const rawTerms = input.searchTerms ?? [];
if (rawTerms.length > 1) {
return res.status(400).json({ success: false, error: 'Pagination supports a single search term.' });
}
const searchTerm = input.searchTerm ?? input.query ?? rawTerms[0];
const page = input.page ?? 1;
const result = await fetchUkVisaJobsPage({
searchKeyword: searchTerm,
page,
});
const totalPages = Math.max(1, Math.ceil(result.totalJobs / result.pageSize));
const response: ApiResponse<UkVisaJobsSearchResponse> = {
success: true,
data: {
jobs: result.jobs,
totalJobs: result.totalJobs,
page: result.page,
pageSize: result.pageSize,
totalPages,
},
};
res.json(response);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
} finally {
if (lockAcquired) {
isUkVisaJobsSearchRunning = false;
}
}
});
const ukVisaJobsImportSchema = z.object({
jobs: z.array(z.object({
title: z.string().trim().min(1).max(500),
employer: z.string().trim().min(1).max(500),
jobUrl: z.string().trim().min(1).max(2000),
sourceJobId: z.string().trim().min(1).max(200).optional(),
employerUrl: z.string().trim().min(1).max(2000).optional(),
applicationLink: z.string().trim().min(1).max(2000).optional(),
location: z.string().trim().max(200).optional(),
deadline: z.string().trim().max(100).optional(),
salary: z.string().trim().max(200).optional(),
jobDescription: z.string().trim().max(20000).optional(),
datePosted: z.string().trim().max(100).optional(),
degreeRequired: z.string().trim().max(200).optional(),
jobType: z.string().trim().max(200).optional(),
jobLevel: z.string().trim().max(200).optional(),
})).min(1).max(200),
});
/**
* POST /api/ukvisajobs/import - Import selected UKVisaJobs results into the DB
*/
apiRouter.post('/ukvisajobs/import', async (req: Request, res: Response) => {
try {
const input = ukVisaJobsImportSchema.parse(req.body ?? {});
const jobs = input.jobs.map((job) => ({
...job,
source: 'ukvisajobs' as const,
}));
const result = await jobsRepo.bulkCreateJobs(jobs);
const response: ApiResponse<UkVisaJobsImportResponse> = {
success: true,
data: {
created: result.created,
skipped: result.skipped,
},
};
res.json(response);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
// ============================================================================
// Webhook for n8n
// ============================================================================

View File

@ -14,6 +14,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const UKVISAJOBS_DIR = join(__dirname, '../../../../extractors/ukvisajobs');
const STORAGE_DIR = join(UKVISAJOBS_DIR, 'storage/datasets/default');
const AUTH_CACHE_PATH = join(UKVISAJOBS_DIR, 'storage/ukvisajobs-auth.json');
const UKVISAJOBS_API_URL = 'https://my.ukvisajobs.com/ukvisa-api/api/fetch-jobs-data';
const UKVISAJOBS_PAGE_SIZE = 15;
let isUkVisaJobsRunning = false;
interface UkVisaJobsAuthSession {
token?: string;
@ -37,6 +40,117 @@ export interface UkVisaJobsResult {
error?: string;
}
function toStringOrNull(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return null;
}
function toNumberOrNull(value: unknown): number | null {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function buildCookieHeader(session: UkVisaJobsAuthSession): string {
const cookieParts: string[] = [];
if (session.csrfToken) cookieParts.push(`csrf_token=${session.csrfToken}`);
if (session.ciSession) cookieParts.push(`ci_session=${session.ciSession}`);
const token = session.authToken || session.token;
if (token) cookieParts.push(`authToken=${token}`);
return cookieParts.join('; ');
}
function buildVisaInfoDescription(raw: UkVisaJobsApiJob): string | undefined {
const visaInfo: string[] = [];
if (raw.visa_acceptance?.toLowerCase() === 'yes') visaInfo.push('Visa acceptance: Yes');
if (raw.applicants_outside_uk?.toLowerCase() === 'yes') visaInfo.push('Accepts applicants outside UK');
if (raw.likely_to_sponsor?.toLowerCase() === 'yes') visaInfo.push('Likely to sponsor');
if (raw.definitely_sponsored?.toLowerCase() === 'yes') visaInfo.push('Definitely sponsored');
if (raw.new_entrant?.toLowerCase() === 'yes') visaInfo.push('New entrant friendly');
if (raw.student_graduate?.toLowerCase() === 'yes') visaInfo.push('Student/Graduate friendly');
if (visaInfo.length === 0) return undefined;
return `Visa sponsorship info: ${visaInfo.join(', ')}`;
}
function formatSalary(raw: UkVisaJobsApiJob): string | undefined {
const minSalary = toNumberOrNull(raw.min_salary);
const maxSalary = toNumberOrNull(raw.max_salary);
const interval = toStringOrNull(raw.salary_interval);
if (minSalary && maxSalary && maxSalary > 0) {
return `GBP ${minSalary.toLocaleString()}-${maxSalary.toLocaleString()}${interval ? ` / ${interval}` : ''}`;
}
if (maxSalary && maxSalary > 0) {
return `GBP ${maxSalary.toLocaleString()}${interval ? ` / ${interval}` : ''}`;
}
return undefined;
}
function mapApiJob(raw: UkVisaJobsApiJob): CreateJobInput {
const description = toStringOrNull(raw.description) ?? buildVisaInfoDescription(raw);
return {
source: 'ukvisajobs',
sourceJobId: toStringOrNull(raw.id) ?? undefined,
title: toStringOrNull(raw.title) ?? 'Unknown Title',
employer: toStringOrNull(raw.company_name) ?? 'Unknown Employer',
employerUrl: toStringOrNull(raw.company_link) ?? undefined,
jobUrl: toStringOrNull(raw.job_link) ?? '',
applicationLink: toStringOrNull(raw.job_link) ?? undefined,
location: toStringOrNull(raw.city) ?? undefined,
deadline: toStringOrNull(raw.job_expire) ?? undefined,
salary: formatSalary(raw),
jobDescription: description ?? undefined,
datePosted: toStringOrNull(raw.created_date) ?? undefined,
degreeRequired: toStringOrNull(raw.degree_requirement) ?? undefined,
jobType: toStringOrNull(raw.job_type) ?? undefined,
jobLevel: toStringOrNull(raw.job_level) ?? undefined,
};
}
interface UkVisaJobsApiJob {
id: string;
title: string;
company_name: string;
company_link?: string;
job_link: string;
city?: string;
created_date?: string;
job_expire?: string;
description?: string;
min_salary?: string;
max_salary?: string;
salary_interval?: string;
salary_method?: string;
degree_requirement?: string;
job_type?: string;
job_level?: string;
job_industry?: string;
visa_acceptance?: string;
applicants_outside_uk?: string;
likely_to_sponsor?: string;
definitely_sponsored?: string;
new_entrant?: string;
student_graduate?: string;
}
interface UkVisaJobsApiResponse {
status: number;
totalJobs: number;
query?: string;
jobs: UkVisaJobsApiJob[];
}
/**
* Basic HTML to text conversion to extract job description.
*/
@ -134,7 +248,85 @@ async function clearStorageDataset(): Promise<void> {
}
}
export async function fetchUkVisaJobsPage(options: { searchKeyword?: string; page?: number } = {}): Promise<{
jobs: CreateJobInput[];
totalJobs: number;
page: number;
pageSize: number;
}> {
const page = options.page && options.page > 0 ? options.page : 1;
const authSession = await loadCachedAuthSession();
const token = authSession?.token || authSession?.authToken;
if (!token) {
throw new Error('UK Visa Jobs auth session missing. Run the extractor to refresh tokens.');
}
const formData = new FormData();
formData.append('is_global', '0');
formData.append('sortBy', 'desc');
formData.append('pageNo', String(page));
formData.append('visaAcceptance', 'false');
formData.append('applicants_outside_uk', 'false');
formData.append('searchKeyword', options.searchKeyword ? options.searchKeyword : 'null');
formData.append('token', token);
const cookies = buildCookieHeader({
token: authSession?.token,
authToken: authSession?.authToken,
csrfToken: authSession?.csrfToken,
ciSession: authSession?.ciSession,
});
const response = await fetch(UKVISAJOBS_API_URL, {
method: 'POST',
headers: {
'accept': 'application/json, text/plain, */*',
'cookie': cookies,
'origin': 'https://my.ukvisajobs.com',
'referer': `https://my.ukvisajobs.com/open-jobs/1?is_global=0&sortBy=desc&pageNo=${page}&visaAcceptance=false&applicants_outside_uk=false`,
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
body: formData,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`UK Visa Jobs API returned ${response.status}: ${text}`);
}
let data: UkVisaJobsApiResponse;
try {
data = JSON.parse(text) as UkVisaJobsApiResponse;
} catch (error) {
throw new Error('UK Visa Jobs API returned an invalid response.');
}
if (data.status !== 1) {
throw new Error(`UK Visa Jobs API returned status ${data.status}`);
}
const jobs = (data.jobs || [])
.map(mapApiJob)
.filter((job) => Boolean(job.jobUrl));
const totalJobs = Number.isFinite(data.totalJobs) ? data.totalJobs : jobs.length;
return {
jobs,
totalJobs,
page,
pageSize: UKVISAJOBS_PAGE_SIZE,
};
}
export async function runUkVisaJobs(options: RunUkVisaJobsOptions = {}): Promise<UkVisaJobsResult> {
if (isUkVisaJobsRunning) {
return { success: false, jobs: [], error: 'UK Visa Jobs extractor is already running' };
}
isUkVisaJobsRunning = true;
try {
console.log('🇬🇧 Running UK Visa Jobs extractor...');
// Determine terms to run
@ -226,6 +418,9 @@ export async function runUkVisaJobs(options: RunUkVisaJobsOptions = {}): Promise
console.log(`✅ UK Visa Jobs: imported total ${allJobs.length} unique jobs`);
return { success: true, jobs: allJobs };
} finally {
isUkVisaJobsRunning = false;
}
}
/**

View File

@ -170,6 +170,19 @@ export interface JobsListResponse {
byStatus: Record<JobStatus, number>;
}
export interface UkVisaJobsSearchResponse {
jobs: CreateJobInput[];
totalJobs: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface UkVisaJobsImportResponse {
created: number;
skipped: number;
}
export interface PipelineStatusResponse {
isRunning: boolean;
lastRun: PipelineRun | null;