Spaces:
Running on Zero
Running on Zero
| """ | |
| Job Posting URL Scraper & Content Extractor. | |
| Extracts job title, company name, and full description text from LinkedIn, | |
| Indeed, Greenhouse, Lever, Workday, and generic career postings. | |
| """ | |
| import re | |
| import logging | |
| from pydantic import BaseModel | |
| import httpx | |
| from bs4 import BeautifulSoup | |
| logger = logging.getLogger("memora.scraper") | |
| USER_AGENT = ( | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" | |
| ) | |
| class ScrapedJob(BaseModel): | |
| url: str | |
| company: str = "" | |
| role_title: str = "" | |
| description: str = "" | |
| success: bool = True | |
| message: str = "" | |
| def clean_text(html_content: str) -> str: | |
| soup = BeautifulSoup(html_content, "html.parser") | |
| # Remove script, style, nav, footer, header tags | |
| for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "svg"]): | |
| tag.decompose() | |
| text = soup.get_text(separator="\n") | |
| # Clean excessive whitespace | |
| lines = [line.strip() for line in text.splitlines() if line.strip()] | |
| return "\n".join(lines) | |
| def parse_job_html(url: str, html_content: str) -> ScrapedJob: | |
| soup = BeautifulSoup(html_content, "html.parser") | |
| # 1. Meta / OpenGraph detection | |
| og_title = "" | |
| og_desc = "" | |
| og_site = "" | |
| tag = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"}) | |
| if tag and tag.get("content"): | |
| og_title = tag["content"].strip() | |
| tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"}) | |
| if tag and tag.get("content"): | |
| og_desc = tag["content"].strip() | |
| tag = soup.find("meta", property="og:site_name") | |
| if tag and tag.get("content"): | |
| og_site = tag["content"].strip() | |
| page_title = soup.title.string.strip() if soup.title and soup.title.string else "" | |
| # Heuristic parsing for role and company from title (e.g. "Software Engineer at Google" or "Google - Software Engineer") | |
| title_to_parse = og_title or page_title | |
| role_title = "" | |
| company = og_site | |
| if title_to_parse: | |
| # Common title patterns: "Role at Company", "Company - Role", "Role | Company" | |
| match = re.search(r"^(.*?)\s+(?:at|@)\s+(.*?)(?:\s*[-|•·].*)?$", title_to_parse, re.IGNORECASE) | |
| if match: | |
| role_title = match.group(1).strip() | |
| if not company: | |
| company = match.group(2).strip() | |
| else: | |
| parts = re.split(r"\s*[-|•·]\s*", title_to_parse) | |
| if len(parts) >= 2: | |
| # E.g. "Google - Senior AI Engineer" or "Senior AI Engineer - Google" | |
| role_title = parts[0].strip() | |
| if not company: | |
| company = parts[1].strip() | |
| else: | |
| role_title = title_to_parse | |
| # 2. Extract main job content | |
| # Target specific known job boards containers if present | |
| content_container = ( | |
| soup.find("div", class_=re.compile(r"description|job-details|job-description|posting-requirements", re.I)) | |
| or soup.find("section", class_=re.compile(r"description|job-details|job-description", re.I)) | |
| or soup.find("main") | |
| or soup.find("article") | |
| or soup.body | |
| ) | |
| if content_container: | |
| for tag in content_container(["script", "style", "nav", "footer", "header", "noscript"]): | |
| tag.decompose() | |
| description = content_container.get_text(separator="\n").strip() | |
| description = "\n".join([line.strip() for line in description.splitlines() if line.strip()]) | |
| else: | |
| description = clean_text(html_content) | |
| # Fallback to meta description if content is too short | |
| if len(description) < 100 and og_desc: | |
| description = f"{og_title}\n\n{og_desc}" | |
| # Trim to reasonable context length for LLM (e.g., 10,000 chars) | |
| if len(description) > 10000: | |
| description = description[:10000] + "\n...[truncated]" | |
| return ScrapedJob( | |
| url=url, | |
| company=company or "Unknown Company", | |
| role_title=role_title or "Job Position", | |
| description=description, | |
| success=bool(description and len(description) >= 50), | |
| message="Successfully extracted job posting" if len(description) >= 50 else "Limited text extracted. You can paste the description manually below." | |
| ) | |
| def fetch_job_from_url(url: str) -> ScrapedJob: | |
| """ | |
| Fetches job details from a given URL via HTTP. | |
| """ | |
| headers = { | |
| "User-Agent": USER_AGENT, | |
| "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", | |
| "Accept-Language": "en-US,en;q=0.9", | |
| "Sec-Fetch-Dest": "document", | |
| "Sec-Fetch-Mode": "navigate", | |
| "Sec-Fetch-Site": "none", | |
| } | |
| try: | |
| with httpx.Client(timeout=15.0, follow_redirects=True, headers=headers) as client: | |
| resp = client.get(url) | |
| resp.raise_for_status() | |
| return parse_job_html(url, resp.text) | |
| except Exception as exc: | |
| logger.warning("Scraping failed for %s: %s", url, exc) | |
| return ScrapedJob( | |
| url=url, | |
| company="", | |
| role_title="", | |
| description="", | |
| success=False, | |
| message=f"Could not automatically fetch page ({type(exc).__name__}). Please paste the job description text manually below." | |
| ) | |
| def fetch_url_content(url: str) -> str: | |
| """ | |
| Convenience function that fetches URL and returns formatted text content. | |
| """ | |
| job = fetch_job_from_url(url) | |
| if job.description: | |
| prefix = f"🏢 Company: {job.company}\n💼 Role: {job.role_title}\n\n" if job.company else "" | |
| return f"{prefix}{job.description}".strip() | |
| return "" | |