You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

72 lines
2.8 KiB
Python

"""LinkedIn Jobs adapter — scroll to load all cards on first page only."""
from __future__ import annotations
from loguru import logger
from gimme_job.adapters.base import ManifestDrivenAdapter
from gimme_job.adapters.registry import register
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
def _deduplicate_title(title: str) -> str:
"""Remove LinkedIn's accessibility text duplication.
LinkedIn renders job titles as:
<span aria-hidden>Title</span>
<span class="accessible">Title with verification</span>
inner_text() returns both, so we get "Title Title with verification".
Strategy: if the title starts with a repeated prefix word-by-word, keep the shorter part.
"""
words = title.split()
n = len(words)
# Try splitting at each midpoint from 1 to n//2
for split in range(1, n // 2 + 1):
prefix = words[:split]
rest = words[split:]
# Exact full duplication: "Foo Bar Foo Bar"
if rest == prefix:
return " ".join(prefix)
# Prefix repeats at start of rest: "Foo Foo with verification"
if rest[:split] == prefix:
return " ".join(prefix)
return title
@register("linkedin")
class LinkedInAdapter(ManifestDrivenAdapter):
def collect_cards(self, page, manifest) -> list[RawJobCard]:
"""Scroll each card into view to trigger lazy-loading, then extract."""
self._scroll_to_load(page)
return super().collect_cards(page, manifest)
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
# Fix relative URLs → absolute
if raw.url and raw.url.startswith("/"):
raw = raw.model_copy(update={"url": "https://www.linkedin.com" + raw.url})
# Fix duplicated titles caused by LinkedIn's accessibility span
# e.g. "Orthodontist Orthodontist with verification" → "Orthodontist"
# e.g. "LOCUM Dentist ... LOCUM Dentist ..." → "LOCUM Dentist ..."
if raw.title:
raw = raw.model_copy(update={"title": _deduplicate_title(raw.title)})
return super().normalize(raw)
@staticmethod
def _scroll_to_load(page) -> None:
"""Scroll each card into view one by one so LinkedIn renders all occluded items."""
try:
count = page.evaluate(
"document.querySelectorAll('li[data-occludable-job-id]').length"
)
for i in range(count):
page.evaluate(f"""() => {{
const cards = document.querySelectorAll('li[data-occludable-job-id]');
if (cards[{i}]) cards[{i}].scrollIntoView({{behavior: 'instant', block: 'center'}});
}}""")
page.wait_for_timeout(120)
logger.debug(f"[linkedin] Scrolled through {count} cards")
except Exception as e:
logger.debug(f"[linkedin] scroll_to_load failed: {e}")