Skip to main content
Glama
Sundeepg98

linkedin-mcp

by Sundeepg98

linkedin

一个 MCP 服务器,把你自己的 LinkedIn 账户数据显示为结构化的工具结果,而不是让你一页页点击浏览的页面。

它那十七个工具中有十四个只读取、不改变任何内容。三个会写入。

直到 2026-08-23,本段还写着 “它只读取。这就是它的全部工作。这个仓库中没有写入路径——不是被禁用,不是被留成存根,也不是藏在某个 flag 后面。” 那句话当时是真的,而且是通过机制强制保证的,并非仅仅是一种声明。但从 linkedin_save_job 发布那天起,它就不再成立。一个保留这句让人安心话的 README,是最先让读者信任的东西,也是最先误导他们的东西。

现在真实的情况是:

  • 这个包中只包含一个能够改变 LinkedIn 上任何内容的调用:writes.perform 里的一次锚定点击。源码扫描器仍然报告它——它没有被教成“看见也装作没看见”——而且它以路径、函数和类别被记录在一个一行的 allowlist 中;一旦这条 allowlist 扩大到其他地方,测试就会失败。

  • 写入操作默认关闭,除非你显式开启。 按进程设置 LINKEDIN_ENABLE_WRITES=1。一个全新的克隆仓库完全无法向 LinkedIn 写入。

  • 每次写入都是两个调用。 第一次调用不会执行任何操作,而是把一个数据块交给你读取;第二次调用从该块中兑换一个一次性 token。token 与目标上的一个动作绑定,并且 120 秒后过期;这让“定时写入”或“无人值守写入”在结构上不可能,而不只是不被鼓励。

  • linkedin_unsave_job 已经构建完成、受门控限制、并且 拒绝执行。参见 拒绝行动的那个

  • 它不会提交求职申请,而这并不是“耸耸肩”。 linkedin_job_detail 会报告 apply_path:一条招聘信息是在 LinkedIn 上申请,还是把你交给外部的申请人追踪系统,并指明是哪个系统。识别这一半功能以读取方式交付;提交这一半则出于一个经过考量的原因而被拒绝——参见申请交付的一半与未提交的一半


在读其他部分之前,请先读这一部分

LinkedIn 的用户协议限制对网站的自动化访问。 无论这个服务器如何构建,这一点都是成立的,下面任何内容都无法改变它。

这个设计所做的是把风险暴露降到最低,而不是假装它不存在:

选择动机

这为什么能降低风险

仅由人类操作驱动

每一次调用都是你在当下主动做出的。没有任何东西按定时器运行,也没有任何东西在你睡觉时运行。

一次只执行一个操作

每个工具调用只加载一个页面。没有滚动循环、没有自动翻页、没有大量发出的请求。

你自己的会话、你自己的机器、你自己的 IP

没有任何 cookie 被导出给第三方。没有代理、没有云计算 IP、没有无头浏览器集群。

一个普通浏览器,除了一个 flag

没有隐身插件、没有 user-agent 或平台伪装、没有指纹修复、没有模拟人类行为的时机设计。只传递了一个 Chromium flag —— --disable-blink-features=AutomationControlled,它阻止 Blink 设置 navigator.webdriver —— 因为 LinkedIn 在登录时会检查它,没有这个 flag 就会拒绝登录。仅此而已。它由 readonly.assert_launch_flags_permitted 在每次启动时强制检查,而 tests/test_launch_boundary.py 在出现第三个 flag 时也会使构建失败。

只有你的数据

你的个人主页浏览记录、你的申请、你保存的职位、你的个人资料、你的通知。不列举或采集其他会员的数据。

读取,除了三个被明确指出的写入

没有什么会被发送、发布、自动浏览、邀请或编辑。保存、取消保存和取消关注属于例外,默认关闭:每次你都根据一次实时读取生成的数据块进行确认,使用的 token 只能使用一次,两分钟后过期。这一行在 2026-08-23 之前写的是“只读”,现在这句话被修正了,而不是悄悄放宽。

这能降低暴露风险,但不能消除它。 自动化访问仍可能导致限流、账号验证,甚至账号被采取行动,而这是你要自己决定接受的。请在注册这个服务器之前,有意识地完成这个决定。

这不是一个“反检测”工具,下面是证据,而不是嘴上保证

上面那一个 Chromium flag 正是那种让一个仓库看起来像在逃避检测的东西。这里有必要直接把测量的结果拿出来说,因为这个声明是可检查的,读者不必只凭语气来相信。

在 2026-08-24 对所有 105 个受控的文件进行审计:

  • 指纹塑造:零。 没有 user-agent、平台、语言、时区、地理位置、视口伪装、设备缩放、WebGL 或 canvas 的修改;没有 page.route 拦截、没有注入 init 脚本、没有追加的请求头、没有代理。这些名字都从整个项目中去搜索过,而每一项返回的都是 个调用点。有一个需要注意的地方,以免用 grep 查找的人被误导: add_init_scriptreadonly.py 中出现两次,但都是“扫描器本身用于检测这类调用的模式”。扫描器会列出它禁止的东西,正因为如此,它的源码里才有这些名字,而项目其余部分都没有—— partition_mutation_hits 用一个被允许的调用点和零个无授权的调用点,独立地确认了这一点。

  • 没有隐身依赖。 三个依赖,没有一个是反检测库;在这种情况下,是 readonly.scan_source_for_evasion 在整个包中返回零命中——它在任何地方的唯一“命中”,是测试里唯一一处故意放的治疗对照组。

  • 时机是固定的,不是被“人性化”的。 每个延时都是常量,并且在包中 import random 出现 0 次。页面之间的 3 秒等待由 MIN_INTERVAL - elapsed 计算并精确执行——这是机器般的规律。随机抖动是那些模仿人类的工具才会做的,这种恒定期望值反而是限流。

  • flag 本身有门去围住它,而不仅仅是靠良好意图: readonly.assert_launch_flags_permitted 在每次启动时运行,并且如果出现第三个 flag,tests/test_launch_boundary.py 就会让构建失败。

这个 flag 阻止了 Blink 暴露 navigator.webdriver,而 LinkedIn 在登录时检查这个属性,并且正是这一点使得一个自动化浏览器对账号合法持有者来说也是不可用的。这就是它的全部功能。让一个自动化浏览器正常工作与逃避检测是两件不同的事,这里只有前者。

许可证由此而来,而且它刻意是“不宽松”的

这个仓库是专有的:保留所有权利,仅供参考,不允许任何人使用、复制、修改或分发。

这不是一个疏忽,也不是一个占位。这个服务器驱动着一个经过身份验证的 LinkedIn 会话,而该会话受到一份禁止自动化的用户协议约束。一个宽松的许可证会邀请陌生人把它指向他们自己的账户——或者更糟,指向别人的账户——从而使得记录了这个仓库的文档同样类目的证据以他人的名字成为行动指南。

它是一个作品集项目。它的目的是被阅读,而不是被部署。 请阅读设计本身、阅读它的边界、阅读那扇“大门”、阅读审计轨迹;这才是它的意义所在。


Related MCP server: LinkedIn MCP Server

它还能做什么

工具

读取内容

linkedin_who_viewed_me

谁查看过你的个人主页。如果账号拥有 Premium Career,则时间范围可达 365 天——这是求职中意图最强的信号。

linkedin_my_applications

你申请过的职位,以及 LinkedIn 显示的状态。

linkedin_saved_jobs

你收藏的职位。

linkedin_search_jobs

按关键字、地点、远程、发布日期、经验级别进行职位搜索。

linkedin_job_detail

一条完整的职位——薪资范围、LinkedIn 的申请人数、工作场所与雇佣类型、招聘状态,以及职位描述。这些在搜索结果卡片或已保存职位卡片上都没有。此外还有 apply_path:这条职位走两条申请路径中的哪一条;如果是站外路径,它会把数据送入哪家申请者跟踪系统。

linkedin_followed_companies

你关注的公司主页,并带有各自的数字 id——linkedin_unfollow_company 靠该 id 定位。LinkedIn 无论你关注了多少家公司,都只渲染约二十行,而且没有翻页方式可以去查看其余内容,因此本工具报告的是它覆盖了哪些行,而不表示已覆盖全部。

linkedin_my_profile

你自己的个人档案:标题/头衔、简介、技能,以及哪些板块被渲染了。Experience/Education/Skills 由 LinkedIn 延迟加载,直到页面滚动后才出现,因此它们读取为 UNKNOWN 而不是零。

linkedin_notifications

你的通知列表。

linkedin_auth_status

是否存在有效会话,通过一次经过认证的请求来判定。

linkedin_login_browser

打开一个窗口,让你自己登录。

linkedin_session_info

会话是否存活以及 这是一个失效的日期,从浏览器自身的 cookie 容器读取。它报告凭据、支持该凭据的 csrf cookie、持久性,以及为什么这里没有任何静默重新认证。renewal.session_lapses_at 是这样一个日期:超过之后任何续期都无效,只能手动登录——这是跨服务器比较时所用字段;在 LinkedIn 上,它等于 cookie 自身的过期时间,因为这里没有任何东西能让会话活过该时间。

linkedin_logout

通过清除本机 cookie 容器来结束 本地 登录。Elice 这里的首个破坏性工具:confirm=False(默认值)不执行任何操作,只预览将会移除的内容。它不会发出任何请求,因此 LinkedIn 永远不会被通知。

linkedin_cdp_status

恢复诊断:是否存在一个此服务器可以附着的 Chrome?在 LinkedIn 上没有任何触碰。

linkedin_server_info

边界、速率设置和启动参数,而不读取源码。

三个会写入接的词

工具

它做什么

linkedin_save_job

收藏一个职位。如果不带 confirm_token 调用,它不会执行任何操作:它会实时读取职位和你已保存列表,并返回一个信息块,其中按职位名称和雇主命名该职位、收藏开关会往哪边动、每个事实出自哪里、以及如何撤销。再次调用并附上该信息块中的 token,即可真正执行操作。

linkedin_unsave_job

结构相同,门槛相同,并且 它会拒绝。见下文。

linkedin_unfollow_company

停止关注一个公司页面。结构相同,并且有还有五道门槛。它是通过 公司 id 来定位,绝不会用名称——名称会冲突、会改,也不是你依赖的东西,而且点击被固定在该 id 所在的行上,因此你所指称的东西和实际点按到的就是同一行,构造上就是如此。

点击之后,结果会从一个不同的页面——也就是你的已收藏列表,连同 LinkedIn 自己每个标签页的计数——而不是刚刚按过的按钮那里得到确认。返程"unknown"。遇到 "unknown" 时不要重试:因为如果这个开关其实已经生效,重试反而会执行相反动作。

那个会拒绝的工具

LinkedIn 通过它的可访问名称来识别保存控件。本仓库持有的每一条捕获——四个职位、两种 hydration 状态、两个不同的日期——都显示是 aria-label=" Save the job ”,也就是 未保存 状态。它在一个职位 已有收藏 时所使用的名称从未被观测到,而这也无法通过读取来观测:因为目前没有任何已收藏的职位可供观察。

所以 linkedin_unsave_job 没有锚点,而本服务器不会自行猜一个。候选 "Saved""Unsave the job" 都貌似合理,可它两个都没见过。因此这个选择被备注工程,而是给出真实原因,而不会说“not implemented”,因为“not implemented”会诱使别人挑一个字符串去实现它。

解决的办法只有一行。 第一次受监督的保存会产生“该 reveal” it:perform 会回读控件变化的标签,并把它报告出来。把它写入 shape.SAVE_LABELSunsave_job 就有了锚点。这是一个公开行为,而不是缺少代码路径。

申请:有用那一半和一个无用的一半

linkedin_job_detail 会告诉你一个 How do you apply a posting for?。 LinkedIn 把“申请”控件绘制成链接而非按钮,因此不触碰它就能看到目标地址,并且 apply_path 报告下面之一:

  • linkedin_apply —— 在 LinkedIn 站内填写并提交申请。

  • offsite —— LinkedIn 将你转向雇主的申请者跟踪系统。目标地址仅通过从 LinkedIn 的离站 wrapper 中解码出来,除此无其他字符串:不会跟随任何重定向,也不联系任何第三方主机。所以你得到是主机地址,就知道要面对的是哪一家的表单。

  • unknown —— 它不会说。这是一个真实且重要的回答;见下文。

而这条可而这很有唤醒一半,不需要额外的页面加载,而且它是一个纯粹的读取。

它不会提交。 这不是因为申请不属于本服务器范畴内,而是因为这些实际测量所得的原因:

  1. apply FLOW 从未被捕获过。 在十三次职位捕获中,表单为零、文件输入为零、对话框为零、筛选问题为零、能提交任何内容的控件也为零。这里没有任何捕获见过“会填写什么”或“会按下什么”。unsave_job 一直承受的正是这条标准,现在它被应用到了最配得上这条标准的操作上。

  2. 从这一处无法撤销任何申请。 无论在哪个确认层级、在任何情况下都不行。撤销申请被永久禁止。

  3. 站外的那一半根本不属于这个 server 的职责。 无论捕获做得有多好,在别人的域名上、按照别人的条款驱赶着别人的表单,那是另一款软件的事。

因此 apply_jobwrites.py 中被完整地写出规范并加以限制,不注册任何工具,也不持有任何 url,所以对它的授权在签发时就被拒绝,而不是等到使用时才被拒绝。

而这个缺口有一个确定的地址——这正是它“未被测量”而非“永久存在”的原因。 scripts/_probe_apply_flow.py 捕获 LinkedIn 托管的流程,并逐一清点每个既有捕获所缺失的控件——表单、文件输入、对话框、筛选问题、那个真正提交的控件。它通过导航而非点击到达该流程(LinkedIn 把 apply 控件渲染成一个链接),本包自带的 mutation 扫描器在它上面发现处可变调用,并且它把 job id 作为必要参数,因此不会因为某种默认值而替你挑一份职位。它还会在前后读取 LinkedIn 自己的 applied 标签页计数,因为打开一次 Easy Apply 流程可能会产生一份草稿——这个假说没有人验证过,但被明确标注为一个假说,并被测量而非想当然。

它尚未运行过。 请在一个其 apply_path 显示为 linkedin_apply 的职位上、找个人陪着你运行它。

为什么分类器要求多个字段一致,而一个显而易见的字段看似已经足够。每个候选都被测量过,而且每一个单独都会失败:data-view-name="job-apply-button" 在十三个捕获中只出现于一个,并且在一个完全水化(hydrated)的站外职位上不存在,所以它的缺失不携带任何信息;外层包装是通用的——一个捕获里有两个这样的包装,但其中只有一个是 apply 控件;可访问名称是单个字段里最强的证据,却又恰是 LinkedIn 已经改掉的那一个:字符串 "Easy Apply" 出现在零个可访问名称中,却在同一页面的正文里出现两次,所以以大家都认识的名字为键的解析器什么都匹配不到。至于水化前的 payload 比一无是处还糟——一个站外职位被测量出携带站内流程自己的标记,并且 job id 相同,这是因为 LinkedIn 把整套 apply 状态机作为“每个职位一份”的模板发放出来。

它刻意不能做什么

Messaging、InMail、连接邀请。个人资料编辑。Open To Work。关注一家公司。发帖、点赞、评论、翻牌。把通知标为已读。收集其他会员的数据。提交申请——正如上文所述。

这些不是“未实现的功能”,而且这些“不能”并不是同一种“不”。linkedin_server_info 把每一项都标为 POLICY、MEASURED 或 UNMEASURED,因为“我们出于原则拒绝它”“我们看过了,它不会起作用”和“还没有人看过”是三句不同的陈述;而一个把这三者压扁到一起的列表,正是一个未经考察的缺口被误读成“一项设计决定”的方式。

Following(关注)是最有意思的那个,它的理由在 2026-08-24 改变了,但答案没有变。它以前被封禁,是因为不存在“取关”这个操作——这个 server 能制造一种自己无法消除的状态。现在这个操作存在了。但它仍然无法被实施,因为撤销操作无法定位目标:一个职位帖子用 slug 来表示它的雇主,而取关页面却按数字 company id 对排找到行;而且这个 repo 里没有任何一次捕获,能够在一个任一动作会使用的表面上同时拿到同一家公司的这两个标识。此外,该表面只渲染了大约 58 行中的 20 行,没有分页控件,所以其中大部分列表在一次页面加载中都不可及。这份拒绝说明会把两个原因都写上,也写清楚什么东西可以解除这一点。

读取自己的收件箱是 UNMEASURED,而不是被拒绝的。 读取边界挡住了 /messaging,而所有为那次拦截写下的理由,都是以发送为出发点的。读,是否真的可能,从来没有被试验过。scripts/_probe_messaging.py 的存在,就是为了验证它,同时也是为了验证那个通常被跳过的部分。假设是——未经验证,这才是关键的一点——LinkedIn 桌面端的在消息视图在加载时会自动打开一个会话,因此对收件箱的“读取”会把一个会话标记为已读,于是“通知”的那种反对意见,就会通过一个自称“只读”的工具,出现在你面前。这个探针通过读取 /feed/ 上的导航徽标在内部前后进行测量——而那次加载本身不会触碰这个表面。它尚未被执行,在它被执行之前,被禁止的清单将保持不变:一个测量不落地的声称,不构成边界变化。

任何其他改动 LinkedIn 服务器上状态的东西,都不在范围之内,并且如果整个 package 里出现第二个 mutating 调用,tests/test_readonly.py 就会让 build 失败。

只有一个工具会改变这台机器上的状态:linkedin_logout(confirm=True) 会清除本地 cookie jar。它不发任何 request,所以 LinkedIn 永远不会知道;linkedin_server_info 会把它列为 local_state_writes 的一部分,而不是并入 read_only 字段。

会被实际触发的两个副作用,明确声明出来而不是藏着

一次读取如果有副作用,就得说出来:

  1. 打开通知页面会清除 LinkedIn 的未读徽标——正如你自己打开那个页面那样。这是被测量过而不是被推测出来的:在 2026-08-21 调用一次后,徽标从报名数量从 1 变成 0,而它此后不会回来。它不可避免——LinkedIn 在页面被送达时就把“列表已读”标记在服务端,因此不存在任何“读取了该页面但徽标保持原样”的读法。没有点击、没有滚动、没有逐条打开,也不存在任何 mark-as-read 调用。唯一避免清除徽标的办法就是不调用 linkedin_notifications 既然徽标无论如何都会消失,那么每一行都携带 unread 字段,其值与 LinkedIn 在读取那一刻记录下的一致——这正是那次页面加载所摧毁的一个事实。

  2. 运行一次职位搜索,会在你自己的近期搜索历史中加入一条记录——与你本人在网站上手动输入该查询完全相同。

两者都在工具 docstring 以及在 linkedin_server_info 中披露了。


Setup

cd D:\Sundeep\projects\job-hunting\mcp-servers\linkedin
pip install -r requirements.txt
playwright install chromium
python -m pytest            # 986 passed

然后,如果 server 已注册到一个 client,那就先调用 linkedin_login_browser。一个窗口将在 linkedin.com/login 打开。请你自己在那个窗口登录——这个 server 永远不会看到、输入、存储或传输任何密码。持久化 Chrome 配置会在之后保留这个会话,所以这是一次性的步骤,一直到 LinkedIn 自己使会话过期为止。

在相信任何读取之前,先用 linkedin_auth_status 确认。

注册它

stdio transport,入口点 linkedin.py

{
  "mcpServers": {
    "linkedin": {
      "command": "python",
      "args": ["D:\\Sundeep\\projects\\job-hunting\\mcp-servers\\linkedin\\linkedin.py"]
    }
  }
}

“只读”是如何被强制执行而不是仅仅被声明的

linkedin_server/readonly.py 持有四种机制,而测试会在相信这些机制能适用于真正的 package 之前,先展示它们在一个植入的非法行为下会失败。一个不可能失败的检查什么也证明不了。

  1. 导航白名单。 assert_read_url 是到达 page.goto 的唯一通道。每一个允许的 url 都是一个被锚定的模式;你输入的关键词不能变成对某个 action url 的导航。被拦截的目标包括 /jobs/application//messaging/、邀请、以及 /edit/open-to-work、任何带 action= 的路径,以及所有 host 不在 www.linkedin.com 的地址。职位帖子模式是整个清单里最严的一种:它只接受数值型 id,并且完全不允许 query string,因为 url 是由整数拼出来的,本身就无可保留。LinkedIn 同时托管的 slug 形式(/jobs/view/senior-node-engineer-at-acme-4600000042)也因为这个原因被拒——slug 本质上就是一个 job title,而 title 是一个纯字符串。

  2. 源码扫描器。 整个 package 会被扫描以寻找一切可能产生副作用的调用——clickfilltypepressselect_optionset_input_files、表单提交请求,以及任何非 GET 的请求。它找到的唯一的一个调用,是 writes.perform 里的那个 click。这个扫描器并没有为了它而放宽;它仍然无保留地报告每一个可变调用。真正接纳这个调用的,是一个只有一行、键为 (path, function, kind) 的单独 allowlist,readonly.SANCTIONED_MUTATIONS。这三个组成部分每一项都在拒绝某个真实存在的东西:dom.py 里的一个 clickwrites.py 里另一个函数的一个 click,都会拒绝;而 perform 内部出现的一个 fill 也会被拒——连同隐藏在下一层闭包中的一个 click 也会被拒绝,因为归属是在最内层的函数上。那五个接近但并没有的案例,每一个都在测试中被展示出来是失败的。此外还单独断言:package 中 mutating 调用的次数与 allowlist 的条目数完全相等——于是 perform 里出现你看不见的 第二个 click 也会被发现,而这个三元组本身无法区分它。

    evaluate 也会被标记出来:三个最终的只读 DOM 采集器各自用行尾的 # readable-ok 来为自己豁免,因此任何一个新出现的 evaluate 都会让构建失败,直到有人在 可 review 的 diff 中为它豁免。

  3. 工具界面检查。 任何工具名称都不能包含一个“写”动词,任何工具 docstring 也不能做出肯定性的写入声明。Docstring 仍然可以说明一个工具不能做什么——“has no way to add or remove anything”就是只读工具该有的句子,因此这项检查寻找的是否定句型,而不是屏蔽那些词语。

  4. 启动边界。 assert_launch_flags_permitted 会拒绝除了两个被允许的 flag 之外的任何 Chromium flag,并且拒绝携带除 AutomationControlled 之外任何值的 --disable-blink-features。因为那个 flag 可以关闭任意的 Blink 行为,所以只允许这个名字是不够的。browser.py 会把它运行在任何一次 launch 之,于是它约束的是运行时,而不仅仅是 CI。与此配套,另一个扫描器会拒绝由依赖形态进入代码库的反检测库(playwright_stealthundetected_chromedriver、验证码求解器、TLS 伪装客户端)——而匹配只针对 import 行,这样这个文件就仍然可以用叙述性的文字来描述这么一小段边界。

注入的脚本也会单独被扫描,查找任何可能修改页面的内容(.click(.value =dispatchEventfetch(,等等)。它们会去查询 DOM 并读取文本。

这种扫描是被绑定在实际会被执行的东西上,而不是绑定在那些“被命名为某某”的东西。测试会解析 package,找出每一次 page.evaluate(...) 调用的第一个实参,把它解析到其模块级常量并对该常量进行扫描——因此一个脚本无法在不被读取的情况下被注入;万一这个检查无法解析它(比如它在运行时才被拼装起来),那么整个构建就会直接失败。它之前的一个版本扫描的是手写的三个以 _JS 结尾的名字组成的手写清单;有一次严苛的 review 让一个名叫 EVIL_INLINE 的常量携带者 localStorage.setItemfetch( 穿过了原有的调用点并通过了所有测试后发布。现在这个洞已经被堵上,而那是罪证已经成为了一个测试。

与这个 server 同一天发布的一个姊妹 server 做的正相反:它只要看到一个 session cookie 存在,就立刻报告成功。LinkedIn 也会把 cookie 发给尚未登录的访问者,所以那份“成功”什么也不代表。

而这个 server 的结论来自 GET /voyager/api/me——这是 LinkedIn 自己的 web 应用在页面加载时所发起的身份识别调用。一个 li_at cookie 的出现,本身只是充分理由去再次征求这个终点的结果。

这里报告三种结果,而不是两种:

  • authenticated: true——端点返回了一个身份。

  • authenticated: false——端点拒绝,或者 feed 被重定向到了尚未登录的墙上。

  • authenticated: null——两者都无法被建立。“未知”不会坍缩成“未登录”,否则这个 server 会在你的会话完全正常的情况下叫你不必再登录一次。

佐证只能把一个“未知”变成 false;它绝不允许凭借更弱的证据制造出一个 true

Cookie 是凭据:它们从不被记录,绝不由该服务器持久化,也绝不会出现在工具结果中。只报告它们是否存在,有两个测试断言了这一点。

登录,以及它能持续多久

调用 linkedin_login_browser 一个 Chrome 窗口会在 LinkedIn 的登录页面打开,你在窗口中键入即可。这个服务器永远不会看到、键入、存储或传送密码——不存在任何能做到这一点的代码路径。窗口会保持打开,直到身份端点确认了真实会话、你关闭它,或者 wait_seconds 超时(默认为 300;如果你需要更长时间,可传入更大的数字)。

这是一次性步骤,不是每次会话都要做。 会话保存在磁盘上的 Chrome 配置目录 _state/chrome-profile/ 中,因此它能经受以下事件:

事件

会话能存活吗?

原因

本服务器重启

会话在磁盘上,而缓存中。

机器重启

同上。

配置目录被删除

那个目录就是会话本身。

在窗口内部退出登录

LinkedIn 会撤销它。

LinkedIn 让 cookie 过期

见下文。

LinkedIn 给的时间有多长。 linkedin_session_info 会读取 li_at cookie 的过期日期和剩余天数,直接从浏览器自身的 cookie 存储中取——所以你永远不必猜测;而且这是实测值,不是 README 的背景。为便于参考,该配置中 LinkedIn 自己的长效 cookie(bcookiebscookie)签发时的过期时间是 365 天。真正决定登录的是 li_at 这个值,只有真实登录才能产生它。

Cookie 是凭据:从不记录,绝不由该服务器持久化,也绝不出现在任何工具结果中。只呈现名称、是否存在,以及过期时间。

当 cookie 过期时,这个服务器的每一个读取工具都会报错——{"error": "not_authenticated", "message": "..."},并指出 linkedin_login_browser 是恢复路径。它绝不会用空列表来替代响应;来自过期会话的空列表,与你确实没有任何数据时的空列表是无法区分的。

冷启动,以及其中的陷阱

li_li 是一个持久 cookie。而 JSESSIONID——LinkedIn 自己的网页应用会把它复制到 csrf-token 空中,没有它,尝试验证身份就不会被应答——是一个会话 cookie(本配置的 cookie 存储中为 is_persistent=0)。因此,浏览器每次冷启动时,cookie 库里都装着一个完全有效的登录态,却没有任何 csrf token。

如果服务器不考虑最初就去请求身份端点,它会发出一个不带 token 的请求,被拒绝,然后告诉你重新登录——其实你的会话是完好的。所以,在冷 cookie 库上,check_auth 会先加载一个 LinkedIn 页面,让 LinkedIn 发出这个 cookie,然后才向身份端点询问。这次加载同时也充当了"确认"的存在,因此不新增额外请求。

恢复路径:附加到你自己的 Chrome

这不是日常路径。 上面提到的持久化配置就是解决方案;这里是当那个配置文件里的会话失效,而我们又保存不中登录时的后备方案。设置 LINKEDIN_CDP_ATTACH=1 后,这个服务器不再启动任何浏览器——它只通过 CDP 连接到启动的那个 Chrome。

有两件事会让它静默失败,这两点都在这台机器上实测过:

  1. 从任务栏打开的 Chrome 没有 DevTools 端口。 "我的浏览器是开着的"并不够;它必须以 --remote-debugging-port 启动才行。

  2. Chrome 的 Singleton 会吞掉那个参数。 如果已有任意一个 Chrome 在运行,带参数再启动第二个时,参数会被递给第一个并退出——没有端口,没有报错,退出码是零。

所以,要么先完全退出 Chrome(包括正在运行的窗口以及后台进程),这样才能保住你的真实配置,也才保得住你在 LinkedIn 中的真实会话:

"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9224

要么为它单独准备一份配置,让它和正在运行的 Chrome 并存,但这个新配置里未登录任何账号,你需要在它打开的窗口中手动登录一次 LinkedIn:

"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9224 --user-data-dir="%LOCALAPPDATA%\linkedin-cdp"

打开 http://127.0.0.1:9224/json/version 来确认是否生效——能返回 JSON 就说明端口已经就绪。或者直接调用 linkedin_cdp_status,它会为你探测该端口,并在没有任何进程监听时告诉你该用哪条命令来启动。地址使用 127.0.0.1 而不是 localhost:Chrome 只把端口绑在 IPv4 上,解析的 localhost 会先命中 ::1,多等一次超时(实测为 2085 毫秒,而 IPv4 只需要 35 毫秒)。

端口用 9224,这样就不会和姊妹项目 Naukri 服务器的 9223 端口冲突。

在附加模式下,这个服务器不计取配置锁(它不触及任何配置),而是在自己的标签页里工作,不会操作你任何一个已有的标签页;退出时也只断开连接,不会关闭你的浏览器——Playwright 在 CDP 连接上调用 close() 只会断开客户端,这一点在真实 Chrome 上用过之后才正式采用。两种模式下,禁止写入工具的白名单完全相同。

频率上的克制

  • 两次网页加载之间至少有 2 秒间隔,全局生效。这是克制、规则,而不是伪装:它刻意让请求不会"看起来像别的样子"。

  • 每次工具调用只加载一个页面。 唯一例外的是 linkedin_my_profile(include_skills=True),它会加载第二页,并在结果中报告 pages_loaded: 2

  • 不自动翻页。 请主动用 start=25 来请求某个搜索的下一页。每个列表结果都带有 cappedpage_hadlimit,因此"25 条结果"绝不会被误当成"总共有 25 条"。

  • 同一时刻只处理一个调用,调度器会串行执行;同一时刻只允许一个进程,通过 Chrome 配置目录上的跨进程锁保证串行。两个进程共用同一个 Chromium 用户数据目录,会把它损坏、让你丢失会话。这个问题的代价是让一个姊妹项目损失了 37 分钟。

  • 窗口即使闲置也会在 5 分钟后自动关闭,并释放锁。

当某些内容无法读取时

服务器会直接报错,而不是返回空列表来掩盖。一个渲染失败的页面上得到的空列表,和你确实没有任何数据的空列表没有区别,因此两者绝不能混淆。读取失败时会返回 {"error": "extraction_failed", "url": ..., "hint": ...},这样你可以自行打开同一个页面,看看到底遇到了什么。

唯一的例外是 linkedin_search_jobs,因为那里 0 个结果本身就是合法回答;此时返回 results: [],同时附上一条 note

页面是如何被读取的

LinkedIn 的 class 名每次都会自动生成,其 GraphQL 查询 id 每次发布也会轮换,因此这两样都太脆弱,不能作为锚点。真正稳定的锚是链接的形状:人在 /in/<slug> 后面,职位在 /jobs/view/<id> 后面。它的每一种列表页面都是先找到这些链接,再读取它们所在卡片上的文本,最后由 shape.py 中的纯函数负责解析——正因如此,解析可以在不浏览器、不联网、不登录账号的情况下被完整测试。

通知是唯一没有稳定的每项链接的页面,所以它是留给结构来定位的。这处代码最需要随 LinkedIn 变化而更新,如果它抓取不到某条,会报错而不是静默返回空列表。

结构

linkedin.py              entry point (stdio)
linkedin_server/
  config.py                  paths, timeouts, caps, the rate floor,
                             the two launch flags
  readonly.py                the allowlist, the scanners, the verb list,
                             the launch boundary
  profile_lock.py            cross-process lock on the Chrome profile
  browser.py                 persistent context, single-flight, idle close
  auth.py                    the login gate, session lifetime, cold start
  cdp_bridge.py              the recovery path: attach to a running Chrome
  dom.py                     the read-only harvesters and the control readers
  shape.py                   pure parsers and the result envelope
  server.py                  the seventeen tools
  errors.py
tests/                       1393 tests, no network, no account
  fixtures/                  frozen LinkedIn markup, scrubbed

状态

已构建并经过测试:1393 个测试,全程不需要网络,也不需要账号。它们多数完全不需要浏览器;依赖 fixture 的模块会启动一个本地无头 Chromium,在内网的静态版本元素上真实跑一遍那些读取逻辑,这些内容不会触达这台机器以外的任何位置。

其中重复提到的这些数字最可能是老是错的。它曾在测试总数突破一千之后,连续三章仍然写着 986……这个数字本身是没问题的,但它就是那种让四份文档里继续写着"这个服务器不能运行"的恶习所在。所以现在每一次提到这个数量时,我们都会重新实测量一次,别忘了直接继续沿用前一次的数字。

第一次直播运行:2026-08-21。 登录成功,会话得以持久保存。因此,上面的标志位在这台机器上已经验证为充分,同时确认了 /voyager/api/me 端点,以及 li_at 的存活期(365天)。之后我们使用真实账号逐一运行了包括截图在内的每一只读工具。11 个工具里有 4 个第一个运行就正常;本次实验全程已记录在 ../_audit/2026-08-21-linkedin-parse-fix.md 里,下面是它的发现。

linkedin_who_viewed_me 当时返回出来的是"不是名字"的名字。 每一行都带着页面标题"谁看了你的主页",这是页面里真实人物链接被剪开后的名字——一共四行,一个重复的公司名,四行链接都是真实的。当天就修复了:行的归还准则不再依赖于 LinkedIn 在 hydration 后才会补齐的属性;受隐私限制的查看者不再默默消失(这些人占了它的 10 个中的 6 个),还顺带读了备注。线下在线验证通过:10 行,10 个不同名字,没有一个缺少字段。

第二轮修复,2026-08-22。 上一遍结尾有 3 个接点仍然是坏掉的,已修复并通过真——验证地复测。所有 4 处缺陷的形态完全相同:要么是当时的读取逻辑以文本 Linked 已经不再输出的标记为锚,要么是依赖渲染程度而变化的某种标记。

工具

之前原来的

现在

linkedin_my_profile

报错:读不到任何名字

从一个完全没有h1 的页面中读取姓名、头衔、所在地、"关于"和照片。现在,一个"section"指是由它自带的 heading 最大的祖先表示,且这个祖先里正好只有一个 heading——这与"按行读取"时是规则——因此页面签入前后两种状态,输出结果完全一致。线下端已验证。

linkedin_saved_jobslinkedin_my_applications

在重定向上报错

现在读取位于 /jobs-tracker/?stage=saved?stage=applied 的页面。这两处列表确实都是空的,因此现在显式地说明"空"并返回 LinkedIn 自己显示的计数与空态文案。若页不能后台佐证为"0",仍算读失败。线下已验证。

linkedin_notifications

有行数据,但带了噪声

屏幕补充文本现在是用"数量"而不是按"词组"去减除,when 会取卡片自身的时间节点。每一行还会带上当前读取时判定得到的 unread 状态。已用某次真实页面冻结存档做过验证。

my_profile 里的 skills

之前是返回 AllIndustry KnowledgeTools & Technologies

现在返回了真实的技能列表——该账号有 20 个技能,全部归键在对页面每一个技能唯一的锚点下。

有一个配置文件 profile 读取那里再强调不会做的事情:"Experienced/ Education/ Skills 这三个字段并不在主页页面的原始 HTML 上——LinkedIn 是将它们延迟到滑动到对应位置时才去加载,而这个服务器不模拟滚动。所以它们会被标记为 UNKNOWN(未知),而不是给为 0,details_async_urls 提供了访问它们的地址入口单。

第三次补测,2026-08-22。 linkedin_search_jobs 是最初最后一个还有问题的工具。在对一个"已验证雇主"的职位 search 满列表中,LinkedIn 会在那一行里加一行屏幕读取用的文本" with verification"("某某公司优先");原本按这个"按位置读取"的错误逻辑,thistext 被当成了公司名,而真实的公司名则被它挤到了 location 里。这在两次实际线上搜索里各坏了 14 行中的 5 行。

该修复并不是针对某个字符串的规则。字段不再按“第 1 行、第 2 行、第 3 行”的方式读取,因为 LinkedIn 插入的任何一行都会使它后面的所有字段发生偏移,而同样的这两个页面上都出现过“Promoted”“Apply”“Viewed”“Actively reviewing applicants”、一个薪酬标签和一条校友信息行。如今,每个字段都锚定在标识着它的元素上:职位名称锚定在让该行成为职位行的链接文本上,并按出现次数扣减页面自身为屏幕阅读器提供的文案;公司锚定在 LinkedIn 为雇主 logo 提供的可访问名称上——logo 是一张图片,所以不会被某一行文本推走;地点锚定在实体组合块内部的元数据列表上,而该组合块无需任何类名,只需定位到同时包含该链接和那枚 logo 的最小祖先元素即可。若某个界面这些锚点一个都没有——求职跟踪器正是如此——就照旧回退为按顺序读取各行,同以前一样。

在同一个查询上做了实时验证:7 行中的 7 行全部与 LinkedIn 自有的 artdeco-entity-lockup 元素一致,而该修复刻意没有使用这些元素;这 7 行中有 3 行带有验证装饰标记。测试在每个冻结行的每个位置逐一注入一种 LinkedIn 尚未发布的装饰标记,并要求解析结果不得随之移动;同时还提供了对照组,展示一旦去掉这些锚点,同样的注入就会把所有字段全部打乱。

Install Server
F
license - not found
A
quality
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Sundeepg98/linkedin-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server