Spaces:
Sleeping
feat: universal JD extraction + friendlier reload error message
Browse filesCompile confirmed OK via build log marker TECTONIC_REAL_WARMUP_OK. This
change touches only JD extraction and error messaging; the ATS pipeline,
background worker, and PDF download flow are untouched.
content.js: set scoped when a real description selector matched or when
JD-signal phrases are present, instead of depending on a fragile detail
pane container. Fixes LinkedIn collections/easy-apply and other variants.
Add JD_SIGNALS and _hasJdSignal so any platform whose text reads like a
JD is trusted and bypasses the listing-junk guard. Broaden generic
selectors to Workday, Lever, Greenhouse, Ashby and company sites. Bump
the lazy-load wait to 2.5s and add a one-time retry for slow SPA pages.
popup.js: detect the post-update context-invalidated error and tell the
user to refresh the page and run again, instead of the raw error.
manifest 1.6.1 to 1.7.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- HISTORY.md +34 -0
- extension/content.js +78 -29
- extension/manifest.json +1 -1
- extension/popup/popup.js +9 -1
|
@@ -4,6 +4,40 @@ A running log of everything built, fixed, and changed. Most recent first.
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## 2026-06-23 (PM14) — Fix Tectonic segfault (sanitize fontawesome OTF) + dedupe keywords
|
| 8 |
|
| 9 |
Build log (commit 65ea09d) gave the smoking gun: Tectonic SEGFAULTS loading
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## 2026-06-24 (AM1) — Compile confirmed OK; universal JD extraction + friendlier reload error
|
| 8 |
+
|
| 9 |
+
Build log for 29baf71 shows `TECTONIC_REAL_WARMUP_OK` (29.75 KiB PDF) — the
|
| 10 |
+
PDF-compile saga is resolved end-to-end. Remaining work was extension JD
|
| 11 |
+
extraction; the ATS pipeline, background worker, and PDF/download flow are left
|
| 12 |
+
untouched per request.
|
| 13 |
+
|
| 14 |
+
**Universal, robust JD extraction (`extension/content.js`):**
|
| 15 |
+
- `scoped` is now set whenever a real description selector matched (length ≥ 80)
|
| 16 |
+
OR the text carries JD-signal phrases — no longer dependent on a fragile
|
| 17 |
+
`detailPane` container match. Fixes LinkedIn `/collections/easy-apply/` and the
|
| 18 |
+
other LinkedIn URL variants that previously reported "not a job posting".
|
| 19 |
+
- New `JD_SIGNALS` + `_hasJdSignal()`: any platform whose extracted text reads
|
| 20 |
+
like a JD ("responsibilities", "about the job", "what you'll do", …) is trusted
|
| 21 |
+
and bypasses the listing-junk guard.
|
| 22 |
+
- `extractGenericJD` candidate set broadened to the major ATS/company platforms:
|
| 23 |
+
Workday (`[data-automation-id=jobPostingDescription]`), Lever
|
| 24 |
+
(`[data-qa=job-description]`), Greenhouse (`.job__description`), Ashby,
|
| 25 |
+
SmartRecruiters, plus `[class*=description]` — so it works on company career
|
| 26 |
+
sites, not just LinkedIn.
|
| 27 |
+
- SPA timing: lazy-load wait 1.2s → 2.5s, plus a ONE-TIME retry (900ms) in the
|
| 28 |
+
EXTRACT_JD handler when the first pass is weak — covers late-hydrating detail
|
| 29 |
+
panes on LinkedIn/Indeed/company SPAs.
|
| 30 |
+
|
| 31 |
+
**Friendlier "Extension context invalidated" (`extension/popup/popup.js`):**
|
| 32 |
+
That error only happens when the extension is updated while a page keeps the OLD
|
| 33 |
+
content script. handleResult now detects it (and "message port closed"/"receiving
|
| 34 |
+
end does not exist") and shows "The extension was just updated. Please refresh this
|
| 35 |
+
page (F5), then click Run again." instead of the raw error.
|
| 36 |
+
|
| 37 |
+
manifest 1.6.1 → 1.7.0.
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
## 2026-06-23 (PM14) — Fix Tectonic segfault (sanitize fontawesome OTF) + dedupe keywords
|
| 42 |
|
| 43 |
Build log (commit 65ea09d) gave the smoking gun: Tectonic SEGFAULTS loading
|
|
@@ -113,11 +113,23 @@ function scrubOverlayLines(text) {
|
|
| 113 |
* @returns {string}
|
| 114 |
*/
|
| 115 |
function extractGenericJD() {
|
| 116 |
-
// Candidate containers — ordered from most-specific to least-specific
|
|
|
|
|
|
|
| 117 |
const candidates = [
|
| 118 |
...document.querySelectorAll(
|
| 119 |
'article, main, [role="main"], .description, .job-description, ' +
|
| 120 |
-
'.job-details, .posting-description, #job-details, #jobDescriptionText'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
)
|
| 122 |
];
|
| 123 |
|
|
@@ -205,8 +217,9 @@ function parseDocTitle() {
|
|
| 205 |
async function fastJD(selectors, root) {
|
| 206 |
const now = firstMatch(selectors, root);
|
| 207 |
if (now.length > 50) return now;
|
| 208 |
-
//
|
| 209 |
-
|
|
|
|
| 210 |
if (el) {
|
| 211 |
const txt = (el.innerText || el.textContent || '').trim();
|
| 212 |
if (txt.length > 50) return txt;
|
|
@@ -273,16 +286,20 @@ async function extractLinkedIn() {
|
|
| 273 |
'.description__text',
|
| 274 |
], scope);
|
| 275 |
|
| 276 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
// pane (never the whole page, so the left job list can't win the contest).
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
scoped = true;
|
| 286 |
}
|
| 287 |
if (!jd_text || jd_text.length < 50) {
|
| 288 |
jd_text = extractGenericJD();
|
|
@@ -363,6 +380,12 @@ async function extractJD() {
|
|
| 363 |
result.job_title = result.job_title || dt.job_title;
|
| 364 |
result.company = result.company || dt.company;
|
| 365 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
return result;
|
| 367 |
}
|
| 368 |
|
|
@@ -480,6 +503,23 @@ const NON_JOB_TITLES = [
|
|
| 480 |
'collections', 'my jobs', 'saved jobs', 'recommended jobs', 'jobs for you',
|
| 481 |
];
|
| 482 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
/**
|
| 484 |
* True when the extracted result is a jobs LIST / search / recommendations page
|
| 485 |
* rather than a single job posting. Such pages yield junk "keywords" (e.g.
|
|
@@ -517,24 +557,33 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
| 517 |
}
|
| 518 |
|
| 519 |
if (msg.type === 'EXTRACT_JD') {
|
| 520 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
.then((result) => {
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
result
|
| 528 |
-
result.extraction_reason = 'not_a_job_posting';
|
| 529 |
-
result.jd_text = '';
|
| 530 |
-
} else if (!result.jd_text || result.jd_text.length < 80) {
|
| 531 |
-
result.extraction_failed = true;
|
| 532 |
-
result.jd_text = '';
|
| 533 |
-
}
|
| 534 |
-
if (result.jd_text && result.jd_text.length > 16000) {
|
| 535 |
-
result.jd_text = result.jd_text.slice(0, 16000);
|
| 536 |
}
|
| 537 |
-
sendResponse(result);
|
| 538 |
})
|
| 539 |
.catch((err) => {
|
| 540 |
sendResponse({
|
|
|
|
| 113 |
* @returns {string}
|
| 114 |
*/
|
| 115 |
function extractGenericJD() {
|
| 116 |
+
// Candidate containers — ordered from most-specific to least-specific. Covers
|
| 117 |
+
// the major ATS/company-site platforms so the extension works everywhere, not
|
| 118 |
+
// just LinkedIn: Greenhouse, Lever, Ashby, Workday, SmartRecruiters, etc.
|
| 119 |
const candidates = [
|
| 120 |
...document.querySelectorAll(
|
| 121 |
'article, main, [role="main"], .description, .job-description, ' +
|
| 122 |
+
'.job-details, .posting-description, #job-details, #jobDescriptionText, ' +
|
| 123 |
+
// Workday
|
| 124 |
+
'[data-automation-id="jobPostingDescription"], ' +
|
| 125 |
+
// Lever
|
| 126 |
+
'[data-qa="job-description"], .section-wrapper.page-full-width, ' +
|
| 127 |
+
// Greenhouse
|
| 128 |
+
'#content .job__description, .job__description, #app_body, ' +
|
| 129 |
+
// Ashby / SmartRecruiters / Recruitee / generic
|
| 130 |
+
'.ashby-job-posting-right-pane, .job-sections, .jobAd, ' +
|
| 131 |
+
'[class*="jobDescription"], [class*="job-description"], [class*="JobDescription"], ' +
|
| 132 |
+
'[class*="posting"], [class*="description"]'
|
| 133 |
)
|
| 134 |
];
|
| 135 |
|
|
|
|
| 217 |
async function fastJD(selectors, root) {
|
| 218 |
const now = firstMatch(selectors, root);
|
| 219 |
if (now.length > 50) return now;
|
| 220 |
+
// Wait on the most-likely selector for SPA lazy-load (description panes on
|
| 221 |
+
// LinkedIn/Indeed hydrate after navigation). 2.5s keeps it fast but reliable.
|
| 222 |
+
const el = await waitForElement(selectors[0], 2500, root);
|
| 223 |
if (el) {
|
| 224 |
const txt = (el.innerText || el.textContent || '').trim();
|
| 225 |
if (txt.length > 50) return txt;
|
|
|
|
| 286 |
'.description__text',
|
| 287 |
], scope);
|
| 288 |
|
| 289 |
+
// A description selector matched (`#job-details` is the stable anchor across
|
| 290 |
+
// ALL LinkedIn variants: /jobs/view, /collections, /collections/easy-apply,
|
| 291 |
+
// /search). That text is the posting, never the left list → trust it directly.
|
| 292 |
+
let scoped = !!(jd_text && jd_text.length >= 80);
|
| 293 |
+
|
| 294 |
+
// Selectors missed → scope the densest-block fallback to the right-hand detail
|
| 295 |
// pane (never the whole page, so the left job list can't win the contest).
|
| 296 |
+
if (!scoped) {
|
| 297 |
+
if ((!jd_text || jd_text.length < 50) && detailPane) {
|
| 298 |
+
jd_text = scrubOverlayLines(stripChrome(detailPane)).trim();
|
| 299 |
+
}
|
| 300 |
+
if (jd_text && jd_text.length >= 120 && _hasJdSignal(jd_text)) {
|
| 301 |
+
scoped = true; // detail-pane text that reads like a JD
|
| 302 |
+
}
|
|
|
|
| 303 |
}
|
| 304 |
if (!jd_text || jd_text.length < 50) {
|
| 305 |
jd_text = extractGenericJD();
|
|
|
|
| 380 |
result.job_title = result.job_title || dt.job_title;
|
| 381 |
result.company = result.company || dt.company;
|
| 382 |
}
|
| 383 |
+
// Any platform: if the extracted text reads like a real JD, mark it trusted so
|
| 384 |
+
// the listing-junk guard never blocks it (company sites, Greenhouse, Lever, …).
|
| 385 |
+
if (!result.scoped && _hasJdSignal(result.jd_text) &&
|
| 386 |
+
(result.jd_text || '').length >= 200) {
|
| 387 |
+
result.scoped = true;
|
| 388 |
+
}
|
| 389 |
return result;
|
| 390 |
}
|
| 391 |
|
|
|
|
| 503 |
'collections', 'my jobs', 'saved jobs', 'recommended jobs', 'jobs for you',
|
| 504 |
];
|
| 505 |
|
| 506 |
+
// Phrases that reliably indicate the text IS a job description (any platform).
|
| 507 |
+
// Used to accept a JD even on split-view pages where some "Easy Apply" chrome
|
| 508 |
+
// leaks in, so the listing-junk guard never blocks a real posting.
|
| 509 |
+
const JD_SIGNALS = [
|
| 510 |
+
'responsibilit', 'requirement', 'qualification', 'about the job',
|
| 511 |
+
'about the role', 'about the company', "what you'll do", 'what you will do',
|
| 512 |
+
'who you are', 'you will', 'we are looking', 'role overview', 'job description',
|
| 513 |
+
'preferred', 'minimum qualifications', 'key skills', 'what we', 'your role',
|
| 514 |
+
'in this role', 'nice to have', 'must have', 'day to day', 'day-to-day',
|
| 515 |
+
];
|
| 516 |
+
|
| 517 |
+
/** True if `text` contains language characteristic of a real job description. */
|
| 518 |
+
function _hasJdSignal(text) {
|
| 519 |
+
const l = (text || '').toLowerCase();
|
| 520 |
+
return JD_SIGNALS.some((s) => l.includes(s));
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
/**
|
| 524 |
* True when the extracted result is a jobs LIST / search / recommendations page
|
| 525 |
* rather than a single job posting. Such pages yield junk "keywords" (e.g.
|
|
|
|
| 557 |
}
|
| 558 |
|
| 559 |
if (msg.type === 'EXTRACT_JD') {
|
| 560 |
+
// Extract, and if the page clearly hadn't loaded the posting yet (short text
|
| 561 |
+
// and no JD signal), retry ONCE after a short delay — covers SPA navigations
|
| 562 |
+
// on LinkedIn/Indeed/company sites where the detail pane hydrates late.
|
| 563 |
+
const tryExtract = () => extractJD();
|
| 564 |
+
const finalize = (result) => {
|
| 565 |
+
if (!result.scoped && looksLikeListingJunk(result)) {
|
| 566 |
+
result.extraction_failed = true;
|
| 567 |
+
result.extraction_reason = 'not_a_job_posting';
|
| 568 |
+
result.jd_text = '';
|
| 569 |
+
} else if (!result.jd_text || result.jd_text.length < 80) {
|
| 570 |
+
result.extraction_failed = true;
|
| 571 |
+
result.jd_text = '';
|
| 572 |
+
}
|
| 573 |
+
if (result.jd_text && result.jd_text.length > 16000) {
|
| 574 |
+
result.jd_text = result.jd_text.slice(0, 16000);
|
| 575 |
+
}
|
| 576 |
+
sendResponse(result);
|
| 577 |
+
};
|
| 578 |
+
tryExtract()
|
| 579 |
.then((result) => {
|
| 580 |
+
const weak = (!result.jd_text || result.jd_text.length < 80)
|
| 581 |
+
&& !looksLikeListingJunk(result);
|
| 582 |
+
if (weak) {
|
| 583 |
+
setTimeout(() => tryExtract().then(finalize).catch(() => finalize(result)), 900);
|
| 584 |
+
} else {
|
| 585 |
+
finalize(result);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
}
|
|
|
|
| 587 |
})
|
| 588 |
.catch((err) => {
|
| 589 |
sendResponse({
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"manifest_version": 3,
|
| 3 |
"name": "ATS Resume Generator",
|
| 4 |
-
"version": "1.
|
| 5 |
"description": "Tailors your resume (PDF or LaTeX) to any job posting using the ATS pipeline.",
|
| 6 |
"permissions": [
|
| 7 |
"storage",
|
|
|
|
| 1 |
{
|
| 2 |
"manifest_version": 3,
|
| 3 |
"name": "ATS Resume Generator",
|
| 4 |
+
"version": "1.7.0",
|
| 5 |
"description": "Tailors your resume (PDF or LaTeX) to any job posting using the ATS pipeline.",
|
| 6 |
"permissions": [
|
| 7 |
"storage",
|
|
@@ -448,7 +448,15 @@ function handleResult(result) {
|
|
| 448 |
statusEl.innerHTML = '';
|
| 449 |
const line = document.createElement('div');
|
| 450 |
line.style.color = 'red';
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
statusEl.appendChild(line);
|
| 453 |
// Config errors → offer a one-click jump to Options.
|
| 454 |
if (/option|api url|token|not_configured|configure/i.test(msg)) {
|
|
|
|
| 448 |
statusEl.innerHTML = '';
|
| 449 |
const line = document.createElement('div');
|
| 450 |
line.style.color = 'red';
|
| 451 |
+
// "Extension context invalidated" happens when the extension was just
|
| 452 |
+
// updated/reloaded while this page kept the OLD content script. A page
|
| 453 |
+
// refresh re-injects the new one. Give that exact instruction instead of
|
| 454 |
+
// the raw error.
|
| 455 |
+
if (/context invalidated|message port closed|receiving end does not exist/i.test(msg)) {
|
| 456 |
+
line.textContent = 'The extension was just updated. Please refresh this page (F5), then click Run again.';
|
| 457 |
+
} else {
|
| 458 |
+
line.textContent = `Error: ${msg}`;
|
| 459 |
+
}
|
| 460 |
statusEl.appendChild(line);
|
| 461 |
// Config errors → offer a one-click jump to Options.
|
| 462 |
if (/option|api url|token|not_configured|configure/i.test(msg)) {
|