Python搭建考研教资资讯聚合平台:从Django选型到中文推荐算法实践
先说实话我做这个系统的起因特别朴素身边好几个考研、准备教资的朋友每天在公众号、QQ群、学校官网之间来回切一会儿找招生简章一会儿查考试时间信息散得到处都是还经常漏掉关键节点。我当时就想与其继续当“人肉收藏夹”不如直接用Python把考试资讯聚合起来做一个能分类浏览、关键词搜索、还能自动推荐相关内容的Web平台。技术栈也很清晰后端用Python的Django或Flask前端用Vue开发工具选PyCharm。系统做完之后不仅朋友在用整个过程中踩过的坑和几个关键取舍我觉得也很值得拿出来聊聊。这个项目适合两类人一类是学完Python基础、想找一个完整项目练手的新手另一类是时间紧、需要快速搭建信息聚合类Web应用的开发者。文章会从需求拆解、技术选型、数据库设计、中文匹配算法、前后端实操写到高频问题排查所有代码都是能直接跑起来的。1. 项目整体拆解资讯系统不只是“发文章的网站”1.1 需求本质先把用户画像和核心流程画清楚很多人一听到“考研教资考资讯系统”第一反应就是做个文章列表加个详情页这其实是个误区。这类系统的难点不在增删改查而在怎么让海量资讯对特定用户“有用”。先拆场景。考研用户关注的是院校招生简章、专业目录、国家线、复试通知教资用户关注的是报名时间、考试科目、成绩查询、认定公告。这两类用户有重叠但不完全相同所以分类体系必须分开不能混在一个大类里。再拆用户角色至少分三种游客可以浏览资讯、搜索、看推荐不需要登录注册用户可以收藏资讯、订阅分类、查看个性化推荐管理员负责资讯的发布、编辑、下架以及分类管理角色一分开权限模型和接口设计就清晰了。游客接口只读注册用户接口多出收藏和订阅管理员接口走单独的认证逻辑。核心流程其实只有三条用户进入系统后可以按分类刷资讯也可以搜索关键词找资讯还可以看系统根据历史行为推荐的资讯。管理员在后台发布一条资讯后系统要做分词、关键词抽取、入库然后进入推荐候选池。这三条流程串起来就是一个完整可用的资讯平台。我建议动手前先画三张图实体关系图、接口清单、页面跳转图。不用画得多规范自己能看懂就行但一定要画。我见过太多人上来就写代码写到一半发现缺字段、缺接口回头改数据表是最痛苦的。1.2 技术选型Django还是Flask我的双轨方案Django和Flask之争在Python社区快成月经贴了。我的观点很直接选型不是看哪个框架更“高级”而是看你的业务重不重、工期紧不紧、团队熟不熟。先看对比维度DjangoFlask定位全家桶框架微框架Admin后台自带开箱即用无需自己写或用第三方扩展ORM内置功能强大不内置常用SQLAlchemy认证权限内置User模型、session、权限需要扩展如Flask-Login适合场景业务功能多、后台管理重的项目原型验证、轻量平台、单机小应用学习曲线稍陡概念多平缓上手快迁移部署相对重轻量适合本地部署我的最终做法是双轨主项目用Django因为它的Admin后台和ORM能帮我省掉大量重复劳动同时保留一个Flask的轻量版本用于快速验证算法效果和给朋友演示。实际开发中我建议你也这样操作先用Flask把推荐算法、页面原型跑通再迁移到Django完善管理后台两边代码主体是通用的迁移成本没有想象中高。为什么强调本地部署考研教资资讯这类数据敏感的校内应用很多时候并不想上云一台普通电脑装好Python环境和Node环境就能跑反而更实用。Flask SQLite这种组合天然适合Django加SQLite同样可以不需要一上来就上MySQL、PostgreSQL。1.3 前端选Vue的理由简单直接组件化是最大红利前端这块我当时比较过React、Vue和原生JS。原生JS写交互多了根本维护不住React的生态虽大但学习曲线比Vue陡最终选了Vue。理由有三个。第一Vue的模板语法对后端出身的开发者极友好。写过Django模板或者Flask的Jinja2模板的人看Vue的{{ variable }}插值、v-if、v-for几乎是零成本迁移。第二单文件组件让代码组织非常清晰。一个资讯卡片是一个.vue组件一个分类侧边栏是一个组件一个推荐列表是一个组件各自管各自的样式和逻辑互不干扰。这对一个人维护全栈项目特别重要。第三Vue Router做动态路由很方便。资讯详情页的路由可以写成/article/:id根据ID拉取不同内容分类页可以写成/category/:slug一个组件覆盖所有分类不需要为每个分类单独建页面。前后端分离还有个好处调试接口可以直接用Postman或Apifox测后端API前端页面可以先用Mock数据开发两边并行推进。2. 核心数据模型与中文匹配算法2.1 表结构这样设计后面能少返工数据库是这个系统的地基表设计得好不好直接决定后面要不要返工。我当时按“内容、用户、行为”三个维度来设计总共六张核心表。资讯相关三张表名关键字段说明categoryid, name, slug, typetype区分考研/教资slug用于前端路由articleid, category_id, title, summary, content, source, publish_time, viewscontent存正文source存信息来源tagid, name标签方便推荐系统做匹配用户相关两张表名关键字段说明userid, username, password_hash, role, created_atrole区分普通用户/管理员favoriteid, user_id, article_id, created_at收藏关系表行为记录一张表名关键字段说明behavior_logid, user_id, article_id, action, created_ataction包括view/favorite/search说一下几个容易踩坑的字段设计细节。category表的slug字段很多人会漏掉挺可惜的。如果前端分类页路由用数字ID比如/category/3搜索引擎和用户都看不懂这个数字什么含义用英文短横线命名如/category/kaoyan-zhaosheng可读性就好很多。article表的summary字段最好单独存。有人图省事前端列表页直接content[:100]截取但正文里有图片标签、Markdown符号截出来很难看。发布资讯时自动生成摘要才是正道后面讲过滤算法的时候我再细说。content字段用TextField没毛病但如果正文要存Markdown源码建议单独加一个content_html字段避免前端每次渲染都要现转性能会好一些。2.2 中文关键词精准匹配从失物招领匹配借鉴来的推荐思路这个系统的推荐功能核心思路其实是从一个校园失物招领平台借鉴来的。失物招领平台怎么匹配用户发布一个“蓝色保温杯”系统自动匹配可能匹配的招领信息原理就是先分词再算文本相似度。考研教资资讯系统的推荐完全可以复用这套逻辑用户看了一篇“2024考研国家线公布”的文章系统要能自动找出“考研国家线”“复试分数线”“考研调剂”等相关内容推给他。中文匹配第一个绕不开的环节是分词。英文天然按空格切词中文不行“考研国家线”切成“考研”、“国家”、“线”才能匹配。我用的是jieba分词库轻量、准确率够用、纯Python实现本地部署完全没问题。基础版算法我推荐Jaccard相似度公式很简单两个文本分词后取交集大小除以并集大小得到一个0到1之间的分数。import jieba def jaccard_similarity(text1: str, text2: str) - float: if not text1 or not text2: return 0.0 set1 set(jieba.lcut(text1)) set2 set(jieba.lcut(text2)) # 过滤单字和常见无意义词 set1 {w for w in set1 if len(w) 1} set2 {w for w in set2 if len(w) 1} if not set1 or not set2: return 0.0 intersection set1 set2 union set1 | set2 return len(intersection) / len(union) # 示例 print(jaccard_similarity(2024考研国家线公布, 2024年考研国家线正式公布)) # 分数很高 print(jaccard_similarity(考研国家线公布, 教师资格证考试报名时间)) # 分数很低Jaccard的好处是简单直观、解释性好对小体量的资讯系统足够用。但它有个弱点完全不考虑词频。一篇长文章反复出现“考研”这个核心词Jaccard只会记一次。所以实际项目里我用了一个升级版用sklearn的TfidfVectorizer配合cosine_similarityfrom sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity import jieba def tokenize(text: str): return .join(w for w in jieba.lcut(text) if len(w) 1) def get_recommendations(target_text: str, article_list: list, top_k: int 5): corpus [tokenize(target_text)] [tokenize(a[title] a[summary]) for a in article_list] vectorizer TfidfVectorizer() tfidf_matrix vectorizer.fit_transform(corpus) sim_scores cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:]).flatten() top_indices sim_scores.argsort()[-top_k:][::-1] return [article_list[i] for i in top_indices if sim_scores[i] 0.1]实际效果比纯Jaccard好不少因为它会给高频且重要的词更高的权重。举个生活化的例子Jaccard像是只看两个人认识的朋友名单重合度TF-IDF则是不仅看名单还看他们频繁聊的共同话题是什么。做推荐时还有个小技巧匹配不能只盯正文标题、摘要、标签要加权处理。我的做法是构建索引文本时把标题重复三次再拼上摘要和标签比如title title title summary tags。这样标题里的词在TF-IDF计算中权重自然更高推荐结果会更准。2.3 无效信息过滤和匹配精度优化只做相似度计算远远不够如果不对输入数据做清洗你会看到各种奇怪的“推荐结果”。我遇到过最典型的情况用户搜“考研”结果推荐了一篇正文里包含了大量“考研”字样但其实是卖广告的文章。所以过滤环节一定要做。第一层是停用词过滤。中文里“的、了、和、是、在”这些词没有任何信息量必须过滤掉。我维护了一个大概200个词的中文停用词表网上有现成资源自己也可以逐步积累。建议加到项目的utils目录下独立成一个py文件别塞在算法代码里。第二层是文本长度校验。太短的文本没法做可靠匹配比如用户搜索“考研”只有两个字分词后就一个词任何文章都能沾上边。这种我会做不过滤而是先把热门分类文章按浏览量排序兜底避免推荐结果太惊悚。第三层是内容质量标记。来源不明的文章、重复发布的文章即使被匹配到也会拉低用户体验。资讯发布时我会先跑一遍查重如果标题相似度超过0.85就直接提示管理员“疑似重复”。def is_duplicate(new_title: str, existing_titles: list, threshold: float 0.85) - bool: for old_title in existing_titles: sim jaccard_similarity(new_title, old_title) if sim threshold: return True return False这个阈值0.85是试出来的。一开始用的0.9太宽松重复内容漏进来不少调到0.8又太严格正常的系列文章比如“考研政治每日一题一”“考研政治每日一题二”也会被判断为重复。最终0.85算是一个比较舒服的中间值。3. 实操落地从PyCharm到前后端联调3.1 环境搭建用PyCharm管理项目环境开发环境是整个项目的第一步也是新手最容易翻车的环节。我建议在PyCharm里为这个项目单独创建虚拟环境不要直接装进系统Python否则以后装其他项目依赖时往往会互相污染版本。要知道不同项目之间的包版本冲突是非常痛的例如有的项目要Django 3、有的要Django 4共用一套环境会让你寸步难行。PyCharm创建虚拟环境的路径很直观打开设置找到Project Interpreter点Add Interpreter选Virtualenv EnvironmentPython版本建议选3.9或3.10。太老的3.6很多库已经不支持太新的3.12一开始也容易遇到依赖库没跟上。后端依赖只需要四个django或flask、jieba、sklearn、django-cors-headers或flask-cors。用PyCharm的包管理界面装就行也可以命令行pip install。前端环境需要Node.js和npm。Vue CLI的安装命令很简单npm install -g vue/cli然后vue create web创建项目。如果Network慢可以先用国内镜像源设置方式是这样的在C:\Users\你的用户名\下新建.npmrc文件写入registryhttps://registry.npmmirror.com会快很多。这里多说一句PyCharm社区版完全够用没必要折腾那些来路不明的激活工具社区版支持Python开发的所有核心功能Django和Flask的模板提示也都有省下的时间去把系统功能做好比什么都值。3.2 Django版核心模型、接口与推荐实现创建Django项目我之前一直习惯用命令行django-admin startproject exam_backend cd exam_backend python manage.py startapp article python manage.py startapp user然后把数据模型写进article/models.py我直接给出精简版from django.db import models class Category(models.Model): name models.CharField(max_length50, uniqueTrue) slug models.SlugField(max_length50, uniqueTrue) type models.CharField(max_length20, defaultkaoyan) # kaoyan / jiaoshi class Article(models.Model): category models.ForeignKey(Category, on_deletemodels.CASCADE, related_namearticles) title models.CharField(max_length200) summary models.CharField(max_length300, blankTrue) content models.TextField() source models.CharField(max_length200, blankTrue) publish_time models.DateTimeField(auto_now_addTrue) views models.IntegerField(default0) class Meta: ordering [-publish_time]视图部分用Django自带的分页和ORM查询就能实现核心接口from django.core.paginator import Paginator from django.http import JsonResponse from .models import Article def article_list(request): category_id request.GET.get(category) keyword request.GET.get(keyword, ).strip() page int(request.GET.get(page, 1)) qs Article.objects.all() if category_id: qs qs.filter(category_idcategory_id) if keyword: qs qs.filter(title__icontainskeyword) | qs.filter(summary__icontainskeyword) paginator Paginator(qs, 10) data [{id: a.id, title: a.title, summary: a.summary, source: a.source, publish_time: a.publish_time.strftime(%Y-%m-%d)} for a in paginator.page(page)] return JsonResponse({code: 0, data: data})URL路由我记得写django的时候新手很容易漏import实际很简单from django.urls import path from . import views urlpatterns [ path(api/articles, views.article_list), path(api/articles/int:article_id, views.article_detail), path(api/recommend, views.recommend_list), ]推荐接口就是我们第2章那个get_recommendations函数的封装唯一要注意的是接口返回前一定要过滤掉自己当前已经打开的这篇文章否则就会出现“推荐了你正在看的这条新闻”这种笑话。Django的Admin后台也是它的一大优势注册一下模型就能直接发布资讯from django.contrib import admin from .models import Article, Category admin.register(Article) class ArticleAdmin(admin.ModelAdmin): list_display (title, category, publish_time, views) search_fields (title,) list_filter (category,)这样一个可以日常维护资讯内容的后台就算做完了这类系统将来会不会继续使用很大程度上就看后台录入信息方不方便。3.3 Flask版轻量化替代五步搞定核心服务如果只是给几个朋友用或者部署在一台配置不太高的旧电脑上Flask版反而更合适。我用Flask做了个精简版整个后端代码不到200行。from flask import Flask, request, jsonify import sqlite3 import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity app Flask(__name__) def get_db(): conn sqlite3.connect(exam.db) conn.row_factory sqlite3.Row return conn app.route(/api/articles) def list_articles(): conn get_db() keyword request.args.get(keyword, ).strip() if keyword: rows conn.execute(SELECT * FROM article WHERE title LIKE ? OR summary LIKE ?, (f%{keyword}%, f%{keyword}%)).fetchall() else: rows conn.execute(SELECT * FROM article ORDER BY publish_time DESC LIMIT 20).fetchall() return jsonify({code: 0, data: [dict(r) for r in rows]}) app.route(/api/recommend) def recommend(): target request.args.get(query, ) conn get_db() articles conn.execute(SELECT * FROM article).fetchall() # 复用上一节的推荐逻辑 corpus [ .join(w for w in jieba.lcut(target) if len(w) 1)] article_texts [] for a in articles: article_texts.append( .join(w for w in jieba.lcut(a[title] a[summary]) if len(w) 1)) # 处理空文本 if not article_texts or all(len(t) 0 for t in article_texts): return jsonify({code: 0, data: []}) vectorizer TfidfVectorizer() matrix vectorizer.fit_transform(corpus article_texts) scores cosine_similarity(matrix[0:1], matrix[1:]).flatten() top_idx scores.argsort()[-5:][::-1] results [dict(articles[i]) for i in top_idx if scores[i] 0.1] return jsonify({code: 0, data: results}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)Flask版我用SQLite存数据建表语句手动执行一次就行初始化脚本里顺便塞几条测试资讯方便后面联调。关于Flask的一句话总结如果你要做一个“能跑、能演示、能本地部署”的轻量平台它是绝佳选择如果你要做一个“能持续运营、有后台权限管理”的产品还是老老实实上Django。3.4 Vue前端资讯列表、详情页与关键词搜索前端页面我分成三个核心组件分类侧边栏、资讯列表、搜索框再加上一个资讯详情页。路由设计如下// router/index.js import { createRouter, createWebHistory } from vue-router import Home from ../views/Home.vue import ArticleDetail from ../views/ArticleDetail.vue const routes [ { path: /, name: Home, component: Home }, { path: /category/:slug, name: Category, component: Home }, { path: /article/:id, name: ArticleDetail, component: ArticleDetail } ] const router createRouter({ history: createWebHistory(), routes })资讯列表页的写法用Vue3的组合式APIscript setup import { ref, onMounted, watch } from vue import { useRoute } from vue-router import axios from axios import ArticleCard from ../components/ArticleCard.vue const route useRoute() const articles ref([]) const loading ref(false) async function fetchArticles() { loading.value true try { const params {} if (route.params.slug) params.category route.params.slug if (route.query.keyword) params.keyword route.query.keyword const res await axios.get(/api/articles, { params }) articles.value res.data.data } finally { loading.value false } } onMounted(fetchArticles) watch(() route.params.slug, fetchArticles) /script template div classhome div classsearch-box input v-modelkeyword placeholder搜索考研/教资资讯... / button clicksearch搜索/button /div div classarticle-list ArticleCard v-forarticle in articles :keyarticle.id :articlearticle / /div /div /template这里我踩过一个坑watch监听路由参数变化时需要同时监听route.params.slug和route.query.keyword一开始只监听了前者导致从搜索页跳转分类页时列表不刷新。写监听器时宁可多写一个监听来源也不要少写。资讯卡片组件就是一个展示用的子组件点击标题跳转详情页template div classcard clickgoDetail h3{{ article.title }}/h3 p{{ article.summary }}/p div classmeta span{{ article.source }}/span span{{ article.publish_time }}/span /div /div /template script setup import { useRouter } from vue-router const props defineProps({ article: Object }) const router useRouter() function goDetail() { router.push(/article/${props.article.id}) } /script详情页根据路由参数ID请求接口渲染正文。如果正文是Markdown格式前端用marked库转HTML再配合一点CSS样式阅读体验就很在线了。3.5 联调跨域处理与本地部署前后端分离开发时前端跑在8080端口后端跑在8000端口或5000端口浏览器会拦截跨域请求。Django需要装django-cors-headers然后在settings里配置INSTALLED_APPS [ # ... corsheaders, ] MIDDLEWARE [ # ... corsheaders.middleware.CorsMiddleware, # ... ] CORS_ALLOW_ALL_ORIGINS True # 开发环境先全放开Flask就简单一点from flask_cors import CORS CORS(app)开发联调通过后部署有两种方式。第一种是纯本地运行后端python runserver前端npm run serve两个终端窗口各跑各的自己能访问就行。第二种是更正式的部署前端npm run build产出静态文件让Django或Flask直接托管这个目录用户访问一个端口就能用整套系统省掉跨域问题。我个人推荐第二种演示起来方便也更接近真实上线。4. 开发中常见的坑与排查技巧4.1 Token、跨域、实时推送这几个问题最容易被卡住开发和联调阶段最容易卡住的有四个问题我逐个说下排查思路。第一Django的CSRF和自定义Token怎么共存。Django默认会在POST请求时校验CSRF Token但前后端分离后前端通过axios发送JSON请求往往不带CSRF Token。一个方案是JWT用djangorestframework-simplejwt库登录后返回一个Token前端请求时在Header里带上Authorization: Bearer token。另一个更轻的方案是直接给API视图加csrf_exempt装饰器只管接口开发效率但这只适合内部系统或学习项目正规上线还是建议JWT。第二搜索接口返回慢。很多人一上来就用LIKE %keyword%查数据库数据量一上来就卡。我的做法是搜索条件限定在标题和摘要两个短字段上先用数据库检索粗筛再对结果集做相似度排序这样接口响应时间可以稳定在几百毫秒以内。如果你的数据量真到了几万条以上就得考虑全文索引或者接入专门的搜索引擎了不过对资讯系统来说前期真的不需要。第三后台有数据前端怎么实时推送。最正统的方案是WebSocket但重。Django要装ChannelsFlask要装Flask-SocketIO配置相对复杂。如果只是想让用户感知“有新资讯了”先用简单的轮询或者Server-Sent EventsSSE就足够了。前端每隔30秒请求一次最新的资讯ID和本地缓存的对比不一样就弹提示。这个方案代码量极小实测体验也还行。等用户量真正上来再换WebSocket不迟。第四用户反馈推荐结果不相关。这个八成不是算法问题而是数据问题。先检查新增的资讯有没有正确的摘要和标签再看停用词表是否需要更新。我遇到过最离谱的情况是用户搜索“考研英语”推荐列表里全是“英语四六级”原因是两篇文章都频繁出现“英语”这个词这时候就必须靠标签做领域约束推荐时加上分类过滤条件。4.2 环境类问题速查表照表排查能省半小时问题现象排查思路PyCharm不识别Python解释器代码一堆红色波浪线Project Interpreter重新选择虚拟环境的python.exepip安装包超时终端卡在Downloading改用国内镜像源vue命令找不到提示不是内部或外部命令重新安装Vue CLI确认Node安装成功端口被占用runserver或serve启动即报错换端口或找到占用进程结束掉数据库迁移不生效新增字段后查询报错python manage.py makemigrationsmigrate都要执行jieba导入慢首次import要等几秒正常现象分词词典加载需要时间前端请求404接口路径不对检查Vue的axios baseURL和后端URL是否一致中文乱码数据库里和页面上显示乱码建库建表时统一用utf8mb4或utf8这里再补充一个我自己摸索出来的经验项目一开工就把依赖清单固定下来。后端在项目根目录准备一个requirements.txt前端启用package.json的lock文件。这样不管换电脑还是给别人复现项目两条命令就能还原全部环境比所谓“在一台电脑上一遍过”的运气要靠谱得多。4.3 匹配推荐效果优化的调试经验推荐效果是这类系统最容易“看起来能用用起来鸡肋”的部分。分享三个调优方向。先把分词结果可视化。写一个临时脚本输入一句话打印jieba.lcut的结果。这样可以快速发现哪些该合的词被切开哪些该切的词没切开。考研资讯里“国家线”常被切成“国家”和“线”我会在自定义词典里提前加入“国家线”“复试线”“调剂系统”这些领域词。再调整匹配策略。TF-IDF是全局加权但对短文本推荐Jaccard反而更直接。我的实操经验是标题匹配用Jaccard正文匹配用TF-IDF余弦相似度两轮结果合并去重后再综合排序。这样短标题和长正文的优势都能发挥。最后是记录日志。推荐接口每次请求都打印匹配分Top10的标题和分数。你看着日志做调优比盲猜有效十倍。我调试“无效信息过滤”阈值的时候就是靠日志发现0.85这个临界值的0.84放进来一条高度相似但确实是不同考试的指南0.86却把同主题的系列文章都拦了最后锁在0.85刚好。写在最后说实话这类系统技术上不算多高深真正有价值的是把一堆零散的信息变成能搜、能推、能用起来的流程以及在整个开发过程中对一些关键决定的思考Django和Flask怎么选、推荐算法用Jaccard还是TF-IDF、前端要不要上Vue全家桶。这些问题的答案不是固定的但每个都值得你在自己的项目里试一遍。如果你也是Python入门不久建议就先从Flask版跑起来加上几个分类和文章把推荐效果调到舒服的状态再考虑要不要迁移到Django去完善管理后台。过程中遇到卡住的地方照着我前面说的几个排查方向去试大部分都能顺利绕过去。这个系统后续还可以加上考试倒计时提醒、订阅推送、甚至爬虫自动抓取官网公告扩展方向很多就看你的实际场景需要什么了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →