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:
Amruth Pillai
2026-08-17 22:32:32 +02:00
parent 7a14b0dfbc
commit da2f1f8244
6 changed files with 114 additions and 551 deletions
@@ -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}
+13 -138
View File
@@ -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 &amp;lt;Engineer&amp;gt;</h1>
<a class="topcard__org-name-link">Example &amp; 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 &lt;Engineer&gt;");
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",
);
});
});
+6 -347
View File
@@ -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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&#(\d+);/g, (_match, decimal: string) => numericEntity(decimal, 10))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_match, hexadecimal: string) => numericEntity(hexadecimal, 16))
.replace(/&amp;/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 12 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([
+8 -3
View File
@@ -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", () => {
+2 -7
View File
@@ -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);
}
});
});
+3 -13
View File
@@ -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",