saitejatirunagari Claude Opus 4.8 commited on
Commit
16f3e9f
·
1 Parent(s): 29baf71

feat: universal JD extraction + friendlier reload error message

Browse files

Compile 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 CHANGED
@@ -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
extension/content.js CHANGED
@@ -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
- // Single short wait on the most-likely selector for SPA lazy-load.
209
- const el = await waitForElement(selectors[0], 1200, root);
 
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
- // If selectors missed, fall back to the densest text block WITHIN the detail
 
 
 
 
 
277
  // pane (never the whole page, so the left job list can't win the contest).
278
- let scoped = false;
279
- if ((!jd_text || jd_text.length < 50) && detailPane) {
280
- jd_text = scrubOverlayLines(stripChrome(detailPane)).trim();
281
- }
282
- if (jd_text && jd_text.length >= 80 && detailPane) {
283
- // We extracted from a real detail pane trust it; skip the listing-junk
284
- // heuristic (a detail pane legitimately contains an "Easy Apply" button).
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
- extractJD()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  .then((result) => {
522
- // Reject jobs LIST / search / recommendation pages — they have no single
523
- // JD and produce junk keywords. Surface a clear reason so the popup tells
524
- // the user to open a specific posting (or paste the JD) instead of
525
- // silently generating a 0%-coverage resume from page chrome.
526
- if (!result.scoped && looksLikeListingJunk(result)) {
527
- result.extraction_failed = true;
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({
extension/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "manifest_version": 3,
3
  "name": "ATS Resume Generator",
4
- "version": "1.6.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",
extension/popup/popup.js CHANGED
@@ -448,7 +448,15 @@ function handleResult(result) {
448
  statusEl.innerHTML = '';
449
  const line = document.createElement('div');
450
  line.style.color = 'red';
451
- line.textContent = `Error: ${msg}`;
 
 
 
 
 
 
 
 
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)) {