linkdin_automate / src /extractor.py
swaraj
Initial commit
c291af8
Raw
History Blame Contribute Delete
12.9 kB
"""Extraction logic -- three-tier LLM strategy:
Tier 1 (Remote gpt-4o-compatible) -- REMOTE_LLM_BASE_URL set in .env
Supports text extraction + image OCR via vision API.
Tier 2 (Local Ollama) -- text extraction only.
Tier 3 (Rules only) -- regex email/URL, always runs as baseline.
The app is fully functional with rules-only; each LLM tier improves
role / company / location / experience accuracy.
"""
from __future__ import annotations
import base64
import json
import os
import re
from typing import Optional
import requests
from dotenv import load_dotenv
from src.models import HiringPost
load_dotenv()
_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
_LINKEDIN_URL_RE = re.compile(r"https?://(?:www\.)?linkedin\.com/[^\s\"'>]+")
# New unified LLM settings (take priority when set)
_LLM_BASE_URL = os.getenv("LLM_BASE_URL", "").rstrip("/")
_LLM_MODEL = os.getenv("LLM_MODEL", "")
_LLM_API_KEY = os.getenv("OLLAMA_API_KEY", "NO_API_KEY")
_LLM_TEMP = float(os.getenv("LLM_TEMPERATURE", "0.1"))
# Legacy settings (used only when LLM_BASE_URL is not set)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
REMOTE_BASE_URL = os.getenv("REMOTE_LLM_BASE_URL", "").rstrip("/")
REMOTE_API_KEY = os.getenv("REMOTE_LLM_API_KEY", "NO_API_KEY")
REMOTE_MODEL = os.getenv("REMOTE_LLM_MODEL", "gpt-4o")
# Resolved active endpoint: new LLM_BASE_URL wins over legacy REMOTE_LLM_BASE_URL
_ACTIVE_BASE_URL = _LLM_BASE_URL or REMOTE_BASE_URL
_ACTIVE_MODEL = _LLM_MODEL or REMOTE_MODEL
_ACTIVE_API_KEY = _LLM_API_KEY if _LLM_BASE_URL else REMOTE_API_KEY
# Vision/OCR is only available on models that explicitly support it.
# We enable it only when REMOTE_LLM_MODEL=gpt-4o is set (legacy remote path).
_OCR_ENABLED = bool(REMOTE_BASE_URL) and "gpt-4o" in REMOTE_MODEL.lower()
_EXTRACT_PROMPT = """\
You are an information extraction assistant. Extract job details from the LinkedIn post below.
Return ONLY valid JSON with these keys (use empty string if not found):
role, company, location, experience, hr_mail
Rules:
- role: exact job title mentioned (e.g. "GenAI Engineer", "ML Lead")
- company: hiring company name
- location: city/state (e.g. "Pune", "Bengaluru", "Remote")
- experience: years of experience required — return as simple format like "3", "5", "7-10", etc (e.g. "3-5 years" → return "3-5", "5+ yrs" → return "5+", "Fresher" → return "Fresher", "")
- hr_mail: ALL email addresses present in the post, comma-separated (any type — recruiter, HR, personal, company), else ""
Post:
---
{text}
---
JSON:"""
_OCR_PROMPT = """\
This image is from a LinkedIn hiring post. Extract all readable text from it.
Return plain text only, no JSON, no markdown."""
def _extract_min_experience_years(experience_str: str) -> str:
"""Extract minimum experience value as integer from experience string.
Examples:
"3-5 years" → "3"
"5+ years" → "5"
"Fresher" → "0"
"2-3 yrs" → "2"
"" → ""
"""
if not experience_str or not isinstance(experience_str, str):
return ""
exp_lower = experience_str.lower().strip()
# Handle "fresher" case
if "fresher" in exp_lower:
return "0"
# Extract the first number found in the string
numbers = re.findall(r'\d+', exp_lower)
if numbers:
return numbers[0] # Return the first (minimum) number found
return ""
# ── public API ───────────────────────────────────────────────────────────────────
def extract(raw: dict, keywords: list[str]) -> HiringPost:
"""Return a HiringPost built from rules + best available LLM."""
text = raw.get("raw_text", "")
post_link = raw.get("post_link", "")
source = raw.get("source", "")
posted_at_raw = raw.get("posted_at_raw", "")
images: list[str] = raw.get("images", []) # base64 data URIs or http URLs
# --- rules layer (always runs) ---
emails = list(dict.fromkeys(e.lower() for e in _EMAIL_RE.findall(text))) # unique, ordered
hr_mail = ", ".join(emails) if emails else ""
if not post_link:
# Prefer actual post/activity URLs over profile/company URLs
for pattern in [
r"https?://(?:www\.)?linkedin\.com/(?:posts|feed/update)/[^\s\"'<>]+",
r"https?://(?:www\.)?linkedin\.com/[^\s\"'<>]+",
]:
m = re.search(pattern, text)
if m:
post_link = m.group(0).split("?")[0]
break
matched = [kw for kw in keywords if kw.lower() in text.lower()]
confidence = len(matched) / max(len(keywords), 1)
post = HiringPost(
hr_mail=hr_mail,
post_link=post_link,
source=source,
posted_at_raw=posted_at_raw,
matched_keywords=matched,
confidence=confidence,
raw_text=text[:5000],
)
# --- OCR images (only when REMOTE_LLM_MODEL=gpt-4o is configured) ---
if images and _OCR_ENABLED:
ocr_texts = [_remote_ocr(img) for img in images[:3]] # cap at 3 images
extra_text = "\n".join(t for t in ocr_texts if t)
if extra_text:
text = text + "\n[OCR from images]\n" + extra_text
post.raw_text = (post.raw_text + "\n" + extra_text)[:1000]
if not post.hr_mail:
ocr_emails = list(dict.fromkeys(e.lower() for e in _EMAIL_RE.findall(extra_text)))
if ocr_emails:
post.hr_mail = ", ".join(ocr_emails)
# --- LLM enhancement: Tier 1 active endpoint, Tier 2 local ollama ---
llm_data = _remote_extract(text) if _ACTIVE_BASE_URL else None
if llm_data is None:
llm_data = _ollama_extract(text)
if llm_data:
post.role = llm_data.get("role", "") or post.role
post.company = llm_data.get("company", "") or post.company
post.location = llm_data.get("location", "") or post.location
post.experience = llm_data.get("experience", "")
if not post.hr_mail:
mail = llm_data.get("hr_mail", "")
if mail:
# LLM may return one or several emails; validate via the model
post.hr_mail = mail.strip()
post.confidence = min(post.confidence + 0.3, 1.0)
# Extract minimum experience years as integer for filtering
post.experience = _extract_min_experience_years(post.experience)
# parse posted_at — prefer explicit posted_at_raw, fall back to scanning raw_text
post.posted_at = _parse_timestamp(posted_at_raw) or _scan_text_for_timestamp(text)
return post
# ── private helpers ───────────────────────────────────────────────────────────────
def _remote_extract(text: str) -> Optional[dict]:
"""Call the active OpenAI-compatible /chat/completions endpoint."""
try:
prompt = _EXTRACT_PROMPT.format(text=text[:2000])
resp = requests.post(
f"{_ACTIVE_BASE_URL}/chat/completions",
headers={
"Authorization": f"Bearer {_ACTIVE_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": _ACTIVE_MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": _LLM_TEMP,
},
timeout=60,
)
resp.raise_for_status()
content: str = resp.json()["choices"][0]["message"]["content"]
content = re.sub(r"```(?:json)?", "", content).strip("` \n")
parsed = json.loads(content)
# LLM sometimes returns a list — take the first element
if isinstance(parsed, list):
parsed = parsed[0] if parsed else {}
if not isinstance(parsed, dict):
return None
return parsed
except Exception as e:
# Log for debugging but don't crash
print(f"[extractor] LLM call failed: {e}")
return None
def _remote_ocr(image_data: str) -> str:
"""Send a base64 image or URL to the active vision model and return text."""
if not _ACTIVE_BASE_URL:
return ""
try:
if image_data.startswith("http"):
image_part = {"type": "image_url", "image_url": {"url": image_data}}
else:
if not image_data.startswith("data:"):
image_data = f"data:image/png;base64,{image_data}"
image_part = {"type": "image_url", "image_url": {"url": image_data}}
resp = requests.post(
f"{_ACTIVE_BASE_URL}/chat/completions",
headers={
"Authorization": f"Bearer {_ACTIVE_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": _ACTIVE_MODEL,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": _OCR_PROMPT},
image_part,
],
}],
"temperature": 0.0,
},
timeout=45,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"].strip()
except Exception:
return ""
def _ollama_extract(text: str) -> Optional[dict]:
"""Call Ollama /api/generate and return parsed JSON or None on any error."""
try:
prompt = _EXTRACT_PROMPT.format(text=text[:2000])
resp = requests.post(
f"{OLLAMA_BASE_URL}/api/generate",
json={"model": OLLAMA_MODEL, "prompt": prompt, "stream": False},
timeout=30,
)
resp.raise_for_status()
response_text: str = resp.json().get("response", "")
# strip any markdown code fences
response_text = re.sub(r"```(?:json)?", "", response_text).strip("` \n")
return json.loads(response_text)
except Exception:
return None
def _parse_timestamp(raw: str):
"""Best-effort parse of relative or absolute timestamps found in posts."""
if not raw:
return None
from datetime import datetime, timedelta, timezone
now = datetime.now(tz=timezone.utc)
raw_l = raw.lower().strip()
patterns = [
(re.compile(r"(\d+)\s*h(?:ours?)?(?:\s*ago)?"), lambda m: now - timedelta(hours=int(m.group(1)))),
(re.compile(r"(\d+)\s*m(?:in(?:utes?)?)?(?:\s*ago)?"), lambda m: now - timedelta(minutes=int(m.group(1)))),
(re.compile(r"(\d+)\s*d(?:ays?)?(?:\s*ago)?"), lambda m: now - timedelta(days=int(m.group(1)))),
(re.compile(r"just now|moments? ago"), lambda m: now),
]
for pattern, calc in patterns:
m = pattern.search(raw_l)
if m:
try:
return calc(m)
except Exception:
pass
# Try ISO / common date strings
for fmt in ("%Y-%m-%d", "%d %b %Y", "%b %d, %Y", "%d/%m/%Y"):
try:
return datetime.strptime(raw.strip(), fmt).replace(tzinfo=timezone.utc)
except ValueError:
pass
return None
def _scan_text_for_timestamp(text: str):
"""Scan raw post text for common timestamp phrases and return a datetime."""
_TS_PATTERNS = [
re.compile(r"(\d+)\s*hour[s]?\s*ago", re.IGNORECASE),
re.compile(r"(\d+)\s*hr[s]?\s*ago", re.IGNORECASE),
re.compile(r"(\d+)\s*h\s*ago", re.IGNORECASE),
re.compile(r"(\d+)\s*min(?:ute)?[s]?\s*ago", re.IGNORECASE),
re.compile(r"(\d+)\s*day[s]?\s*ago", re.IGNORECASE),
re.compile(r"just now", re.IGNORECASE),
re.compile(r"moments?\s*ago", re.IGNORECASE),
re.compile(r"posted\s+(\d+)\s*h(?:ours?)?", re.IGNORECASE),
re.compile(r"posted\s+(\d+)\s*day[s]?", re.IGNORECASE),
]
from datetime import datetime, timedelta, timezone
now = datetime.now(tz=timezone.utc)
for pat in _TS_PATTERNS:
m = pat.search(text)
if m:
try:
n = int(m.group(1)) if m.lastindex and m.lastindex >= 1 else 0
pattern_str = pat.pattern.lower()
if "hour" in pattern_str or r"\bh\b" in pattern_str or "hr" in pattern_str:
return now - timedelta(hours=n)
if "min" in pattern_str:
return now - timedelta(minutes=n)
if "day" in pattern_str:
return now - timedelta(days=n)
return now # just now / moments ago
except Exception:
pass
return None