Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand All @@ -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;
};
Expand Down Expand Up @@ -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, '')
: '';
Expand All @@ -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
// <script>/<style>); `textContent` is a fallback for undrawn/jsdom contexts.
const hasProfileSections =
Boolean(headline) || experiences.length > 0 || educationItems.length > 0;
const bodyText = clean(document.body?.innerText || document.body?.textContent || '');
const blockedReason = hasProfileSections ? null : blockedReasonFromText(bodyText);

return {
profileUrl: window.location.href,
...(blockedReason ? { blockedReason } : {}),
sections: {
headline,
summary: clean(summary),
experiences: sectionItems(experience).map(parseExperience),
experiences,
skills: allTexts('span[aria-hidden="true"], .pvs-list__item--with-top-padding')
.filter((value) => value.length <= 80)
.slice(0, 80),
education: sectionItems(education).map(parseEducation),
education: educationItems,
links,
},
};
Expand Down
24 changes: 24 additions & 0 deletions apps/extension/src/models/linkedin-import.model.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ permission, `tab.url` is `undefined` and the URL classification would produce a
misleading `profile_not_found`. With the permission granted (by the side panel
gate), `tab.url` is readable for LinkedIn tabs.

### Blocked-reason detection (DOM)

`blockedReason` is computed inside `extractLinkedInProfileFromDom` (injected into
the page). It is the **fallback** for challenge interstitials LinkedIn serves on
a `/in/` URL without changing the URL — URL-path redirects to `/checkpoint/` or
`/challenge/` are already caught earlier by `classifyLinkedInUrl`.

Detection is **specific, not greedy**:

- Block signals are challenge-page-specific phrases only: "security
verification", "unusual activity", "verify your identity", "temporarily
restricted", "security check".
- The bare words "challenge" / "checkpoint" are NOT block signals: they appear
in legitimate profile prose (e.g. "I enjoy new challenges", "project
checkpoint") and previously caused false `rate_limited_or_blocked` errors that
blocked real imports.
- Defensive guard: text signals are authoritative only when the page has no
parseable profile sections (no headline, no experiences, no education). A page
that yielded real profile sections is never treated as blocked on text alone.

## Invariants

1. `chrome.permissions.request()` is called **only** from the side panel during
Expand All @@ -108,6 +128,10 @@ extracting → merging` sequence.
null/unknown bridge responses (facade graceful handling).
6. The LLM never decides a transition. It may only enrich extracted content in
a dedicated AI worker. **Le LLM produit des signaux ; le modèle décide.**
7. DOM block detection is specific: only challenge-page phrases are block
signals, and only when no profile sections are present. The bare words
"challenge" / "checkpoint" in body prose must NOT trigger a block — they
appear in legitimate profile text.

## Non-goals

Expand Down
92 changes: 92 additions & 0 deletions apps/extension/tests/unit/profile-extractors/linkedin-dom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from 'vitest';
import { extractLinkedInProfileFromDom } from '../../../src/lib/shell/profile-extractors/linkedin.extractor';

type DomSnapshot = ReturnType<typeof extractLinkedInProfileFromDom>;

function renderProfile(bodyHtml: string): void {
document.body.innerHTML = bodyHtml;
}

const PROFILE_WITH_CHALLENGE_IN_BIO = `
<main>
<h1>Jane Doe</h1>
</main>
<section>
<h2>About</h2>
<p>Frontend engineer who thrives on new challenges and ambiguous problems.</p>
</section>
<section>
<h2>Experience</h2>
<ul>
<li>Lead Frontend\nScaleOps\n2020 — 2024\nParis</li>
</ul>
</section>
`;

describe('extractLinkedInProfileFromDom — blocked-reason detection', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('does not block when a real profile mentions "challenge" in its About section', () => {
renderProfile(PROFILE_WITH_CHALLENGE_IN_BIO);

const snapshot: DomSnapshot = extractLinkedInProfileFromDom();

expect(snapshot.blockedReason).toBeUndefined();
// Headline + an experience section prove real profile sections were found,
// which is what the `hasProfileSections` guard keys on. (Line-level parsing
// of each <li> relies on `innerText` and is covered by the parser tests.)
expect(snapshot.sections.headline).toBe('Jane Doe');
expect(snapshot.sections.experiences).toHaveLength(1);
});

it('does not block on "unusual activity" when real profile sections are present', () => {
renderProfile(`
<main><h1>Jane Doe</h1></main>
<section>
<h2>About</h2>
<p>I investigate unusual activity in distributed systems.</p>
</section>
<section>
<h2>Experience</h2>
<ul><li>SRE\nAcme\n2019 — 2024</li></ul>
</section>
`);

const snapshot: DomSnapshot = extractLinkedInProfileFromDom();

expect(snapshot.blockedReason).toBeUndefined();
expect(snapshot.sections.experiences).toHaveLength(1);
});

it('blocks when LinkedIn serves a security-verification interstitial with no profile sections', () => {
renderProfile(`
<div>
<h1>Security verification</h1>
<p>Let's do a quick security check. Please verify your identity.</p>
</div>
`);

const snapshot: DomSnapshot = extractLinkedInProfileFromDom();

expect(snapshot.blockedReason).toBe('security verification required');
expect(snapshot.sections.experiences).toHaveLength(0);
expect(snapshot.sections.headline).toBe('');
});

it('does not treat the bare word "checkpoint" in profile prose as a block signal', () => {
renderProfile(`
<main><h1>Jane Doe</h1></main>
<section>
<h2>Experience</h2>
<ul><li>PM\nAcme\n2021 — 2024\nRan the quarterly project checkpoint</li></ul>
</section>
`);

const snapshot: DomSnapshot = extractLinkedInProfileFromDom();

expect(snapshot.blockedReason).toBeUndefined();
expect(snapshot.sections.experiences).toHaveLength(1);
});
});
Loading