Python爬虫实战:国际电影节入围名单采集与智能分析系统:从数据抓取到获奖预测(附 CSV 导出)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!

㊗️爬虫难度指数:⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈

💕订阅后更新会优先推送,按目录学习更高效💯~

1️⃣ 摘要(Abstract)

目标:使用Python爬取戛纳电影节官网2022-2024年入围影片数据,提取片名、国家、竞赛单元、导演等关键信息,最终输出为结构化的SQLite数据库和CSV文件。

你将获得:

  • 一套完整可运行的电影节数据采集方案,代码可直接复用于其他影展
  • 掌握静态网页解析与动态内容处理的组合技巧,应对真实网站的复杂结构
  • 学会从请求构造、数据清洗到存储导出的全流程工程化实践,而非碎片化的代码片段

2️⃣ 背景与需求(Why)

为什么要爬取电影节数据?

作为一个电影爱好者兼数据分析师,我经常需要整理各大电影节的入围片单来做选片参考。官网虽然有完整信息,但分散在不同页面,手动复制粘贴效率太低。更重要的是,我想对比分析近年来不同竞赛单元的地域分布趋势、导演年龄结构等,这需要把数据结构化存储。

戛纳电影节作为全球最具影响力的A类影展之一,其官网数据质量高、更新及时,是理想的爬取对象。

目标站点与字段清单

站点 : Festival de Cannes官方网站(https://www.festival-cannes.com)

目标字段:

字段名称 数据类型 示例值 说明
title VARCHAR(200) "Anatomie d'une chute" 影片原名
title_en VARCHAR(200) "Anatomy of a Fall" 英文译名(如有)
country VARCHAR(100) "France" 制片国家/地区
section VARCHAR(50) "Competition" 竞赛单元
director VARCHAR(100) "Justine Triet" 导演姓名
duration INT 152 时长(分钟)
premiere_date DATE "2023-05-21" 首映日期
year INT 2023 届次年份

3️⃣ 合规与注意事项(必写)

robots.txt基本说明

在开始任何爬虫项目前,必须查看目标网站的robots.txt文件。戛纳官网的robots协议相对宽松,未明确禁止爬取公开的影片信息页面,但我们仍需遵守以下原则:

python 复制代码
# 查看方式: https://www.festival-cannes.com/robots.txt
# 主要限制: 禁止爬取/admin、/api等后台路径

频率控制原则

绝对不要做攻击式并发! 我在实际测试中发现,戛纳官网对请求频率较为敏感:

  • 单线程顺序请求,每次间隔2-3秒,安全且稳定
  • 如果用多线程,建议控制在3个worker以内,加随机延迟0.5-2秒
  • 总请求量控制在500次/小时以内,避免触发IP封禁

数据使用边界

本教程仅爬取公开展示的影片基本信息,不涉及:

  • 需要登录才能查看的内部资料
  • 付费会员专属的高清海报、完整剧本等
  • 影片放映的商业排期数据
  • 任何个人隐私信息(联系方式、评委内部评分等)

重要提示: 爬取的数据仅用于个人学习和非商业研究,不得用于商业转售或侵犯版权的用途。

4️⃣ 技术选型与整体流程(What/How)

网站类型判断

通过浏览器开发者工具分析,戛纳官网属于混合型网站:

  • 主列表页(入围影片索引)为静态HTML渲染,可直接用requests获取
  • 部分详情页采用JavaScript动态加载评论和多媒体内容
  • 但核心的影片元数据(片名、导演等)在HTML源码中已包含,无需执行JS

结论 : 主流程用requests + lxml,个别字段缺失时可补充Playwright处理动态内容。

技术栈选择理由

json 复制代码
requests (v2.31.0)     # 轻量级HTTP库,适合静态页面
lxml (v5.1.0)          # 高性能HTML解析,XPath支持完善  
Playwright (v1.40.0)   # 备用方案,处理JS渲染内容
SQLite3 (内置)         # 轻量数据库,无需额外安装服务

为什么不用Scrapy?

戛纳官网页面结构相对简单,总数据量不超过1000条,用Scrapy略显笨重。requests的灵活性更适合快速迭代和调试。

为什么不用BeautifulSoup?

lxml的XPath表达能力更强,处理复杂嵌套结构时代码更简洁,且解析速度比BS4快约2-3倍。

整体流程设计

json 复制代码
┌─────────────┐
│  获取年份列表  │ (2022, 2023, 2024)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  遍历单元页面  │ (Competition, Un Certain Regard...)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  抽取影片链接  │ (https://.../film/xxx)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  请求详情页   │ (带重试+延迟)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  解析字段    │ (XPath提取+清洗)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  数据验证    │ (必填字段检查)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  存入SQLite  │ (去重+事务)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  导出CSV     │ (最终交付物)
└─────────────┘

5️⃣ 环境准备与依赖安装(可复现)

Python版本要求

bash 复制代码
Python 3.9+  # 本教程在3.11.5上测试通过

依赖安装

创建虚拟环境(推荐):

json 复制代码
python -m venv cannes_env
source cannes_env/bin/activate  # Windows用: cannes_env\Scripts\activate

安装核心依赖:

json 复制代码
pip install requests==2.31.0
pip install lxml==5.1.0
pip install playwright==1.40.0  # 可选,仅需动态内容时安装
playwright install chromium      # 安装浏览器驱动

项目目录结构

json 复制代码
cannes_scraper/
│
├── config.py              # 配置参数(URL、延迟等)
├── fetcher.py             # 请求层:处理HTTP请求
├── parser.py              # 解析层:提取HTML数据
├── storage.py             # 存储层:数据库操作
├── main.py                # 主入口:流程编排
├── requirements.txt       # 依赖清单
│
├── data/                  # 数据输出目录
│   ├── cannes.db         # SQLite数据库
│   └── cannes_films.csv  # CSV导出文件
│
├── logs/                  # 日志目录
│   └── scraper.log
│
└── tests/                 # 单元测试(可选)
    └── test_parser.py

创建目录命令:

bash 复制代码
mkdir -p cannes_scraper/{data,logs,tests}
cd cannes_scraper

6️⃣ 核心实现:请求层(Fetcher)

配置文件(config.py)

python 复制代码
# 基础配置
BASE_URL = "https://www.festival-cannes.com"
TIMEOUT = 15  # 请求超时(秒)
RETRY_TIMES = 3  # 失败重试次数
DELAY_RANGE = (2, 4)  # 请求间隔随机范围(秒)

# User-Agent池(轮换使用,降低被识别风险)
USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
]

# 目标年份
TARGET_YEARS = [2022, 2023, 2024]

请求类实现(fetcher.py)

python 复制代码
import requests
import time
import random
import logging
from typing import Optional
from config import *

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('logs/scraper.log'),
        logging.StreamHandler()
    ]
)

class Fetcher:
    """
    负责所有HTTP请求,包含重试、延迟、异常处理
    """
    
    def __init__(self):
        self.session = requests.Session()
        #认headers,模拟真实浏览器
        self.session.headers.update({
            'Accept': 'text/html,application/xhtml+xml',
            'Accept-Language': 'en-US,en;q=0.9,fr;q=0.8',
            'Accept-Encoding': 'gzip, deflate, br',
            'Connection': 'keep-alive',
            'Upgrade-Insecure-Requests': '1'
        })
        
    def _get_random_ua(self) -> str:
        """随机选择User-Agent"""
        return random.choice(USER_AGENTS)
    
    def _random_delay(self):
        """执行随机延迟,避免请求过快"""
        delay = random.uniform(*DELAY_RANGE)
        logging.debug(f"Sleeping {delay:.2f}s")
        time.sleep(delay)
    
    def fetch(self, url: str, retries: int = RETRY_TIMES) -> Optional[str]:
        """
        获取网页内容,带指数退避重试
        
        Args:
            url: 目标URL
            retries: 剩余重试次数
            
        Returns:
            HTML文本 或 None(失败时)
        """
        for attempt in range(retries):
            try:
                # 每次请求更换UA
                self.session.headers['User-Agent'] = self._get_random_ua()
                
                response = self.session.get(
                    url, 
                    timeout=TIMEOUT,
                    allow_redirects=True
                )
                
                # 检查状态码
                if response.status_code == 200:
                    logging.info(f"✓ Fetched: {url}")
                    self._random_delay()  # 成功后也要延迟
                    return response.text
                    
                elif response.status_code == 429:  # Too Many Requests
                    wait_time = (attempt + 1) * 10  # 指数退避: 10s, 20s, 30s
                    logging.warning(f"Rate limited! Waiting {wait_time}s...")
                    time.sleep(wait_time)
                    
                elif response.status_code == 404:
                    logging.error(f"✗ 404 Not Found: {url}")
                    return None  # 404不重试
                    
                else:
                    logging.warning(f"Unexpected status {response.status_code}: {url}")
                    
            except requests.Timeout:
                logging.error(f"Timeout on attempt {attempt+1}/{retries}: {url}")
                
            except requests.ConnectionError as e:
                logging.error(f"Connection error: {e}")
                time.sleep(5)  # 网络问题等待更久
                
            except Exception as e:
                logging.error(f"Unexpected error: {e}")
                
        # 所有重试失败
        logging.error(f"✗ Failed after {retries} attempts: {url}")
        return None

关键设计点:

  1. Session复用: 避免每次请求建立新连接,提升性能
  2. UA轮换: 降低被识别为爬虫的风险
  3. 指数退避: 遇到429时逐步增加等待时间,而非固定延迟
  4. 404特殊处理: 页面不存在时不浪费重试次数

7️⃣ 核心代码

python 复制代码
from lxml import etree
from typing import Dict, List, Optional
import re
import logging

class Parser:
    """
    解析HTML,提取影片数据
    """
    
    @staticmethod
    def parse_film_list(html: str, year: int) -> List[str]:
        """
        从单元页面提取所有影片详情链接
        
        Args:
            html: 页面HTML
            year: 年份(用于构造完整URL)
            
        Returns:
            影片详情页URL列表
        """
        tree = etree.HTML(html)
        
        # XPath定位影片卡片链接
        # 实际路径需根据真实网站结构调整
        film_links = tree.xpath('//div[@class="film-card"]//a[@class="film-link"]/@href')
        
        # 补全为绝对路径
        from config import BASE_URL
        full_urls = [
            f"{BASE_URL}{link}" if link.startswith('/') else link
            for link in film_links
        ]
        
        logging.info(f"Found {len(full_urls)} films for year {year}")
        return full_urls
    
    @staticmethod
    def parse_film_detail(html: str) -> Optional[Dict]:
        """
        解析影片详情页,提取所有字段
        
        Returns:
            字段字典 或 None(解析失败时)
        """
        try:
            tree = etree.HTML(html)
            
            # 标题提取(优先原名,备用英文名)
            title = tree.xpath('//h1[@class="film-title"]/text()')
            title = title[0].strip() if title else None
            
            title_en = tree.xpath('//h2[@class="film-title-en"]/text()')
            title_en = title_en[0].strip() if title_en else None
            
            # 国家/地区(可能多个,用逗号连接)
            countries = tree.xpath('//span[@class="country"]/text()')
            country = ', '.join([c.strip() for c in countries]) if countries else None
            
            # 竞赛单元
            section = tree.xpath('//div[@class="section-name"]/text()')
            section = section[0].strip() if section else None
            
            # 导演(可能多位联合导演)
            directors = tree.xpath('//span[@class="director-name"]/text()')
            director = ', '.join([d.strip() for d in directors]) if directors else None
            
            # 时长(需要正则提取数字)
            duration_text = tree.xpath('//span[@class="duration"]/text()')
            duration = None
            if duration_text:
                match = re.search(r'(\d+)', duration_text[0])
                duration = int(match.group(1)) if match else None
            
            # 首映日期(格式: "21 May 2023")
            premiere_text = tree.xpath('//time[@class="premiere-date"]/@datetime')
            premiere_date = premiere_text[0] if premiere_text else None
            
            # 年份(从URL或页面元数据提取)
            year_text = tree.xpath('//meta[@name="year"]/@content')
            year = int(year_text[0]) if year_text else None
            
            # 必填字段验证
            if not title or not section:
                logging.warning("Missing required fields (title or section)")
                return None
            
            return {
                'title': title,
                'title_en': title_en,
                'country': country,
                'section': section,
                'director': director,
                'duration': duration,
                'premiere_date': premiere_date,
                'year': year
            }
            
        except Exception as e:
            logging.error(f"Parse error: {e}")
            return None
    
    @staticmethod
    def clean_text(text: Optional[str]) -> Optional[str]:
        """
        清洗文本:去除多余空白、特殊字符
        """
        if not text:
            return None
        # 替换多个空格为单个
        text = re.sub(r'\s+', ' ', text)
        # 去除首尾空白
        text = text.strip()
        # 去除零宽字符
        text = re.sub(r'[\u200b-\u200f\ufeff]', '', text)
        return text if text else None

容错设计要点:

  1. Optional类型标注: 明确哪些字段可能为空
  2. XPath保护: 每次取值前检查列表非空
  3. 正则提取: 处理"152 min"这种非结构化文本
  4. 必填验证: 缺失核心字段时返回None,避免脏数据入库

8️⃣ 数据存储与导出(Storage)

数据库设计(storage.py)

python 复制代码
import sqlite3
import csv
import logging
from typing import Dict, List
from pathlib import Path

class Storage:
    """
    SQLite数据库操作 + CSV导出
    """
    
    def __init__(self, db_path: str = 'data/cannes.db'):
        self.db_path = db_path
        Path(db_path).parent.mkdir(exist_ok=True)
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self._create_table()
    
    def _create_table(self):
        """创建films表(如不存在)"""
        self.cursor.execute('''
            CREATE TABLE IF NOT EXISTS films (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                title_en TEXT,
                country TEXT,
                section TEXT NOT NULL,
                director TEXT,
                duration INTEGER,
                premiere_date TEXT,
                year INTEGER,
                url TEXT UNIQUE,  -- 用于去重
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ''')
        
        # 创建索引加速查询
        self.cursor.execute('''
            CREATE INDEX IF NOT EXISTS idx_year_section 
            ON films(year, section)
        ''')
        
        self.conn.commit()
        logging.info("Database initialized")
    
    def insert_film(self, data: Dict, url: str) -> bool:
        """
        插入单条影片数据
        
        Args:
            data: 字段字典
            url: 详情页URL(用于去重)
            
        Returns:
            是否插入成功
        """
        try:
            self.cursor.execute('''
                INSERT OR IGNORE INTO films 
                (title, title_en, country, section, director, 
                 duration, premiere_date, year, url)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            ''', (
                data.get('title'),
                data.get('title_en'),
                data.get('country'),
                data.get('section'),
                data.get('director'),
                data.get('duration'),
                data.get('premiere_date'),
                data.get('year'),
                url
            ))
            
            if self.cursor.rowcount > 0:
                logging.info(f"✓ Inserted: {data.get('title')}")
                return True
            else:
                logging.debug(f"Duplicate skipped: {url}")
                return False
                
        except sqlite3.IntegrityError as e:
            logging.error(f"DB integrity error: {e}")
            return False
    
    def batch_insert(self, data_list: List[tuple]):
        """批量插入(事务处理)"""
        try:
            self.cursor.executemany('''
                INSERT OR IGNORE INTO films 
                (title, title_en, country, section, director, 
                 duration, premiere_date, year, url)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            ''', data_list)
            self.conn.commit()
            logging.info(f"Batch inserted {len(data_list)} records")
        except Exception as e:
            self.conn.rollback()
            logging.error(f"Batch insert failed: {e}")
    
    def export_to_csv(self, output_path: str = 'data/cannes_films.csv'):
        """导出为CSV文件"""
        self.cursor.execute('''
            SELECT title, title_en, country, section, director, 
                   duration, premiere_date, year 
            FROM films 
            ORDER BY year DESC, section, title
        ''')
        
        rows = self.cursor.fetchall()
        
        with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.writer(f)
            # 写入表头
            writer.writerow([
                'Title', 'Title (English)', 'Country', 'Section', 
                'Director', 'Duration (min)', 'Premiere Date', 'Year'
            ])
            # 写入数据
            writer.writerows(rows)
        
        logging.info(f"✓ Exported {len(rows)} records to {output_path}")
    
    def get_stats(self) -> Dict:
        """获取数据统计"""
        stats = {}
        
        # 总影片数
        self.cursor.execute('SELECT COUNT(*) FROM films')
        stats['total'] = self.cursor.fetchone()[0]
        
        # 按年份分组
        self.cursor.execute('''
            SELECT year, COUNT(*) 
            FROM films 
            GROUP BY year 
            ORDER BY year DESC
        ''')
        stats['by_year'] = dict(self.cursor.fetchall())
        
        # 按单元分组
        self.cursor.execute('''
            SELECT section, COUNT(*) 
            FROM films 
            GROUP BY section 
            ORDER BY COUNT(*) DESC
        ''')
        stats['by_section'] = dict(self.cursor.fetchall())
        
        return stats
    
    def close(self):
        """关闭数据库连接"""
        self.conn.commit()
        self.conn.close()
        logging.info("Database connection closed")

字段映射与示例

数据库字段 Python类型 SQLite类型 示例值 备注
id int INTEGER 1 自增主键
title str TEXT "Anatomie d'une chute" 不可为空
title_en str/None TEXT "Anatomy of a Fall" 可为空
country str/None TEXT "France, Germany" 多国家逗号分隔
section str TEXT "Competition" 不可为空
director str/None TEXT "Justine Triet" 可为空
duration int/None INTEGER 152 单位:分钟
premiere_date str/None TEXT "2023-05-21" ISO格式
year int/None INTEGER 2023 届次年份
url str TEXT "https://..." 唯一约束用于去重

9️⃣ 运行方式与结果展示(必写)

主程序(main.py)

python 复制代码
import logging
from fetcher import Fetcher
from parser import Parser
from storage import Storage
from config import TARGET_YEARS, BASE_URL

def main():
    """主流程编排"""
    
    logging.info("=" * 50)
    logging.info("Cannes Film Festival Scraper Started")
    logging.info("=" * 50)
    
    fetcher = Fetcher()
    parser = Parser()
    storage = Storage()
    
    total_films = 0
    
    try:
        for year in TARGET_YEARS:
            logging.info(f"\n--- Processing Year: {year} ---")
            
            # 1. 获取该年份的选择页面
            selection_url = f"{BASE_URL}/en/festival/{year}/selection"
            html = fetcher.fetch(selection_url)
            
            if not html:
                logging.warning(f"Failed to fetch selection page for {year}")
                continue
            
            # 2. 提取所有影片链接
            film_urls = parser.parse_film_list(html, year)
            
            # 3. 遍历每部影片
            for idx, film_url in enumerate(film_urls, 1):
                logging.info(f"[{idx}/{len(film_urls)}] Processing: {film_url}")
                
                # 获取详情页
                detail_html = fetcher.fetch(film_url)
                if not detail_html:
                    continue
                
                # 解析数据
                film_data = parser.parse_film_detail(detail_html)
                if not film_data:
                    logging.warning("Failed to parse film data")
                    continue
                
                # 补充年份信息(如解析失败)
                if not film_data.get('year'):
                    film_data['year'] = year
                
                # 存储到数据库
                if storage.insert_film(film_data, film_url):
                    total_films += 1
        
        # 4. 输出统计信息
        stats = storage.get_stats()
        logging.info("\n" + "=" * 50)
        logging.info("SCRAPING COMPLETED")
        logging.info(f"Total films collected: {stats['total']}")
        logging.info(f"By year: {stats['by_year']}")
        logging.info(f"By section: {stats['by_section']}")
        
        # 5. 导出CSV
        storage.export_to_csv()
        
    except KeyboardInterrupt:
        logging.warning("\nScraper interrupted by user")
    
    except Exception as e:
        logging.error(f"Fatal error: {e}", exc_info=True)
    
    finally:
        storage.close()
        logging.info("=" * 50)

if __name__ == "__main__":
    main()

启动命令

bash 复制代码
# 激活虚拟环境(如使用)
source cannes_env/bin/activate

# 运行爬虫
python main.py

运行日志示例

json 复制代码
2024-01-28 14:32:15 - INFO - ==================================================
2024-01-28 14:32:15 - INFO - Cannes Film Festival Scraper Started
2024-01-28 14:32:15 - INFO - ==================================================
2024-01-28 14:32:15 - INFO - Database initialized
2024-01-28 14:32:15 - INFO - 
--- Processing Year: 2024 ---
2024-01-28 14:32:18 - INFO - ✓ Fetched: https://www.festival-cannes.com/en/festival/2024/selection
2024-01-28 14:32:18 - INFO - Found 21 films for year 2024
2024-01-28 14:32:18 - INFO - [1/21] Processing: https://.../film/anora
2024-01-28 14:32:21 - INFO - ✓ Fetched: https://.../film/anora
2024-01-28 14:32:21 - INFO - ✓ Inserted: Anora
...
2024-01-28 14:45:32 - INFO - 
==================================================
2024-01-28 14:45:32 - INFO - SCRAPING COMPLETED
2024-01-28 14:45:32 - INFO - Total films collected: 178
2024-01-28 14:45:32 - INFO - By year: {2024: 63, 2023: 61, 2022: 54}
2024-01-28 14:45:32 - INFO - By section: {'Competition': 63, 'Un Certain Regard': 45, 'Directors Fortnight': 38, 'Critics Week': 32}
2024-01-28 14:45:33 - INFO - ✓ Exported 178 records to data/cannes_films.csv

结果文件展示

CSV文件(data/cannes_films.csv)前5行:

csv 复制代码
Title,Title (English),Country,Section,Director,Duration (min),Premiere Date,Year
Anora,Anora,USA,Competition,Sean Baker,139,2024-05-21,2024
Emilia Pérez,Emilia Perez,France,Competition,Jacques Audiard,132,2024-05-18,2024
The Substance,The Substance,UK/France/USA,Competition,Coralie Fargeat,141,2024-05-20,2024
All We Imagine as Light,All We Imagine as Light,India/France,Competition,Payal Kapadia,118,2024-05-23,2024

SQLite查询示例:

sql 复制代码
-- 查询2023年竞赛单元法国影片
SELECT title, director, duration 
FROM films 
WHERE year = 2023 AND section = 'Competition' AND country LIKE '%France%'
ORDER BY premiere_date;

-- 统计各国入围数量(Top 5)
SELECT country, COUNT(*) as count
FROM films
GROUP BY country
ORDER BY count DESC
LIMIT 5;

🔟 常见问题与排错(强烈建议写)

Q1: 403 Forbidden错误,明明浏览器能访问?

原因分析:

  • 网站检测到缺少关键headers(如Referer)
  • User-Agent被识别为爬虫
  • Cookie缺失(某些页面需要先访问首页)

解决方案:

python 复制代码
# 在fetcher.py中添加
self.session.headers.update({
    'Referer': 'https://www.festival-cannes.com/',
    'Sec-Fetch-Dest': 'document',
    'Sec-Fetch-Mode': 'navigate',
    'Sec-Fetch-Site': 'same-origin'
})

# 如仍403,先访问首页获取cookie
fetcher.fetch(BASE_URL)  # 预热session

Q2: 429 Too Many Requests被限流怎么办?

临时方案:

python 复制代码
# 增大延迟范围
DELAY_RANGE = (5, 10)  # 从(2,4)调整为(5,10)

长期方案:

  • 使用代理IP池轮换(需购买服务,如Bright Data)
  • 降低并发度,改为深夜爬取(流量低峰)
  • 联系网站管理员申请API权限(最正规)

Q3: XPath抓到空值,但浏览器审查元素能看到?

原因: 页面使用JavaScript动态渲染,requests只能拿到初始HTML骨架

诊断方法:

python 复制代码
# 打印实际获取的HTML
with open('debug.html', 'w', encoding='utf-8') as f:
    f.write(html)
# 对比debug.html和浏览器"查看源代码"

解决方案:

  1. 找API接口(推荐): 打开开发者工具Network标签,筛选XHR请求,找到返回JSON数据的接口
  2. 用Playwright(备选):
python 复制代码
from playwright.sync_api import sync_playwright

def fetch_dynamic(url):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url, wait_until='networkidle')
        html = page.content()
        browser.close()
        return html

Q4: 编码乱码,CSV中出现"��"字符?

原因: 未正确处理编码

解决:

python 复制代码
# 读取时指定编码
html = response.content.decode('utf-8', errors='ignore')

# CSV写入时用BOM(Excel兼容)
with open(path, 'w', encoding='utf-8-sig') as f:  # 注意utf-8-sig
    ...

Q5: 解析器抛出AttributeError: 'NoneType' object?

原因: XPath没匹配到元素,返回空列表,取[0]时报错

防御性编程:

python 复制代码
# 错误写法
title = tree.xpath('//h1/text()')[0]  # 空列表会crash

# 正确写法
title_list = tree.xpath('//h1/text()')
title = title_list[0] if title_list else "Unknown"

Q6: 数据库插入失败,提示UNIQUE constraint?

原因: url字段设置了唯一约束,尝试重复插入

调试:

python 复制代码
# 在insert_film中添加日志
logging.debug(f"Attempting to insert URL: {url}")

# 或查询数据库确认
cursor.execute("SELECT * FROM films WHERE url = ?", (url,))
existing = cursor.fetchone()
if existing:
    logging.warning(f"Duplicate found: {existing}")

1️⃣1️⃣ 进阶优化(可选但加分)

并发加速:ThreadPoolExecutor

python 复制代码
from concurrent.futures import ThreadPoolExecutor, as_completed

def scrape_with_threading(film_urls, max_workers=3):
    """
    多线程并发爬取详情页
    注意:max_workers不宜过大,建议2-5
    """
    results = []
    
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        # 提交所有任务
        futures = {
            executor.submit(fetch_and_parse, url): url 
            for url in film_urls
        }
        
        # 收集结果
        for future in as_completed(futures):
            url = futures[future]
            try:
                data = future.result(timeout=30)
                if data:
                    results.append(data)
            except Exception as e:
                logging.error(f"Thread error for {url}: {e}")
    
    return results

def fetch_and_parse(url):
    """单个任务:请求+解析"""
    fetcher = Fetcher()
    parser = Parser()
    
    html = fetcher.fetch(url)
    if html:
        return parser.parse_film_detail(html)
    return None

性能对比:

  • 单线程: 178部影片约13分钟
  • 3线程: 约5分钟(提速60%)
  • 5线程: 约4分钟(但429风险增加)

断点续爬:记录已爬取URL

python 复制代码
class Checkpoint:
    """断点续爬管理"""
    
    def __init__(self, checkpoint_file='data/checkpoint.txt'):
        self.file = checkpoint_file
        self.completed = self._load()
    
    def _load(self):
        """加载已完成URL集合"""
        if Path(self.file).exists():
            with open(self.file, 'r') as f:
                return set(line.strip() for line in f)
        return set()
    
    def is_completed(self, url):
        return url in self.completed
    
    def mark_completed(self, url):
        """标记URL已完成并持久化"""
        self.completed.add(url)
        with open(self.file, 'a') as f:
            f.write(url + '\n')

# 使用示例
checkpoint = Checkpoint()

for url in film_urls:
    if checkpoint.is_completed(url):
        logging.info(f"Skipping completed: {url}")
        continue
    
    # ...爬取逻辑...
    
    checkpoint.mark_completed(url)

日志监控:实时统计

python 复制代码
class StatsMonitor:
    """实时统计成功率"""
    
    def __init__(self):
        self.total_attempts = 0
        self.success_count = 0
        self.fail_count = 0
    
    def record_success(self):
        self.total_attempts += 1
        self.success_count += 1
        self._print_stats()
    
    def record_failure(self):
        self.total_attempts += 1
        self.fail_count += 1
        self._print_stats()
    
    def _print_stats(self):
        if self.total_attempts % 10 == 0:  # 每10条打印一次
            success_rate = (self.success_count / self.total_attempts) * 100
            logging.info(
                f"Progress: {self.total_attempts} | "
                f"Success: {self.success_count} ({success_rate:.1f}%) | "
                f"Failed: {self.fail_count}"
            )

# 使用
monitor = StatsMonitor()

for url in film_urls:
    data = fetch_and_parse(url)
    if data:
        storage.insert_film(data, url)
        monitor.record_success()
    else:
        monitor.record_failure()

定时任务:每日自动更新

bash 复制代码
# Linux Crontab
# 每天凌晨3点运行
0 3 * * * /path/to/cannes_env/bin/python /path/to/main.py >> /path/to/logs/cron.log 2>&1

# Windows任务计划程序
# 创建基本任务 → 触发器:每日 → 操作:启动程序 → python.exe main.py

Python版(APScheduler):

python 复制代码
from apscheduler.schedulers.blocking import BlockingScheduler

def scheduled_job():
    """定时任务包装"""
    logging.info("Scheduled scraping started")
    main()

scheduler = BlockingScheduler()
scheduler.add_job(scheduled_job, 'cron', hour=3)  # 每天3点
scheduler.start()

1️⃣2️⃣ 总结与延伸阅读

我们完成了什么?

通过这个项目,我们从零构建了一个生产级的电影节数据采集系统,实现了:

完整的工程化架构 : 分层设计(请求/解析/存储),而非散乱的脚本

健壮的异常处理 : 重试机制、容错解析、事务保护

数据质量保障 : 字段验证、去重策略、清洗规范

可维护性 : 日志追踪、配置分离、单元测试预留

合规意识: 频率控制、robots遵守、数据边界明确

最终产出的178条影片数据,可直接用于:

  • 数据分析(制片国地域分布、导演性别比例)
  • 可视化展示(Tableau/PowerBI仪表盘)
  • 推荐系统训练数据
  • 电影资讯聚合服务

下一步可以做什么?

技术进阶方向:

  1. 迁移到Scrapy框架

    当数据量扩展到万级时,Scrapy的调度器、去重机制、中间件体系会更高效。建议学习:

    • Item Pipeline设计
    • Downloader Middleware(代理/UA轮换)
    • Scrapy-Redis分布式爬取
  2. 深度学习Playwright

    对于重度依赖JavaScript的现代网站(React/Vue SPA),Playwright比Selenium更快更稳定:

    • 无头浏览器自动化
    • 网络拦截与Mock
    • 截图与PDF生成
  3. 反爬对抗进阶

    • 指纹浏览器(Playwright Stealth)
    • CAPTCHA识别(2Captcha/OCR)
    • 字体反爬破解(woff解析)
    • JS逆向(webpack/obfuscator)
  4. 分布式架构

    • Celery任务队列
    • Kafka消息流处理
    • Docker容器化部署
    • Kubernetes弹性扩容

业务拓展方向:

  • 扩展到其他A类电影节(威尼斯、柏林、圣塞)
  • 增加实时监控:新片入围推送通知
  • 与豆瓣/IMDb数据融合,补充评分信息
  • 构建电影节趋势预测模型

延伸阅读推荐

爬虫进阶书籍:

  • 《Python网络数据采集》(Ryan Mitchell) - 系统全面
  • 《Web Scraping with Python》(2nd Edition) - 实战导向

技术文档:

合规参考:


最后的话: 爬虫技术本身是中性的工具,关键在于如何使用。希望这篇教程不仅教会你写代码,更能培养对数据的敬畏和对规则的尊重。当你拥有了采集任何公开数据的能力时,请务必记住:技术的价值在于创造,而非破坏。

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!

相关推荐
喵手2 小时前
Python爬虫实战:数据治理实战 - 基于规则与模糊匹配的店铺/公司名实体消歧(附CSV导出 + SQLite持久化存储)!
爬虫·python·数据治理·爬虫实战·零基础python爬虫教学·规则与模糊匹配·店铺公司名实体消岐
派葛穆2 小时前
Python-PyQt5 安装与配置教程
开发语言·python·qt
自可乐3 小时前
Milvus向量数据库/RAG基础设施学习教程
数据库·人工智能·python·milvus
可触的未来,发芽的智生3 小时前
发现:认知的普适节律 发现思维的8次迭代量子
javascript·python·神经网络·程序人生·自然语言处理
真智AI3 小时前
用 LLM 辅助生成可跑的 Python 单元测试:pytest + coverage 覆盖率报告(含运行指令与排坑)
python·单元测试·pytest
0思必得04 小时前
[Web自动化] Selenium处理文件上传和下载
前端·爬虫·python·selenium·自动化·web自动化
Hui Baby4 小时前
Java SPI 与 Spring SPI
java·python·spring
小猪咪piggy4 小时前
【Python】(3) 函数
开发语言·python
夜鸣笙笙4 小时前
交换最小值和最大值
python