ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Python的手机舆情系统:爬虫采集、MySQL存储与Flask搜索实战

基于Python的手机舆情系统:爬虫采集、MySQL存储与Flask搜索实战 简介一份围绕手机行业舆情监测的Python应用综述适合具备基础Python语法、想进一步掌握爬虫与Flask Web开发的读者。文档完整梳理了基于urllib抓取权威资讯站、通过re和w3lib.html提取关键字段并清除HTML标签、再以MySQL持久化存储的技术链路同时分析了Flask后端与HTML/CSS/JavaScript/jQuery前端的交互实现覆盖自定义频道、栏目关键词检索、查看更多等实际功能模块。资源包含1个PDF文档共1.61MB便于直接阅读和快速查阅已有219人学习下载。读者可从中理解舆情数据从采集、清洗、存储到页面展示的完整工程流程也可借鉴正则匹配、网页提纯、Flask轻量级部署等具体编码细节适合作为课程设计、毕业设计或Python全栈入门项目参考资料。1. 手机舆情系统的本质不是爬虫是数据筛选器很多人第一次看到“手机舆情系统”这个词第一反应是“又一个爬虫项目”。但真把需求拆开看它的核心并不在抓取而在“过滤”。用户不是想要全网所有手机资讯而是想按自己定义的关键词比如“骁龙 8 Gen2 翻车”“折叠屏 铰链 异响”从权威手机资讯站里快速捞到相关文章。这套基于 Python 的舆情系统就是这么设计的用 urllib 抓取列表页和正文页用正则抽取目标字段用 w3lib.html 做网页提纯落库 MySQL再用 Flask 把“自定义频道—自定义栏目—关键词匹配—文章列表”串成一条可交互的链路。适合三类人准备买手机但不想被广告淹没的普通用户、做竞品舆情监测的产品运营、想低成本搭建垂直资讯检索工具的 Python 开发者。它解决的真正痛点不是“爬不到”而是“爬到了但筛不出有用的”。2. 数据采集链路urllib 抓取、正则匹配与 w3lib.html 提纯2.1 为什么选 urllib 而不是 requests这个系统的数据采集层设计得比较朴素直接用 Python 标准库 urllib 完成页面下载。放在今天很多人会优先选 requests因为它封装了会话、代理和重定向代码更短。但 urllib 的价值在于零依赖、跨环境稳定在早期 Python 教学和论文场景里是默认选择。我一般会这么看待两者的取舍如果目标是“快速验证抓取逻辑”requests 更顺手如果目标是“最小化运行环境、避免第三方库版本冲突”urllib 依然是合格方案。urllib 抓取页面的基本套路是三步构造 Request 对象、携带 User-Agent 请求头、读取响应内容。下面是抓取一个资讯列表页的示例。import urllib.request def fetch_page(url: str) - str: headers { # 很多站点会拦截默认 UA伪装成浏览器可降低被拒概率 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } req urllib.request.Request(url, headersheaders) # 超时设 10 秒避免单个源卡死整个采集任务 with urllib.request.urlopen(req, timeout10) as resp: # 很多中文资讯站是 utf-8但部分老站点是 gbk需要按实际页面编码解析 return resp.read().decode(utf-8, errorsignore)这段代码里有三个关键参数timeout10是连接超时超过 10 秒就抛异常防止某个站点响应缓慢拖垮整批任务errorsignore在解码时忽略无法识别的字节避免因个别乱码字符中断抓取User-Agent是服务器识别客户端身份的字段改成常见浏览器的值能减少被反爬策略拒绝的情况。注意这里的抓取对象是权威手机资讯站的文章页不是高频接口所以这个力度已经够用。如果对方有更严格的反爬还需要加延时和重试但那是另一个话题。2.2 用 re 正则从列表页批量生成文章 URL抓回列表页 HTML 之后下一步是提取文章链接和标题。原论文用的是 Python 的re包。正则在这类半结构化页面里依然是最直接的武器尤其是当你只想拿a href...标题/a这种固定格式时。下面是我常用的一段提取逻辑import re def extract_article_links(html: str, domain: str): # 匹配常见的 a href绝对或相对路径标题/a # 这里仅匹配链接文字长度在 5~80 之间的过滤掉“首页”“更多”这类导航 pattern ra[^]href([^])[^]*([^]{5,80})/a results [] for href, title in re.findall(pattern, html): # 跳过 javascript 伪链接和外部站点 if href.startswith(javascript) or href.startswith(#): continue # 相对路径补全成绝对地址方便后续去重和抓取正文 full_url href if href.startswith(http) else domain href results.append({title: title.strip(), url: full_url}) return resultsre.findall返回所有匹配的二元组href和title的提取规则都写在 pattern 里。[^]{5,80}表示标题字符数在 5 到 80 之间太短的通常是“下一页”“登录”这类导航文字直接跳过。拿到相对路径后用domain href拼成绝对地址因为后续要逐个请求正文页没有完整 URL 是抓不下来的。这里还有一个隐藏问题有些站点的链接不是直接指向 HTML 文章页而是带参数的跳转地址比如/news/123.html?findex。如果正文页 URL 不干净后面的去重会很难做所以建议在拼接前用urlparse把 query 参数清掉只保留路径部分。2.3 用 w3lib.html 去除 HTML 标签而不是自己写正则正文页的 HTML 比列表页复杂得多里面混着广告脚本、推荐位、面包屑导航和一堆div嵌套。如果继续用正则硬剥标签很容易把文章正文里的符号误伤。这里的规范做法是用w3lib.html库它是 Scrapy 生态里专门处理 HTML 文本的库和scrapy.utils同源单独拿出来用也很方便。from w3lib.html import remove_tags, remove_tags_with_content import html as html_module def clean_article_content(raw_html: str) - str: # 先把 script 和 style 整块内容连标签带内容一起删掉 without_script remove_tags_with_content(raw_html, which_ones(script, style)) # 再删除剩余的所有标签只留文本 text_only remove_tags(without_script) # 反转义 nbsp; amp; 等 HTML 实体否则页面上会显示乱码 return html_module.unescape(text_only).strip()remove_tags_with_content的作用是连标签带内部内容一起删除专门对付script里的 JS 逻辑和style里的 CSS这些文本即使剥离了标签也会污染正文。之后再用remove_tags把所有剩余标签剥掉。最后用标准库html.unescape把nbsp;转成真正的空格、amp;转成。这一步很容易漏一旦漏掉存入 MySQL 的文章中会出现大量nbsp;和ldquo;之类的残留影响之后的全文搜索匹配。以下表格汇总了本章用到的主要处理函数和用途方便后续排查时对照。函数所属库用途常见坑urllib.request.urlopenurllib下载网页内容不设超时或 UA容易被拒re.findallre提取列表页中的链接和标题网页改版后正则失效remove_tags_with_contentw3lib.html删除 script/style 整块内容漏掉 iframe 里的内容remove_tagsw3lib.html删除剩余 HTML 标签会把超过符号当标签头html.unescapehtml反转义 HTML 实体漏掉后出现乱码文本还有一个容易忽略的细节remove_tags只删标签不去处理注释!-- --。如果页面里残留大段注释它们也会混进正文。我一般会在脱标签之前先执行一次re.sub(r!--.*?--, , html, flagsre.S)把注释去掉这不算过度处理而是保证入库文本干净的必要步骤。3. 存储层设计按网站分表的 MySQL 结构与编码陷阱3.1 数据库编码全 utf-8 为什么插不进中文采集到的文章文本最终要落到 MySQL。原论文特意提到一个现象把所有character_set_client、character_set_connection、character_set_results都设为 utf-8 后中文仍然插入失败。这个问题的根因不在表字段的 charset而在客户端连接层与服务器实际字符集不一致。在 MySQL 中写入链路是客户端连接字符集 → 服务端接收字符集 → 表字符集。character_set_client告诉服务器“客户端发来的字节是什么编码”如果客户端用 utf-8 发送但character_set_client是 gbk服务器就会按 gbk 解读 utf-8 字节导致乱码或报 Incorrect string value 错误。原论文最终采用的方案是混合编码client、connection、results 设为 gbk而 database 和 server 保持 utf8。这在实际操作中是一种可用配置但要注意它的适用范围仅在数据内容确实以 gbk 形式从 Python 连接发送时成立。如果 Python 连接串里写的是charsetutf8那么character_set_client就必须是 utf8否则照样出错。这里不要死搬论文的编码表而应该理解三者的对应关系。下面这段是我常用的一键校对方法-- 查看当前连接和数据库的字符集配置 SHOW VARIABLES LIKE character\_set\_%; -- 如果发现 client 与数据库不一致指定连接编码重新连接 SET NAMES utf8mb4;SET NAMES utf8mb4会把character_set_client、character_set_connection、character_set_results统一设为 utf8mb4再配合表结构DEFAULT CHARSETutf8mb4中文和 emoji 都不会出问题。为什么原论文不用 utf8mb4因为早期 MySQL 5.5 和 Python 2 时代 utf8mb4 支持还不完善而且 gbk 占用字节少能省一点内存。现在的项目直接用SET NAMES utf8mb4更省心。3.2 每站一表稳定性优先于查询效率数据库建表策略是这套系统里一个值得借鉴的设计每个资讯网站一张表而不是把 9 个来源的文章混在一张大表里。原论文的理由是避免单表数据量过大一旦表损坏导致全部数据不可用。每站一表确实牺牲了跨表查询性能——想同时搜中关村在线和太平洋时得写 UNION 或多表查询。但从运维角度看这种隔离让单站抓取失败后的排查成本变得非常低某个表结构变了只影响那一张表不影响其他来源。以中关村在线为例建表语句可以写成这样CREATE TABLE IF NOT EXISTS zol ( zol_id INT NOT NULL AUTO_INCREMENT, zol_title VARCHAR(200) NOT NULL COMMENT 文章标题, zol_url VARCHAR(300) NOT NULL COMMENT 文章原始链接, zol_time VARCHAR(50) DEFAULT NULL COMMENT 发布时间, zol_content MEDIUMTEXT COMMENT 清洗后的正文, PRIMARY KEY (zol_id), UNIQUE KEY uk_zol_url (zol_url) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT中关村在线文章表;字段类型上注意两点一是zol_url必须加UNIQUE KEY这是最原始的去重手段。爬虫重复抓取时直接INSERT IGNORE就能跳过已有记录不需要先查询再判断。二是正文用MEDIUMTEXT最多能存 16MB一篇手机评测文章通常只有几十 KB完全够用。不要用TEXT因为TEXT最多 64KB遇到长文会被截断。zol_time故意用VARCHAR而不是DATETIME因为各站点的时间格式五花八门有2015-02-19 10:00也有02月19日先按字符串存下来等后续需要排序时再用STR_TO_DATE转换这是处理异构数据源时比较稳妥的做法。每站一表的工作量是显而易见的9 个来源就是 9 张结构相似的表字段前缀不同。实际开发时我不会手写 9 遍建表语句而是用一段 Python 脚本生成sites { zol: 中关村在线, sina: 新浪手机, 3533_: 手机世界, pcpop: 泡泡手机, imobile: 手机之家, cnmo: 手机中国, 163_: 网易手机, it168: IT168手机, } for prefix, name in sites.items(): sql f CREATE TABLE IF NOT EXISTS {prefix} ( {prefix}_id INT NOT NULL AUTO_INCREMENT, {prefix}_title VARCHAR(200) NOT NULL, {prefix}_url VARCHAR(300) NOT NULL, {prefix}_time VARCHAR(50) DEFAULT NULL, {prefix}_content MEDIUMTEXT, PRIMARY KEY ({prefix}_id), UNIQUE KEY uk_url ({prefix}_url) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; print(sql)这个生成逻辑相当于把建表规则模板化后面新增一个资讯源时只需要往sites字典里加一项脚本就能自动产出对应建表语句。注意3533_和163_这些表名以数字开头或关键字冲突的场景MySQL 里直接写会语法错误所以必须用反引号包裹。这也是做成自动生成脚本比手写更不容易漏反引号的原因之一。3.3 写入时的参数化与去重存储层最后一步是把清洗后的文章写进对应表。很多初学者写插入语句时会把 URL 和标题用字符串拼接这样做有两个风险一是文章标题里可能含有单引号直接断句二是构造 SQL 时有注入风险。正确做法是用参数化查询。我一般用 PyMySQLimport pymysql def insert_article(table: str, title: str, url: str, pub_time: str, content: str): conn pymysql.connect( host127.0.0.1, userroot, password123456, databasepublic_opinion, charsetutf8mb4 ) try: with conn.cursor() as cur: sql f INSERT IGNORE INTO {table} ({table}_title, {table}_url, {table}_time, {table}_content) VALUES (%s, %s, %s, %s) cur.execute(sql, (title, url, pub_time, content)) conn.commit() finally: conn.close()INSERT IGNORE配合建表时的UNIQUE KEY实现幂等写入如果 URL 已经存在这次插入会被忽略不会报错也不会产生重复数据。%s占位符由 PyMySQL 负责转义标题正文里的任何特殊字符都安全。charsetutf8mb4必须和数据库字符集一致否则即使表结构正确连接层字符集不一致依然会写入乱码。这里刻意把表名用 f-string 拼接是因为表名无法用占位符但表名来自内部白名单sites字典不会来自用户输入所以是安全的。如果你担心表名被污染可以在插入前加一句assert table in sites把意外情况直接暴露出来。4. Flask 后台与全文搜索自定义频道、栏目和关键词匹配4.1 Flask 路由与模板渲染把数据库文章变成页面后台部分采用 Flask它的路由设计和 Python 语法一样简洁。系统里“自定义频道”和“自定义栏目”本质上是同一种东西一个配置了关键词的过滤器。频道是顶级分类比如“安卓资讯”“苹果资讯”栏目挂在频道下每个栏目有自己的关键词。用户进入栏目页后台就拿这个关键词去数据库里做全文搜索。下面是一个简化但完整的 Flask 视图函数from flask import Flask, render_template, request, jsonify import pymysql app Flask(__name__) def query_articles(keyword: str, limit: int 10, offset: int 0): # 连库操作抽出来避免每个视图重复写连接参数 conn pymysql.connect( host127.0.0.1, userroot, password123456, databasepublic_opinion, charsetutf8mb4 ) # 9 张来源表的表名固定白名单 tables [zol, sina, 3533_, pcpop, imobile, cnmo, 163_, it168] results [] keyword_escaped keyword.replace(%, \\%).replace(_, \\_) with conn.cursor() as cur: for table in tables: sql f SELECT {table}_title AS title, {table}_url AS url, {table}_time AS pub_time FROM {table} WHERE {table}_title LIKE %s OR {table}_content LIKE %s ORDER BY {table}_id DESC LIMIT %s OFFSET %s # 参数化查询防止注入通配符转义后用户输入的 % 只是普通字符 cur.execute(sql, (f%{keyword_escaped}%, f%{keyword_escaped}%, limit, offset)) results.extend(cur.fetchall()) conn.close() return results这段查询做了两件事第一遍历 8 张来源表对每张表在标题和正文里执行LIKE模糊匹配第二用LIMIT和OFFSET做分页对应前端的“查看更多”。keyword.replace(%, \\%)很关键它把用户输入里的%和_转义成普通字符否则用户搜“100%”时%会被当成通配符匹配到所有文章。ORDER BY id DESC保证最新入库的文章排在前面。页面渲染部分Flask 用render_template把文章列表传给前端模板模板里用 Jinja2 语法循环输出。具体到“自定义频道”“自定义栏目”这两个功能后台需要两张配置表一张存频道一张存栏目栏目表里带channel_id外键和keyword字段。配置表的操作无外乎增删改查这里不展开重点是搜索逻辑和分页逻辑要复用同一个query_articles函数避免在多个视图里复制粘贴 SQL。4.2 LIKE 全文搜索的边界与替代方案用LIKE %keyword%做全文搜索在数据量小的时候完全没问题。手机舆情系统单站日增量是几十篇跑一年也就一两万条记录LIKE加上ORDER BY id DESC配合 MySQL 的索引响应时间在几十毫秒级别。但如果文章量过十万这个写法就会明显变慢原因是%keyword%这种前导通配符无法利用普通索引只能全表扫描。这时候有两个升级方向一是改用 MySQL 的FULLTEXT索引配合MATCH ... AGAINST做真正的全文检索二是接入 Elasticsearch。对这套系统来说Elasticsearch 有些重但FULLTEXT是零额外依赖的合理过渡。建表时给内容字段加全文索引就能用ALTER TABLE zol ADD FULLTEXT INDEX ft_zol_content (zol_content);然后查询改成SELECT zol_title, zol_url, zol_time FROM zol WHERE MATCH(zol_content) AGAINST(处理器 功耗 IN BOOLEAN MODE);IN BOOLEAN MODE支持、-等操作符比如处理器 -火龙表示必须包含“处理器”但不能出现“火龙”。不过要注意FULLTEXT的默认 parser 对中文分词支持并不好需要配合ngram解析器才能正确处理中文。所以在系统早期LIKE反而是最可控的方案。我的习惯是维持LIKE作为主查询预留FULLTEXT索引的建表脚本等数据量上来了再切换而不是一上来就把架构复杂化。4.3 前端 jQuery 实现“查看更多”避开整页刷新系统前端用 HTML、CSS、JavaScript 和 jQuery 实现页面交互。“查看更多”按钮是典型的 Ajax 场景点击按钮向后台请求下一页数据然后追加到当前列表不刷新整个页面。jQuery 写法很短但需要注意参数组织方式$(function () { let offset 0; const limit 10; let keyword $(#search-keyword).data(keyword); $(#load-more).click(function () { offset limit; $.ajax({ url: /api/articles, method: GET, data: { keyword: keyword, limit: limit, offset: offset }, dataType: json }).done(function (resp) { if (resp.length 0) { // 没有更多数据时禁用按钮而不是反复请求 $(#load-more).prop(disabled, true).text(没有更多了); return; } // 把返回的文章列表追加到容器末尾 resp.forEach(function (item) { $(#article-list).append( lia href item.url target_blank item.title /a span classtime item.pub_time /span/li ); }); }).fail(function () { // 失败时恢复 offset防止用户点一次丢一条数据 offset - limit; alert(加载失败请稍后重试); }); }); });代码里的data(keyword)是从模板渲染结果里取关键词不写死在 JS 里这样同一个页面文件可以被不同频道复用。offset是当前已加载的条数每次点击增加limit。.fail里把offset回滚是因为如果请求失败但前端已增加偏移量下一次成功时会跳过一批数据。服务端对应的/api/articles路由直接复用前面写的query_articles函数返回 JSON 数组给前端。整个交互链路是栏目页加载时先渲染第一页点击查看更多时 Ajax 请求后续数据JSON 到达后拼接 DOM。这套逻辑不依赖现代前端框架却足够支撑一个以内容展示为核心的舆情系统。下面表格列出前后端的关键接口约定方便团队协作时对齐参数。参数类型必填说明keywordstring是栏目配置的关键词URL 编码后传输limitint否单次返回条数默认 10offsetint否起始偏移量默认 0返回值JSON 数组-元素含title、url、pub_time字段参数化的好处是前端改动成本低。比如想改成“每页显示 20 条”只需要改 JS 里的limit 20后端无需变化。如果后续要加“按时间排序”或“按来源过滤”在/api/articles里加参数即可不用动数据库表结构。5. 上线前必做的验证与三个易踩的坑5.1 用真实文章验证查全率与查准率系统开发完成后不能只在本地跑通流程就宣告结束。我用一个简单的脚本验证采集效果每个来源取最近 20 篇文章标题人工确认这 20 篇是否都进了库。比对标准是查全率和查准率。查全率指网站真实文章有多少被正确抓取查准率指抓下来的内容里有多少是有效文章而非广告或导航。脚本逻辑是先查库再和源站比对python check_coverage.py --tables zol,sina --days 7check_coverage.py内部做的事情是按zol_time筛选最近 7 天的记录输出每个表的文章数量再随机抽 5 篇打开原链接核对标题和正文是否一致。我一般要求核心来源查全率不低于 95%如果低于这个值优先看正则是否漏掉了分页列表里的链接。5.2 正则更新后如何回归站点改版是爬虫项目最大的不稳定因素。改版后原来的正则可能匹配不到链接或者匹配到错误内容。我在re提取函数入口加了一个简单的断言式检查每个列表页至少提取到 10 个合法链接否则就告警。这样能尽早暴露问题而不是等用户发现列表页是空的时候才排查。5.3 关键词越界%与_的转义要写到每个查询里我见过最隐蔽的问题是搜索“小米 14_ultra”时搜不到结果原因是_在LIKE中是单字符通配符14_ultra会匹配14Xultra也可以匹配14_ultra但它同时也可能把用户的搜索意图扩大。更重要的是搜索“100%好评”时%直接变成任意匹配返回一堆不相干内容。这个问题在第四章已经写了转义代码但要提醒的是如果系统里有多个搜索入口比如栏目关键词、用户手动搜索框、后台管理搜索每个入口都必须做同样的转义。我通常把转义逻辑封装成一个公共函数def escape_like(keyword: str) - str: # 反斜杠本身也要先转义否则 replace 结果可能出错 return keyword.replace(\\, \\\\).replace(%, \\%).replace(_, \\_)这个函数在查询前统一调用而不是在每个视图里手写replace。除此之外还有一个常被忽略的验证点空关键词搜索必须返回空列表不能把全表数据抖出去。在query_articles开头加一句if not keyword.strip(): return []既保护数据库也避免用户误触发生全量加载。本文还有配套的精品资源点击获取
返回列表