简介LinkedIn员工信息采集爬虫项目面向需要批量获取指定公司公开员工资料的研究人员、数据分析师与招聘从业者。项目以公司名称为检索入口通过Python脚本完成搜索、页面解析、详情抓取与数据落盘并提供应对反爬机制的实现思路适合有一定Python基础、希望快速理解LinkedIn公开数据采集流程的开发者参考。压缩包共3个文件包含1个Python主脚本、1个Markdown说明文档及1个Git忽略文件整体仅5KB结构简明便于直接阅读和二次修改。目前已有1601人学习适合作为轻量级爬虫教学样例或人才挖掘辅助工具。开源代码保留了核心请求与解析逻辑README中对项目定位、运行方式与合规注意点均有说明拿到手后可结合自身需求调整抓取字段、存储格式与请求策略快速搭建面向特定公司的LinkedIn信息采集原型。1. LinkedinSpider 是什么输入公司名单输出一张员工线索表LinkedinSpider 解决的是一个非常具体的业务问题你手里有一批目标公司名单想拿到这些公司里的关键人物——销售找采购决策人猎头找技术骨干市场人员找行业里活跃的发言人。手动操作是这样的打开平台、搜公司名、进入公司主页、翻员工列表、逐个记录职位和城市两三百家公司就是一到两天的机械劳动复制粘贴还容易串行。把流程脚本化之后输入是一个公司名单的文本文件输出是一张结构化的员工信息表姓名、当前职位、所在城市、个人主页链接甚至最近几段工作经历。这篇笔记从“搜公司名”讲到“拿到员工信息并清洗成可用的线索表”覆盖技术选型、核心代码、参数边界和最容易翻车的地方适合手里有明确名单但缺少联系人数据的从业者直接照着落地。2. 选型与原理为什么“公司名→员工信息”不是简单解析搜索页2.1 三条技术路线为什么最后选了“浏览器辅助 HTTP 接口”第一反应是直接解析网页 HTML发请求、拿静态页面、用解析库抽取员工卡。这条路不是不行而是页面结构频繁调整平台每次改版你的解析逻辑就要跟着改维护成本很高。第二反应是纯手工整理用浏览器内置的搜索、复制功能做刚才说过百来家公司还能忍上千家就没有可行性。第三条路是“浏览器辅助 HTTP 接口”先用浏览器模拟登录拿登录态然后找到网页端实际调用的内部接口这些接口返回的是结构化 JSON解析起来比 HTML 稳定得多。三条路线的对比如下方案入口抗改版能力改造成本适用场景纯 HTML 解析搜索页/员工列表页弱改版即失效低一次性小批量浏览器人工复制手动搜索不涉及代码极高几十家以内浏览器辅助 内部接口网页端实际调用的 JSON 接口中上接口相对稳定中几百到几千家我一般会优先选第三条。原因是内部接口的返回字段是服务端直接给的字段名、嵌套结构都比较固定而且分页、排序、筛选都有明确的参数。你只需要做一次抓包把接口的域名、路径、请求头和分页参数记下来后续批量跑的时候用普通请求库。2.2 关键三样东西登录态、CSRF Token、分页游标不管选哪条路有三样东西是绕不开的。第一是登录态很多平台的员工列表页在未登录状态下只返回前几条结果甚至不返回人员数据所以你要有一个可用的账号会话把登录后的 Cookie 延续到脚本里。第二是 CSRF Token平台内部接口为了防跨站请求几乎所有请求头里都要带一个令牌这个令牌通常在登录后的首页 HTML 或某个固定接口的响应头里可以拿到。第三是分页游标员工列表接口很少用“第几页”来分页而是用一个不透明的游标字符串标记当前位置请求时带上上一轮返回的游标才能拿到下一批数据。这三样东西只要有一个不对接口的表现就是 401、空列表或者反复返回第一页。后面第 3 章的代码里会逐一说明怎么取、怎么传、怎么判断失效。先记住一点不要把这三样东西硬编码死在脚本里它们都有过期时间要设计成可刷新、可重新导入的配置项。2.3 能稳定拿到哪些字段又有哪些字段抓不到明确字段边界能避免后期大量返工。我通常把字段分成两类一类是稳定可用的一类是可遇不可求的。稳定字段包括姓名、个人主页链接、当前职位头衔、所在城市或地区、行业关键词、头像、是否在职。这些字段在员工列表接口和个人主页接口里都有而且格式比较规整。不稳定或者说经常缺失的字段包括完整工作经历、教育背景、邮箱、手机号、入离职时间。工作经历和个人主页的可见范围直接挂钩和登录账号的社交关系、对方的隐私设置有关邮箱和手机号这类联系方式平台基本不会直接返回不要指望脚本能拿到更别把“抓联系方式”写进预期里。一句话脚本能做的是把公开页面上已有的信息结构化不是去挖隐私数据。注意平台员工列表页展示的往往不是公司全量员工而是“与你已有社交关系或相似背景的人”优先排序的结果。这意味着你拿到的是一份优质样本不一定是完整名册。批量脚本输出的覆盖率和名单质量要以此为基础做评估。3. 核心脚本搭建从公司名字到员工信息的三段式实现3.1 环境准备与登录态导入环境比较简单Python 3 加两个常用库就够。需要用到的库有requests、pandas如果想让流程更自动化可以加一个浏览器自动化库来帮你在第一次登录时取 Cookie这不是必须的因为手动从浏览器开发者工具里复制也能完成同样的目标。下面是最小环境准备和请求头模板# 最小环境 # pip install requests pandas import requests import time import json import random import re import html BASE_URL https://www.linkedin.com HEADERS_TEMPLATE { accept: application/vnd.linkedin.normalizedjson2.1, accept-language: zh-CN,zh;q0.9,en;q0.8, # csrf-token 后续通过 get_token() 动态填充 csrf-token: , # cookie 从浏览器登录态的请求头里复制按 k1v1; k2v2 格式 cookie: , }这段代码的核心是请求头模板。其中accept字段是平台内部接口要求的响应格式如果你抓到的请求头里是别的值以抓包结果为准不要照抄。cookie是登录态的载体直接在浏览器开发者工具里找到任意一个接口请求复制它的 Cookie 请求头即可。提示Cookie 有效期通常在几小时到几天不等脚本要设计成“检测到 401 就停下来提醒重新导出 Cookie”而不是自动重试避免账号被风控盯上。3.2 第一步公司名搜索拿到公司实体 ID公司名不是直接拿去拉员工列表的平台内部以“实体 ID”为主键所以第一步要做公司名搜索拿到匹配的实体 ID 和公司主页信息。搜索接口通常带类型过滤参数只返回公司类型的候选避免混入人、职位、群组等噪音。def get_token() - str: 从首页 HTML 里提取 csrf-token首次运行时调用一次即可 resp requests.get(BASE_URL /feed/, headersHEADERS_TEMPLATE, timeout15) # 不同平台的 token 位置不一样常见做法是从 HTML 中查找 namecsrf-token m re.search(rnamecsrf-token content([^]), resp.text) return m.group(1) if m else def search_company(keyword: str) - list[dict]: 输入公司名返回候选公司实体列表 url f{BASE_URL}/voyager/api/guest/navigation-typeahead params { keywords: keyword, type: COMPANY, q: typeahead, start: 0, } resp requests.get(url, headersHEADERS_TEMPLATE, paramsparams, timeout10) resp.raise_for_status() candidates [] for item in resp.json().get(elements, []): entity item.get(entity, {}) urn entity.get(objectUrn, ) # 形如 urn:li:company:123456 candidates.append({ id: urn.split(:)[-1], name: entity.get(name), headline: entity.get(headline, ), # 行业/规模信息一般在这里 url: entity.get(navigationUrl, ), }) return candidates逻辑说明typeahead是搜索建议接口输入关键词后返回一组候选实体。代码里取了objectUrn的最后一段作为公司实体 ID这个 ID 是后续所有接口的主键。headline字段虽然不是公司全称但通常会带上行业描述可以用来辅助判断同名公司。如果没有返回结果优先检查 Cookie 是否过期而不是改参数。参数说明typeCOMPANY是类型过滤不加的话会混入个人用户start0表示从第一条开始keywords会自动做 URL 编码不需要手动处理。搜索结果通常最多返回 8 个候选这是平台侧限制脚本层面尊重就好。3.3 第二步拉员工列表处理分页游标拿到公司实体 ID 之后请求员工列表接口。这个接口的分页参数在不同时期不一样老版本用start/count新版本用cursor正确的做法是抓包对比第一页和第二页的请求差异看看平台到底用了哪个参数。def fetch_people(company_id: str, cursor: str , count: int 24) - tuple[list[dict], str]: 拉取公司员工列表返回 (人员列表, 下一页游标) url f{BASE_URL}/voyager/api/organization/companies/{company_id}/people params { count: count, cursor: cursor, # 首页传空字符串后续传上次返回的 nextCursor q: people, start: 0, # 部分版本用 start/count以抓包结果为准 } resp requests.get(url, headersHEADERS_TEMPLATE, paramsparams, timeout15) resp.raise_for_status() payload resp.json() people [] for item in payload.get(elements, []): hit item.get(hitInfo, {}) public_id hit.get(publicIdentifier, ) people.append({ public_id: public_id, # 个人主页 URL 后缀 name: hit.get(name, {}).get(text, ), title: hit.get(title, ), # 当前职位头衔 location: hit.get(location, ), # 城市/地区 profile_url: f{BASE_URL}/in/{public_id}, }) # 游标位置新接口通常返回 nextCursor老接口可能是在 paging 里 next_cursor payload.get(paging, {}).get(nextCursor, ) return people, next_cursor逻辑说明这个函数的关键在cursor参数的传递。第一次调用传空字符串接口返回第一批数据和下一页的游标第二次调用时把游标传进去如此循环直到游标为空。注意这里每次调用返回的不只是数据还有游标调用方要用while循环包住整个拉取过程见 3.5 节的调度逻辑。参数说明count24是平台网页端的分页大小一次性拉太多会被接口拒绝保持 24 到 50 之间比较稳妥。publicIdentifier是个人主页 URL 的后缀比如某个用户主页是/in/john-smith那么public_id就是john-smith。这个 ID 是后续去重和详情抓取的关键字段。3.4 第三步进个人主页抓详情字段员工列表接口能拿到姓名、职位、城市这些基础字段但工作经历和教育背景在列表接口里一般没有需要进个人主页才能拿到。个人主页的抓取有两种路径一种是把页面源码里内嵌的 JSON 抠出来另一种是直接调个人资料接口。内嵌 JSON 的方式更通用因为页面加载时会把首屏数据直接写在 HTML 里。def fetch_profile(public_id: str) - dict: 抓取个人详情页解析内嵌 JSON url f{BASE_URL}/in/{public_id} resp requests.get(url, headersHEADERS_TEMPLATE, timeout15) text resp.text # 常见做法页面初始数据在 code idbootstrap-data-xxx 标签里 m re.search(rcode idbootstrap-data-([a-f0-9])(.*?)/code, text, re.S) if not m: return {public_id: public_id, name: , experiences: [], education: []} raw html.unescape(m.group(2)) data json.loads(raw) profile data.get(data, {}).get(profile, {}) experiences [] for exp in profile.get(experience, []): experiences.append({ company: exp.get(companyName, ), title: exp.get(title, ), start: exp.get(timeRange, {}).get(start, {}).get(year, ), end: exp.get(timeRange, {}).get(end, {}).get(year, ), }) return { public_id: public_id, name: profile.get(name, ), headline: profile.get(headline, ), city: profile.get(geoLocationName, ), experiences: experiences, education: [e.get(schoolName, ) for e in profile.get(education, [])], }逻辑说明re.search用正则从 HTML 里找到内嵌 JSON 所在的code标签re.S让.能匹配换行符避免 JSON 跨行时截断。html.unescape把页面里的\u0026这类转义字符还原成可解析的原文。拿到 JSON 之后按嵌套结构逐层取字段。这个函数的难点不在请求而在解析路径不同页面版本的字段嵌套层级可能不同写完后要随机抽几个页面验证。参数说明public_id直接拼在 URL 里不需要额外编码因为这个 ID 本身只包含字母、数字和连字符。timeRange里的start/end字段是工作经历的时间区间旧版本只有year新版本可能带month兼容写法是只取year做展示。教育经历同理取schoolName即可。3.5 全流程调度断点续爬与频率控制单家公司的流程跑通后就要考虑批量调度了。批量爬虫最容易犯的错误是两个一是没有任何进度记录跑到一半挂了要从头再来二是请求频率太激进触发风控。def run_pipeline(companies_file: str, output_file: str) - None: 主调度公司名单 - 员工列表 - 个人详情 - CSV 输出 companies [line.strip() for line in open(companies_file, encodingutf-8) if line.strip()] for idx, company in enumerate(companies, start1): # 断点续爬跳过已完成的公司 if idx get_last_progress(progress.pos): continue candidates search_company(company) selected select_best_candidate(candidates, company) # 见第 4 章 cursor while True: people, cursor fetch_people(selected[id], cursorcursor) for p in people: profile fetch_profile(p[public_id]) save_row(output_file, company, selected, p, profile) if not cursor: break time.sleep(random.uniform(3, 6)) save_progress(progress.pos, idx)逻辑说明主循环由两层组成外层遍历公司内层翻员工列表。内层循环的退出条件只有一个就是cursor为空说明列表页已经翻完。random.uniform(3, 6)是请求间隔单位是秒这个间隔不是拍脑袋定的而是基于接口响应速度和风控阈值综合出来的一个保守值。参数说明companies_file是输入文件每行一个公司名建议先用 10 家验证再放全量。progress.pos是断点文件每次跑完一家公司就写一次当前索引脚本中断后下次启动会跳过去。save_row函数负责把数据追加写入 CSV为了防止中断丢数据每拿到一个人就写一行不要攒到最后统一写。注意断点续爬不只是为了省时间更是为了降低风控风险。一旦脚本因为登录态失效中断恢复后从断点继续而不是从头刷一遍能明显减少短时间内的重复请求。4. 让数据变得可用清洗、去重与公司名匹配4.1 公司名归一化同一个集团为什么抓到七八个实体直接拿原始公司名去搜索返回的候选实体里经常出现“集团母公司”“某事业部”“某地区分公司”等多个实体。比如搜“某某科技集团”结果里可能有“某某科技集团股份有限公司”“某某科技集团杭州分公司”“某某科技集团创新研究院”它们确实是同一个集团但实体 ID 不同员工列表也完全不同。如果脚本自动选了第一个候选很可能选到的是分公司数据范围就不对了。def normalize_company(name: str) - str: 公司名归一化去空格、去后缀方便比对 name name.strip().lower() name re.sub(r[\s\-_/], , name) for suffix in [inc, ltd, llc, gmbh, 株式会社, 有限公司, 集团, 股份]: name name.replace( suffix, ).replace(suffix , ) return name.strip()这个函数的逻辑是把公司名里的空格、连字符统一替换成单个空格再把常见的公司后缀去掉得到一个干净的核心词。它解决的问题是同名公司在不同结果里的表述差异但要注意归一化只能辅助比对不能作为最终选型依据。最终还是要看实体 ID、行业描述和公司主页的 URL。我一般会这么做先用 30 家公司的名单做一次预跑把搜索接口返回的所有候选实体存下来人工确认一遍哪个实体对应原始名单里的公司然后把“公司名 → 实体 ID”的映射固定到一个本地 JSON 文件里。批量跑的时候优先查映射文件查不到再走搜索接口。这样既保证了准确性又避免了每次搜索时选错实体。4.2 员工去重与职位字段清洗员工数据抓下来之后去重是第一步。同一个员工可能既出现在“员工列表”里又出现在“可能感兴趣的人”推荐里或者因为一家公司对应多个实体同一个人被重复抓了两次。去重的关键不是按姓名而是按public_id因为姓名可能重名但个人主页 URL 后缀是唯一的。def deduplicate(rows: list[dict]) - list[dict]: 按 public_id 去重保留第一次出现的记录 seen set() result [] for row in rows: pid row.get(public_id, ) if pid and pid not in seen: seen.add(pid) result.append(row) return result逻辑说明这里用set记录已经见过的public_id遇到重复直接丢弃。如果你用的是 pandas 处理数据等价操作是df.drop_duplicates(subset[public_id])。注意去重时机放在数据汇总之后、导出 CSV 之前而且去重要以“同一个人”为准而不是以“同一个人在同一家公司的记录”为准因为有的人可能同时出现在两家关联公司的页面上。职位头衔的清洗同样重要。抓回来的title字段经常有这几类问题全大写、前后带空白、含有 Emoji、平台本地化语言混杂。我的处理习惯是先去掉 Emoji 和乱码符号再做首字母大写最后过滤掉长度小于 2 的无意义短词比如“未知”“null”“-”。这一步不追求完美目标是让职位头衔在后续的 Excel 筛选和分类统计里能看、能排序。4.3 质量校准随机抽 30 条人工核对清洗代码写得再花哨也不如人工抽检一遍。我的习惯是每次跑完一批数据不管规模多大都随机抽 30 条出来核对三个维度姓名是不是真实人名公司实体是否对应原始名单里的公司职位头衔是否可读且合理。核对方法很简单把抽样结果导出成 CSV打开后逐条对比原始名单。import pandas as pd def quality_check(df: pd.DataFrame, sample_size: int 30) - pd.DataFrame: 随机抽样并输出方便人工核对 sample df.sample(nsample_size, random_state42) return sample[[company_raw, company_name, employee_name, title, city, profile_url]]这个函数没什么技术难度但它背后的标准很重要。如果 30 条里出现 2 条以上公司实体不对说明第 4.1 节的实体映射有问题要停掉批量任务回头修映射如果出现 5 条以上title为空或不可读说明详情页解析路径可能变了要去抓包对比字段层级。质量抽检不是走流程是给批量脚本踩刹车的机会。5. 避坑排查Linkedin 爬虫最容易翻车的五个地方5.1 登录态莫名失效接口开始返回 401现象脚本跑得好好的突然一大批公司返回 401或者fetch_people里的resp.raise_for_status()直接抛异常。这时候如果继续跑后面的数据全部是空浪费请求还容易被风控。原因Cookie 过期是最常见的其次是同一账号在多个设备登录导致旧的会话被顶下线。还有一个隐蔽原因csrf-token没有随 Cookie 一起刷新Cookie 还在有效期内但 Token 已经失效了接口也会拒绝请求。解决在脚本里加一个统一的异常拦截遇到 401 就立即停止主循环输出“登录态失效请重新导出 Cookie”的提示不要自动重试。同时把 Cookie 和 Token 的获取封装成独立函数重新导出后只要更新配置文件就能继续跑。血的教训是别把 Cookie 写死在代码里写到外部文件里不然每次换 Cookie 都要改代码。5.2 列表翻到第二页就抓不到人现象fetch_people第一页返回 24 个人第二页开始返回空列表或者返回的数据和第一页完全一样。原因分页参数传错了。老接口用start/count分页第一页start0第二页start24新接口用cursor而且start参数可能被忽略。如果你按旧习惯传了start24而接口实际期待cursor它只会返回第一页的数据。解决抓包对比第一页和第二页的请求 URL重点看 URL 参数差异。如果差异在cursor字段上就把start参数从代码里删掉专注维护游标如果差异在start上就反推count是否正确。这个判断决定权在真实接口行为不在代码习惯。5.3 明明搜“某公司”抓回来一堆同名但不对的实体现象原始名单里是“某信息技术有限公司”结果员工列表返回的全是另一个城市的同名公司或者是一个空壳公司的空列表。原因平台搜索候选是按相关性排序的同名公司在不同地区、不同行业都有搜索接口返回的第一个候选不一定就是要找的那家。A 同学在这上面踩过坑脚本自动选了第一个候选跑完 500 家才发现小半数据是错的返工成本极高。解决预跑阶段把搜索候选全部打印出来人工确认实体。确认后把映射存成 JSON 文件批量脚本只读映射不直接信任搜索接口的排序。映射文件长这样{某信息技术有限公司: 123456, 某集团: 789012}。这一步看似多了一些人工操作实际上是整个链路里性价比最高的投入。5.4 抓回来的职位是一堆无意义短词现象title字段时不时出现空字符串、“-”“unknown”或者整个列表里一半人的职位头衔都是“某公司员工”这种泛化描述。原因一部分员工设置了隐私对非好友隐藏职位详情另一部分是平台在未登录或登录态降级时会故意返回泛化字段。你要知道平台对职位头衔的可见性是分级的与登录账号的关系越远返回的字段越模糊。解决遇到缺失字段不要补默认值直接在表里留空后续人工或业务侧补充。批量脚本里做一个统计如果某家公司的员工列表里title缺失率超过 30%就把这家公司标记为“待人工处理”而不是让脏数据混进最终结果里。5.5 请求频率稍微调快就触发临时限制现象间隔从 3 秒调成 1 秒跑了 200 家之后接口开始返回 429 或类似的状态码所有请求都失败。原因平台对单个会话的请求频率有阈值而且这个阈值不是固定的和账号历史行为、当前时段都有关系。频率看上去像玄学实际是你在短时间内触发了同一出口地址的请求量上限。解决把频率控制做成参数化的默认间隔 3 到 6 秒随机遇到 429 就指数退避比如第一次等 60 秒、第二次等 120 秒、第三次等 300 秒。同时控制并发数批量任务不要多开一个进程跑到底因为并发翻倍并不会让整体速度翻倍只会让风控概率翻倍。注意如果只跑几十家公司的数据别写脚本直接手动在平台上搜索导出就可以了。脚本的启动成本、Cookie 维护成本、清洗成本在小批量场景下远高于手动操作的价值。6. 进阶用法如何让爬虫结果真正被业务用上6.1 输出与增量更新从一次性脚本变成可持续跑的流程第一批数据跑完不是终点业务上通常会每周或每月拉一次新增员工。增量更新的关键是复用上一次的public_id集合新拉到的数据先做差集只保留之前没出现过的人避免新老数据混在一起。这个逻辑用 pandas 写很简单先读取历史 CSV再对新数据做public_id的集合差。输出编码上用utf-8-sig否则 Excel 打开会乱码这是最容易被忽略的细节。6.2 上线前的验证节奏与字段覆盖率我跑批量的固定习惯是三级验证先拿 10 家公司跑通全流程确认字段解析正确再扩到 100 家观察 429 出现频率和字段缺失率最后才放全量。字段覆盖率有一个参考线姓名覆盖率接近 100%职位覆盖率 90% 左右城市覆盖率 70% 以上属于正常如果职位覆盖率低于 80%优先检查登录态是否降级。每次跑完我都会留一个抽样文件把每条数据的title和experiences人工扫一眼。这个习惯帮我避免了至少三次大规模返工。这些年做数据采集最大的收获是脚本能解决效率问题但不能替代业务判断。公司名和实体 ID 的映射要人工确认字段缺失要允许存在频率控制要尊重平台限制。把脚本当成一个持续维护的工具而不是一次性跑完就扔的脚本。希望帮到你。本文还有配套的精品资源点击获取