数据采集与融合技术作业3

作业1

(1)指定一个网站,爬取这个网站中的所有图片,例如中国气象网(http://www.weather.com.cn)。实现单线程和多线程的方式爬取。

一、爬取前的准备:工具与环境

核心库介绍
  • requests:发送 HTTP 请求,获取网页源码和图片二进制数据
  • BeautifulSoup4:解析 HTML 文档,提取图片 URL
  • os:处理文件路径与目录创建
  • threading:实现多线程并发爬取(控制并发数避免被封 IP)
  • lxml:HTML 解析器

二、实验完整过程

单线程方式爬取流程:
  • 发送请求,获取目标网页的 HTML 源码
  • 解析 HTML,提取所有标签的src属性(即图片 URL)
  • 遍历图片 URL,逐个下载并保存到本地
    导入所需库
import requests
from bs4 import BeautifulSoup
import os

QQ_1763449160800
图片url都是存在标签里面的src属性中
提取图片 URL,只需要提取所有标签的src属性

def get_image_urls(url, base_url):
    response = requests.get(url)
    soup = BeautifulSoup(response.text, 'lxml')
    img_tags = soup.find_all('img')
    img_urls = []
    for img in img_tags:
        img_src = img.get('src')
        if img_src:
            if not img_src.startswith('http'):
                img_src = base_url + img_src
            img_urls.append(img_src)
    return img_urls

下载并保存图片

def download_image(url, save_dir):
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)
    file_name = url.split('/')[-1]
    response = requests.get(url)
    with open(os.path.join(save_dir, file_name), 'wb') as f:
        f.write(response.content)
    print(f"下载完成:{url}")

主函数

if __name__ == "__main__":
    target_url = "http://www.weather.com.cn"
    base_url = "http://www.weather.com.cn"
    save_directory = "作业1/images1"
    image_urls = get_image_urls(target_url, base_url)
    for url in image_urls:
        download_image(url, save_directory)

完整代码

import requests
from bs4 import BeautifulSoup
import os

def get_image_urls(url, base_url):
    """
    解析目标网页,提取所有图片的完整URL
    :param url: 目标网页的URL(需爬取图片的网页地址)
    :param base_url: 网站基础URL(用于补全相对路径的图片地址)
    :return: 包含所有完整图片URL的列表
    """
    response = requests.get(url)
    soup = BeautifulSoup(response.text, 'lxml')
    img_tags = soup.find_all('img')
    img_urls = []
    for img in img_tags:
        img_src = img.get('src')
        if img_src:
            if not img_src.startswith('http'):
                img_src = base_url + img_src
            img_urls.append(img_src)
    return img_urls

def download_image(url, save_dir):
    """
    根据图片URL下载图片,并保存到本地指定目录
    :param url: 单张图片的完整URL
    :param save_dir: 图片保存到本地的目录路径
    """
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)
    file_name = url.split('/')[-1]
    response = requests.get(url)
    with open(os.path.join(save_dir, file_name), 'wb') as f:
        f.write(response.content)
    print(f"下载完成:{url}")

if __name__ == "__main__":
    target_url = "http://www.weather.com.cn"
    base_url = "http://www.weather.com.cn"
    save_directory = "作业1/images1"
    image_urls = get_image_urls(target_url, base_url)
    for url in image_urls:
        download_image(url, save_directory)
多线程方式爬取流程:

信号量定义

# 用于限制并发线程数的信号量(避免请求过多被封)
MAX_WORKERS = 5
semaphore = threading.Semaphore(MAX_WORKERS)

完整代码:

import requests
from bs4 import BeautifulSoup
import os
import threading

MAX_WORKERS = 5
semaphore = threading.Semaphore(MAX_WORKERS)

def get_image_urls(url, base_url):
    """
    解析目标网页,提取所有图片的完整URL
    :param url: 目标网页的URL(需爬取图片的网页地址)
    :param base_url: 网站基础URL(用于补全相对路径格式的图片地址)
    :return: 包含所有完整图片URL的列表
    """
    response = requests.get(url)
    soup = BeautifulSoup(response.text, 'lxml')
    img_tags = soup.find_all('img')
    img_urls = []
    for img in img_tags:
        img_src = img.get('src')
        if img_src:
            if not img_src.startswith('http'):
                img_src = base_url + img_src
            img_urls.append(img_src)
    return img_urls

def download_image(url, save_dir):
     """
    多线程下载单张图片(带信号量并发控制、异常处理、流式下载)
    :param url: 单张图片的完整URL
    :param save_dir: 图片保存到本地的目录路径
    """
    with semaphore:
        try:
            if not os.path.exists(save_dir):
                os.makedirs(save_dir)
            

            file_name = url.split('/')[-1].split('?')[0]
            file_name = file_name[:50]
            
            response = requests.get(url, timeout=15, stream=True)
            response.raise_for_status()
            
            save_path = os.path.join(save_dir, file_name)
            with open(save_path, 'wb') as f:
                for chunk in response.iter_content(chunk_size=1024):
                    if chunk:
                        f.write(chunk)
            
            print(f"下载完成:{url}")
        except Exception as e:
            print(f"下载失败 {url}:{e}")

if __name__ == "__main__":
    target_url = "http://www.weather.com.cn"
    base_url = "http://www.weather.com.cn"
    save_directory = "作业1/images2"

    img_urls = get_image_urls(target_url, base_url)

    threads = []
    # 创建线程对象:指定线程执行的函数、函数参数、线程名称
    for url in img_urls:
        thread = threading.Thread(
            target=download_image,
            args=(url, save_directory),
            name=f"Downloader-{len(threads)+1}"
        )
        threads.append(thread)
        thread.start()
    # 等待所有线程执行完成
    for thread in threads:
        thread.join()

两个程序爬到的结果都如下

QQ_1763448998839

(2)心得体会

本次作业让我掌握了 requests 库发送 HTTP 请求、BeautifulSoup4 解析 HTML 文档的基础流程。通过提取标签的src属性获取图片 URL 时,深刻理解了相对路径与绝对路径的差异,学会了用基础 URL 补全相对路径的方法,避免因路径错误导致的下载失败。

作业2

  • 熟练掌握 scrapy 中 Item、Pipeline 数据的序列化输出方法;Scrapy+Xpath +MySQL 数据库存储技术路线爬取股票相关信息。
  • 候选网站:
    东方财富网: https://www.eastmoney.com/
    新浪股票: http://finance.sina.com.cn/stock/
  • 输出信息: MySQL 数据库存储和输出格式如下,表头应是英文命名例如:序号 id,股票代码: bStockNo……,由同学们自行定义设计表头

2c0d39d7-520b-4713-869b-2b1a38f6c0e3

一、爬取前的准备:工具与环境

技术栈选择
  • Scrapy:强大的 Python 爬虫框架,用于数据提取和处理流程管理
  • SQLite:轻量级数据库,适合存储中小型数据集
  • Pandas:数据分析库,用于数据展示和后续分析
  • 正则表达式:用于从文本中提取 JSON 数据片段
项目结构
StockSpider/
├── StockSpider/
    ├── __init__.py
    ├── items.py 
    ├── middlewares.py
    ├── pipelines.py 
    ├── settings.py
    ├── run.py
    └── spiders/
        ├── __init__.py
        └── eastmoney_spider.py 

二、实验完整过程

数据结构定义(items.py)
把需要输出的字段都存到StockItem类中

import scrapy
class StockItem(scrapy.Item):
    """股票数据Item定义,对应原数据字段"""
    serial_num = scrapy.Field()  # 序号
    stock_code = scrapy.Field()  # 股票代码
    stock_name = scrapy.Field()  # 股票名称
    latest_price = scrapy.Field()  # 最新价格
    price_change = scrapy.Field()  # 涨跌额
    price_change_rate = scrapy.Field()  # 涨跌幅
    volume = scrapy.Field()  # 成交量
    turnover = scrapy.Field()  # 成交额
    amplitude = scrapy.Field()  # 振幅
    highest_price = scrapy.Field()  # 最高
    lowest_price = scrapy.Field()  # 最低
    opening_price = scrapy.Field()  # 今开
    previous_close = scrapy.Field()  # 昨收

爬虫逻辑与作业2的逻辑一样

c0726ddf-a8e9-4166-acf8-36ec30e8a52e

将源码存到本地文件,避免多次访问给网站造成骚扰
爬虫实现(eastmoney_spider.py)

import scrapy
import re
import json
import os
from datetime import datetime
from StockSpider.items import StockItem

class StockSpider(scrapy.Spider):
    name = 'stock'
    allowed_domains = []
    
    # 配置文件路径和页数
    custom_settings = {
        'FILE_PATH': '股票源码.txt',
        'TOTAL_PAGES': 1
    }
    cnt=1
    
    def start_requests(self):
        """开始请求"""
        file_path = self.settings.get('FILE_PATH')
        
        if not os.path.exists(file_path):
            self.logger.error(f"本地文件不存在:{file_path}")
            return
        # 模拟请求,将文件内容作为响应传递
        with open(file_path, 'r', encoding='utf-8') as f:
            file_content = f.read()
        
        # 为每一页生成一个"响应"
        total_pages = self.settings.get('TOTAL_PAGES', 1)
        for page_num in range(1, total_pages + 1):
            yield scrapy.Request(
                url=f'file:///{os.path.abspath(file_path)}?page={page_num}',
                callback=self.parse,
                meta={'page_num': page_num, 'file_content': file_content}
            )
    
    def parse(self, response):
        """解析本地文件内容"""
        page_num = response.meta.get('page_num')
        source_content = response.meta.get('file_content')
        
        self.logger.info(f"开始解析第{page_num}页数据")
        pat = r'"diff":\[(.*?)\]'
        data_str_list = re.compile(pat, re.S).findall(source_content)
        
        if not data_str_list or data_str_list[0].strip() == "":
            self.logger.warning(f"第{page_num}页无有效数据")
            return
        
        try:
            # 解析JSON数据
            data_str = data_str_list[0]
            data = json.loads(f"[{data_str}]")
            for item in data:
                stock_item = StockItem()
                stock_item['serial_num']=self.cnt
                self.cnt+=1
                stock_item['stock_code'] = item.get('f12', '')
                stock_item['stock_name'] = item.get('f14', '')
                stock_item['latest_price'] = round(item.get('f2', 0)/100, 2)
                stock_item['price_change'] = f"{round(item.get('f4', 0)/100, 2)}"
                stock_item['price_change_rate'] = f"{round(item.get('f3', 0)/100, 2)}%"
                stock_item['volume'] = f"{round(item.get('f5', 0)/10000, 2)}万"
                stock_item['turnover'] = f"{round(item.get('f6', 0)/1e8, 2)}亿"
                stock_item['amplitude'] = round(item.get('f7', 0)/100, 2)
                stock_item['highest_price'] = round(item.get('f15', 0)/100, 2)
                stock_item['lowest_price'] = round(item.get('f16', 0)/100, 2)
                stock_item['opening_price'] = round(item.get('f17', 0)/100, 2)
                stock_item['previous_close'] = round(item.get('f18', 0)/100, 2)
                yield stock_item
            
            self.logger.info(f"第{page_num}页解析完成,共提取{len(data)}条数据")
        
        except json.JSONDecodeError as e:
            self.logger.error(f"第{page_num}页JSON解析失败: {e}")
        except Exception as e:
            self.logger.error(f"第{page_num}页解析异常: {e}")

数据处理管道(pipelines.py)
我实现了两个管道:一个用于数据库存储,另一个用于 DataFrame 展示。
数据库存储

import sqlite3
from scrapy.exceptions import DropItem
from datetime import datetime

class StockScraperPipeline:
    """股票数据处理管道:存储到SQLite数据库"""
    
    def open_spider(self, spider):
        """爬虫启动时初始化数据库连接"""
        self.conn = sqlite3.connect('local_stock_data.db')
        self.cursor = self.conn.cursor()
        self.create_table()
    
    def create_table(self):
        """创建股票数据表"""
        create_sql = '''
        CREATE TABLE IF NOT EXISTS local_stock_market (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            serial_num INTEGER NOT NULL,
            stock_code TEXT NOT NULL UNIQUE,
            stock_name TEXT NOT NULL,
            latest_price FLOAT NOT NULL,
            price_change TEXT NOT NULL,
            price_change_rate TEXT NOT NULL,
            volume TEXT NOT NULL,
            turnover TEXT NOT NULL,
            amplitude FLOAT NOT NULL,
            highest_price FLOAT NOT NULL,
            lowest_price FLOAT NOT NULL,
            opening_price FLOAT NOT NULL,
            previous_close FLOAT NOT NULL
        )
        '''
        try:
            self.cursor.execute(create_sql)
            self.conn.commit()
        except Exception as e:
            print(f"创建数据表失败: {e}")
            raise  # 建表失败直接终止爬虫
    
    def process_item(self, item, spider):
        """处理每一条股票数据并插入数据库"""
        try:
            # 数据非空校验
            required_fields = ['serial_num', 'stock_code', 'stock_name', 'latest_price']
            for field in required_fields:
                if not item.get(field) and item.get(field) != 0:  # 允许 0 值(如价格为0)
                    raise ValueError(f"关键字段 '{field}' 为空,数据无效:{dict(item)}")
            
            # 插入数据
            insert_sql = '''
            INSERT OR IGNORE INTO local_stock_market (
                serial_num, stock_code, stock_name, latest_price, price_change,
                price_change_rate, volume, turnover, amplitude, highest_price,
                lowest_price, opening_price, previous_close
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            '''
            # 构造参数元组
            params = (
                item['serial_num'],
                item['stock_code'],
                item['stock_name'],
                item['latest_price'],
                item['price_change'],
                item['price_change_rate'],
                item['volume'],
                item['turnover'],
                item['amplitude'],
                item['highest_price'],
                item['lowest_price'],
                item['opening_price'],
                item['previous_close']
            )
            
            self.cursor.execute(insert_sql, params)
            self.conn.commit()
            return item
        
        except sqlite3.IntegrityError as e:
            # 处理唯一约束冲突
            raise DropItem(f"数据重复,已丢弃:{item}")
        except ValueError as e:
            # 处理数据校验失败
            print(f"数据无效: {e}")
            raise DropItem(f"数据无效,已丢弃:{item}")
        except Exception as e:
            # 其他未知错误
            print(f"插入数据失败: {e} - 数据详情:{dict(item)}")
            raise DropItem(f"处理股票数据失败: {item}")
    
    def close_spider(self, spider):
        """爬虫关闭时关闭数据库连接"""
        try:
            self.conn.close()
            print("数据库连接已关闭")
        except Exception as e:
            print(f"关闭数据库连接失败: {e}")

DataFrame 展示

class StockDataFramePipeline(object):
    """额外管道:收集所有数据并输出DataFrame表格"""
    def open_spider(self, spider):
        self.all_items = []
    
    def process_item(self, item, spider):
        self.all_items.append(dict(item))
        return item
    
    def close_spider(self, spider):
        """爬虫结束后输出DataFrame表格"""
        print("\n" + "-"*50)
        print(f"数据收集完成,共收集 {len(self.all_items)} 条记录")
        print("-"*50)
        
        if not self.all_items:
            print("未收集到任何有效股票数据")
            return
        
        import pandas as pd
        columns = [
            "serial_num", "stock_code", "stock_name", "latest_price", 
            "price_change", "price_change_rate", "volume", "turnover",
            "amplitude", "highest_price", "lowest_price", "opening_price",
            "previous_close"
        ]
        
        # 创建DataFrame并处理数据类型
        df = pd.DataFrame(self.all_items, columns=columns)
        
        pd.set_option('display.unicode.ambiguous_as_wide', True)
        pd.set_option('display.unicode.east_asian_width', True)
        pd.set_option('display.max_columns', None)
        pd.set_option('display.width', 200)
        pd.set_option('display.precision', 2)
        # 输出格式化表格
        print("\n" + "="*200)
        print("股票数据汇总表")
        print("="*200)
        print(df.to_string(index=False, col_space=12))
        print(f"\n共提取 {len(df)} 条股票数据,已保存到 local_stock_data.db 数据库")
        print("="*100)

配置与启动
后面的数字代表优先级,应当让存储数据库管道优先级更高(先执行),然后再输出到终端
settings.py

FEED_EXPORT_ENCODING = "utf-8"
ITEM_PIPELINES = {
    'StockSpider.pipelines.StockScraperPipeline': 300,
    'StockSpider.pipelines.StockDataFramePipeline': 400,
}

run.py

from scrapy import cmdline
cmdline.execute("scrapy crawl stock -s LOG_ENABLED=False".split())

结果

b5dce4ba5bb185f69ead62a039dcb05f

(2)心得体会

  • 初期对 Scrapy 的工作流程不熟悉,不清楚 Item 如何传递给 Pipeline,通过查看官方文档与调试代码,理解了 “yield Item” 后数据自动传入 Pipeline 的机制,体会到框架化开发的便捷性。
  • 正则表达式提取 JSON 数据时,因未考虑换行符导致匹配失败,后续添加re.S参数( DOTALL 模式),让.能够匹配换行符,解决了该问题,让我意识到正则表达式的匹配模式需要根据数据格式灵活调整。
  • 数据格式转换(如将原始数据除以 100 转换为正常价格)时,需注意数据类型的一致性,避免出现类型错误,这让我认识到结构化数据处理中 “数据清洗” 的重要性。

作业3

  • 要求:熟练掌握 scrapy 中 Item、Pipeline 数据的序列化输出方法;使用 scrapy 框架 + Xpath+MySQL 数据库存储技术路线爬取外汇网站数据。
  • 候选网站:招商银行网: https://www.boc.cn/sourcedb/whpj/
技术栈
  • Python 3.11
  • Scrapy:强大的 Python 爬虫框架
  • SQLite:轻量级关系型数据库
项目结构
forex/
├── forex/
    ├── __init__.py
    ├── items.py 
    ├── middlewares.py
    ├── pipelines.py 
    ├── settings.py
    ├── run.py
    └── spiders/
        ├── __init__.py
        └── myspider.py 

二、实验完整过程

观察网页很容易得到如下的爬取逻辑,方法与上面一个类似,不再赘述
定义数据结构(items.py)

import scrapy

class ForexItem(scrapy.Item):
    Currency = scrapy.Field()  # 货币名称
    TBP = scrapy.Field()       # 现汇买入价
    CBP = scrapy.Field()       # 现钞买入价
    TSP = scrapy.Field()       # 现汇卖出价
    CSP = scrapy.Field()       # 现钞卖出价
    Time = scrapy.Field()      # 时间

QQ_1763449832345
第一个tr是表头,接下来每个tr中都包含我们所需要的一个ForexItem对象
编写爬虫(myspider.py)

import scrapy
from datetime import datetime
from forex.items import ForexItem

class BocSpider(scrapy.Spider):
    name = 'boc'
    allowed_domains = ['boc.cn']
    start_urls = ['https://www.boc.cn/sourcedb/whpj/']

    def parse(self, response):
        # 提取外汇数据行,跳过表头行
        rows = response.xpath('//div[@class="main"]/following-sibling::table/tr[position()>1]')
        
        for row in rows:
            item = ForexItem()
            item['Currency'] = row.xpath('td[1]/text()').extract_first()
            item['TBP'] = row.xpath('td[2]/text()').extract_first()
            item['CBP'] = row.xpath('td[3]/text()').extract_first()
            item['TSP'] = row.xpath('td[4]/text()').extract_first()
            item['CSP'] = row.xpath('td[5]/text()').extract_first()
            item['Time'] = row.xpath('td[8]/text()').extract_first()
            yield item

数据处理与存储(pipelines.py)

import sqlite3
from scrapy.exceptions import DropItem

class SqlitePipeline:
    def open_spider(self, spider):
        # 连接数据库并创建表
        self.conn = sqlite3.connect('forex_data.db')
        self.cursor = self.conn.cursor()
        create_table_sql = '''
        CREATE TABLE IF NOT EXISTS forex_data (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            Currency TEXT NOT NULL,
            TBP REAL,
            CBP REAL,
            TSP REAL,
            CSP REAL,
            Time TEXT
        )
        '''
        # 打印表头
        print(f"  {'Currency':<15}   {'TBP':<10}   {'CBP':<10}   {'TSP':<10}   {'CSP':<10}   {'Time':<10}  ")
        self.cursor.execute(create_table_sql)
        self.conn.commit()

    def process_item(self, item, spider):
        # 终端格式化输出
        print(f"  {item['Currency']:<15}   {item['TBP']:<10}   {item['CBP']:<10}   {item['TSP']:<10}   {item['CSP']:<10}   {item['Time']:<10} ")

        # 插入数据到数据库
        insert_sql = '''
        INSERT INTO forex_data (Currency, TBP, CBP, TSP, CSP, Time)
        VALUES (?, ?, ?, ?, ?, ?)
        '''
        self.cursor.execute(insert_sql, (
            item['Currency'],
            item['TBP'],
            item['CBP'],
            item['TSP'],
            item['CSP'],
            item['Time']
        ))
        self.conn.commit()
        return item

    def close_spider(self, spider):
        # 关闭数据库连接
        self.cursor.close()
        self.conn.close()

项目设置(settings.py)

ITEM_PIPELINES = {
    "forex.pipelines.SqlitePipeline": 300,
}

run.py

from scrapy import cmdline
cmdline.execute("scrapy crawl boc -s LOG_ENABLED=False".split())

结果:

94a747a7700ddc1b510d60ed79b47d7d

(2)心得体会

  • 初期 XPath 表达式未考虑表头行,导致将表头数据也爬取下来,后续通过position()>1筛选掉表头行,解决了该问题,让我意识到解析时必须关注节点的位置关系。
  • 部分外汇数据的买入价 / 卖出价可能为空,未添加异常处理导致数据库插入时出现警告,后续可在 Pipeline 中添加数据类型转换与空值处理,进一步提升爬虫的鲁棒性。
posted @ 2025-11-18 15:21  贪吃小屁  阅读(19)  评论(0)    收藏  举报