Skip to content

Commit c638077

Browse files
committed
fix: restore the extractor entrypoint and regenerate the two artifacts
Three CI failures, one of them a real runtime break. get_all_conversations had been deleted from LinkedInExtractor by an earlier regex edit that was meant to restore get_inbox. The MCP tool calls that method, so the tool would have failed at runtime. Nothing caught it: every test here either drives the reader directly or mocks the extractor, so the passthrough itself was untested. ty found it. Added two tests that would have: one asserts the method exists, and one spies on the reader and compares the forwarded keyword set against the reader's own signature, so a silently dropped argument fails too. Regenerated docs/scraping-architecture.md and the facade-contract policy trace, both of which enumerate the scraping surface and so change when a tool is added. The trace generator refuses to write into the canonical fixture directory, so it was generated elsewhere and the single changed file copied in deliberately: 90 lines added, none removed. All pre-commit hooks pass.
1 parent d487a8e commit c638077

4 files changed

Lines changed: 173 additions & 1 deletion

File tree

docs/scraping-architecture.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ a page-owning collaborator.
4040
| `search_urls` | `CONTENT_DATE_POSTED_MAP`, `EXPERIENCE_LEVEL_MAP`, `JOB_DATE_POSTED_MAP`, `JOB_TYPE_MAP`, `NETWORK_TOKENS`, `SORT_BY_MAP`, `WORK_TYPE_MAP`, `build_company_search_url()`, `build_content_search_url()`, `build_job_search_url()`, `build_people_search_url()` | `browser-free` |
4141
| `session` | `NAV_DELAY`, `ScrapingSession` | `page-owning` |
4242
| `text` | `DETAIL_CAPTURE_EN_US`, `DetailCaptureTextTable`, `SIDEBAR_CHROME_EN`, `SidebarChromeTable`, `filter_linkedin_noise_lines()`, `strip_conversation_chrome()`, `strip_linkedin_noise()`, `truncate_linkedin_noise()` | `browser-free` |
43+
| `voyager_messaging` | `KNOWN_CATEGORIES`, `MAX_PAGE_SIZE`, `MESSAGING_URL`, `VoyagerMessagingReader` | `page-owning` |
4344

4445
## Internal import graph
4546

@@ -51,7 +52,7 @@ a page-owning collaborator.
5152
- `content` -> `session`, `text`
5253
- `contracts` -> `identifiers`, `link_metadata`
5354
- `conversations` -> `content`, `identifiers`, `link_metadata`, `navigation`, `profile_page`, `session`, `text`
54-
- `extractor` -> `capture`, `company`, `connection_actions`, `content`, `contracts`, `conversations`, `feed`, `job_pages`, `jobs`, `message_sender`, `navigation`, `person`, `posts`, `profile_page`, `session`, `text`
55+
- `extractor` -> `capture`, `company`, `connection_actions`, `content`, `contracts`, `conversations`, `feed`, `job_pages`, `jobs`, `message_sender`, `navigation`, `person`, `posts`, `profile_page`, `session`, `text`, `voyager_messaging`
5556
- `feed` -> `content`, `contracts`, `feed_payload`, `navigation`, `session`, `text`
5657
- `feed_payload` -> `link_metadata`
5758
- `fields` -> `capture`
@@ -68,13 +69,15 @@ a page-owning collaborator.
6869
- `search_urls` -> `contracts`
6970
- `session` -> _(none)_
7071
- `text` -> _(none)_
72+
- `voyager_messaging` -> _(none)_
7173

7274
## `LinkedInExtractor` public coroutine surface
7375

7476
- `click_button_by_text`
7577
- `connect_with_person`
7678
- `extract_feed`
7779
- `extract_page`
80+
- `get_all_conversations`
7881
- `get_company_employees`
7982
- `get_conversation`
8083
- `get_inbox`
@@ -104,6 +107,7 @@ a page-owning collaborator.
104107
- `_message_sender`
105108
- `_person`
106109
- `_posts`
110+
- `_voyager_messaging`
107111

108112
## Dependency-direction violations
109113

linkedin_mcp_server/scraping/extractor.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,29 @@ async def get_inbox(self, limit: int = 20) -> dict[str, Any]:
230230
"""List recent conversations from the messaging inbox."""
231231
return await self._conversations.get_inbox(limit)
232232

233+
async def get_all_conversations(
234+
self,
235+
limit: int = 200,
236+
max_pages: int = 60,
237+
cursor: str | None = None,
238+
quiet_for_days: int | None = None,
239+
awaiting_reply_only: bool = False,
240+
category: str | None = None,
241+
page_size: int = 25,
242+
known_thread_urns: list[str] | None = None,
243+
) -> dict[str, Any]:
244+
"""Page the whole mailbox via the conversations API, clicking nothing."""
245+
return await self._voyager_messaging.get_all_conversations(
246+
limit=limit,
247+
max_pages=max_pages,
248+
cursor=cursor,
249+
quiet_for_days=quiet_for_days,
250+
awaiting_reply_only=awaiting_reply_only,
251+
category=category,
252+
page_size=page_size,
253+
stop_at_thread_urns=set(known_thread_urns or ()) or None,
254+
)
255+
233256
async def get_conversation(
234257
self,
235258
linkedin_username: str | None = None,

tests/fixtures/scraping-policy/v1/facade-contract.json

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,96 @@
156156
"type": "object"
157157
}
158158
},
159+
"get_all_conversations": {
160+
"input": {
161+
"additionalProperties": false,
162+
"properties": {
163+
"awaiting_reply_only": {
164+
"default": false,
165+
"description": "only return threads whose newest message is\ntheirs, so a reply is owed",
166+
"type": "boolean"
167+
},
168+
"category": {
169+
"anyOf": [
170+
{
171+
"type": "string"
172+
},
173+
{
174+
"type": "null"
175+
}
176+
],
177+
"default": null,
178+
"description": "SERVER-SIDE filter, the only one LinkedIn actually honours.\nOne of INBOX, PRIMARY_INBOX, ARCHIVE, INMAIL, STARRED, SPAM.\nThese jump anywhere in time in a single call. An unknown value\nis rejected rather than passed through, because the API answers\none with an empty page rather than an error."
179+
},
180+
"cursor": {
181+
"anyOf": [
182+
{
183+
"type": "string"
184+
},
185+
{
186+
"type": "null"
187+
}
188+
],
189+
"default": null,
190+
"description": "next_cursor from a previous call, to resume the walk"
191+
},
192+
"known_thread_urns": {
193+
"anyOf": [
194+
{
195+
"items": {
196+
"type": "string"
197+
},
198+
"type": "array"
199+
},
200+
{
201+
"type": "null"
202+
}
203+
],
204+
"default": null,
205+
"description": "thread urns already recorded elsewhere. The\nmailbox is recency-ordered, so once a whole page is threads you\nalready know, everything behind it is older and also known, and\nthe walk stops. This is what keeps a repeat sync to one or two\npages instead of the whole mailbox."
206+
},
207+
"limit": {
208+
"default": 200,
209+
"description": "Maximum conversations to return (1-2000, default 200)",
210+
"maximum": 2000,
211+
"minimum": 1,
212+
"type": "integer"
213+
},
214+
"max_pages": {
215+
"default": 60,
216+
"description": "Safety cap on cursor pages to walk (1-200, default 60)",
217+
"maximum": 200,
218+
"minimum": 1,
219+
"type": "integer"
220+
},
221+
"page_size": {
222+
"default": 25,
223+
"description": "rows per request, max 25 (measured). Above 25 the API\nreturns EMPTY rather than an error, so this is clamped.",
224+
"maximum": 25,
225+
"minimum": 1,
226+
"type": "integer"
227+
},
228+
"quiet_for_days": {
229+
"anyOf": [
230+
{
231+
"minimum": 1,
232+
"type": "integer"
233+
},
234+
{
235+
"type": "null"
236+
}
237+
],
238+
"default": null,
239+
"description": "only return threads with no activity for this many\ndays — the reconnect filter"
240+
}
241+
},
242+
"type": "object"
243+
},
244+
"output": {
245+
"additionalProperties": true,
246+
"type": "object"
247+
}
248+
},
159249
"get_company_employees": {
160250
"input": {
161251
"additionalProperties": false,

tests/test_voyager_messaging.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,58 @@ async def test_filtered_walks_still_request_full_pages(self):
406406
reader = _Reader([_payload(rows, None)])
407407
await reader.get_all_conversations(limit=5, page_size=25, quiet_for_days=365)
408408
assert "count:25" in reader.fetched[0], "filtered walk keeps full pages"
409+
410+
411+
class TestExtractorWiring:
412+
"""The tool calls `extractor.get_all_conversations`. Every other test here
413+
either drives the reader directly or mocks the extractor, so none of them
414+
would notice the method going missing -- which is exactly what happened
415+
during a refactor, caught only by the type checker."""
416+
417+
def test_extractor_exposes_the_tool_entrypoint(self):
418+
from linkedin_mcp_server.scraping.extractor import LinkedInExtractor
419+
420+
assert hasattr(LinkedInExtractor, "get_all_conversations")
421+
422+
async def test_extractor_forwards_every_argument_to_the_reader(self):
423+
"""A passthrough that silently drops an argument is the other way this
424+
breaks without any test noticing."""
425+
import inspect
426+
427+
from linkedin_mcp_server.scraping.extractor import LinkedInExtractor
428+
429+
seen: dict = {}
430+
431+
class _Spy:
432+
async def get_all_conversations(self, **kwargs):
433+
seen.update(kwargs)
434+
return {"conversations": [], "count": 0}
435+
436+
extractor = LinkedInExtractor.__new__(LinkedInExtractor)
437+
extractor._voyager_messaging = _Spy() # type: ignore[attr-defined]
438+
439+
await extractor.get_all_conversations(
440+
limit=7,
441+
max_pages=3,
442+
cursor="CUR",
443+
quiet_for_days=30,
444+
awaiting_reply_only=True,
445+
category="INMAIL",
446+
page_size=11,
447+
known_thread_urns=["a", "b"],
448+
)
449+
450+
assert seen["limit"] == 7
451+
assert seen["max_pages"] == 3
452+
assert seen["cursor"] == "CUR"
453+
assert seen["quiet_for_days"] == 30
454+
assert seen["awaiting_reply_only"] is True
455+
assert seen["category"] == "INMAIL"
456+
assert seen["page_size"] == 11
457+
assert seen["stop_at_thread_urns"] == {"a", "b"}
458+
459+
# Every reader parameter the extractor is meant to relay must be relayed.
460+
reader_params = set(
461+
inspect.signature(VoyagerMessagingReader.get_all_conversations).parameters
462+
) - {"self"}
463+
assert reader_params == set(seen), reader_params ^ set(seen)

0 commit comments

Comments
 (0)