简介:LinkedIn Spider 是一份面向数据研究人员、招聘专员与市场分析师的 Python 开源爬虫方案,核心功能是根据公司名称批量获取该公司员工的公开 LinkedIn 资料,解决人工逐页检索效率低下的问题。包内共 3 个文件,以 Python 脚本为主,另含 Markdown 说明文档与 gitignore 配置文件,压缩包仅 5KB,结构精简,适合开发者直接阅读与二次修改。脚本覆盖搜索请求、HTML 解析、深度抓取与 CSV/JSON 存储等环节,并讨论了验证码、IP限制等反爬应对思路;说明文档对工作流程、依赖库和合规使用做了梳理。已有 1601 人学习下载,能帮助入门者快速理解爬虫设计框架,也可作为扩展多公司批量抓取、数据清洗与人才分析任务的基础工具。使用时需遵守 LinkedIn 服务条款并注意请求频率与隐私边界。
1. 企业员工数据从哪来:LinkedIn爬虫项目的边界和真实难度
如果你手里只有一堆公司名称,想要拿到这些公司对应的员工姓名、职位和主页链接,直接去人力平台手动检索,效率低得让人想摔键盘。LinkedinSpider这类爬虫项目,核心就是解决“按公司名批量抓员工信息”这个需求:输入一份公司名单,输出结构化员工数据,省掉逐个搜索、逐页翻看的时间。很多人以为这是“拿到源码就能跑”的事,实际上坑远比想象中多,登录态失效、搜索页结构变化、翻页丢数据,每一个都能卡住半天。这篇笔记会把拆项目时验证过的登录态维持、搜索结果解析、数据去重和增量更新方案完整写出来,新手可以照步骤跑通,熟手可以直接看边界条件和参数取舍。
2. 先解决登录态:复用浏览器会话与一次扫码换无限次抓取的实现
2.1 为什么不能用裸请求:从302跳转到登录墙的观察记录
拆这个爬虫项目之前,我先试过直接用 requests 构造请求访问搜索页,结果发现一个很典型的现象:请求返回的 HTML 里没有任何员工卡片数据,只有一段重定向脚本,目标指向一个登录页面。原因是目标平台对未携带有效身份的请求会统一 302 到登录页面,而且这个判断不只是看 Cookie,还看请求头里的关键标记。我尝试在 Header 里补上常见的浏览器字段,但很快发现补了也白补,缺的是登录后种下的会话凭证。
这种情况下,业内比较成熟的思路是放弃裸请求,改用浏览器自动化工具加载真实登录态。常见的方案有 Selenium、Playwright、Puppeteer 三种。我做过简单对比:Selenium 生态老但依赖重,浏览器驱动和浏览器版本对不上就崩;Puppeteer 只认 Chromium,后续要接其他浏览器又得重写;Playwright 相对省心,因为它把浏览器内核的下载、驱动匹配都封装好了,用起来最接近“是人坐在电脑前操作浏览器”的效果。实际拆这个项目时,我验证过 Playwright 处理这种动态页面和一个带用户身份登录的站点的稳定性,所以下面的例子都基于 Playwright。
2.2 复用 storage_state 的完整代码与参数说明
登录态不能每次跑脚本都重新扫码,否则这个工具就没有意义。Playwright 提供了一个关键机制:persistent context 和 storage_state。简单说,第一次手动扫码登录后,把浏览器上下文的所有状态(Cookie、LocalStorage、IndexedDB)序列化到一个 JSON 文件里,后续脚本每次启动时直接加载这个文件,就能跳过扫码步骤,直接以已登录身份访问。
完整脚本分两步。第一步是生成登录态文件:
from playwright.sync_api import sync_playwright def create_login_state(save_path: str) -> None: with sync_playwright() as p: # launch 时指定 headless=False,因为首次登录需要人工扫码 browser = p.chromium.launch(headless=False) context = browser.new_context( # 设置一个常规尺寸,不要用默认的 800x600,容易被识别 viewport={"width": 1366, "height": 768}, locale="zh-CN", timezone_id="Asia/Shanghai" ) page = context.new_page() # 打开登录页,注意这个 URL 是通用入口 page.goto("https://www.linkedin.com/login", timeout=60000) print("请在浏览器中完成登录,登录成功后脚本会自动保存状态...") # 轮询等待登录完成,判断标志是地址栏跳回首页或 feed 页 page.wait_for_url( lambda url: "feed" in url or "checkpoint" not in url, timeout=180000 ) # 关键步骤:把上下文状态保存为 JSON 文件 context.storage_state(path=save_path) browser.close() if __name__ == "__main__": create_login_state("linkedin_state.json")这里有个细节值得说明:上面代码里 wait_for_url 的等待条件写得比较宽松,原因是登录成功后不同账号可能跳转位置不同,有人跳首页,有人跳安全验证页,如果等待条件写得太死,后面代码就断了。我一般会让它等 180 秒,这个时间足够完成手动扫码。save_path 参数建议放在项目根目录的 state 文件夹下,不要丢在临时目录里,因为后续每次抓取都要读它。
生成一次之后,日常抓取脚本不用再打开浏览器,直接加载状态文件:
from playwright.sync_api import sync_playwright import pathlib def load_context_from_state(state_file: str): with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context( storage_state=state_file, viewport={"width": 1366, "height": 768} ) return browser, contextheadless=True 表示无头模式,也就是后台运行,不弹出浏览器窗口。注意:无头模式在较老版本的 Playwright 中容易被检测,如果你发现无头模式抓不到数据,先切 headless=False 做对照实验。新版 Chromium 对无头模式的伪装做得已经比较接近真人,但碰到反爬调整时仍然会翻车,所以我会把无头模式当成默认选项、当成备选方案,而不是一台默认跑到底。
2.3 会话失效的三种表现和恢复方法
storage_state 不是一劳永逸的。拆项目过程中,我遇到会话失效的场景有三种,这里写成速查表,方便你排查时对照。
| 失效场景 | 典型现象 | 恢复方法 |
|---|---|---|
| Cookie 过期 | 抓取时返回的页面里没有员工卡片,只有通用空态页 | 重新执行 create_login_state 脚本,覆盖旧的 JSON 文件 |
| 风控校验 | 启动后页面出现安全验证,需要输入验证码,爬虫直接停在某一步 | 检查页面 URL 是否包含 checkpoint;如果包含,手动登录一次并重新保存状态 |
| 多端互踢 | 同一账号在不同设备登录,之前的会话被踢下线 | 避免同一账号在多个环境同时使用,抓取脚本部署在一台机器上 |
最让人头疼的是第二种,因为风控校验不一定出现在脚本启动时,可能出现在某个搜索页翻页到第三、第四页的时候。所以我在设计抓取逻辑时,每翻一页前都做一次“当前页面是否包含登录页标志”的检查,发现情况不对就立即终止抓取并发出提醒,而不是傻等着超时。这个检查逻辑在后文第4章会详细写。
3. 按公司名搜员工:搜索链接构造、结果解析与翻页策略
3.1 搜索结果 URL 的组成与分页规律
登录态解决后,下一步是构造搜索请求。这里有一个容易搞错的地方:搜索员工不是直接访问某个公司主页,而是要借助站内搜索功能,在搜索框里输入公司名,再限定结果类型为人。
观察搜索结果的 URL,可以发现它有几个关键参数:keywords 表示搜索关键词,也就是公司名;facetCurrentCompany 用于限定当前公司过滤条件;page 表示页码。不同入口的 URL 结构会有差异,但从搜索结果页复制出来的 URL,规律通常是这样:
https://www.linkedin.com/search/results/people/?keywords=公司名&facetCurrentCompany=123456&page=1facetCurrentCompany 后面跟的是一串数字,这串数字是公司的站内 ID。也就是说,纯靠公司名去搜不够精确,因为会搜出曾在这家公司工作过的人,以及同名公司的人。最精准的做法是:先打开公司主页,从公司主页进入员工列表页,这种页面抓到的数据才是“当前在职员工”。
我拆这个项目时确认过两种方式的差异:用搜索关键词抓到的结果里混着大量已离职员工,这对“根据公司名抓员工信息”来说数据失真;从公司主页的员工入口进,结果相对干净,但需要先解决每个公司的唯一标识问题。所以这个资源里更推荐的做法是,维护一份“公司名 + 公司主页URL”的输入表,脚本根据主页 URL 自动提取员工列表入口,而不是直接填公司名去搜。
def build_employee_url(company_url: str, page_num: int) -> str: # company_url 形如 https://www.linkedin.com/company/xxx/ # 员工列表入口在主页路径基础上拼接 /people/ base = company_url.rstrip("/") return f"{base}/people/?page={page_num}"这段逻辑不复杂,但有一个边界条件需要特别注意:如果公司主页 URL 里本身带了 query 参数,直接用 rstrip("/") 之后拼接会导致参数丢失。我在另一个项目里踩过这个坑,建议先解析 URL 再重组,不要手工拼字符串。
3.2 用 CSS 选择器定位结果卡片与提取员工主页链接
拿到员工列表页后,页面结构大致是这样的:每个人是一张卡片,卡片里有姓名、职位、所在地、以及一个指向个人主页的链接。在 Playwright 中,可以用 CSS 选择器把这些节点批量取出来。
def extract_people_links(page) -> list: # 等待结果卡片容器出现,这个类名是页面结构的一种,实际使用时需要检查 page.wait_for_selector("div.pv-browse__people-list", timeout=15000) cards = page.query_selector_all("li.pv-browse__person") people = [] for card in cards: name_link = card.query_selector("a.pv-browse__person-name-link") if not name_link: continue name = name_link.inner_text().strip() href = name_link.get_attribute("href") people.append({"name": name, "profile_url": href}) return people这里有几个参数值得解释。wait_for_selector 的 timeout 设为 15000 毫秒,是因为员工列表页的加载速度受网络影响明显,尤其是翻到第 5 页以后可能出现加载变慢;如果时间设得太短,常常会误报页面出错。选择器用了 li.pv-browse__person,这个写法在页面结构没调整的情况下有效,但这类站点的前端 HTML 更新频率不算低,所以拆这个项目时我把选择器单独放在了一个配置文件里,一旦页面改版,只需要改选择器而不用改主逻辑。
拿到 profile_url 后,还需要做一部补全。很多卡片里的链接是相对路径,形如 /in/abc123,需要拼上域名前缀。补全逻辑我会顺手写进同一个函数里:
from urllib.parse import urljoin BASE = "https://www.linkedin.com" def normalize_profile_url(raw: str) -> str: if raw.startswith("http"): return raw return urljoin(BASE, raw)这个 normalize 函数看起来简单,但重要的一点是,它不仅能补相对路径,还能顺手去掉一些卡片链接里多余的追踪参数。有些卡片链接后面跟着 ?trk=xxxx 之类的参数,这些参数对后续抓取个人主页没有用处,反而会造成同一个人的不同链接被当作两个数据。所以我在去重时用 normalize 之后的链接作为唯一键。
3.3 后置处理:链接去重、补全公司字段与请求限速
员工列表页一次只能看到一页,翻多了还会有展示上限,所以抓取过程中很常见的情况是:公司 A 的员工出现在公司 B 的搜索页里(因为交叉任职),同一个人的链接被多次抓到。数据落地前必须先做两件事。
第一件事是去重。我用 profile_url 作为唯一标识,在内存里维护一个集合,读到的链接如果已经存在,就丢弃;如果不存在,才保留。这个做法比直接看姓名去重更可靠,因为重名的人很多,但链接不会重复。
def deduplicate(people): seen = set() unique = [] for p in people: key = p["profile_url"] if key in seen: continue seen.add(key) unique.append(p) return unique第二件事是给每条员工数据补上“所属公司”字段。这一步容易被忽略,因为直接在员工列表页抓到的人,你心里知道他们属于这家公司,但数据量大以后,如果每条记录里没有公司字段,后续按公司维度聚合时就会很痛苦。我一般会在抓取每个公司时把公司名作为固定值写进条目里,这样输出结果可以直接丢进任何表格工具。
请求限速是另一个不可回避的问题。疯狂翻页会让账号被临时限制访问,表现是从某页开始突然返回空数据。这里的问题其实和代码写法无关,属于并发控制缺失引发的连带后果。我的做法是设置一个简单的随机延时器:
import time import random def random_sleep(min_sec: float = 2.0, max_sec: float = 5.0) -> None: time.sleep(random.uniform(min_sec, max_sec))有人会把延时设置成固定的 3 秒,但我建议用随机区间。固定延时在数据采集场景下容易被识别出机器节奏,随机延时的效果通常好一点。这个说法没有严格的数据支撑,更像是经验玄学,但多个项目的实际表现确实如此。
4. 必踩的五个坑:登录态失效、翻页丢失、数据错位与超时排查
4.1 场景一:明明登录成功,为什么抓几页后全是空数据
现象:脚本跑前 3 页正常,第 4 页开始返回的员工列表为空,请求没有报错,页面也正常加载,但就是没有数据。
原因:目标平台对单次会话的访问深度有限制,连续翻页到一定数量后,后续请求不再返回结果内容,而是返回一个“看起来正常但没有数据”的空壳页面。这时候如果你不做内容校验,程序会误以为这家公司没有员工,并把空结果写入最终数据文件,造成后续误判。
解决:每次翻页后检查页面中是否包含员工卡片的容器节点,如果连续 2 次为空,就停止翻页并打印消息提示。同时把已抓到的数据立即写入本地文件,不要攒在内存里最后才写,避免进程中断导致数据丢失。
def check_page_has_results(page) -> bool: try: page.wait_for_selector("li.pv-browse__person", timeout=5000) return True except Exception: return False我习惯把这种检查放在每次翻页之后,一旦返回 False,立刻执行保存操作并跳出循环。这里要注意,wait_for_selector 的 timeout 不能设太长,否则每次翻页都会因为等待而严重降低效率,我的经验是 5 秒是合理值,既不会频繁误判,也不会等太久。
4.2 场景二:staff 抓取为空,但手动打开浏览器明明能看到数据
现象:同样的公司,手动用浏览器打开员工列表页能看到数据,但脚本跑出来却一无所获。
原因:最常见的情况是脚本走到了错误的分支入口,比如输入的公司主页 URL 不是主公司主页,而是某个子页面,导致拼接出来的员工入口链接不对。另一种原因是页面结构不同,有的公司员工列表页采用旧版布局,有的采用新版布局,选择器不通用。
解决:不要盲目改选择器,先手动打开页面看实际 DOM 结构。右键点击员工卡片,选择“检查”,看卡片对应的标签结构,然后按实际结构调整 CSS 选择器。我在拆项目时把选择器提取到了配置文件里,遇到页面结构调整时,只需要改 config 不用改代码。
4.3 场景三:员工链接丢失了 http 头部,变成乱码形式的地址
现象:抓出来的 profile_url 有的是纯路径 /in/xxx,有的带着完整域名,有的甚至带着奇怪的跳转参数,看起来像乱码。
原因:站内搜索结果卡片通常会用带跳转参数的链接来统计点击来源,这些链接如果不做清洗,直接存下来的话,后期请求个人主页会多一次跳转,增加负载也增加被拦截的概率。
解决:统一用 urljoin 处理,先把相对路径补成绝对地址,再使用正则剥离 trk 参数。
import re def clean_url(raw: str) -> str: full = urljoin("https://www.linkedin.com", raw) full = re.sub(r"\?trk=.*$", "", full) return full4.4 场景四:员工姓名与主页链接错位
现象:张三的名字配上了李四的主页链接,而且不是偶发,是一整段卡片的错位,前面的名字配的是后面的链接。
原因:节点提取时没有建立稳定的对应关系。常见的错误写法是先提取所有 name,再提取所有 href,然后按索引合并,一旦页面中有一个人缺了链接,索引就对不齐了。
解决:必须在一个卡片节点内同时取姓名和链接,也就是先定位卡片容器,再在容器内部查找子节点,确保一一对应。写爬虫是一条稳定原则:先找到唯一的父节点,再做孩子节点的提取,不要跨节点组合。
4.5 场景五:task 超时,抓取进程卡死不退出
现象:某一次翻页后,请求长时间不返回,程序卡在 page.goto 或 wait_for_selector 上,最终一直不退出。
原因:网络请求被挂起,或者页面里的异步资源迟迟不加载完。Playwright 默认的页面加载策略是 load 事件,这意味着所有图片和脚本都加载完才继续执行,实际上很多页面会因为这个策略而卡住。
解决:把页面加载策略改为 domcontentloaded,并且给 goto 加一个超时时间。这样页面主体结构解析完就放行,异步资源不参与等待。
page.goto(url, wait_until="domcontentloaded", timeout=30000)这类修改对抓取效率提升很明显,原来的场景卡住基本都涉及图片资源加载过慢。改动之后,单页抓取时间可能快一倍以上。
5. 让数据可直接入库:员工数据清洗、增量更新与二次验证
5.1 员工数据清洗:姓名、职位与主页的标准化
抓到姓名、职位、主页链接之后,不能直接存库,因为原始字段往往带着大量干扰内容。比如姓名字段在页面上可能是纯文本,复制下来却带有多余空格;职位字段会带公司名后缀,例如“高级工程师 at 某公司”;主页链接则可能有跳转追踪参数。
我一般会定义一层清洗函数,把这些常见干扰统一处理掉。清洗规则的优先级是:先清链接,再清姓名,最后清职位。
def clean_record(record: dict) -> dict: name = " ".join(record.get("name", "").split()) title = record.get("title", "").split(" at ")[0].strip() url = clean_url(record.get("profile_url", "")) return {"name": name, "title": title, "profile_url": url, "company": record.get("company")}这个函数里 title 字段的处理用了 at 作为分割标记,原因是页面展示时常用这种格式,但这只是通用做法,如果碰到某些行业职位本身就带 at 的情况,清洗结果会有偏差。我建议清洗后做一次人工抽样检查,看保留的数据是否符合预期。
5.2 增量更新:避免每天重复抓全量数据
爬虫项目跑起来之后,还有一个比稳定抓取更实际的问题:员工数据是变化的,今天抓过一批,明天又有新员工入职、老员工离职。如果每天重新抓全量,既浪费请求额度,又容易被限制访问。增量更新的思路是,每天只抓上次抓取时间之后发生变化的员工记录,但问题在于搜索接口不支持按更新时间过滤。
退而求其次的做法是:每天全量抓一遍,但只把和上次不同的部分写入新增表。这样网资源有限,但它胜在逻辑简单,而且工作情况稳定。
import json, pathlib def load_previous_data(path): if not pathlib.Path(path).exists(): return {} with open(path, "r", encoding="utf-8") as f: return {item["profile_url"]: item for item in json.load(f)} def diff_records(new_records, old_map): added = [] for record in new_records: old = old_map.get(record["profile_url"]) if not old: added.append(record) continue if old["title"] != record["title"] or old["company"] != record["company"]: added.append(record) return addeddiff_records 这个函数是整套增量逻辑的核心:profile_url 不在旧数据里说明是新员工,连续存在但职位或公司变了说明发生了变动。这样每次运行只更新变化的部分,同时保留全量快照作为历史存档。
5.3 二次验证:用个人主页请求确认数据可用性
清洗和更新都做完之后,还需要验证一件事:抓到的个人主页链接是否能正常访问。由于反爬的存在,少量链接可能会在批量请求过程中被临时重定向到验证页面,导致库里有数据、但直接访问时打不开。
验证方式是在抓取结束后随机抽样若干条记录,逐个请求主页链接,检查返回的内容里是否包含人名。这里我不用 Playwright,因为请求量小,用简单的 HTTP 库加携带头信息就够了。
import requests def verify_profile(url: str, expected_name: str) -> bool: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } try: resp = requests.get(url, headers=headers, timeout=10) return expected_name in resp.text except Exception: return False抽样比例我一般放在 5% 到 10% 之间,比例太低看不出问题,比例太高又浪费请求资源。验证结果如果发现某个公司的大批量链接全部验证失败,通常不是链接问题,而是账号被限制了访问,这时候要回到第 2 章重新保存登录态,而不是逐条修数据。
从那以后,我每次部署这类对象抓取工具都会强制检查一遍三件事:登录态文件是否可复用、单页内容校验是否能拦截空结果、数据落盘是否即时。这三件事在拆 LinkedinSpider 这个项目的过程里已经成了习惯。希望这篇拆解笔记对你有用,如果你在自己跑的过程里遇到上面这张表里没列到的问题,建议先看页面实际返回的结构,再调整选择器与逻辑。
本文还有配套的精品资源,点击获取