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:
48
worker/adapters/dataannotation.ts
Normal file
48
worker/adapters/dataannotation.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
45
worker/adapters/greenhouse.ts
Normal file
45
worker/adapters/greenhouse.ts
Normal 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
40
worker/adapters/index.ts
Normal 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
58
worker/apply.ts
Normal 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());
|
||||
Reference in New Issue
Block a user