数据采集实验3
数据采集课程实验博客
作者: 黄炜杰 学号: 102402126
目标环境: Windows 11、Python 3.10+、requests、BeautifulSoup、Scrapy、PyMySQL、MySQL 8
运行前请安装根目录 requirements.txt 中的依赖;两个 Scrapy 作业还需要修改各自 settings.py 里的 MySQL 用户名和密码。
运行入口:作业①在 作业1 目录执行 python 1.py 和 python 1_2.py;作业②在 作业2 目录执行 scrapy crawl eastmoney_stock;作业③在 作业3 目录执行 scrapy crawl boc_forex。两个 Scrapy 工程的 Pipeline 会自动建库建表,也可以手动执行各自的 sql/create_table.sql。
一、作业①:中国气象网图片爬虫(单线程 / 多线程)
(一)作业代码和运行图片
作业从中国气象网首页出发,优先抓取 p.weather.com.cn 的图片文章,单线程版顺序下载,多线程版用 ThreadPoolExecutor 下载图片,两个版本都限制 26 页、126 张图片,并在页面请求之间随机等待。
1. 单线程版:1.py
作业1/1.py
# -*- coding: utf-8 -*-
"""
作业①:中国气象网图片爬虫(单线程版)
作者:黄炜杰
学号:102402126
功能:
从中国气象网首页出发,按“文章页优先”的顺序抓取站内页面,
解析页面中的图片并下载到脚本同级 images/ 目录。程序会打印每个
图片 URL,并在结束时汇总页数、图片数和总耗时。
技术路线:
requests 发送 HTTP 请求 + BeautifulSoup(lxml) 解析 HTML,
单线程顺序下载图片。
运行前安装依赖:
pip install requests beautifulsoup4 lxml
运行:
python 1.py
"""
from __future__ import annotations
import hashlib
import json
import logging
import random
import re
import time
from collections import deque
from pathlib import Path
from typing import Optional
from urllib.parse import urldefrag, urljoin, urlparse
import requests
from bs4 import BeautifulSoup
from bs4.element import Tag
# ==================== 爬取限制(按学号 102402126 计算) ====================
# 学号末 2 位是 26,因此总爬取页数上限设为 26 页。
# 学号末 3 位是 126,因此总下载图片数量上限设为 126 张。
# 这两个值既是课程要求,也是控制请求量、降低被封禁风险的保护措施。
MAX_PAGES = 26
MAX_IMAGES = 126
# 相邻两次页面请求之间随机等待 0.5~1 秒,模拟正常人工浏览节奏。
# 多张图片的下载发生在页面处理阶段,不会绕开这个限速。
PAGE_DELAY_RANGE = (0.5, 1.0)
REQUEST_TIMEOUT = 15
TARGET_HOST = "weather.com.cn"
START_URL = "http://www.weather.com.cn/"
# 只下载常见位图格式;扩大范围会把图标、字体等无关资源也带进来。
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
# 页面内链中如果出现这些后缀,就说明不是 HTML 页面,直接跳过。
NON_PAGE_SUFFIXES = IMAGE_SUFFIXES | {
".css", ".js", ".json", ".xml", ".ico", ".svg", ".pdf",
".zip", ".rar", ".7z", ".mp3", ".mp4", ".avi", ".apk", ".exe",
}
# 统一请求头,务必带真实的 User-Agent,避免被站点当作脚本直接拒绝。
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept": (
"text/html,application/xhtml+xml,image/avif,"
"image/webp,*/*;q=0.8"
),
"Accept-Language": "zh-CN,zh;q=0.9",
}
# 所有路径都以脚本所在目录为基准,避免从 Spyder 或其它目录启动时
# 把 images 文件夹创建到意料之外的位置。
BASE_DIR = Path(__file__).resolve().parent
IMAGE_DIR = BASE_DIR / "images"
LOG_PATH = BASE_DIR / "crawler.log"
TIMING_PATH = BASE_DIR / "crawl_timing.json"
# 用于识别本程序生成的图片,重复运行时只清理这些文件。
IMAGE_FILE_PATTERN = re.compile(
r"^\d{4}_[0-9a-f]{12}\.(?:jpg|png|gif|webp|bmp)$"
)
# 模式名称会写入 crawl_timing.json,另一版本读取后即可打印耗时对比。
MODE_NAME = "单线程"
LOGGER = logging.getLogger("weather_image_crawler_single")
def setup_logging() -> None:
"""配置文件日志,把页面和图片下载失败的原因追加写入 crawler.log。"""
LOGGER.setLevel(logging.INFO)
if LOGGER.handlers:
return
try:
handler = logging.FileHandler(LOG_PATH, mode="a", encoding="utf-8")
except OSError as exc:
print(f"[警告] 无法创建日志文件:{exc}")
return
handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
)
LOGGER.addHandler(handler)
def prepare_image_dir() -> None:
"""创建 images 目录,并清理上次运行生成的图片,保证本次结果可核对。
只删除符合“4 位序号_12 位哈希.扩展名”命名的文件,不会碰用户放在
该目录里的截图或其它资料。
"""
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
removed = 0
for file_path in IMAGE_DIR.iterdir():
if not file_path.is_file():
continue
if IMAGE_FILE_PATTERN.fullmatch(file_path.name):
try:
file_path.unlink()
removed += 1
except OSError as exc:
LOGGER.warning("清理旧图片失败:%s(%s)", file_path, exc)
if removed:
print(f"[准备] 已清理上次运行生成的 {removed} 张图片。")
def normalize_page_url(raw_url: str, base_url: str) -> Optional[str]:
"""把页面中的相对链接转成绝对链接,并过滤站外和二进制资源。
返回值 None 表示该链接不进入待抓队列;返回字符串表示可以抓取。
"""
raw_url = raw_url.strip()
if not raw_url or raw_url.startswith(("javascript:", "mailto:", "tel:")):
return None
# 去掉 #锚点,避免同一个页面因为页内跳转而重复入队。
absolute_url, _ = urldefrag(urljoin(base_url, raw_url))
parsed = urlparse(absolute_url)
host = (parsed.hostname or "").lower()
if parsed.scheme not in ("http", "https"):
return None
# 只抓中国气象网主域名及其子域名,防止爬到广告站或第三方网站。
if host != TARGET_HOST and not host.endswith(f".{TARGET_HOST}"):
return None
if Path(parsed.path).suffix.lower() in NON_PAGE_SUFFIXES:
return None
# 图片频道的 *_list.shtml 是同一组图的翻页副本,跳过可减少重复图片。
if parsed.path.endswith("_list.shtml"):
return None
return absolute_url
def page_priority(page_url: str) -> int:
"""给待抓页面分级,数字越小越先抓。
0:p.weather.com.cn 的图片频道文章页,正文通常是一组高清图;
1:其它带年份的资讯/生活/视频文章页;
2:栏目首页或普通 HTML 页;
3:城市天气预报等图片重复度高的页面,放到最后。
"""
parsed = urlparse(page_url)
host = (parsed.hostname or "").lower()
path = parsed.path
if (
host.startswith("p.")
and path.endswith(".shtml")
and re.search(r"/20\d{2}/\d{2}/", path)
):
return 0
if "/weather1d/" in path or path.startswith("/weather/"):
return 3
if re.search(r"/20\d{2}/\d{2}/", path) and path.endswith(".shtml"):
return 1
if path.endswith(("/", ".shtml", ".html")):
return 2
return 3
def enqueue_page_links(
soup: BeautifulSoup,
base_url: str,
queues: list[deque[str]],
scheduled: set[str],
) -> int:
"""提取页面内链并按优先级入队,返回本次新增的链接数。"""
added = 0
for anchor in soup.find_all("a", href=True):
page_url = normalize_page_url(anchor.get("href", ""), base_url)
if page_url is None or page_url in scheduled:
continue
queues[page_priority(page_url)].append(page_url)
scheduled.add(page_url)
added += 1
return added
def pop_next_page(queues: list[deque[str]]) -> Optional[str]:
"""按优先级取出下一个页面;所有队列都为空时返回 None。"""
for queue in queues:
if queue:
return queue.popleft()
return None
def is_image_url(image_url: str) -> bool:
"""判断 URL 是否指向中国气象网内的常见格式图片。"""
parsed = urlparse(image_url)
host = (parsed.hostname or "").lower()
if parsed.scheme not in ("http", "https"):
return False
if host != TARGET_HOST and not host.endswith(f".{TARGET_HOST}"):
return False
return Path(parsed.path).suffix.lower() in IMAGE_SUFFIXES
def get_image_url(img_tag: Tag, base_url: str) -> Optional[str]:
"""从 img 的懒加载属性、src 或 srcset 中取出第一个有效图片地址。"""
candidates: list[str] = []
# 很多资讯页采用懒加载,data-src 等属性里才是原图,因此优先读取。
for attribute in ("data-src", "data-original", "data-lazy-src", "src"):
value = img_tag.get(attribute)
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
# srcset 可能是“a.jpg 1x, b.jpg 2x”,这里取第一个候选地址。
for attribute in ("srcset", "data-srcset"):
value = img_tag.get(attribute)
if isinstance(value, str) and value.strip():
first_item = value.split(",")[0].strip().split(" ")[0]
if first_item:
candidates.append(first_item)
for candidate in candidates:
if candidate.startswith("data:"):
continue
absolute_url = urljoin(base_url, candidate)
if is_image_url(absolute_url):
return absolute_url
return None
def extract_image_urls(soup: BeautifulSoup, page_url: str) -> list[str]:
"""提取当前页面全部图片 URL,并在页面内部去重。"""
image_urls: list[str] = []
seen: set[str] = set()
for img_tag in soup.find_all("img"):
image_url = get_image_url(img_tag, page_url)
if image_url is not None and image_url not in seen:
seen.add(image_url)
image_urls.append(image_url)
return image_urls
def fetch_html(url: str) -> tuple[Optional[str], Optional[str]]:
"""请求一个页面,返回 (HTML 文本, 重定向后的 URL)。
网络异常只记录日志并返回 (None, None),由主循环继续抓下一个页面,
不会因为单个页面失败而中断整个作业。
"""
try:
response = requests.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
content_type = response.headers.get("Content-Type", "").lower()
if content_type and "html" not in content_type:
LOGGER.warning("不是 HTML,已跳过:%s(%s)", url, content_type)
print(f" [跳过] 非 HTML 页面:{content_type}")
return None, None
# 气象网部分页面未在响应头声明编码,用 apparent_encoding 自动识别。
response.encoding = response.apparent_encoding or "utf-8"
return response.text, response.url
except requests.RequestException as exc:
LOGGER.warning("页面请求失败:%s(%s)", url, exc)
print(f" [失败] 页面请求异常:{exc}")
return None, None
def detect_image_suffix(content: bytes) -> Optional[str]:
"""根据文件头判断图片真实格式,避免站点把 JPEG 标成 PNG 的情况。"""
if content.startswith(b"\xff\xd8"):
return ".jpg"
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png"
if content.startswith((b"GIF87a", b"GIF89a")):
return ".gif"
if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
return ".webp"
if content.startswith(b"BM"):
return ".bmp"
return None
def download_image(image_url: str, serial: int) -> Optional[str]:
"""下载一张图片,成功时返回保存的文件名。
文件名格式为“序号_网址哈希.扩展名”:
序号保证本次运行内不会重名;
网址哈希避免不同页面出现同名图片时互相覆盖。
"""
print(f"[图片 {serial:03d}] {image_url}", flush=True)
digest = hashlib.md5(image_url.encode("utf-8")).hexdigest()[:12]
try:
response = requests.get(
image_url, headers=HEADERS, timeout=REQUEST_TIMEOUT
)
response.raise_for_status()
suffix = detect_image_suffix(response.content)
if suffix is None:
raise ValueError("响应内容不是可识别的图片格式")
filename = f"{serial:04d}_{digest}{suffix}"
(IMAGE_DIR / filename).write_bytes(response.content)
return filename
except requests.RequestException as exc:
LOGGER.warning("图片下载失败:%s(%s)", image_url, exc)
print(f" [失败] 图片请求异常:{exc}")
except (OSError, ValueError) as exc:
LOGGER.warning("图片保存失败:%s(%s)", image_url, exc)
print(f" [失败] 图片保存异常:{exc}")
return None
def record_timing_and_compare(elapsed: float) -> None:
"""保存本模式耗时,并在另一模式已运行过时打印单/多线程对比。"""
timing: dict[str, object] = {}
if TIMING_PATH.exists():
try:
loaded = json.loads(TIMING_PATH.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
timing = loaded
except (OSError, json.JSONDecodeError) as exc:
LOGGER.warning("读取历史耗时失败:%s", exc)
timing[MODE_NAME] = round(elapsed, 2)
timing["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
try:
TIMING_PATH.write_text(
json.dumps(timing, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as exc:
LOGGER.warning("写入耗时记录失败:%s", exc)
print(f"[警告] 无法写入耗时记录:{exc}")
single_time = timing.get("单线程")
multi_time = timing.get("多线程")
if isinstance(single_time, (int, float)) and isinstance(
multi_time, (int, float)
):
print(
f"单/多线程耗时对比:单线程 {single_time:.2f} 秒 | "
f"多线程 {multi_time:.2f} 秒"
)
if multi_time > 0 and single_time > multi_time:
speedup = single_time / multi_time
print(
f"多线程少用 {single_time - multi_time:.2f} 秒,"
f"加速比约 {speedup:.2f} 倍。"
)
else:
print("本次多线程未体现速度优势,可能受网络波动影响。")
print("说明:两次运行时间、网络状况不同,对比仅用于课程展示。")
else:
print("耗时对比:另一版本尚未运行,暂时无法比较。")
print("请运行另一版本(1.py / 1_2.py),后运行的一版会自动打印对比。")
def main() -> None:
"""主流程:准备环境、按优先级抓页面、下载图片并输出汇总。"""
setup_logging()
prepare_image_dir()
LOGGER.info("单线程任务开始,页数上限=%d,图片上限=%d",
MAX_PAGES, MAX_IMAGES)
start_time = time.perf_counter()
print("=" * 66)
print("作业① 中国气象网图片爬虫(单线程版)")
print("作者:黄炜杰 学号:102402126")
print(f"限制:最多 {MAX_PAGES} 页、{MAX_IMAGES} 张图片;"
f"每页间隔 {PAGE_DELAY_RANGE[0]}~{PAGE_DELAY_RANGE[1]} 秒")
print(f"起始页:{START_URL}")
print("=" * 66)
# 四条队列分别对应图片文章、普通文章、栏目页、其它页。
# 列表下标就是 page_priority() 的返回值,抓取时按 0→3 的顺序取。
queues: list[deque[str]] = [deque(), deque(), deque(), deque()]
scheduled: set[str] = {START_URL}
queues[0].append(START_URL)
pages_crawled = 0
page_requests = 0
downloaded_count = 0
next_serial = 1
seen_images: set[str] = set()
first_request = True
try:
# 用请求次数控制硬上限:即使某页失败,也不会突破 26 次页面请求。
while page_requests < MAX_PAGES:
page_url = pop_next_page(queues)
if page_url is None:
print("\n[提示] 待抓队列已空,提前结束页面遍历。")
break
page_requests += 1
# 页面请求前随机限速;第一次请求不等待,避免启动无谓变慢。
if not first_request:
time.sleep(random.uniform(*PAGE_DELAY_RANGE))
first_request = False
print(f"\n[页面 {page_requests:02d}/{MAX_PAGES}] "
f"请求 {page_url}", flush=True)
html, final_url = fetch_html(page_url)
if html is None or final_url is None:
continue
try:
soup = BeautifulSoup(html, "lxml")
image_urls = extract_image_urls(soup, final_url)
added_links = enqueue_page_links(
soup, final_url, queues, scheduled
)
except Exception as exc:
# 解析失败也只影响当前页,记录日志后继续下一页。
LOGGER.exception("页面解析失败:%s", page_url)
print(f" [失败] 页面解析异常:{exc}")
continue
pages_crawled += 1
print(f" 抓取成功,发现图片 {len(image_urls)} 个,"
f"新增待抓链接 {added_links} 条")
if downloaded_count >= MAX_IMAGES:
print(" 已达到图片上限,后续只遍历页面链接。")
continue
page_new_images = 0
for image_url in image_urls:
if downloaded_count >= MAX_IMAGES:
break
if image_url in seen_images:
continue
seen_images.add(image_url)
filename = download_image(image_url, next_serial)
next_serial += 1
if filename is not None:
downloaded_count += 1
page_new_images += 1
print(f" 本页新增图片 {page_new_images} 张,"
f"累计 {downloaded_count}/{MAX_IMAGES} 张")
except KeyboardInterrupt:
print("\n[中断] 用户终止了程序,准备输出已完成的部分。")
LOGGER.warning("用户中断任务,已抓取 %d 页、%d 张图片",
pages_crawled, downloaded_count)
elapsed = time.perf_counter() - start_time
file_count = sum(
1
for path in IMAGE_DIR.iterdir()
if path.is_file() and IMAGE_FILE_PATTERN.fullmatch(path.name)
)
LOGGER.info("单线程任务结束:页数=%d,图片=%d,耗时=%.2f 秒",
pages_crawled, downloaded_count, elapsed)
print("\n" + "=" * 66)
print("作业① 单线程版 运行汇总")
print(f"实际爬取页数:{pages_crawled}(成功页数)")
print(f"页面请求次数:{page_requests}(上限 {MAX_PAGES})")
print(f"实际下载图片数:{downloaded_count}(上限 {MAX_IMAGES})")
print(f"总耗时:{elapsed:.2f} 秒")
print(f"图片目录:{IMAGE_DIR}")
print(f"失败日志:{LOG_PATH}")
if file_count != downloaded_count:
print(f"[警告] 目录中识别到 {file_count} 个图片文件,"
"与下载计数不一致,请检查保存权限。")
if pages_crawled < page_requests:
print(f"[提示] 有 {page_requests - pages_crawled} 次页面请求失败,"
"详细原因见日志。")
record_timing_and_compare(elapsed)
print("=" * 66)
if __name__ == "__main__":
main()
2. 多线程版:1_2.py
作业1/1_2.py
# -*- coding: utf-8 -*-
"""
作业①:中国气象网图片爬虫(多线程版)
作者:黄炜杰
学号:102402126
功能:
从中国气象网首页出发,按“文章页优先”的顺序抓取站内页面,
解析页面中的图片并下载到脚本同级 images/ 目录。程序会打印每个
图片 URL,并在结束时汇总页数、图片数和总耗时。
技术路线:
requests 发送 HTTP 请求 + BeautifulSoup(lxml) 解析 HTML;
页面仍然顺序抓取并限速,图片交给 ThreadPoolExecutor 并发下载;
用 threading.Lock 保护共享下载计数和控制台输出。
运行前安装依赖:
pip install requests beautifulsoup4 lxml
运行:
python 1_2.py
"""
from __future__ import annotations
import hashlib
import json
import logging
import random
import re
import threading
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Optional
from urllib.parse import urldefrag, urljoin, urlparse
import requests
from bs4 import BeautifulSoup
from bs4.element import Tag
# ==================== 爬取限制(按学号 102402126 计算) ====================
# 学号末 2 位是 26,因此总爬取页数上限设为 26 页。
# 学号末 3 位是 126,因此总下载图片数量上限设为 126 张。
# 这两个值既是课程要求,也是控制请求量、降低被封禁风险的保护措施。
MAX_PAGES = 26
MAX_IMAGES = 126
# 相邻两次页面请求之间随机等待 0.5~1 秒,模拟正常人工浏览节奏。
# 多张图片的下载发生在页面处理阶段,不会绕开这个限速。
PAGE_DELAY_RANGE = (0.5, 1.0)
# 图片下载线程数取 8:既能明显缩短耗时,又不会瞬间发出上百个并发请求。
MAX_WORKERS = 8
REQUEST_TIMEOUT = 15
TARGET_HOST = "weather.com.cn"
START_URL = "http://www.weather.com.cn/"
# 只下载常见位图格式;扩大范围会把图标、字体等无关资源也带进来。
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
# 页面内链中如果出现这些后缀,就说明不是 HTML 页面,直接跳过。
NON_PAGE_SUFFIXES = IMAGE_SUFFIXES | {
".css", ".js", ".json", ".xml", ".ico", ".svg", ".pdf",
".zip", ".rar", ".7z", ".mp3", ".mp4", ".avi", ".apk", ".exe",
}
# 统一请求头,务必带真实的 User-Agent,避免被站点当作脚本直接拒绝。
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept": (
"text/html,application/xhtml+xml,image/avif,"
"image/webp,*/*;q=0.8"
),
"Accept-Language": "zh-CN,zh;q=0.9",
}
# 所有路径都以脚本所在目录为基准,避免从 Spyder 或其它目录启动时
# 把 images 文件夹创建到意料之外的位置。
BASE_DIR = Path(__file__).resolve().parent
IMAGE_DIR = BASE_DIR / "images"
LOG_PATH = BASE_DIR / "crawler.log"
TIMING_PATH = BASE_DIR / "crawl_timing.json"
# 用于识别本程序生成的图片,重复运行时只清理这些文件。
IMAGE_FILE_PATTERN = re.compile(
r"^\d{4}_[0-9a-f]{12}\.(?:jpg|png|gif|webp|bmp)$"
)
# 模式名称会写入 crawl_timing.json,另一版本读取后即可打印耗时对比。
MODE_NAME = "多线程"
LOGGER = logging.getLogger("weather_image_crawler_multi")
# 多个下载线程会同时更新成功图片数,必须用锁保证计数准确。
COUNT_LOCK = threading.Lock()
# 多个线程同时 print 时可能出现半行拼接,用打印锁保证每行完整。
PRINT_LOCK = threading.Lock()
# 由工作线程累加,主线程读取;所有读写都在 COUNT_LOCK 保护下进行。
downloaded_count = 0
def print_line(message: str) -> None:
"""线程安全地打印一行内容,避免多线程输出互相穿插。"""
with PRINT_LOCK:
print(message, flush=True)
def setup_logging() -> None:
"""配置文件日志,把页面和图片下载失败的原因追加写入 crawler.log。"""
LOGGER.setLevel(logging.INFO)
if LOGGER.handlers:
return
try:
handler = logging.FileHandler(LOG_PATH, mode="a", encoding="utf-8")
except OSError as exc:
print(f"[警告] 无法创建日志文件:{exc}")
return
handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
)
LOGGER.addHandler(handler)
def prepare_image_dir() -> None:
"""创建 images 目录,并清理上次运行生成的图片,保证本次结果可核对。
只删除符合“4 位序号_12 位哈希.扩展名”命名的文件,不会碰用户放在
该目录里的截图或其它资料。
"""
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
removed = 0
for file_path in IMAGE_DIR.iterdir():
if not file_path.is_file():
continue
if IMAGE_FILE_PATTERN.fullmatch(file_path.name):
try:
file_path.unlink()
removed += 1
except OSError as exc:
LOGGER.warning("清理旧图片失败:%s(%s)", file_path, exc)
if removed:
print(f"[准备] 已清理上次运行生成的 {removed} 张图片。")
def normalize_page_url(raw_url: str, base_url: str) -> Optional[str]:
"""把页面中的相对链接转成绝对链接,并过滤站外和二进制资源。
返回值 None 表示该链接不进入待抓队列;返回字符串表示可以抓取。
"""
raw_url = raw_url.strip()
if not raw_url or raw_url.startswith(("javascript:", "mailto:", "tel:")):
return None
# 去掉 #锚点,避免同一个页面因为页内跳转而重复入队。
absolute_url, _ = urldefrag(urljoin(base_url, raw_url))
parsed = urlparse(absolute_url)
host = (parsed.hostname or "").lower()
if parsed.scheme not in ("http", "https"):
return None
# 只抓中国气象网主域名及其子域名,防止爬到广告站或第三方网站。
if host != TARGET_HOST and not host.endswith(f".{TARGET_HOST}"):
return None
if Path(parsed.path).suffix.lower() in NON_PAGE_SUFFIXES:
return None
# 图片频道的 *_list.shtml 是同一组图的翻页副本,跳过可减少重复图片。
if parsed.path.endswith("_list.shtml"):
return None
return absolute_url
def page_priority(page_url: str) -> int:
"""给待抓页面分级,数字越小越先抓。
0:p.weather.com.cn 的图片频道文章页,正文通常是一组高清图;
1:其它带年份的资讯/生活/视频文章页;
2:栏目首页或普通 HTML 页;
3:城市天气预报等图片重复度高的页面,放到最后。
"""
parsed = urlparse(page_url)
host = (parsed.hostname or "").lower()
path = parsed.path
if (
host.startswith("p.")
and path.endswith(".shtml")
and re.search(r"/20\d{2}/\d{2}/", path)
):
return 0
if "/weather1d/" in path or path.startswith("/weather/"):
return 3
if re.search(r"/20\d{2}/\d{2}/", path) and path.endswith(".shtml"):
return 1
if path.endswith(("/", ".shtml", ".html")):
return 2
return 3
def enqueue_page_links(
soup: BeautifulSoup,
base_url: str,
queues: list[deque[str]],
scheduled: set[str],
) -> int:
"""提取页面内链并按优先级入队,返回本次新增的链接数。"""
added = 0
for anchor in soup.find_all("a", href=True):
page_url = normalize_page_url(anchor.get("href", ""), base_url)
if page_url is None or page_url in scheduled:
continue
queues[page_priority(page_url)].append(page_url)
scheduled.add(page_url)
added += 1
return added
def pop_next_page(queues: list[deque[str]]) -> Optional[str]:
"""按优先级取出下一个页面;所有队列都为空时返回 None。"""
for queue in queues:
if queue:
return queue.popleft()
return None
def is_image_url(image_url: str) -> bool:
"""判断 URL 是否指向中国气象网内的常见格式图片。"""
parsed = urlparse(image_url)
host = (parsed.hostname or "").lower()
if parsed.scheme not in ("http", "https"):
return False
if host != TARGET_HOST and not host.endswith(f".{TARGET_HOST}"):
return False
return Path(parsed.path).suffix.lower() in IMAGE_SUFFIXES
def get_image_url(img_tag: Tag, base_url: str) -> Optional[str]:
"""从 img 的懒加载属性、src 或 srcset 中取出第一个有效图片地址。"""
candidates: list[str] = []
# 很多资讯页采用懒加载,data-src 等属性里才是原图,因此优先读取。
for attribute in ("data-src", "data-original", "data-lazy-src", "src"):
value = img_tag.get(attribute)
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
# srcset 可能是“a.jpg 1x, b.jpg 2x”,这里取第一个候选地址。
for attribute in ("srcset", "data-srcset"):
value = img_tag.get(attribute)
if isinstance(value, str) and value.strip():
first_item = value.split(",")[0].strip().split(" ")[0]
if first_item:
candidates.append(first_item)
for candidate in candidates:
if candidate.startswith("data:"):
continue
absolute_url = urljoin(base_url, candidate)
if is_image_url(absolute_url):
return absolute_url
return None
def extract_image_urls(soup: BeautifulSoup, page_url: str) -> list[str]:
"""提取当前页面全部图片 URL,并在页面内部去重。"""
image_urls: list[str] = []
seen: set[str] = set()
for img_tag in soup.find_all("img"):
image_url = get_image_url(img_tag, page_url)
if image_url is not None and image_url not in seen:
seen.add(image_url)
image_urls.append(image_url)
return image_urls
def fetch_html(url: str) -> tuple[Optional[str], Optional[str]]:
"""请求一个页面,返回 (HTML 文本, 重定向后的 URL)。
网络异常只记录日志并返回 (None, None),由主循环继续抓下一个页面,
不会因为单个页面失败而中断整个作业。
"""
try:
response = requests.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
content_type = response.headers.get("Content-Type", "").lower()
if content_type and "html" not in content_type:
LOGGER.warning("不是 HTML,已跳过:%s(%s)", url, content_type)
print(f" [跳过] 非 HTML 页面:{content_type}")
return None, None
# 气象网部分页面未在响应头声明编码,用 apparent_encoding 自动识别。
response.encoding = response.apparent_encoding or "utf-8"
return response.text, response.url
except requests.RequestException as exc:
LOGGER.warning("页面请求失败:%s(%s)", url, exc)
print(f" [失败] 页面请求异常:{exc}")
return None, None
def detect_image_suffix(content: bytes) -> Optional[str]:
"""根据文件头判断图片真实格式,避免站点把 JPEG 标成 PNG 的情况。"""
if content.startswith(b"\xff\xd8"):
return ".jpg"
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png"
if content.startswith((b"GIF87a", b"GIF89a")):
return ".gif"
if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
return ".webp"
if content.startswith(b"BM"):
return ".bmp"
return None
def download_image(image_url: str, serial: int) -> Optional[str]:
"""在线程池中下载一张图片,成功时返回保存的文件名。
文件名格式为“序号_网址哈希.扩展名”:
序号保证本次运行内不会重名;
网址哈希避免不同页面出现同名图片时互相覆盖。
"""
global downloaded_count
print_line(f"[图片 {serial:03d}] {image_url}")
digest = hashlib.md5(image_url.encode("utf-8")).hexdigest()[:12]
try:
response = requests.get(
image_url, headers=HEADERS, timeout=REQUEST_TIMEOUT
)
response.raise_for_status()
suffix = detect_image_suffix(response.content)
if suffix is None:
raise ValueError("响应内容不是可识别的图片格式")
filename = f"{serial:04d}_{digest}{suffix}"
(IMAGE_DIR / filename).write_bytes(response.content)
# 只有真正保存成功才累加,主线程据此判断是否已达到 126 张上限。
with COUNT_LOCK:
downloaded_count += 1
return filename
except requests.RequestException as exc:
LOGGER.warning("图片下载失败:%s(%s)", image_url, exc)
print_line(f" [失败] 图片请求异常:{exc}")
except (OSError, ValueError) as exc:
LOGGER.warning("图片保存失败:%s(%s)", image_url, exc)
print_line(f" [失败] 图片保存异常:{exc}")
return None
def record_timing_and_compare(elapsed: float) -> None:
"""保存本模式耗时,并在另一模式已运行过时打印单/多线程对比。"""
timing: dict[str, object] = {}
if TIMING_PATH.exists():
try:
loaded = json.loads(TIMING_PATH.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
timing = loaded
except (OSError, json.JSONDecodeError) as exc:
LOGGER.warning("读取历史耗时失败:%s", exc)
timing[MODE_NAME] = round(elapsed, 2)
timing["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
try:
TIMING_PATH.write_text(
json.dumps(timing, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as exc:
LOGGER.warning("写入耗时记录失败:%s", exc)
print(f"[警告] 无法写入耗时记录:{exc}")
single_time = timing.get("单线程")
multi_time = timing.get("多线程")
if isinstance(single_time, (int, float)) and isinstance(
multi_time, (int, float)
):
print(
f"单/多线程耗时对比:单线程 {single_time:.2f} 秒 | "
f"多线程 {multi_time:.2f} 秒"
)
if multi_time > 0 and single_time > multi_time:
speedup = single_time / multi_time
print(
f"多线程少用 {single_time - multi_time:.2f} 秒,"
f"加速比约 {speedup:.2f} 倍。"
)
else:
print("本次多线程未体现速度优势,可能受网络波动影响。")
print("说明:两次运行时间、网络状况不同,对比仅用于课程展示。")
else:
print("耗时对比:另一版本尚未运行,暂时无法比较。")
print("请运行另一版本(1.py / 1_2.py),后运行的一版会自动打印对比。")
def main() -> None:
"""主流程:顺序抓页面,把每页图片提交给线程池并发下载并汇总。"""
global downloaded_count
setup_logging()
prepare_image_dir()
with COUNT_LOCK:
downloaded_count = 0
LOGGER.info("多线程任务开始,页数上限=%d,图片上限=%d,线程数=%d",
MAX_PAGES, MAX_IMAGES, MAX_WORKERS)
start_time = time.perf_counter()
print("=" * 66)
print("作业① 中国气象网图片爬虫(多线程版)")
print("作者:黄炜杰 学号:102402126")
print(f"限制:最多 {MAX_PAGES} 页、{MAX_IMAGES} 张图片;"
f"每页间隔 {PAGE_DELAY_RANGE[0]}~{PAGE_DELAY_RANGE[1]} 秒")
print(f"并发方式:ThreadPoolExecutor,{MAX_WORKERS} 个下载线程")
print(f"起始页:{START_URL}")
print("=" * 66)
# 四条队列分别对应图片文章、普通文章、栏目页、其它页。
# 列表下标就是 page_priority() 的返回值,抓取时按 0→3 的顺序取。
queues: list[deque[str]] = [deque(), deque(), deque(), deque()]
scheduled: set[str] = {START_URL}
queues[0].append(START_URL)
pages_crawled = 0
page_requests = 0
next_serial = 1
seen_images: set[str] = set()
first_request = True
try:
with ThreadPoolExecutor(
max_workers=MAX_WORKERS, thread_name_prefix="image"
) as executor:
# 用请求次数控制硬上限:即使某页失败,也不会突破 26 次请求。
while page_requests < MAX_PAGES:
page_url = pop_next_page(queues)
if page_url is None:
print_line("\n[提示] 待抓队列已空,提前结束页面遍历。")
break
page_requests += 1
# 页面请求前随机限速;第一次请求不等待,避免启动无谓变慢。
if not first_request:
time.sleep(random.uniform(*PAGE_DELAY_RANGE))
first_request = False
print_line(f"\n[页面 {page_requests:02d}/{MAX_PAGES}] "
f"请求 {page_url}")
html, final_url = fetch_html(page_url)
if html is None or final_url is None:
continue
try:
soup = BeautifulSoup(html, "lxml")
image_urls = extract_image_urls(soup, final_url)
added_links = enqueue_page_links(
soup, final_url, queues, scheduled
)
except Exception as exc:
# 解析失败也只影响当前页,记录日志后继续下一页。
LOGGER.exception("页面解析失败:%s", page_url)
print_line(f" [失败] 页面解析异常:{exc}")
continue
pages_crawled += 1
print_line(f" 抓取成功,发现图片 {len(image_urls)} 个,"
f"新增待抓链接 {added_links} 条")
with COUNT_LOCK:
current_count = downloaded_count
if current_count >= MAX_IMAGES:
print_line(" 已达到图片上限,后续只遍历页面链接。")
continue
# 只提交剩余配额数量的图片,避免多个线程同时越界下载。
remaining = MAX_IMAGES - current_count
pending_urls: list[str] = []
for image_url in image_urls:
if len(pending_urls) >= remaining:
break
if image_url in seen_images:
continue
seen_images.add(image_url)
pending_urls.append(image_url)
if pending_urls:
print_line(
f" 提交 {len(pending_urls)} 张图片到线程池..."
)
# future 到 URL 的映射只用于异常日志,不参与计数。
futures = {}
for image_url in pending_urls:
future = executor.submit(
download_image, image_url, next_serial
)
futures[future] = image_url
# 编号按提交顺序分配;线程调度会使打印顺序略有不同。
next_serial += 1
for future in as_completed(futures):
try:
future.result()
except Exception as exc:
failed_url = futures[future]
LOGGER.exception(
"图片线程任务异常:%s", failed_url
)
print_line(f" [失败] 图片线程异常:{exc}")
with COUNT_LOCK:
page_total = downloaded_count
print_line(f" 本页处理完成,累计 {page_total}/{MAX_IMAGES} 张")
except KeyboardInterrupt:
print("\n[中断] 用户终止了程序,准备输出已完成的部分。")
LOGGER.warning("用户中断多线程任务,已抓取 %d 页、%d 张图片",
pages_crawled, downloaded_count)
elapsed = time.perf_counter() - start_time
with COUNT_LOCK:
final_count = downloaded_count
file_count = sum(
1
for path in IMAGE_DIR.iterdir()
if path.is_file() and IMAGE_FILE_PATTERN.fullmatch(path.name)
)
LOGGER.info("多线程任务结束:页数=%d,图片=%d,耗时=%.2f 秒",
pages_crawled, final_count, elapsed)
print("\n" + "=" * 66)
print("作业① 多线程版 运行汇总")
print(f"实际爬取页数:{pages_crawled}(成功页数)")
print(f"页面请求次数:{page_requests}(上限 {MAX_PAGES})")
print(f"实际下载图片数:{final_count}(上限 {MAX_IMAGES})")
print(f"总耗时:{elapsed:.2f} 秒")
print(f"图片目录:{IMAGE_DIR}")
print(f"失败日志:{LOG_PATH}")
if file_count != final_count:
print(f"[警告] 目录中识别到 {file_count} 个图片文件,"
"与下载计数不一致,请检查保存权限。")
if pages_crawled < page_requests:
print(f"[提示] 有 {page_requests - pages_crawled} 次页面请求失败,"
"详细原因见日志。")
record_timing_and_compare(elapsed)
print("=" * 66)
if __name__ == "__main__":
main()
运行截图:




(二)作业心得
这个作业最难的不是发请求,而是让 26 页额度真正花在有图片的页面上。最初按首页链接顺序抓,前面多是天气图标重复的城市页,图片数量一直不够。后来把 p.weather.com.cn 的图片文章设为最高优先级,并跳过 *_list.shtml 这类同组图副本,才让每一页都产生新图片。图片懒加载属性、网址后缀与实际格式不一致、线程池并发时计数越界,也都是实际编码中踩到的坑。多线程版让页面请求继续串行限速,只把图片下载交给 8 个线程,并用 Lock 保护成功计数;这样既遵守每页 0.5~1 秒的间隔,又比顺序下载快。最后把两版耗时写入同一个文件,后运行的一版自动打印对比,截图和核对都方便。
二、作业②:东方财富股票行情爬虫(Scrapy + XPath + MySQL)
(一)作业代码和运行图片
目标行情页由 JavaScript 动态渲染,静态 HTML 中没有完整股票表。Spider 访问行情中心实际使用的公开 JSON 接口,并把每条记录转换为 XML 的 <stock> 节点,再用 XPath 提取字段;Pipeline 负责建表、写入和全表打印。数据量在 Spider 内按 126 条硬截断。
1. Scrapy 配置:scrapy.cfg
作业2/scrapy.cfg
[settings]
default = eastmoney_stock.settings
[deploy]
project = eastmoney_stock
2. Item 定义:items.py
作业2/eastmoney_stock/items.py
# -*- coding: utf-8 -*-
"""作业②:定义东方财富股票行情 Item。
作者:黄炜杰 学号:102402126
简述:字段名与 MySQL 表字段一一对应,供 Spider 和 Pipeline 传递数据。
"""
import scrapy
class EastmoneyStockItem(scrapy.Item):
"""一条股票行情记录。
字段名沿用作业要求的英文名,因此使用 camelCase,
便于与 MySQL 列名逐字对应,避免额外映射出错。
"""
id = scrapy.Field() # 序号(1~126,写入数据库主键)
sStockNo = scrapy.Field() # 股票代码
sStockName = scrapy.Field() # 股票名称
fLatestPrice = scrapy.Field() # 最新报价
fChangeRate = scrapy.Field() # 涨跌幅(%)
fChangeAmount = scrapy.Field() # 涨跌额
fVolume = scrapy.Field() # 成交量(手)
fAmplitude = scrapy.Field() # 振幅(%)
fHigh = scrapy.Field() # 最高价
fLow = scrapy.Field() # 最低价
fOpen = scrapy.Field() # 今开
fClose = scrapy.Field() # 昨收
3. Spider:spiders/stock.py
作业2/eastmoney_stock/spiders/stock.py
# -*- coding: utf-8 -*-
"""作业②:东方财富股票行情爬虫。
作者:黄炜杰 学号:102402126
目标页:
https://quote.eastmoney.com/center/gridlist.html#hs_a_board
说明:
该行情列表由 JavaScript 动态渲染,静态 HTML 中没有完整的股票表格。
页面实际通过 push2.eastmoney.com 的公开行情接口获取数据。本爬虫访问
这个无需登录、无需 API Key 的接口,把每条 JSON 记录包装成 XML 节点,
再用 XPath 提取字段,保持作业要求的 Scrapy + XPath 技术路线。
字段映射(接口字段 -> Item 字段):
f12 -> sStockNo f14 -> sStockName f2 -> fLatestPrice
f3 -> fChangeRate f4 -> fChangeAmount f5 -> fVolume
f7 -> fAmplitude f15 -> fHigh f16 -> fLow
f17 -> fOpen f18 -> fClose
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from urllib.parse import urlencode
from xml.sax.saxutils import escape
import scrapy
from scrapy.selector import Selector
from eastmoney_stock.items import EastmoneyStockItem
# 东方财富行情中心公开数据接口。不包含 token、cookie 或 API Key。
API_URL = "https://push2.eastmoney.com/api/qt/clist/get"
# 沪深京 A 股的市场筛选参数,对应行情中心的“沪深京股市”列表。
MARKET_FILTER = (
"m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048"
)
# 只请求作业需要的字段,减少接口返回体积。
FIELDS = "f2,f3,f4,f5,f7,f12,f14,f15,f16,f17,f18"
# 学号 102402126 末 3 位是 126,作为本作业的硬性条数上限;
# 接口单页最多返回 100 条,因此最多请求两页,解析时在第 126 条硬截断。
PAGE_SIZE = 100
MAX_ITEMS = 126
def records_to_xml(records: list[dict]) -> str:
"""把 JSON 记录包装成 XML,供 Scrapy Selector 用 XPath 解析。"""
chunks = ["<stocks>"]
for record in records:
chunks.append("<stock>")
for key, value in record.items():
# 接口字段名只有 f 加数字,直接作为标签名是安全的。
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", str(key)):
continue
if value is None:
value = ""
chunks.append(f"<{key}>{escape(str(value))}</{key}>")
chunks.append("</stock>")
chunks.append("</stocks>")
return "".join(chunks)
def to_decimal(value: str | None) -> Decimal | None:
"""把接口中的数值字符串转为 Decimal,停牌等占位值返回 None。"""
if value is None:
return None
text = str(value).strip().replace(",", "")
if text in {"", "-", "--", "None"}:
return None
try:
return Decimal(text)
except InvalidOperation:
return None
def to_int(value: str | None) -> int | None:
"""把成交量等数值转为整数,空值返回 None。"""
number = to_decimal(value)
return int(number) if number is not None else None
class EastmoneyStockSpider(scrapy.Spider):
"""抓取沪深京 A 股涨幅榜前 126 条行情,并交给 Pipeline 入库。"""
name = "eastmoney_stock"
allowed_domains = ["eastmoney.com"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.page = 1
self.item_count = 0
self.total = 0
self.seen_codes: set[str] = set()
async def start(self):
"""Scrapy 2.13+ 的异步入口,兼容新版 Scrapy。"""
for request in self.start_requests():
yield request
def start_requests(self):
"""从行情接口第 1 页开始请求(兼容 Scrapy 2.13 以下)。"""
yield self._build_request(self.page)
def _build_request(self, page: int) -> scrapy.Request:
"""按页码拼接接口参数,并带上行情中心页面作为 Referer。"""
params = {
"pn": page,
"pz": PAGE_SIZE,
"po": 1, # 按涨跌幅降序
"np": 1,
"fltt": 2,
"invt": 2,
"fid": "f3",
"fs": MARKET_FILTER,
"fields": FIELDS,
}
url = f"{API_URL}?{urlencode(params)}"
return scrapy.Request(
url=url,
callback=self.parse,
headers={
"Referer": "https://quote.eastmoney.com/center/gridlist.html"
},
meta={"page": page},
dont_filter=True,
)
def parse(self, response):
"""解析一页 JSON,提取行情 Item,并按需请求下一页。"""
try:
payload = response.json()
except (ValueError, TypeError) as exc:
self.logger.error("行情接口返回的不是合法 JSON:%s", exc)
return
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, dict):
self.logger.error("行情接口 data 字段为空,停止解析")
return
records = data.get("diff")
if not isinstance(records, list) or not records:
self.logger.info("第 %d 页没有行情记录,停止翻页", self.page)
return
self.total = int(data.get("total") or 0)
xml_text = records_to_xml(records)
selector = Selector(text=xml_text, type="xml")
for stock in selector.xpath("//stock"):
if self.item_count >= MAX_ITEMS:
break
# 统一用 XPath 提取,字段缺失时返回空字符串。
stock_no = stock.xpath(
"normalize-space(f12)"
).get(default="").strip()
stock_name = stock.xpath(
"normalize-space(f14)"
).get(default="").strip()
if not stock_no or not stock_name:
self.logger.warning("跳过缺少代码或名称的记录:%s", stock_no)
continue
if stock_no in self.seen_codes:
continue
self.seen_codes.add(stock_no)
item = EastmoneyStockItem()
item["id"] = self.item_count + 1
item["sStockNo"] = stock_no
item["sStockName"] = stock_name
item["fLatestPrice"] = to_decimal(
stock.xpath("normalize-space(f2)").get()
)
item["fChangeRate"] = to_decimal(
stock.xpath("normalize-space(f3)").get()
)
item["fChangeAmount"] = to_decimal(
stock.xpath("normalize-space(f4)").get()
)
item["fVolume"] = to_int(
stock.xpath("normalize-space(f5)").get()
)
item["fAmplitude"] = to_decimal(
stock.xpath("normalize-space(f7)").get()
)
item["fHigh"] = to_decimal(
stock.xpath("normalize-space(f15)").get()
)
item["fLow"] = to_decimal(
stock.xpath("normalize-space(f16)").get()
)
item["fOpen"] = to_decimal(
stock.xpath("normalize-space(f17)").get()
)
item["fClose"] = to_decimal(
stock.xpath("normalize-space(f18)").get()
)
self.item_count += 1
yield item
# 未达到 126 条且接口还有数据时,继续请求下一页。
if (
self.item_count < MAX_ITEMS
and self.page * PAGE_SIZE < self.total
):
self.page += 1
yield self._build_request(self.page)
def closed(self, reason: str) -> None:
"""Spider 关闭时记录最终条数,便于核对 126 条上限。"""
self.logger.info(
"东方财富股票爬虫结束:共生成 %d 条 Item(上限 %d),原因=%s",
self.item_count,
MAX_ITEMS,
reason,
)
4. MySQL Pipeline:pipelines.py
作业2/eastmoney_stock/pipelines.py
# -*- coding: utf-8 -*-
"""作业②:把东方财富股票行情写入 MySQL。
作者:黄炜杰 学号:102402126
简述:自动建库建表、逐条写入 Item,并在结束后 SELECT 全表打印。
"""
from __future__ import annotations
import re
import unicodedata
from pathlib import Path
import pymysql
from scrapy.exceptions import DropItem
# MySQL 表名;与 sql/create_table.sql 中的表名保持一致。
TABLE_NAME = "eastmoney_stock"
# 按 Item 字段顺序写入,id 也显式插入,保证序号 1~126 连续。
INSERT_SQL = """
INSERT INTO `eastmoney_stock`
(`id`, `sStockNo`, `sStockName`, `fLatestPrice`, `fChangeRate`,
`fChangeAmount`, `fVolume`, `fAmplitude`, `fHigh`, `fLow`,
`fOpen`, `fClose`)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
SELECT_SQL = """
SELECT `id`, `sStockNo`, `sStockName`, `fLatestPrice`, `fChangeRate`,
`fChangeAmount`, `fVolume`, `fAmplitude`, `fHigh`, `fLow`,
`fOpen`, `fClose`
FROM `eastmoney_stock`
ORDER BY `id`
"""
# 控制台表格的表头、列宽与对齐方式;宽度按标题最大长度设置。
HEADERS = [
"id", "sStockNo", "sStockName", "fLatestPrice", "fChangeRate",
"fChangeAmount", "fVolume", "fAmplitude", "fHigh", "fLow",
"fOpen", "fClose",
]
WIDTHS = [4, 9, 14, 12, 11, 13, 9, 10, 7, 7, 7, 7]
ALIGNS = ["right", "left", "left", "right", "right", "right", "right",
"right", "right", "right", "right", "right"]
# 一行表格的总显示宽度(各列宽 + 列间空格)。
TABLE_WIDTH = sum(WIDTHS) + len(WIDTHS) - 1
def _display_width(text: str) -> int:
"""计算终端显示宽度,中文等全角字符按 2 列处理。"""
return sum(
2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
for char in text
)
def _pad(value: object, width: int, align: str) -> str:
"""按显示宽度补空格,让中文字段也能对齐。"""
text = "-" if value is None else str(value)
gap = max(0, width - _display_width(text))
if align == "right":
return " " * gap + text
return text + " " * gap
def _print_table(rows: list[tuple]) -> None:
"""把 SELECT 结果格式化成文本表格输出到控制台。"""
print("\n" + "=" * TABLE_WIDTH, flush=True)
print("MySQL 表 eastmoney_stock 全表数据", flush=True)
print("-" * TABLE_WIDTH, flush=True)
print(
" ".join(
_pad(header, width, "right" if align == "right" else "left")
for header, width, align in zip(HEADERS, WIDTHS, ALIGNS)
),
flush=True,
)
print("-" * TABLE_WIDTH, flush=True)
for row in rows:
print(
" ".join(
_pad(value, width, align)
for value, width, align in zip(row, WIDTHS, ALIGNS)
),
flush=True,
)
print("-" * TABLE_WIDTH, flush=True)
print(f"共 {len(rows)} 条记录", flush=True)
print("=" * TABLE_WIDTH, flush=True)
class MySQLPipeline:
"""负责建库建表、逐条入库和最终全表校验。"""
def __init__(self) -> None:
"""初始化数据库连接占位,真正的连接在 open_spider 中建立。"""
self.conn: pymysql.connections.Connection | None = None
self.cursor = None
self.database = "spider_homework"
def open_spider(self, spider) -> None:
"""连接 MySQL、创建数据库/目标表,并清空上一次的旧数据。"""
settings = spider.crawler.settings
self.database = settings.get("MYSQL_DATABASE", "spider_homework")
if not re.fullmatch(r"[A-Za-z0-9_]+", self.database):
raise ValueError("MYSQL_DATABASE 只能包含字母、数字和下划线")
try:
# 先不指定数据库连接,随后自动创建数据库,降低首次运行门槛。
self.conn = pymysql.connect(
host=settings.get("MYSQL_HOST", "127.0.0.1"),
port=int(settings.get("MYSQL_PORT", 3306)),
user=settings.get("MYSQL_USER", "root"),
password=settings.get("MYSQL_PASSWORD", ""),
charset=settings.get("MYSQL_CHARSET", "utf8mb4"),
autocommit=False,
)
self.cursor = self.conn.cursor()
self.cursor.execute(
f"CREATE DATABASE IF NOT EXISTS `{self.database}` "
"DEFAULT CHARACTER SET utf8mb4 "
"COLLATE utf8mb4_unicode_ci"
)
self.conn.select_db(self.database)
self._create_table(settings)
self.cursor.execute(f"TRUNCATE TABLE `{TABLE_NAME}`")
self.conn.commit()
spider.logger.info(
"MySQL 就绪:数据库=%s,表=%s(已清空旧数据)",
self.database,
TABLE_NAME,
)
except pymysql.MySQLError as exc:
spider.logger.error("MySQL 初始化失败:%s", exc)
raise
def _create_table(self, settings) -> None:
"""读取独立 sql 文件,执行其中的 CREATE TABLE 语句。"""
sql_path = Path(settings.get("SQL_FILE"))
if not sql_path.exists():
raise FileNotFoundError(f"建表 SQL 不存在:{sql_path}")
raw_sql = sql_path.read_text(encoding="utf-8")
# 去掉整行注释后在分号处切分;本项目的 SQL 中没有复杂存储过程。
no_comment = "\n".join(
line for line in raw_sql.splitlines()
if not line.strip().startswith("--")
)
for statement in no_comment.split(";"):
statement = statement.strip()
if not statement:
continue
upper = statement.upper()
# 数据库名以 settings.py 为准,避免 SQL 文件与配置不一致。
if upper.startswith("CREATE DATABASE") or upper.startswith("USE "):
continue
self.cursor.execute(statement)
def process_item(self, item, spider):
"""把单条 Item 写入 MySQL;失败时记录日志并丢弃这一条。"""
values = (
item.get("id"),
item.get("sStockNo"),
item.get("sStockName"),
item.get("fLatestPrice"),
item.get("fChangeRate"),
item.get("fChangeAmount"),
item.get("fVolume"),
item.get("fAmplitude"),
item.get("fHigh"),
item.get("fLow"),
item.get("fOpen"),
item.get("fClose"),
)
try:
self.cursor.execute(INSERT_SQL, values)
self.conn.commit()
except pymysql.MySQLError as exc:
self.conn.rollback()
spider.logger.error(
"写入失败,股票代码=%s,原因=%s",
item.get("sStockNo"),
exc,
)
raise DropItem(f"写入 MySQL 失败:{item.get('sStockNo')}") from exc
return item
def close_spider(self, spider) -> None:
"""查询全表并按表格打印,随后关闭数据库连接。"""
if self.conn is None:
return
try:
self.cursor.execute(SELECT_SQL)
rows = list(self.cursor.fetchall())
_print_table(rows)
except pymysql.MySQLError as exc:
spider.logger.error("查询全表失败:%s", exc)
finally:
if self.cursor is not None:
self.cursor.close()
self.conn.close()
self.conn = None
5. 配置:settings.py
作业2/eastmoney_stock/settings.py
# -*- coding: utf-8 -*-
"""作业②:东方财富股票爬虫配置。
作者:黄炜杰 学号:102402126
简述:配置下载行为、MySQL 连接参数和 Item Pipeline。
运行前必须修改 MySQL 用户名、密码等连接参数。
"""
from pathlib import Path
BOT_NAME = "eastmoney_stock"
SPIDER_MODULES = ["eastmoney_stock.spiders"]
NEWSPIDER_MODULE = "eastmoney_stock.spiders"
# 行情接口是东方财富行情中心页面实际使用的公开 JSON 数据接口,
# 站点 robots.txt 禁止直接访问该接口;本作业只采集 126 条用于课程练习,
# 已把并发降到 1、页间延时设为 1 秒,避免对服务器造成压力。
ROBOTSTXT_OBEY = False
# 只抓两页行情接口,单线程、低频访问,降低触发限流的概率。
CONCURRENT_REQUESTS = 1
DOWNLOAD_DELAY = 1.0
COOKIES_ENABLED = False
TELNETCONSOLE_ENABLED = False
REMOTE_CONTROL_ENABLED = False
DEFAULT_REQUEST_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://quote.eastmoney.com/center/gridlist.html",
}
ITEM_PIPELINES = {
"eastmoney_stock.pipelines.MySQLPipeline": 300,
}
FEED_EXPORT_ENCODING = "utf-8"
LOG_LEVEL = "INFO"
DOWNLOAD_TIMEOUT = 20
# ==================== MySQL 连接配置 ====================
# 运行前替换:MYSQL_USER 改成本机 MySQL 用户,
# MYSQL_PASSWORD 改成本机 MySQL 密码;数据库不存在时 Pipeline 会自动创建。
MYSQL_HOST = "127.0.0.1"
MYSQL_PORT = 3306
MYSQL_USER = "root"
MYSQL_PASSWORD = "请替换为你的MySQL密码"
MYSQL_DATABASE = "spider_homework"
MYSQL_CHARSET = "utf8mb4"
# 表结构文件,Pipeline 会自动读取并执行其中的 CREATE TABLE。
SQL_FILE = Path(__file__).resolve().parent.parent / "sql" / "create_table.sql"
6. 建表 SQL:sql/create_table.sql
作业2/sql/create_table.sql
-- 东方财富股票行情表
-- 作者:黄炜杰 学号:102402126
-- 说明:可直接在 MySQL 客户端执行;Scrapy Pipeline 也会读取本文件,
-- 自动创建数据库和数据表。运行前请确认 MySQL 配置。
CREATE DATABASE IF NOT EXISTS `spider_homework`
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `spider_homework`;
CREATE TABLE IF NOT EXISTS `eastmoney_stock` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '序号',
`sStockNo` VARCHAR(10) NOT NULL COMMENT '股票代码',
`sStockName` VARCHAR(50) NOT NULL COMMENT '股票名称',
`fLatestPrice` DECIMAL(12, 3) DEFAULT NULL COMMENT '最新报价',
`fChangeRate` DECIMAL(10, 3) DEFAULT NULL COMMENT '涨跌幅(%)',
`fChangeAmount` DECIMAL(12, 3) DEFAULT NULL COMMENT '涨跌额',
`fVolume` BIGINT DEFAULT NULL COMMENT '成交量(手)',
`fAmplitude` DECIMAL(10, 3) DEFAULT NULL COMMENT '振幅(%)',
`fHigh` DECIMAL(12, 3) DEFAULT NULL COMMENT '最高',
`fLow` DECIMAL(12, 3) DEFAULT NULL COMMENT '最低',
`fOpen` DECIMAL(12, 3) DEFAULT NULL COMMENT '今开',
`fClose` DECIMAL(12, 3) DEFAULT NULL COMMENT '昨收',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sStockNo` (`sStockNo`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci
COMMENT = '东方财富股票行情';
运行截图:

(二)作业心得
东方财富行情页打开时能看到完整表格,但查看网页源代码却没有股票行,原因是表格由 JavaScript 请求接口后渲染。这个差异让我改了思路:不再硬套 HTML 选择器,而是抓行情中心实际使用的公开接口。接口返回 JSON,为了仍按作业要求使用 XPath,我把每条记录包成 XML 的 stock 节点,再用 XPath 取 f12、f14、f2 等字段。接口单页最多 100 条,我在 Spider 中设置计数器,在第 126 条截断,避免为了凑数继续请求。入库时有两个细节:价格必须用 Decimal,不能直接转 float;重复运行前要清空旧表,否则会累加。Pipeline 把建表、插入和最后的 SELECT 全表打印串成一条链。
三、作业③:中国银行外汇牌价爬虫(Scrapy + XPath + MySQL)
(一)作业代码和运行图片
中国银行外汇牌价页是静态 HTML,使用 table#priceTable 的 XPath 定位数据行。只入库作业要求的 6 个字段,页面中的发布日期与发布时间合并为 Time。
1. Scrapy 配置:scrapy.cfg
作业3/scrapy.cfg
[settings]
default = boc_forex.settings
[deploy]
project = boc_forex
2. Item 定义:items.py
作业3/boc_forex/items.py
# -*- coding: utf-8 -*-
"""作业③:定义中国银行外汇牌价 Item。
作者:黄炜杰 学号:102402126
简述:字段与 MySQL 表完全对应,共 Currency/TBP/CBP/TSP/CSP/Time 六项。
"""
import scrapy
class BocForexItem(scrapy.Item):
"""一条外汇牌价记录。"""
Currency = scrapy.Field() # 币种
TBP = scrapy.Field() # 现汇买入价
CBP = scrapy.Field() # 现钞买入价
TSP = scrapy.Field() # 现汇卖出价
CSP = scrapy.Field() # 现钞卖出价
Time = scrapy.Field() # 发布日期 + 发布时间
3. Spider:spiders/boc.py
作业3/boc_forex/spiders/boc.py
# -*- coding: utf-8 -*-
"""作业③:中国银行外汇牌价爬虫。
作者:黄炜杰 学号:102402126
目标页:https://www.boc.cn/sourcedb/whpj/
页面结构:
<table id="priceTable"> 中,每一行 tr[td] 是一种货币:
td[1] 货币名称、td[2] 现汇买入价、td[3] 现钞买入价、
td[4] 现汇卖出价、td[5] 现钞卖出价、td[6] 中行折算价、
td[7] 发布日期 + 发布时间、td[8] 发布时间。
Item 只需要 6 个字段,所以 td[1]~td[5],以及 td[7] 的完整时间。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal, InvalidOperation
import scrapy
from scrapy.selector import Selector
from boc_forex.items import BocForexItem
# 中国银行外汇牌价列表页。
START_URL = "https://www.boc.cn/sourcedb/whpj/"
# 只选带 td 的数据行,自动跳过表头 thead/tr[th]。
TABLE_XPATH = '//table[@id="priceTable"]//tr[td]'
def to_decimal(value: str) -> Decimal | None:
"""把牌价字符串转为 Decimal;空值或“--”返回 None。"""
text = value.strip().replace(",", "")
if text in {"", "-", "--", "None"}:
return None
try:
return Decimal(text)
except InvalidOperation:
return None
def to_datetime(value: str) -> datetime | None:
"""解析“2026/11/07 10:30:00”形式的发布日期和发布时间。"""
text = value.strip().replace("/", "-")
for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(text, pattern)
except ValueError:
continue
return None
class BocForexSpider(scrapy.Spider):
"""抓取中国银行外汇牌价表中的全部币种。"""
name = "boc_forex"
allowed_domains = ["boc.cn"]
async def start(self):
"""Scrapy 2.13+ 的异步入口,兼容新版 Scrapy。"""
for request in self.start_requests():
yield request
def start_requests(self):
"""请求外汇牌价列表页(兼容 Scrapy 2.13 以下)。"""
yield scrapy.Request(
START_URL,
callback=self.parse,
headers={"Referer": "https://www.boc.cn/sourcedb/whpj/"},
)
@staticmethod
def _cell(row, index: int) -> str:
"""读取一行中第 index 个 td 的所有文本,并清理多余空白。"""
parts = row.xpath(f"./td[{index}]//text()").getall()
return " ".join(part.strip() for part in parts if part.strip())
def parse(self, response):
"""用 XPath 定位数据行并逐行生成 Item。"""
# 服务器响应头未声明 charset,直接按页面实际使用的 UTF-8 解码,
# 避免 Scrapy 按 ISO-8859-1 解码导致中文币种乱码。
selector = Selector(
text=response.body.decode("utf-8", errors="replace"),
type="html",
)
rows = selector.xpath(TABLE_XPATH)
item_count = 0
for row in rows:
currency = self._cell(row, 1)
if not currency or currency == "货币名称":
continue
raw_time = self._cell(row, 7)
publish_time = to_datetime(raw_time)
if publish_time is None:
self.logger.warning(
"跳过时间格式异常的行:%s,时间=%s", currency, raw_time
)
continue
item = BocForexItem()
item["Currency"] = currency
item["TBP"] = to_decimal(self._cell(row, 2))
item["CBP"] = to_decimal(self._cell(row, 3))
item["TSP"] = to_decimal(self._cell(row, 4))
item["CSP"] = to_decimal(self._cell(row, 5))
item["Time"] = publish_time
item_count += 1
yield item
if item_count == 0:
self.logger.error("没有解析到外汇牌价,请检查页面结构是否变化")
else:
self.logger.info("外汇牌价解析完成,共 %d 条", item_count)
def closed(self, reason: str) -> None:
"""爬虫关闭时提示本次任务结束。"""
self.logger.info("中国银行外汇爬虫结束,原因=%s", reason)
4. MySQL Pipeline:pipelines.py
作业3/boc_forex/pipelines.py
# -*- coding: utf-8 -*-
"""作业③:把中国银行外汇牌价写入 MySQL。
作者:黄炜杰 学号:102402126
简述:自动建库建表、逐条写入 Item,并在结束后 SELECT 全表打印。
"""
from __future__ import annotations
import re
import unicodedata
from pathlib import Path
import pymysql
from scrapy.exceptions import DropItem
# MySQL 表名;与 sql/create_table.sql 中的表名保持一致。
TABLE_NAME = "boc_forex"
# 严格按作业要求的 6 个字段插入,不额外增加 id 或折算价列。
INSERT_SQL = """
INSERT INTO `boc_forex`
(`Currency`, `TBP`, `CBP`, `TSP`, `CSP`, `Time`)
VALUES (%s, %s, %s, %s, %s, %s)
"""
SELECT_SQL = """
SELECT `Currency`, `TBP`, `CBP`, `TSP`, `CSP`, `Time`
FROM `boc_forex`
ORDER BY `Currency`
"""
# 控制台表格的表头、列宽与对齐方式;宽度按标题最大长度设置。
HEADERS = ["Currency", "TBP", "CBP", "TSP", "CSP", "Time"]
WIDTHS = [18, 13, 13, 13, 13, 21]
ALIGNS = ["left", "right", "right", "right", "right", "left"]
# 一行表格的总显示宽度(各列宽 + 列间空格)。
TABLE_WIDTH = sum(WIDTHS) + len(WIDTHS) - 1
def _display_width(text: str) -> int:
"""计算终端显示宽度,中文等全角字符按 2 列处理。"""
return sum(
2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
for char in text
)
def _pad(value: object, width: int, align: str) -> str:
"""按显示宽度补空格,让中文币种也能和数字列对齐。"""
if hasattr(value, "strftime"):
text = value.strftime("%Y-%m-%d %H:%M:%S")
else:
text = "-" if value is None else str(value)
gap = max(0, width - _display_width(text))
if align == "right":
return " " * gap + text
return text + " " * gap
def _print_table(rows: list[tuple]) -> None:
"""把 SELECT 结果格式化成文本表格输出到控制台。"""
print("\n" + "=" * TABLE_WIDTH, flush=True)
print("MySQL 表 boc_forex 全表数据", flush=True)
print("-" * TABLE_WIDTH, flush=True)
print(
" ".join(
_pad(header, width, "right" if align == "right" else "left")
for header, width, align in zip(HEADERS, WIDTHS, ALIGNS)
),
flush=True,
)
print("-" * TABLE_WIDTH, flush=True)
for row in rows:
print(
" ".join(
_pad(value, width, align)
for value, width, align in zip(row, WIDTHS, ALIGNS)
),
flush=True,
)
print("-" * TABLE_WIDTH, flush=True)
print(f"共 {len(rows)} 条记录", flush=True)
print("=" * TABLE_WIDTH, flush=True)
class MySQLPipeline:
"""负责建库建表、逐条入库和最终全表校验。"""
def __init__(self) -> None:
"""初始化数据库连接占位,真正的连接在 open_spider 中建立。"""
self.conn: pymysql.connections.Connection | None = None
self.cursor = None
self.database = "spider_homework"
def open_spider(self, spider) -> None:
"""连接 MySQL、创建数据库/目标表,并清空旧数据。"""
settings = spider.crawler.settings
self.database = settings.get("MYSQL_DATABASE", "spider_homework")
if not re.fullmatch(r"[A-Za-z0-9_]+", self.database):
raise ValueError("MYSQL_DATABASE 只能包含字母、数字和下划线")
try:
self.conn = pymysql.connect(
host=settings.get("MYSQL_HOST", "127.0.0.1"),
port=int(settings.get("MYSQL_PORT", 3306)),
user=settings.get("MYSQL_USER", "root"),
password=settings.get("MYSQL_PASSWORD", ""),
charset=settings.get("MYSQL_CHARSET", "utf8mb4"),
autocommit=False,
)
self.cursor = self.conn.cursor()
self.cursor.execute(
f"CREATE DATABASE IF NOT EXISTS `{self.database}` "
"DEFAULT CHARACTER SET utf8mb4 "
"COLLATE utf8mb4_unicode_ci"
)
self.conn.select_db(self.database)
self._create_table(settings)
self.cursor.execute(f"TRUNCATE TABLE `{TABLE_NAME}`")
self.conn.commit()
spider.logger.info(
"MySQL 就绪:数据库=%s,表=%s(已清空旧数据)",
self.database,
TABLE_NAME,
)
except pymysql.MySQLError as exc:
spider.logger.error("MySQL 初始化失败:%s", exc)
raise
def _create_table(self, settings) -> None:
"""读取独立 sql 文件,执行其中的 CREATE TABLE 语句。"""
sql_path = Path(settings.get("SQL_FILE"))
if not sql_path.exists():
raise FileNotFoundError(f"建表 SQL 不存在:{sql_path}")
raw_sql = sql_path.read_text(encoding="utf-8")
no_comment = "\n".join(
line for line in raw_sql.splitlines()
if not line.strip().startswith("--")
)
for statement in no_comment.split(";"):
statement = statement.strip()
if not statement:
continue
upper = statement.upper()
if upper.startswith("CREATE DATABASE") or upper.startswith("USE "):
continue
self.cursor.execute(statement)
def process_item(self, item, spider):
"""把单条 Item 写入 MySQL;失败时记录日志并丢弃这一条。"""
values = (
item.get("Currency"),
item.get("TBP"),
item.get("CBP"),
item.get("TSP"),
item.get("CSP"),
item.get("Time"),
)
try:
self.cursor.execute(INSERT_SQL, values)
self.conn.commit()
except pymysql.MySQLError as exc:
self.conn.rollback()
spider.logger.error(
"写入失败,币种=%s,原因=%s",
item.get("Currency"),
exc,
)
raise DropItem(f"写入 MySQL 失败:{item.get('Currency')}") from exc
return item
def close_spider(self, spider) -> None:
"""查询全表并按表格打印,随后关闭数据库连接。"""
if self.conn is None:
return
try:
self.cursor.execute(SELECT_SQL)
rows = list(self.cursor.fetchall())
_print_table(rows)
except pymysql.MySQLError as exc:
spider.logger.error("查询全表失败:%s", exc)
finally:
if self.cursor is not None:
self.cursor.close()
self.conn.close()
self.conn = None
5. 配置:settings.py
作业3/boc_forex/settings.py
# -*- coding: utf-8 -*-
"""作业③:中国银行外汇牌价爬虫配置。
作者:黄炜杰 学号:102402126
简述:配置下载行为、MySQL 连接参数和 Item Pipeline。
运行前必须修改 MySQL 用户名、密码等连接参数。
"""
from pathlib import Path
BOT_NAME = "boc_forex"
SPIDER_MODULES = ["boc_forex.spiders"]
NEWSPIDER_MODULE = "boc_forex.spiders"
# 中国银行外汇牌价页是公开静态页面;本作业只访问一次列表页,
# 因此关闭 robots 协议的额外请求,减少对服务器的无用访问。
ROBOTSTXT_OBEY = False
CONCURRENT_REQUESTS = 1
DOWNLOAD_DELAY = 1.0
COOKIES_ENABLED = False
TELNETCONSOLE_ENABLED = False
REMOTE_CONTROL_ENABLED = False
DEFAULT_REQUEST_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
}
ITEM_PIPELINES = {
"boc_forex.pipelines.MySQLPipeline": 300,
}
FEED_EXPORT_ENCODING = "utf-8"
LOG_LEVEL = "INFO"
DOWNLOAD_TIMEOUT = 20
# ==================== MySQL 连接配置 ====================
# 运行前替换:MYSQL_USER 改成本机 MySQL 用户,
# MYSQL_PASSWORD 改成本机 MySQL 密码;数据库不存在时 Pipeline 会自动创建。
MYSQL_HOST = "127.0.0.1"
MYSQL_PORT = 3306
MYSQL_USER = "root"
MYSQL_PASSWORD = "请替换为你的MySQL密码"
MYSQL_DATABASE = "spider_homework"
MYSQL_CHARSET = "utf8mb4"
# 表结构文件,Pipeline 会自动读取并执行其中的 CREATE TABLE。
SQL_FILE = Path(__file__).resolve().parent.parent / "sql" / "create_table.sql"
6. 建表 SQL:sql/create_table.sql
作业3/sql/create_table.sql
-- 中国银行外汇牌价表
-- 作者:黄炜杰 学号:102402126
-- 说明:可直接在 MySQL 客户端执行;Scrapy Pipeline 也会读取本文件,
-- 自动创建数据库和数据表。运行前请确认 MySQL 配置。
CREATE DATABASE IF NOT EXISTS `spider_homework`
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `spider_homework`;
CREATE TABLE IF NOT EXISTS `boc_forex` (
`Currency` VARCHAR(32) NOT NULL COMMENT '币种',
`TBP` DECIMAL(12,4) DEFAULT NULL COMMENT '现汇买入价',
`CBP` DECIMAL(12,4) DEFAULT NULL COMMENT '现钞买入价',
`TSP` DECIMAL(12,4) DEFAULT NULL COMMENT '现汇卖出价',
`CSP` DECIMAL(12,4) DEFAULT NULL COMMENT '现钞卖出价',
`Time` DATETIME NOT NULL COMMENT '发布日期+发布时间',
UNIQUE KEY `uk_currency_time` (`Currency`, `Time`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci
COMMENT = '中国银行外汇牌价';
运行截图:



(二)作业心得
外汇页面结构简单,但有两个容易忽略的问题。第一,响应头没有正确声明 charset,直接按默认编码会得到乱码,所以 Spider 按 UTF-8 解码后再用 XPath。第二,页面把发布日期和发布时间拆成两列,我只保留作业要求的 Time 字段,把两者合并成 DATETIME,既符合 6 字段要求,也便于排序。表里有部分币种的现汇价为空,我没有把空字符串塞进 DECIMAL,而是转成 NULL;入库前清空旧表,避免重复运行出现重复币种。做完后,我对 Scrapy 的 Item、Pipeline 和 XPath 分工更清楚了:Spider 负责定位和清洗字段,数据库写入、建表、异常处理交给 Pipeline,职责分开后也更好维护。

浙公网安备 33010602011771号