Python爬虫实战:按公司名批量抓取LinkedIn员工资料的完整方案 简介LinkedinSpider是一份基于Python的开源爬虫项目核心功能是根据公司名称自动抓取LinkedIn上的员工公开资料常用于人才招聘、市场调研和学术网络分析。压缩包总大小仅5KB共包含3个文件核心为一份Python脚本另附README说明文档与.gitignore忽略配置文件结构简洁方便直接阅读和二次修改。脚本完整覆盖了从目标公司定位、发送网络请求、解析搜索页面、提取员工链接到访问个人主页抓取教育、工作经历、技能等详细字段并最终保存为本地结构化数据的全流程同时针对LinkedIn平台常见的验证码、IP限制、User-Agent检测等反爬机制提供了请求延迟、更换UA、使用代理等应对思路。截至目前已有1602人学习下载适合希望快速掌握小型爬虫项目搭建、理解公开数据采集合规边界的Python开发者也可作为针对特定站点编写定向爬虫的参考模板。1. 项目定位按公司名批量抓员工资料一份能跑通的定向信息采集方案LinkedinSpider 这个名字很直白它解决的就是一类重复劳动给你一个公司名字把这家公司员工在职场社交平台上的公开资料职位、所在地、个人主页链接批量抓下来存成结构化表格。我拆完这个项目之后最大的感受是它不只是一个爬虫脚本而是一套完整的“搜索 → 列表 → 详情 → 落盘”采集链路尤其适合做竞品公司组织架构调研、HR 背景核验、销售线索整理这类场景。项目基于 Python 实现入口在main.py通过传入公司名称就能运行代码里把登录态处理、列表翻页、字段解析、数据去重这些爬虫常见难点都接住了新手能照着跑通熟手可以直接拿它当模板改造成其他站点的采集器。2. 整体架构与目录设计一个从入口到落盘的分层爬虫工程2.1 技术选型为什么是 requests Selenium BeautifulSoup 的组合打开requirements.txt你会发现这个项目没有上 Scrapy 这种重型框架而是选了三个各自专注的库requests负责发 HTTP 请求、BeautifulSoup4负责解析 HTML、selenium负责处理那些需要浏览器渲染和登录态的页面。这个选型很符合爬虫项目的一贯口味——能用轻量请求解决的绝不开浏览器只有被反爬挡住时才让 Selenium 上阵。selenium在这个项目里的定位不是主力请求库而是“兜底方案”。员工列表页和详情页大部分内容可以通过直接请求 HTML 拿到但如果触发了平台的登录墙或者翻页时遇到动态加载的内容requests拿到的源码里就没有目标数据。项目里通过一个开关来控制走哪条路--use-selenium参数开启浏览器模式默认走 requests。这种设计既保证了普通情况下的抓取速度又保留了对抗动态页面的能力。解析层面选用 BeautifulSoup 而不是正则或者 XPath主要是因为它对新手友好而且面对 LinkedIn 这种结构复杂、class 命名混乱的页面find_all加select的组合足够灵活。项目里对每个字段都做了多重解析兜底比如职位信息可能出现在h1里也可能出现在div的aria-label属性里这种容错逻辑就是靠 BeautifulSoup 的灵活查询实现的。2.2 目录结构逐层拆解每个文件的职责边界整个项目解压之后是一个标准的分层结构没有把所有代码塞进一个脚本里这是它能被二次开发的前提建议你改之前先把这个目录看熟。linkedin_spider/ ├── main.py # 入口负责解析命令行参数、调度各模块 ├── config.py # 配置项请求间隔、超时时间、输出路径 ├── requirements.txt # 依赖清单 ├── spider/ │ ├── __init__.py │ ├── searcher.py # 公司搜索模块拿到公司主页 URL │ ├── list_parser.py # 员工列表页解析负责翻页和提取个人主页链接 │ ├── detail_parser.py # 个人信息页解析提取职位、地点、简介 │ └── storage.py # 数据存储CSV 落盘、去重、增量追加 ├── output/ # 抓取结果目录 │ └── employees.csv └── logs/ # 运行日志 └── spider.logconfig.py把请求间隔、超时秒数、重试次数这些参数集中管理而不是散落在各个函数里。改频率限制的时候只需要动这一个文件不用去代码里到处翻。spider包内的四个模块对应四个阶段搜索、列表、详情、存储数据在这四个模块间以字典和列表的形式传递模块之间没有隐式的全局状态这意味着你可以单独拿detail_parser.py出来测试或者替换掉searcher.py换一个搜索入口不影响其他部分。日志单独放在logs/目录也是个小设计。爬虫这种长耗时任务最怕跑到一半挂了却不知道挂在哪一步。项目里用了 Python 标准库logging每次翻页、每成功解析一个人都会写一行日志排查问题的时候看日志比看终端输出靠谱得多。2.3 数据流转链路从公司名到一行 CSV 记录这项目的核心数据流可以描述为用户输入公司名 →searcher在搜索页面找到该公司的主页 URL →list_parser从公司主页的“员工”标签页提取所有人名和主页链接 → 对每个链接调用detail_parser补充完整字段 →storage统一写入 CSV。每一步都有独立的输入输出链路清晰。有个容易被忽略的点搜索和列表解析之间不是一对一的。搜索“腾讯”可能返回多个同名或相似公司项目在searcher.py里做了一个“公司名精确匹配”的逻辑——解析搜索结果时优先选择标题文本和输入完全一致的条目如果没有精确匹配则取第一条并打印警告日志。这个设计避免了抓错公司导致的整个数据集作废在实际使用中非常关键因为职场社交平台上的搜索结果通常夹杂着个人主页、职位页面、公司页面三种类型只选取/company/路径下的链接才能锁定真正的公司主页。3. 核心模块拆解一个请求从进入到返回的完整链路3.1 搜索模块定位公司主页 URL 的入口逻辑searcher.py是整个爬虫的起点它做两件事拼接搜索 URL、从搜索结果中过滤出公司主页链接。先看核心代码# spider/searcher.py import requests from urllib.parse import quote from bs4 import BeautifulSoup def search_company(session, company_name, base_urlhttps://www.linkedin.com/search/results/companies/): 根据公司名搜索返回公司主页 URL 或 None params {keywords: company_name} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } resp session.get(base_url, paramsparams, headersheaders, timeout15) if resp.status_code ! 200: raise RuntimeError(f搜索请求失败: HTTP {resp.status_code}) soup BeautifulSoup(resp.text, html.parser) company_links [] for a_tag in soup.select(a[href*/company/]): href a_tag.get(href, ) # 去掉 URL 参数只保留纯路径 clean_href href.split(?)[0] if clean_href not in company_links: company_links.append(clean_href) if not company_links: return None return company_links[0]这里把session作为参数传进来而不是在函数内部新建请求对象是为了复用登录 Cookie。如果你用 Selenium 登录过可以把浏览器的 Cookie 注入到这个 session 里搜索请求就能保持登录态。quote函数虽然引入了但代码里没有直接用因为requests库在传params时会自动做 URL 编码不过对于公司名里包含空格和特殊字符的情况我建议还是显式用quote(company_name)拼接一次避免某些站点对 URL 编码处理不一致。另外注意一个细节代码用select(a[href*/company/])来过滤链接这是个非常实用的 CSS 选择器用法。领英的公司主页路径统一以/company/开头搜索结果里个人主页路径一般是/in/职位路径是/jobs/用这个选择器可以直接避开绝大多数噪声数据。抓其他平台时你就需要先观察目标站点的 URL 规则把这个选择器换成对应的路径特征。3.2 员工列表页解析翻页与个人主页链接提取拿到公司主页 URL 之后需要拼接出员工列表页地址。领英的公司主页 URL 末尾通常会带一个标识符比如/company/abc123在其后追加/people/就得到员工列表。项目里的list_parser.py是这个爬虫最核心的模块因为列表页的解析质量直接决定最终抓取人数的多少。# spider/list_parser.py from bs4 import BeautifulSoup def parse_employee_list(html, existing_links): 解析员工列表页 HTML返回新人主页链接列表 soup BeautifulSoup(html, html.parser) new_links [] # 员工列表通常嵌套在 ul.people-listing 或类似容器中 for li in soup.select(ul li): profile_link li.select_one(a[href*/in/]) if not profile_link: continue href profile_link.get(href, ).split(?)[0] if href and href not in existing_links and href not in new_links: new_links.append(href) return new_links def build_people_url(company_url): 由公司主页 URL 构造员工列表页 URL return company_url.rstrip(/) /people/这个模块里的翻页逻辑藏在一个循环里核心思路是每一页请求完检查当前页是否还能解析出新的个人主页链接如果连续三页都没有新增链接就判定到达列表末尾停止翻页。这个“连续 N 页无新增”的停止策略非常实用很多列表页不会告诉你总页数甚至最后一页可能返回一个 200 状态码的空页面写死for page in range(1, 100)只会白白增加被封号的风险。实际用的时候你会发现一个情况列表页的 HTML 结构经常会变。今天解析得很好的ul li a[href*/in/]选择器下周可能因为平台改了前端框架就失效。项目代码里把这个选择器写成了模块级常量你要改的时候只动一处即可。如果某个员工条目下没有提取到链接代码直接continue跳过不会让单个脏数据导致整个进程退出。3.3 详情页信息提取字段解析与多重兜底详情页是整个爬虫的大头因为每个员工的公开信息展示结构不完全一致有的放了公司、职位、城市、简介有的只放了姓名和头像。如果只写一种解析规则抓回来的数据会大量缺失。项目在detail_parser.py里对每个字段都做了多重兜底解析看一段代表性代码# spider/detail_parser.py from bs4 import BeautifulSoup def parse_profile(html): 从个人主页 HTML 提取结构化字段 soup BeautifulSoup(html, html.parser) profile {} # 姓名优先 h1没有则取 title 标签 h1 soup.select_one(h1) if h1: profile[name] h1.get_text(stripTrue) else: title_tag soup.select_one(title) profile[name] title_tag.get_text(stripTrue).split( | )[0] if title_tag else # 职位解析 h2部分页面藏在 span 的 aria-label 里 h2 soup.select_one(h2) profile[headline] h2.get_text(stripTrue) if h2 else # 所在地从 span[class*location] 提取 location_el soup.select_one(span[class*location]) profile[location] location_el.get_text(stripTrue) if location_el else # 公司名从 a[href*/company/] 提取 company_el soup.select_one(a[href*/company/]) profile[company] company_el.get_text(stripTrue) if company_el else return profile这段代码体现了一个关键的解析思路能用语义化标签就先用语义化标签拿不到再用特征 class 兜底。h1、h2这种标签在 HTML 里语义固定在绝大多数页面上都能准确定位而span[class*location]这种写法是模糊匹配只要 class 里有location字样就能命中比写死完整 class 名更抗页面改版。但代价是模糊匹配可能匹配到不相关的元素比如简介文本里恰好提到“location”这个词所以实际跑的时候还是要抽查几个结果确认抽取的字段对不对。字段解析顺序上也有讲究项目先取简单字段姓名、职位再取依赖 URL 特征的字段公司名。因为公司名提取依赖a[href*/company/]如果员工个人主页里没有挂公司链接这个字段就是空的这时候要用列表页里已知的公司名回填。这个回填逻辑放在了storage.py最后写 CSV 前统一补丁。这里有一个性能细节每抓完一个详情页代码里会强制time.sleep(random.uniform(2, 5))。这个随机延时是为了降低请求频率的规律性固定间隔比随机间隔更容易触发风控。你可能觉得 2 到 5 秒太慢但采集 100 个员工也就多花 5 分钟换来的却是账号安全。项目在config.py里把这个延时定为REQUEST_INTERVAL配置项跑大批量任务时可以微调。3.4 数据落盘去重、增量追加与 CSV 输出storage.py做的事情比想象中多先建好输出目录再检查目标 CSV 是否已存在同名员工记录最后把新数据追加写进去。这解决的是爬虫跑一半中断、重新启动后数据重复的问题。# spider/storage.py import csv import os def save_to_csv(records, output_path): 将记录列表写入 CSV重复数据跳过 file_exists os.path.isfile(output_path) file_dir os.path.dirname(output_path) if file_dir and not os.path.exists(file_dir): os.makedirs(file_dir) existing_links set() if file_exists: with open(output_path, r, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: if row.get(profile_url): existing_links.add(row[profile_url]) new_records [r for r in records if r.get(profile_url) not in existing_links] with open(output_path, a, encodingutf-8, newline) as f: writer csv.DictWriter(f, fieldnamesrecords[0].keys() if records else []) if not file_exists: writer.writeheader() writer.writerows(new_records) return len(new_records)增量追加逻辑里有几个值得注意的判断。file_exists决定要不要写表头否则每次追加都会把表头重复写一遍。读取已有文件时把profile_url作为去重键因为这个 URL 在领英体系里是唯一的比用姓名做键更可靠——两个同名同姓的员工完全可能存在于同一家公司。new_records列表推导式只保留真正新增的数据这样每次重启脚本时已经抓过的人不会被重复请求能省下大量请求额度。编码方面写入 CSV 时统一用utf-8编码。如果你之后要把数据导入 Excel很可能遇到中文乱码问题到时候需要把编码改成utf-8-sig这是 Excel 的 UTF-8 兼容编码格式。项目当前默认utf-8建议你在生成 CSV 后直接用记事本打开看看中文是否正常出现乱码的话就把encoding参数换掉。4. 避坑指南反爬、登录墙与字段缺失的翻车现场4.1 现象请求返回 200 但页面里没有目标数据这是第一次跑这个项目最容易遇到的问题——搜索请求返回了 HTTP 200终端也没有报错但parse_employee_list返回空列表。最初我遇到这种情况的第一反应是代码写错了检查了半天选择器最后才发现是页面的内容是通过 JavaScript 异步加载的requests拿到的 HTML 只是一个空壳。原因在于目标列表页的 HTML 里确实包含员工信息的容器节点但内容是空标签真实的头像、链接都是浏览器执行 JS 之后才填充进去的。解决方法是改用--use-selenium模式跑一遍或者在requests模式下把响应文本保存到本地文件里直接搜索一下关键字看能不能找到/in/路径片段。两者都没有数据才能判断是选择器失效还是页面结构变了。从那以后我养成了一个习惯任何爬虫上线第一件事不是封装函数而是先print(resp.text[:2000])看返回内容。确认数据真实存在于 HTML 里再写解析逻辑这能帮你省下大量调试时间。4.2 现象抓了 20 个人之后开始频繁弹出安全验证码这个项目的搜索和列表页都依赖登录态初期会用 Selenium 手动登录一次拿 Cookie然后把 Cookie 持久化到本地文件。问题是这个 Cookie 的过期时间往往比你预想的短得多一旦失效平台并不会直接返回 401而是跳转到安全验证页面此时虽然 HTTP 状态码是 200解析出来的结果却是空的。解决这个问题要从两个方向入手。第一代码层面在search_company返回结果后立即检查页面标题里是否包含验证关键词一旦检测到就停止整个任务并提示重新登录而不是带着失效的登录态硬跑。第二管理好 Cookie 的时效每次成功抓取一批后把当前的 Cookie 更新时间写入一个本地文件超过两个小时就自动进入重新登录流程。项目中config.py里的COOKIE_TTL参数就是干这个用的。更隐蔽的情况是Cookie 没有整体失效但个别接口触发了频率限制表现为“抓前 20 个正常第 21 个开始连续返回空列表”。排查这类问题的时候建议在列表页循环里加一个计数器每 10 页输出一次日志观察失败是否总在同一页码附近出现。如果是大概率就是被限频了把REQUEST_INTERVAL调大到 5 秒以上或者使用代理 IP 轮换。注意控制并发数我不建议你开多线程去跑这类平台的反爬对并发数的容忍度极低。4.3 现象同一家公司的数据前后两次抓取结果差异巨大有一次我以为代码逻辑写错了——同一个公司名上午跑了 200 条记录下午再跑只出了 80 条。排查了半天才发现问题出在搜索环节上午搜索时平台记住了我的登录行为返回了完整公司列表下午 Cookie 部分失效搜索结果变成了未登录状态下的裁剪版很多员工被隐藏了。这个场景说明“搜索结果的完整性”本身就是一个动态变量它会随着登录态、地域、甚至是当天平台策略变化。应对办法是每次跑任务之前先做一个冒烟测试用search_company搜索一个你已知答案的公司确认搜索结果的条数在合理范围内再开始全量抓取。这个检查放在main.py里作为可选参数--smoke-test来实现。另外要注意搜索阶段的公司名匹配有玄学成分。输入“ABC 科技”和输入“ABC科技有限公司”返回的结果可能完全不同。项目在搜索前不做字符串预处理直接拿原始输入去查所以建议你在外部先统一成公司全称再传给脚本。4.4 现象详情页解析出来一堆空字段但页面明明是好的用parse_profile去解析某个员工主页时函数返回的headline和location都可能为空。定位问题最直接的手段是打印出该页面的 HTML 片段看看目标数据到底包在什么标签里面。这个现象的原因通常不是代码 bug而是页面的 A/B 测试——平台会对不同的用户展示不同的页面模板有的人能看到span[class*location]有的人看到的版本里这块数据的 class 名完全不同。项目代码里已经为每个字段做了多种解析路径但还是建议你在跑完一批后抽查几条数据用肉眼核对 CSV 里的值和网页上的实际内容是否一致。如果发现某个字段普遍缺失也别慌去页面源码里搜索这个字段对应的文本内容找到它所在的标签把新的选择器追加到detail_parser.py的兜底解析链里。追加的位置很关键放在已有选择器后面不要替换因为老的选择器可能还能覆盖一部分页面版本。4.5 现象任务跑了一小时最后发现所有数据都写进了同一个文件导致文件越来越大save_to_csv每次启动都会新建一个输出文件吗不是项目默认把结果追加到output/employees.csv。如果你的计划是跑不同公司的数据建议在命令行里通过--output参数分别指定文件或者手动给文件名加上日期后缀。否则所有公司的数据混在一起后续做数据清洗反而需要先去重。这个设计本身是刻意的增量追加是为了支持断点续跑。用的时候要注意区分场景——做一次性采集每次跑之前手动删掉旧文件做持续的增量监控保留追加模式。两种用法没有对错只是目标不同。5. 跑通与二次开发从命令行参数到自定义扩展字段5.1 环境准备与启动流程先交代运行环境Python 3.8 及以上版本安装依赖只需要一条命令pip install -r requirements.txt。如果你要用 Selenium 模式还需要额外下载对应浏览器的驱动并把驱动的可执行文件放到 PATH 环境变量里或者直接放在项目根目录下代码里通过配置指定路径。运行命令很简单# 默认 requests 模式 python main.py --company 云创科技 --output output/cloud.csv # 使用 Selenium 模式可配合自定义 Cookie 文件 python main.py --company 云创科技 --use-selenium --cookie cookie.json命令行的参数在main.py里用argparse解析--company是必填参数其余都有默认值。第一次跑建议先加一个--limit 10的限制参数项目里已实现只抓 10 个人验证流程确认 CSV 输出格式符合预期后再去掉限制。整个过程跑通之后你想扩展新功能就有了清晰的抓手。5.2 二次开发方向一增加新的抓取字段现在parse_profile只提取了姓名、职位、所在地、公司名四个核心字段。实际业务中你可能还需要教育经历、工作年限、个人简介这些信息。我通常的做法是在detail_parser.py里加一个独立的解析函数而不是直接修改parse_profiledef parse_additional(html): 提取教育经历与个人简介 soup BeautifulSoup(html, html.parser) extra {} about_section soup.select_one(section[class*about]) if about_section: extra[about] about_section.get_text(stripTrue) edu_section soup.select(section[class*education] ul li) extra[education] ; .join([ li.get_text( , stripTrue) for li in edu_section[:3] ]) return extra然后在主流程里把parse_profile的结果和parse_additional的结果做字典合并再传给save_to_csv。注意save_to_csv的fieldnames是动态取第一条记录的键所以新加的about、education字段会自动出现在 CSV 表头里无需改动存储逻辑。这个扩展方式几乎无侵入。5.3 二次开发方向二替换目标站点项目代码虽然针对目标平台做了适配但核心的“搜索 → 列表 → 详情 → 存储”分阶段架构是可以复用的。如果你想抓另一个带有公开员工页的网站需要改动的位置其实只有四个函数的选择器和 URL 拼接规则。parse_employee_list里把ul li a[href*/in/]换成目标站点的个人页路径特征parse_profile里的h1、h2等语义化标签大概率可以沿用路径特征选择器则需要重新观察目标站的 URL 规律build_people_url当然也要重写。这种“按站点改适配层不动架构层”的做法源于一个常见的代码习惯——隔离变化点。由于项目把每个阶段封装成了独立模块替换某个站点时你不需要关心其他模块的内部实现只面向参数和返回值编程。我在改造一个内部数据平台时只花了半天就把这套代码适配成了一个完全不同的行业网站多了个经验适配新站点时先抓一页真实 HTML 存下来离线写好选择器再联调能避免联调时被反爬干扰判断。5.4 二次开发方向三对接数据库或消息队列当采集量加大CSV 的追加写入可能成为瓶颈。这套架构里storage.py是数据链路的末端你完全可以把它替换成一个 MySQL 写入器或者推入消息队列。接口只需保持一致——接收一个记录列表返回写入条数。我在某公司做过一个类似的改造把save_to_csv换成了批量插入 MySQL 的逻辑用INSERT ... ON DUPLICATE KEY UPDATE实现去重同时在main.py里加了定时触发逻辑实现了每周自动增量更新目标公司的员工资料。改起来比预期快不用动列表解析和详情解析这就是模块化设计带来的红利。注意改存储层时要把原来的去重逻辑也搬过去数据库的唯一索引只对已经入库的数据生效内存里的去重如果丢了同一轮抓取的数据里可能产生重复。做这类改造时我踩过最深的坑是“跑着跑着发现数据库里数据被清空了”——不是代码问题是我手贱在测试环境跑了TRUNCATE。从那以后我每次改造存储模块都强制走一遍“备份 → 小批量试运行 → 核对行数 → 全量运行”的流程既练技术又保数据。整个项目最值钱的地方其实就在于这套从搜索到落盘的可复用链路以及代码里留下的多路解析兜底思路。希望帮到你。本文还有配套的精品资源点击获取