第一次数据采集作业

代码仓库:Gitee · 网络爬虫第一次作业

实验环境:Windows、PyCharm、Python 3.14,第三方库为requests和BeautifulSoup,re为Python标准库。

一、大学排名定向爬虫

1. 作业代码、网页检查与运行图片

目标:2020软科中国大学排名

实际F12检查截图

task1_f12_elements

图中选中的节点为span.name-cn,其文本为“清华大学”。向上可以看到学校名称所在的td及排名表格的tr、tbody,支持代码按表格行解析并单独提取中文名称。

task1_f12_response

Network中选中了名为2020的主文档请求,Response搜索“852.5”得到一处匹配,附近还能看到“北京”“综合”的表格单元格。这说明指定数据存在于主文档响应中,可用requests获取后交给BeautifulSoup解析。该截图未显示Headers中的状态码,因此不据此判断HTTP状态。

实现说明:GET请求使用30秒超时,HTTP失败立即报错。BeautifulSoup按行解析,输出五个指定字段,另外保存HTML与CSV。

完整代码:task1_ranking.py

import csv
import sys
from pathlib import Path

import requests
from bs4 import BeautifulSoup

URL = "https://www.shanghairanking.cn/rankings/bcur/2020"
ROOT = Path(__file__).resolve().parent


def parse_rankings(html):
    soup = BeautifulSoup(html, "html.parser")
    records = []
    for row in soup.select("table tbody tr"):
        cells = row.find_all("td", recursive=False)
        if len(cells) < 5:
            continue
        name = cells[1].select_one(".name-cn")
        if name is None:
            raise ValueError("学校名称标签已改变,请用F12重新检查 .name-cn")
        records.append([cells[0].get_text(strip=True), name.get_text(strip=True),
                        cells[2].get_text(strip=True), cells[3].get_text(strip=True),
                        cells[4].get_text(strip=True)])
    if not records:
        raise ValueError("未找到排名行,可能为验证页或页面结构已改变")
    return records


def main():
    """保存原始响应与CSV,并打印实际解析出的行。"""
    response = requests.get(URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=30)
    response.raise_for_status()
    response.encoding = "utf-8"
    data_dir = ROOT / "data"
    data_dir.mkdir(exist_ok=True)
    (data_dir / "ranking.html").write_text(response.text, encoding="utf-8")
    records = parse_rankings(response.text)
    headings = ["排名", "学校名称", "省市", "学校类型", "总分"]
    print("2020软科中国大学排名(当前HTML首屏)")
    print("\t".join(headings))
    for record in records:
        print("\t".join(record))
    with (data_dir / "ranking.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(headings)
        writer.writerows(records)
    print(f"共 {len(records)} 条;首屏结果不代表完整榜单。")


if __name__ == "__main__":
    try:
        main()
    except (requests.RequestException, ValueError, OSError) as error:
        print(f"爬取失败:{error}", file=sys.stderr)
        sys.exit(1)

PyCharm运行结果截图

task1_pycharm

2. 作业心得

这次实验让我熟悉了requests获取网页、BeautifulSoup解析表格的基本过程。在F12中定位“清华大学”后,我发现学校名称所在的单元格还包含英文名称和其他标签,所以需要单独提取span.name-cn,才能让输出保持清晰。随后在Network的Response中搜索到“852.5”,也帮助我理解了为什么这个页面可以直接通过requests获取目标数据。

程序运行后打印了30条排名信息,第一条的学校、地区、类型和总分都能与网页对应。不过,网页显示共有567所学校,因此首屏30条不能代表完整榜单。这让我认识到,判断爬虫是否完成目标,除了看程序是否报错,还要检查字段是否准确、抓取范围是否说明清楚。以后遇到空结果,我会先检查网页响应和标签结构,再调整解析代码。

二、书包商品搜索与比价

1. 作业代码、网页检查与运行图片

自选商城:MoonRock梦乐书包商城搜索页。使用requests和re,不使用BeautifulSoup解析商品。

实际F12检查截图

task2_f12_elements

页面显示“共37个商品”和“1/3”。Elements展开了li id="li_3212" class="item",商品名称位于div.item-name > a,链接的title属性包含完整名称。左侧该商品展示价格为69元;截图中的div.item-price尚未展开,价格标签可结合下面的Response截图检查。

task2_f12_headers

页面显示“2/3”,Network的Headers显示请求方法为GET、状态为200 OK,响应类型为text/html; charset=utf-8。最终请求URL使用encode参数;这张图没有直接显示明文page=2,因此只用于说明第二页显示状态和所选主文档请求信息。

task2_f12_response

在第二页主文档Response搜索li_3411,结果为“1 of 1”,可见<li id="li_3411" class="item">。其上方另一个商品块中可见div.item-name、goods.php?id=3410和em class="sale-price main-color",说明商品名称与售价标签存在于响应HTML中。截图右侧文本被截断,不能仅凭此图读取完整商品名称或价格数值。

task2_f12_page2_search

同一第二页响应搜索第一页商品IDli_3212,结果为“0 of 0”。结合第二页能找到li_3411,说明两页返回的商品内容不同。无匹配在这里是翻页对照结果,不代表爬虫出错。

实现说明:先用正则分割每个li商品块,再在块内匹配标题和价格,从而保证两者属于同一商品。删除标题里的HTML高亮标签,解码实体,以Decimal进行价格排序。按page翻页,间隔一秒,按ID去重,最后核对商品总数。

完整代码:task2_shop.py

import csv
import html
import re
import sys
import time
from decimal import Decimal
from pathlib import Path

import requests

URL = "https://moonrockbags.cn/search.php"
ROOT = Path(__file__).resolve().parent
# 先分割商品块,再在同一块内匹配名称与价格,避免两列错位。
BLOCK_PATTERN = re.compile(r'<li\b[^>]*\bid="li_(\d+)"[^>]*>(.*?)</li>', re.S)
NAME_PATTERN = re.compile(r'<div\b[^>]*class="item-name"[^>]*>\s*<a\b[^>]*>(.*?)</a>', re.S)
PRICE_PATTERN = re.compile(r'<em\b[^>]*class="sale-price\b[^"]*"[^>]*>\s*[¥¥]\s*([\d,]+(?:\.\d+)?)\s*</em>', re.S)


def parse_products(text):
    """去除关键词高亮标签,并用Decimal保存金额。"""
    records = []
    for product_id, block in BLOCK_PATTERN.findall(text):
        name_match = NAME_PATTERN.search(block)
        price_match = PRICE_PATTERN.search(block)
        if not name_match or not price_match:
            raise ValueError(f"商品{product_id}缺少名称或价格,请检查HTML")
        name = html.unescape(re.sub(r"<[^>]+>", "", name_match[1])).strip()
        price = Decimal(price_match[1].replace(",", ""))
        records.append((product_id, name, price))
    if not records:
        raise ValueError("未找到商品,可能为空搜索结果、验证页或结构变化")
    return records


def main():
    """读取搜索结果的全部页;保持一秒访问间隔并按商品ID去重。"""
    data_dir = ROOT / "data"
    data_dir.mkdir(exist_ok=True)
    products = {}
    page, total_pages, expected = 1, 1, None
    with requests.Session() as session:
        session.headers.update({"User-Agent": "Mozilla/5.0"})
        while page <= total_pages:
            response = session.get(URL, params={"keywords": "书包", "page": page}, timeout=30)
            response.raise_for_status()
            response.encoding = "utf-8"
            text = response.text
            (data_dir / f"shop_page_{page}.html").write_text(text, encoding="utf-8")
            pages_match = re.search(r"共\s*(\d+)\s*页", text)
            count_match = re.search(r"共\s*(?:<[^>]+>\s*)*(\d+)\s*(?:<[^>]+>\s*)*个商品", text)
            if not pages_match or not count_match:
                raise ValueError("未找到总页数/商品数,请检查搜索页面")
            total_pages = int(pages_match[1])
            if not 1 <= total_pages <= 20:
                raise ValueError("页数超出本作业的20页访问上限")
            if expected is None:
                expected = int(count_match[1])
            elif expected != int(count_match[1]):
                raise ValueError("抓取过程中商品总数发生变化,请重新运行")
            before = len(products)
            for product_id, name, price in parse_products(text):
                products[product_id] = (product_id, name, price)
            if len(products) == before:
                raise ValueError("翻页没有新增商品,请检查page参数")
            print(f"第 {page}/{total_pages} 页,累计 {len(products)} 项")
            page += 1
            if page <= total_pages:
                time.sleep(1)
    if len(products) != expected:
        raise ValueError(f"条数不一致:提取{len(products)}项,页面声明{expected}项")
    records = sorted(products.values(), key=lambda item: (item[2], int(item[0])))
    print("序号\t价格(元)\t商品名")
    with (data_dir / "products.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(["序号", "价格", "商品名", "商品ID"])
        for index, (product_id, name, price) in enumerate(records, 1):
            print(f"{index}\t{price:.2f}\t{name}")
            writer.writerow([index, f"{price:.2f}", name, product_id])
    print(f"共 {len(records)} 项。搜索结果含配件;价格为展示标价。")


if __name__ == "__main__":
    try:
        main()
    except (requests.RequestException, ValueError, OSError) as error:
        print(f"爬取失败:{error}", file=sys.stderr)
        sys.exit(1)

PyCharm运行结果截图

task2_pycharm_1

task2_pycharm_2

2. 作业心得

商品爬取让我体会到正则表达式需要建立在网页结构检查的基础上。通过F12,我确认了商品名称和价格标签所在的位置,也观察到第一页的li_3212在第二页响应中没有匹配,而第二页可以找到li_3411。这让我理解了翻页后返回的内容会发生变化,不能只抓取第一页就认为已经得到全部搜索结果。本次程序读取了3页,共输出37项商品,并按价格从低到高排列。

在整理代码时,我理解了先划分商品块、再提取名称和价格的作用:它能减少名称与价格对应错误的风险。查看结果时还发现,搜索“书包”也会返回防雨罩等配件,因此69元的最低展示价不能直接当作书包本体的最低价。比价不仅需要排序,还需要理解商品内容和价格含义;页面标价也不一定等于使用优惠后的实际支付金额。

三、网页图片批量下载

1. 作业代码、网页检查与运行图片

目标:福州大学新闻网·影像福大。

实际F12检查截图

task3_f12_elements

Elements选中了第一张栏目图片的img标签,src以/__local/开头并以.jpg结尾。这是从站点根目录开始的相对路径,需要使用urljoin与页面地址组合成完整URL。外层a的href指向新闻详情页,不能把详情页地址当作图片下载地址。

task2_f12_headers

Network选择Img筛选后,选中一条.png图片请求。Headers显示完整Request URL、GET方法、Content-Type: image/png和Content-Length: 1223098。状态为200 OK (from memory cache),表示浏览器这次使用了内存缓存,不能据此声称本次重新从服务器下载了图片。程序运行时另行通过requests请求并保存资源。

task3_f12_preview

切换到同一请求的Preview,可以看到图片与网页第二张栏目缩略图内容一致;底部显示1280 × 720、image/png。这用于核对选中的资源确实是图片。Img请求列表还含GIF、SVG等格式,程序按照题目只筛选JPEG、JPG和PNG;浏览器请求数量也不等于程序按单页HTML收集并去重后的图片数量。

实现说明:使用urljoin补全URL,去重后逐个请求。依据真实文件头保存为png或jpg,URL哈希命名防止重名覆盖;来源、状态、字节数写入CSV。所有成功图片都保存在同一个downloaded_images文件夹。

完整代码:task3_images.py

"""作业③:下载给定页面直接引用的JPEG/JPG/PNG图片,保留来源清单。"""
import csv
import hashlib
import re
import sys
import time
from pathlib import Path
from urllib.parse import urldefrag, urljoin, urlsplit

import requests
from bs4 import BeautifulSoup

URL = "https://news.fzu.edu.cn/yxfd.htm"
ROOT = Path(__file__).resolve().parent
EXTENSIONS = {".jpg", ".jpeg", ".png"}


def collect_image_urls(html, page_url):
    """提取图片属性、srcset、图片链接和内联CSS;补全相对路径并去重。"""
    soup = BeautifulSoup(html, "html.parser")
    candidates = []
    for tag in soup.find_all(True):
        if tag.name in {"img", "source"}:
            for attribute in ("src", "data-src", "data-original", "data-lazy-src"):
                if tag.get(attribute):
                    candidates.append(tag[attribute])
            for attribute in ("srcset", "data-srcset"):
                for entry in tag.get(attribute, "").split(","):
                    if entry.strip():
                        candidates.append(entry.strip().split()[0])
        if tag.name == "a" and tag.get("href"):
            candidates.append(tag["href"])
    for css_url in re.findall(r"url\(\s*['\"]?([^)'\"]+)['\"]?\s*\)", html):
        candidates.append(css_url.strip())
    urls = []
    seen = set()
    for candidate in candidates:
        url = urldefrag(urljoin(page_url, candidate))[0]
        parts = urlsplit(url)
        if parts.scheme not in {"http", "https"}:
            continue
        if Path(parts.path).suffix.lower() not in EXTENSIONS or url in seen:
            continue
        seen.add(url)
        urls.append(url)
    return urls


def main():
    """验证图片文件头后落盘;失败逐项记录,并用非零退出码报告。"""
    output_dir = ROOT / "downloaded_images"
    output_dir.mkdir(exist_ok=True)
    data_dir = ROOT / "data"
    data_dir.mkdir(exist_ok=True)
    records = []
    with requests.Session() as session:
        session.headers.update({"User-Agent": "Mozilla/5.0", "Referer": URL})
        response = session.get(URL, timeout=30)
        response.raise_for_status()
        response.encoding = "utf-8-sig"
        (data_dir / "images_page.html").write_text(response.text, encoding="utf-8")
        urls = collect_image_urls(response.text, response.url)
        if not urls:
            raise ValueError("没有找到目标格式图片,请重新检查网页结构")
        for index, url in enumerate(urls, 1):
            filename = ""
            try:
                image = session.get(url, timeout=30)
                image.raise_for_status()
                content = image.content
                if content.startswith(b"\x89PNG\r\n\x1a\n"):
                    extension = ".png"
                elif content.startswith(b"\xff\xd8\xff"):
                    extension = ".jpg"
                else:
                    raise ValueError("响应不是PNG/JPEG,可能是错误页")
                # URL哈希让重名文件不会互相覆盖,也便于重复运行。
                filename = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] + extension
                (output_dir / filename).write_bytes(content)
                records.append([filename, url, "成功", len(content)])
                print(f"[{index}/{len(urls)}] 已保存 {filename}({len(content)}字节)")
            except (requests.RequestException, ValueError, OSError) as error:
                records.append([filename, url, f"失败:{error}", 0])
                print(f"[{index}/{len(urls)}] 失败:{url};{error}")
            if index < len(urls):
                time.sleep(0.3)
    with (data_dir / "image_manifest.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(["文件名", "来源URL", "状态", "字节数"])
        writer.writerows(records)
    failures = sum(row[2] != "成功" for row in records)
    print(f"找到 {len(urls)} 张,成功 {len(urls) - failures} 张,失败 {failures} 张。")
    print(f"保存目录:{output_dir}")
    if failures:
        raise ValueError("部分图片下载失败,详情见data/image_manifest.csv")


if __name__ == "__main__":
    try:
        main()
    except (requests.RequestException, ValueError, OSError) as error:
        print(f"爬取失败:{error}", file=sys.stderr)
        sys.exit(1)

PyCharm运行结果截图

task3_pycharm

下载后的文件展示

task3_downloaded_folder

文件展示截图中共有19个JPG、PNG文件,可见栏目内容图、Logo及小图标,与运行结果的19张成功记录一致。文件名采用图片URL的哈希值以避免重名覆盖。该截图未包含文件夹地址栏,保存目录以此前PyCharm运行截图中的downloaded_images路径为准。

2. 作业心得

图片下载实验让我进一步理解了网页中的资源路径。在Elements中可以看到图片的src以/__local/开头,这种路径需要与网站地址组合后才能用于下载。Network中的Headers和Preview则可以帮助确认资源格式和图片内容。我也注意到浏览器的Img列表包含GIF、SVG等资源,所以不能把浏览器显示的所有图片请求都算作本题要求的JPG、JPEG或PNG图片。

本次程序成功保存了19张图片,文件夹里既有栏目图片,也有Logo和较小的图标。结合运行日志和文件缩略图检查后,我认识到,文件小并不一定说明下载失败,还需要看它实际对应的内容。保存图片时进行URL去重、检查文件头并记录下载结果,能让后续核对更方便。这次抓取的范围是当前页面直接引用的图片;如果要继续获取其他分页或新闻详情中的图片,还需要增加页面访问逻辑。

posted @ 2026-10-11 22:22  NovaVespera  阅读(6)  评论(0)    收藏  举报