diff --git a/gimme_job/adapters/linkedin.py b/gimme_job/adapters/linkedin.py
index f92a6e8..8a0cd89 100644
--- a/gimme_job/adapters/linkedin.py
+++ b/gimme_job/adapters/linkedin.py
@@ -8,6 +8,30 @@ 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:
+ Title
+ Title with verification
+ 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):
@@ -17,6 +41,16 @@ class LinkedInAdapter(ManifestDrivenAdapter):
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