mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-19 21:11:45 +10:00
refactor(applications): autofill from a pasted posting instead of a URL
Fetching an arbitrary job URL server side meant owning SSRF defence, redirect and size limits, and per-site scraping quirks. The autofill tool now takes only pasted text, so the URL input, the fetch path and its MCP annotation are gone. The sheet gates the call behind a tested AI provider and a minimum paste length so a stray snippet does not spend an AI call.
This commit is contained in:
@@ -6,8 +6,8 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { SparkleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@reactive-resume/ui/components/sheet";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
@@ -27,6 +28,10 @@ import { FileAttachmentField } from "./file-attachment-field";
|
||||
|
||||
// Preset source suggestions surfaced via a <datalist>; the field itself stays free-text.
|
||||
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company Website", "Referral", "Recruiter", "Other"];
|
||||
// Mirrors the server-side cap on `applications.ai.autofill`.
|
||||
const MAX_JOB_DESCRIPTION_CHARS = 20_000;
|
||||
// ponytail: a paste shorter than this is a snippet, not a posting — don't burn an AI call on it.
|
||||
const MIN_AUTOFILL_CHARS = 200;
|
||||
const todayInputValue = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const emptyForm = () => ({
|
||||
@@ -99,6 +104,10 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
|
||||
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
|
||||
|
||||
// Same gate as the rest of the AI surfaces: at least one enabled provider that tested green.
|
||||
const { data: providers } = useQuery(orpc.aiProviders.list.queryOptions());
|
||||
const aiEnabled = providers?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
@@ -117,11 +126,11 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
orpc.applications.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application added to your pipeline.`);
|
||||
toast.add({ type: "success", description: t`Application added to your pipeline.` });
|
||||
setForm(emptyForm());
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't add the application. Please try again.`),
|
||||
onError: () => toast.add({ type: "error", description: t`Couldn't add the application. Please try again.` }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -129,10 +138,10 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
orpc.applications.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application updated.`);
|
||||
toast.add({ type: "success", description: t`Application updated.` });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't save your changes. Please try again.`),
|
||||
onError: () => toast.add({ type: "error", description: t`Couldn't save your changes. Please try again.` }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -145,14 +154,19 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
role: result.role || prev.role,
|
||||
location: result.location || prev.location,
|
||||
salary: result.salary || prev.salary,
|
||||
jobDescription: result.jobDescription || prev.jobDescription,
|
||||
}));
|
||||
toast.success(t`Filled in what we could from the posting.`);
|
||||
toast.add({ type: "success", description: t`Filled in what we could from the posting.` });
|
||||
},
|
||||
onError: (error) => toast.error(error.message || t`Auto-fill failed. Paste the description instead.`),
|
||||
onError: (error) => toast.add({ type: "error", description: error.message || t`Auto-fill failed.` }),
|
||||
}),
|
||||
);
|
||||
|
||||
const runAutofill = (jobDescription: string) => {
|
||||
const posting = jobDescription.trim();
|
||||
if (posting.length < MIN_AUTOFILL_CHARS || autofill.isPending) return;
|
||||
autofill.mutate({ jobDescription: posting.slice(0, MAX_JOB_DESCRIPTION_CHARS) });
|
||||
};
|
||||
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
const submit = () => {
|
||||
@@ -195,32 +209,57 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4 [&>*]:shrink-0">
|
||||
{/* AI job-posting autofill: extracts the fields below from a posting URL. */}
|
||||
{!isEditing && (
|
||||
<div className="rounded-lg border border-border border-dashed p-3">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>Paste a job posting URL</Trans>
|
||||
</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<Input
|
||||
value={form.sourceUrl}
|
||||
placeholder="https://…"
|
||||
onChange={(event) => set("sourceUrl", event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!form.sourceUrl.trim() || autofill.isPending}
|
||||
onClick={() => autofill.mutate({ sourceUrl: form.sourceUrl.trim() })}
|
||||
>
|
||||
<SparkleIcon />
|
||||
{autofill.isPending ? <Trans>Reading…</Trans> : <Trans>Auto-fill</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
<Trans>Let AI read the posting and fill the fields below.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
{/* Pasted job description: stored with the application and used for every AI action.
|
||||
Collapsed by default so the form stays short; hidden entirely when AI is off. */}
|
||||
{aiEnabled && (
|
||||
<Accordion className="rounded-lg border border-border border-dashed px-3">
|
||||
<AccordionItem value="job-description">
|
||||
<AccordionTrigger>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<SparkleIcon className="text-primary" />
|
||||
<Trans>Job description</Trans>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Copy the entire job description from the posting and paste it below. We'll fill in the fields for
|
||||
you and keep the text with this application for match scoring and tailoring.
|
||||
</Trans>
|
||||
</p>
|
||||
<Textarea
|
||||
// Fixed height: the accordion panel measures its content once, so a textarea that
|
||||
// grew with the pasted text would overflow the clipped panel.
|
||||
className="field-sizing-fixed h-40"
|
||||
value={form.jobDescription}
|
||||
rows={8}
|
||||
maxLength={MAX_JOB_DESCRIPTION_CHARS}
|
||||
placeholder={t`Paste the full job description here…`}
|
||||
onChange={(event) => set("jobDescription", event.target.value)}
|
||||
onPaste={(event) => runAutofill(event.clipboardData.getData("text"))}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{autofill.isPending ? (
|
||||
<Trans>Reading the posting…</Trans>
|
||||
) : (
|
||||
<Trans>Pasting fills the fields automatically.</Trans>
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={form.jobDescription.trim().length < MIN_AUTOFILL_CHARS || autofill.isPending}
|
||||
onClick={() => runAutofill(form.jobDescription)}
|
||||
>
|
||||
<SparkleIcon />
|
||||
<Trans>Fill fields</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Field label={t`Company`} required>
|
||||
@@ -273,6 +312,15 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t`Job posting link`}>
|
||||
<Input
|
||||
type="url"
|
||||
value={form.sourceUrl}
|
||||
placeholder="https://…"
|
||||
onChange={(event) => set("sourceUrl", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isEditing && (
|
||||
<Field label={t`Stage date`}>
|
||||
<Input
|
||||
@@ -327,15 +375,6 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t`Job description`}>
|
||||
<Textarea
|
||||
value={form.jobDescription}
|
||||
rows={3}
|
||||
placeholder={t`Paste the posting — powers AI match scoring and tailoring.`}
|
||||
onChange={(event) => set("jobDescription", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t`Notes`}>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const lookupMock = vi.hoisted(() => vi.fn());
|
||||
const requestMock = vi.hoisted(() => vi.fn());
|
||||
const protectedProcedureMock = vi.hoisted(() => {
|
||||
const chain = {
|
||||
route: vi.fn(() => chain),
|
||||
@@ -14,9 +11,6 @@ const protectedProcedureMock = vi.hoisted(() => {
|
||||
return chain;
|
||||
});
|
||||
|
||||
vi.mock("node:dns/promises", () => ({ lookup: lookupMock }));
|
||||
vi.mock("node:http", () => ({ request: requestMock }));
|
||||
vi.mock("node:https", () => ({ request: requestMock }));
|
||||
vi.mock("ai", () => ({ generateText: vi.fn() }));
|
||||
vi.mock("../../context", () => ({ protectedProcedure: protectedProcedureMock }));
|
||||
vi.mock("../../middleware/rate-limit", () => ({ aiRequestRateLimit: vi.fn() }));
|
||||
@@ -27,140 +21,21 @@ vi.mock("./service", () => ({
|
||||
applicationService: { getById: vi.fn(), setAiResult: vi.fn(), update: vi.fn(), addNote: vi.fn() },
|
||||
}));
|
||||
|
||||
const { autofillInputSchema, fetchJobPostingText } = await import("./ai");
|
||||
|
||||
function mockRequestResponse(statusCode: number, headers: Record<string, string>, body = "") {
|
||||
requestMock.mockImplementation((_url, _options, callback) => {
|
||||
const response = Readable.from(body ? [Buffer.from(body)] : []) as Readable & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
response.statusCode = statusCode;
|
||||
response.headers = headers;
|
||||
callback(response);
|
||||
return { on: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
}
|
||||
|
||||
describe("fetchJobPostingText", () => {
|
||||
beforeEach(() => {
|
||||
lookupMock.mockReset();
|
||||
requestMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects private IP URLs before fetching", async () => {
|
||||
await expect(fetchJobPostingText("http://127.0.0.1/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects hostnames that resolve to private addresses", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts DNS lookup failures to bad requests", async () => {
|
||||
lookupMock.mockRejectedValue(new Error("ENOTFOUND"));
|
||||
|
||||
await expect(fetchJobPostingText("https://missing.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects redirects instead of following them", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
mockRequestResponse(302, { location: "/" });
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects oversized pages before reading the body", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
mockRequestResponse(200, { "content-length": "200001", "content-type": "text/html" }, "ignored");
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("pins the request lookup to the validated public address", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
let pinnedAddress: string | undefined;
|
||||
requestMock.mockImplementation((_url, options, callback) => {
|
||||
options.lookup("jobs.example", {}, (_error: Error | null, address: string) => {
|
||||
pinnedAddress = address;
|
||||
});
|
||||
const response = Readable.from([
|
||||
Buffer.from("<html><script>nope</script><body><h1>Senior Engineer</h1></body></html>"),
|
||||
]) as Readable & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
response.statusCode = 200;
|
||||
response.headers = { "content-type": "text/html" };
|
||||
callback(response);
|
||||
return { on: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).resolves.toBe("Senior Engineer");
|
||||
expect(pinnedAddress).toBe("93.184.216.34");
|
||||
});
|
||||
|
||||
it("supports Node lookup calls with all=true", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
let lookupResult: unknown;
|
||||
requestMock.mockImplementation((_url, options, callback) => {
|
||||
options.lookup("jobs.example", { all: true }, (_error: Error | null, result: unknown) => {
|
||||
lookupResult = result;
|
||||
});
|
||||
const response = Readable.from([Buffer.from("<html><body>Senior Engineer</body></html>")]) as Readable & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
response.statusCode = 200;
|
||||
response.headers = { "content-type": "text/html" };
|
||||
callback(response);
|
||||
return { on: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).resolves.toBe("Senior Engineer");
|
||||
expect(lookupResult).toEqual([{ address: "93.184.216.34", family: 4 }]);
|
||||
});
|
||||
|
||||
it("reads LinkedIn job URLs through the public guest endpoint", async () => {
|
||||
requestMock.mockImplementation((url, _options, callback) => {
|
||||
expect(String(url)).toBe("https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/4426311357");
|
||||
const response = Readable.from([
|
||||
Buffer.from(`
|
||||
<h1 class="top-card-layout__title">Senior &lt;Engineer&gt;</h1>
|
||||
<a class="topcard__org-name-link">Example & Co</a>
|
||||
<span class="topcard__flavor topcard__flavor--bullet">Remote</span>
|
||||
<div class="show-more-less-html__markup"><p>Build useful products.</p><p>Work with TypeScript.</p></div>
|
||||
<h3 class="description__job-criteria-subheader">Employment type</h3>
|
||||
<span class="description__job-criteria-text">Full-time</span>
|
||||
`),
|
||||
]) as Readable & { statusCode: number; headers: Record<string, string> };
|
||||
response.statusCode = 200;
|
||||
response.headers = { "content-type": "text/html" };
|
||||
callback(response);
|
||||
return { on: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
const posting = await fetchJobPostingText("https://www.linkedin.com/jobs/view/senior-engineer-4426311357");
|
||||
expect(posting).toContain("Company: Example & Co");
|
||||
expect(posting).toContain("Senior <Engineer>");
|
||||
expect(posting).toContain("Build useful products.");
|
||||
});
|
||||
|
||||
it("rejects LinkedIn URLs without a job posting ID", async () => {
|
||||
await expect(fetchJobPostingText("https://www.linkedin.com/jobs/search/?keywords=engineer")).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
const { autofillInputSchema } = await import("./ai");
|
||||
|
||||
describe("autofillInputSchema", () => {
|
||||
it("rejects oversized pasted job descriptions", () => {
|
||||
expect(() => autofillInputSchema.parse({ jobDescription: "x".repeat(20_001) })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects blank pasted job descriptions", () => {
|
||||
expect(() => autofillInputSchema.parse({ jobDescription: " " })).toThrow();
|
||||
expect(() => autofillInputSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a pasted posting", () => {
|
||||
expect(autofillInputSchema.parse({ jobDescription: " Senior Engineer at Acme " }).jobDescription).toBe(
|
||||
"Senior Engineer at Acme",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import type { IncomingHttpHeaders, IncomingMessage } from "node:http";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { generateText } from "ai";
|
||||
import z from "zod";
|
||||
@@ -15,23 +10,7 @@ import { resumeService } from "../resume/service";
|
||||
import { applicationService } from "./service";
|
||||
|
||||
const reserved = { tags: ["Applications", "AI"] } as const;
|
||||
const MAX_JOB_POSTING_BYTES = 200_000;
|
||||
const MAX_PASTED_JOB_DESCRIPTION_CHARS = 20_000;
|
||||
const JOB_POSTING_CONTENT_TYPES = ["text/html", "text/plain", "application/xhtml+xml", "application/xml", "text/xml"];
|
||||
const LINKEDIN_JOB_POSTING_URL = "https://www.linkedin.com/jobs-guest/jobs/api/jobPosting";
|
||||
const LINKEDIN_FETCH_RETRIES = 3;
|
||||
type ValidatedAddress = { address: string; family: 4 | 6 };
|
||||
|
||||
type LinkedInJobPosting = {
|
||||
title: string;
|
||||
company: string | null;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
seniority: string | null;
|
||||
employmentType: string | null;
|
||||
jobFunction: string | null;
|
||||
industries: string | null;
|
||||
};
|
||||
|
||||
// Resolve the user's default (tested + enabled) AI provider into a ready model instance.
|
||||
async function resolveModel(userId: string) {
|
||||
@@ -68,331 +47,15 @@ async function generatePlainText(model: Awaited<ReturnType<typeof resolveModel>>
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string) {
|
||||
const parts = address.split(".").map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
||||
const [a = 0, b = 0] = parts;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
a >= 224
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateAddress(address: string) {
|
||||
if (address.startsWith("::ffff:")) return isPrivateIPv4(address.slice(7));
|
||||
if (isIP(address) === 4) return isPrivateIPv4(address);
|
||||
|
||||
const normalized = address.toLowerCase();
|
||||
return (
|
||||
normalized === "::1" ||
|
||||
normalized === "::" ||
|
||||
normalized.startsWith("fc") ||
|
||||
normalized.startsWith("fd") ||
|
||||
normalized.startsWith("fe8") ||
|
||||
normalized.startsWith("fe9") ||
|
||||
normalized.startsWith("fea") ||
|
||||
normalized.startsWith("feb")
|
||||
);
|
||||
}
|
||||
|
||||
async function assertPublicHttpUrl(url: string): Promise<{ parsed: URL; address: ValidatedAddress }> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "The job posting URL is invalid." });
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Only http(s) job posting URLs are supported." });
|
||||
}
|
||||
if (parsed.hostname.toLowerCase() === "localhost") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Local job posting URLs are not supported." });
|
||||
}
|
||||
|
||||
const addresses = isIP(parsed.hostname)
|
||||
? [{ address: parsed.hostname, family: isIP(parsed.hostname) as 4 | 6 }]
|
||||
: ((await lookup(parsed.hostname, { all: true, verbatim: true })) as ValidatedAddress[]);
|
||||
if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Private or local job posting URLs are not supported." });
|
||||
}
|
||||
|
||||
const [address] = addresses;
|
||||
if (!address) throw new ORPCError("BAD_REQUEST", { message: "The job posting URL could not be resolved." });
|
||||
return { parsed, address };
|
||||
}
|
||||
|
||||
function headerValue(headers: IncomingHttpHeaders, name: string) {
|
||||
const value = headers[name];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
async function readTextResponse(response: IncomingMessage) {
|
||||
const contentType = headerValue(response.headers, "content-type")?.split(";")[0]?.trim().toLowerCase();
|
||||
if (contentType && !JOB_POSTING_CONTENT_TYPES.includes(contentType)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "The job posting URL did not return a text page." });
|
||||
}
|
||||
|
||||
const contentLength = Number(headerValue(response.headers, "content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_JOB_POSTING_BYTES) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "The job posting page is too large. Paste the description instead.",
|
||||
});
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
|
||||
for await (const value of response) {
|
||||
const chunk = typeof value === "string" ? Buffer.from(value) : value;
|
||||
total += chunk.byteLength;
|
||||
if (total > MAX_JOB_POSTING_BYTES) {
|
||||
response.destroy();
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "The job posting page is too large. Paste the description instead.",
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
function requestJobPosting(parsed: URL, address: ValidatedAddress, signal: AbortSignal) {
|
||||
return new Promise<IncomingMessage>((resolve, reject) => {
|
||||
const client = parsed.protocol === "https:" ? https : http;
|
||||
const request = client.request(
|
||||
parsed,
|
||||
{
|
||||
signal,
|
||||
headers: {
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
},
|
||||
lookup: (_hostname, options, callback) => {
|
||||
if (options.all) {
|
||||
callback(null, [address]);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, address.address, address.family);
|
||||
},
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function decodeLinkedInHtml(text: string) {
|
||||
const numericEntity = (value: string, radix: number) => {
|
||||
const codePoint = Number.parseInt(value, radix);
|
||||
return codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : "";
|
||||
};
|
||||
|
||||
return text
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&#(\d+);/g, (_match, decimal: string) => numericEntity(decimal, 10))
|
||||
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_match, hexadecimal: string) => numericEntity(hexadecimal, 16))
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function cleanLinkedInHtml(html: string) {
|
||||
return decodeLinkedInHtml(
|
||||
html
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function linkedInJobId(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (hostname !== "linkedin.com" && !hostname.endsWith(".linkedin.com")) return null;
|
||||
|
||||
const slug = parsed.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
return slug.match(/(?:^|-)(\d{6,})$/)?.[1] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLinkedInUrl(url: string) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return hostname === "linkedin.com" || hostname.endsWith(".linkedin.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLinkedInJobPosting(html: string): LinkedInJobPosting {
|
||||
const title = html.match(/class="(?:top-card-layout__title|topcard__title)[^"]*"[^>]*>([\s\S]*?)<\/h[12]>/i)?.[1];
|
||||
const organization = html.match(/class="topcard__org-name-link[^"]*"[^>]*>([\s\S]*?)<\/a>/i)?.[1];
|
||||
const location = html.match(/class="topcard__flavor topcard__flavor--bullet"[^>]*>([\s\S]*?)<\/span>/i)?.[1];
|
||||
const description = html.match(
|
||||
/class="(?:show-more-less-html__markup|description__text[^"]*)"[^>]*>([\s\S]*?)<\/div>/i,
|
||||
)?.[1];
|
||||
const criteria: Record<string, string> = {};
|
||||
const criteriaPattern =
|
||||
/class="description__job-criteria-subheader"[^>]*>([\s\S]*?)<\/h3>[\s\S]*?class="description__job-criteria-text[^"]*"[^>]*>([\s\S]*?)<\/span>/gi;
|
||||
|
||||
for (const match of html.matchAll(criteriaPattern)) {
|
||||
const [label, value] = [match[1], match[2]];
|
||||
if (!label || !value) continue;
|
||||
criteria[cleanLinkedInHtml(label).toLowerCase()] = cleanLinkedInHtml(value);
|
||||
}
|
||||
|
||||
return {
|
||||
title: title ? cleanLinkedInHtml(title) : "",
|
||||
company: organization ? cleanLinkedInHtml(organization) || null : null,
|
||||
location: location ? cleanLinkedInHtml(location) || null : null,
|
||||
description: description
|
||||
? decodeLinkedInHtml(
|
||||
description
|
||||
.replace(/<\s*br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/(p|li|ul|ol|div|h\d)>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, " "),
|
||||
)
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim() || null
|
||||
: null,
|
||||
seniority: criteria["seniority level"] ?? null,
|
||||
employmentType: criteria["employment type"] ?? null,
|
||||
jobFunction: criteria["job function"] ?? null,
|
||||
industries: criteria.industries ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function linkedInPostingText(posting: LinkedInJobPosting) {
|
||||
return [
|
||||
posting.title,
|
||||
posting.company ? `Company: ${posting.company}` : "",
|
||||
posting.location ? `Location: ${posting.location}` : "",
|
||||
posting.seniority ? `Seniority: ${posting.seniority}` : "",
|
||||
posting.employmentType ? `Employment type: ${posting.employmentType}` : "",
|
||||
posting.jobFunction ? `Job function: ${posting.jobFunction}` : "",
|
||||
posting.industries ? `Industries: ${posting.industries}` : "",
|
||||
posting.description ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.slice(0, 8_000);
|
||||
}
|
||||
|
||||
async function fetchLinkedInJobPostingText(jobId: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
for (let attempt = 0; attempt <= LINKEDIN_FETCH_RETRIES; attempt++) {
|
||||
const response = await new Promise<IncomingMessage>((resolve, reject) => {
|
||||
const request = https.request(
|
||||
new URL(`${LINKEDIN_JOB_POSTING_URL}/${jobId}`),
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
},
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
|
||||
if (response.statusCode === 429 || (response.statusCode && response.statusCode >= 500)) {
|
||||
response.resume();
|
||||
if (attempt === LINKEDIN_FETCH_RETRIES) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "LinkedIn is temporarily unavailable. Try again later." });
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
if (response.statusCode === 404) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "The LinkedIn job posting was not found." });
|
||||
}
|
||||
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Couldn't fetch the LinkedIn job posting." });
|
||||
}
|
||||
|
||||
const posting = parseLinkedInJobPosting(await readTextResponse(response));
|
||||
const text = linkedInPostingText(posting);
|
||||
if (!text) throw new ORPCError("BAD_REQUEST", { message: "Couldn't read the LinkedIn job posting." });
|
||||
return text;
|
||||
}
|
||||
throw new ORPCError("BAD_REQUEST", { message: "LinkedIn is temporarily unavailable. Try again later." });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort fetch + strip of a job posting page. http(s) only, size/time capped.
|
||||
export async function fetchJobPostingText(url: string): Promise<string> {
|
||||
const jobId = linkedInJobId(url);
|
||||
if (jobId) return fetchLinkedInJobPostingText(jobId);
|
||||
if (isLinkedInUrl(url)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "The LinkedIn job URL must include a job posting ID." });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const { parsed, address } = await assertPublicHttpUrl(url);
|
||||
const response = await requestJobPosting(parsed, address, controller.signal);
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Redirecting job posting URLs are not supported." });
|
||||
}
|
||||
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: `Couldn't fetch the posting (HTTP ${response.statusCode ?? "unknown"}).`,
|
||||
});
|
||||
}
|
||||
const html = await readTextResponse(response);
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 8_000);
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError) throw error;
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Couldn't read the job posting. Paste the description instead." });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const autofillOutput = z.object({
|
||||
company: z.string(),
|
||||
role: z.string(),
|
||||
location: z.string(),
|
||||
salary: z.string(),
|
||||
jobDescription: z.string(),
|
||||
});
|
||||
|
||||
export const autofillInputSchema = z.object({
|
||||
sourceUrl: z.string().optional(),
|
||||
jobDescription: z.string().max(MAX_PASTED_JOB_DESCRIPTION_CHARS).optional(),
|
||||
jobDescription: z.string().trim().min(1).max(MAX_PASTED_JOB_DESCRIPTION_CHARS),
|
||||
});
|
||||
|
||||
// Tolerant of LLM variance: clamp the score, cap the lists by slicing rather than rejecting.
|
||||
@@ -412,7 +75,8 @@ const matchScoreOutput = z.object({
|
||||
});
|
||||
|
||||
export const aiRouter = {
|
||||
// Extract structured fields from a pasted job description or a posting URL.
|
||||
// Extract structured fields from a pasted job description. The posting text itself is stored
|
||||
// verbatim on the application, so nothing here fetches or scrapes a URL.
|
||||
autofill: protectedProcedure
|
||||
.route({ method: "POST", path: "/applications/ai/autofill", operationId: "aiAutofillApplication", ...reserved })
|
||||
.input(autofillInputSchema)
|
||||
@@ -420,15 +84,10 @@ export const aiRouter = {
|
||||
.output(autofillOutput)
|
||||
.handler(async ({ context, input }) => {
|
||||
const model = await resolveModel(context.user.id);
|
||||
const posting =
|
||||
input.jobDescription?.trim() || (input.sourceUrl ? await fetchJobPostingText(input.sourceUrl) : "");
|
||||
if (!posting) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Provide a job posting URL or paste the description." });
|
||||
}
|
||||
|
||||
return generateJson(
|
||||
model,
|
||||
`Extract the following fields from this job posting. Return ONLY JSON with keys company, role, location, salary, jobDescription. Use an empty string for anything not stated. "jobDescription" should be a concise 1–2 paragraph plain-text summary of the responsibilities and requirements.\n\nJOB POSTING:\n${posting}`,
|
||||
`Extract the following fields from this job posting. Return ONLY JSON with keys company, role, location, salary. Use an empty string for anything not stated.\n\nJOB POSTING:\n${input.jobDescription}`,
|
||||
autofillOutput,
|
||||
);
|
||||
}),
|
||||
@@ -449,7 +108,7 @@ export const aiRouter = {
|
||||
if (!application.resumeId)
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Link a resume to this application first." });
|
||||
if (!application.jobDescription) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Add a job description (via Auto-fill or Edit) first." });
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Paste the job description into this application first." });
|
||||
}
|
||||
|
||||
const [model, resume] = await Promise.all([
|
||||
@@ -517,7 +176,7 @@ export const aiRouter = {
|
||||
if (!application.resumeId)
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Link a resume to this application first." });
|
||||
if (!application.jobDescription) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Add a job description (via Auto-fill or Edit) first." });
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Paste the job description into this application first." });
|
||||
}
|
||||
|
||||
const [model, resume] = await Promise.all([
|
||||
|
||||
@@ -94,16 +94,21 @@ describe("buildMcpServerCard", () => {
|
||||
|
||||
it("accepts only http/https application source URLs", () => {
|
||||
const create = TOOL_META[MCP_TOOL_NAME.createApplication].inputSchema;
|
||||
const autofill = TOOL_META[MCP_TOOL_NAME.autofillApplicationFromJob].inputSchema;
|
||||
|
||||
expect(create.safeParse({ company: "Acme", role: "Engineer", sourceUrl: "https://example.com/job" }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(autofill.safeParse({ sourceUrl: "http://example.com/job" }).success).toBe(true);
|
||||
const invalidUrl = create.safeParse({ company: "Acme", role: "Engineer", sourceUrl: "ftp://example.com/job" });
|
||||
expect(invalidUrl.success).toBe(false);
|
||||
if (!invalidUrl.success) expect(invalidUrl.error.issues[0]?.message).toBe("URL must use http or https.");
|
||||
expect(autofill.safeParse({ sourceUrl: "javascript:alert(1)" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a pasted job posting to autofill an application", () => {
|
||||
const autofill = TOOL_META[MCP_TOOL_NAME.autofillApplicationFromJob].inputSchema;
|
||||
|
||||
expect(autofill.safeParse({ jobDescription: "Senior Engineer at Acme" }).success).toBe(true);
|
||||
expect(autofill.safeParse({ sourceUrl: "https://example.com/job" }).success).toBe(false);
|
||||
expect(autofill.safeParse({ jobDescription: " " }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects application document payloads above 10MB decoded", () => {
|
||||
|
||||
@@ -102,14 +102,9 @@ describe("tool annotations", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("marks only job-posting autofill as open-world", () => {
|
||||
expect(TOOL_META[MCP_TOOL_NAME.autofillApplicationFromJob].annotations.openWorldHint).toBe(true);
|
||||
});
|
||||
|
||||
it("declares no tools as open-world by default", () => {
|
||||
it("declares no tools as open-world", () => {
|
||||
for (const [name, { annotations }] of Object.entries(TOOL_META)) {
|
||||
if (name === MCP_TOOL_NAME.autofillApplicationFromJob) continue;
|
||||
expect(annotations.openWorldHint).toBe(false);
|
||||
expect(annotations.openWorldHint, name).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,12 +21,6 @@ const READ_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const READ_OPEN_WORLD_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
};
|
||||
const WRITE_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
@@ -458,13 +452,9 @@ export const TOOL_META = {
|
||||
},
|
||||
[T.autofillApplicationFromJob]: {
|
||||
title: "Autofill Application From Job",
|
||||
description:
|
||||
"Use AI to extract company, role, location, salary, and job description from a job URL or pasted posting.",
|
||||
inputSchema: z.object({
|
||||
sourceUrl: httpUrlSchema.optional(),
|
||||
jobDescription: z.string().max(20_000).optional(),
|
||||
}),
|
||||
annotations: READ_OPEN_WORLD_NON_IDEMPOTENT,
|
||||
description: "Use AI to extract company, role, location, and salary from a pasted job posting.",
|
||||
inputSchema: z.object({ jobDescription: z.string().trim().min(1).max(20_000) }),
|
||||
annotations: READ_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.scoreApplicationMatch]: {
|
||||
title: "Score Application Match",
|
||||
|
||||
Reference in New Issue
Block a user