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

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];