From 8300260ac3187e3b4763b3c9cebf1336949bcd43 Mon Sep 17 00:00:00 2001 From: anrulazrudinwee-stack Date: Fri, 27 Mar 2026 17:59:05 +0800 Subject: [PATCH 1/3] Add job recommendation and URL scraper feature --- app/api/jobs/scrape/route.ts | 43 +++ app/jobs/recommendation/page.tsx | 520 +++++++++++++++++++++++------- lib/services/jobRecommendation.ts | 347 ++++++++++++++++++++ 3 files changed, 799 insertions(+), 111 deletions(-) create mode 100644 app/api/jobs/scrape/route.ts create mode 100644 lib/services/jobRecommendation.ts diff --git a/app/api/jobs/scrape/route.ts b/app/api/jobs/scrape/route.ts new file mode 100644 index 0000000..b28088e --- /dev/null +++ b/app/api/jobs/scrape/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/services/db"; +import { extractJobFromUrl } from "@/lib/services/jobRecommendation"; + +export const maxDuration = 30; + +export async function POST(req: Request) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { url } = body as { url?: string }; + + if (!url || typeof url !== "string") { + return NextResponse.json({ error: "url is required" }, { status: 400 }); + } + + // Basic URL validation + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return NextResponse.json({ error: "Invalid URL provided" }, { status: 400 }); + } + + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return NextResponse.json({ error: "Only http/https URLs are supported" }, { status: 400 }); + } + + const job = await extractJobFromUrl(url); + + return NextResponse.json({ job }); + } catch (err) { + console.error("Job scrape error:", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to extract job details" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/jobs/recommendation/page.tsx b/app/jobs/recommendation/page.tsx index a0948bd..57cb559 100644 --- a/app/jobs/recommendation/page.tsx +++ b/app/jobs/recommendation/page.tsx @@ -1,40 +1,393 @@ "use client"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { SiteNavbar } from "@/components/layout/SiteNavbar"; import { useLanguage } from "@/components/providers/language-provider"; +import type { JobRecommendation, ScrapedJob } from "@/lib/services/jobRecommendation"; -type Recommendation = { - title: string; - company: string; - matchScore: number; - strengths: string[]; - improvements: string[]; - reasoning: string; - applyUrl: string; -}; - -const mockRecommendation: Recommendation = { - title: "Operations Executive", - company: "Inter Group", - matchScore: 88, - strengths: [ - "Your coordination and documentation experience align well with this role.", - "Your communication background supports stakeholder and internal team follow-up.", - "Your profile shows transferable operational support capabilities.", - ], - improvements: [ - "Add more measurable achievements to your resume.", - "Highlight specific tools or systems you have used.", - "Strengthen your professional summary around operations impact.", - ], - reasoning: - "This role appears to be the strongest fit based on your coordination, administration, and support experience. Your background aligns well with process support and day-to-day operational responsibilities.", - applyUrl: "https://example.com/job/operations-executive", -}; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Tab = "recommendations" | "scrape"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatSalary(min: number | null, max: number | null): string { + if (!min && !max) return "Salary not stated"; + if (min && max) return `$${min.toLocaleString()} – $${max.toLocaleString()} / month`; + if (min) return `From $${min.toLocaleString()} / month`; + return `Up to $${max!.toLocaleString()} / month`; +} + +function ScoreBadge({ score }: { score: number }) { + const color = + score >= 80 + ? "text-emerald-700 bg-emerald-50 border-emerald-100" + : score >= 60 + ? "text-amber-700 bg-amber-50 border-amber-100" + : "text-slate-600 bg-slate-50 border-slate-200"; + + return ( +
+

Match

+

{score}%

+
+ ); +} + +// --------------------------------------------------------------------------- +// Job Card +// --------------------------------------------------------------------------- + +function JobCard({ + job, + selected, + onClick, +}: { + job: JobRecommendation; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Recommendations Tab +// --------------------------------------------------------------------------- + +function RecommendationsTab({ t }: { t: (key: string) => string }) { + const [jobs, setJobs] = useState([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + const res = await fetch("/api/jobs/recommend"); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Failed to load recommendations"); + setJobs(data.recommendations ?? []); + if (data.recommendations?.length) setSelected(data.recommendations[0]); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + if (loading) { + return ( +
+
+

Finding your best job matches…

+
+ ); + } + + if (error) { + return ( +
+ {error} + {error.includes("resume") && ( +
+ + Upload Resume → + +
+ )} +
+ ); + } + + if (!jobs.length) { + return ( +
+

No matching jobs found. Try updating your resume profile.

+
+ ); + } + + return ( +
+ {/* Job list */} +
+

+ {jobs.length} matches found +

+ {jobs.map((job) => ( + setSelected(job)} + /> + ))} +
+ + {/* Detail panel */} + {selected && ( +
+
+
+

{selected.company}

+

{selected.title}

+

+ {formatSalary(selected.salaryMin, selected.salaryMax)} + {selected.employmentType && ` · ${selected.employmentType}`} +

+
+ +
+ + {/* Skills */} + {selected.skills.length > 0 && ( +
+ {selected.skills.map((s) => ( + + {s} + + ))} +
+ )} + + {/* Reasoning */} +
+

+ Why this matches you +

+

{selected.reasoning}

+
+ + {/* Strengths & Improvements */} +
+
+

+ Your Strengths +

+
+ {selected.strengths.map((item) => ( +

• {item}

+ ))} +
+
+
+

+ To Improve +

+
+ {selected.improvements.map((item) => ( +

• {item}

+ ))} +
+
+
+ + {/* CTA */} +
+ + View on {selected.source} → + + + Practice Interview + +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// URL Scraper Tab +// --------------------------------------------------------------------------- + +function ScrapeTab() { + const [url, setUrl] = useState(""); + const [job, setJob] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleExtract() { + if (!url.trim()) return; + setLoading(true); + setError(null); + setJob(null); + + try { + const res = await fetch("/api/jobs/scrape", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: url.trim() }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Failed to extract job"); + setJob(data.job); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Extract Job from URL

+

+ Paste any job posting URL — LinkedIn, JobStreet, Indeed, MyCareersFuture, or any other + job board. +

+ +
+ setUrl(e.target.value)} + placeholder="https://www.linkedin.com/jobs/view/..." + className="flex-1 rounded-xl border border-slate-200 px-4 py-3 text-sm text-slate-700 outline-none focus:border-navy-950 focus:ring-2 focus:ring-navy-950/10" + /> + +
+ + {error && ( +

{error}

+ )} +
+ + {job && ( +
+
+
+

{job.source}

+

+ {job.title ?? "Untitled Role"} +

+ {job.company && ( +

{job.company}

+ )} +
+
+ +
+ {job.salary && 💰 {job.salary}} + {job.location && 📍 {job.location}} + {job.employmentType && 🕐 {job.employmentType}} +
+ + {job.skills.length > 0 && ( +
+

+ Key Skills +

+
+ {job.skills.map((s) => ( + + {s} + + ))} +
+
+ )} + + {job.description && ( +
+

+ Description +

+

+ {job.description.slice(0, 800)} + {job.description.length > 800 && "…"} +

+
+ )} + +
+ + View Original Posting → + + + Practice Interview + +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- export default function JobRecommendationPage() { const { t } = useLanguage(); + const [tab, setTab] = useState("recommendations"); return (
@@ -62,90 +415,35 @@ export default function JobRecommendationPage() {

-
-
-
-
-

{mockRecommendation.company}

-

- {mockRecommendation.title} -

-
- -
-

- {t("jobs_recommendation_score")} -

-

- {mockRecommendation.matchScore}% -

-
-
- -
-

- {t("jobs_recommendation_section_why_match")} -

-

- {mockRecommendation.reasoning} -

-
- -
-
-

- {t("jobs_recommendation_panel_strengths")} -

-
- {mockRecommendation.strengths.map((item) => ( -

• {item}

- ))} -
-
- -
-

- {t("jobs_recommendation_panel_improvements")} -

-
- {mockRecommendation.improvements.map((item) => ( -

• {item}

- ))} -
-
-
-
- - + {/* Tabs */} +
+ +
+ + {tab === "recommendations" ? ( + + ) : ( + + )} ); diff --git a/lib/services/jobRecommendation.ts b/lib/services/jobRecommendation.ts new file mode 100644 index 0000000..e9f6e41 --- /dev/null +++ b/lib/services/jobRecommendation.ts @@ -0,0 +1,347 @@ +import OpenAI from "openai"; +import type { ResumeProfile } from "@/lib/types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MCFJob { + uuid: string; + title: string; + company: { name: string }; + salary: { minimum: number | null; maximum: number | null }; + employmentTypes: { jobType: string }[]; + categories: { category: string }[]; + skills: { skill: string }[]; + positionLevels: { position: string }[]; + metadata: { jobPostId: string; originalPostUrl?: string }; + description?: string; +} + +export interface JobRecommendation { + id: string; + title: string; + company: string; + matchScore: number; + salaryMin: number | null; + salaryMax: number | null; + employmentType: string | null; + skills: string[]; + applyUrl: string; + strengths: string[]; + improvements: string[]; + reasoning: string; + source: "MyCareersFuture"; +} + +export interface ScrapedJob { + title: string | null; + company: string | null; + description: string | null; + salary: string | null; + location: string | null; + employmentType: string | null; + skills: string[]; + applyUrl: string; + source: string; +} + +// --------------------------------------------------------------------------- +// MyCareersFuture API +// --------------------------------------------------------------------------- + +const MCF_API_BASE = "https://api.mycareersfuture.gov.sg/v2"; + +function buildMCFSearchQuery(profile: ResumeProfile): string { + // Use target roles first, fall back to most recent job title + if (profile.target_roles?.length) { + return profile.target_roles[0]; + } + if (profile.experiences?.length) { + return profile.experiences[0].title ?? ""; + } + return ""; +} + +export async function fetchMCFJobs(profile: ResumeProfile, limit = 10): Promise { + const query = buildMCFSearchQuery(profile); + if (!query) return []; + + const params = new URLSearchParams({ + search: query, + limit: String(limit), + page: "0", + }); + + // Add salary filter if we can estimate from profile + const res = await fetch(`${MCF_API_BASE}/jobs?${params.toString()}`, { + headers: { + Accept: "application/json", + "User-Agent": "VeriClause/1.0", + }, + next: { revalidate: 300 }, // cache 5 mins + }); + + if (!res.ok) { + throw new Error(`MCF API error: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + return (data.results ?? []) as MCFJob[]; +} + +// --------------------------------------------------------------------------- +// OpenAI matching +// --------------------------------------------------------------------------- + +function getOpenAIClient(): OpenAI { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error("OPENAI_API_KEY not set"); + return new OpenAI({ apiKey }); +} + +const MATCH_SYSTEM_PROMPT = `You are an expert Singapore career advisor. Given a candidate's resume profile and a list of job postings, score and rank each job by fit. + +For each job return: +- matchScore: 0–100 integer +- strengths: 2–3 bullet points on why the candidate is a good fit +- improvements: 2–3 bullet points on gaps to address +- reasoning: 1–2 sentence overall reasoning + +Return ONLY a JSON array in this exact shape, no extra text: +[ + { + "id": "", + "matchScore": 85, + "strengths": ["...", "..."], + "improvements": ["...", "..."], + "reasoning": "..." + } +] + +Consider: skills overlap, seniority level, industry match, years of experience, target roles. +Singapore job market context applies.`; + +interface MatchResult { + id: string; + matchScore: number; + strengths: string[]; + improvements: string[]; + reasoning: string; +} + +export async function scoreJobsWithAI( + profile: ResumeProfile, + jobs: MCFJob[], +): Promise { + if (!jobs.length) return []; + + const client = getOpenAIClient(); + + const profileSummary = { + headline: profile.headline, + skills: profile.skills.slice(0, 20), + years_experience: profile.years_experience, + seniority_level: profile.seniority_level, + target_roles: profile.target_roles, + target_industries: profile.target_industries, + recent_titles: profile.experiences.slice(0, 3).map((e) => e.title), + }; + + const jobSummaries = jobs.map((j) => ({ + id: j.uuid, + title: j.title, + company: j.company?.name, + skills: j.skills?.map((s) => s.skill).slice(0, 10), + categories: j.categories?.map((c) => c.category), + positionLevel: j.positionLevels?.[0]?.position ?? null, + })); + + const userContent = `Candidate Profile:\n${JSON.stringify(profileSummary, null, 2)}\n\nJobs:\n${JSON.stringify(jobSummaries, null, 2)}`; + + const response = await client.chat.completions.create({ + model: "gpt-4o-mini", + temperature: 0.2, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: MATCH_SYSTEM_PROMPT }, + { role: "user", content: userContent }, + ], + }); + + const raw = response.choices[0]?.message?.content ?? "{}"; + try { + const parsed = JSON.parse(raw); + // Handle both array and wrapped object responses + const results: MatchResult[] = Array.isArray(parsed) + ? parsed + : (parsed.results ?? parsed.jobs ?? []); + return results; + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Merge MCF jobs + AI scores into final recommendations +// --------------------------------------------------------------------------- + +export async function getJobRecommendations( + profile: ResumeProfile, +): Promise { + const jobs = await fetchMCFJobs(profile, 10); + if (!jobs.length) return []; + + const scores = await scoreJobsWithAI(profile, jobs); + const scoreMap = new Map(scores.map((s) => [s.id, s])); + + const recommendations: JobRecommendation[] = jobs.map((job) => { + const score = scoreMap.get(job.uuid); + const applyUrl = + job.metadata?.originalPostUrl ?? + `https://www.mycareersfuture.gov.sg/job/${job.metadata?.jobPostId ?? job.uuid}`; + + return { + id: job.uuid, + title: job.title, + company: job.company?.name ?? "Unknown", + matchScore: score?.matchScore ?? 50, + salaryMin: job.salary?.minimum ?? null, + salaryMax: job.salary?.maximum ?? null, + employmentType: job.employmentTypes?.[0]?.jobType ?? null, + skills: job.skills?.map((s) => s.skill).slice(0, 8) ?? [], + applyUrl, + strengths: score?.strengths ?? [], + improvements: score?.improvements ?? [], + reasoning: score?.reasoning ?? "This role matches your profile.", + source: "MyCareersFuture", + }; + }); + + // Sort by matchScore descending + return recommendations.sort((a, b) => b.matchScore - a.matchScore); +} + +// --------------------------------------------------------------------------- +// Job URL scraper (any job board) +// --------------------------------------------------------------------------- + +const SCRAPE_SYSTEM_PROMPT = `You are a job posting parser. Extract structured information from the raw HTML/text of a job posting page. + +Return ONLY a JSON object in this exact shape, no extra text: +{ + "title": string | null, + "company": string | null, + "description": string | null, + "salary": string | null, + "location": string | null, + "employmentType": string | null, + "skills": string[] +} + +If a field is not found, use null. For skills, extract up to 15 key skills/requirements mentioned.`; + +export async function extractJobFromUrl(url: string): Promise { + // Detect source from URL + let source = "Unknown"; + if (url.includes("linkedin.com")) source = "LinkedIn"; + else if (url.includes("mycareersfuture.gov.sg")) source = "MyCareersFuture"; + else if (url.includes("jobstreet.com")) source = "JobStreet"; + else if (url.includes("indeed.com")) source = "Indeed"; + else if (url.includes("glassdoor.com")) source = "Glassdoor"; + + // For MCF URLs, use the API directly instead of scraping + if (source === "MyCareersFuture") { + const match = url.match(/JOB-[\w-]+/i); + if (match) { + const jobPostId = match[0]; + const res = await fetch( + `${MCF_API_BASE}/jobs?jobPostId=${jobPostId}`, + { + headers: { + Accept: "application/json", + "User-Agent": "VeriClause/1.0", + }, + }, + ); + if (res.ok) { + const data = await res.json(); + const job: MCFJob = data.results?.[0]; + if (job) { + return { + title: job.title ?? null, + company: job.company?.name ?? null, + description: job.description ?? null, + salary: job.salary?.minimum + ? `$${job.salary.minimum.toLocaleString()} – $${job.salary.maximum?.toLocaleString() ?? "?"} / month` + : null, + location: "Singapore", + employmentType: job.employmentTypes?.[0]?.jobType ?? null, + skills: job.skills?.map((s) => s.skill) ?? [], + applyUrl: url, + source: "MyCareersFuture", + }; + } + } + } + } + + // Fallback: scrape HTML for other job boards + const res = await fetch(url, { + headers: { + "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", + Accept: "text/html,application/xhtml+xml", + }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch URL: ${res.status} ${res.statusText}`); + } + + const html = await res.text(); + + // Strip HTML tags to get readable text (keep it under token limit) + const text = html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 12000); + + const client = getOpenAIClient(); + + const response = await client.chat.completions.create({ + model: "gpt-4o-mini", + temperature: 0, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: SCRAPE_SYSTEM_PROMPT }, + { role: "user", content: `Job posting page content:\n\n${text}` }, + ], + }); + + const raw = response.choices[0]?.message?.content ?? "{}"; + let parsed: Omit; + try { + parsed = JSON.parse(raw); + } catch { + parsed = { + title: null, + company: null, + description: null, + salary: null, + location: null, + employmentType: null, + skills: [], + }; + } + + return { + ...parsed, + skills: parsed.skills ?? [], + applyUrl: url, + source, + }; +} \ No newline at end of file From 7daca2989180e68c2e5ead87417176edc30a612a Mon Sep 17 00:00:00 2001 From: anrulazrudinwee-stack Date: Fri, 27 Mar 2026 18:48:24 +0800 Subject: [PATCH 2/3] Fix company name and AI scoring for job recommendations --- app/api/jobs/recommend/route.ts | 56 +++++++++++++++++++++++++++++++ lib/services/jobRecommendation.ts | 49 +++++++++++++-------------- package-lock.json | 13 +++++++ 3 files changed, 92 insertions(+), 26 deletions(-) create mode 100644 app/api/jobs/recommend/route.ts diff --git a/app/api/jobs/recommend/route.ts b/app/api/jobs/recommend/route.ts new file mode 100644 index 0000000..ac083dc --- /dev/null +++ b/app/api/jobs/recommend/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { getAuthenticatedUser, getResume, listResumes } from "@/lib/services/db"; +import { getJobRecommendations } from "@/lib/services/jobRecommendation"; + +export const maxDuration = 60; + +export async function GET(req: Request) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const resumeId = searchParams.get("resume_id"); + + let profile = null; + + if (resumeId) { + // Use specific resume + const resume = await getResume(resumeId, user.id); + if (!resume) { + return NextResponse.json({ error: "Resume not found" }, { status: 404 }); + } + profile = resume.parsed_profile; + } else { + // Use latest resume with a profile + const resumes = await listResumes(user.id); + const withProfile = resumes.find((r) => r.parsed_profile != null); + if (!withProfile) { + return NextResponse.json( + { error: "No resume profile found. Please upload and analyse your resume first." }, + { status: 404 }, + ); + } + profile = withProfile.parsed_profile; + } + + if (!profile) { + return NextResponse.json( + { error: "Resume has not been profiled yet. Please wait for profiling to complete." }, + { status: 400 }, + ); + } + + const recommendations = await getJobRecommendations(profile); + + return NextResponse.json({ recommendations }); + } catch (err) { + console.error("Job recommendation error:", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to fetch recommendations" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/lib/services/jobRecommendation.ts b/lib/services/jobRecommendation.ts index e9f6e41..d6988cb 100644 --- a/lib/services/jobRecommendation.ts +++ b/lib/services/jobRecommendation.ts @@ -8,13 +8,14 @@ import type { ResumeProfile } from "@/lib/types"; export interface MCFJob { uuid: string; title: string; - company: { name: string }; + postedCompany: { name: string } | null; + company?: { name: string } | null; salary: { minimum: number | null; maximum: number | null }; - employmentTypes: { jobType: string }[]; + employmentTypes: { employmentType: string }[]; categories: { category: string }[]; skills: { skill: string }[]; positionLevels: { position: string }[]; - metadata: { jobPostId: string; originalPostUrl?: string }; + metadata: { jobPostId: string; jobDetailsUrl?: string; originalPostUrl?: string }; description?: string; } @@ -108,16 +109,18 @@ For each job return: - improvements: 2–3 bullet points on gaps to address - reasoning: 1–2 sentence overall reasoning -Return ONLY a JSON array in this exact shape, no extra text: -[ - { - "id": "", - "matchScore": 85, - "strengths": ["...", "..."], - "improvements": ["...", "..."], - "reasoning": "..." - } -] +Return ONLY a JSON object in this exact shape, no extra text: +{ + "matches": [ + { + "id": "", + "matchScore": 85, + "strengths": ["...", "..."], + "improvements": ["...", "..."], + "reasoning": "..." + } + ] +} Consider: skills overlap, seniority level, industry match, years of experience, target roles. Singapore job market context applies.`; @@ -151,7 +154,7 @@ export async function scoreJobsWithAI( const jobSummaries = jobs.map((j) => ({ id: j.uuid, title: j.title, - company: j.company?.name, + company: j.postedCompany?.name ?? j.company?.name ?? "Unknown", skills: j.skills?.map((s) => s.skill).slice(0, 10), categories: j.categories?.map((c) => c.category), positionLevel: j.positionLevels?.[0]?.position ?? null, @@ -172,10 +175,7 @@ export async function scoreJobsWithAI( const raw = response.choices[0]?.message?.content ?? "{}"; try { const parsed = JSON.parse(raw); - // Handle both array and wrapped object responses - const results: MatchResult[] = Array.isArray(parsed) - ? parsed - : (parsed.results ?? parsed.jobs ?? []); + const results: MatchResult[] = parsed.matches ?? parsed.results ?? parsed.jobs ?? []; return results; } catch { return []; @@ -197,20 +197,17 @@ export async function getJobRecommendations( const recommendations: JobRecommendation[] = jobs.map((job) => { const score = scoreMap.get(job.uuid); - const applyUrl = - job.metadata?.originalPostUrl ?? - `https://www.mycareersfuture.gov.sg/job/${job.metadata?.jobPostId ?? job.uuid}`; return { id: job.uuid, title: job.title, - company: job.company?.name ?? "Unknown", + company: job.postedCompany?.name ?? job.company?.name ?? "Unknown", matchScore: score?.matchScore ?? 50, salaryMin: job.salary?.minimum ?? null, salaryMax: job.salary?.maximum ?? null, - employmentType: job.employmentTypes?.[0]?.jobType ?? null, + employmentType: job.employmentTypes?.[0]?.employmentType ?? null, skills: job.skills?.map((s) => s.skill).slice(0, 8) ?? [], - applyUrl, + applyUrl: job.metadata?.jobDetailsUrl ?? `https://www.mycareersfuture.gov.sg/job/${job.metadata?.jobPostId ?? job.uuid}`, strengths: score?.strengths ?? [], improvements: score?.improvements ?? [], reasoning: score?.reasoning ?? "This role matches your profile.", @@ -270,13 +267,13 @@ export async function extractJobFromUrl(url: string): Promise { if (job) { return { title: job.title ?? null, - company: job.company?.name ?? null, + company: job.postedCompany?.name ?? job.company?.name ?? null, description: job.description ?? null, salary: job.salary?.minimum ? `$${job.salary.minimum.toLocaleString()} – $${job.salary.maximum?.toLocaleString() ?? "?"} / month` : null, location: "Singapore", - employmentType: job.employmentTypes?.[0]?.jobType ?? null, + employmentType: job.employmentTypes?.[0]?.employmentType ?? null, skills: job.skills?.map((s) => s.skill) ?? [], applyUrl: url, source: "MyCareersFuture", diff --git a/package-lock.json b/package-lock.json index 5b12fac..4e56c3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1326,6 +1326,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.98.0.tgz", "integrity": "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", @@ -1407,6 +1408,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1489,6 +1491,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2071,6 +2074,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3319,6 +3323,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3488,6 +3493,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5049,6 +5055,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6079,6 +6086,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6345,6 +6353,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" }, @@ -6357,6 +6366,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" @@ -7441,6 +7451,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7512,6 +7523,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -7649,6 +7661,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From e9509ebe6f5437ddc16d90c68170e16939c1c125 Mon Sep 17 00:00:00 2001 From: anrulazrudinwee-stack Date: Fri, 27 Mar 2026 19:03:10 +0800 Subject: [PATCH 3/3] Fix MCF URL extraction to handle UUID and JOB- formats --- lib/services/jobRecommendation.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/services/jobRecommendation.ts b/lib/services/jobRecommendation.ts index d6988cb..9b373ba 100644 --- a/lib/services/jobRecommendation.ts +++ b/lib/services/jobRecommendation.ts @@ -249,11 +249,15 @@ export async function extractJobFromUrl(url: string): Promise { // For MCF URLs, use the API directly instead of scraping if (source === "MyCareersFuture") { - const match = url.match(/JOB-[\w-]+/i); - if (match) { - const jobPostId = match[0]; + const jobMatch = url.match(/JOB-[\w-]+/i); + const uuidMatch = url.match(/([a-f0-9]{32})(?:[^a-f0-9]|$)/i); + const jobPostId = jobMatch?.[0] ?? null; + const uuid = uuidMatch?.[1] ?? null; + + if (jobPostId || uuid) { + const query = jobPostId ? `jobPostId=${jobPostId}` : `uuid=${uuid}`; const res = await fetch( - `${MCF_API_BASE}/jobs?jobPostId=${jobPostId}`, + `${MCF_API_BASE}/jobs?${query}`, { headers: { Accept: "application/json",