saitejatirunagari Claude Opus 4.8 commited on
Commit
cf63aec
·
1 Parent(s): 42d2d15

fix: server-side JD extraction takes the job description only (all platforms)

Browse files

LinkedIn link runs polluted the resume with page chrome (cookie/privacy text,
recruiter name, similar-jobs/other companies, footer links -> 225 junk keywords).
Rewrote _extract_from_html: delete chrome sub-trees (_NOISE_SELECTOR), pick the
first most-specific JD container that reads like a real JD (_JD_PRIMARY, ordered
LinkedIn/Indeed/Greenhouse/Lever/Workday/Ashby/generic), and scrub residual
UI/legal/CTA lines (_LINE_NOISE). Verified JD kept, all chrome dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (2) hide show
  1. HISTORY.md +24 -0
  2. src/jd_from_url.py +98 -26
HISTORY.md CHANGED
@@ -4,6 +4,30 @@ A running log of everything built, fixed, and changed. Most recent first.
4
 
5
  ---
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## 2026-06-24 (AM4) — Telegram via Cloudflare relay (HF blocks Telegram egress)
8
 
9
  `GET /telegram/diag` confirmed it definitively: from inside the Space,
 
4
 
5
  ---
6
 
7
+ ## 2026-06-24 (AM5) — Clean server-side JD extraction (JD only, all platforms)
8
+
9
+ First successful Telegram run worked but the resume was polluted: the server-side
10
+ fetch of a LinkedIn link grabbed the WHOLE page, injecting 225 "keywords" incl.
11
+ cookie/privacy text, the recruiter's name, other companies' jobs ("similar jobs"),
12
+ and footer links. Root cause: `src/jd_from_url.py` used a crude densest-block grab.
13
+
14
+ Rewrote `_extract_from_html` to extract the JOB DESCRIPTION ONLY:
15
+ - Delete page-chrome sub-trees first (`_NOISE_SELECTOR`): nav/header/footer/aside,
16
+ and anything whose class/id hints similar/related/recommended/also-viewed/
17
+ more-jobs/cookie/consent/banner/signup/login/footer/nav.
18
+ - Pick the FIRST most-specific JD container (`_JD_PRIMARY`, ordered) that reads like
19
+ a real JD (length ≥ 200 + JD-signal) — LinkedIn `.show-more-less-html__markup` /
20
+ `#job-details`, Indeed `#jobDescriptionText`, Greenhouse `.job__description`,
21
+ Lever `[data-qa=job-description]`, Workday `[data-automation-id=jobPostingDescription]`,
22
+ Ashby, then generic — so we never swallow the surrounding page.
23
+ - Scrub residual UI/legal/CTA lines (`_LINE_NOISE`: cookie/privacy/sign in/seniority
24
+ level/employment type/people also viewed/…).
25
+ Verified on a synthetic LinkedIn page: keeps the JD (defense intelligence, roadmap,
26
+ stakeholder management, MVP delivery), drops all chrome (Meesho/Deltek/HappyFox,
27
+ recruiter name, cookie/privacy, footer job links). Works for company sites too.
28
+
29
+ ---
30
+
31
  ## 2026-06-24 (AM4) — Telegram via Cloudflare relay (HF blocks Telegram egress)
32
 
33
  `GET /telegram/diag` confirmed it definitively: from inside the Space,
src/jd_from_url.py CHANGED
@@ -30,17 +30,73 @@ _JD_SIGNALS = (
30
  # Hosts that need a real browser (JS render + anti-bot) rather than plain HTTP.
31
  _BROWSER_HOSTS = ("linkedin.", "indeed.", "glassdoor.", "naukri.", "ziprecruiter.")
32
 
33
- # Description containers across the major ATS / job platforms.
34
- _JD_SELECTORS = (
35
- "#job-details, article.jobs-description__container, .jobs-description__content, "
36
- ".jobs-box__html-content, #jobDescriptionText, "
37
- "[data-automation-id='jobPostingDescription'], [data-qa='job-description'], "
38
- ".job__description, .opening, .section-wrapper.page-full-width, "
39
- ".ashby-job-posting-right-pane, .job-sections, .jobAd, "
40
- "[class*='jobDescription'], [class*='job-description'], [class*='JobDescription'], "
41
- "[class*='posting'], [class*='description'], article, main, [role='main']"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  )
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  def _has_jd_signal(text: str) -> bool:
46
  low = (text or "").lower()
@@ -48,7 +104,11 @@ def _has_jd_signal(text: str) -> bool:
48
 
49
 
50
  def _extract_from_html(html: str) -> tuple[str, str]:
51
- """Return (page_title, best_description_text) from raw HTML."""
 
 
 
 
52
  from bs4 import BeautifulSoup
53
  soup = BeautifulSoup(html or "", "lxml")
54
 
@@ -56,29 +116,41 @@ def _extract_from_html(html: str) -> tuple[str, str]:
56
  if soup.title and soup.title.string:
57
  page_title = soup.title.string.strip()
58
 
59
- # Strip non-content chrome so it never wins the densest-block contest.
60
- for tag in soup(["script", "style", "nav", "header", "footer", "aside",
61
- "form", "button", "svg", "noscript", "iframe"]):
62
- tag.decompose()
63
-
64
- best = ""
65
  try:
66
- for el in soup.select(_JD_SELECTORS):
67
- txt = el.get_text(separator="\n", strip=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  if len(txt) > len(best):
69
  best = txt
70
- except Exception: # noqa: BLE001 - bad selector on exotic markup
71
- pass
72
 
73
- # Fallback: densest div/section block (bounded to avoid whole-page nav noise).
74
- if len(best) < 300:
75
  for el in soup.find_all(["div", "section"]):
76
- txt = el.get_text(separator="\n", strip=True)
77
- if 300 <= len(txt) < 20000 and len(txt) > len(best):
78
  best = txt
79
 
80
- # Collapse excessive blank lines.
81
- best = re.sub(r"\n{3,}", "\n\n", best).strip()
82
  return page_title, best
83
 
84
 
 
30
  # Hosts that need a real browser (JS render + anti-bot) rather than plain HTTP.
31
  _BROWSER_HOSTS = ("linkedin.", "indeed.", "glassdoor.", "naukri.", "ziprecruiter.")
32
 
33
+ # Ordered, MOST-SPECIFIC-FIRST JD containers per platform. We pick the first one
34
+ # that yields a real JD (length + JD-signal), so we grab ONLY the description —
35
+ # never the whole page (which on LinkedIn includes "similar jobs", the recruiter
36
+ # card, footer links, cookie banner, etc.).
37
+ _JD_PRIMARY = [
38
+ # LinkedIn (public job-view markup + authed app)
39
+ ".show-more-less-html__markup",
40
+ ".description__text .show-more-less-html__markup",
41
+ ".jobs-description__content .jobs-box__html-content",
42
+ ".jobs-description__content", ".jobs-box__html-content",
43
+ "#job-details", ".description__text",
44
+ # Indeed
45
+ "#jobDescriptionText",
46
+ # Greenhouse
47
+ ".job__description", "#content .body", "#content",
48
+ # Lever
49
+ "[data-qa='job-description']", ".section-wrapper.page-full-width",
50
+ # Workday
51
+ "[data-automation-id='jobPostingDescription']",
52
+ # Ashby / SmartRecruiters / Recruitee / generic ATS
53
+ ".ashby-job-posting-right-pane", ".jobAd", ".job-sections",
54
+ "[class*='jobDescription']", "[class*='job-description']",
55
+ "[class*='JobDescription']",
56
+ # last-resort generic page regions
57
+ "article", "main", "[role='main']",
58
+ ]
59
+
60
+ # Whole sub-trees to delete BEFORE extracting — page chrome that pollutes the JD
61
+ # (this is what put "cookie policy", recruiter names, and other companies' jobs
62
+ # into the resume). Matched on tag, role, or class/id substring.
63
+ _NOISE_SELECTOR = (
64
+ "nav, header, footer, aside, script, style, svg, noscript, iframe, form, button, "
65
+ "[role='navigation'], [role='banner'], [role='contentinfo'], [aria-hidden='true'], "
66
+ "[class*='similar'], [class*='related'], [class*='recommend'], [class*='also-viewed'], "
67
+ "[class*='alsoViewed'], [class*='people-also'], [class*='more-jobs'], [class*='moreJobs'], "
68
+ "[class*='footer'], [class*='nav-'], [class*='navbar'], [class*='header'], "
69
+ "[class*='cookie'], [class*='consent'], [class*='banner'], [class*='promo'], "
70
+ "[class*='signup'], [class*='sign-up'], [class*='signin'], [class*='sign-in'], "
71
+ "[class*='login'], [class*='subscribe'], [class*='advert'], [class*='newsletter'], "
72
+ "[id*='similar'], [id*='related'], [id*='footer'], [id*='cookie'], [id*='nav']"
73
  )
74
 
75
+ # Short label/legal/CTA lines that are page chrome, not JD content. A line is
76
+ # dropped if it CONTAINS one of these (case-insensitive).
77
+ _LINE_NOISE = (
78
+ "cookie policy", "privacy policy", "user agreement", "terms of service",
79
+ "by clicking", "you agree", "sign in", "join now", "create job alert",
80
+ "set alert", "get notified", "people also viewed", "similar jobs",
81
+ "be an early applicant", "easy apply", "show more", "show less",
82
+ "seniority level", "employment type", "job function", "referrals increase",
83
+ "see who you know", "help center", "explore", "first name", "last name",
84
+ "save job", "apply now", "report this job", "skip to", "follow company",
85
+ )
86
+
87
+
88
+ def _scrub_lines(text: str) -> str:
89
+ out = []
90
+ for ln in (text or "").splitlines():
91
+ low = ln.strip().lower()
92
+ if not low:
93
+ out.append(ln)
94
+ continue
95
+ if any(n in low for n in _LINE_NOISE):
96
+ continue
97
+ out.append(ln)
98
+ return "\n".join(out)
99
+
100
 
101
  def _has_jd_signal(text: str) -> bool:
102
  low = (text or "").lower()
 
104
 
105
 
106
  def _extract_from_html(html: str) -> tuple[str, str]:
107
+ """Return (page_title, JOB-DESCRIPTION-ONLY text) from raw HTML.
108
+
109
+ Strategy: delete page-chrome sub-trees, then pick the FIRST most-specific JD
110
+ container that reads like a real JD — so we capture only the description, not
111
+ the surrounding page (similar jobs, recruiter card, footer, cookie banner)."""
112
  from bs4 import BeautifulSoup
113
  soup = BeautifulSoup(html or "", "lxml")
114
 
 
116
  if soup.title and soup.title.string:
117
  page_title = soup.title.string.strip()
118
 
119
+ # 1. Delete chrome / noise sub-trees up front.
 
 
 
 
 
120
  try:
121
+ for el in soup.select(_NOISE_SELECTOR):
122
+ el.decompose()
123
+ except Exception: # noqa: BLE001 - exotic markup
124
+ for tag in soup(["script", "style", "nav", "header", "footer", "aside",
125
+ "form", "button", "svg", "noscript", "iframe"]):
126
+ tag.decompose()
127
+
128
+ def _clean_text(el) -> str:
129
+ return _scrub_lines(
130
+ re.sub(r"\n{3,}", "\n\n", el.get_text(separator="\n", strip=True))
131
+ ).strip()
132
+
133
+ # 2. Most-specific-first: return the first container that IS a real JD.
134
+ best = ""
135
+ for sel in _JD_PRIMARY:
136
+ try:
137
+ els = soup.select(sel)
138
+ except Exception: # noqa: BLE001
139
+ continue
140
+ for el in els:
141
+ txt = _clean_text(el)
142
+ if len(txt) >= 200 and _has_jd_signal(txt):
143
+ return page_title, txt # scoped, clean JD — done
144
  if len(txt) > len(best):
145
  best = txt
 
 
146
 
147
+ # 3. Fallback: densest div/section block (chrome already removed), bounded.
148
+ if len(best) < 250:
149
  for el in soup.find_all(["div", "section"]):
150
+ txt = _clean_text(el)
151
+ if 250 <= len(txt) < 20000 and len(txt) > len(best):
152
  best = txt
153
 
 
 
154
  return page_title, best
155
 
156