简介:该开源项目是一个基于Python的LinkedIn爬虫工具,核心功能是根据用户输入的公司名称,自动抓取该公司在LinkedIn上的员工公开资料,覆盖职位、地点、教育背景、工作经历等字段。主要面向需要批量获取职业数据的研究人员、数据分析师、招聘人员与市场调研者,也适合想学习网络爬虫开发的中高级Python开发者。压缩包总共3个文件,包括主爬虫脚本、说明文档和配置文件,整体大小约5KB,代码结构清晰,便于快速阅读、修改与二次部署。项目完整演示了从构造搜索请求、解析HTML页面、逐页翻转到访问个人页深度抓取的流程,并涉及验证码、IP限制、User-Agent检测等反爬机制的应对思路。同时用到了requests、BeautifulSoup、selenium、pandas等常用库的搭配,可帮助使用者理解真实爬虫项目的落地方式。目前已有1601人浏览学习,适合作为开源爬虫研究与人才数据采集的参考模板,但使用时务必遵循LinkedIn服务条款,注意请求频率与隐私合规。
1. LinkedinSpider:为什么“按公司抓员工”这件事值得自己动手
拿到LinkedinSpider这个标题时,我第一反应是:这又是一个被各家“数据服务商”明码标价的接口活儿。企业做人才盘点、销售找决策人、猎头摸底目标公司,都会问同样一个问题——这家公司有哪些关键岗位的人在领英上?外包一条接口报价不低,按需拉一次数据还可能涉及合规风险。自己用爬虫按公司名去抓,最大的价值不是省那点钱,而是每次抓完数据都留在本地,字段可以自己重新定义,坏了可以重新跑。这个方案适合三类人:需要定期做竞品人才地图的HRBP、做B2B销售线索的开发人员、以及想给内部系统接“公司-员工”关系数据的后端工程师。我默认你的环境是Python 3.8以上,能正常访问领英网页,其余前置条件在下面几章逐步补。
这条路的可行性建立在领英当前的产品机制上:公司主页下的People列表是公开可见的,不需要互为好友;搜索时能看到姓名、职位头衔、所在地、公司、部分经历摘要。LinkedinSpider要做的就是用程序替代人肉搜索:拿到公司名→解析出该公司主页ID→基于公司维度做人员列表请求,而不是用普通的关键词空间搜索。整条链路拆开来看,没有哪个环节是不可逾越的,真正的坑在登录态、参数结构和频率限制的后半段。接下来我会按“机制—准备—实现—合规—避坑—进阶”的顺序,把从零到能用的完整路径讲清楚。
2. 先搞清楚抓取对象:领英的“公司维度”数据是怎么暴露给爬虫的
2.1 公开可见不等于随便抓:登录态与访问边界的真实关系
领英站内有两套数据暴露逻辑。一套是面向搜索引擎和未登录访客的“公开档案视图”,能看到姓名缩写、公司名、职位名,但看不到完整履历和联系方式;另一套是登录后看到的完整列表视图,People搜索结果页会返回更完整的姓名和职位。LinkedinSpider这类工具能拿到可用的姓名+当前公司+职位级别,靠的就是登录视图。很多人误以为不登录也能抓全量,实测结果是:未登录状态下连续翻页十几次就会被重定向到登录墙,而且返回的JSON里名字是乱码缩写。我的做法是手动在浏览器登录一次,把Cookie导出来给脚本用。
这里要区分两个Cookie体系。一个是网页端的li_at,它是长期会话凭证,有效期一般在30天左右;另一个是JSESSIONID,属于短期会话标识。爬虫请求时推荐带上完整的Cookie请求头,而不是只带一两个关键项。原因在于领英的风控会校验Cookie的键值对完整性,缺失任何一个常用键都有可能触发安全校验页。一个可复现的判断方法是:请求返回200但内容是一段包含challenge字样的HTML,就说明Cookie携带不全或者已经过期,而不是IP被封。
2.2 公司统一标识(Company URN)是整条抓取链路的钥匙
按公司名搜索员工,核心不是存公司名的字符串,而是先找到领英内部的Company URN。这个数值ID长这样:urn:li:organization:12345678,中间那一串数字是这家公司在领英内的唯一标识。为什么一定要这个ID?因为People列表的查询参数用的是currentCompany=[ID],而不是公司名字符串;用名称去查,系统会走一次模糊匹配,返回结果不稳定且漏掉改过名的公司。
拿到Company URN有两种常见做法:第一种,直接访问公司主页URL,比如linkedin.com/company/公司英文名,页面源码的og:title和meta标签里有organizationId;第二种,用领英的/voyager/api/organization/companies接口按名称模糊搜索。第一种不用额外接口,解析HTML即可,但公司名要精确匹配URL里的slug;第二种对拼写容忍度高,适合公司全称和简称不一致的场景。我一般优先用第二种,因为它在没拿到主页URL时也能反查,而且返回的JSON里直接带URN和公司主页地址两个字段。
2.3 员工列表的数据来源:不是页面HTML,而是接口JSON
领英的People列表页背后有独立的接口端点。普通搜索和公司维度搜索走的是同一个/voyager/api/search/blended接口,区别只在查询参数。这个接口默认返回混合内容,要在JSON节点elements里找到type==SEARCH_CARD且entityUrn以urn:li:people:开头的条目,每条里包含:name(姓名)、headline(职位头衔)、subOccupation(次级职位)、location(所在地)、publicIdentifier(个人主页尾号)。比较关键的是,接口返回的职位头衔有些是自动规范化过的,有些是用户自填的,字段名和含义在解析时最容易翻车,后面避坑章会展开讲。
接口的翻页逻辑和网页端搜索框完全一致,用page参数控制页码,每页默认25条。filters里的currentCompany或company列表决定按公司过滤,keywords留空即表示只看该公司全员。这个接口没有公开的官方文档可查,参数名是通过浏览器开发者工具抓到的,不同账号、不同地区看到的参数结构可能有差异,所以下文实现的脚本里我会把请求参数集中放在一个配置区,方便你对照自己的抓包结果做微调。
3. 动手前的准备清单:Cookie获取、环境安装与目标规则定义
3.1 手动获取登录Cookie:这一步无法全自动,安全可控
爬虫脚本本身无法绕过登录验证,所以第一步用浏览器手动登录领英,然后从开发者工具里复制Cookie。操作路径:打开浏览器开发者工具(F12)→切到Network面板→刷新一次领英首页→在请求列表里点任意一条领英自身的XHR请求→在Request Headers里找到cookie字段→完整复制值。不要只复制li_at,整段复制最省事。粘到脚本里时注意Windows机换行有时会带\r,记得清洗掉。
获取Cookie时有三个检查点:第一,确认这个Cookie是从www.linkedin.com域下拿的,别从第三方域名的页面拿;第二,确认账号处于登录态,验证方式是复制Cookie后在Incognito窗口贴到领英页面能直接进入Feed;第三,Cookie有有效期,脚本里做成从环境变量读取而不是硬编码,便于过期后快速替换。
# 安装依赖,只需要三个基础库 pip install requests beautifulsoup4# cookie_loader.py import os def load_cookie() -> str: """ 从环境变量读取 Cookie,避免明文硬编码进代码库 用法: export LINKEDIN_COOKIE='li_at=xxx; JSESSIONID=yyy; ...' """ cookie = os.getenv("LINKEDIN_COOKIE") if not cookie: raise RuntimeError("请先设置环境变量 LINKEDIN_COOKIE") return cookie这段代码做了两件事:一是规定Cookie只从环境变量进入程序,二是给出一个标准化读取入口。参数说明:环境变量名LINKEDIN_COOKIE可自定,注意Shell里复制Cookie时若包含单引号,需要在导出时改用双引号包裹或用转义符,否则变量值会被截断。养成这个从环境变量读取的习惯,后续把脚本放到定时任务或容器里都不用改代码。
3.2 确定目标公司名和抓取深度:先列清单,再定边界
抓取前把公司名的变体问题先解决。同一家公司在领英上可能出现多个主页,比如“某某科技(中国)”和“某某科技(总部)”是两个独立Company URN,员工列表完全不重叠。LinkedinSpider按公司名抓取时,如果只抓其中一个,数据天然不全;如果同名两个都抓,又会重复。所以出发前建一个“公司入围表”,字段包括:公司中文全称、领英主页slug、Company URN(如果能查到)、抓取页码上限。
抓取深度上,我一般建议先跑一页25条验证解析正确,再放量到全量。全量翻页不是越多越好——单次会话内翻页超过30页,触发安全校验的概率指数上升。如果目标公司员工总量超过750人,建议拆成“按地区过滤”或“按职能关键词过滤”分批拉取,避免一个会话翻太多页。这个上限没有官方数字,是团队在多次抓取中测试出的经验值,和账号权重、操作习惯都有关系。
3.3 请求头伪装与请求频率:从第一行代码就做对
请求头直接决定第一印象。最低配置是User-Agent、Accept、Accept-Language三项,其中User-Agent务必用当前浏览器真实版本,不要用Python默认的python-requests标识。Accept-Language建议设成zh-CN,zh;q=0.9,en;q=0.8,和浏览器常规行为一致。Accept字段用text/html,application/xhtml+xml,...而不是application/json,因为第一步解析公司URN走得是HTML页面。
频率控制方面,两个随机暂停点:一个是每次翻页后的延迟,我习惯设为4到9秒的随机值;另一个是每完成5页后加一次20到35秒的长暂停。不要用固定延迟,固定间隔在时间序列上的方差为零,更容易被频率分析识别。更稳妥的做法是把每次请求的时间戳和延迟间隔写进本地日志,方便事后回溯“哪一段时间连续请求触发了限制”。这些参数都会体现在第四章的完整脚本里。
4. 核心实现:解析公司URN、循环抓员工列表、清洗落库
4.1 公司反查接口:按公司名获取Company URN
公司反查这一步可以走搜索接口,也可以走页面解析;这里给出更易复现的页面解析方案。核心逻辑:请求领英公司名的公开主页URL,从HTML的meta标签中读出organizationId,再拼出完整的urn:li:organization:{id}。这种方式不需要额外的搜索接口权限,只要公司名的slug拼对就能出结果。如果公司名的slug带特殊字符(如&、数字组合),用urllib.parse.quote做一次编码再拼接URL。
# resolve_company.py import re import requests def resolve_company_urn(slug: str, cookie: str) -> tuple[str, str]: """ 按公司slug解析Company URN和公司全称 slug示例: 'acme-corp'(从公司主页URL尾部复制) """ url = f"https://www.linkedin.com/company/{slug}" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Cookie": cookie, } resp = requests.get(url, headers=headers, timeout=15) if resp.status_code != 200: raise RuntimeError(f"公司主页请求失败: status={resp.status_code}") # organizationId 出现在<meta content="{数字}" name="organizationId">里 m = re.search(r'name="organizationId"\s+content="(\d+)"', resp.text) if not m: raise RuntimeError("未找到organizationId,可能是slug错误或页面结构有变") org_id = m.group(1) # 公司全称从 og:title 拿,后面做结果标注用 t = re.search(r'property="og:title"\s+content="(.*?)\s*\|', resp.text) company_name = t.group(1).strip() if t else slug return f"urn:li:organization:{org_id}", company_name这段代码有一个设计细节:正则只匹配name="organizationId"这种属性顺序,实际页面里content可能出现在name前面,如果你的页面结构不同,需要调换正则里的属性顺序。逻辑说明:返回值是URN和公司全称的二元组,URN用于后续员工查询参数,公司全称用于给抓取结果打标签。参数说明:timeout设为15秒是兼顾慢网络和快速失败;如果公司slug里包含中文或空格,需要先做URL编码。注意这里只处理了单次请求,如果公司主页是/company/acme-corp/people/结构,slug仍取路径里company/与/之间那段,不受结尾people影响。
4.2 员工列表拉取:参数组织、翻页循环与JSON解析
公司URN到手后,接下来的核心请求是/voyager/api/search/blended接口。这个接口用GET请求就能返回JSON,但参数必须严格按照web端抓包结构来。接口地址、查询参数模板和关键字段说明如下:
# fetch_employees.py import json import random import time import requests def build_people_params(company_urn: str, page: int) -> dict: """ 构造员工列表查询参数 company_urn: resolve_company_urn返回的完整URN page: 页码,从1开始 """ return { "keywords": "", # 留空表示该公司全员,不为空时做关键词过滤 "currentCompany": [company_urn], # 公司维度过滤,可以是列表,支持多公司合并 "origin": "GLOBAL_SEARCH_HEADER", # 来源标识,复制自web端,影响返回字段完整度 "page": page, # 从1开始,超过30页风控概率升高 "spellCorrectionEnabled": "false", # 关闭拼写纠正,避免返回无关结果 } def fetch_employee_page(session: requests.Session, params: dict) -> list[dict]: api = "https://www.linkedin.com/voyager/api/search/blended" resp = session.get(api, params=params, timeout=20) if resp.status_code != 200: # 200说明正常;429限流;403多半是Cookie失效或风控校验 raise RuntimeError(f"员工列表请求失败: status={resp.status_code}") try: data = resp.json() except json.JSONDecodeError: # 返回的不是JSON时,有可能命中了安全页面,整页HTML会混进来 raise RuntimeError("响应不是JSON,请检查Cookie或是否触发风控") employees = [] # elements节点里嵌套了多层的元素列表,需要逐层遍历 for element in data.get("elements", []): for item in element.get("elements", []): if not item.get("entityUrn", "").startswith("urn:li:people:"): continue employees.append({ "name": item.get("name", ""), "headline": item.get("headline", ""), "sub_occupation": item.get("subOccupation", {}).get("text", ""), "location": item.get("location", ""), "public_id": item.get("publicIdentifier", ""), "profile_url": f"https://www.linkedin.com/in/{item.get('publicIdentifier', '')}", }) return employees这段代码的解析逻辑拆开看:第一层elements对应混合搜索结果,里面每个element再展开一层elements才是单条人员卡片;用entityUrn的前缀做类型过滤,强制只保留人员条目,把公司、职位、课程等混杂结果全部排除。参数说明:currentCompany传列表形式是官方web端的格式,即使只查一家公司也要保持["urn:..." ],传纯字符串会导致部分账号请求直接返回空。origin参数的取值也很关键,不同来源标识影响返回载荷的字段完整度,这里用GLOBAL_SEARCH_HEADER能稳定拿到subOccupation,换成SEARCH_BUTTON会少这个字段。如果拿不到location字段,尝试在headers里补上CSRF-Token和csrf-token两个请求头,值从Cookie的JSESSIONID中提取。
4.3 翻页循环与数据落地:输出CSV同时保留原始JSON
单页解析没问题后,把循环逻辑串起来。翻页不是简单加一就完事,需要判断“是否有下一页”,判据是当前页返回条数是否为0,或是否达到预设上限。同时每页返回后要检查data里是否有elements为空但请求成功的情况——这种情况通常是参数结构有问题,不是公司没人,盲目翻页只会浪费请求次数。循环中把原始JSON落盘一份,方便后续重新清洗,不必每次重抓。
# run_spider.py import csv import json import os import random import time from fetch_employees import build_people_params, fetch_employee_page from resolve_company import resolve_company_urn from cookie_loader import load_cookie COOKIE = load_cookie() COMPANY_SLUG = "acme-corp" # 替换成目标公司的slug MAX_PAGE = 10 # 建议上限,不要超过30 OUTPUT_CSV = "employees.csv" RAW_DIR = "raw_json" def main(): session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 ... AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Cookie": COOKIE, }) # 1. 解析URN company_urn, company_name = resolve_company_urn(COMPANY_SLUG, COOKIE) print(f"[info] 目标公司: {company_name}, URN: {company_urn}") # 2. 翻页抓取 os.makedirs(RAW_DIR, exist_ok=True) all_rows = [] for page in range(1, MAX_PAGE + 1): params = build_people_params(company_urn, page) rows = fetch_employee_page(session, params) if not rows: print(f"[info] 第{page}页无数据,停止翻页") break all_rows.extend(rows) # 原始JSON留档,字段后续解析时再做二次扩展 with open(os.path.join(RAW_DIR, f"page_{page}.json"), "w", encoding="utf-8") as f: json.dump(rows, f, ensure_ascii=False, indent=2) # 4-9秒随机延迟,每5页加一次长暂停 time.sleep(random.uniform(4, 9)) if page % 5 == 0: time.sleep(random.uniform(20, 35)) # 3. 写CSV with open(OUTPUT_CSV, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=[ "name", "headline", "sub_occupation", "location", "profile_url" ]) writer.writeheader() writer.writerows(all_rows) print(f"[done] 总计抓取 {len(all_rows)} 条,已写入 {OUTPUT_CSV}") if __name__ == "__main__": main()循环逻辑里有两个容易被忽略的点:第一是break条件,如果第一页就是空列表,说明不是公司没人,而是参数或Cookie有问题,直接停止比反复重试稳妥;第二是CSV的编码,这里用utf-8-sig而不是utf-8,这样生成的CSV用Excel打开时中文不会出现乱码。参数说明:MAX_PAGE设10只是默认值,按员工量调整,如果公司就几十个人跑到第二页就自动停了;RAW_DIR建议固定存放而不做清理,原始JSON对比解析后CSV的字段,排查问题时能快速定位是清洗逻辑还是来源问题。这个脚本已经够日常小批量使用,接下来看合规和风控边界怎么拿捏。
5. 抓取频率、数据合规与账号风控:LinkedinSpider能活多久取决于这几点
5.1 合规红线:爬取个人信息的合法边界不在技术,在用途
LinkedinSpider抓的是个人姓名、职位、公司、所在城市,这些字段在法律上属于个人信息。技术能不能抓到是一回事,抓下来怎么用是另一回事。如果抓来用于招聘人才寻访,在多数司法辖区属于正当商业目的,但要控制数据量且不要抓联系方式字段;如果抓来用于营销推送、批量电话销售,风险非常高。我在团队里定的红线是:不抓邮箱、不抓手机号、不抓个人主页里的详细经历文本,只保留姓名、职位、公司这些“名片级”信息,且数据定期清理。
另外一点:领英的用户协议明确禁止未经授权的数据抓取,爬虫账号被发现后轻则封禁,重则影响同IP段其他账号。所以合规上要做的不是“不让抓”,而是“抓得少、抓得礼貌、只用于明确用途”。每批数据抓完后打上“抓取日期+用途标签”两个字段,后续审计时能说清楚这批数据的来路和去处,这是从业者最容易被忽略的一环。
5.2 风控触发条件与频率参数:把速度控制在“像人在操作”
第4章的代码已经做了随机延迟和长暂停,但还不够。领英的风控不只盯请求频率,还有三个维度:每日总量、翻页连续性、单号活跃时段。单账号单日建议控制在300页以内;翻页时不要连续无间隔,每翻一页至少等够4秒以上;活跃时段尽量落在当地时间工作日白天,凌晨批量操作最容易触发校验。
这里有一个常见的认识误区:降低频率就能无限安全?不是。用户的搜索行为和爬虫的“机械翻页”在行为序列上存在明显差异——人翻到第二页看到结果后往往会点开两三个主页看看,爬虫只会一路翻到底。因此我建议在翻页到第3、7、11页时,随机间隔地访问一两条员工主页详情(只访问不解析也行),让行为序列更接近真人。这个“访问锚点”的成本极低,但对风控特征的弱化很有效,属于值得做的小动作。
5.3 多账号与代理的边界:不建议自己做,优先降低单号压力
谈到大规模抓取,很多人的第一想法是买一批账号轮换。这个方向我不建议在小团队里做:账号注册时的设备指纹、支付信息、使用习惯都会关联,一个号触发校验会拖累一批号,维护成本和翻车概率不成比例。更合理的做法是把单账号的抓取量压到安全区间,配合“分时段+分职能关键词”的方式拉长整体周期。比如目标公司800人,不要一天拉完,分成4天,每天按“工程师”“销售”“市场”三个关键词各拉一小批。总量不变,但单日行为曲线平缓得多。
代理方面,市面上的数据中心IP与领英普通用户的IP属性差异较大,容易直接触发登录校验;住宅IP成本又高。所以第4章的脚本里没有写代理支持,就是为了让你先跑通单账号低频模式,确认业务数据质量后,再讨论要不要scale。不要一开始就上多号多代理,多数早期项目的失败原因不是规模不够而是确定性不够。
6. LinkedinSpider常见问题排查:6个实际踩过的坑与修复方法
6.1 返回200但elements为空列表:不是公司没人,是参数结构不对
有一类典型现象:请求响应是200,JSON正常返回,但elements里一个人员条目都没有。一开始我以为是这家公司员工都没写领英,后来检查抓包数据发现,是currentCompany传成了字符串而不是列表。领英接口对这个参数的类型要求非常严格:传"urn:li:organization:123456"(字符串)大概率返回空结果;传["urn:li:organization:123456"](单元素列表)才正常。排查路径:先看响应里有没有error字段;其次把参数原样打印出来和浏览器抓包对比;最后检查origin参数是否被改成了不存在的值,导致返回体结构变化。
6.2 JSONDecodeError频繁出现:命中HTML安全校验页
现象是连续抓到第15页左右,突然抛“响应不是JSON”,把返回内容打印出来会发现是一整页HTML,里面有px-challenge字样。原因分析:请求头里Cookie不完整或频率超标,领英返回了安全校验页而不是JSON接口响应。解决的思路分两步走:第一步,短绷带——检查Cookie里的li_at是否还在有效期内,重新导出并替换有条件就换一个网络出口;第二步,治本——把MAX_PAGE降下来,并开启第5章说的“翻页中途随机访问锚点”,如果脚本连续两次在第15页附近翻车,说明频率还是太快,把随机延迟从4~9秒加到8~15秒再测。这类问题没有一劳永逸的解法,每次遇到都要重新调参。
6.3 抓到的headline全是“xx公司高级工程师”但没有具体职权
还有一类常见现象是头部字段和预期不同。比如:公司是某商业银行,但员工headline多数是“Analyst”没写细分小组;或者职位头衔是系统自动填的“Associate”,和本人自填的“M&A Associate”不一致。原因在于领英的搜索接口返回的headline是用户公开资料当前值,部分账号的职业头衔经过系统规范化,而subOccupation字段往往保存了更精确的自填内容。解析时不要只取headline,把subOccupation字段一并入库,后续在做职位分组统计时,用subOccupation做补充。代码里我已经放了这段逻辑,但注意subOccupation在前端是可选字段,缺失时会是空字符串,不是None。
6.4 公司重名导致URN解析到错误主页
按slug解析URN时,一个经典翻车场景是:公司全称不同,但领英URL的slug完全相同。比如某地产公司和某咨询公司同时用了“XX-partners”这个slug,实际是两家完全独立的企业。resolve_company_urn函数只按URL拿ID,不校验公司名匹配度,所以解析后要用og:title的返回值对比一次——如果你在函数第25行那一步能拿到的公司全称和预期不一致,就要换slug。这个校验逻辑在第4章代码里是做成打印提示的,实际使用建议改成抛异常或记录警告。另外注意领英上公司主页有“关注数、员工数”的预估聚合页,这类页面不是公司主页格式,更复杂,不适用于这个解析流程。
6.5 相同公司两次抓取结果条数不一致:员工列表本身就是动态的
今天抓了150条,一周后再抓只剩148条,或变成155条,看起来像是数据丢失或脚本bug。原因:领英的员工列表反映的是员工在领英上填写的“当前公司”字段,有人离职改了公司,有人新入职补填了资料,还有一部分人设了隐私不对搜索引擎暴露但登录可见,列表本身每天都在变。解决方式:每次抓取把结果存成一个带日期的目录或文件名,对比时按“快照”概念处理,不要期望两次结果完全一致。抓“趋势”要用后续第7章的增量更新方案,不是重跑全量再diff。
6.6 中文公司名编码报错或URL拼接失败
公司名是中文时拼slug会遇到一个问题:领英公司URL的slug一般是最初提交公司主页时填写的英文名称生成的,中文本名并不直接存在URL里。直接拿中文去拼URL必然404。解决方法是先用领英搜索框反查:请求/voyager/api/search/blended带keywords=中文公司名,从返回里过滤type==COMPANY的条目拿slug,再走resolve流程。这样要两次请求,但稳定。注意:中文名搜索接口返回的首页推荐公司可能有多页,建议取第一条按粉丝数排序的,其余公司条目标记为候选公司,在CSV里输出一行一个候选,供人工确认。
7. 进阶用法:从“按公司名抓全量”到“按职位关键词过滤+增量更新”
7.1 给员工列表加职位关键词过滤:把抓取目标从“全员”变成“关键角色”
前文的抓取是公司维度全量拉取。实际业务中需求往往是“抓这家公司的Java工程师”或“华东区销售负责人”。领英接口支持在build_people_params里同时传currentCompany和keywords,两者的关系是AND,即“该公司&含关键词”的人。关键词匹配作用于职位头衔和工作经历,相当于一次缩小范围的预筛选,减少翻页量。
def build_filtered_params(company_urn: str, page: int, keyword: str) -> dict: params = build_people_params(company_urn, page) params["keywords"] = keyword # 增加岗位相关过滤,减少不相关结果 params["filters"] = {"role": ["ENGINEERING", "PRODUCT"]} return params参数说明:filters里支持传入role(职位职能类)、seniority(资历级别)、location(地域)等维度;取值枚举需要从接口返回的facet字段里读取,同一份响应里会附带可用的过滤值列表。这个扩展的代价是要额外读取facet做映射,首次使用建议先跑一次不带filters的接口,把返回的facet打印出来对照着填,不要凭文档硬拼。
7.2 增量更新:用“上次抓取时间”避免每天重复全量拉取
重跑全量在频率上是浪费的,改进思路是维护一个“公司员工快照表”,按publicIdentifier做主键,每次抓取只更新已有员工的headline变化和发现新员工,不重复下载未变化的旧数据。这样单日翻页量可以大幅压降,同时也让风控更安全。实现思路:抓取后的数据统一进SQLite,先按publicIdentifier的集合求差集,只抓新增部分,旧数据每30天才重刷一次。
# incremental.py import sqlite3 def init_db(db_path: str) -> None: conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS employee_snapshot ( public_id TEXT PRIMARY KEY, name TEXT, headline TEXT, location TEXT, profile_url TEXT, company_urn TEXT, first_seen_at TEXT DEFAULT CURRENT_TIMESTAMP, last_updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() conn.close() def upsert_employees(db_path: str, employees: list[dict], company_urn: str) -> int: conn = sqlite3.connect(db_path) updated = 0 for emp in employees: cur = conn.execute( "SELECT 1 FROM employee_snapshot WHERE public_id = ?", (emp["public_id"],) ) if cur.fetchone(): conn.execute(""" UPDATE employee_snapshot SET headline = ?, location = ?, last_updated_at = CURRENT_TIMESTAMP WHERE public_id = ? """, (emp["headline"], emp["location"], emp["public_id"])) else: conn.execute(""" INSERT INTO employee_snapshot (public_id, name, headline, location, profile_url, company_urn) VALUES (?, ?, ?, ?, ?, ?) """, (emp["public_id"], emp["name"], emp["headline"], emp["location"], emp["profile_url"], company_urn)) updated += 1 conn.commit() conn.close() return updated逻辑说明:public_id对应个人主页URL尾号,是稳定的唯一标识;主键冲突时走更新分支,不冲突时走插入分支,天然完成增量合并。first_seen_at和last_updated_at两个时间戳是给后续“员工离职监测”用的——当某条数据超过30天没刷新且查询接口搜不到该public_id时,标记为“可能已离职”,这只是推断值,不能当成事实写入对外报告。参数说明:company_urn存了来源公司,处理跨公司兼职场景时就靠这个字段区分归属。
7.3 验证数据质量:抽检率与字段完整性
脚本跑完后别急着交付,至少做三项抽检:第一,用抓取结果里的profile_url随机抽3%直接人工打开,确认姓名和当前公司和结果一致;第二,检查headline字段中文占比与预期相符,比如某中国区公司如果有80%以上的headline显示英文,有可能是公司主页选错了(多语言公司主页常见);第三,检查location字段是否为空的比例是否异常,正常应低于15%,如果空值过多说明接口返回的字段有缺失,可能需要补请求subOccupation或改用搜索接口从elements外层取位置信息。这三步大概花十几分钟,但能拦住大部分“数据看似正常但字段错位”的返工。
7.4 从抓取到内部系统:数据格式规范化与字段映射
CSV落地后进入企业系统前,通常要做字段的标准化映射。比如headline里含有“资深”或“Sr.”的,映射为“资深/高级”级别;location按城市名映射到区域;profile_url统一转成内部系统的联系人链接。这些映射规则不是技术难点,但直接影响下游使用方的体验。我习惯在脚本的最后加一个normalize.py模块,专门做字段映射,把“抓取逻辑”和“清洗逻辑”分开维护——爬虫结构变了只改fetch部分,业务字段规则变了只改normalize部分,互不干扰。
这套抓取方案的现实收获是:某个模拟项目里,我花了两个晚上把脚本改成公司+关键词过滤,结果整体翻页量降了70%,数据准确率提升到可用水平。把本文章代码保存为三个文件依次执行,第一周小批量参数调顺,第二周就可以给业务方交付第一版人才地图。如果你在操作时碰到页数一多就校验报错,别急着怀疑IP和代理,先把延迟参数调大、把锚点请求加上——很多时候问题不在“速度不够慢”,而在“行为不够像人”。希望帮到你。
本文还有配套的精品资源,点击获取