Spaces:
Sleeping
Sleeping
Commit ·
cf63aec
1
Parent(s): 42d2d15
fix: server-side JD extraction takes the job description only (all platforms)
Browse filesLinkedIn 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>
- HISTORY.md +24 -0
- 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 |
-
#
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
".
|
| 40 |
-
"
|
| 41 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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(
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 74 |
-
if len(best) <
|
| 75 |
for el in soup.find_all(["div", "section"]):
|
| 76 |
-
txt =
|
| 77 |
-
if
|
| 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 |
|