LinkedIn招聘薪资数据采集:IPIDEA代理助力职场薪资透明化

22 阅读21分钟

一、职场人的薪资信息困境

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%✅ 优秀
平均响应时间1200ms1500-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官网注册账号。注册完成后,进入控制台进行代理配置:

image-20260112113404432

第一步:选择代理类型

在控制台左侧菜单找到"动态代理",点击展开后选择"动态住宅代理"。

第二步:配置代理参数

进入"API提取"标签页,配置以下参数:

  • 选择套餐:账户额度
  • 选择国家/地区:全球混播(或根据需求选择特定地区)
  • 提取数量:根据采集规模选择(如100)
  • 数据格式:TXT
  • 代理协议:HTTP/HTTPS
  • 分隔符:回车换行(\r\n)

第三步:生成代理链接

配置完成后,点击"生成链接"按钮,系统会在右侧"API链接"区域生成一个API链接。这个链接每次调用都会返回新的代理IP。

image-20260112114916403

第四步:添加IP白名单

在顶部菜单点击"IP白名单管理",将你的本机IP或服务器IP添加到白名单中,确保API调用正常。

image-20260112151902601

💡 提示:如果不确定自己的公网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&regions=&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个代理IP
  • return_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)

运行测试代码,如果看到类似以下输出,说明代理配置成功:

image-20260112151629500
正在获取代理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的方法

  1. 在浏览器中登录LinkedIn
  2. 打开开发者工具(F12)-> Network
  3. 刷新页面,找到任意请求
  4. 在请求头中复制Cookie值
  5. 解析并保存到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条职位数据并且保存到了执行目录中去。

image-20260112153516070

当然也可以进行完整采集。

image-20260112153628464

五、薪资数据分析与可视化

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

可以对采集到的数据进行整理,清理,做成表格更方便的去查看。

image-20260112153758590

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/ 目录。

image-20260112162449991 image-20260112162502908 image-20260112162559360 image-20260112162517960

六、总结

完整使用示例

将以上所有模块整合,创建主程序入口:

# 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的公开职位数据。这是整个数据分析流程的基础,没有稳定的数据源,后续的分析和可视化都无从谈起。

在信息时代,数据即洞察,洞察即价值——掌握从数据获取到价值呈现的能力,是必备技能。