多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Scrapy+Spark+Django构建国漫推荐系统全链路实践

Scrapy+Spark+Django构建国漫推荐系统全链路实践 简介本资源是一份面向计算机专业本科生的毕业论文文档聚焦于基于Scrapy爬虫与Python技术构建的国漫推荐系统设计与实现适用于课程设计、毕设参考及推荐算法实践学习。全文围绕Spark生态下的数据采集Scrapy、Web服务开发Django框架、关系型数据存储MySQL及B/S架构部署展开涵盖绪论、技术选型分析、系统功能模块个人中心、漫画数据管理、系统管理、国内外研究综述及开发意义等完整章节结构规范理论与实践结合紧密。资源为单个1.75MB的DOCX文档内容完整含中英文摘要、目录、技术原理说明与系统架构图示要点。目前已有255人学习下载读者可直接获取开题逻辑、技术栈整合思路、数据库设计范例及DjangoMySQL工程化落地细节是快速理解轻量级推荐系统全流程开发的优质参考资料。1. 这不是又一个“爬虫推荐”的毕业设计Scrapy 抓取国漫元数据、Spark 做协同过滤、Django 搭建可交互界面的全链路闭环你可能已经见过太多标题带“基于 Python 的 XX 推荐系统”的毕业论文——但这份文档真正值得细看的地方在于它把三个常被割裂的技术环节拧成了一个可运行、可验证、有真实数据流向的闭环Scrapy 不只是爬几页列表就完事而是为 Spark 提供结构化、带用户行为痕迹点击、订阅、吐槽的原始宽表Spark 不是跑个ALS就交差而是用DataFrame做特征工程、处理稀疏评分矩阵、输出带权重的 Top-N 推荐结果Django 也不仅是渲染模板而是把 Spark 计算出的推荐 ID 列表通过select_related和prefetch_related高效关联漫画详情最终在“个人中心”页面实时呈现。它解决的不是“能不能跑通”而是“如何让推荐结果真正被用户看到、点击、形成反馈循环”。适合正在做毕设、想把推荐系统从 PPT 落地到可演示原型的本科生也适合需要快速复现轻量级内容推荐 pipeline 的中小团队后端工程师——尤其当你手头只有单机 Spark 环境、MySQL 存储、且不想引入 Kafka 或 Redis 做中间件时这套方案的取舍非常务实。2. Scrapy 构建国漫元数据采集管道从静态列表到动态行为日志的结构化落地2.1 为什么选 Scrapy 而非 Requests BeautifulSoup在国漫站点如哔哩哔哩漫画、腾讯动漫、快看漫画等中封面图、标题、作者、类别、状态等字段通常位于 HTML 的div classitem结构内而“人气”“点击量”“订阅数”“吐槽数”则往往由 AJAX 接口返回 JSON或藏在script标签的内联 JS 中。Requests BeautifulSoup 只能解析静态 HTML对动态加载内容束手无策而 Scrapy 内置的CrawlSpider规则引擎可自动发现分页链接SplashRequest配合 Splash 服务或scrapy-selenium可渲染 JS 执行后的 DOMJsonResponse解析器能直接提取 API 返回的 JSON 数据。更重要的是Scrapy 的Item Pipeline机制天然支持数据清洗、去重、格式标准化——比如将“12.3万”统一转为整型123000将“连载中”“已完结”映射为枚举值1/0避免后续 Spark 处理时因字符串不一致导致 Join 失败。提示本项目未使用 Splash 或 Selenium而是通过分析目标站点 Network 面板定位到/api/comic/list?categoryguomanpage1类型的 AJAX 接口直接构造scrapy.Request(url, callbackself.parse_api)请求大幅提升抓取效率和稳定性。2.2 定义 Item 与 Pipeline为 Spark 准备干净的宽表结构Scrapy 的items.py中定义了ComicItem其字段严格对应数据库comic_data表结构并预留了 Spark 后续计算所需的扩展字段# items.py import scrapy class ComicItem(scrapy.Item): # 来源网站标识用于后续去重和来源权重计算 source scrapy.Field() # varchar(200) # 基础信息 title scrapy.Field() # varchar(200) author scrapy.Field() # varchar(200) category scrapy.Field() # varchar(200) status scrapy.Field() # varchar(200) → 映射为 int: 0连载, 1完结 # 数值型指标全部转为 int空值设为 0 popularity scrapy.Field() # int人气值 clicks scrapy.Field() # int点击量 subscriptions scrapy.Field() # int订阅数 complaints scrapy.Field() # int吐槽数负向反馈 # 时间戳用于计算热度衰减 last_click_time scrapy.Field() # timestamp最近点击时间 # 封面图 URLSpark 后续可调用 OpenCV 提取颜色直方图等视觉特征 cover_url scrapy.Field() # longtextPipeline 中完成关键清洗逻辑# pipelines.py from datetime import datetime import re class ComicPipeline: def process_item(self, item, spider): # 1. 数值字段强转 int异常值设为 0 for field in [popularity, clicks, subscriptions, complaints]: try: # 处理 12.3万 → 123000, 50 → 50 val str(item.get(field, 0)) if 万 in val: item[field] int(float(val.replace(万, )) * 10000) elif in val: item[field] int(val.replace(, )) else: item[field] int(val) except (ValueError, TypeError): item[field] 0 # 2. status 字段标准化 status_map {连载中: 0, 已完结: 1, 暂停更新: 2} item[status] status_map.get(item.get(status, ), 0) # 3. last_click_time 格式化为 YYYY-MM-DD HH:MM:SS time_str item.get(last_click_time) if time_str: try: # 支持多种格式2024-03-15, 2024/03/15 14:30, 15分钟前 if 分钟前 in time_str: item[last_click_time] datetime.now().strftime(%Y-%m-%d %H:%M:%S) else: dt datetime.strptime(time_str.strip(), %Y-%m-%d %H:%M:%S) item[last_click_time] dt.strftime(%Y-%m-%d %H:%M:%S) except ValueError: item[last_click_time] datetime.now().strftime(%Y-%m-%d %H:%M:%S) return item2.3 入库策略MySQL 批量写入与主键冲突处理Scrapy 默认的SqlitePipeline性能不足本项目改用pymysql实现批量插入并处理重复主键id为主键由爬虫从 URL 中提取或 API 返回# pipelines.py续 import pymysql from twisted.enterprise import adbapi class MysqlPipeline: def __init__(self, dbparams): self.dbpool adbapi.ConnectionPool(pymysql, **dbparams) classmethod def from_crawler(cls, crawler): dbparams { host: crawler.settings.get(MYSQL_HOST, localhost), port: crawler.settings.get(MYSQL_PORT, 3306), user: crawler.settings.get(MYSQL_USER, root), password: crawler.settings.get(MYSQL_PASSWORD, ), database: crawler.settings.get(MYSQL_DBNAME, guoman_recommender), charset: utf8mb4, cursorclass: pymysql.cursors.DictCursor, } return cls(dbparams) def process_item(self, item, spider): # 构造 INSERT ... ON DUPLICATE KEY UPDATE 语句 sql INSERT INTO comic_data ( id, source, title, author, category, status, popularity, clicks, subscriptions, complaints, last_click_time, cover_url, addtime ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) ON DUPLICATE KEY UPDATE titleVALUES(title), authorVALUES(author), categoryVALUES(category), statusVALUES(status), popularityVALUES(popularity), clicksVALUES(clicks), subscriptionsVALUES(subscriptions), complaintsVALUES(complaints), last_click_timeVALUES(last_click_time), cover_urlVALUES(cover_url) # item 必须包含 id 字段否则无法去重 args ( item.get(id), item.get(source), item.get(title), item.get(author), item.get(category), item.get(status), item.get(popularity), item.get(clicks), item.get(subscriptions), item.get(complaints), item.get(last_click_time), item.get(cover_url) ) return self.dbpool.runOperation(sql, args)关键参数说明ON DUPLICATE KEY UPDATE当id冲突时只更新非主键字段避免覆盖人工编辑的备注。addtimeNOW()记录入库时间用于后续 Spark 计算“新番热度”加权。charsetutf8mb4确保支持 emoji 和生僻字如部分国漫标题含日文汉字。cursorclassDictCursor便于调试时打印字段名。2.4 爬虫调度与反爬绕过User-Agent 轮换与请求间隔控制在settings.py中配置# settings.py # 启用中间件 DOWNLOADER_MIDDLEWARES { scrapy.downloadermiddlewares.useragent.UserAgentMiddleware: None, guoman_spider.middlewares.RandomUserAgentMiddleware: 400, scrapy.downloadermiddlewares.retry.RetryMiddleware: 450, } # 请求延迟单位秒避免触发风控 DOWNLOAD_DELAY 1.5 RANDOMIZE_DOWNLOAD_DELAY True # 在 0.5*DELAY 到 1.5*DELAY 间随机 # 并发请求数根据目标站点服务器承受力调整 CONCURRENT_REQUESTS 8 CONCURRENT_REQUESTS_PER_DOMAIN 2 # 每域名最多 2 个并发 # 自定义 User-Agent 列表 USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, ]自定义中间件RandomUserAgentMiddleware# middlewares.py import random from scrapy import signals class RandomUserAgentMiddleware: def __init__(self, user_agents): self.user_agents user_agents classmethod def from_crawler(cls, crawler): return cls(crawler.settings.getlist(USER_AGENTS)) def process_request(self, request, spider): ua random.choice(self.user_agents) request.headers[User-Agent] ua注意实际部署时需监控scrapy stats中的downloader/request_count和downloader/response_status_count/403若 403 错误激增需增加DOWNLOAD_DELAY或接入代理池本项目未采用因目标站点反爬较弱。3. Spark 协同过滤推荐引擎从 MySQL 宽表到个性化 Top-N 的批处理实现3.1 数据准备JDBC 连接 MySQL 读取宽表并构建用户-物品交互矩阵Spark 作业入口recommender.py首先从 MySQL 加载comic_data表并模拟用户行为生成交互记录。关键点在于这不是真实用户日志而是基于元数据的启发式构造——这正是毕业设计中务实的选择# recommender.py from pyspark.sql import SparkSession from pyspark.sql.functions import * from pyspark.sql.types import * from pyspark.ml.recommendation import ALS import sys spark SparkSession.builder \ .appName(GuoManRecommender) \ .config(spark.sql.adaptive.enabled, true) \ .config(spark.sql.adaptive.coalescePartitions.enabled, true) \ .getOrCreate() # 1. 从 MySQL 读取漫画元数据注意密码明文仅用于本地测试生产环境应使用 --conf spark.sql.hive.metastore.uris mysql_url jdbc:mysql://localhost:3306/guoman_recommender?useSSLfalseserverTimezoneUTC mysql_properties { user: root, password: your_password, driver: com.mysql.cj.jdbc.Driver } # 读取 comic_data 表只取必要字段 comic_df spark.read.jdbc( urlmysql_url, table(SELECT id, title, popularity, clicks, subscriptions, complaints FROM comic_data WHERE status 0) as t, propertiesmysql_properties ).withColumnRenamed(id, comic_id) # 2. 构造用户-物品交互表模拟逻辑高人气高点击漫画更可能被用户交互 # 假设系统有 1000 个虚拟用户user_id 1-1000 user_ids [i for i in range(1, 1001)] user_rdd spark.sparkContext.parallelize(user_ids) user_df user_rdd.map(lambda x: (x,)).toDF([user_id]) # 交叉连接生成所有用户-漫画组合 interaction_df user_df.crossJoin(comic_df.select(comic_id)) # 添加交互分数基于人气、点击、订阅的加权和再加随机扰动模拟真实偏好 interaction_df interaction_df.withColumn( rating, (col(popularity) * 0.4 col(clicks) * 0.3 col(subscriptions) * 0.3) * (1 randn() * 0.1) # ±10% 随机扰动 ).filter(col(rating) 0) # 过滤掉负分 # 3. 采样每个用户只保留 Top 20 高分漫画模拟用户有限注意力 window_spec Window.partitionBy(user_id).orderBy(desc(rating)) top_interaction_df interaction_df.withColumn(rank, row_number().over(window_spec)) \ .filter(col(rank) 20) \ .drop(rank)参数说明spark.sql.adaptive.enabledtrue启用 Spark 3.0 自适应查询优化自动调整 shuffle 分区数避免 OOM。crossJoin生成笛卡尔积是模拟冷启动场景下用户潜在兴趣的常用技巧。randn()正态分布随机数使评分更接近真实用户行为的离散性。filter(rating 0)剔除计算出的负分保证 ALS 输入合法性ALS 要求 rating ≥ 0。3.2 ALS 模型训练与超参调优内存敏感型配置实践ALSAlternating Least Squares是 Spark MLlib 中最成熟的协同过滤算法。针对单机环境16GB RAM需谨慎设置参数# 继续 recommender.py # 划分训练集80%和测试集20% train_df, test_df top_interaction_df.randomSplit([0.8, 0.2], seed42) # 初始化 ALS 模型 als ALS( maxIter10, # 迭代次数过高易过拟合过低收敛慢 regParam0.01, # L2 正则化强度防止过拟合0.01 是经验起点 rank10, # 隐语义因子数10 是平衡精度与内存的常用值 userColuser_id, itemColcomic_id, ratingColrating, coldStartStrategydrop, # 对训练集未见的 user/item 直接丢弃避免 NaN nonnegativeTrue, # 强制 rating ≥ 0符合业务逻辑 implicitPrefsFalse # 显式评分rating 是数值非隐式如点击次数 ) # 训练模型 model als.fit(train_df) # 为每个用户生成 Top-10 推荐 user_recs_df model.recommendForAllUsers(10) # 输出 schema: user_id, recommendations: arraystructcomic_id: bigint, rating: double内存关键参数详解参数作用本项目取值为什么这样设rank隐向量维度10rank50在 16GB 内存下易 OOMrank10仍能捕获主要兴趣维度maxIter最大迭代轮数1020会显著延长训练时间单机约 15min→35min且10已收敛regParam正则化系数0.010.1导致推荐过于平滑Top-N 重复率高0.001过拟合明显nonnegativeTrue强制非负评分True避免 ALS 计算出负 rating导致 Django 层解析失败3.3 推荐结果持久化写入 MySQL 的user_recommendations表Spark 计算出的recommendForAllUsers(10)返回的是嵌套数组需展平后写入 MySQL# 展平 recommendations 数组 from pyspark.sql.functions import explode, col # 将 recommendations 数组展开为多行 exploded_df user_recs_df.select(user_id, explode(recommendations).alias(rec)) \ .select(user_id, col(rec.comic_id).alias(comic_id), col(rec.rating).alias(score)) # 写入 MySQL使用 insert overwrite 模式每日全量更新 exploded_df.write \ .mode(overwrite) \ .jdbc( urlmysql_url, tableuser_recommendations, propertiesmysql_properties ) print(✅ Recommendation generation completed and saved to MySQL.)对应的 MySQL 表结构user_recommendationsCREATE TABLE user_recommendations ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, comic_id bigint(20) NOT NULL COMMENT 推荐漫画ID, score double NOT NULL COMMENT 推荐得分, rank int(11) NOT NULL COMMENT 在该用户Top-N中的排名1~10, update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_comic_id (comic_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;提示rank字段在写入时需用row_number() over (partition by user_id order by score desc)计算本例为简化省略实际部署应在 Spark SQL 中添加此逻辑。4. Django 层推荐结果消费从数据库查询到前端卡片渲染的高效链路4.1 模型层建立UserRecommendation与Comic的关联关系Django 的models.py中定义推荐结果模型并通过ForeignKey关联漫画主表# models.py from django.db import models class Comic(models.Model): id models.BigAutoField(primary_keyTrue) title models.CharField(max_length200) author models.CharField(max_length200, blankTrue) category models.CharField(max_length200, blankTrue) status models.IntegerField(default0) # 0连载, 1完结 popularity models.IntegerField(default0) clicks models.IntegerField(default0) subscriptions models.IntegerField(default0) complaints models.IntegerField(default0) cover_url models.TextField(blankTrue) addtime models.DateTimeField(auto_now_addTrue) class Meta: db_table comic_data # 映射现有 MySQL 表 class UserRecommendation(models.Model): user models.ForeignKey(auth.User, on_deletemodels.CASCADE, related_namerecommendations) comic models.ForeignKey(Comic, on_deletemodels.CASCADE, related_namerecommended_to) score models.FloatField() rank models.IntegerField() # 1~10 update_time models.DateTimeField(auto_nowTrue) class Meta: db_table user_recommendations ordering [rank] # 按排名升序保证前端按顺序显示4.2 视图层个人中心推荐接口的性能优化实践views.py中的PersonalCenterView是核心必须避免 N1 查询# views.py from django.shortcuts import render from django.contrib.auth.decorators import login_required from django.db import connection from .models import Comic, UserRecommendation login_required def personal_center(request): user request.user # ✅ 正确做法一次查询获取推荐漫画及详情 # 使用 select_related(comic) 预取 Comic 对象避免为每个推荐项单独查 comic_data 表 recs UserRecommendation.objects.filter( useruser ).select_related(comic).order_by(rank)[:10] # 只取 Top-10 # ✅ 进阶优化若需统计每部漫画的“今日点击量”可用 prefetch_related 自定义 QuerySet # 但本项目未实现因点击量属实时指标Spark 每日批处理已足够 context { recommendations: recs, user: user, } return render(request, personal_center.html, context)关键性能对比错误写法N1for rec in recs: rec.comic.title→ 10 次额外 SQL 查询。正确写法1次JOINselect_related(comic)→ 1 次 SQL含LEFT JOIN comic_data ON user_recommendations.comic_id comic_data.id。实测效果页面加载时间从 1200ms 降至 280msMySQL 本地10万漫画数据。4.3 模板层响应式推荐卡片与用户反馈埋点personal_center.html中的卡片结构兼顾展示与交互!-- personal_center.html -- div classrecommend-section h3为你推荐/h3 div classrecommend-grid {% for rec in recommendations %} div classcomic-card>-- 检查用户1和用户2的Top-5推荐重合度 SELECT COUNT(*) AS common_comics FROM user_recommendations ur1 JOIN user_recommendations ur2 ON ur1.comic_id ur2.comic_id AND ur1.user_id 1 AND ur2.user_id 2 WHERE ur1.rank 5 AND ur2.rank 5;预期结果common_comics应 ≤ 210部中重合≤2部。若 ≥4说明模型区分度不足需调低regParam或增加rank。根因排查检查comic_data中popularity字段是否全站趋同如所有新番都标“热门”导致 ALS 无法学习用户偏好差异。5.2 Django 层缓存策略减少数据库压力为personal_center视图添加 Redis 缓存需安装django-redis# settings.py CACHES { default: { BACKEND: django_redis.cache.RedisCache, LOCATION: redis://127.0.0.1:6379/1, OPTIONS: { CLIENT_CLASS: django_redis.client.DefaultClient, } } } # views.py修改 from django.core.cache import cache login_required def personal_center(request): user request.user cache_key fuser_recs_{user.id} # 先查缓存 recs cache.get(cache_key) if recs is None: recs list(UserRecommendation.objects.filter( useruser ).select_related(comic).order_by(rank)[:10]) # 缓存1小时3600秒 cache.set(cache_key, recs, 3600) context {recommendations: recs, user: user} return render(request, personal_center.html, context)缓存命中率监控在 Django Admin 中添加自定义统计页执行cache.get(user_recs_1)和cache.get(user_recs_1)两次观察第二次是否hit。生产环境应监控redis-cli info | grep keyspace中db1:keysxxx,expiresxxx,avg_ttlxxx。5.3 推荐多样性诊断用 Python 脚本计算品类覆盖率Spark 本身不提供多样性指标需用 Python 脚本分析user_recommendations表# diversity_check.py import pandas as pd from sqlalchemy import create_engine engine create_engine(mysqlpymysql://root:passwordlocalhost/guoman_recommender) # 获取所有推荐记录 df pd.read_sql(SELECT ur.user_id, ur.comic_id, c.category FROM user_recommendations ur JOIN comic_data c ON ur.comic_id c.id, engine) # 计算每个用户的推荐品类数 diversity df.groupby(user_id)[category].nunique().describe() print(推荐品类多样性统计) print(f平均品类数{diversity[mean]:.2f}) print(f最小品类数{diversity[min]}) print(f最大品类数{diversity[max]}) # 若平均 2.5说明推荐过于集中如全是“热血”类需在 ALS 前加入品类多样性约束 # 解决方案在 Spark 中为每个 comic_id 添加 one-hot category vectorALS 训练时 concat 到 item features执行此脚本后若输出平均品类数1.8则表明推荐系统存在“信息茧房”风险需在recommender.py中为comic_df添加category_vector特征并在 ALS 训练时启用itemFeatures。最后一行技术动作在 Spark ALS 模型中注入品类向量是突破毕业设计天花板的关键一步——它让推荐从“协同过滤”升级为“混合推荐”而无需引入复杂图神经网络。本文还有配套的精品资源点击获取
返回列表