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 (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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 */}
+
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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 && "…"}
+
+
+ )}
+
+
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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(/