LinkedIn爬虫实战:Playwright实现登录态复用与员工数据采集
简介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 时指定 headlessFalse因为首次登录需要人工扫码 browser p.chromium.launch(headlessFalse) context browser.new_context( # 设置一个常规尺寸不要用默认的 800x600容易被识别 viewport{width: 1366, height: 768}, localezh-CN, timezone_idAsia/Shanghai ) page context.new_page() # 打开登录页注意这个 URL 是通用入口 page.goto(https://www.linkedin.com/login, timeout60000) print(请在浏览器中完成登录登录成功后脚本会自动保存状态...) # 轮询等待登录完成判断标志是地址栏跳回首页或 feed 页 page.wait_for_url( lambda url: feed in url or checkpoint not in url, timeout180000 ) # 关键步骤把上下文状态保存为 JSON 文件 context.storage_state(pathsave_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(headlessTrue) context browser.new_context( storage_statestate_file, viewport{width: 1366, height: 768} ) return browser, contextheadlessTrue 表示无头模式也就是后台运行不弹出浏览器窗口。注意无头模式在较老版本的 Playwright 中容易被检测如果你发现无头模式抓不到数据先切 headlessFalse 做对照实验。新版 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公司名facetCurrentCompany123456page1facetCurrentCompany 后面跟的是一串数字这串数字是公司的站内 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, timeout15000) 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 函数看起来简单但重要的一点是它不仅能补相对路径还能顺手去掉一些卡片链接里多余的追踪参数。有些卡片链接后面跟着 ?trkxxxx 之类的参数这些参数对后续抓取个人主页没有用处反而会造成同一个人的不同链接被当作两个数据。所以我在去重时用 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, timeout5000) 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_untildomcontentloaded, timeout30000)这类修改对抓取效率提升很明显原来的场景卡住基本都涉及图片资源加载过慢。改动之后单页抓取时间可能快一倍以上。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, encodingutf-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, headersheaders, timeout10) return expected_name in resp.text except Exception: return False抽样比例我一般放在 5% 到 10% 之间比例太低看不出问题比例太高又浪费请求资源。验证结果如果发现某个公司的大批量链接全部验证失败通常不是链接问题而是账号被限制了访问这时候要回到第 2 章重新保存登录态而不是逐条修数据。从那以后我每次部署这类对象抓取工具都会强制检查一遍三件事登录态文件是否可复用、单页内容校验是否能拦截空结果、数据落盘是否即时。这三件事在拆 LinkedinSpider 这个项目的过程里已经成了习惯。希望这篇拆解笔记对你有用如果你在自己跑的过程里遇到上面这张表里没列到的问题建议先看页面实际返回的结构再调整选择器与逻辑。本文还有配套的精品资源点击获取