Skip to content
Open
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
60 changes: 50 additions & 10 deletions linkedin_mcp_server/scraping/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,12 +733,19 @@ async def click_button_by_text(
return False

async def _dialog_is_open(self, *, timeout: int = 1000) -> bool:
"""Return whether a dialog is currently open (structural check)."""
locator = self._page.locator(_DIALOG_SELECTOR)
"""Return whether a dialog is currently open (structural check).

Uses ``wait_for(state="visible", timeout=timeout)`` directly so the
full timeout budget is spent waiting for the dialog to mount —
important after navigations that auto-open a modal (e.g. the
``/preload/custom-invite/`` deeplink), where the modal mounts a
few hundred ms after ``goto`` resolves on ``domcontentloaded``.
The previous early ``count() == 0`` short-circuit returned False
before the modal had a chance to render.
"""
locator = self._page.locator(_DIALOG_SELECTOR).first
try:
if await locator.count() == 0:
return False
await locator.first.wait_for(state="visible", timeout=timeout)
await locator.wait_for(state="visible", timeout=timeout)
return True
except Exception:
return False
Expand All @@ -751,7 +758,7 @@ async def _click_dialog_primary_button(self, *, timeout: int = 5000) -> bool:
times out, so callers can fall back to a keyboard submit.
"""
buttons = self._page.locator(
f"{_DIALOG_SELECTOR} button, {_DIALOG_SELECTOR} [role='button']"
f":is({_DIALOG_SELECTOR}) button, :is({_DIALOG_SELECTOR}) [role='button']"
)
count = await buttons.count()
if count == 0:
Expand Down Expand Up @@ -1574,7 +1581,7 @@ async def _submit_invite_dialog(self, note: str | None) -> tuple[bool, bool]:
# then fails and the caller returns connect_unavailable
# without sending — the same outcome as today.
buttons = self._page.locator(
f"{_DIALOG_SELECTOR} button, {_DIALOG_SELECTOR} [role='button']"
f":is({_DIALOG_SELECTOR}) button, :is({_DIALOG_SELECTOR}) [role='button']"
)
btn_count = await buttons.count()
if btn_count >= 2:
Expand All @@ -1590,16 +1597,49 @@ async def _submit_invite_dialog(self, note: str | None) -> tuple[bool, bool]:

note_sent = await self._fill_dialog_textarea(note)
if not note_sent:
await self._dismiss_dialog()
return False, False
# The textarea never mounted. The most common cause as of
# 2026-05 is LinkedIn's "Send unlimited personalized
# invites with Premium" upsell, which replaces the
# invite dialog after "Add a note" is clicked once the
# account's free-note quota for the month is exhausted.
# Detect it by the rebuilt dialog content. If found, the
# connection request still has a viable path: dismiss
# the upsell, re-trigger the invite via the deeplink
# (URL is unchanged through the upsell), and fall through
# to the primary-button click below to send without a
# note. The caller sees note_sent=False so they know the
# personalization was dropped.
upsell_visible = False
try:
upsell_visible = await self._page.locator(
f':is({_DIALOG_SELECTOR}):has-text("free custom notes"), '
f':is({_DIALOG_SELECTOR}):has-text("personalized invites with Premium")'
).first.is_visible()
except Exception:
logger.debug("Premium upsell detection failed", exc_info=True)
if upsell_visible:
logger.info(
"Free-note quota exhausted (Premium upsell shown); "
"falling back to no-note send."
)
invite_url = self._page.url
await self._dismiss_dialog()
await self._navigate_to_page(invite_url)
if not await self._dialog_is_open(timeout=5000):
return False, False
# Fall through with note_sent=False so the primary
# button (Send without a note) gets clicked below.
else:
await self._dismiss_dialog()
return False, False

sent = await self._click_dialog_primary_button()
if not sent:
# Fallback: focus the primary button positionally so a subsequent
# Enter targets it instead of a focused textarea (where Enter
# would just insert a newline).
buttons = self._page.locator(
f"{_DIALOG_SELECTOR} button, {_DIALOG_SELECTOR} [role='button']"
f":is({_DIALOG_SELECTOR}) button, :is({_DIALOG_SELECTOR}) [role='button']"
)
btn_count = await buttons.count()
if btn_count > 0:
Expand Down
72 changes: 72 additions & 0 deletions tests/test_scraping.py
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,78 @@ def locator_router(selector: str):
assert clicks == [0, 1]
textarea_locator.fill.assert_awaited_once()

async def test_dialog_selector_matches_alertdialog_and_aria_modal(self):
"""Regression for issue #455 (post-fix-456 breakage on Ivan's
account): the gating "Add a note to your invitation?" dialog
rendered by ``/preload/custom-invite/?vanityName=`` is an
``artdeco-modal`` whose root carries ``role="alertdialog"`` and
``aria-modal="true"`` rather than the legacy ``role="dialog"``.
The previous selector only matched ``dialog[open]`` and
``[role="dialog"]``, so ``_dialog_is_open`` returned False even
though the dialog was visible — ``connect_with_person`` then
bailed out with ``connect_unavailable`` before ever clicking
"Send without a note" / "Add a note".

This guards the selector against future narrowing regressions
— if any of these substrings drop out, the gating dialog
stops being detected and connect attempts silently fail.
"""
from linkedin_mcp_server.scraping.extractor import (
_DIALOG_SELECTOR,
_DIALOG_TEXTAREA_SELECTOR,
)

# Each of these ARIA patterns must independently be matched so
# we are robust to whichever attribute LinkedIn chooses on a
# given account/locale.
assert 'role="dialog"' in _DIALOG_SELECTOR
assert 'role="alertdialog"' in _DIALOG_SELECTOR
assert 'aria-modal="true"' in _DIALOG_SELECTOR
assert "dialog[open]" in _DIALOG_SELECTOR

# Textarea selector must be scoped under the same broadened
# dialog roots so the note textarea is found inside any of them.
assert 'role="dialog"' in _DIALOG_TEXTAREA_SELECTOR
assert 'role="alertdialog"' in _DIALOG_TEXTAREA_SELECTOR
assert 'aria-modal="true"' in _DIALOG_TEXTAREA_SELECTOR

async def test_dialog_is_open_waits_for_late_mounting_dialog(
self, mock_page
):
"""Regression for issue #455 (post-fix-456 follow-up): after
``goto(/preload/custom-invite/?vanityName=)`` resolves on
``domcontentloaded``, the gating dialog mounts a few hundred ms
later. The previous ``_dialog_is_open`` short-circuited on
``count() == 0`` and returned False before the modal rendered,
causing ``submitted=False`` and ``connect_unavailable`` even
though the dialog was about to appear.

This asserts the helper now waits the full ``timeout`` budget
for the dialog to become visible rather than reading ``count``
eagerly."""
extractor = LinkedInExtractor(mock_page)

first_locator = MagicMock()
wait_calls: list[dict] = []

async def fake_wait_for(*args, **kwargs):
wait_calls.append(kwargs)
return None

first_locator.wait_for = AsyncMock(side_effect=fake_wait_for)

dialog_locator = MagicMock()
dialog_locator.first = first_locator

mock_page.locator = MagicMock(return_value=dialog_locator)

result = await extractor._dialog_is_open(timeout=5000)

assert result is True
# The helper must have spent its budget on wait_for(visible)
# rather than reading .count() and exiting early.
assert wait_calls == [{"state": "visible", "timeout": 5000}]

async def test_references_are_grouped_by_section(self, mock_page):
extractor = LinkedInExtractor(mock_page)
with (
Expand Down
Loading