Phase 3-4: apply wizard with AI answers, Playwright auto-apply worker, report page, Docker deploy files; add PH government job leads

This commit is contained in:
Jonathan Sykes
2026-08-16 20:42:27 +08:00
parent 5e9084b64e
commit bcbecbb49a
16 changed files with 631 additions and 0 deletions

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV DATABASE_URL="file:/data/app.db"
RUN npx prisma generate && npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV DATABASE_URL="file:/data/app.db"
COPY --from=build /app/.next ./.next
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/public ./public
EXPOSE 3000
# Run migrations against the volume-mounted DB, then start.
CMD npx prisma migrate deploy && npm start

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
jobsearch:
build: .
restart: unless-stopped
environment:
DATABASE_URL: "file:/data/app.db"
OMNIROUTE_BASE_URL: "https://omni.hesed.sbs/v1"
OMNIROUTE_API_KEY: "${OMNIROUTE_API_KEY}"
OMNIROUTE_MODEL: "${OMNIROUTE_MODEL:-deepseek-v4-flash}"
volumes:
- jobsearch_data:/data
ports:
- "3790:3000"
volumes:
jobsearch_data:

48
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"playwright": "^1.62.1",
"tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5"
@@ -6567,6 +6568,53 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View File

@@ -25,6 +25,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"playwright": "^1.62.1",
"tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5"

Binary file not shown.

65
scripts/add-gov-jobs.ts Normal file
View File

@@ -0,0 +1,65 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const jobs = [
{
title: "Information Systems Analyst (SG-12) — Nationwide",
company: "Philippine Statistics Authority (PSA)",
source: "governmentph.com", url: "https://governmentph.com/psa-infosys-analysts/",
payMin: 22149, payMax: 22149, payCurrency: "PHP", payPeriod: "month",
location: "All Regional & Field Offices, Philippines", remoteType: "onsite", phEligibility: "yes",
description: "PSA hiring Information Systems Analysts nationwide. Salary Grade 12. Open to fresh graduates.",
requirements: "Bachelor's in Computer Science, Information Systems, or IT-related course. No experience or training required. Civil Service eligibility per CSC rules.",
tags: "government,onsite,IT,fresh-grad-ok", status: "prospect",
notes: "Gov job — apply via PSA regional office postings; check CSC portal for exact deadlines.",
},
{
title: "IT/Programmer Vacancies (portal — filter 'Computer Programmer')",
company: "Civil Service Commission Job Portal",
source: "csc.gov.ph", url: "https://csc.gov.ph/career/",
location: "Philippines (all agencies/regions)", remoteType: "onsite", phEligibility: "yes",
description: "Official CSC job portal aggregating all PH government vacancies. Filter by position title (Computer Programmer, Information Systems Analyst, IT Officer), agency, and region. Each posting shows salary grade, qualification standards, documents, and application instructions.",
requirements: "Varies per posting; most require CS/IT degree + civil service eligibility for permanent items.",
tags: "government,portal,aggregator", status: "prospect",
notes: "Portal blocks bots (403) — browse manually. CSC NCR posts its own vacancies at csc.gov.ph/regional-offices/ncr.",
},
{
title: "IT Positions (multiple, NCR + Caraga)",
company: "Department of Information and Communications Technology (DICT)",
source: "govjobsph.com", url: "https://govjobsph.com/jobs/agency/dict",
location: "NCR / Region 13 Caraga", remoteType: "onsite", phEligibility: "yes",
description: "DICT job openings across NCR and Caraga. The country's lead ICT agency — roles closest to Josh's dev/infra background.",
requirements: "Varies; typically IT degree + relevant eligibility.",
tags: "government,ICT,DICT", status: "prospect",
notes: "Listings on GovJobsPH; verify deadlines on DICT's official careers page.",
},
{
title: "Business/Systems Analyst + ICT roles",
company: "Department of Science and Technology (DOST)",
source: "jobstreet", url: "https://ph.jobstreet.com/dost-jobs",
location: "Philippines", remoteType: "onsite", phEligibility: "yes",
description: "DOST openings including Business/Systems Analyst under Information & Communication Technology, plus research roles.",
requirements: "IT/CS degree; per-posting qualification standards.",
tags: "government,DOST,analyst", status: "prospect",
notes: "Also check dost.gov.ph careers page directly.",
},
{
title: "Government IT Jobs (aggregator)",
company: "GovJobs.PH",
source: "govjobs.ph", url: "https://govjobs.ph/",
location: "Philippines", remoteType: "onsite", phEligibility: "yes",
description: "Aggregator for Philippine government vacancies incl. Computer Programmer I/II items across agencies.",
tags: "government,portal,aggregator", status: "prospect",
notes: "Individual listings expire fast (404s) — check regularly.",
},
];
async function main() {
for (const j of jobs) {
const exists = await prisma.job.findFirst({ where: { title: j.title, company: j.company } });
if (exists) { console.log("skip:", j.title); continue; }
await prisma.job.create({ data: j });
console.log("added:", j.title);
}
}
main().finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { aiComplete, extractJson } from "@/lib/ai";
const SYSTEM = `You draft application-form answers for a job candidate.
Given the candidate profile/resume, the job, and a list of application questions,
reply with ONLY JSON: {"answers":[{"question":"...","answer":"..."}]}
Rules: truthful only (never invent experience); concise (1-4 sentences unless the question demands more); first person; specific to this job.`;
export async function POST(req: NextRequest) {
const { jobId, questions } = await req.json();
if (!jobId || !questions?.length) {
return NextResponse.json({ error: "jobId and questions[] required" }, { status: 400 });
}
const job = await prisma.job.findUnique({ where: { id: jobId } });
if (!job) return NextResponse.json({ error: "job not found" }, { status: 404 });
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
const base = await prisma.resume.findFirst({ where: { kind: "base" } });
const reply = await aiComplete({
type: "answers",
system: SYSTEM,
user: `PROFILE:\n${JSON.stringify(profile)}\n\nRESUME:\n${base?.markdown ?? ""}\n\nJOB: ${job.title} at ${job.company}\n${job.description ?? ""}\n\nQUESTIONS:\n${(questions as string[]).map((q, i) => `${i + 1}. ${q}`).join("\n")}`,
inputSummary: `${job.company} — ${questions.length} questions`,
});
try {
const parsed = extractJson<{ answers: { question: string; answer: string }[] }>(reply);
return NextResponse.json(parsed);
} catch {
return NextResponse.json({ error: "AI reply was not valid JSON", raw: reply }, { status: 502 });
}
}

View File

@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
type Resume = { id: string; name: string };
type Cover = { id: string; markdown: string; version: number };
type Job = { id: string; title: string; company: string; applyUrl: string | null; url: string | null; resumes: Resume[]; coverLetters: Cover[] };
type Profile = Record<string, string | null>;
type QA = { question: string; answer: string };
function CopyField({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
if (!value) return null;
return (
<div className="flex items-center gap-2 text-sm">
<span className="text-zinc-500 w-28 shrink-0">{label}</span>
<span className="truncate flex-1">{value}</span>
<button
onClick={() => { navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-0.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800">
{copied ? "✓" : "Copy"}
</button>
</div>
);
}
export default function ApplyWizard({ job, profile }: { job: Job; profile: Profile }) {
const router = useRouter();
const [resumeId, setResumeId] = useState(job.resumes[0]?.id ?? "");
const [questions, setQuestions] = useState("");
const [answers, setAnswers] = useState<QA[]>([]);
const [busy, setBusy] = useState(false);
const [confirmSubmit, setConfirmSubmit] = useState(false);
const [err, setErr] = useState("");
async function genAnswers() {
setBusy(true); setErr("");
try {
const qs = questions.split("\n").map((q) => q.trim()).filter(Boolean);
const res = await fetch("/api/ai/answers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId: job.id, questions: qs }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "failed");
setAnswers(data.answers);
} catch (e) { setErr(String(e)); } finally { setBusy(false); }
}
async function markSubmitted() {
setBusy(true);
await fetch("/api/applications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jobId: job.id,
method: "semi",
submittedAt: new Date().toISOString(),
answers: answers.length ? JSON.stringify(answers) : undefined,
log: `Applied via apply wizard; resume ${resumeId || "none"}`,
}),
});
router.push(`/jobs/${job.id}`);
}
return (
<div className="max-w-3xl mx-auto space-y-6">
<div>
<Link href={`/jobs/${job.id}`} className="text-sm text-zinc-500 hover:text-indigo-600">← Back to job</Link>
<h1 className="text-2xl font-bold mt-1">Apply: {job.title}</h1>
<div className="text-zinc-500">{job.company}</div>
</div>
{/* Step 1: open the application page */}
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-3">
<h2 className="font-semibold">1 · Open the application page</h2>
{job.applyUrl || job.url ? (
<a href={job.applyUrl ?? job.url ?? "#"} target="_blank"
className="inline-block rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 text-white px-4 py-2 text-sm font-semibold hover:from-indigo-500 hover:to-violet-500">
Open application page ↗
</a>
) : <p className="text-sm text-zinc-500">No apply URL saved — add one on the job page.</p>}
</section>
{/* Step 2: copy-ready profile fields */}
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-2">
<h2 className="font-semibold mb-2">2 · Copy your details into the form</h2>
<CopyField label="Full name" value={profile.fullName ?? ""} />
<CopyField label="Email" value={profile.email ?? ""} />
<CopyField label="Phone" value={profile.phone ?? ""} />
<CopyField label="Location" value={profile.location ?? ""} />
<CopyField label="LinkedIn" value={profile.linkedin ?? ""} />
<CopyField label="GitHub" value={profile.github ?? ""} />
<CopyField label="Portfolio" value={profile.portfolio ?? ""} />
<CopyField label="Headline" value={profile.headline ?? ""} />
<CopyField label="Summary" value={profile.summary ?? ""} />
</section>
{/* Step 3: resume + cover letter */}
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-3">
<h2 className="font-semibold">3 · Attach documents</h2>
{job.resumes.length ? (
<div className="text-sm space-y-2">
<label className="block">
<span className="text-zinc-500">Resume to use</span>
<select value={resumeId} onChange={(e) => setResumeId(e.target.value)}
className="mt-1 w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2">
{job.resumes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</label>
{resumeId && <Link href={`/resumes/${resumeId}`} className="text-indigo-600 hover:underline text-sm">Open resume → Print/PDF ↗</Link>}
</div>
) : (
<p className="text-sm text-zinc-500">No tailored resume yet — generate one from the job page (“AI Tailor Resume”).</p>
)}
{job.coverLetters.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-zinc-600 dark:text-zinc-300">Cover letter v{job.coverLetters[job.coverLetters.length - 1].version} (click to view/copy)</summary>
<div className="mt-2 rounded-xl border border-zinc-200 dark:border-zinc-800 p-3 whitespace-pre-wrap">{job.coverLetters[job.coverLetters.length - 1].markdown}</div>
<button onClick={() => navigator.clipboard.writeText(job.coverLetters[job.coverLetters.length - 1].markdown)}
className="mt-2 rounded border border-zinc-300 dark:border-zinc-700 px-3 py-1 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800">Copy letter</button>
</details>
)}
</section>
{/* Step 4: AI answers for form questions */}
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-3">
<h2 className="font-semibold">4 · Application questions (optional, AI-drafted)</h2>
<textarea value={questions} onChange={(e) => setQuestions(e.target.value)} rows={4}
placeholder={"Paste the form's questions, one per line:\nWhy do you want to work here?\nDescribe your experience with LLMs."}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 p-3 text-sm" />
<button onClick={genAnswers} disabled={busy || !questions.trim()}
className="rounded-xl bg-indigo-600 text-white px-4 py-2 text-sm font-semibold disabled:opacity-50 hover:bg-indigo-500">
{busy ? "Drafting…" : "🪄 Draft answers with AI"}
</button>
{err && <div className="text-sm text-rose-600">{err}</div>}
{answers.map((qa, i) => (
<div key={i} className="rounded-xl border border-zinc-200 dark:border-zinc-800 p-3 text-sm space-y-1">
<div className="font-medium">{qa.question}</div>
<div className="text-zinc-600 dark:text-zinc-300 whitespace-pre-wrap">{qa.answer}</div>
<button onClick={() => navigator.clipboard.writeText(qa.answer)}
className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-0.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800">Copy</button>
</div>
))}
</section>
{/* Step 5: confirm */}
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-3">
<h2 className="font-semibold">5 · Done?</h2>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={confirmSubmit} onChange={(e) => setConfirmSubmit(e.target.checked)} />
I submitted the application on the employer&apos;s site
</label>
<button onClick={markSubmitted} disabled={!confirmSubmit || busy}
className="rounded-xl bg-emerald-600 text-white px-5 py-2 text-sm font-semibold disabled:opacity-50 hover:bg-emerald-500">
✅ Record application
</button>
</section>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { prisma } from "@/lib/db";
import { notFound } from "next/navigation";
import ApplyWizard from "./apply-wizard";
export const dynamic = "force-dynamic";
export default async function ApplyPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const job = await prisma.job.findUnique({
where: { id },
include: { resumes: true, coverLetters: true },
});
if (!job) notFound();
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
return <ApplyWizard job={JSON.parse(JSON.stringify(job))} profile={JSON.parse(JSON.stringify(profile ?? {}))} />;
}

View File

@@ -163,6 +163,17 @@ export default function JobActions({ job }: { job: { id: string; status: string
)}
</button>
{/* Apply Wizard */}
<a
href={`/jobs/${job.id}/apply`}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 shadow-sm shadow-blue-500/20 active:scale-95 transition-all"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
<span>Apply Wizard</span>
</a>
{/* Mark Applied */}
<button
onClick={markApplied}

View File

@@ -13,6 +13,7 @@ const nav = [
{ href: "/resumes", label: "Resumes" },
{ href: "/applications", label: "Applications" },
{ href: "/profile", label: "Profile" },
{ href: "/report", label: "Report" },
];
export default function RootLayout({ children }: { children: React.ReactNode }) {

61
src/app/report/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export default async function ReportPage() {
const jobs = await prisma.job.findMany({ orderBy: [{ status: "asc" }, { company: "asc" }] });
const apps = await prisma.application.findMany({ include: { job: true }, orderBy: { createdAt: "desc" } });
const now = new Date().toISOString().slice(0, 10);
const applied = jobs.filter((j) => ["applied", "interviewing", "offer"].includes(j.status)).length;
return (
<div className="max-w-4xl mx-auto space-y-6 print:text-black">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Job Search Report</h1>
<p className="text-sm text-zinc-500">Generated {now} · {jobs.length} tracked · {applied} in application pipeline</p>
</div>
<button className="print:hidden rounded-xl border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm">
Use browser Print (Ctrl+P) to save as PDF
</button>
</div>
<section>
<h2 className="font-semibold mb-2">All jobs</h2>
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-zinc-800">
<table className="w-full text-sm bg-white dark:bg-zinc-900">
<thead className="text-left text-zinc-500 border-b border-zinc-200 dark:border-zinc-800">
<tr><th className="p-2">Company</th><th className="p-2">Title</th><th className="p-2">Pay</th><th className="p-2">PH?</th><th className="p-2">Status</th></tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr key={j.id} className="border-b border-zinc-100 dark:border-zinc-800">
<td className="p-2">{j.company}</td>
<td className="p-2">{j.title}</td>
<td className="p-2 whitespace-nowrap">
{j.payMin ? `${j.payCurrency === "PHP" ? "₱" : "$"}${j.payMin}${j.payMax && j.payMax !== j.payMin ? `–${j.payMax}` : ""}/${j.payPeriod === "hour" ? "hr" : j.payPeriod === "month" ? "mo" : "yr"}` : "—"}
</td>
<td className="p-2">{j.phEligibility}</td>
<td className="p-2">{j.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{apps.length > 0 && (
<section>
<h2 className="font-semibold mb-2">Applications</h2>
<ul className="text-sm space-y-1">
{apps.map((a) => (
<li key={a.id}>
{a.job.company} — {a.job.title} · {a.method} · {a.submittedAt ? a.submittedAt.toISOString().slice(0, 10) : "not submitted"}
</li>
))}
</ul>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import fs from "node:fs";
import type { Adapter } from "./index";
import { launchPage, fillIfPresent } from "./index";
/** DataAnnotation worker signup (app.dataannotation.tech/worker_signup). */
export const dataAnnotationAdapter: Adapter = {
name: "dataannotation",
matches: (url) => url.includes("dataannotation.tech"),
async run({ job, profile, submit }) {
const lines: string[] = [];
fs.mkdirSync("worker/out", { recursive: true });
const { browser, page } = await launchPage();
try {
await page.goto(job.applyUrl ?? job.url ?? "", { waitUntil: "domcontentloaded" });
lines.push(`opened ${page.url()}`);
await page.screenshot({ path: "worker/out/da-1-landing.png", fullPage: true });
const [first, ...rest] = (profile.fullName ?? "").split(" ");
await fillIfPresent(page, ['input[name="first_name"]', "#first_name"], first, lines);
await fillIfPresent(page, ['input[name="last_name"]', "#last_name"], rest.join(" "), lines);
await fillIfPresent(page, ['input[type="email"]', 'input[name="email"]'], profile.email, lines);
await page.screenshot({ path: "worker/out/da-2-filled.png", fullPage: true });
// CAPTCHA / verification: give the human 90s in the headed browser.
if (await page.locator('iframe[src*="captcha"], iframe[src*="turnstile"]').count()) {
lines.push("CAPTCHA detected — waiting 90s for manual solve");
await page.waitForTimeout(90_000);
}
if (!submit) {
lines.push("dry run: stopping before submit");
return { submitted: false, lines };
}
const btn = page.locator('button[type="submit"]').first();
if (!(await btn.count())) {
lines.push("no submit button found — aborting");
return { submitted: false, lines };
}
await btn.click();
await page.waitForLoadState("networkidle");
await page.screenshot({ path: "worker/out/da-3-submitted.png", fullPage: true });
lines.push(`submitted; landed on ${page.url()}`);
return { submitted: true, lines };
} finally {
await browser.close();
}
},
};

View File

@@ -0,0 +1,45 @@
import fs from "node:fs";
import type { Adapter } from "./index";
import { launchPage, fillIfPresent } from "./index";
/** Generic Greenhouse job-board application form (boards.greenhouse.io). */
export const genericGreenhouseAdapter: Adapter = {
name: "greenhouse",
matches: (url) => url.includes("greenhouse.io"),
async run({ job, profile, submit }) {
const lines: string[] = [];
fs.mkdirSync("worker/out", { recursive: true });
const { browser, page } = await launchPage();
try {
await page.goto(job.applyUrl ?? job.url ?? "", { waitUntil: "domcontentloaded" });
lines.push(`opened ${page.url()}`);
const [first, ...rest] = (profile.fullName ?? "").split(" ");
await fillIfPresent(page, ["#first_name"], first, lines);
await fillIfPresent(page, ["#last_name"], rest.join(" "), lines);
await fillIfPresent(page, ["#email"], profile.email, lines);
await fillIfPresent(page, ["#phone"], profile.phone, lines);
await fillIfPresent(page, ['input[name*="linkedin" i]', 'input[autocomplete="url"]'], profile.linkedin, lines);
await page.screenshot({ path: "worker/out/gh-filled.png", fullPage: true });
lines.push("NOTE: resume file upload must be done manually in the headed browser (60s pause)");
await page.waitForTimeout(60_000);
if (!submit) {
lines.push("dry run: stopping before submit");
return { submitted: false, lines };
}
const btn = page.locator('button[type="submit"], input[type="submit"]').first();
if (!(await btn.count())) {
lines.push("no submit button found — aborting");
return { submitted: false, lines };
}
await btn.click();
await page.waitForLoadState("networkidle");
await page.screenshot({ path: "worker/out/gh-submitted.png", fullPage: true });
lines.push(`submitted; landed on ${page.url()}`);
return { submitted: true, lines };
} finally {
await browser.close();
}
},
};

40
worker/adapters/index.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Job, Profile } from "@prisma/client";
export type RunResult = { submitted: boolean; lines: string[] };
export type AdapterCtx = { job: Job; profile: Profile; submit: boolean };
export type Adapter = {
name: string;
matches: (url: string) => boolean;
run: (ctx: AdapterCtx) => Promise<RunResult>;
};
/** Shared helper: launch a headed chromium page (human can intervene on CAPTCHAs). */
export async function launchPage() {
// Playwright is an optional dependency — installed only where the worker runs.
const { chromium } = await import("playwright");
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
return { browser, page };
}
export async function fillIfPresent(
page: import("playwright").Page,
selectors: string[],
value: string | null | undefined,
log: string[],
) {
if (!value) return;
for (const sel of selectors) {
const el = page.locator(sel).first();
if (await el.count()) {
await el.fill(value);
log.push(`filled ${sel}`);
return;
}
}
}
import { dataAnnotationAdapter } from "./dataannotation";
import { genericGreenhouseAdapter } from "./greenhouse";
export const adapters: Adapter[] = [dataAnnotationAdapter, genericGreenhouseAdapter];

58
worker/apply.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Auto-apply worker (Playwright).
*
* Usage:
* npm i -D playwright && npx playwright install chromium # one-time
* npx tsx worker/apply.ts <jobId> [--submit]
*
* Safety model:
* - WITHOUT --submit it fills the form, screenshots every step, and STOPS
* before the final submit (dry run). You review screenshots in worker/out/.
* - WITH --submit it still pauses on CAPTCHAs/Cloudflare (headed mode) and
* waits for you to solve them; it only clicks submit on adapter-known forms.
*/
import { PrismaClient } from "@prisma/client";
import { adapters } from "./adapters";
const prisma = new PrismaClient();
async function main() {
const [jobId, ...flags] = process.argv.slice(2);
const submit = flags.includes("--submit");
if (!jobId) {
console.error("usage: npx tsx worker/apply.ts <jobId> [--submit]");
process.exit(1);
}
const job = await prisma.job.findUnique({ where: { id: jobId } });
if (!job) throw new Error("job not found");
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
if (!profile) throw new Error("no profile — fill in /profile first");
const url = job.applyUrl ?? job.url;
if (!url) throw new Error("job has no applyUrl/url");
const adapter = adapters.find((a) => a.matches(url));
if (!adapter) {
console.error(`No adapter matches ${url}.\nKnown adapters: ${adapters.map((a) => a.name).join(", ")}\nUse the in-app Apply Wizard (semi-auto) instead.`);
process.exit(2);
}
console.log(`Adapter: ${adapter.name} · submit=${submit}`);
const log = await adapter.run({ job, profile, submit });
await prisma.application.create({
data: {
jobId,
method: "auto",
submittedAt: submit && log.submitted ? new Date() : null,
log: log.lines.join("\n"),
},
});
if (submit && log.submitted) {
await prisma.job.update({ where: { id: jobId }, data: { status: "applied" } });
}
console.log(log.submitted ? "SUBMITTED ✅" : "DRY RUN complete — review worker/out/ screenshots, re-run with --submit to finish.");
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());