From dfd0f82ad8c57f8da84f3b0a36a14d475e5f2ad8 Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Thu, 9 Jul 2026 19:45:05 +0200 Subject: [PATCH] fix(linkedin): prevent false block on profile prose containing "challenge" LinkedIn profile imports were failing with `rate_limited_or_blocked` for users whose profile bio mentioned "challenge" or "checkpoint" (e.g. "I enjoy new challenges"). The DOM block detector greedily scanned the entire page `innerText` for those bare words and treated any match as a challenge interstitial. Root cause: `blockedReasonFromText` matched bare "challenge"/"checkpoint" against `document.body.innerText`, which fires on legitimate profile prose. Fix: - Remove the greedy bare-word check. Only challenge-page-specific phrases qualify as block signals ("security verification", "unusual activity", "verify your identity", "security check", "temporarily restricted"). - Add a `hasProfileSections` guard: text-based block signals are authoritative only when no profile sections (headline, experiences, education) were found. A page that yielded real sections is never treated as blocked on text alone. - Fall back to `textContent` when `innerText` is undrawn (jsdom / edge layouts) so the guard still works in tests. Real `/checkpoint/` and `/challenge/` URL redirects are still caught earlier by `classifyLinkedInUrl`, so genuine interstitials still block. Model: updated `linkedin-import.model.md` (source of truth) with the detection strategy and invariant #7. Tests: added `linkedin-dom.test.ts` (4 cases) covering challenge-in-bio, unusual-activity-in-profile, security-verification interstitial, and checkpoint-in-prose. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../profile-extractors/linkedin.extractor.ts | 31 +++++-- .../src/models/linkedin-import.model.md | 24 +++++ .../profile-extractors/linkedin-dom.test.ts | 92 +++++++++++++++++++ 3 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 apps/extension/tests/unit/profile-extractors/linkedin-dom.test.ts diff --git a/apps/extension/src/lib/shell/profile-extractors/linkedin.extractor.ts b/apps/extension/src/lib/shell/profile-extractors/linkedin.extractor.ts index 47741e9c..73b58e6d 100644 --- a/apps/extension/src/lib/shell/profile-extractors/linkedin.extractor.ts +++ b/apps/extension/src/lib/shell/profile-extractors/linkedin.extractor.ts @@ -75,9 +75,15 @@ function classifyLinkedInUrl(url: string): ProfileExtractorErrorCode | null { } } -function extractLinkedInProfileFromDom(): LinkedInDomProfileSnapshot { +export function extractLinkedInProfileFromDom(): LinkedInDomProfileSnapshot { const clean = (value: string | null | undefined): string => (value ?? '').replace(/\s+/g, ' ').trim(); + // Specific, non-greedy: the bare words "challenge"/"checkpoint" are NOT block + // signals — they appear in legitimate profile prose ("I enjoy new challenges") + // and previously caused false `rate_limited_or_blocked` errors. Only + // challenge-page phrases qualify, and only when no profile sections are + // present (see the `hasProfileSections` guard below). + // See src/models/linkedin-import.model.md. const blockedReasonFromText = (value: string): string | null => { const text = value.toLowerCase(); if (text.includes('security verification')) { @@ -89,12 +95,12 @@ function extractLinkedInProfileFromDom(): LinkedInDomProfileSnapshot { if (text.includes('verify your identity')) { return 'identity verification required'; } + if (text.includes('security check')) { + return 'security check required'; + } if (text.includes('temporarily restricted')) { return 'temporarily restricted session'; } - if (text.includes('checkpoint') || text.includes('challenge')) { - return 'linkedin checkpoint challenge'; - } return null; }; @@ -146,11 +152,12 @@ function extractLinkedInProfileFromDom(): LinkedInDomProfileSnapshot { const about = sectionByHeading(['about', 'infos', 'à propos']); const experience = sectionByHeading(['experience', 'expérience']); const education = sectionByHeading(['education', 'formation']); - const blockedReason = blockedReasonFromText(clean(document.body?.innerText)); const headline = text('.pv-text-details__left-panel .text-body-medium') || text('[data-generated-suggestion-target]') || text('main h1'); + const experiences = sectionItems(experience).map(parseExperience); + const educationItems = sectionItems(education).map(parseEducation); const summary = about ? clean((about as HTMLElement).innerText).replace(/^about|^à propos/i, '') : ''; @@ -162,17 +169,27 @@ function extractLinkedInProfileFromDom(): LinkedInDomProfileSnapshot { })) .filter((link) => link.url.includes('linkedin.com') || !link.url.includes('/feed/')); + // Defensive guard: text-based block signals are authoritative only when the + // page has no parseable profile sections. A real profile that mentions + // "challenge" or "unusual activity" in its bio must NOT be treated as a + // checkpoint interstitial. `innerText` is preferred (layout-aware, ignores + //