首页
/ career-ops linkedin-join 实战:用本地脚本交叉比对 LinkedIn 人脉与求职漏斗,零 token 离线找出"认识的人在哪家公司"

career-ops linkedin-join 实战:用本地脚本交叉比对 LinkedIn 人脉与求职漏斗,零 token 离线找出"认识的人在哪家公司"

2026-09-05 11:34:27作者:侯霆垣

本文基于 career-ops 仓库中的 docs/LINKEDIN_JOIN.md 展开。它解决一个很具体、也很贵的问题:我的求职漏斗里的公司,是否恰好有我认识的人? 读完后你会掌握完整的操作流程:如何从 LinkedIn 导出人脉数据、如何用 linkedin-join.mjsdata/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 人脉导出

  1. LinkedIn → SettingsData PrivacyGet a copy of your data
  2. 只勾选 Connections,而不是申请全量归档。Connections.csv 是这个脚本唯一读取的文件,而范围更窄的请求 LinkedIn 出件更快;
  3. 文件就绪后 LinkedIn 会发邮件附上下载链接;
  4. 解压后把 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 namecompany 两个单元格,就认定为表头。测试 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.mjsparseTrackerTargets()(复用 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.mjsnormalizeTextKey 归一化,保留任意文字系统),命中的人在报告中标记 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

默认展示 exactstrongweak 需要显式加 --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';

三条设计红线,直接决定了这个功能可不可信:

  1. strong 要求区分性集合相等,而非嵌套。 Epic 被包含在 Epic Games 里,Blue 被包含在 Blue Cloud Ventures 里,但它们指向的是与 Epic Systems、Optimal Blue 不同的公司。任一侧多出一个区分性 token 就改变了实体;只有 IncGroupTechnologies 这类填充词允许不同。
  2. 绝不使用子串匹配,所以 Loop 永远不会匹配 Loopio
  3. 纯通用词永不匹配,所以 Monogram HealthAdvocate 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 DaddyPayPal/Pay PalServiceNow/Service Now 都是同一雇主的两种写法,只有无分隔符的 key 能把它们并起来。代价是 "A B" 和 "AB" 会碰撞,但从源码注释看,这个碰撞最终还要过"人眼读两个原始名"这一关,所以划算。测试 tests/linkedin-join.test.mjs 固化了这 5 个真实变体。

重音折叠双向进行,包括朴素折叠会静默丢字的不可分解字母。 这一点是整个模块最容易做错的细节。foldToken()linkedin-join.mjs)委托给 lib/ascii-fold.mjs——仓库的规范名称折叠实现。单纯的 NFD + 去 combining marks 对 不可分解拉丁字母无效:ø 上的斜杠是字形本身,没有可剥离的组合标记,所以朴素折叠下 Ørsted 永远匹配不上 OrstedIşık 匹配不上 IsikStraße 匹配不上 StrasseasciiFold() 内置了 ø→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 & CoAcme%20%26%20Co)、过滤到二度。

第 5 节:隐私边界

导出文件是第三方 PII——别人的姓名、雇主和主页 URL——所以值得精确说明它去了哪里。

脚本不做的事:无网络访问。无 LLM 调用。无任何写操作:不建缓存、不建索引、不留 sidecar 状态、不改你的 tracker 或通讯录。CSV 每次运行都是重新读取的,这意味着它是可丢弃的——用完删掉,需要时重新导出。data/ 目录被 gitignore,文件永远不会被提交,updater 也从不触碰它。只有你手动粘贴的行才会到达 data/contacts.tsv

这一点在数据契约里有独立背书:DATA_CONTRACT.mddata/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 与 ISO 2026-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.csvdata/contacts.tsv 两行的完整契约;
  • contacts.mjs--tsv 行的最终去向:求职通讯录与 vCard 3.0 导出器;
  • tests/linkedin-join.test.mjs — 匹配、解析、去重、日期、URL 生成的完整测试契约(运行方式:node --test tests/linkedin-join.test.mjs)。
登录后查看全文
热门项目推荐
相关项目推荐