一、职场人的薪资信息困境
1.1 求职者与HR的薪资博弈困局
"您的期望薪资是多少?"
这个问题,几乎是每一场面试中都会出现的灵魂拷问。作为求职者,报高了怕吓跑机会,报低了又怕亏待自己。而另一边,HR也在纠结:给高了公司成本上升,给低了候选人可能转头就走。
这种博弈的根源在于——信息不对称。
我身边就有这样的真实案例。朋友小李是一名有5年经验的Java开发工程师,去年跳槽时,他在一家互联网公司面试后报了月薪25K的期望。HR当场就答应了,小李还挺高兴,觉得自己谈判成功。结果入职后才发现,同组一个3年经验的同事,薪资居然是28K。原来那家公司当时急缺人,市场价早就涨到30K以上了。
这种"薪资黑箱"现象,困扰着无数职场人:
- 求职者:不知道目标岗位的真实薪资范围,只能凭感觉开价
- 在职员工:不清楚自己是否被"低估",跳槽还是留守心里没底
- HR从业者:制定薪酬体系缺乏市场数据支撑,人才竞争力难以评估
- 创业公司:不了解行业薪资水平,招人时要么出价过高烧钱,要么开价太低招不到人
1.2 LinkedIn的海量职场数据价值
要解决薪资信息不透明的问题,最直接的方式就是获取真实的市场薪资数据。而LinkedIn(领英),作为全球最大的职业社交平台,坐拥超过10亿用户,无疑是最有价值的数据来源之一。
LinkedIn上的职位发布信息包含了极其丰富的薪资相关数据:
| 数据维度 | 价值说明 |
|---|---|
| 职位名称 | 了解不同岗位的市场定价 |
| 薪资范围 | 直接获取企业愿意支付的薪酬区间 |
| 公司信息 | 分析不同规模/行业公司的薪资差异 |
| 工作地点 | 比较不同城市/国家的薪资水平 |
| 经验要求 | 建立经验与薪资的对应关系 |
| 技能标签 | 发现高薪技能,指导职业发展方向 |
如果能够系统性地采集这些数据,并进行多维度分析,我们就能构建一个"薪资透视镜",让职场人在谈薪时有据可依,不再盲目。
1.3 数据采集面临的技术挑战
然而,想要从LinkedIn获取数据并非易事。作为一个商业化程度极高的平台,LinkedIn部署了多层安全验证措施:
单靠普通的爬虫技术很难解决。特别是网络访问异常问题,一旦被平台检测到异常访问模式,轻则管理访问频率控制,重则直接账号状态异常。
这就是为什么我们需要引入专业的代理IP服务——IPIDEA。
二、IPIDEA代理服务介绍
2.1 为什么LinkedIn采集需要代理服务
在数据采集领域,代理IP的作用就像是给爬虫穿上了"网络中转服务"。每次请求都使用不同的IP地址,让目标网站无法通过IP来识别访问者。
对于LinkedIn这样的平台,使用代理IP有以下核心价值:
- 处理网络访问管理:动态分配网络节点,防止单一节点访问受限
- 模拟真实用户:使用住宅IP,请求特征更接近真实用户
- 实现跨地域访问:使用不同国家的IP,获取全球范围的职位数据
- 提高采集效率:多IP并发请求,大幅提升数据采集速度
2.2 IPIDEA产品线概览
IPIDEA 提供多种类型的代理产品,不同场景可选择不同方案:
| 产品类型 | 特点 | 适用场景 | 本项目适用度 |
|---|---|---|---|
| 动态住宅代理 | 真实家庭IP,高隐私保护 | 社交平台数据采集 | ⭐⭐⭐⭐⭐ 推荐 |
| 静态住宅代理 | IP固定,稳定性强 | 需要长期登录的场景 | ⭐⭐⭐ |
| 数据中心代理 | 速度快,性价比高 | 大规模通用采集 | ⭐⭐ |
| ISP代理 | 运营商级别IP | 高安全性需求场景 | ⭐⭐⭐⭐ |
对于LinkedIn这类社交平台,动态住宅代理是最优选择——真实的住宅IP来源让请求特征更接近普通用户。
2.3 代理性能实测
选择代理服务商时,口说无凭,实测数据最有说服力。下面是一个简单的性能测评脚本:
# benchmark_proxy.py - 代理性能测评核心代码
import requests
import time
import statistics
from config import ProxyConfig
class ProxyBenchmark:
"""代理性能测评工具"""
def __init__(self):
self.test_url = "http://httpbin.org/ip"
def run_benchmark(self, num_requests=20):
"""运行性能测评"""
results = {
"success": 0,
"failed": 0,
"response_times": [],
"unique_ips": set()
}
for i in range(num_requests):
try:
proxies = ProxyConfig.get_proxies()
start = time.time()
response = requests.get(self.test_url, proxies=proxies, timeout=15)
elapsed = (time.time() - start) * 1000 # 毫秒
if response.status_code == 200:
ip = response.json().get('origin', '').split(',')[0].strip()
results["success"] += 1
results["response_times"].append(elapsed)
results["unique_ips"].add(ip)
print(f" ✓ #{i+1}: {ip} ({elapsed:.0f}ms)")
except Exception as e:
results["failed"] += 1
print(f" ✗ #{i+1}: {str(e)[:30]}")
time.sleep(0.5)
# 计算统计指标
success_rate = results["success"] / num_requests * 100
avg_time = statistics.mean(results["response_times"]) if results["response_times"] else 0
unique_rate = len(results["unique_ips"]) / results["success"] * 100 if results["success"] else 0
return {
"success_rate": round(success_rate, 1),
"avg_response_ms": round(avg_time, 0),
"ip_unique_rate": round(unique_rate, 1)
}
# 运行测评
benchmark = ProxyBenchmark()
report = benchmark.run_benchmark(20)
print(f"\n成功率: {report['success_rate']}%")
print(f"平均响应: {report['avg_response_ms']}ms")
print(f"IP唯一率: {report['ip_unique_rate']}%")
我实际运行了20次请求的测评,结果如下:
测评环境:macOS, Python 3.11, 网络环境良好
| 测评指标 | IPIDEA实测值 | 行业平均水平* | 评价 |
|---|---|---|---|
| 连接成功率 | 95% | 80-90% | ✅ 优秀 |
| 平均响应时间 | 1200ms | 1500-2500ms | ✅ 优秀 |
| IP唯一率 | 100% | 70-85% | ✅ 优秀 |
| IP池覆盖 | 220国家 | 100-200国家 | ✅ 领先 |
三、环境准备与代理配置
3.1 Python开发环境搭建
本项目基于Python 3.8+开发,需要安装以下依赖库:
# 创建虚拟环境(推荐)
python -m venv linkedin_env
source linkedin_env/bin/activate # Linux/Mac
# linkedin_env\Scripts\activate # Windows
# 安装依赖
pip install requests
pip install beautifulsoup4
pip install lxml
pip install pandas
pip install matplotlib
pip install seaborn
pip install wordcloud
创建项目目录结构:
linkedin_salary_crawler/
├── config.py # 配置文件(代理信息等)
├── crawler.py # 数据采集核心模块
├── analyzer.py # 数据分析模块
├── visualizer.py # 可视化模块
├── main.py # 主程序入口
├── data/ # 数据存储目录
│ ├── raw/ # 原始数据
│ └── processed/ # 处理后的数据
└── output/ # 可视化图表输出目录
3.2 IPIDEA代理账号申请与配置
首先,访问IPIDEA官网注册账号。注册完成后,进入控制台进行代理配置:
第一步:选择代理类型
在控制台左侧菜单找到"动态代理",点击展开后选择"动态住宅代理"。
第二步:配置代理参数
进入"API提取"标签页,配置以下参数:
- 选择套餐:账户额度
- 选择国家/地区:全球混播(或根据需求选择特定地区)
- 提取数量:根据采集规模选择(如100)
- 数据格式:TXT
- 代理协议:HTTP/HTTPS
- 分隔符:回车换行(\r\n)
第三步:生成代理链接
配置完成后,点击"生成链接"按钮,系统会在右侧"API链接"区域生成一个API链接。这个链接每次调用都会返回新的代理IP。
第四步:添加IP白名单
在顶部菜单点击"IP白名单管理",将你的本机IP或服务器IP添加到白名单中,确保API调用正常。
💡 提示:如果不确定自己的公网IP,可以访问 httpbin.org/ip 查看
将获取的API链接保存到config.py配置文件中:
# config.py - IPIDEA代理配置
import requests
class ProxyConfig:
"""IPIDEA代理配置类"""
# API方式获取代理(适合动态轮换)
# 将下面的URL替换为你在IPIDEA控制台生成的API链接
PROXY_API_URL = "http://api.proxy.ipidea.io/getProxyIp?num=1&return_type=txt&lb=1&sb=0&flow=1®ions=&protocol=http"
@classmethod
def get_proxy_from_api(cls):
"""从API获取代理IP"""
try:
response = requests.get(cls.PROXY_API_URL, timeout=10)
if response.status_code == 200:
proxy_ip = response.text.strip().split('\n')[0] # 获取第一个IP
return f"http://{proxy_ip}"
return None
except Exception as e:
print(f"获取代理失败: {e}")
return None
@classmethod
def get_proxies(cls):
"""获取requests格式的代理配置"""
proxy_url = cls.get_proxy_from_api()
if proxy_url:
return {
"http": proxy_url,
"https": proxy_url
}
return None
⚠️ 注意:API链接中的参数说明:
num=1:每次获取1个代理IPreturn_type=txt:返回TXT格式lb=1:负载均衡flow=1:流量计费模式protocol=http:HTTP协议
3.3 代理连接测试验证
在正式采集前,务必测试代理连接是否正常。以下是完整的测试代码:
import requests
import time
from config import ProxyConfig
class ProxyTester:
"""代理连接测试类"""
def __init__(self):
self.test_url = "http://httpbin.org/ip"
def test_single_request(self):
"""单次请求测试"""
try:
print("正在获取代理IP...")
proxies = ProxyConfig.get_proxies()
if not proxies:
print("✗ 获取代理IP失败,请检查API链接和白名单配置")
return False
print(f" 获取到代理: {proxies['http']}")
print("正在测试代理连接...")
start_time = time.time()
response = requests.get(
self.test_url,
proxies=proxies,
timeout=15
)
elapsed_time = round(time.time() - start_time, 2)
if response.status_code == 200:
result = response.json()
print(f"✓ 代理连接成功")
print(f" 出口IP: {result.get('origin', '未知')}")
print(f" 响应时间: {elapsed_time}秒")
return True
else:
print(f"✗ 请求失败,状态码: {response.status_code}")
return False
except requests.exceptions.Timeout:
print("✗ 请求超时,请检查代理配置")
return False
except requests.exceptions.ProxyError as e:
print(f"✗ 代理错误: {e}")
return False
except Exception as e:
print(f"✗ 未知错误: {e}")
return False
def test_multiple_requests(self, count=5):
"""多次请求测试(验证网络节点轮换)"""
print(f"\n正在进行{count}次连续测试(每次获取新的代理IP)...")
print("-" * 50)
ips = set()
success_count = 0
for i in range(count):
try:
# 每次请求都获取新的代理IP
proxies = ProxyConfig.get_proxies()
if not proxies:
print(f" 第{i+1}次: 获取代理失败")
continue
response = requests.get(
self.test_url,
proxies=proxies,
timeout=15
)
if response.status_code == 200:
ip = response.json().get('origin', '未知')
ips.add(ip)
success_count += 1
print(f" 第{i+1}次: {ip}")
time.sleep(2) # 间隔2秒,防止请求过快
except Exception as e:
print(f" 第{i+1}次: 请求失败 - {e}")
print("-" * 50)
print(f"测试完成: 成功 {success_count}/{count} 次")
print(f"共使用了 {len(ips)} 个不同的IP地址")
return success_count == count
# 运行测试
if __name__ == "__main__":
tester = ProxyTester()
# 单次测试
if tester.test_single_request():
# 多次测试
tester.test_multiple_requests(5)
运行测试代码,如果看到类似以下输出,说明代理配置成功:
正在获取代理IP...
获取到代理: http://103.xxx.xxx.xxx:xxxx
正在测试代理连接...
✓ 代理连接成功
出口IP: 103.xxx.xxx.xxx
响应时间: 1.23秒
正在进行5次连续测试(每次获取新的代理IP)...
--------------------------------------------------
第1次: 45.xxx.xxx.xxx
第2次: 103.xxx.xxx.xxx
第3次: 78.xxx.xxx.xxx
第4次: 156.xxx.xxx.xxx
第5次: 92.xxx.xxx.xxx
--------------------------------------------------
测试完成: 成功 5/5 次
共使用了 5 个不同的IP地址
四、LinkedIn职位薪资数据采集实战
4.1 技术方案选型与架构设计
对于LinkedIn数据采集,我们采用以下技术方案:
技术选型:
- 请求库:requests(轻量、灵活、易于配置代理)
- 解析库:BeautifulSoup + lxml(高效解析HTML)
- 数据处理:pandas(强大的数据处理能力)
- 代理服务:IPIDEA动态住宅代理
架构设计:
┌──────────────────────────────────────────────────────────────┐
│ LinkedIn薪资采集系统 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 配置层 │ -> │ 采集层 │ -> │ 解析层 │ -> │ 存储层 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │ │ │ │ │
│ 代理配置 请求管理 HTML解析 数据持久化 │
│ Cookie管理 重试机制 字段提取 CSV/JSON │
│ UA轮换 延迟控制 薪资标准化 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ IPIDEA代理层 │ │
│ │ 动态网络节点分配 | 全球节点 | 高隐私保护 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
4.2 登录状态管理与Cookie持久化
LinkedIn的职位详情页需要登录才能查看完整信息。我们通过Cookie持久化来管理登录状态:
import json
import os
from datetime import datetime
class CookieManager:
"""Cookie管理器:负责保存和加载LinkedIn登录Cookie"""
def __init__(self, cookie_file="data/linkedin_cookies.json"):
self.cookie_file = cookie_file
self._ensure_dir()
def _ensure_dir(self):
"""确保目录存在"""
os.makedirs(os.path.dirname(self.cookie_file), exist_ok=True)
def save_cookies(self, cookies_dict):
"""保存Cookie到文件"""
data = {
"cookies": cookies_dict,
"saved_at": datetime.now().isoformat(),
"expires_hint": "建议每7天更新一次Cookie"
}
with open(self.cookie_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"✓ Cookie已保存到 {self.cookie_file}")
def load_cookies(self):
"""从文件加载Cookie"""
if not os.path.exists(self.cookie_file):
print("⚠ Cookie文件不存在,请先登录LinkedIn并导出Cookie")
return None
with open(self.cookie_file, 'r', encoding='utf-8') as f:
data = json.load(f)
saved_at = data.get("saved_at", "未知")
print(f"✓ 已加载Cookie (保存于: {saved_at})")
return data.get("cookies", {})
def cookies_to_header(self, cookies_dict):
"""将Cookie字典转换为请求头格式"""
if not cookies_dict:
return ""
return "; ".join([f"{k}={v}" for k, v in cookies_dict.items()])
获取Cookie的方法:
- 在浏览器中登录LinkedIn
- 打开开发者工具(F12)-> Network
- 刷新页面,找到任意请求
- 在请求头中复制Cookie值
- 解析并保存到JSON文件
4.3 职位搜索与数据提取
以下是完整的LinkedIn职位采集器实现:
import requests
import random
import time
import re
from bs4 import BeautifulSoup
from typing import List, Dict, Optional
from config import ProxyConfig
class LinkedInSalaryCrawler:
"""LinkedIn薪资数据采集器"""
def __init__(self):
# 代理配置
self.proxies = ProxyConfig.get_proxies()
# 请求配置
self.base_url = "https://www.linkedin.com"
self.search_url = "https://www.linkedin.com/jobs/search/"
self.timeout = 20
self.max_retries = 3
self.delay_min = 3
self.delay_max = 7
# User-Agent池
self.user_agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
]
# Cookie管理
self.cookie_manager = CookieManager()
self.cookies = self.cookie_manager.load_cookies()
# 采集结果
self.jobs_data = []
# 测试代理
self._test_proxy()
def _test_proxy(self):
"""测试代理连接"""
try:
response = requests.get(
"http://httpbin.org/ip",
proxies=self.proxies,
timeout=10
)
if response.status_code == 200:
ip = response.json().get('origin')
print(f"✓ 代理连接成功,当前IP: {ip}")
else:
print(f"⚠ 代理测试返回状态码: {response.status_code}")
except Exception as e:
print(f"✗ 代理连接失败: {e}")
print(" 请检查IPIDEA配置是否正确")
def _get_headers(self) -> Dict:
"""获取随机请求头"""
headers = {
'User-Agent': random.choice(self.user_agents),
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
}
# 添加Cookie
if self.cookies:
headers['Cookie'] = self.cookie_manager.cookies_to_header(self.cookies)
return headers
def _random_delay(self):
"""随机延迟,模拟人类行为"""
delay = random.uniform(self.delay_min, self.delay_max)
time.sleep(delay)
def _fetch_page(self, url: str) -> Optional[str]:
"""获取页面内容"""
for attempt in range(self.max_retries):
try:
print(f" 正在请求: {url[:80]}...")
response = requests.get(
url,
headers=self._get_headers(),
proxies=self.proxies,
timeout=self.timeout
)
if response.status_code == 200:
print(f" ✓ 请求成功")
return response.text
elif response.status_code == 429:
print(f" ⚠ 请求频率过高,等待后重试...")
time.sleep(30)
continue
else:
print(f" ⚠ 状态码: {response.status_code}")
except requests.exceptions.Timeout:
print(f" ⚠ 请求超时 (尝试 {attempt + 1}/{self.max_retries})")
except requests.exceptions.ProxyError:
print(f" ⚠ 代理错误,正在更换网络节点...")
except Exception as e:
print(f" ⚠ 请求错误: {e}")
if attempt < self.max_retries - 1:
self._random_delay()
print(f" ✗ 请求失败,已达最大重试次数")
return None
def search_jobs(self, keyword: str, location: str = "", pages: int = 5) -> List[Dict]:
"""搜索职位"""
print(f"\n{'='*60}")
print(f"开始搜索职位: {keyword}")
print(f"地区: {location if location else '全部'}")
print(f"采集页数: {pages}")
print(f"{'='*60}")
all_jobs = []
for page in range(pages):
start = page * 25 # LinkedIn每页25条
# 构建搜索URL
params = f"keywords={keyword}&start={start}"
if location:
params += f"&location={location}"
search_url = f"{self.search_url}?{params}"
print(f"\n第 {page + 1}/{pages} 页:")
html = self._fetch_page(search_url)
if not html:
print(f" 跳过本页")
continue
# 解析职位列表
jobs = self._parse_job_list(html)
all_jobs.extend(jobs)
print(f" 本页采集到 {len(jobs)} 条职位")
# 随机延迟
if page < pages - 1:
self._random_delay()
print(f"\n{'='*60}")
print(f"搜索完成,共采集 {len(all_jobs)} 条职位")
print(f"{'='*60}")
self.jobs_data.extend(all_jobs)
return all_jobs
def _parse_job_list(self, html: str) -> List[Dict]:
"""解析职位列表页"""
soup = BeautifulSoup(html, 'lxml')
jobs = []
# LinkedIn职位卡片的选择器
job_cards = soup.find_all('div', class_='base-card')
for card in job_cards:
try:
job = self._parse_job_card(card)
if job and job.get('title'):
jobs.append(job)
except Exception as e:
print(f" ⚠ 解析职位卡片失败: {e}")
continue
return jobs
def _parse_job_card(self, card) -> Optional[Dict]:
"""解析单个职位卡片"""
job = {}
# 职位标题
title_elem = card.find('h3', class_='base-search-card__title')
if title_elem:
job['title'] = title_elem.get_text(strip=True)
# 公司名称
company_elem = card.find('h4', class_='base-search-card__subtitle')
if company_elem:
job['company'] = company_elem.get_text(strip=True)
# 工作地点
location_elem = card.find('span', class_='job-search-card__location')
if location_elem:
job['location'] = location_elem.get_text(strip=True)
# 职位链接
link_elem = card.find('a', class_='base-card__full-link')
if link_elem:
job['url'] = link_elem.get('href', '')
# 发布时间
time_elem = card.find('time')
if time_elem:
job['posted_date'] = time_elem.get('datetime', '')
# 薪资信息(如果在列表页显示)
salary_elem = card.find('span', class_='job-search-card__salary-info')
if salary_elem:
job['salary_raw'] = salary_elem.get_text(strip=True)
job['salary_info'] = self._parse_salary(job['salary_raw'])
else:
job['salary_raw'] = ''
job['salary_info'] = {}
# 添加采集时间
job['crawled_at'] = time.strftime('%Y-%m-%d %H:%M:%S')
return job
def _parse_salary(self, salary_text: str) -> Dict:
"""解析薪资信息"""
# 这部分在下一节详细实现
return self._standardize_salary(salary_text)
4.4 薪资信息解析与标准化
LinkedIn上的薪资信息格式多样,需要进行标准化处理:
class SalaryParser:
"""薪资解析器:处理各种薪资格式"""
# 货币符号映射
CURRENCY_MAP = {
'$': 'USD',
'£': 'GBP',
'€': 'EUR',
'¥': 'CNY',
'₹': 'INR',
}
# 时间单位映射(统一转换为年薪)
PERIOD_MULTIPLIER = {
'hour': 2080, # 假设每年工作2080小时
'hr': 2080,
'day': 260, # 假设每年工作260天
'week': 52,
'wk': 52,
'month': 12,
'mo': 12,
'year': 1,
'yr': 1,
'annual': 1,
}
@classmethod
def parse(cls, salary_text: str) -> Dict:
"""
解析薪资文本,返回标准化的薪资信息
示例输入:
- "$120,000 - $150,000/yr"
- "£50K - £70K per year"
- "$45/hr"
- "€4,500 - €6,000/month"
返回:
{
'raw': 原始文本,
'currency': 货币代码,
'min_salary': 最低薪资(年薪),
'max_salary': 最高薪资(年薪),
'avg_salary': 平均薪资(年薪),
'period': 原始周期单位,
'is_range': 是否为范围
}
"""
if not salary_text:
return {}
result = {
'raw': salary_text,
'currency': 'USD', # 默认美元
'min_salary': 0,
'max_salary': 0,
'avg_salary': 0,
'period': 'year',
'is_range': False
}
text = salary_text.lower().replace(',', '').replace(' ', '')
# 识别货币
for symbol, code in cls.CURRENCY_MAP.items():
if symbol in salary_text:
result['currency'] = code
text = text.replace(symbol.lower(), '')
break
# 识别时间周期
multiplier = 1
for period, mult in cls.PERIOD_MULTIPLIER.items():
if period in text:
result['period'] = period
multiplier = mult
break
# 提取数字
# 处理 K/k 表示千
text = re.sub(r'(\d+)k', lambda m: str(int(m.group(1)) * 1000), text)
# 提取所有数字
numbers = re.findall(r'\d+\.?\d*', text)
numbers = [float(n) for n in numbers if float(n) > 0]
if len(numbers) >= 2:
# 薪资范围
result['is_range'] = True
result['min_salary'] = numbers[0] * multiplier
result['max_salary'] = numbers[1] * multiplier
result['avg_salary'] = (result['min_salary'] + result['max_salary']) / 2
elif len(numbers) == 1:
# 单一薪资
result['min_salary'] = numbers[0] * multiplier
result['max_salary'] = numbers[0] * multiplier
result['avg_salary'] = numbers[0] * multiplier
return result
@classmethod
def format_salary(cls, amount: float, currency: str = 'USD') -> str:
"""格式化薪资显示"""
if amount >= 1000000:
return f"{currency} {amount/1000000:.1f}M"
elif amount >= 1000:
return f"{currency} {amount/1000:.0f}K"
else:
return f"{currency} {amount:.0f}"
将薪资解析集成到采集器中:
# 在 LinkedInSalaryCrawler 类中添加
def _standardize_salary(self, salary_text: str) -> Dict:
"""标准化薪资信息"""
return SalaryParser.parse(salary_text)
def enrich_job_details(self, job: Dict) -> Dict:
"""获取职位详情页,补充更多信息"""
if not job.get('url'):
return job
html = self._fetch_page(job['url'])
if not html:
return job
soup = BeautifulSoup(html, 'lxml')
# 补充详细描述
desc_elem = soup.find('div', class_='description__text')
if desc_elem:
job['description'] = desc_elem.get_text(strip=True)
# 提取技能要求
skills = []
skill_elems = soup.find_all('span', class_='skill-category-entity__name')
for elem in skill_elems:
skills.append(elem.get_text(strip=True))
job['skills'] = skills
# 提取经验要求
criteria_elems = soup.find_all('li', class_='description__job-criteria-item')
for elem in criteria_elems:
header = elem.find('h3')
if header and 'experience' in header.get_text(strip=True).lower():
value = elem.find('span')
if value:
job['experience_level'] = value.get_text(strip=True)
return job
可以看到,很快的就采集到了10条职位数据并且保存到了执行目录中去。
当然也可以进行完整采集。
五、薪资数据分析与可视化
5.1 数据清洗与预处理
采集到的原始数据需要进行清洗和预处理:
import pandas as pd
import numpy as np
from typing import List, Dict
class SalaryDataProcessor:
"""薪资数据处理器"""
def __init__(self, data: List[Dict]):
self.raw_data = data
self.df = pd.DataFrame(data)
def clean_data(self) -> pd.DataFrame:
"""数据清洗"""
df = self.df.copy()
# 1. 删除重复数据
df = df.drop_duplicates(subset=['title', 'company', 'location'])
print(f"去重后剩余 {len(df)} 条数据")
# 2. 处理缺失值
df['salary_raw'] = df['salary_raw'].fillna('')
df['location'] = df['location'].fillna('Unknown')
df['company'] = df['company'].fillna('Unknown')
# 3. 提取薪资字段
df['min_salary'] = df['salary_info'].apply(
lambda x: x.get('min_salary', 0) if isinstance(x, dict) else 0
)
df['max_salary'] = df['salary_info'].apply(
lambda x: x.get('max_salary', 0) if isinstance(x, dict) else 0
)
df['avg_salary'] = df['salary_info'].apply(
lambda x: x.get('avg_salary', 0) if isinstance(x, dict) else 0
)
df['currency'] = df['salary_info'].apply(
lambda x: x.get('currency', 'USD') if isinstance(x, dict) else 'USD'
)
# 4. 过滤有效薪资数据
df_with_salary = df[df['avg_salary'] > 0].copy()
print(f"有薪资信息的数据: {len(df_with_salary)} 条")
# 5. 统一货币(转换为美元)
exchange_rates = {
'USD': 1.0,
'EUR': 1.08,
'GBP': 1.27,
'CNY': 0.14,
'INR': 0.012,
}
df_with_salary['salary_usd'] = df_with_salary.apply(
lambda row: row['avg_salary'] * exchange_rates.get(row['currency'], 1.0),
axis=1
)
# 6. 提取地区信息
df_with_salary['country'] = df_with_salary['location'].apply(
lambda x: x.split(',')[-1].strip() if ',' in str(x) else x
)
df_with_salary['city'] = df_with_salary['location'].apply(
lambda x: x.split(',')[0].strip() if ',' in str(x) else x
)
# 7. 标准化职位类别
df_with_salary['job_category'] = df_with_salary['title'].apply(
self._categorize_job
)
self.df_clean = df_with_salary
return df_with_salary
def _categorize_job(self, title: str) -> str:
"""职位分类"""
title_lower = title.lower()
categories = {
'Software Engineer': ['software engineer', 'developer', 'programmer', 'sde'],
'Data Scientist': ['data scientist', 'data analyst', 'machine learning', 'ml engineer'],
'Product Manager': ['product manager', 'product owner', 'pm'],
'Designer': ['designer', 'ux', 'ui', 'creative'],
'Marketing': ['marketing', 'growth', 'seo', 'content'],
'Sales': ['sales', 'account executive', 'business development'],
'HR': ['hr', 'human resources', 'recruiter', 'talent'],
'Finance': ['finance', 'accountant', 'financial', 'cfo'],
'Operations': ['operations', 'ops', 'supply chain', 'logistics'],
}
for category, keywords in categories.items():
for keyword in keywords:
if keyword in title_lower:
return category
return 'Other'
def get_statistics(self) -> Dict:
"""获取基本统计信息"""
df = self.df_clean
stats = {
'total_jobs': len(df),
'avg_salary': df['salary_usd'].mean(),
'median_salary': df['salary_usd'].median(),
'min_salary': df['salary_usd'].min(),
'max_salary': df['salary_usd'].max(),
'std_salary': df['salary_usd'].std(),
'top_categories': df['job_category'].value_counts().head(5).to_dict(),
'top_locations': df['country'].value_counts().head(5).to_dict(),
}
return stats
可以对采集到的数据进行整理,清理,做成表格更方便的去查看。
5.2 多维度薪资分析
class SalaryAnalyzer:
"""薪资分析器"""
def __init__(self, df: pd.DataFrame):
self.df = df
def analyze_by_category(self) -> pd.DataFrame:
"""按职位类别分析"""
result = self.df.groupby('job_category').agg({
'salary_usd': ['count', 'mean', 'median', 'min', 'max', 'std']
}).round(2)
result.columns = ['count', 'mean', 'median', 'min', 'max', 'std']
result = result.sort_values('median', ascending=False)
print("\n📊 按职位类别薪资分析:")
print(result.to_string())
return result
def analyze_by_location(self) -> pd.DataFrame:
"""按地区分析"""
result = self.df.groupby('country').agg({
'salary_usd': ['count', 'mean', 'median']
}).round(2)
result.columns = ['count', 'mean', 'median']
result = result[result['count'] >= 5] # 至少5条数据
result = result.sort_values('median', ascending=False)
print("\n📊 按地区薪资分析:")
print(result.head(10).to_string())
return result
def analyze_by_experience(self) -> pd.DataFrame:
"""按经验级别分析"""
if 'experience_level' not in self.df.columns:
print("⚠ 数据中没有经验级别信息")
return pd.DataFrame()
result = self.df.groupby('experience_level').agg({
'salary_usd': ['count', 'mean', 'median']
}).round(2)
result.columns = ['count', 'mean', 'median']
result = result.sort_values('median', ascending=False)
print("\n📊 按经验级别薪资分析:")
print(result.to_string())
return result
def analyze_skills_salary(self) -> Dict:
"""技能与薪资关系分析"""
if 'skills' not in self.df.columns:
return {}
skill_salary = {}
for _, row in self.df.iterrows():
salary = row['salary_usd']
skills = row.get('skills', [])
if isinstance(skills, list):
for skill in skills:
if skill not in skill_salary:
skill_salary[skill] = []
skill_salary[skill].append(salary)
# 计算每个技能的平均薪资
result = {}
for skill, salaries in skill_salary.items():
if len(salaries) >= 3: # 至少3个样本
result[skill] = {
'count': len(salaries),
'avg_salary': np.mean(salaries),
'median_salary': np.median(salaries)
}
# 按平均薪资排序
result = dict(sorted(result.items(), key=lambda x: x[1]['avg_salary'], reverse=True))
print("\n📊 高薪技能TOP 10:")
for i, (skill, data) in enumerate(list(result.items())[:10]):
print(f" {i+1}. {skill}: ${data['avg_salary']:,.0f} (样本: {data['count']})")
return result
5.3 六大可视化图表
import matplotlib.pyplot as plt
import seaborn as sns
from wordcloud import WordCloud
import warnings
warnings.filterwarnings('ignore')
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
class SalaryVisualizer:
"""薪资可视化器"""
def __init__(self, df: pd.DataFrame, output_dir: str = "output"):
self.df = df
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
# 设置配色方案
self.colors = sns.color_palette("husl", 10)
def plot_all(self):
"""生成所有图表"""
print("\n📈 开始生成可视化图表...")
self.plot_salary_distribution()
self.plot_category_boxplot()
self.plot_location_barplot()
self.plot_experience_salary()
self.plot_skill_wordcloud()
self.plot_salary_trend()
print(f"\n✓ 所有图表已保存到 {self.output_dir}/ 目录")
def plot_salary_distribution(self):
"""图表1: 薪资分布直方图"""
fig, ax = plt.subplots(figsize=(12, 6))
# 绘制直方图
sns.histplot(
data=self.df,
x='salary_usd',
bins=30,
kde=True,
color=self.colors[0],
alpha=0.7,
ax=ax
)
# 添加均值和中位数线
mean_salary = self.df['salary_usd'].mean()
median_salary = self.df['salary_usd'].median()
ax.axvline(mean_salary, color='red', linestyle='--', linewidth=2, label=f'均值: ${mean_salary:,.0f}')
ax.axvline(median_salary, color='green', linestyle='--', linewidth=2, label=f'中位数: ${median_salary:,.0f}')
ax.set_xlabel('年薪 (USD)', fontsize=12)
ax.set_ylabel('频次', fontsize=12)
ax.set_title('LinkedIn职位薪资分布', fontsize=14, fontweight='bold')
ax.legend()
# 格式化x轴
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x/1000:.0f}K'))
plt.tight_layout()
plt.savefig(f'{self.output_dir}/01_salary_distribution.png', dpi=300, bbox_inches='tight')
plt.close()
print(" ✓ 薪资分布直方图")
def plot_category_boxplot(self):
"""图表2: 职位类别薪资箱线图"""
fig, ax = plt.subplots(figsize=(14, 8))
# 按中位数排序
order = self.df.groupby('job_category')['salary_usd'].median().sort_values(ascending=False).index
sns.boxplot(
data=self.df,
x='job_category',
y='salary_usd',
order=order,
palette='husl',
ax=ax
)
ax.set_xlabel('职位类别', fontsize=12)
ax.set_ylabel('年薪 (USD)', fontsize=12)
ax.set_title('不同职位类别的薪资分布', fontsize=14, fontweight='bold')
# 格式化y轴
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x/1000:.0f}K'))
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.savefig(f'{self.output_dir}/02_category_boxplot.png', dpi=300, bbox_inches='tight')
plt.close()
print(" ✓ 职位类别薪资箱线图")
def plot_location_barplot(self):
"""图表3: 地区薪资柱状图"""
# 按地区计算平均薪资
location_salary = self.df.groupby('country')['salary_usd'].agg(['mean', 'count']).reset_index()
location_salary = location_salary[location_salary['count'] >= 3] # 至少3条数据
location_salary = location_salary.sort_values('mean', ascending=True).tail(15)
fig, ax = plt.subplots(figsize=(12, 8))
bars = ax.barh(
location_salary['country'],
location_salary['mean'],
color=sns.color_palette("viridis", len(location_salary))
)
# 在柱状图上添加数值标签
for bar, count in zip(bars, location_salary['count']):
width = bar.get_width()
ax.text(width + 2000, bar.get_y() + bar.get_height()/2,
f'${width/1000:.0f}K (n={count})',
va='center', fontsize=9)
ax.set_xlabel('平均年薪 (USD)', fontsize=12)
ax.set_ylabel('国家/地区', fontsize=12)
ax.set_title('各地区平均薪资对比 (TOP 15)', fontsize=14, fontweight='bold')
# 格式化x轴
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x/1000:.0f}K'))
plt.tight_layout()
plt.savefig(f'{self.output_dir}/03_location_barplot.png', dpi=300, bbox_inches='tight')
plt.close()
print(" ✓ 地区薪资柱状图")
def plot_experience_salary(self):
"""图表4: 经验与薪资关系散点图"""
if 'experience_level' not in self.df.columns or self.df['experience_level'].isna().all():
print(" ⚠ 跳过经验薪资图(无数据)")
return
fig, ax = plt.subplots(figsize=(12, 6))
# 经验级别排序
exp_order = ['Entry level', 'Associate', 'Mid-Senior level', 'Director', 'Executive']
df_plot = self.df[self.df['experience_level'].isin(exp_order)].copy()
if len(df_plot) == 0:
print(" ⚠ 跳过经验薪资图(无有效数据)")
return
sns.boxplot(
data=df_plot,
x='experience_level',
y='salary_usd',
order=exp_order,
palette='coolwarm',
ax=ax
)
ax.set_xlabel('经验级别', fontsize=12)
ax.set_ylabel('年薪 (USD)', fontsize=12)
ax.set_title('经验级别与薪资关系', fontsize=14, fontweight='bold')
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x/1000:.0f}K'))
plt.tight_layout()
plt.savefig(f'{self.output_dir}/04_experience_salary.png', dpi=300, bbox_inches='tight')
plt.close()
print(" ✓ 经验与薪资关系图")
def plot_skill_wordcloud(self):
"""图表5: 技能标签词云图"""
if 'skills' not in self.df.columns:
print(" ⚠ 跳过技能词云(无数据)")
return
# 收集所有技能
all_skills = []
for skills in self.df['skills'].dropna():
if isinstance(skills, list):
all_skills.extend(skills)
if not all_skills:
print(" ⚠ 跳过技能词云(无有效数据)")
return
# 统计技能频率
from collections import Counter
skill_freq = Counter(all_skills)
# 生成词云
wordcloud = WordCloud(
width=1200,
height=600,
background_color='white',
colormap='viridis',
max_words=100,
min_font_size=10,
max_font_size=100
).generate_from_frequencies(skill_freq)
fig, ax = plt.subplots(figsize=(15, 8))
ax.imshow(wordcloud, interpolation='bilinear')
ax.axis('off')
ax.set_title('热门技能词云', fontsize=16, fontweight='bold', pad=20)
plt.tight_layout()
plt.savefig(f'{self.output_dir}/05_skill_wordcloud.png', dpi=300, bbox_inches='tight')
plt.close()
print(" ✓ 技能标签词云图")
def plot_salary_trend(self):
"""图表6: 薪资趋势折线图(按职位类别)"""
fig, ax = plt.subplots(figsize=(12, 6))
# 按职位类别绘制
categories = self.df['job_category'].value_counts().head(5).index
for i, category in enumerate(categories):
df_cat = self.df[self.df['job_category'] == category]
# 假设我们有时间序列数据,这里用累积分布模拟
salaries_sorted = df_cat['salary_usd'].sort_values().values
percentiles = np.linspace(0, 100, len(salaries_sorted))
ax.plot(percentiles, salaries_sorted,
label=category, color=self.colors[i], linewidth=2)
ax.set_xlabel('百分位', fontsize=12)
ax.set_ylabel('年薪 (USD)', fontsize=12)
ax.set_title('各职位类别薪资百分位分布', fontsize=14, fontweight='bold')
ax.legend(title='职位类别', loc='upper left')
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x/1000:.0f}K'))
ax.set_xlim(0, 100)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(f'{self.output_dir}/06_salary_percentile.png', dpi=300, bbox_inches='tight')
plt.close()
print(" ✓ 薪资百分位分布图")
经过处理之后,所有图表已保存到 output/ 目录。
六、总结
完整使用示例
将以上所有模块整合,创建主程序入口:
# main.py - 主程序入口
from crawler import LinkedInSalaryCrawler
from analyzer import SalaryDataProcessor, SalaryAnalyzer
from visualizer import SalaryVisualizer
import json
def main():
"""主程序"""
print("="*60)
print("LinkedIn薪资数据采集与分析系统")
print("="*60)
# ========== 第一步:数据采集 ==========
print("\n📥 第一步:数据采集")
crawler = LinkedInSalaryCrawler()
# 定义搜索关键词
keywords = [
"Software Engineer",
"Data Scientist",
"Product Manager",
]
# 采集数据
for keyword in keywords:
crawler.search_jobs(
keyword=keyword,
location="United States",
pages=3
)
# 保存原始数据
with open('data/raw/linkedin_jobs.json', 'w', encoding='utf-8') as f:
json.dump(crawler.jobs_data, f, ensure_ascii=False, indent=2)
print(f"\n✓ 原始数据已保存,共 {len(crawler.jobs_data)} 条")
# ========== 第二步:数据处理 ==========
print("\n📊 第二步:数据处理")
processor = SalaryDataProcessor(crawler.jobs_data)
df_clean = processor.clean_data()
# 保存处理后的数据
df_clean.to_csv('data/processed/linkedin_salary.csv', index=False, encoding='utf-8-sig')
# 打印基本统计
stats = processor.get_statistics()
print(f"\n基本统计信息:")
print(f" 总职位数: {stats['total_jobs']}")
print(f" 平均薪资: ${stats['avg_salary']:,.0f}")
print(f" 中位薪资: ${stats['median_salary']:,.0f}")
print(f" 薪资范围: ${stats['min_salary']:,.0f} - ${stats['max_salary']:,.0f}")
# ========== 第三步:数据分析 ==========
print("\n📈 第三步:数据分析")
analyzer = SalaryAnalyzer(df_clean)
analyzer.analyze_by_category()
analyzer.analyze_by_location()
analyzer.analyze_skills_salary()
# ========== 第四步:可视化 ==========
print("\n🎨 第四步:生成可视化图表")
visualizer = SalaryVisualizer(df_clean, output_dir='output')
visualizer.plot_all()
print("\n" + "="*60)
print("✓ 全部任务完成!")
print("="*60)
print(f"\n输出文件:")
print(f" - 原始数据: data/raw/linkedin_jobs.json")
print(f" - 处理数据: data/processed/linkedin_salary.csv")
print(f" - 可视化图表: output/")
if __name__ == "__main__":
main()
项目回顾
回顾整个项目,我们完成了从数据采集到可视化分析的完整链路:
| 阶段 | 技术要点 | 收获 |
|---|---|---|
| 环境搭建 | Python虚拟环境、依赖管理 | 规范的项目结构 |
| 代理配置 | IPIDEA动态住宅代理、IP分配 | 解决了海外平台访问问题 |
| 数据采集 | requests + BeautifulSoup、Cookie管理 | 掌握了结构化数据提取 |
| 数据处理 | pandas清洗、薪资标准化 | 多源数据的统一处理 |
| 可视化 | matplotlib + seaborn、词云图 | 数据洞察的直观呈现 |
在这个项目中,代理服务解决的核心问题是网络访问层面的技术挑战——让采集程序能够稳定地获取到LinkedIn的公开职位数据。这是整个数据分析流程的基础,没有稳定的数据源,后续的分析和可视化都无从谈起。
在信息时代,数据即洞察,洞察即价值——掌握从数据获取到价值呈现的能力,是必备技能。