关于 LinkedIn Sales Navigator
面向使用 AI 助手的销售团队,借助手动登录的 Chromium 浏览器搜索 LinkedIn Sales Navigator 联系人和账户。结果保存在本地,并提供带防护的线索补充与触达工具。
主要功能
- 01搜索联系人和账户
- 02将搜索结果存入 SQLite
- 03补充线索并读取档案
- 04跟踪触达和回复
- 05带防护的消息起草与发送
- 06导出为 JSON 或 CSV
适用场景
- 01从 Sales Navigator 搜集潜在客户
- 02查看已保存的线索以供分析
- 03检查回复和触达状态
- 04导出搜索结果供后续使用
MCP 相关
向 MCP 客户端提供搜索、补充信息、档案、流程和触达工具,以及已保存查询资源和开发客户工作流提示词。LinkedIn 请求通过用户手动登录的持久浏览器会话执行。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
6收录的工具列表 · 更新于 2 周前
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| pages | integer | 否 | 本次调用要获取的 25 条结果页面数(1-10)。再次调用即可继续获取后续页面。 |
| resume | boolean | 否 | 若为 true(默认值),则从上次使用相同 URL 停止的位置继续。若为 false,则从第 1 页开始(已保存的记录会保留并去重)。 |
| refresh | boolean | 否 | 若为 true,则清除此查询已保存的进度和记录,并从第 1 页重新抓取。 |
| search_url | string | 是 | 完整的 Sales Navigator URL(/sales/search/people、 /sales/search/leads 或 /sales/lists/people)。 |
| include_raw | boolean | 否 | 将 LinkedIn 的原始元素 JSON 与每条记录一并保留 (字段显示异常时很有用)。 |
详细介绍
LinkedIn Sales Navigator MCP 服务
此 MCP 服务可让 AI 助手(Claude Desktop、Claude Code 和任何 MCP 客户端)访问 LinkedIn Sales Navigator 联系人和账户搜索 — 通过驱动你机器上真实且已登录的浏览器,并捕获 Sales Navigator 自身的搜索 API 响应。
为什么采用这种设计(而不重放 Cookie)
常见的做法 — 复制你的 li_at 和 JSESSIONID Cookie,再从服务端通过 HTTP 请求重放 —
会让你反复被登出。LinkedIn 会根据 IP、浏览器指纹、TLS 和完整的 Cookie 集评估每个会话;
来自另一台机器的两个重放 Cookie 看起来就像会话被劫持,因此 LinkedIn 会使其失效。
本服务采用相反的做法。它会保留一个持久化的浏览器配置文件,你只需手动登录一次, 随后即可让这个真实会话完成工作:
MCP client (Claude) ──stdio/HTTP──> this server ──drives──> your logged-in Chromium ──> Sales Navigator
│
captures the JSON the browser
itself receives (page.on "response")
每个发往 LinkedIn 的请求都来自真实浏览器:你的 IP、浏览器指纹、完整的 Cookie 存储、 由浏览器生成的 CSRF/跟踪标头;浏览器也会照常刷新会话。不会重放或重构任何内容。 这正是你能保持登录状态的原因。
我们绝不会自动执行登录操作 — 输入凭据是明显的机器人信号。你只需手动登录一次;配置文件会持续保留。
工具
| 工具 | 功能 |
|---|---|
search_contacts | 根据 Sales Navigator URL 搜索人员/潜在客户。在浏览器中导航并翻页,将记录保存到 SQLite,并返回简短的进度摘要。 |
search_accounts | 根据 Sales Navigator URL 搜索公司/账户。操作方式相同,搜索对象为账户。 |
enrich_leads | 为已保存的联系人搜索结果添加 Open Profile / InMail 状态。默认情况下(method="compose"),它会打开每位潜在客户的消息面板,还会记录联系信息和洞察(近期帖子、职位变动、共同联系人)。可选择启用并支持断点续做 — 参见 Open Profile 状态。 |
fetch_lead_profiles | 深度 3:获取用于起草消息的完整个人资料(每位潜在客户约 15 KB)。通过 ENABLE_PROFILE 选择启用。 |
get_lead_profile | 读取一份已存储的完整个人资料。仅限本地操作,不会调用 LinkedIn。 |
pipeline_status | 查看完整漏斗:已抓取 → 已丰富 → 已开放 → 已获取个人资料 → 已发送。 |
reconcile_outreach | 直接与 LinkedIn 核对卡在 sending 状态的发送记录。 |
run_outreach_batch | 通过 MCP 抽样,在一次调用中为多位潜在客户起草并发送消息。防护措施相同;dry_run 默认为 true。 |
outreach_next | 实时会话:遍历搜索结果,从下一位符合条件的潜在客户所在行打开其消息面板,捕获面板内容,并返回该潜在客户的数据以供起草消息。自动跳过非 Open Profile 潜在客户和已有对话的潜在客户。mode:draft、supervised(倒计时 30 s)或 auto(5 s)。 |
outreach_submit | 在打开的面板中填入草稿。draft 模式会保存草稿;否则,页面中会显示一个横幅,倒计时期间提供 跳过 / 立即发送 / 暂停,倒计时归零时面板中的内容(包括编辑内容)会重新检查,并通过与 send_message 相同的两阶段提交、上限和去重机制发送。 |
outreach_skip | 关闭打开的面板,不发送消息,并记录原因。 |
实时会话中哪些由代码处理,哪些由模型处理。 所有可根据数据作出的判断,都会在消耗任何令牌之前由代码完成:ICP 筛选(FIT_RULES_FILE,在打开面板前应用,因此被拒绝的潜在客户也不会产生 LinkedIn 请求)、Open Profile 和现有对话线程检查、将潜在客户信息精简至固定预算(摘要、当前职位、三条近期帖子),以及公司信息模块 — 将 LinkedIn 自带的 Account IQ 档案提炼为优先事项、挑战、收入模式、员工规模和新闻;如果档案为空,则使用 TinyFish 新闻(TINYFISH_API_KEY)。每个会话只发送一次产品/服务介绍,不会为每位潜在客户重复发送。模型只负责撰写消息。
| check_replies | 读取收件箱并标记已回复的潜在客户。不发送任何内容。 |
| next_outreach_batch | 列出符合首次发送消息条件的潜在客户 — 优先 Open Profile,排除已联系过的对象。只读。 |
| send_message | 发送一条消息。唯一会向 LinkedIn 写入数据的工具:默认关闭,dry_run=true 为默认值。 |
| outreach_status | 按状态和渠道统计数量,并显示每日上限剩余额度。 |
| check_session_status | 报告浏览器配置文件是否有有效的 Sales Navigator 会话(告知你是否需要重新运行 --login)。 |
| list_queries | 列出所有已保存的搜索及其进度:url_hash、状态、last_page、records_count。 |
| get_results | 将已保存查询的记录中指定范围的片段(1–200 条)载入对话,以供分析。 |
| export_results | 将已保存查询的记录导出为 JSON 和/或 CSV,保存到输出文件夹。 |
两个搜索工具都需要提供一个 完整的 Sales Navigator URL(在 UI 中创建搜索,然后从地址栏复制),以及 pages 数量(1–10,每页 25 条结果)。
除了工具之外,服务还会公开一个 资源(sales-nav://queries —
已保存的查询及其进度,作为可附加的 JSON 上下文),以及一个
提示词(sales_nav_search_workflow — 逐步介绍潜在客户开发流程的操作手册,适用于支持 MCP 提示词的客户端)。
搜索工具不会返回记录
这是有意为之,也是最可能让你感到意外的地方。记录会写入 SQLite;工具只返回状态对象,因此抓取 250 条记录不会把 250 条记录全部塞进模型的上下文:
{
"url_hash": "a6ca46c9365bce93",
"scraper_type": "contacts",
"status": "paused", // new | in_progress | paused | complete
"new_records_this_call": 25,
"total_records": 25,
"total_available": 11897313,
"pages_fetched": 1,
"last_page": 1,
"next_page": 2, // null once exhausted
"raw_dir": null, // set when include_raw=true
"suggestion": "Saved 25 records so far (through page 1) ..."
}
要获取数据,请调用 get_results(获取样本)或 export_results(导出文件),也可以直接读取 SQLite 数据库。
流水线深度
查询能进行到哪一步取决于查询本身,而不是发起查询的调用,因此明天恢复运行时,系统仍知道这次搜索是为了什么。
| 深度 | 端点 | 每条潜在客户的成本 | 门控 |
|---|---|---|---|
search | 潜在客户搜索 | 每 25 条一页 | 始终启用 |
open_profile | + enrich_leads | ~240 B | ENABLE_ENRICH(默认开启) |
full | + fetch_lead_profiles | ~15 KB | ENABLE_PROFILE(默认关闭) |
search_contacts(url, pages=4, depth="open_profile")
深度会存储在查询中,并通过 next_step 一并返回。如果请求的深度未被服务端启用,系统会拒绝请求并说明原因,而不是悄悄降低深度。因此,默认安装不会被指向一个列表后就拉取数千份完整档案。
enrich_leads 和 fetch_lead_profiles 使用的是同一个端点,只是投影不同,并且刻意分开:前者会遍历整个列表,找出可以免费发送消息的人;后者只会获取你即将联系的潜在客户的完整资料。把它们合并会为你永远不会联系的潜在客户拉取大量数据,还会让“近期动态”变得和搜索结果一样过时。
发送消息
这是唯一会向 LinkedIn 写入数据的功能,因此它的处理方式与这里的其他功能都不同。读取信息看起来像是有人在浏览;一连串发送消息看起来就和实际情况一样,而后果会落在你的账户上,而不是代码上。因此,所有默认设置都采取谨慎策略:
| 防护项 | 默认值 |
|---|---|
ENABLE_SENDING | false — 全新安装无法向任何人发送消息 |
dry_run | true — 返回完整草稿,不会发送 |
ALLOW_CREDIT_SPEND | false — 拒绝任何会消耗 InMail 点数的操作 |
SEND_DAILY_CAP | 40,滚动 24 小时内,涵盖所有营销活动 |
SEND_DELAY_MIN/MAX | 每次发送间隔 45–120s |
免费与付费,以及为什么要先进行信息补全
Open Profile 成员可以发送消息,无需消耗 InMail 点数;其他人则会从有限的月度配额中扣除一点。撰写窗口会说明具体情况——"Free to Open Profile" 或 "Use 1 of N credits";发送工具会读取这行文字、记录发送渠道,并且只有在你明确允许的情况下才会走付费路径。
这就是为什么 enrich_leads 在商业层面很重要,而不仅仅是补充元数据:默认情况下,next_outreach_batch 只会返回已确认可通过 Open Profile 免费联系的潜在客户,因此免费渠道是最便捷的选择。
绝不重复联系
联系状态保存在 lead_outreach 中,以 (member_id, campaign) 为键,排除规则是全局的:任何营销活动中有 sent 记录的人,都会从之后的所有批次中筛除。此服务端其他位置的去重机制可以避免无谓请求;而这里的去重是为了防止两次搜索恰好找到同一个人,导致重复发送消息。member_id 是唯一能在不同搜索之间保持稳定的标识符,因此将其用作键。
状态会在每次发送后提交,而不是在整个批次结束后提交——如果发生崩溃,绝不能让消息已在 LinkedIn 上发出,却未在这里记录。
优惠文件由你掌控
服务端既不会生成文案,也不会了解你卖的是什么。你的定位信息保存在一个被 gitignore 排除且不会打包的 Markdown 文件中:
cp offer.example.md offer.md # then edit it
echo "OFFER_FILE=./offer.md" >> .env
sales_nav_compose_message 提示词会将该文件与潜在客户记录及撰写规则结合起来;由你的 MCP 客户端所用模型撰写消息。如果未配置优惠文件,提示词将拒绝渲染。
证据门槛
起草模型必须返回它所依据的记录字段:
{"subject": "...", "body": "...", "evidence_used": ["positions[0].title", "companyName"]}
每个条目都会与实际获取的潜在客户记录进行核对。若字段不存在,消息就会被拒绝并且不会发送。这是一项成本低、结果确定的检查,用于确保“个性化”确实以我们实际掌握的数据为依据——如果消息声称对方做过会议演讲,却没有任何信息支持,就会被拒绝。evidence_used 为空也会被拒绝:那只是模板,不是个性化内容。
无人值守批次
其他所有情况下,这种分工都是有意为之:服务端决定联系谁,客户端的模型决定说什么。这需要由人工驱动的一轮交互——在办公桌前没问题,按计划自动运行时却毫无用处。
run_outreach_batch 通过 MCP 采样弥补了这一不足。服务端会为每份草稿请求你的客户端提供内容(ctx.sample),因此这里不会运行模型,也不会存放 API 密钥,这个进程也不会生成任何文案——它只会发出请求。
自动化不会放宽任何要求。每份草稿都要经过与手写内容相同的发送流程:证据门槛、全局去重、每日上限、默认仅使用免费渠道,以及两阶段提交。dry_run 仍默认为 true。解析失败或验证不通过的草稿会被记录并跳过,不会发送,也不会导致批次提前结束;达到上限时,批次会提前停止。
run_outreach_batch(url_or_hash, campaign="q3", limit=5) # drafts only
run_outreach_batch(url_or_hash, campaign="q3", limit=5, dry_run=False) # sends
模型收到的潜在客户信息范围刻意保持精简——姓名、职位、公司、任职时长、摘要、工作经历。不包含 entity_urn,因为其中带有会话令牌。
回复
不衡量效果的发送不算营销活动,而给已经回复的人发送跟进消息,还不如不跟进。check_replies 会读取 Sales Navigator 收件箱并标记回复。
它使用 salesApiMessagingThreads,因此不会解析渲染后的文本——如果消息的 author 是除你之外的参与者,这条消息就是一条回复。关于该负载,有两点值得了解,因为这两点都是吃过亏才发现的:
participantsResolutionResults会将*<urn>映射为相同的<urn>——这只是引用,并非已解析的个人资料;而included返回为空。负载中没有objectUrn,因此任何地方都没有 member_id。匹配会通过参与者 URN 中嵌入的 profileId 进行,这是entity_urn中稳定的部分(其后的 authToken 仅在搜索范围内有效)。- 如果无法识别查看者,就不会对任何内容进行分类。危险之处不在于漏掉一条回复,而在于把你自己发出的消息误认为潜在客户的回复,因此它会选择拒绝判断,而不是猜测。
系统只会匹配此服务端记录过发送操作的潜在客户;你手动联系过的对话会保持不变。replied 计为已联系,因此回复绝不会导致重复的首次触达。
循环
enrich_leads -> who is free to message
next_outreach_batch -> who is eligible, never-contacted
get_results -> the lead's own words
sales_nav_compose_message prompt -> draft
send_message dry_run=true -> review
send_message dry_run=false -> send
check_replies -> who answered
outreach_status -> where the campaign stands
从设计上即可续跑。发送十条后停止,明天再回来调用 next_outreach_batch,它会从上次停止的位置继续。
职级
搜索 Lead 时,请求使用的装饰 ID 为
com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-16,而不是 Sales Navigator 自带的 Web 客户端请求的
-14。-16 是严格超集:字段完全相同,另增加 seniorityV2s,每个 Lead 大约多占 230 字节。
这是服务端唯一会修改浏览器请求内容的地方;对于不带 LeadSearchResult ID 的 URL,重写操作不会产生任何变化。
seniorityV2s 是 LinkedIn 自己的职级分类,且可包含多个值——创始人会返回 Owner / Partner + CXO + Senior。枚举按值排序:
| id | Level | id | Level |
|---|---|---|---|
| 320 | Owner / Partner | 210 | Experienced Manager |
| 310 | CXO | 200 | Entry Level Manager |
| 300 | Vice President | 130 | Strategic |
| 220 | Director | 120 | Senior |
| 110 | Entry Level | 100 | In Training |
记录会在 seniorities 下保存完整列表(按职级从高到低排序),并通过 seniorityTop / seniorityTopId 提供大多数调用方需要的单个值。各行会写入 seniorities 子表;CSV 导出包含 seniority_top、
seniority_top_id 和 seniority_summary。
请注意,这些值是推断得出的,并非真实数据——“Director of Client Engagement” 会返回 Director + Senior。在此更改前捕获的 Lead 不会有职级;对应列为空,而非错误。
Open Profile 状态
Sales Navigator 的搜索载荷包含一个 openLink 字段,这是个陷阱:所有潜在客户的该字段值都是 false,包括高级会员。它只是无用的装饰。因此,此服务不会将其暴露出来——发布一个对所有人都显示权威结论“不是 Open Profile”的列,还不如什么都不发布。
真正有效的标志是 memberBadges.openLink,但只有个人资料端点会返回它——每位潜在客户都要请求一次。它没有批量形式;无论尝试哪种形式,使用 ids=List(...) 批量调用 salesApiProfiles 都会返回 400。也无法让搜索端点返回该字段:它只接受已注册的 decorationId,不接受自由形式的字段投影;而已注册的 ID(LeadSearchResult-13 … -16)都不包含该字段。
因此,它是一个独立的可选工具:
enrich_leads(url_or_hash, limit=50, only_missing=true, method="compose")
两种方法:
method | 功能 | 获取内容 | 速度 |
|---|---|---|---|
compose(默认) | 点击搜索结果中每位潜在客户所在行的 Message,记录面板加载的内容,然后关闭面板。不会自行发起请求,也不会加载潜在客户页面。 | Open Profile 状态、联系信息(电子邮件、网站、电话、账号)、动态(近期帖子和文章、工作变动、共同联系人)、现有对话长度、公司档案(原始数据) | ~2 s 捕获 + 间隔等待(实测约 11 s/位潜在客户) |
fetch | 在页面内为每位潜在客户发起一次精简的个人资料请求 | 仅 Open Profile 状态 | ~1 s/位潜在客户 |
compose 会逐行遍历查询结果的各个页面。LinkedIn 会在不同访问之间重新排序,因此会从它出现过的任一已访问页面中获取潜在客户;只有找不到的潜在客户才会回退到其个人资料页面(via="lead_page" 会强制走这条路径,速度较慢,而且会实际查看个人资料)。面板只会通过其“Close conversation”按钮关闭;不会填写任何字段,也不会触碰 Send。
开始时使用较小的 limit 进行抽样,再决定是否处理整个查询。两种方法都支持断点续跑且具备幂等性——已处理的潜在客户会跳过,失败项会保持待处理并重试——因此重复调用会逐步完成整个查询。失败的 compose 捕获不会覆盖之前成功的结果。
数据存放位置。 补充数据会写入独立的 lead_enrichment 表,绝不会写入 leads.raw_json,也不会写入规范化记录。这样做有两个原因:
iter_records会在读取时从raw_json重新生成每条记录,因此写入其他位置的数据会被悄然丢弃;而将其写入raw_json则会破坏normalize.py所依赖的“原始数据与 LinkedIn 发来的内容完全一致”这一不变量。- 该表以
member_id为键,它在不同搜索之间保持稳定;而entity_urn中嵌入了每次搜索专用的身份验证令牌。通过三种不同搜索找到的同一位潜在客户只会获取一次,并由三次搜索共享。
get_results 和 export_results 会将这些数据关联回来:JSON 中会包含一个 enrichment 块(compose 捕获后包含 contactInfo 和 insights 列表);CSV 中会包含 open_profile / inmail_restriction / enriched_at 列,对于已捕获的潜在客户,还会包含 emails / websites / phones / other_contacts / latest_post / job_change / existing_thread_messages / captured_at。联系信息和动态分别存放在独立的 lead_contact_info 和 lead_insights 表中。
空值表示“尚未检查”,这与 false 并不相同——将两者分开存储,关键就在于此。
还有一点需要注意:inmailRestriction 描述的是你向某人发送 InMail 的权限,而不是对方的 Open Profile 状态。几乎所有人都会显示 NO_RESTRICTION,因此不要用它来代替 Open Profile 状态。
搜索支持断点续跑。 URL 会被哈希为 url_hash;再次调用相同 URL 时,会从 next_page 继续,而不是重新开始。Sales Navigator 将单次搜索限制在最多 100 页(2,500 条结果),无论 total_available 报告的数量是多少——若要超出此限制,请将搜索拆分为更精细的筛选条件,并通过去重合并各个结果。
浏览器生命周期
首次调用需要浏览器的工具时,才会延迟启动一个 Chromium;关闭时会由生命周期钩子或空闲看门狗将其关闭。不会在每次调用时启动浏览器:每次调用都重新启动很慢,而且针对同一配置文件反复启动会带来会话风险。
空闲超时。 没有工具调用达到 IDLE_BROWSER_TIMEOUT 秒后(默认 3600 秒,即一小时),浏览器会自行关闭,并在下次调用时重新启动。这样,在你不进行抓取时可以释放几百 MB 内存,代价是下次调用会多花几秒钟,除此之外没有其他影响:你的登录状态保存在配置文件目录中,而不是进程中。将其设为 0,即可让浏览器在整个服务运行期间常驻。
看门狗会获取与工具相同的锁,因此绝不会在操作进行到一半时关闭浏览器——长时间的抓取只会阻塞它,而等到锁释放时,浏览器就不再处于空闲状态。
孤儿进程。 生命周期钩子只会在正常关闭时运行。如果服务被终止、崩溃或其 stdio 传输中断,Chromium 会继续运行并继续占用配置文件,之后每次启动都会失败,并提示 "Opening in existing browser session",直到有人手动将其终止。
现在服务可以自行恢复:如果启动失败且看起来是配置文件锁导致的,服务会查找占用该确切配置文件目录的进程,先尝试正常终止,再强制终止,然后重试一次。无法接管孤儿进程——patchright 使用 --remote-debugging-pipe 启动,因此没有可供连接的调试端点——但不会丢失任何内容,因为会话保存在磁盘上的配置文件中,而不是进程中。
只有当解析后的配置文件路径原样出现在命令行中且可执行文件看起来是浏览器时,才会匹配。日常使用的 Chrome、其他自动化浏览器,以及仅仅提及该路径的任何进程,都不会成为候选对象;如果筛选器无法确定,就不会匹配任何进程。
设置
从 PyPI 安装(无需克隆):
uvx --from linkedin-sales-nav-mcp patchright install chromium # one-time browser download
或从源代码安装:
git clone https://github.com/nick-choudhary/linkedin-sales-nav-mcp
cd linkedin-sales-nav-mcp
uv sync
uv run patchright install chromium # one-time browser download
cp .env.example .env # optional; defaults are fine on your machine
1. 登录一次
uvx linkedin-sales-nav-mcp --login # PyPI install
# or, from a clone: uv run linkedin-sales-nav-mcp --login
浏览器窗口会打开。登录 LinkedIn,打开 Sales Navigator,完成任何双重身份验证(2FA)或检查点验证。服务会检测到已登录的会话并保存配置文件,然后退出。
2. 运行服务
uvx linkedin-sales-nav-mcp # stdio, PyPI install
# or, from a clone: uv run linkedin-sales-nav-mcp
Claude Desktop / Claude Code 配置
通过 PyPI 安装:
{
"mcpServers": {
"sales-navigator": {
"command": "uvx",
"args": ["linkedin-sales-nav-mcp"]
}
}
}
从克隆的仓库安装:
{
"mcpServers": {
"sales-navigator": {
"command": "uv",
"args": ["run", "--project", "/path/to/linkedin-sales-nav-mcp", "linkedin-sales-nav-mcp"]
}
}
}
配置中不包含任何密钥——会话保存在浏览器配置文件中。
使用 --project,不要使用 --directory。 两者都会让 uv 指向仓库,但 --directory 会将工作目录切换到仓库,这会让导出文件进入仓库,而不是你正在处理的项目。--project 不会改变工作目录,这正是下文的导出布局所预期的行为。
一次安装,供所有项目使用
每个配置都指向仓库路径会很繁琐。将命令安装到 PATH 中即可:
uv tool install linkedin-sales-nav-mcp # from PyPI
# or: uv tool install /path/to/linkedin-sales-nav-mcp (from a clone)
这样每个项目的配置都只需:
{
"mcpServers": {
"sales-navigator": {
"command": "linkedin-sales-nav-mcp"
}
}
}
无需路径,无需标志;移动仓库后也无需更新。拉取更改后,使用 --force 重新运行安装命令即可应用更改。
无论哪种方式,数据库都是共享的,登录状态也会沿用,因此新项目无需单独运行 --login——只需在其 .mcp.json 中添加对应条目即可。
一次只运行一个服务
你可以在任意多个项目中配置它,但同一时间只能运行一个。浏览器配置文件是持久化的 Chromium 配置文件,而 Chromium 会对其进行独占锁定,因此第一个服务运行期间启动第二个服务会导致浏览器启动失败。如果你使用 uv run,第一个服务还会占用仓库的 .venv,第二次运行 uv run 时可能会在尝试同步环境时失败。
环境变量
| 变量 | 默认值 | 用途 |
|---|---|---|
USER_DATA_DIR | ~/.linkedin-sales-nav/profile | 持久化浏览器配置文件 |
HEADLESS | false | false = 显示窗口(最安全);true = 无头模式(更容易被检测) |
CHROME_PATH | — | 使用你自己的 Chrome,而非捆绑的 Chromium |
PROXY_SERVER | — | 在自己的机器上留空;仅在远程时用于住宅网络出口节点 |
IDLE_BROWSER_TIMEOUT | 3600 | 空闲这么多秒后关闭浏览器;需要时会重新启动。0 表示保持打开 |
NAV_TIMEOUT / CAPTURE_WAIT / LOGIN_TIMEOUT | 60 / 25 / 300 | 超时时间(秒) |
TOOL_TIMEOUT | 600.0 | 每个工具的 MCP 超时时间(秒)——必须大于下方的节奏控制时长 |
PACING_ENABLED | true | 页面之间设置类似真人操作的延迟(见下文) |
PAGE_DELAY_MIN / PAGE_DELAY_MAX | 3.0 / 8.0 | 翻页前随机停留的时间(秒) |
LONG_PAUSE_EVERY | 5 | 每浏览 N 页后进行较长时间的休息(0 表示禁用) |
LONG_PAUSE_MIN / LONG_PAUSE_MAX | 20.0 / 45.0 | 休息时长(秒) |
STATE_DIR | ~/.linkedin-sales-nav | sales_nav.db 和原始采集数据的存放位置——可在不同项目间沿用 |
OUTPUT_DIR | output | JSON/CSV 导出的写入位置,相对于服务的运行目录 |
TRANSPORT / HOST / PORT / HTTP_PATH | stdio / 127.0.0.1 / 9000 / /mcp | 传输方式 |
LOG_LEVEL | WARNING | DEBUG、INFO、WARNING、ERROR |
数据存储位置
数据存放在两个目录中,因为它们的生命周期不同。
状态数据存放在 <STATE_DIR>(默认值为 ~/.linkedin-sales-nav,与浏览器配置文件位于同一目录):SQLite 数据库 sales_nav.db,以及 <url_hash>/raw/ 下的原始捕获数据。它属于你的 LinkedIn 账号,而不是任何单个项目,因此无论你从哪个目录启动服务,使用的都是同一个数据库——list_queries 会显示所有目录中的搜索历史。
导出文件属于项目产物,因此路径相对于工作目录解析。export_results 会将 JSON/CSV 写入 <OUTPUT_DIR>/<url_hash>/(默认为 output/<url_hash>/),保存到你运行搜索的项目目录中。数据库始终是唯一可信数据源;导出文件按需从数据库生成。
从 1.0 升级。 以前,数据库存放在相对于启动目录的
output/sales_nav.db中。从 1.1 起,数据库存放在~/.linkedin-sales-nav/sales_nav.db,不再从旧路径读取,因此现有的output/sales_nav.db看起来会是空的。你可以移动它(将sales_nav.db、sales_nav.db-wal、sales_nav.db-shm和<url_hash>/目录一起移动——-wal文件保存了近期写入的数据),删除它并重新运行搜索,或设置STATE_DIR=./output以保留旧布局。
Schema 版本 2(PRAGMA user_version = 2):
| 表 | 内容 |
|---|---|
queries | 每个搜索 URL 对应一行:url_hash、状态、last_page、total_available、records_count |
leads | 人员(34 列):姓名组成部分、member_id、职位、公司及 company_id、行业、地点、任职时长、open_link、premium、raw_json |
accounts | 公司(16 列):company_id、名称、行业、员工人数范围、描述、raw_json |
positions | 每个当前职位对应一行,因此,一位线索对象同时担任两个职位时会有两行。搜索响应不包含过往任职信息,因此不会存储这些信息 |
badges | 线索对象/账号的亮点徽章(共同联系人、“最近换工作”等) |
有两点值得注意:
- 始终存储
raw_json。 LinkedIn 的内部 API 没有文档说明,且会随时间变化,因此每一行都会保留完整的原始元素。如果规范化器遗漏了某个字段,之后无需重新抓取即可恢复。 - 记录会在多次运行之间去重。
UNIQUE(url_hash, record_key)配合INSERT OR IGNORE,可确保恢复运行、重新运行或重叠搜索时都不会产生重复行。
你可以使用任何支持 SQLite 的工具查询:
sqlite3 ~/.linkedin-sales-nav/sales_nav.db \
"SELECT full_name, title, company_name FROM leads LIMIT 10;"
数据库和导出文件都包含真实的个人数据。因此,output/ 已加入 .gitignore——请保持这一设置。默认情况下,<STATE_DIR> 位于仓库之外,因此不会有提交风险,但它是值得重点保护的数据副本:其中会累积所有项目的数据。
访问节奏
页面不会连续抓取。每抓取一页后,服务会随机等待 3–8s,再点击“Next”;每约 5 页(上下浮动 ±1 页)会暂停 20–45s。滚动节奏也会有所变化——包括步数、距离以及每次滚动之间的间隔。
这样做是为了控制访问节奏,而不是提高速度:无论会话看起来多么真实,每两秒加载一页、永不停歇且没有间隔,都会显露出机器行为的特征。因此,一次完整的 10 页调用需要几分钟,其中大部分时间是有意等待。这是预期行为。
使用 PAGE_DELAY_* / LONG_PAUSE_* 调整节奏,或设置 PACING_ENABLED=false 完全禁用延迟(不推荐)。如果大幅提高相关设置,也要相应提高 TOOL_TIMEOUT。
这会降低你的访问痕迹,但不会让你隐身。 账号被标记的原因是访问量;任何抖动设置都无法改变你今天查看了多少份个人资料。只抓取所需内容,分散抓取时间,并使用你自己的账号。
Agent 使用示例
用户: 找出柏林中型金融科技公司的工程主管。
Agent: 构建/获取一个 Sales Navigator 搜索 URL(粘贴一个,或使用 URL 构建技能),然后:
search_contacts( search_url="https://www.linkedin.com/sales/search/people?query=(...)", pages=2 )→
{"url_hash": "a6ca...", "new_records_this_call": 50, "total_records": 50, "next_page": 3, "status": "paused", ...}—— 这 50 条记录现已存入 SQLite,不会出现在回复中。Agent: 随后提取回答所需的内容:
get_results(url_or_hash="a6ca...", limit=25) # 用于推理的样本 export_results(url_or_hash="a6ca...", format="csv") # 或导出为磁盘上的文件
重要的诚实说明
- 字段映射尽力而为。 我们会捕获 LinkedIn 内部的 sales-api
JSON。该接口未公开文档,且会随时间变化。规范化器
(
sales_nav_mcp/normalize.py)会提取一直较为稳定的字段。如果某个字段 为空,无需重新抓取也有两种方法可以还原真实数据:每行的raw_json列,或搜索时设置include_raw=true,将完整且未经修改的 API 响应 写入<STATE_DIR>/<url_hash>/raw/。根据这些数据扩展映射器。这是预期的维护方式。 - “Next”控件的分页选择器可能会变化;代码会尝试多种回退方案,如果都不匹配,
则会正常停止。如果深度分页提前停止,请更新
sales_nav_mcp/capture.py中的_NEXT_SELECTORS。 - 在自己的计算机上运行。 数据中心/云 IP 会重新引入此设计旨在规避的 退出登录风险。
- 抓取 LinkedIn 受 LinkedIn 的服务条款约束。请使用自己的账号, 并控制在保守的频率。
开发
uv run pytest # full unit suite, no browser and no network
测试覆盖:URL 验证、使用合成值对照真实捕获元素结构进行 JSON 规范化、SQLite 存储和导出,以及节流—— 包括延迟计算及其在捕获循环中的接入;使用模拟页面和注入的时钟,确保测试套件不会实际等待。
未覆盖:实时浏览器流程。可通过运行 --login 后执行真实搜索来测试。
每次推送和 PR 都会在 CI 中运行同一套测试,并执行 ruff lint/format 检查(Python 3.12–3.14 的 Linux,以及 Python 3.13 的 Windows 和 macOS);发布流水线也会在发布任何内容前重新运行测试。
许可证及适用对象
MIT —— 详见 LICENSE。简单来说:任何人都可以免费使用、复制、 修改和再分发本软件,无论是否用于商业用途。唯一要求是在副本中保留版权和许可证声明; 本软件不提供任何担保。
该许可证不涵盖你与 LinkedIn 的关系:
- 本项目与 LinkedIn 无关,未获得其认可或支持。它会在 LinkedIn 自有的 Web 应用中自动操作浏览器。
- LinkedIn 的服务条款限制自动访问 LinkedIn。是否使用此服务由你自行决定并承担责任——请使用自己的账号、控制在保守的使用量,并接受账号可能受到限制的风险。
- 你收集的数据是真实个人的真实个人数据。根据你和数据主体所在地区的不同,处理这些数据可能受 GDPR 或 CCPA 等隐私法律约束。合规责任在于你,而非此工具。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。