☰
LinkedIn爬虫:从公司名到员工信息的数据抓取拆解
2026/10/19 6:05:20 网站建设 项目流程

简介:LinkedIn Spider 是一个开源爬虫项目,面向需要批量获取公司员工公开资料的研究人员、数据分析师与招聘人员。它通过公司名称定位页面,解析员工链接、职位、工作地点,并深入个人页抓取教育背景、工作经验与技能,同时兼顾验证码、IP限制等反爬应对策略。压缩包内共3个文件,以Python脚本为主,辅以README说明与gitignore配置,整体仅5KB,结构精简,适合快速阅读与二次开发,目前已有1602人学习下载。资源中的Python实现完整覆盖了从目标设定、网络请求、数据解析到结果存储的爬虫流程,涉及requests、BeautifulSoup、lxml、selenium、pandas等典型库的使用,并给出延迟请求、更换User-Agent、代理IP等反爬思路,同时在README中说明了尊重隐私、遵守服务条款的合规要点。无论是用于市场研究、招聘寻源、销售线索挖掘,还是学术领域的社会网络分析,这份项目都能提供一套可定制、可扩展的参考框架,帮助开发者快速上手LinkedIn公开数据采集。

1. Linkedin爬虫:根据公司名抓取员工信息,真正要解决的问题是什么?

Linkedin爬虫这个标题听起来像“输入公司名,吐员工名单”的傻瓜工具,但拆完代码包之后你会发现,真正值钱的不是外层调度,而是两条解析路径:一条从公司主页里提取数字公司ID,另一条从人员搜索结果中把姓名、职位、所在地逐条剥离出来。它解决的核心问题,是销售线索挖掘、猎头寻访、竞品组织盘点时,手里只有一串公司名单、却缺结构化员工列表的尴尬。适合有Python基础、想直接复用抓取逻辑而不是从零绕平台页面结构的开发者。下文我按“选型—实现—清洗—排错—验证”的顺序拆开讲,每个环节都给可复现的参数和踩坑记录。

2. 抓取原理与选型:请求响应式爬虫为什么比浏览器自动化更适合?

拿到一个Linkedin爬虫资源,第一件事不是看请求代码,而是看它的抓取模式。很多人第一反应是Selenium打开浏览器自动点击,翻页等待、输入延迟,确实能跑但对机器性能要求高、稳定性差,平台页面改一次就要同步修改多处点击路径。而请求响应式爬虫直接用requests发GET请求,只要维护好Cookie和少数几个关键参数,就能稳定拿数据。

两类方案对比如下:

对比项请求响应式浏览器自动化
发起方式requests 直接 GETSelenium 模拟点击
运行内存几十 MB800MB 以上
翻页控制调整 start 参数等待元素出现
页面改版影响只改解析逻辑点击和定位都要改
调试成本保存 HTML 本地分析需要截图或录屏

这个资源选择请求响应式,还因为Linkedin的搜索结果页本身会把数据以嵌入JSON的形式放进HTML源码,直接构造请求拿到的是完整响应体,解析路径比浏览器自动化更短。代价是需要更精细地控制请求头、Cookie和延时,这部分正是踩坑最多的地方。

2.1 数据链路梳理:公司ID是连接“公司名”和“员工列表”的中间变量

直接用公司名去人员搜索框里搜,返回的结果其实是模糊匹配,混着大量同名公司和上下游企业员工。正确链路是三段式:先从公司主页提取数字公司ID,再用这个ID构造人员搜索请求,最后请求详情页补全字段。公司ID在整个抓取链路里是核心中间变量,建议单独存一张中间表,和原始公司名一一对应,方便后面排查。

import re def extract_company_id(page_html: str) -> int | None: # 从公司主页源码中抓取数字公司ID,优先匹配 JSON 字段 patterns = [ r'"companyId"\s*:\s*(\d+)', r'company/(\d+)', r'"mwlCompanyId"\s*:\s*(\d+)' ] for pat in patterns: matched = re.search(pat, page_html) if matched: return int(matched.group(1)) return None

逻辑说明:三条正则覆盖了新旧两种页面结构,优先取companyId字段;如果老页面没有该字段,则回退到URL路径里的数字ID。参数说明:返回值会作为后续人员搜索请求的currentCompany参数;若返回None,说明公司主页本身被登录墙拦截或页面结构大改,这时应放弃这一行,不要硬猜ID。

2.2 响应结构:优先解析嵌入JSON,不盲写XPath

拿到搜索结果页,表面是HTML,真正含数据的是一段以嵌入JSON形式存在的部分,而且层级很深。一年内我经历过几次页面标签改版,XPath路径每隔几个月就得维护一次,而嵌入JSON的字段名相对稳定。所以现在的做法是:先把响应体保存到本地debug目录,搜索“profile”或“headline”关键词,确认数据结构后再决定用JSON还是XPath兜底。

import json def extract_embedded_json(html: str) -> dict: # 定位 script 标签里的 JSON 起始位置,再做花括号配对 marker = "window.__next_f=" pos = html.find(marker) if pos == -1: return {} start = html.find("{", pos) depth, end = 0, len(html) for i in range(start, len(html)): if html[i] == "{": depth += 1 elif html[i] == "}": depth -= 1 if depth == 0: end = i + 1 break try: return json.loads(html[start:end]) except json.JSONDecodeError: return {}

逻辑说明:不是所有嵌入数据都在同一个script标签里,实际开发时我会用循环把多个script节点都提取出来再合并,避免漏字段。参数说明:该函数返回dict;后续提取人员列表时,用dict的get方法逐层取值,而不是直接依赖下标,因为下标顺序会随页面结构调整。

通常主列表字段的解析路径是data.search.people,每个节点包含person_name、headline、profile_url。下面是从嵌入JSON中解析人员列表的代码:

def parse_people_from_payload(payload: dict): # 从 JSON 里提取人员列表,只保留三个快速展示字段 records = [] people = payload.get("data", {}).get("search", {}).get("people", []) for node in people: records.append({ "profile_url": node.get("profile_url", ""), "name": node.get("full_name", ""), "headline": node.get("headline", "") }) return records

逻辑说明:列表页字段有限,先用这三个字段做去重和初步筛选,等进入详情页再补全。参数说明:如果people字段返回空列表,不要立刻认定公司无员工,先确认嵌入JSON是否被登录墙替换。

请求响应式的稳定性高度依赖登录态Cookie,因此抓取时我会用一个session对象贯穿全程,登录态快过期时优先补Cookie,不要频繁重新登录,否则容易触发账号风控。

3. 从公司名到员工详情:任务骨架、翻页参数与断点续爬做法

3.1 项目骨架与输入输出约定:companies.csv 进,employees.csv 出

一份可二次开发的Linkedin爬虫,目录结构通常是这样:

linkedin_spider/ ├── main.py # 调度入口,读取公司名单、调用抓取 ├── config.py # Cookie、请求间隔、超时和输出路径 ├── parsers.py # 主页、列表页、详情页的解析函数 ├── storage.py # 落盘、去重、CSV 写出 ├── input/ │ └── companies.csv # 必填输入:公司名列表 └── output/ └── employees.csv # 最终输出:员工信息

逻辑说明:main.py只做流程串联,不直接写解析细节;parsers.py只负责解析和清洗,不维护调度状态;storage.py统一处理去重和写入。参数说明:companies.csv最少只要一列company_name;如果还有company_industry和company_location这两列,可以用来辅助判断同名公司,避免后续抓错。

启动任务时从companies.csv逐行读入,先把每一行都调用extract_company_id生成公司ID,全部解析完成后才进入员工抓取阶段。这里有个习惯值得保留:解析公司ID的结果先落到output/company_ids.csv,方便中途检查,不用重新请求公司主页。

3.2 人员列表抓取与翻页终止条件

人员列表抓取最常见的错误是把keywords直接设为公司名,结果返回的全是模糊匹配结果。正确做法是把公司ID作为过滤条件,keywords留空。

def fetch_people(session, company_id, start=0, count=25): url = "https://www.linkedin.com/search/results/people/" params = { "keywords": "", # 留空,避免模糊匹配 "currentCompany": [company_id], # 只保留当前在职员工 "start": start, # 翻页起点 "count": count, # 每页条数 "sortBy": "relevance" # 相关度排序 } resp = session.get(url, params=params) return resp.text

参数含义如下:

参数作用建议值
keywords关键词搜索,空串不干扰公司过滤空
currentCompany限定目标公司ID,只返回该司在职员工公司ID
start翻页起点,每翻一页加250, 25, 50...
count每页返回条数25
sortBy结果排序方式relevance

翻页终止条件要写清楚:当返回的记录条数小于count,说明已经到末页;但如果公司员工总数恰好是25的整数倍,最后一页记录数等于count,还需要再多请求一次空列表才能结束。为了防止异常情况导致死循环,我会设置一个强制上限,累计超过1500条就停止并输出告警日志。

断点续爬是这类型长任务的必要能力。每抓完一个公司ID,就把该公司已完成的标记写入output/progress.json,重启任务时先加载这个文件,跳过已完成公司。

import json import os def load_progress(): # 读取断点文件,返回已完成公司ID集合 path = "output/progress.json" if not os.path.exists(path): return set() with open(path, encoding="utf-8") as f: return set(json.load(f))

逻辑说明:断点文件只用公司ID,不用公司名,因为公司名可能带空格或特殊字符。参数说明:load_progress返回set集合,主程序判断if company_id in done: continue即可。

3.3 详情页补全字段与解析回退

列表页只能拿到姓名、职位、头像快照,但缺少所在地、工作经历、技能标签。详情页解析我分为两层:先用嵌入JSON拿结构化数据,拿不到再走XPath兜底。

def parse_profile(html, base_record): record = dict(base_record) payload = extract_embedded_json(html) if not payload: # 兜底:用 XPath 解析 DOM 里的正文字段 record["headline"] = extract_by_xpath(html, "//h2[contains(@class, 'headline')]") record["location"] = extract_by_xpath(html, "//li[contains(@class, 'location')]") return record record["headline"] = payload.get("headline", "") record["location"] = payload.get("geo", {}).get("location", "") record["experience"] = payload.get("experience", []) return record

逻辑说明:先执行JSON解析,只有JSON缺失时才走XPath,不要反过来。record是基础记录,函数在它上面追加字段,保证列表页数据不丢失。参数说明:experience字段未来要转成“公司-职位-时间段”三要素文本,所以在这一层保留原始列表,清洗阶段再做拼接。

抓完一批数据后要立即做乐观锁式的进度保存,避免进程中断后从头再来。每抓完一个公司ID就在progress.json里追加一个ID,同时把该公司的原始HTML按公司ID存一份到debug目录,后续发现解析规则写错,不需要重新抓取,直接重跑解析函数即可。

4. 字段清洗与存储:统一字段映射、去重和最终Excel输出

4.1 字段映射规范:原始JSON到最终CSV的六列

原始JSON记录通常有20多个键,但最终客户往往只需要六列标准字段。字段映射表如下:

标准列来源示例
company_name输入名单某科技公司
person_name列表页 name张三
headline详情页 headline高级后端工程师
location详情页 geo广州
profile_url列表页 url/in/zhangsan-123
company_id公司主页提取123456

写入代码用Python标准库csv,注意编码必须用utf-8-sig,否则Excel双击打开中文会乱码,这是血泪经验。

import csv from pathlib import Path def save_records(records: list[dict], output_path: str = "output/employees.csv"): Path(output_path).parent.mkdir(parents=True, exist_ok=True) fieldnames = ["company_name", "person_name", "headline", "location", "profile_url", "company_id"] with open(output_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(records)

逻辑说明:csv.DictWriter会自动按fieldnames对齐字段,字典里多余的键不会写入文件。参数说明:encoding参数使用utf-8-sig而不是utf-8,是因为Excel在读取无BOM的UTF-8时容易把中文识别成乱码;如果后续还要用pandas处理,读回时指定encoding="utf-8-sig"即可。

4.2 去重策略与校验:避免“一人多条、多人一条”

列表翻页过程中,同一个员工大概率出现多次;另外同名员工在同家公司也可能真实存在。两种情况的处理粒度不一样,不能一刀切按姓名去重。

import re def clean_profile_url(url): # 去掉 ? 后面的追踪参数,保留可阅读路径 return re.sub(r"\?.*$", "", url) def deduplicate(records): seen = set() for rec in records: key = (rec["company_id"], rec["person_name"], rec.get("headline", "")) if key not in seen: rec["profile_url"] = clean_profile_url(rec["profile_url"]) seen.add(key) yield rec

逻辑说明:复合键由公司ID、姓名、岗位三部分组成,三条完全相同才判定为重复,避免误删同名同事。参数说明:profile_url的追踪参数每个页面都不一样,如果不清理,它会导致同一员工被当成不同记录。headline为空时,复合键等于只剩两项,此刻需为headline补“未知岗位”,否则同名的几位员工会被合并成一条。

清洗阶段还建议增加一个字段数量校验:如果某条记录缺headline或location,把它标记为“待补充”,而不是直接删除。缺失记录数量超过总数的15%,说明详情页解析出现问题,需要回头检查嵌入JSON的取值路径。

5. 爬取排错与避坑记录:登录墙、验证码与数据错位的5个案例

5.1 搜索页返回登录墙,采集结果始终为0

现象:状态码是200,但解析出来的人员列表为空,程序不报错,就是没数据。 原因:平台把请求重定向到了登录页,requests默认跟随重定向,所以你看到的是登录后的页面,不是搜索结果页,200状态码是假象。 解决:每次请求后检查URL里是否出现auth或login关键字;把响应HTML保存到本地,用文本搜索“登录”或“验证身份”关键词;确认是登录墙后补充有效Cookie,并把Cookie注入session,不要重复调用登录接口。

5.2 翻页循环一直跑,但数据不新增

现象:start从0一直递增到1000,输出文件大小却几乎不变。 原因:列表接口在受限状态下忽略了start参数,每次都返回第一页内容;也有可能是Cookie里缺少分页所需的某个参数。 解决:在日志里打印每一页第一个姓名,如果连续三页第一个姓名相同,说明翻页无效,立即终止循环并提示“翻页失效”。优先检查Cookie过期状态,其次检查请求参数里start是否被URL编码成了字符串而不是数字。

5.3 岗位字段错位或大面积缺失,名单整体不可用

现象:headline大量为空,或者官职被解析成公司名,location栏出现网址。 原因:详情页的嵌入JSON并不是统一结构,部分字段在DOM里、部分在JSON里,且新旧版本的键名风格不同。 解决:对每条记录做两级解析,JSON缺失时走XPath兜底,不要把空值写入最终结果。同时保留每页原始HTML,后续键名变化时直接拿本地HTML重新解析,不需要重新抓一遍网络请求。

5.4 请求频率稍高就触发验证码,中断在任务三分之一处

现象:前几百条正常,之后突然返回一份固定模板页面,内容全是验证码提示。 原因:请求间隔固定且太短,行为节奏被识别为程序操作。 解决:把请求间隔从0.5秒拉长到3到5秒,并加入随机抖动;每个公司任务每5分钟最多请求150条左右;一旦检测到验证码模板就熔断退出,等待10分钟后再恢复该任务,而不是继续硬刷。

5.5 公司重名导致集团和子公司员工混在一起

现象:输入“某科技公司”,抓回来的却是集团总部和其他子公司的员工。 原因:公司名匹配时没有参考行业、地址、规模等辅助字段,按关键词搜到了错误的主体。 解决:在公司主页解析时同时保存company_industry和company_location字段;公司ID提取为空时直接跳过该行,不要用公司名代替ID去请求员工列表;最终输出CSV里保留company_id列,方便回查数据归属。

6. 验证与交付:抽检回访、清理脏数据,输出最终员工名单

6.1 抽样核对:人工回访四个细节

数据清洗完成后,我习惯在每个公司名单里随机抽5到10条,打开profile_url人工核对四件事:姓名是否真实存在、headline是否和公司业务匹配、location是否符合预期、公司ID是否与目标公司一致。如果抽样匹配率低于80%,说明公司ID映射或解析逻辑有问题,不能直接交付。

抽检代码可以这样写:

import random def sample_and_verify(records, sample_ratio=0.1): # 抽取10%记录用于人工回访 sample_size = max(1, int(len(records) * sample_ratio)) sample = random.sample(records, sample_size) verified = [] for rec in sample: # 这一段人工确认,确认后把结果追加进verified verified.append(rec) return verified

逻辑说明:随机抽样用random.sample避免手动选样偏向某一页。参数说明:sample_ratio默认0.1,名单总量很大时可以调到0.05,总量小于10条时全部检查。

6.2 导出终稿的文件组织与版本习惯

交付目录我会分成三层:output/employees.csv是最终名单,output/company_ids.csv是公司ID映射表,raw_html/是按公司ID存放的原始页面备份。这样如果对方反馈某个字段不对,可以直接用备份重跑解析,不需要重新请求。文件命名带上日期,例如employees_20250101.csv,避免覆盖上一版。

从那以后,我每处理一份名单任务都会强制走一遍“抽检10条 + 核对公司ID + 保留原始HTML”的流程,这套习惯帮我挡下了好几次因为公司重名导致的批量返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询