mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-20 05:21:44 +10:00
feat: support editing AI provider models in the UI and auto-fill LinkedIn job postings (#3259)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
co-authored by
Amruth Pillai
parent
3266066826
commit
47349e7ab3
@@ -125,6 +125,38 @@ describe("fetchJobPostingText", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("autofillInputSchema", () => {
|
||||
|
||||
@@ -18,8 +18,21 @@ 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) {
|
||||
const provider = await aiProvidersService.getDefaultRunnable({ userId });
|
||||
@@ -179,8 +192,167 @@ function requestJobPosting(parsed: URL, address: ValidatedAddress, signal: Abort
|
||||
});
|
||||
}
|
||||
|
||||
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 await 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 {
|
||||
|
||||
Reference in New Issue
Block a user