career-ops linkedin-join 实战:用本地脚本交叉比对 LinkedIn 人脉与求职漏斗,零 token 离线找出"认识的人在哪家公司"
本文基于 career-ops 仓库中的 docs/LINKEDIN_JOIN.md 展开。它解决一个很具体、也很贵的问题:我的求职漏斗里的公司,是否恰好有我认识的人? 读完后你会掌握完整的操作流程:如何从 LinkedIn 导出人脉数据、如何用 linkedin-join.mjs 与 data/applications.md 跟踪表和 portals.yml 扫描目标做交集比对、三级公司名匹配算法(exact/strong/weak)的设计取舍,以及这套工具在隐私边界上的精确约定。
定位:一个零成本、只读、离线的人脉交叉查询
linkedin-join.mjs 回答一个问题:"我是否已经认识漏斗中某家公司的人?"
它的工作方式是:读取 LinkedIn 导出的 Connections.csv,将其中的雇主名与 career-ops 已经在跟踪的公司名单做匹配,只输出交集。整个过程无网络请求、无模型调用、不写任何磁盘——运行一次的成本是零 token,耗时约等于读一遍 CSV,因为它只做了这一件事。
从 linkedin-join.mjs 的文件头注释看,脚本对自己有两个刻意的否定,这是理解整个功能定位的关键:
- 它不是评估输入。A–F 区块(evaluation blocks)才是报告分数的所有者,认识某家公司的人不会让一个岗位变得更匹配;
- 它不是内容来源。脚本输出的任何内容都不得变成 CV、cover letter 或申请表里的陈述。
它改变的只有一件事:你联系谁、通过哪个渠道联系。AGENTS.md 的脚本清单中同样把它标注为 "Operational only: never a scoring input, never a content source",与文件头注释互相印证。
第 1 步:获取 LinkedIn 人脉导出
- LinkedIn → Settings → Data Privacy → Get a copy of your data;
- 只勾选 Connections,而不是申请全量归档。
Connections.csv是这个脚本唯一读取的文件,而范围更窄的请求 LinkedIn 出件更快; - 文件就绪后 LinkedIn 会发邮件附上下载链接;
- 解压后把
Connections.csv放进data/:
mv ~/Downloads/Connections.csv data/
有两个容易踩的坑值得提前说明:
导出文件的表头前有自由文本前言。 文件开头是一段 Notes: 前言,而 LinkedIn 以前改过这段前言的长度。不要手动删它——解析器按内容而非行号定位表头。看 linkedin-join.mjs 中的 findHeaderRow():
export function findHeaderRow(rows) {
for (let i = 0; i < rows.length; i++) {
const lower = rows[i].map(c => c.trim().toLowerCase());
if (lower.includes('first name') && lower.includes('company')) return i;
}
return -1;
}
只要某一行同时含有 first name 和 company 两个单元格,就认定为表头。测试 tests/linkedin-join.test.mjs 用 5 行前言验证了这一行为:前言变长不会导致列错位。
CSV 解析是严格的 RFC 4180 实现。 LinkedIn 会给任何含逗号的 Position 或 Company 字段加引号(如 "Director, Strategic Accounts"),而引号内还可能出现换行和双引号转义。linkedin-join.mjs 内置的 parseCsv() 逐字符状态机处理了这三类情况,parseConnections() 则按表头列名(而非列序)取单元格——上游列重排不会悄悄让所有字段错位。
不想放默认位置? 下面所有命令都接受 --csv <path> 指定任意路径。
第 2 步:运行
最常用的一条命令:
node linkedin-join.mjs --summary
输出按公司分组(示例输出):
LinkedIn warm-intro join
5 connections · 4 target companies · 4 companies with a connection · 4 people
── In your tracker ────────────────────────────────────────
Siemens Digital Industries Software [#1 Applied · Staff Engineer]
· Jane Doe — Director, Platform Engineering — since 2021-08 — strong match: "Siemens"
https://www.linkedin.com/in/janedoe
2nd-degree: https://www.linkedin.com/search/people/?keywords=Siemens%20Digital%20Industries%20Software&network=%5B%22S%22%5D
── Scanner targets (no application yet) ─────────────────────
Ørsted
· Mette Sørensen — Wind Analytics Lead — since 2023-11
https://www.linkedin.com/in/mettes
2nd-degree: https://www.linkedin.com/search/people/?keywords=%C3%98rsted&network=%5B%22S%22%5D
Notes:
! 1 connections have no employer listed (cannot join)
! 1 target rows skipped (placeholder names)
Review before acting: a 1st-degree connection is not automatically a warm intro.
两个区块对应两份被交叉的公司名单:
- In your tracker — 来自 data/applications.md 跟踪表的公司,所以行内带有 tracker 编号、状态和岗位;
- Scanner targets — 来自
portals.yml中启用的tracked_companies,即你尚未投递的公司。
从源码看,这两份名单分别在 linkedin-join.mjs 的 parseTrackerTargets()(复用 tracker-parse.mjs 的列解析)和 parsePortalTargets()(用 js-yaml 读取,跳过 enabled: false 的条目)中生成。另外,tracker 行内的括号内容(如代理机构名 "FORT (client undisclosed)")会被单独提取为一个目标——认识代理机构的人同样是 warm intro。
逐个公司提问
批量报告只列出有匹配的公司,这对摘要场景正确,但对直接提问是错的——空白会被读成"没查过"而不是"查过了,没人"。--company 无论是否命中都会用文字回答,而且该公司不必已被跟踪(例如扫描刚冒出来的新公司):
node linkedin-join.mjs --company Datavant --summary
Do you know anyone at "Datavant"?
Yes — 1 connection:
· Ravi Patel — Staff Data Engineer — since 2019-02
https://www.linkedin.com/in/ravipatel
Second-degree (not in any export — open it yourself, nothing is fetched):
https://www.linkedin.com/search/people/?keywords=Datavant&network=%5B%22S%22%5D
Do you know anyone at "Stripe"?
No. No first-degree connection in your export lists this company.
(Strict name matching. Try --include-weak for looser variants.)
从 main() 可以看到,--company 会临时构造一个 source: 'query' 的目标,绕开 tracker 和 portals 两个来源——这就是"公司不用先被跟踪"的实现依据。
把人脉推进你的通讯录
--tsv 输出与 data/contacts.tsv 形状一致的行,并跳过已在通讯录中的人。它是只打印、从不追加——你读完后把想留的行自己粘贴进去,之后 contacts.mjs(通讯录 → vCard 3.0 导出器)和 contacto 模式就从那里接手:
node linkedin-join.mjs --tsv
# name company type title phone email linkedin tracker# notes
Jane Doe Siemens Digital Industries Software peer Director, Platform Engineering https://www.linkedin.com/in/janedoe 1 LinkedIn 1st-degree, connected 2021-08-03. Name match strong: "Siemens". Verify current employer before outreach.
renderTsv() 里有一个安全细节:人名、职位、雇主都是外部控制的文本,若单元格以 =、+、-、@ 开头,粘贴进电子表格后会触发公式注入,所以这些单元格会先被加上撇号前缀(在 trim 之后判断,避免前导空格掩盖首字符)。
--tsv 的"是否已在通讯录"判断来自 parseKnownContacts():它读 data/contacts.tsv 已有的 name+company 键(用 tracker-parse.mjs 的 normalizeTextKey 归一化,保留任意文字系统),命中的人在报告中标记 already in contacts.tsv,--tsv 模式则直接跳过,避免重复建议。
全部参数
| 参数 | 作用 |
|---|---|
| (无参数) | 向 stdout 输出 JSON:targets、totals、quality 计数、当前生效的 filters |
--summary |
上文那种按公司分组的人读报告 |
--company <name> |
只回答一个公司,即使没有匹配(JSON;加 --summary 得到文字答案) |
--tsv |
向 stdout 打印 contacts.tsv 形状的行(从不追加) |
--tracker-only |
只用 data/applications.md 里的公司 |
--portals-only |
只用 portals.yml 扫描目标 |
--include-weak |
纳入 weak 级名称匹配(见下文——更噪) |
--since <YYYY> |
只保留某 4 位年份当年或之后建立的联系 |
--csv <path> |
从非默认位置读取导出文件 |
--self-test |
运行内联匹配器自检 |
--help, -h |
打印用法 |
参数解析有两个值得注意的契约,都来自 lib/cli-flags.mjs:
- 带值参数同时接受
--flag value和--flag=value两种形式; - 值为空的带值参数是用法错误而非静默取默认值;未识别的参数退出码 1 而非落到默认行为——一个拼写错误不可能悄悄给你一份你没要的名单。
linkedin-join.mjs 的注释记录了这段的工程动机:手写 indexOf() 解析器看不到 --flag=value 形式,--csv=/other.csv 会被静默丢弃,工具转而读取 data/Connections.csv 并打印一份"看似合理但查询的是没人要的文件"的报告。
--since 的保守语义:无法解析日期的联系不会被放过,而是被排除,并在 JSON 输出中计入 quality.undatedExcludedBySince。"一个你永远不知道存在的 warm intro"是这个功能里代价最高的失败,所以这些计数值得读。此外从源码看(main() 的注释),--since 0000 会转换成 0,因此判断是否启用过滤器用的是 sinceYear !== null 而非真值判断——否则这个过滤会被静默忽略。测试 tests/linkedin-join.test.mjs 专门覆盖了这条边界。
第 3 节:公司名是如何匹配的
LinkedIn 里的雇主名是自由文本,精确字符串相等会漏掉大部分真实命中。算法是:名称先折叠(大小写、重音、标点、空格),然后拆成全部 token 与区分性 token(剔除通用行业和法人词),比较时产生三级结果:
| 级别 | 规则 | 示例 |
|---|---|---|
exact |
折叠后的 key 相等 | GE HealthCare ~ GE Healthcare |
strong |
区分性 token 集合相等,仅通用填充词不同 | Siemens ~ Siemens Digital Industries Software |
weak |
区分性 token 有重叠但集合不等 | Epic Systems ~ Epic Games |
默认展示 exact 与 strong;weak 需要显式加 --include-weak。
核心比较逻辑在 matchCompany():
const identical = da.size === db.size && shared.length === da.size;
const substantive = shared.some(t => t.length >= 3);
if (identical && substantive) return 'strong';
return 'weak';
三条设计红线,直接决定了这个功能可不可信:
strong要求区分性集合相等,而非嵌套。Epic被包含在Epic Games里,Blue被包含在Blue Cloud Ventures里,但它们指向的是与 Epic Systems、Optimal Blue 不同的公司。任一侧多出一个区分性 token 就改变了实体;只有Inc、Group、Technologies这类填充词允许不同。- 绝不使用子串匹配,所以
Loop永远不会匹配Loopio。 - 纯通用词永不匹配,所以
Monogram Health和Advocate Health各自独立。同理,匿名化的 tracker 行(Stealth Startup、?、Undisclosed)会因区分性 token 为空而整体弃为目标(parseTrackerTargets() 将其计入skipped,报告中作为 "placeholder names" 跳过),而不是去匹配恰好同名写法的任何联系。
"通用词"清单在 GENERIC 中,约 90 个词条,覆盖四类:冠词连词、法人后缀(inc/gmbh/pty 等)、公司填充词(holdings/ventures 等)、以及本管道垂直领域里高频到构成噪声的行业词(technologies/software/data/ai/health…),外加匿名占位词(stealth/undisclosed/client…)。
空格被刻意忽略。 companyTokens() 构造比较 key 时用的是 all.join('')——拼接而非空格连接(linkedin-join.mjs)。原因是 LinkedIn 雇主字符串在空格上的变化远大于用词上的变化:GoDaddy/Go Daddy、PayPal/Pay Pal、ServiceNow/Service Now 都是同一雇主的两种写法,只有无分隔符的 key 能把它们并起来。代价是 "A B" 和 "AB" 会碰撞,但从源码注释看,这个碰撞最终还要过"人眼读两个原始名"这一关,所以划算。测试 tests/linkedin-join.test.mjs 固化了这 5 个真实变体。
重音折叠双向进行,包括朴素折叠会静默丢字的不可分解字母。 这一点是整个模块最容易做错的细节。foldToken()(linkedin-join.mjs)委托给 lib/ascii-fold.mjs——仓库的规范名称折叠实现。单纯的 NFD + 去 combining marks 对 不可分解拉丁字母无效:ø 上的斜杠是字形本身,没有可剥离的组合标记,所以朴素折叠下 Ørsted 永远匹配不上 Orsted、Işık 匹配不上 Isik、Straße 匹配不上 Strasse。asciiFold() 内置了 ø→o、æ→ae、ß→ss、ı→i、ŋ→ng 等经过验证的映射表来补上这一类。
非拉丁文名被保留而非清空。 asciiFold 对完全没有拉丁内容的文本(CJK、西里尔、希腊)返回空串——对"与 ASCII 主机名比较"的调用方这是正确语义,但这里两侧都是自由文本,所以 foldToken() 在折叠结果为空时回退到 normalizeTextKey():后者先做 NFKC(全角/半角归一)、小写化、只删除标点类字符而保留所有文字系统的字母和数字,因此 株式会社X 依然能匹配 株式会社X 自己。
每次匹配都印出两个原始名。 只要两侧名称不完全相同,报告就会把 LinkedIn 侧的写法与跟踪侧的写法并排打印,并注明级别(strong match: "Siemens");JSON 输出里每条匹配都带 linkedinCompany 字段,exact 匹配也不例外。设计意图是明确写出来的:你是最后一道过滤器,这正是打印出这一对名字的意义。
第 4 节:二度人脉(second-degree)
导出文件只含一度联系人。二度边只存在于 LinkedIn 自己的 UI 里,不可导出,所以诚实的答案是给链接而不是给结果:每个目标都带一条预填好的 people-search URL,已过滤到二度网络(network=["S"])。
构造逻辑只有五行(secondDegreeSearchUrl()):
export function secondDegreeSearchUrl(company) {
const q = encodeURIComponent(String(company || '').trim());
return `https://www.linkedin.com/search/people/?keywords=${q}&network=%5B%22S%22%5D`;
}
career-ops 构造这个字符串但从不抓取它。你在自己的浏览器里、以本人登录状态下打开它。测试 tests/linkedin-join.test.mjs 断言了 URL 的三个性质:以 https://www.linkedin.com/search/people/? 开头、公司名经 encodeURIComponent(Acme & Co → Acme%20%26%20Co)、过滤到二度。
第 5 节:隐私边界
导出文件是第三方 PII——别人的姓名、雇主和主页 URL——所以值得精确说明它去了哪里。
脚本不做的事:无网络访问。无 LLM 调用。无任何写操作:不建缓存、不建索引、不留 sidecar 状态、不改你的 tracker 或通讯录。CSV 每次运行都是重新读取的,这意味着它是可丢弃的——用完删掉,需要时重新导出。data/ 目录被 gitignore,文件永远不会被提交,updater 也从不触碰它。只有你手动粘贴的行才会到达 data/contacts.tsv。
这一点在数据契约里有独立背书:DATA_CONTRACT.md 中 data/Connections.csv 一行明确写道 "Read fresh on every run by linkedin-join.mjs, which writes no cache, index or sidecar state, so the file is disposable… Never enters a prompt and never leaves the machine"。
会流动的是什么。 文件本身从不进入任何 prompt。但它的输出是另一回事:如果你让 CLI agent 去跑 join 而不是自己跑,agent 会读 stdout——而那份 stdout 是一串真实的人名、职位和主页 URL,它就此进入了该会话的上下文,受你的 CLI 对上下文的处理策略约束。
这也许正是你要的——问"我在 Datavant 认识谁?"并得到有用答案,就是这个功能的本体。但如果你希望第三方数据留在本机,就在终端里自己跑、当场读结果。脚本两种方式下行为完全一致,区别只在于谁来读输出。--company <name> 是窄口径选项:只返回一个公司的联系,而不是整个交集。
第 6 节:找不到东西时的退出行为
退出码 1 会告诉你原因,典型消息:
Connections export not readable: /path/to/career-ops/data/Connections.csv
Export it from LinkedIn (Settings → Data Privacy → Get a copy of your data → Connections),
drop Connections.csv in data/, or pass --csv <path> pointing at the file.
--tracker-only and --portals-only are mutually exclusive: together they exclude every target source. Pass neither to search both.
--since expects a 4-digit year, got "20".
"Stealth Startup" has no identifying words to match on (all generic or placeholder terms). Give a more specific company name.
这四条分别在 main() 的互斥检查、--since 的 4 位年份正则(/^\d{4}$/,拒绝 parseInt('2020x') 式静默截断)、以及 --company 的占位词检查中产生。注意所有文件读取都走 readOrNull()——路径不存在、是目录(existsSync 对目录返回 true,随后 readFileSync 会抛裸的 EISDIR 堆栈)、挂载或权限问题,一律收敛成 CLI 提示而不是崩溃。
退出码 0 但无匹配是真实答案而非失败。--summary 末尾的 Notes: 块解释被排除了什么、为什么:
CSV header row not found— 该文件不是 LinkedIn Connections 导出(按内容找表头失败);N connections have no employer listed— LinkedIn 对部分联系留空 Company 列,没有可 join 的键(parseConnections() 把它计入quality.noCompany而非静默丢弃);N unparseable "Connected On" dates— 只在用了--since时有意义。日期解析同时支持 LinkedIn 曾发布的两种形态03 Aug 2026与 ISO2026-08-03,且对"形状合法但日历非法"的日期(如31 Feb 2026、非闰年的29 Feb 2026)回读 Date 对象校验,报为不可解析而不是伪造一个看似真实的 ISO 串(parseConnectedOn();测试覆盖见 tests/linkedin-join.test.mjs);N target rows skipped (placeholder names)— 上文说的匿名公司行。
如果你预期有命中却没出现,试 --include-weak 并读它打印的原始名称;如果你怀疑匹配器本身有问题,node linkedin-join.mjs --self-test 不需要任何导出文件就能跑匹配器自带的 30 余项检查(大小写与重音折叠、不可分解拉丁、CJK/西里尔保留、嵌套降级、CSV 引号转义、表头按内容定位、去重合并等),全部通过时打印 PASS n/n self-test checks。
交叉合并的底层行为:一个补充视角
原文档说明了两个输出区块来自两份名单,源码里还有一个更微妙的机制值得知道:目标去重是按"匹配等价"而非"相同 key"进行的(joinConnections())。tracker 里的 Akamai 和 portals 里的 Akamai Technologies 是同一雇主(strong 级,只是填充词不同)但 key 不同;如果只按 key 去重,同一条联系会被两个目标各匹配一次、报告两遍,而 portals 那份还带着"尚无投递"的误注。实现的做法是:tracker 来源先排序在前,后续目标若与已保留目标构成 exact/strong 等价即被合并——留下的副本携带 tracker 上下文(编号、状态)。但 weak 级等价绝不合并,因为 weak 本身就意味着两个名字很可能是不同公司,合并会捏造出匹配器自己都拒绝断言的等价关系。JSON 输出里的 targetCount 因此报告的是合并后的数量而非输入列表长度。
相关文档
- docs/SCRIPTS.md — 完整脚本参考(其中
linkedin-join.mjs一行即指向本文所依据的文档); - DATA_CONTRACT.md — 哪些文件属于你的、哪些归 updater 管,含
data/Connections.csv与data/contacts.tsv两行的完整契约; - contacts.mjs —
--tsv行的最终去向:求职通讯录与 vCard 3.0 导出器; - tests/linkedin-join.test.mjs — 匹配、解析、去重、日期、URL 生成的完整测试契约(运行方式:
node --test tests/linkedin-join.test.mjs)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00