malloc-app / app /services /scraper.py
sudhanshu388's picture
Upload 64 files
0d374d2 verified
Raw
History Blame Contribute Delete
5.75 kB
"""
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 ""