ARTICLE DETAIL

资讯详情

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

Python爬虫实战:从旅游网站数据采集到智能推荐系统全流程

Python爬虫实战:从旅游网站数据采集到智能推荐系统全流程 1. 项目概述与整体设计思路1.1 为什么要做这个“旅游大神养成”项目先交代一下背景。我本身是个喜欢折腾数据的人这几年用Python写过不少爬虫脚本从电商商品价格监控到影评数据抓取都试过。但真正让我觉得“爬虫这东西不只是技术玩具”的是一次旅行规划时被信息淹没的崩溃体验想查某座城市的景点、门票、开放时间、用户评分得在好几个App和网站之间来回切换手动抄录到Excel里对比。折腾了整整一个晚上行程还没定下来人先累了。做完这个项目之后我的感受是数据采集技术完全可以用来解决这类“信息整理”问题。这个项目做的事情很简单用Python爬虫把目标旅游网站上公开的景点信息抓下来经过清洗和结构化存储再基于用户偏好做一个简单的智能推荐系统。说白了就是先把自己变成“数据收集狂魔”再让自己变成“推荐引擎”。需要说明的是这个项目面向的读者范围很广刚开始学Python但不知道能做什么的人可以用它串联requests、BeautifulSoup、Pandas、SQLite这些常用工具有一定爬虫基础但想尝试完整项目闭环的人可以重点看推荐系统部分哪怕你只是对旅行感兴趣也能从数据视角重新理解“怎么选景点”。有一点我必须开头就讲清楚整个项目的数据来源必须选择允许爬取、且带有公开数据接口的网站抓取频率要克制任何绕过登录验证、破解加密参数的骚操作都不在这个项目的范围内。爬虫的边界感比爬虫本身的技术含量更重要。1.2 技术选型为什么是requests BeautifulSoup而不是Scrapy先看整个项目的技术基座。很多人一提到爬虫就想到Scrapy觉得那才是“正规军”。但我在这个项目里刻意没有用它原因有三个第一这个项目的核心价值在于把“采集-清洗-存储-推荐”全链路跑通重点不是高并发采集第二requests配合BeautifulSoup足以覆盖大多数静态页面的需求代码逻辑更透明适合学习第三Scrapy的工程结构对新手来说太重了写完了可能都不知道数据是怎么流转的。如果遇到需要渲染JavaScript才能加载内容的站点我建议优先用Playwright这类自动化工具这个是后话了。不过在这个项目中我特意选了一个服务端渲染的旅游信息页面作为数据源——直接通过requests请求就能拿到完整的HTML内容这是最简单也最稳的学习路径。至于存储方案我选了SQLite而不是MySQL。原因很简单这是一个个人项目SQLite零配置、单文件、Python内置支持把数据库文件复制一份就能带走对数据量在几万条级别的小型系统来说完全够用。后面如果你想扩展换成MySQL也就是改几行连接代码的事。整个项目的架构可以拆成四个模块采集层、清洗层、存储层、推荐层。每个模块的职责是单一的模块之间通过数据格式解耦。这个设计思路也是我踩过坑之后总结出来的一开始我把所有代码写在一个文件里结果改一处就要连带改三处后来才按职责拆散。代码结构这件事越早意识到越省事。2. 爬虫核心实现从请求到解析2.1 如何选择目标网站和分析URL规律目标站点我选的是一个公开的城市旅游信息页面这类站点的页面结构通常是列表页详情页的树形结构。先说列表页它展示一座城市的全部景点包含名称、评分、简介等摘要信息。再看详情页点击任意一个景点会进入独立页面里面有更详细的内容比如地址、开放时间、门票信息、游客评价等。这个树形结构的爬取思路就是两层循环第一层遍历列表页拿到所有景点详情页的URL第二层逐个访问详情页提取完整字段。整个过程看起来简单但有一个关键的前提——对URL规律的观察和推断。我通常的做法是先在浏览器里打开列表页翻到第二页、第三页看URL的变化规律。这个项目的列表页URL是典型的查询参数风格https://example-travel.com/pois?citybeijingpage1 https://example-travel.com/pois?citybeijingpage2规律非常明确page参数控制分页。详情页URL则是纯路径风格比如/poi/10086用景点ID表示。这里有个容易被忽略的细节有些站点的列表页会标注总页数有些不会。我在代码里用了两种方式兜底解析页面中“共X页”的文字如果没有就循环请求直到返回空列表为止。这个逻辑能避免写死页数上限导致漏采也能防止无限循环。2.2 请求头伪装与请求参数的细节处理拿到URL规律之后很多人会直接写requests.get(url)然后就能成功。但真实场景没那么简单。有些站点会校验User-Agent如果你用默认的python-requests/2.x大概率会收到403或者被重定向到验证页面。我之前踩过一次坑第一次请求就带了默认UA结果返回了一堆乱码——对方直接返回了压缩后的异常页面。所以现在我写爬虫第一件事就是把请求头完整写清楚import requests headers { 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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, } session requests.Session() session.headers.update(headers) response session.get(https://example-travel.com/pois?citybeijingpage1, timeout10)这里我用了requests.Session()而不是直接requests.get()原因是Session会保持TCP连接复用和Cookie的自动管理连续翻很多页时效率更高。timeout10也很重要它避免了某个请求卡住之后整个程序挂死。另一个经常被忽略的点响应编码。部分旅游站点采用UTF-8编码但有些老站点可能是GBK。如果直接使用response.text中文可能变成乱码。我现在一律用response.encoding response.apparent_encoding来让requests根据页面内容自动判断编码实测下来很少有乱码问题。如果你打算长期维护这个爬虫我建议把请求部分封装成可复用的函数并把所有可配置项UA字符串、请求间隔时间、超时时间统一放到文件顶部的配置区。一个小项目的技术债很多时候都是从“临时写死一个参数”开始积累的。2.3 页面解析BeautifulSoup的使用与字段提取解析HTML我用的BeautifulSoup因为这个项目的站点结构不算复杂用正则表达式处理太脆弱用XPath又要额外引入lxml的语法心智负担。BeautifulSoup在这类场景里最顺手语法直观容错性也强页面结构稍微脏一点也能处理。下面是我解析列表页的核心逻辑from bs4 import BeautifulSoup def parse_list_page(html): soup BeautifulSoup(html, html.parser) poi_items [] for card in soup.select(div.poi-card): item { poi_id: card.get(data-id), name: card.select_one(h3.poi-name).get_text(stripTrue), rating: card.select_one(span.rating).get_text(stripTrue), detail_url: card.select_one(a.poi-link).get(href), } poi_items.append(item) return poi_itemsselect和select_one是CSS选择器方法比find_all和find写起来简洁不少。需要注意两个点第一get_text(stripTrue)会把标签内部文字提出来并去掉首尾空白避免数据里混进一堆空格和换行第二有些字段不一定存在——比如一个景点可能没有评分——直接用select_one会返回None所以要么加判断要么用try/except兜底。解析详情页时我提取的字段包括景点名称、城市、评分、门票价格、建议游玩时长、开放时间、简介、经纬度用于后续的距离计算、用户好评数、差评数。字段尽量丰富因为后面做推荐系统时特征工程需要足够的信息量。字段提取完成后我先把数据放到了一个临时的Python列表里。在数据量不大的这个阶段不急着落库先看清楚哪些字段是完整的、哪些是缺失的然后再进入清洗环节。3. 数据清洗与存储别让脏数据毁掉推荐系统3.1 用Pandas做字段清洗和类型转换实话实说刚抓下来的数据如果直接拿去存储或者做推荐结果会惨不忍睹。比如“门票价格”这一列有的值是“60元”有的是“免费”还有的是“成人票120元、儿童票60元”。这种非结构化的文本不做处理后面做数值计算时根本没法用。我习惯用Pandas来处理清洗工作。先读取爬虫得到的数据列表创建DataFrame然后按字段类型逐列处理import pandas as pd df pd.DataFrame(raw_data_list) # 门票价格提取第一个出现的数字免费记为0 import re def extract_price(text): if not isinstance(text, str): return None if 免费 in text: return 0 match re.search(r\d, text) return float(match.group()) if match else None df[price] df[ticket_info].apply(extract_price) # 开放时间统一格式缺失填充未知 df[opening_hours] df[opening_hours].fillna(未知) # 评分转为浮点数无法转换的丢弃 df[rating] pd.to_numeric(df[rating], errorscoerce) df df.dropna(subset[rating]) # 经纬度拆分为数值列 df[lat] pd.to_numeric(df[latitude], errorscoerce) df[lng] pd.to_numeric(df[longitude], errorscoerce)这里用到的一个核心技巧是不要一上来就做复杂的清洗而是先“观察字段分布”比如用df.info()看每列的非空数量用df[price].value_counts()看价格列的取值种类。只有知道数据长什么样才知道该用什么规则去清洗。清洗逻辑里有一个判断需要留意一个字段如果“缺失率”超过60%那么这个字段对推荐算法的贡献就非常有限我通常会先保留但不让它参与后面的特征计算如果一个字段是识别景点类型的关键比如“标签”字段缺失太严重就得考虑从详情页摘要里通过关键词反推或者直接放弃这个特征。另外数值单位统一也是一个大坑。上面“门票价格”的例子中“免费”被转成0元没有歧义但有些页面标注的可能是“120元起”这种情况下提取出来的120其实只是最低价。我最终决定把这些“起”字的信息单独记录一列避免误导推荐系统。3.2 SQLite入库与去重设计清洗完成之后数据就可以入库了。SQLite的使用非常简单Python标准库自带sqlite3不需要额外安装任何东西。我的表结构主要包含三个字段组景点基础信息、访问数据、以及供推荐系统使用的特征字段。先看建表语句CREATE TABLE IF NOT EXISTS poi ( poi_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT, rating REAL, price REAL, opening_hours TEXT, description TEXT, category TEXT, lat REAL, lng REAL, good_review_cnt INTEGER, bad_review_cnt INTEGER, crawled_at TIMESTAMP );这里特别要注意的是poi_id INTEGER PRIMARY KEY。SQLite中如果一个整型主键没有显式赋值会自动生成rowid但我们要保留网站上原来的poi_id目的是为了去重——同一个景点即使被多次抓取也不会重复入库。我写了一个简单的去重逻辑先通过主键判断是否已存在如果存在就跳过这次写入或者更新评分等可变字段不存在则执行插入import sqlite3 from datetime import datetime conn sqlite3.connect(travel.db) cur conn.cursor() def insert_or_update_poi(row): cur.execute(SELECT 1 FROM poi WHERE poi_id ?, (row[poi_id],)) exists cur.fetchone() if exists: cur.execute( UPDATE poi SET rating ?, price ? WHERE poi_id ? , (row[rating], row[price], row[poi_id])) else: cur.execute( INSERT INTO poi (poi_id, name, city, rating, price, opening_hours, description, category, lat, lng, good_review_cnt, bad_review_cnt, crawled_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) , (row[poi_id], row[name], row[city], row[rating], row[price], row[opening_hours], row[description], row[category], row[lat], row[lng], row[good_review_cnt], row[bad_review_cnt], datetime.now())) conn.commit() for _, row in df.iterrows(): insert_or_update_poi(row) conn.close()关于写入性能我提一个实用的优化经验如果数据量超过几千行逐条commit()会很慢因为每次提交都会触发磁盘同步。更快的做法是攒够一定数量后批量提交或者在插入前用executemany()。demo阶段无所谓但你要是换了大数据集这个优化能省出好几倍时间。入库之后顺手加一个简单的数据质量验证SELECT COUNT(*) FROM poi; SELECT city, COUNT(*) FROM poi GROUP BY city; SELECT AVG(rating) FROM poi;看到这些数字正常才说明采集和清洗环节真的完成了。4. 智能推荐系统的核心逻辑4.1 基于内容的推荐原理与适用场景推荐系统在业界有两大门派协同过滤和基于内容的推荐。协同过滤依赖“用户的集体行为”——比如买了这个商品的人还买了什么里面隐含了群体的决策智慧。但放在我这个场景里协同过滤有个致命前置条件需要有大量用户行为数据。我的爬虫只抓了景点信息没有抓用户的历史行为这个时候强行上协同过滤就是无米之炊。所以我选了基于内容的推荐。核心原理非常朴素给每个景点构造一个“特征向量”当用户表达偏好时把用户的偏好也变成一个向量然后计算向量之间的相似度选出最接近用户偏好的景点排序返回。你可以把特征向量理解成“给景点画像”。比如故宫的画像可能是古建筑0.9历史0.8文化0.95自然风光0.1。而一个用户说自己喜欢“历史文化”系统就把“历史”和“文化”的权重调高再去跟每个景点的画像做点积运算得分高的排在前面。这个逻辑很像相亲平台给人打标签找对象本质上都是“用标签匹配”。基于内容的推荐还有一个好处可解释性强。推荐完一个景点我可以明确告诉用户“因为你偏好历史古迹而故宫的历史文化标签匹配度高达92%”。这种解释能力在旅游场景里特别重要——用户更愿意接受一个讲得出理由的建议而不是一个莫名其妙出现在屏幕上的“猜你喜欢”。4.2 构造特征向量与相似度计算特征工程是推荐系统里最花时间也最有价值的一环。在这个项目里我用景点的三个维度构造向量第一文本标签维度。每个景点在详情页里通常带有“历史古迹”“自然风光”“亲子游”等标签我对全部标签做统计构建一个标签词表然后把每个景点映射成标签出现次数的向量。第二评分与热度维度。景点评分和评论数量代表“大众认可度”这两个数值要归一化到0~1区间避免因量纲不同干扰相似度计算。归一化公式很简单score_norm (value - min_value) / (max_value - min_value)第三价格敏感度维度。严格来说这是一个用户偏好参数不是景点本身的属性。用户在标注偏好时可以选择“不差钱”或者“穷游”系统会把价格维度加权到相似度计算中从而实现“同等条件下优先推荐更便宜的景点”。下面这是相似度计算的简化实现我用了余弦相似度。余弦相似度衡量两个向量的夹角夹角越小代表越相似它对向量“绝对值大小”不敏感适合文本这类稀疏向量场景import numpy as np from sklearn.feature_extraction.text import CountVectorizer from sklearn.metrics.pairwise import cosine_similarity # 1. 把标签列转成文本矩阵每个景点一行标签空格分隔 tag_texts df[category].fillna().tolist() vectorizer CountVectorizer() tag_matrix vectorizer.fit_transform(tag_texts) # 2. 加上评分、热度、价格这三个数值特征归一化后 numeric_features df[[rating_norm, review_cnt_norm, price_norm]].values feature_matrix np.hstack([tag_matrix.toarray(), numeric_features]) # 3. 计算所有景点两两之间的相似度 similarity_matrix cosine_similarity(feature_matrix) # 4. 根据用户偏好构造查询向量取相似度TopK user_pref_tags [历史, 古建筑] pref_vector np.zeros(feature_matrix.shape[1]) for tag in user_pref_tags: if tag in vectorizer.vocabulary_: idx vectorizer.vocabulary_[tag] pref_vector[idx] 1.0 # 默认用户评分为偏好取平均价格倾向取0 pref_vector[-3] 0.8 # rating 偏好 pref_vector[-2] 0.6 # 热度偏好 pref_vector[-1] 0.2 # 对价格不敏感偏高端 user_sim cosine_similarity([pref_vector], feature_matrix)[0] top_indices np.argsort(user_sim)[::-1][:10]代码里有个细节值得展开数值特征列是直接用np.hstack拼到标签矩阵后面的也就是说最终向量的维度是标签数量3。这个过程看起来简单但如果你不加归一化评分从0到5和标签从0到1的尺度差异就会让评分这一个维度碾压所有标签维度导致推荐结果趋同。我在最初版本就吃过这个亏所有景点全部按评分从高到低排列标签完全失去意义。归一化之后每个维度的贡献就均衡了。用户偏好向量里给价格维度设置了0.2的低权重代表“不差钱”如果换成穷游用户这个值可以调成-0.5让推荐结果倾向于价格低的景点。4.3 推荐结果展示与“可解释性”输出算法计算出一堆相似度数值之后最终还要翻译成用户能看懂的推荐结果。我的做法是把结果转成一个标准表格结构景点名称、相似度得分、匹配标签Top3、一句话推荐理由。匹配标签的展示逻辑是这样的从用户偏好的标签中找出当前景点的标签向量中权重最高的三个拼接成“历史文化古建筑”的文本。这个能力直接来自特征工程阶段保留的标签词表它让推荐系统不只是一个黑盒还能解释“为什么推荐这个”。推荐结果输出做成了简单的命令行交互欢迎使用智能旅游推荐系统 请输入你偏好的景点类型如历史、自然、艺术、美食 历史 艺术 正在计算推荐结果... Top 5 推荐景点 1. 故宫博物院 | 相似度 0.93 | 匹配标签历史、古建筑、艺术 2. 颐和园 | 相似度 0.87 | 匹配标签历史、自然、皇家园林 ...这时候整个系统就算闭环了数据采集、清洗入库、特征构造、推荐计算、结果展示。从一个空白的城市页面开始到一堆结构化的数据再到一个能理解用户口味的推荐引擎。整个过程跑通之后你可以试着换一个城市重新采集系统不需要改任何代码就能直接生成那座城市的推荐结果这就是数据与逻辑分离带来的迁移能力。5. 常见问题与排查技巧实录5.1 高频报错与解决方案这个项目我前后调试了很多轮踩的坑足够整理一份速查表了。下面几个是最常遇到的按出现频率排序第一个问题是ConnectionError或者Timeout。这通常是目标网站暂时无法访问、本机网络波动或者请求频率太高被限制了。排查步骤先用浏览器正常访问一次目标页面确认站点确实在线再用curl命令请求看是否返回相同结果最后检查代码里的请求间隔时间。我建议每次请求之间至少间隔2到3秒并在代码里加一个简单的退避重试逻辑import time def safe_get(url, retries3): for i in range(retries): try: resp session.get(url, timeout10) if resp.status_code 200: return resp except requests.RequestException: pass time.sleep(2 * (i 1)) return None第二个经典问题是AttributeError: NoneType object has no attribute get_text。这个报错十有八九是页面解析时select_one没找到对应元素。原因可能是页面结构变化了、这个字段本身不存在、或者请求返回的根本不是正常的HTML比如被封IP后的验证页面。遇到这个报错我建议先保存一份响应内容到本地文件用浏览器打开检查比对着报错瞎猜高效得多with open(debug_page.html, w, encodingutf-8) as f: f.write(response.text)第三个问题是中文数据乱码。大部分情况是编码判断错误上面提到过用apparent_encoding解决。但也存在另一种情况数据在入库后正常读取时乱码这就跟终端显示编码不一致有关。排查时要区分是请求阶段乱码、存储阶段乱码、还是展示阶段乱码别一上来就改抓取代码。5.2 规范化采集与后续扩展建议关于爬虫边界我用这个项目反复向自己强调一个问题技术能力不等于使用权限。写爬虫之前先看目标网站的robots协议尊重对方的访问规则设置合理的抓取间隔。比如我这个项目列表页总共只有几十页我用2秒间隔去抓全程下来也就一两分钟对目标站点的压力可以忽略不计。数据抓下来之后也只用于个人学习研究不做任何商业化使用。另外如果你真想把这个项目推向生产级有几个方向值得继续扩展第一引入代理池和更智能的重试策略。当目标站点有更严格的频控时单IP容易被封这时候需要IP轮换。但代理池本身就是一门完整的技术建议在理解基础爬虫之后再碰。第二把“推荐系统”从基于内容扩展到“协同过滤内容”的混合推荐。混合推荐需要用户行为数据你可以自己造数据比如给不同的模拟用户打上不同的偏好记录然后对比不同推荐策略的效果。第三加一层Web可视化界面。这个项目目前是命令行交互数据展示能力有限。你可以用Flask或者Streamlit把推荐结果呈现成网页版用户点几个按钮就能调整偏好权重直观感受推荐结果随参数的变化。这一步做完一个完整的“数据采集到智能服务”的项目闭环就彻底圆满了。最后再分享一个我个人的体会很多人学爬虫是从“抓某个网站”开始的但真正让你成长的是把一个完整的项目全链路跑通的过程。数据采集只是入口清洗考验你对数据的理解存储考验你的工程取舍推荐算法考验你的逻辑思维。每一步都有独立的坑但每一步也都在帮你建立“解决问题”的直觉。这个项目做完之后你获得的绝对不只是一段能跑的Python脚本而是面对任何“信息整理”类问题时都敢说一句“这个我可以搞定”的底气。
返回列表