简介:本资源是一套基于Python与requests库实现的京东商品评论爬取与分类保存实战项目,面向Python初学者及网络数据采集爱好者,解决电商评论数据自动化获取、清洗与结构化存储的实际问题。压缩包共7个文件,含3个CSV(分别存储正面、负面、中性评论数据)、1个核心爬虫脚本jdspider.py、1个README.md说明文档、1个LICENSE授权文件及1个.gitattributes配置文件,整体2.5MB,轻量易部署,适合本地快速运行与调试。已有49人学习下载,反映出其在入门级爬虫实践中的实用热度。读者可直接复用完整可运行代码,掌握HTTP请求构造、京东反爬应对策略(如请求头模拟、间隔控制)、JSON响应解析、情感维度分类逻辑及CSV分文件落地等关键技能,同时通过清晰的目录组织与注释完备的脚本,获得从环境搭建到结果验证的全流程参考。
1. 为什么用 requests 爬京东评论不是“最简方案”,却是最可控的落地选择?
你刚在某技术群看到一个标题:“Python + 基于 requests 的京东商品评论爬取与分类保存!.zip”——点开发现没源码、没说明、只有个压缩包,心里一沉:这又是个半截子项目?别急。我去年在某高校实验室带学生做电商舆情分析时,也从这个标题起步,最终跑通了日均稳定采集 200+ SKU、单商品万级评论、自动按情感/类型/时间分片落库的 pipeline。它不炫技(不用 Selenium、不套 Scrapy 框架),不依赖黑盒 API,核心就靠requests+ 精准逆向 + 分层校验。关键在于:京东 PC 端评论接口虽有反爬,但未启用强动态加密(如 WebAssembly 验证或高频 token 刷新),其callback参数、score过滤逻辑、page分页规则全部可复现;而requests正好卡在这个“够用且透明”的临界点——你能看清每条请求头怎么构造、每个 cookie 怎么续期、每次响应怎么解析,出了问题不是对着黑匣子干瞪眼,而是直接print(r.request.headers)、r.text[:200]两行定位。适合两类人:一是想真正吃透电商接口反爬逻辑的新手(别被“自动化”带偏,先学会手动拆解);二是需要长期维护、对稳定性要求高于开发速度的中小项目(比如某跨平台系统里嵌入的竞品评论监控模块)。这不是“爬虫入门教程”,是我在真实项目中反复压测、替换过 3 轮 User-Agent 池、重写 5 版评论清洗逻辑后沉淀下来的最小可行路径。
2. 从商品 ID 到评论 JSON:requests 请求链的四层构造
京东评论数据不藏在 HTML 里,而由独立接口返回,典型路径为:https://club.jd.com/comment/productPageComments.action?callback=fetchJSON_comment98vv123&productId=100012345678&score=0&sortType=5&page=1&pageSize=10&isShadowSku=0&fold=1
这个 URL 看似简单,实则暗含四层依赖关系:商品标识层 → 接口协议层 → 会话状态层 → 时间上下文层。漏掉任何一层,请求都会返回空数据或 403。下面逐层拆解,所有代码均可直接复制运行(需替换product_id)。
2.1 商品 ID 提取:别信页面 URL,用skuId替代productId
京东商品页 URL 如https://item.jd.com/100012345678.html,其中数字看似是productId,但实际接口中该字段常对应skuId(SKU 编号),而productId是商品 SPUID(标准产品 ID)。二者可能不同。正确做法是:
- 先 GET 商品页 HTML;
- 用正则提取
var skuId = "100012345678";; - 再用该
skuId去调评论接口。
import re import requests def extract_sku_id(item_url): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } r = requests.get(item_url, headers=headers, timeout=10) r.raise_for_status() # 京东页面中 skuId 定义格式固定,优先匹配 var skuId = "xxx"; match = re.search(r'var\s+skuId\s*=\s*"(\d+)"', r.text) if match: return match.group(1) # 备用:从 meta 标签>import random def build_comment_url(sku_id, page=1, score=0, sort_type=5): # callback 必须匹配京东前端定义的函数名,实测后缀 3 位数字即可 callback = f"fetchJSON_comment98vv{random.randint(100, 999)}" base_url = "https://club.jd.com/comment/productPageComments.action" params = { "callback": callback, "productId": sku_id, "score": score, "sortType": sort_type, "page": page, "pageSize": 10, # 京东限制:最大 10 条/页,不可改 "isShadowSku": 0, "fold": 1 } from urllib.parse import urlencode return f"{base_url}?{urlencode(params)}" url = build_comment_url("100012345678", page=1) print(url) # 输出示例:https://club.jd.com/comment/productPageComments.action?callback=fetchJSON_comment98vv456&productId=100012345678&score=0&sortType=5&page=1&pageSize=10&isShadowSku=0&fold=1逻辑说明:
pageSize=10是硬性限制,试图设为 20 会返回{"comments":[]};isShadowSku=0表示非虚拟商品(如会员卡),fold=1表示展开全部评论(否则只返回首屏)。
参数说明:random.randint(100, 999)模拟前端生成的随机后缀,实测无需严格同步,只要格式匹配即可;urlencode确保中文等特殊字符安全编码。
2.3 请求头与会话维持:User-Agent、Referer、Cookie 的协同策略
京东对无 Referer 或低频 UA 的请求会返回{"comments":[]}。必须构造完整会话链:
User-Agent:需轮换(至少 3 个以上),避免被 UA 指纹识别;Referer:必须为对应商品页 URL,否则 403;Cookie:需携带shshshfpa、__jda等基础字段(首次访问商品页自动设置)。
import time def get_comment_data(sku_id, page=1): session = requests.Session() # Step 1: 先访问商品页,获取初始 Cookie 和 Referer item_url = f"https://item.jd.com/{sku_id}.html" headers_base = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" } session.get(item_url, headers=headers_base, timeout=10) # Step 2: 构造评论接口请求 url = build_comment_url(sku_id, page=page) headers_api = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": item_url, "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "X-Requested-With": "XMLHttpRequest" } # Step 3: 发起请求,带 session 自动继承 Cookie r = session.get(url, headers=headers_api, timeout=15) r.raise_for_status() # Step 4: 解析 JSONP 响应(剥离 callback 包裹) jsonp_text = r.text.strip() if jsonp_text.startswith("fetchJSON_comment"): # 提取括号内 JSON 字符串 start = jsonp_text.find("(") + 1 end = jsonp_text.rfind(")") json_str = jsonp_text[start:end] import json return json.loads(json_str) else: raise ValueError(f"Unexpected response format: {jsonp_text[:100]}") # 示例调用 try: data = get_comment_data("100012345678", page=1) print(f"第1页共 {data.get('maxPage', 0)} 页,获取 {len(data.get('comments', []))} 条评论") except Exception as e: print(f"请求失败: {e}")逻辑说明:
session.get(item_url)不仅获取 Cookie,还触发京东服务端记录本次会话的 Referer 上下文;后续session.get(url)自动携带该 Cookie 和 Referer,形成可信链路。
参数说明:timeout=15给 JSONP 解析留出余量;X-Requested-With: XMLHttpRequest是关键 header,缺失会导致返回 HTML 而非 JSONP。
2.4 分页与终止判断:用maxPage和comments长度双校验
京东接口返回 JSON 中含maxPage字段,但该值有时滞后(如新增评论未刷新),不能单独依赖。必须结合comments列表长度判断是否到底:
- 若
len(comments) == 0:当前页无数据,立即停止; - 若
len(comments) < 10:最后一页(因 pageSize=10); - 若
len(comments) == 10:继续下一页,但需检查maxPage是否超限(防无限循环)。
def fetch_all_comments(sku_id, max_pages=100): all_comments = [] for page in range(1, max_pages + 1): try: data = get_comment_data(sku_id, page=page) comments = data.get("comments", []) # 终止条件 1:本页无评论 if not comments: print(f"第{page}页无评论,提前结束") break # 终止条件 2:本页不足 10 条(最后一页) if len(comments) < 10: all_comments.extend(comments) print(f"第{page}页仅 {len(comments)} 条,已到末页") break # 终止条件 3:达到 maxPage 上限 max_page = data.get("maxPage", 0) if page >= max_page: all_comments.extend(comments) print(f"已达接口声明最大页数 {max_page}") break all_comments.extend(comments) print(f"已获取第{page}页,累计 {len(all_comments)} 条") # 防封策略:页间休眠 1~2 秒 time.sleep(random.uniform(1.2, 1.8)) except Exception as e: print(f"第{page}页请求异常: {e}") break return all_comments # 示例:拉取某 SKU 全量评论 comments = fetch_all_comments("100012345678") print(f"总计获取 {len(comments)} 条原始评论")逻辑说明:
time.sleep(random.uniform(1.2, 1.8))模拟人工浏览节奏,避免高频请求触发风控;max_pages=100是安全上限,京东单商品评论通常不超过 50 页(500 条)。
参数说明:max_pages可根据商品热度调整,新品可设 50,爆款设 100;random.uniform比固定 sleep 更难被模式识别。
3. 评论清洗与结构化:从 raw JSON 到可分类字段
京东返回的comments是嵌套字典列表,字段命名混乱(如creationTime、referenceTime、commentTime并存)、内容含 HTML 标签、用户昵称脱敏。必须清洗为统一 schema 才能分类保存。我们定义目标结构:
{ "id": "123456789", # 评论唯一 ID(jdCommentId) "sku": "100012345678", # 商品 SKU "user_nickname": "J***e", # 清洗后昵称(保留首尾,中间 *) "user_level": "PLUS", # 用户等级(PLUS/VIP/普通) "score": 5, # 评分(1~5) "content": "屏幕很亮,发货很快!", # 纯文本内容(去 HTML) "creation_time": "2024-03-15 14:22:33", # 标准化时间 "useful_vote_count": 12, # 有用数 "image_count": 2, # 配图数量 "is_mobile": True, # 是否来自移动端 "user_client": "android" # 客户端类型(android/ios/pc) }3.1 时间字段归一化:三类时间戳的优先级取舍
京东评论 JSON 中存在多个时间字段:
creationTime: "2024-03-15 14:22:33"(字符串,最常用)referenceTime: "2024-03-15T14:22:33.000+0800"(ISO 格式,部分评论有)commentTime: 1710483753000(毫秒时间戳,少数)
清洗逻辑:优先用creationTime(覆盖 95%+ 评论);若为空,降级用referenceTime;再为空,用commentTime转换。所有结果转为YYYY-MM-DD HH:MM:SS字符串。
from datetime import datetime def parse_jd_time(comment_dict): # 优先级:creationTime > referenceTime > commentTime time_str = comment_dict.get("creationTime") if time_str and isinstance(time_str, str) and len(time_str) >= 19: # 直接使用,格式如 "2024-03-15 14:22:33" return time_str[:19] time_str = comment_dict.get("referenceTime") if time_str and isinstance(time_str, str): # ISO 格式:2024-03-15T14:22:33.000+0800 → 提取前19位 if "T" in time_str: return time_str.replace("T", " ")[:19] timestamp_ms = comment_dict.get("commentTime") if isinstance(timestamp_ms, (int, float)): try: dt = datetime.fromtimestamp(timestamp_ms / 1000) return dt.strftime("%Y-%m-%d %H:%M:%S") except (OSError, ValueError): pass return "1970-01-01 00:00:00" # 默认兜底 # 测试 test_comment = {"creationTime": "2024-03-15 14:22:33", "referenceTime": "2024-03-15T14:22:33.000+0800"} print(parse_jd_time(test_comment)) # 输出:2024-03-15 14:22:33逻辑说明:
creationTime是京东前端展示的“发表时间”,语义最明确;referenceTime是服务端记录的“参考时间”,精度更高但字段不稳定;commentTime是毫秒戳,需除以 1000 转为秒级。
参数说明:[:19]截取确保格式统一(避免微秒部分干扰);strftime保证输出恒定长度。
3.2 内容清洗:HTML 标签、换行符、广告语的三重过滤
京东评论内容常含<br>、<em>等标签,以及“此用户未填写评价”、“【此条评论由京东自动晒单】”等模板文本。清洗需分步:
- 用
re.sub(r'<[^>]+>', '', text)去 HTML 标签; - 用
re.sub(r'\s+', ' ', text).strip()合并空白符; - 用预设关键词列表过滤无效内容。
import re # 京东常见无效评论模板(正则匹配) INVALID_PATTERNS = [ r"此用户未填写评价", r"【此条评论由京东自动晒单】", r"该用户未及时填写评价", r"用户未填写评价内容", r"暂无文字评价", ] def clean_comment_content(raw_content): if not isinstance(raw_content, str): return "" # Step 1: 去 HTML 标签 text = re.sub(r'<[^>]+>', '', raw_content) # Step 2: 规范空白符 text = re.sub(r'\s+', ' ', text).strip() # Step 3: 过滤无效模板 for pattern in INVALID_PATTERNS: if re.search(pattern, text): return "" # 清洗后为空,表示该评论无有效内容 return text # 示例 raw = "屏幕很亮,<br>发货很快!<em>推荐购买</em>" clean = clean_comment_content(raw) print(f"原始: {raw}") print(f"清洗: {clean}") # 输出:屏幕很亮, 发货很快! 推荐购买 → 注意中间空格,下一步再处理逻辑说明:
re.sub(r'\s+', ' ', ...)将连续空白(包括换行、制表符)替换为单空格,避免\n\n导致字段错位;无效模板用re.search而非in,支持模糊匹配(如“自动晒单”前后有空格)。
参数说明:INVALID_PATTERNS可随业务扩展,例如增加"京东物流"(若需排除纯夸物流的评论)。
3.3 用户信息提取:等级、客户端、移动标识的映射规则
京东评论 JSON 中userLevelName字段值为"PLUS"、"VIP"、"普通"等,但isMobile字段为布尔值,userClient字段需从userClient或userLevelName推断:
| 原始字段 | 映射逻辑 | 示例 |
|---|---|---|
userLevelName | 直接取值,空则"普通" | "PLUS"→"PLUS" |
isMobile | 布尔值,True 即移动端 | True→True |
userClient | 若存在则用;否则根据isMobile推断:True→"android",False→"pc" | {}→"pc" |
def extract_user_info(comment_dict): # 用户等级 level = comment_dict.get("userLevelName", "普通") if not isinstance(level, str): level = "普通" # 移动端标识 is_mobile = comment_dict.get("isMobile", False) if not isinstance(is_mobile, bool): is_mobile = False # 客户端类型 client = comment_dict.get("userClient", "") if not isinstance(client, str) or not client.strip(): client = "android" if is_mobile else "pc" return { "user_level": level, "is_mobile": is_mobile, "user_client": client.lower() } # 示例 cmt = {"userLevelName": "PLUS", "isMobile": True, "userClient": ""} info = extract_user_info(cmt) print(info) # {'user_level': 'PLUS', 'is_mobile': True, 'user_client': 'android'}逻辑说明:
userClient字段在部分评论中为空,但isMobile字段稳定存在,因此用其推断更可靠;client.lower()统一大小写,避免"Android"和"android"混淆。
参数说明:level默认"普通"符合京东实际(未认证用户即普通),比"unknown"更符合业务语义。
3.4 结构化封装:将 raw comment 映射为标准 dict
整合前述清洗逻辑,封装为单函数:
def normalize_comment(raw_comment, sku_id): """将京东 raw comment 字典转为标准化结构""" if not isinstance(raw_comment, dict): return None # 基础字段 comment_id = str(raw_comment.get("id") or raw_comment.get("jdCommentId") or "") content = clean_comment_content(raw_comment.get("content", "")) # 过滤空内容 if not content.strip(): return None # 构建结果 return { "id": comment_id, "sku": sku_id, "user_nickname": raw_comment.get("nickname", "")[:1] + "***" + raw_comment.get("nickname", "")[-1:] if raw_comment.get("nickname") else "匿名用户", "user_level": extract_user_info(raw_comment)["user_level"], "score": int(raw_comment.get("score", 0)), "content": content, "creation_time": parse_jd_time(raw_comment), "useful_vote_count": int(raw_comment.get("usefulVoteCount", 0)), "image_count": int(raw_comment.get("imageCount", 0)), "is_mobile": extract_user_info(raw_comment)["is_mobile"], "user_client": extract_user_info(raw_comment)["user_client"] } # 示例使用 raw_cmt = { "id": 123456789, "nickname": "京东用户", "userLevelName": "PLUS", "score": 5, "content": "屏幕很亮,<br>发货很快!", "creationTime": "2024-03-15 14:22:33", "usefulVoteCount": 12, "imageCount": 2, "isMobile": True } norm = normalize_comment(raw_cmt, "100012345678") print(norm) # 输出:{'id': '123456789', 'sku': '100012345678', 'user_nickname': '京***户', ...}逻辑说明:
nickname脱敏采用首字符 + *** + 末字符,符合国内隐私规范;score强制int转换,避免字符串"5"导致后续数值计算错误。
参数说明:sku_id作为外部传入,确保结构化时绑定商品上下文;if not content.strip(): return None是关键过滤,避免空评论污染数据集。
4. 分类保存策略:按时间、情感、类型三维度切片落盘
“分类保存”不是简单按文件夹分,而是建立可检索、可回溯、可增量更新的数据组织方式。我们采用三层目录 + 双格式存储:
- 第一层:按日期切片(
data/20240315/)→ 解决冷热分离,便于删除过期数据; - 第二层:按商品切片(
data/20240315/100012345678/)→ 避免单目录文件爆炸; - 第三层:按类型切片(
comments.jsonl/stats.csv)→ 支持不同消费场景。
存储格式选JSONL(每行一个 JSON)而非单 JSON,因:
- 流式写入,内存友好(万级评论不 OOM);
- 可
tail -n 100快速查最新评论; jq工具直接过滤(如jq 'select(.score==1)' comments.jsonl)。
4.1 目录结构自动生成与安全写入
import os from datetime import datetime def get_save_path(sku_id, base_dir="data"): """生成安全保存路径:data/YYYYMMDD/sku_id/""" date_str = datetime.now().strftime("%Y%m%d") path = os.path.join(base_dir, date_str, sku_id) os.makedirs(path, exist_ok=True) return path def save_comments_jsonl(comments, sku_id, base_dir="data"): """将评论列表保存为 JSONL 文件""" save_dir = get_save_path(sku_id, base_dir) file_path = os.path.join(save_dir, "comments.jsonl") # 使用临时文件 + 原子写入,防中断损坏 temp_path = file_path + ".tmp" try: with open(temp_path, "w", encoding="utf-8") as f: for cmt in comments: if cmt: # 过滤 None import json f.write(json.dumps(cmt, ensure_ascii=False) + "\n") # 原子替换 if os.path.exists(file_path): os.replace(temp_path, file_path) else: os.rename(temp_path, file_path) print(f"已保存 {len(comments)} 条评论至 {file_path}") except Exception as e: if os.path.exists(temp_path): os.remove(temp_path) raise e # 示例:保存清洗后的评论 normalized_comments = [normalize_comment(c, "100012345678") for c in comments if c] save_comments_jsonl(normalized_comments, "100012345678")逻辑说明:
os.replace()在 POSIX 系统上是原子操作,在 Windows 上用os.rename()模拟,确保写入过程不被中断破坏;ensure_ascii=False保留中文,避免\u4f60\u597d形式。
参数说明:base_dir="data"可配置为绝对路径(如/home/user/jd_data),方便部署;exist_ok=True避免多线程重复创建目录报错。
4.2 情感维度分类:基于规则的极性打标(非模型)
京东评论天然带score字段(1~5 分),可直接映射情感:
score == 1 or score == 2→"negative"score == 3→"neutral"score == 4 or score == 5→"positive"
但需增强鲁棒性:当content含明确负面词(如“假货”、“骗人”、“不发货”)且score>=4时,强制标为"conflict"(矛盾评论,需人工复核)。
# 负面关键词(业务可配置) NEGATIVE_KEYWORDS = ["假货", "骗人", "不发货", "发错货", "质量差", "太差", "垃圾", "坑"] def label_sentiment(comment_dict): score = comment_dict.get("score", 0) content = comment_dict.get("content", "") if score in [1, 2]: return "negative" elif score == 3: return "neutral" elif score in [4, 5]: # 冲突检测:高分但含负面词 if any(kw in content for kw in NEGATIVE_KEYWORDS): return "conflict" return "positive" else: return "unknown" # 为每条评论添加 sentiment 字段 for cmt in normalized_comments: if cmt: cmt["sentiment"] = label_sentiment(cmt) # 按情感分类保存(同目录下不同文件) def save_by_sentiment(comments, sku_id, base_dir="data"): save_dir = get_save_path(sku_id, base_dir) # 按 sentiment 分组 groups = {} for cmt in comments: if not cmt: continue sent = cmt.get("sentiment", "unknown") if sent not in groups: groups[sent] = [] groups[sent].append(cmt) # 分别保存 for sent, cmts in groups.items(): file_path = os.path.join(save_dir, f"comments_{sent}.jsonl") with open(file_path, "w", encoding="utf-8") as f: for cmt in cmts: import json f.write(json.dumps(cmt, ensure_ascii=False) + "\n") print(f"已保存 {len(cmts)} 条 {sent} 评论至 {file_path}") save_by_sentiment(normalized_comments, "100012345678")逻辑说明:
label_sentiment优先信任score,仅在高分+负面词时触发冲突标记,避免过度依赖关键词导致误判(如“质量差”在差评中是正常描述);groups字典实现 O(1) 分组,比多次遍历高效。
参数说明:NEGATIVE_KEYWORDS可存为外部 YAML 文件,支持热更新;sentiment字段为字符串,便于后续grep或数据库WHERE sentiment='negative'查询。
4.3 类型维度分类:按内容长度、图片数、客户端的组合切片
除情感外,业务常需按评论“类型”分析:
- 长评(
len(content) > 50):含细节体验,价值高; - 图评(
image_count > 0):视觉证据强; - APP 评(
user_client in ["android", "ios"]):移动端用户行为。
我们生成组合标签,如"long"、"image"、"app",并保存为独立文件:
def classify_comment_type(comment_dict): """返回类型标签列表""" tags = [] content_len = len(comment_dict.get("content", "")) if content_len > 50: tags.append("long") if comment_dict.get("image_count", 0) > 0: tags.append("image") if comment_dict.get("user_client", "") in ["android", "ios"]: tags.append("app") return tags or ["short"] # 默认 short # 按类型保存 def save_by_type(comments, sku_id, base_dir="data"): save_dir = get_save_path(sku_id, base_dir) # 按 type 分组(type 是标签字符串,如 "long_image") from itertools import combinations type_groups = {} for cmt in comments: if not cmt: continue tags = classify_comment_type(cmt) # 生成所有非空子集标签(如 ["long","image"] → "long", "image", "long_image") for r in range(1, len(tags)+1): for combo in combinations(tags, r): type_key = "_".join(sorted(combo)) if type_key not in type_groups: type_groups[type_key] = [] type_groups[type_key].append(cmt) # 保存 for type_key, cmts in type_groups.items(): file_path = os.path.join(save_dir, f"comments_{type_key}.jsonl") with open(file_path, "w", encoding="utf-8") as f: for cmt in cmts: import json f.write(json.dumps(cmt, ensure_ascii=False) + "\n") print(f"已保存 {len(cmts)} 条 {type_key} 评论至 {file_path}") save_by_type(normalized_comments, "100012345678")逻辑说明:
combinations(tags, r)生成所有可能的标签组合(如["long","image"]生成"long"、"image"、"long_image"),覆盖单维度和交叉维度分析需求;sorted(combo)确保"image_long"和"long_image"统一为后者。
参数说明:content_len > 50是经验值,可根据业务调整
本文还有配套的精品资源,点击获取