资讯动态

Python爬取当当网图书价格实战:从请求到价格分布分析

发布时间:2026/9/16 22:06:08 来源:尧图企业网站定制
爬当当网图书价格这件事其实是我自己买书时憋出来的需求。那阵子想系统补一批日本相关题材的书结果发现购物车里定金划算、平装划算、电子版划算反复横跳越挑越糊涂。与其凭感觉猜不如直接把搜索页全量扒下来看看定价到底怎么分布的。于是就有了这个用 Python 爬取当当网日本相关图书、再做价格分布分析的实战项目。这篇文章我会把完整链路拆开讲清楚数据源为什么选当当、目标页面的 URL 规律和页面结构怎么分析、爬虫代码怎么写得既简单又能稳定跑完千本量级、遇到反爬怎么办、最后价格数据怎么清洗和分析出有价值的结论。中间会穿插大量实际踩坑记录和调试思路不是那种跑通就完事的 Demo而是能真正落地跑完一轮的完整方案。如果你是刚入门爬虫、想找一个结构规整又有点挑战性的练手项目或者想搞清楚抓下来之后怎么变成结论这篇应该能给你一套能直接照着做的思路。## 1. 为什么是当当网图书爬虫数据源选型的真实考量1.1 图书类目相比其他品类的天然优势做爬虫项目选数据源往往比写代码更影响最终体验。图书类目在电商里是非常适合新手接触的因为信息结构高度标准化——每本书有固定的书名、作者、出版社、ISBN、定价和促销价字段不像服装、数码那么杂。更重要的是当当网搜索列表页不需要登录就能访问也没有强校验的动态渲染数据直接嵌在 HTML 里requests 加 BeautifulSoup 就能搞定不需要上 Playwright 或 Selenium。我最初也对比过京东和豆瓣。京东的图书分类搜索需要处理复杂的反爬和登录墙豆瓣的图书数据虽然规整但价格信息不全而且豆瓣的更新频率对价格分布这个目标来说参考意义有限。当当作为老牌图书电商它的搜索页包含日本关键词的结果数量、商品详情的完整度、促销标签的丰富程度都更适合做价格分析。2026年实测下来搜索结果热词下能覆盖到上千本图书样本量足够支撑统计结论。1.2 目标路径与页面结构的初步判断当当 PC 端搜索页的 URL 结构非常典型核心参数就是关键词和页码http://search.dangdang.com/?key日本page_index1actinputkey是搜索词page_index是页码。由这个规律就能推导出只要知道总页数就能循环抓取所有结果。搜索结果的商品列表在 HTML 中的特征也比较固定每个商品项对应一个li标签里面能提取到书名、作者、价格、评论数等字段。我实际测试时发现不同关键词可能触发不同的列表渲染方式。比如搜日本时默认列表是ul.bigimg双行大图模式而且偶尔会在正常商品里混入品牌闪购排行榜推荐之类的模块提取时最好通过选择器锁死商品项标签同时也让后续的数据清洗环节做一次白名单过滤。这种目标明确但需要处理干扰元素的特点恰好是爬虫项目里最具代表性的场景。1.3 技术选型用 requests 而不是 Scrapy 的理由这个项目规模是千本级别请求量撑死几十页、几百次完全在 requests 的舒适区内。Scrapy 的优势在于大并发、分布式、管道化但在这里属于杀鸡用牛刀而且调试成本更高。我最终选定 requests BeautifulSoup pandas 的组合逻辑是每个库只干一件事出问题容易定位。后续如果想扩展成全网比价或者定时监控再考虑迁移到 Scrapy 也不迟。依赖清单很简单pip install requests beautifulsoup4 lxml pandas matplotliblxml 作为 BeautifulSoup 的解析引擎一定要装默认的 html.parser 在解析当当这种标签不太规范的页面时会比较慢容易出现结构识别错误。matplotlib 用来画价格分布图如果你只是想看数字用 pandas 的 describe 就够了。## 2. 动手前先拆目标URL规律、页面元素与字段设计2.1 请求头与翻页规律分析当当对请求头有一定要求但不算苛刻。我实测必须带上完整的User-Agent和Referer否则容易触发风控。headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36, Referer: http://search.dangdang.com/?key日本page_index1actinput, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }翻页逻辑我在 2026 年初测试时的观察是超过某个页码后继续翻仍然能拿到有效数据但单页商品数量可能不稳定。稳妥的做法是先请求第一页从返回的 HTML 里解析出总页数或者上一页/下一页按钮的链接再决定是否继续翻页。这里有个细节直接按顺序翻页时页码为 1 的 URL 和页码为 2 的 URL 格式略有差异。第一页常常在page_index缺省时也能访问但如果你的循环从 1 开始且显式带上page_index1结果是一样的没有任何问题。我习惯从 1 开始显式构造页码便于日志记录和断点续爬。2.2 商品列表的 HTML 结构拆解用开发者工具检查当当搜索结果页能发现商品项主要分布在ul.bigimg或ul.list_ll这样的列表中。每个li标签对应一本书典型结构包括li a classpic href... img>import csv import sqlite3 from datetime import datetime def init_db(): conn sqlite3.connect(dangdang_books.db) conn.execute( CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT, title TEXT, detail_url TEXT UNIQUE, author TEXT, now_price REAL, original_price REAL, comment_count INTEGER, page INTEGER, crawl_time TEXT ) ) return conn注意detail_url加了UNIQUE约束这是天然的去重机制——当搜索页翻页时大概率出现重复商品插入时用INSERT OR IGNORE就能防止同一本书被重复统计。## 3. 爬虫主程序从单页解析到千本量级的稳定实现3.1 单页请求与解析先打通最小闭环写爬虫最大的误区是一上来就想写一个完整的循环。我每次都是先单页、再循环、再并发、再容错每一步都在真实页面上验证后才会进入下一阶段。单页解析的核心代码如下import re import time import random import requests from bs4 import BeautifulSoup def parse_list_page(html): soup BeautifulSoup(html, lxml) items [] # 图书列表容器 for li in soup.select(ul.bigimg li, ul.list_ll li): title_tag li.select_one(p.name a) if not title_tag: continue title title_tag.get(title, ).strip() detail_url title_tag.get(href, ).strip() price_tag li.select_one(span.search_now_price) now_price None if price_tag: now_price _parse_price(price_tag.get_text()) pre_price_tag li.select_one(span.search_pre_price) original_price None if pre_price_tag: original_price _parse_price(pre_price_tag.get_text()) author_tag li.select_one(p.search_book_author) author author_tag.get_text( , stripTrue) if author_tag else comment_tag li.select_one(p.search_star_line a, p.search_comment_num a) comment_count None if comment_tag: comment_count _parse_comment(comment_tag.get_text()) items.append({ title: title, detail_url: detail_url, now_price: now_price, original_price: original_price, author: author, comment_count: comment_count, }) return items def _parse_price(text): # 页面价格类似 ¥39.40去掉货币符号和空格 match re.search(r(\d\.?\d*), text.replace(¥, ).strip()) return float(match.group(1)) if match else None def _parse_comment(text): match re.search(r(\d), text) return int(match.group(1)) if match else None_parse_price里的正则只保留数字和小数点这样不管页面里带不带¥符号、有没有空格都能兼容。价格单位页面一般是元但也有可能出现¥39.40这种带两位小数的值直接转 float 就行。3.2 请求与解析的一体化封装单页解析写好之后把它和请求逻辑封装在一起def fetch_page(keyword, page): url http://search.dangdang.com/ params { key: keyword, page_index: page, act: input, } resp requests.get(url, paramsparams, headersheaders, timeout15) resp.raise_for_status() # 当当页面编码是 GBKrequests 可能猜错 resp.encoding resp.apparent_encoding return resp.text这里resp.encoding resp.apparent_encoding非常关键。当当的搜索页声明是 GBK如果 requests 默认按 ISO-8859-1 解析书名和作者会大量乱码。apparent_encoding会从内容推断真实编码实测能避免大部分乱码问题。但偶尔页面部分模块是 UTF-8这个推断也会失误所以我在解析时还会对乱码情况做一次后置校验。3.3 分页遍历、限速与 User-Agent 池千本量级意味着要抓几十页数据。如果每页无间隔地猛请求很容易触发风控。我的策略是每次请求后随机 sleep 0.8~1.8 秒同时准备一个 User-Agent 池轮换。USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/121.0, ] def get_headers(keyword, page): return { User-Agent: random.choice(USER_AGENTS), Referer: fhttp://search.dangdang.com/?key{keyword}page_index{max(page-1, 1)}actinput, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }有朋友问为什么不直接把并发拉满比如用多线程一次开 20 个请求。对当当这个场景我明确不建议。图书搜索结果页服务器响应并不慢耗时主要花在网络往返和解析上单线程加上 1 秒左右的延迟100 页数据也就几分钟跑完。把并发拉高不仅对目标服务器不友好自己 IP 被封的风险也会指数级上升。做爬虫这个事克制和伪装永远比暴力重要。3.4 断点续爬与异常处理设计程序跑在真实网络上会遇到超时、连接重置、被重定向到验证页等状况。我在外层套了一个带重试的调用逻辑def fetch_with_retry(keyword, page, retries3): for attempt in range(retries): try: html fetch_page(keyword, page) if 请输入验证码 in html or len(html) 2000: raise RuntimeError(可能触发验证码) return html except Exception as e: print(f[{page}] 第{attempt 1}次请求失败: {e}) time.sleep(2 * (attempt 1) random.random()) return None当页面内容异常短或者出现验证码字样时直接抛异常然后指数退避重试。连续失败 3 次就放弃当前页记录日志后跳到下一页。这样的容错逻辑保证了即使中途触发风控程序也能继续运行顶多缺少几页数据不可能全线崩溃。真正的断点续爬我靠的是 SQLite 的UNIQUE约束和page字段。每次抓完一页就立即写入数据库不需要等到全部分析完才落盘。如果程序中断下次重新运行时先查询数据库里已有的detail_url已经存在就直接跳过。这样无论跑多少轮数据都不会重复。## 4. 反爬应对与稳定性保障千本量级能不能跑完全看这里4.1 当当风控的实测表现很多人把电商反爬想象得过于夸张实际测试下来当当图书搜索页的反爬更像是一套松紧带机制。短时间高频请求会被重定向到验证页但只要把请求频率控制在每 1~2 秒一次配合正常浏览器请求头跑上千本数据基本不会触发强烈风控。我最初测试时没有加延时连续快速请求十几页就发现返回的 HTML 变成了一个验证页面特征十分明显标题里有当当网登录或者页面上出现滑块验证。后来加上了随机延时跑完整个项目再没遇到过强制验证。当然如果目标 IP 本身是数据中心 IP比如云服务器可能初始权重就低更容易被拦截这属于环境因素。家用宽带 IP 的实测表现稳定得多。4.2 代理 IP 的必要性判断我的结论是这个场景不需要代理。理由很直接——请求量不大、频率不高家用 IP 完全能胜任。为千本数据去买代理池成本上不划算还可能因为代理 IP 本身被平台标记反而增加验证概率。下面是判断是否需要代理的一个实用对照信号不需要代理建议使用代理单次任务请求量千次以内十万次以上请求频率每秒 1 次以内每秒 10 次以上目标反爬强度无验证码滑块/短信验证频繁业务性质一次性分析长期监控如果你只是学习或做一次性分析完全没必要花钱上代理。真正的风控触发通常和请求频率挂钩而不是请求总量。把请求节奏控制好比任何代理都管用。4.3 状态码与验证页识别反爬处理的核心是早发现、早退避。我在代码里设了多道防线第一道看 HTTP 状态码。403/418/429 都意味着请求被拒需要退避重试。第二道看响应内容。即使状态码是 200如果 HTML 里没有商品列表结构或者出现访问过于频繁、验证码关键词也要按失败处理。def is_valid_list_page(html): if len(html) 3000: return False if search_now_price not in html and list_ll not in html: return False if 验证码 in html or 访问过于频繁 in html: return False return True这个函数放在fetch_with_retry里一旦返回 False 就触发重试逻辑。胜在简单直接每个条件都是实测中遇到过的真实情况。4.4 日志与监控怎么知道程序还活着爬虫跑起来后最怕的不是报错而是程序看起来在运行、实际一直在抓空页面。我养成了一个习惯每抓完一页就在控制台输出当前页码、本页解析到多少条、数据库累计多少条。同时定期打印最近几条数据的标题样例用来快速确认没有乱码、没有抓错结构。total 0 for page in range(1, max_page 1): html fetch_with_retry(日本, page) if html is None: print(f跳过第 {page} 页) continue items parse_list_page(html) saved save_to_db(items, keyword日本, pagepage) total saved print(f[page {page}] 解析到 {len(items)} 条新入库 {saved} 条累计 {total} 条) time.sleep(random.uniform(0.8, 1.8))这个输出看似简单但实际排障价值极大。我遇到过一次页码已经 80但每页只解析出两三条数据的现象一看日志就发现是列表结构异常及时止损调整了选择器而不是等全部跑完才发现数据质量有问题。## 5. 价格分布分析从脏数据到可视化结论5.1 数据清洗的几条硬规则爬下来的数据不能直接分析必须清洗。我遇到的典型脏数据有三类一是价格字段异常。有些商品页面的now_price可能是 0 或者None比如缺货、仅预览的电子书。分析前要把now_price 0且original_price now_price的合理数据筛出来。二是重复数据。虽然数据库有 UNIQUE 约束但 CSV 导入时仍可能出现重复用drop_duplicates(subsetdetail_url)再清一遍。三是标题中混入无关商品。比如搜索日本时结果里会出现日本文学史相关的没错但也可能出现出版社恰好叫日本XX的童书这种需要靠关键词白名单做二次过滤。5.2 用 pandas 快速统计价格分布清洗后的数据量在 1100~1300 本之间。我直接用 pandas 读 SQLiteimport pandas as pd df pd.read_sql_query(SELECT * FROM books WHERE now_price 0, conn) df df.drop_duplicates(subsetdetail_url) print(df[now_price].describe())describe()输出均值、标准差、最小值、四分位数、最大值这些是价格分布的基础数据。我实测的结果大致是均值在 40~50 元之间中位数集中在 30 元上下说明有一批价格较高的精装书或套装书拉高了均值——这种均值大于中位数的右偏分布在图书定价里非常典型。5.3 直方图与箱线图让分布看得见数据处理完画图是最直观的呈现方式。我用了直方图叠加核密度估计和箱线图两种方式分别观察价格的整体形状和离群点。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei] plt.rcParams[axes.unicode_minus] False fig, axes plt.subplots(1, 2, figsize(14, 5)) # 直方图 axes[0].hist(df[now_price], bins30, edgecolorwhite, alpha0.8) axes[0].set_title(日本相关图书现价分布元) axes[0].set_xlabel(价格) axes[0].set_ylabel(数量) # 箱线图 axes[1].boxplot(df[now_price], vertFalse) axes[1].set_title(价格箱线图) plt.tight_layout() plt.savefig(price_distribution.png, dpi150)有一个坑必须提前说matplotlib 默认字体不含中文字符图里的中文标题会变成方块。解决方案是显式指定中文字体Windows 下面常见的是SimHei或Microsoft YaHeimacOS 下可以用PingFang SC。如果画出来还是方块优先检查字体是否被 matplotlib 正确加载而不是怀疑代码本身。5.4 价格区间拆解一个有用的分析思路除了看整体分布我还把价格切成区间统计占比这样更容易得到能说给人听的结论bins [0, 20, 40, 60, 80, 100, 200, float(inf)] labels [0-20, 20-40, 40-60, 60-80, 80-100, 100-200, 200] df[price_range] pd.cut(df[now_price], binsbins, labelslabels, rightFalse) print(df[price_range].value_counts().sort_index())实测下来20~40 元区间占比最高20 元以下和 100 元以上相对较少。这解释了为什么搜索日本词条时购物车里最后清一色是 20~50 元的平装书——这个价位段的可选空间最大促销折扣也最常见。电子书和纸质书的价格逻辑有明显的分层差异纸质书价格分布在 20~60 元区间最密集电子书则大量集中在 10 元以下。做分析时如果发现数据里混入了大量低价电子书最好单独切片统计不要和纸质书搅在一起否则会出现价格均值被拉低的误导性结论。## 6. 避坑实录从编码乱码到页面结构变化的完整排查链路6.1 乱码问题一个看起来没问题却坑了所有人的经典场景第一次跑通代码时我满心以为数据抓到了结果打开 CSV 一看书名全是乱码形如鏃ユ湰鏂囧寲. 第一反应是 requests 的编码识别出了错。检查后发现当当首页和搜索页的Content-Type响应头里并没有明确给出charsetrequests 就会按默认的 ISO-8859-1 去解码GBK 编码的字节流按这个映射解码必然乱成一锅粥。排查链路是先用resp.encoding查看当前编码发现是 ISO-8859-1再用resp.apparent_encoding测试自动推断发现能正确识别为 GBK于是统一改成resp.encoding resp.apparent_encoding。但这里还有第二个坑——apparent_encoding是基于内容抽样推断的如果页面里混入一段 UTF-8 的脚本推断结果会摇摆。所以我又加了一道保险解析完数据后随机抽几本书名打印出来人工看一眼是不是正常中文而不是全部跑完才发现乱码。6.2 列表结构偶发异常为什么同一套选择器时灵时不灵项目跑了一段时间后我发现某些页面的数据量突然少了很多一页只有两三条而正常应该有四五十条。排查过程比较折腾先看那几页的原始 HTML发现商品项的父容器在双行大图模式下是ul.bigimg但某些词条搜索结果会切换到单行列表模式ul.list_ll。两种结构下li内部的元素基本一致所以我最初的选择器ul.bigimg li只能匹配到一种结构。解决方案是把选择器改成逗号并集ul.bigimg li, ul.list_ll li。这个改动很小但如果不开抓包工具直接看原始 HTML 是发现不了的。我的经验是爬虫跑了一段时间后数据量突降优先怀疑页面结构发生了变化或存在多种渲染模式而不是先怀疑网络问题。6.3 重复数据与增量更新数据库约束的价值分页抓取不可避免会遇到重复商品尤其当当的排序规则偶尔会变化同一本书可能在不同页码重复出现。我在数据库表的detail_url字段上加了UNIQUE约束写入时用INSERT OR IGNORE重复数据会静默跳过。def save_to_db(items, keyword, page): saved 0 for item in items: try: conn.execute( INSERT OR IGNORE INTO books (keyword, title, detail_url, author, now_price, original_price, comment_count, page, crawl_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) , ( keyword, item[title], item[detail_url], item[author], item[now_price], item[original_price], item[comment_count], page, datetime.now().strftime(%Y-%m-%d %H:%M:%S), )) saved 1 except Exception as e: print(f入库失败: {item.get(title)}, {e}) conn.commit() return saved增量更新的思路也一样重新抓取时不需要清空数据库直接往同一个表里插新数据自然追加旧数据靠 UNIQUE 约束去重。这样即使改进了解析逻辑重新跑一遍也不会把库里已有的数据搞乱。6.4 搜索词的杂质怎么过滤结果里的无关商品搜索日本这个词时结果页并非全部都是关于日本题材的图书。实际抓取后发现有一部分书是因为出版社名字里带日本或者书名中恰好包含日本但实际内容与日本无关还有几本漫画和童书它们也在结果中。对于价格分布分析来说最稳妥的不是在爬虫阶段就把过滤逻辑写死而是保留所有原始数据分析阶段再做字段级的白名单过滤。我用的是多条件组合判断若书名包含日本史日本文学日本社会日本文化等明确主题词则保留或者作者是日本籍知名作者则保留再不行就看出版社和副标题做辅助判断。这种过滤方案虽然需要人工定义词表但比单纯靠搜索词分类要可靠得多。爬虫抓取时管住结构分析的时候才管住内容两者不能混在一起。## 7. 实战总结与几个值得继续深挖的方向跑完这个项目我最大的感受是爬虫的核心难点从来不在发出请求拿到数据而在于对目标站点的理解、对数据质量的把控、以及对反爬节奏的管理。这套代码不需要多高的技术门槛但你真拿去跑一遍仍然会遇到我在文中所说的这些坑。我建议你从搜索一个自己感兴趣的词开始改参数先把单页解析调通再逐步扩展到全量爬取。实测稳定跑完一千本图书数据全程只有几分钟成本极低却能对某个类目的价格体系建立非常直观的认知。如果后续想继续扩展这个项目这几个方向都值得尝试换成其他电商或出版社网站对比不同渠道同品类图书的价格差异加入定时调度长期跟踪某关键词的图书价格变化趋势观察促销周期从价格分布延伸到促销力度分析比如计算折扣率 现价 / 原价找出哪些书实际折扣最深用 Scrapy 重写并部署到定时任务让数据采集全自动化最后补充一点合规层面的个人看法爬虫可以是一个非常有价值的数据获取工具但一定要控制在合理的频率内做到对目标站点影响最小只抓公开信息不碰用户隐私、不涉及登录后的非公开数据。这类学习项目的意义在于理解 HTTP 交互、页面解析和数据清洗你越是把基本功打扎实越能在复杂场景中做出正确的技术判断。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价