海河贯穿天津市区沿岸散布着意式风情区、天津之眼、古文化街、五大道等一大批旅游景观节点。去年我开始做一个与城市双修相关的完整项目——把海河沿岸的旅游景观数据汇聚到一个可视化大屏上同时给景区和游客做画像分析。整个系统用Python语言栈实现后端以Django为主Flask承担部分轻量服务数据层靠爬虫从公开渠道持续补充。这篇文章就把整个系统的架构思路、核心实现和踩坑经验完整拆开给准备做类似爬虫 Web框架 可视化大屏项目的读者一个能直接复盘的参照。1. 城市双修与海河景观这个项目到底在解决什么问题1.1 城市双修背景下的旅游景观数字化需求城市双修是生态修复与城市修补的简称放在海河沿岸这个场景里核心就是通过景观改造、绿地恢复、公共空间优化让沿岸既适合居民生活也适合游客游览。但实际推进中会碰到一个很现实的问题海河沿岸的景观资源分散在不同部门的数据里景区人流、游客评价、天气环境、交通接驳这些信息彼此割裂管理者难以快速判断哪个景点当下热度高游客对什么类型的景观最感兴趣哪些区域需要重点修补。旅游景观可视化系统的目标就是把散落的数据汇到一张图上用直观的方式呈现沿岸旅游资源的实时状态。这个需求拆开看有三层第一层是数据采集把景区基础信息、游客评论、实时客流等数据从公开渠道拿下来第二层是画像建模把采集到的数据打上标签形成景区画像和游客画像第三层是可视化呈现把画像结果和统计指标放到大屏上让决策者一眼看懂。每一层都有对应的技术选型我最终全部落在了Python生态里原因后面细说。1.2 海河沿岸场景的数据维度拆解在做数据模型之前我先把海河沿岸旅游场景的数据维度列了一个清单这个清单直接决定了爬虫要采什么、画像要建什么景区基础信息名称、位置、门票价格、开放时间、景观类型标签、简介游客评论数据评分、评论内容、发布时间、评论者特征实时客流数据景区热力值、拥挤程度部分平台会公开周边环境数据天气、空气质量、温度交通接驳数据地铁站点、公交线路、停车场信息经营数据餐饮、文创店铺分布这些维度中最容易拿到且最能反映双修效果的是游客评论数据和景区基础信息。评论里藏着游客对景观环境、设施完善度、生态感受的真实反馈比如绿化很好步道方便夜景漂亮这类关键词其实就是城市双修成效的天然指标。我的爬虫和画像系统重点就是围绕这些数据来构建。2. 技术栈选型为什么是Python Django/Flask而不是其他组合2.1 从爬虫到可视化的全链路语言统一这个项目最初也犹豫过要不要前端用Node.js、后端用Java、爬虫用Python但很快否掉了。原因很朴素爬虫脚本、数据清洗、画像计算、后端接口、可视化数据聚合如果拆成多种语言光维护数据格式转换就能耗掉一半精力。Python一个语言能覆盖从requests抓取到pandas清洗、再到Django接口输出的全流程数据模型也可以直接复用这是项目快速落地的最重要因素。在Python Web框架的选择上我采用了Django为主、Flask为辅的组合。Django负责核心业务系统——数据模型、用户认证、后台管理、大屏数据接口因为Django自带ORM和Admin画像标签体系这类强数据结构的模块非常适合。Flask则承担两个轻量服务一个是爬虫定时任务的简单控制面板另一个是实时客流数据的推送服务。Flask的路由灵活、部署轻便这种边际成本很低的小服务没必要动用Django全家桶。2.2 Django与Flask的分工边界很多人会问一个系统里同时用两个Web框架不重复吗我的答案是只要边界清晰反而比强行用一个框架更舒服。Django和Flask的具体分工如下模块使用框架原因景区/游客画像管理Django数据模型复杂需要ORM和Admin后台大屏统计报表APIDjango查询逻辑复杂需要聚合计算框架爬虫定时任务控制Flask轻量路由只需几个API控制调度实时向大屏推送客流Flask WebSocket独立长连接服务避免阻塞Django主进程Django的项目结构里我建了景区app、画像app、可视化app三个业务模块每个app对应独立的数据模型和视图集合。Flask服务则是独立进程部署通过HTTP接口与Django交换数据。这样做的最大好处是某个服务挂了不会拖垮整个系统Django需要更新时Flask还能继续推数据。2.3 核心依赖清单整个项目的Python环境依赖大概如下版本是我实测稳定的组合Django4.2.7 flask3.0.0 flask-socketio5.3.6 requests2.31.0 beautifulsoup44.12.2 lxml4.9.3 pandas2.1.4 sqlalchemy2.0.23 celery5.3.4 redis5.0.1这里特别提一下Celery我用来处理爬虫的定时任务和画像计算的异步任务。最开始图省事直接用crontab跑爬虫脚本但脚本多了之后失败重试、任务状态跟踪都要自己写换用Celery后在Django里可以直接查任务状态配合Redis做消息队列稳定很多。3. 爬虫层从公开数据源采集旅游景观数据3.1 数据源评估与合法性边界爬虫是整个画像系统的数据源头也是踩坑最多的环节。我先说合规问题我只采集公开可访问的景区基础信息、游客公开评论和平台公开统计指标严格遵守robots协议不对接口做暴力请求登录后才可见的数据一律不碰。做数据项目的人必须清楚这个边界否则系统做得再好也会出问题。在合规前提下数据源的选择也很讲究。我最终锁定了三类景区官网和官方公众号文章偏基础信息和官方活动OTA平台的公开景区页面偏游客评分和评价标签公开气象数据接口偏环境数据。这三类数据的实时性和稳定性都比较好反爬压力也相对温和。3.2 Requests BeautifulSoup抓取景区基础信息景区基础信息的抓取比较简单使用requests获取页面HTML再用BeautifulSoup解析结构化字段。核心逻辑如下import requests from bs4 import BeautifulSoup def fetch_scenic_info(url): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) name soup.select_one(h1.scenic-name).text.strip() desc soup.select_one(div.scenic-desc).text.strip() tags [tag.text for tag in soup.select(a.tag-item)][:5] return { name: name, description: desc, tags: ,.join(tags), source_url: url }这里有两个容易踩的坑。第一个是页面编码很多景区站点用的是GBK编码直接用resp.text会乱码resp.apparent_encoding也不一定准确我后来是先用requests拿到resp.encoding再手动比对页面meta标签里的charset实测比自动检测更靠谱。第二个是页面结构差异不同景区官网的HTML结构完全不同不能指望一个选择器走天下我的做法是维护一个站点选择器配置表每个数据源对应自己的CSS选择器。3.3 OTA平台评论数据采集与反爬应对OTA平台的游客评论是本项目最有价值的数据源但采集难度也上了几个台阶。平台的页面大量使用JS动态渲染直接requests拿到的HTML里根本没有评论数据。我的处理思路是先分析页面数据接口用浏览器开发者工具找到返回JSON的异步接口然后直接请求接口。评论接口一般会带签名参数这个不好绕过。我采用的方案是降低采集频率每个景区只采集前3页公开评论间隔5到8秒随机延时并且一天内对同一景区只跑一次。这样既够画像系统使用也不会对平台造成压力。import time import random import requests def fetch_comments_with_delay(api_url, pages3): all_comments [] for page in range(1, pages 1): params {page: page, pageSize: 20} resp requests.get(api_url, paramsparams, timeout10) if resp.status_code 200: data resp.json() all_comments.extend(data.get(comments, [])) time.sleep(random.uniform(5, 8)) return all_comments评论数据拿到后不是直接入库要做一轮脱敏和清洗。用户名只保留首尾字符邮箱、手机号如果有的话直接丢弃HTML标签全部剥离表情符号过滤掉只保留文字内容。清洗之后的评论才是画像系统的输入。3.4 SQLAlchemy模型设计与数据落库Django自带ORM为什么我在爬虫层还要单独用SQLAlchemy原因是我把爬虫模块做成了独立服务不依赖Django环境。这样爬虫跟主系统解耦更新Django代码时不会影响爬虫运行爬虫挂了也不影响大屏展示历史数据。SQLAlchemy的数据表设计我简化后大概是这样的from sqlalchemy import Column, Integer, String, Text, DateTime, Float, create_engine from sqlalchemy.ext.declarative import declarative_base from datetime import datetime Base declarative_base() class ScenicSpot(Base): __tablename__ scenic_spot id Column(Integer, primary_keyTrue) name Column(String(100), uniqueTrue) description Column(Text) tags Column(String(200)) location Column(String(200)) ticket_price Column(Float, default0) open_time Column(String(100)) source_url Column(String(300)) created_at Column(DateTime, defaultdatetime.now) class TouristComment(Base): __tablename__ tourist_comment id Column(Integer, primary_keyTrue) scenic_id Column(Integer, indexTrue) rating Column(Float, default5.0) content Column(Text) comment_date Column(DateTime) clean_content Column(Text) created_at Column(DateTime, defaultdatetime.now)clean_content字段就是清洗后的评论文本画像系统只读这个字段原始content留作审计用。两个表都建了索引后面做聚合统计时能明显感觉到查询速度的差别。4. 画像系统景区画像与游客画像怎么建模4.1 画像标签体系设计画像系统是整个项目的核心增值模块。所谓景区画像就是用一组标签来描述一个景区的特征——历史文化自然风光亲子友好夜景打卡商业成熟这些标签让管理者能快速分类和对比沿岸景区。游客画像则是描述某类游客的行为偏好家庭出游情侣打卡摄影爱好者深度文化游。标签体系我设计成三级第一级是景区基础属性标签包括景观类型、所在区位、门票区间第二级是游客感知标签从评论关键词里提取比如绿化好设施新人少清净夜景美第三级是运营特征标签比如高热度口碑佳需重点修补这是由画像系统自动计算输出的。4.2 游客行为数据的清洗与打标画像计算的第一步是把清洗后的评论分词然后用关键词词典匹配打标。我没有用太重的机器学习模型而是结合jieba分词加自定义词典的方式因为旅游评论的场景相对固定关键词匹配已经能达到不错的效果。import jieba import jieba.analyse def build_scenic_profile(clean_comments): tag_keywords { 历史文化: [历史, 古建筑, 文化, 百年, 意式风情], 自然风光: [绿化, 花, 河景, 夜景, 日落, 清新], 拥挤体验: [人多, 排队, 拥挤, 堵], 设施完善: [步道, 厕所, 指示牌, 休息区], 需要修补: [破损, 脏, 围挡, 维修中, 垃圾] } profile {tag: 0 for tag in tag_keywords} for comment in clean_comments: words jieba.lcut(comment) for tag, keywords in tag_keywords.items(): if any(kw in words for kw in keywords): profile[tag] 1 total max(len(clean_comments), 1) profile {k: round(v / total, 2) for k, v in profile.items()} return profile这个方案的优点是可解释性强管理者看到历史文化0.42就能理解是42%的评论提到了历史文化相关词。缺点是标签词典需要人工维护每隔一段时间要把新的网络热词加进去比如网红打卡出片这类词是最近才出现的。我建了一个后台维护页面运营人员可以直接增删关键词不用改代码。4.3 画像接口设计与查询优化画像结果存储时我把每个景区的标签向量直接冗余存储到Django的画像表里而不是每次实时计算。原因是评论采集是定时任务画像也做成定时重算大屏访问时直接读结果表响应都在毫秒级。Django端我设计了一个画像查询接口from django.core.serializers import serialize from django.http import JsonResponse from .models import ScenicProfile, ScenicSpot def get_scenic_profile(request, scenic_id): profile ScenicProfile.objects.select_related(scenic).get(scenic_idscenic_id) data { scenic_id: profile.scenic_id, name: profile.scenic.name, tags: json.loads(profile.tag_json), scores: json.loads(profile.score_json), updated_at: profile.updated_at.strftime(%Y-%m-%d %H:%M) } return JsonResponse(data)画像表的tag_json字段存标签列表score_json存标签权重。用Django的select_related把景区表关联查询合并成一条SQL避免N1查询。这个优化在景区数量超过20个之后效果非常明显。5. 可视化大屏从数据到前端展示的完整链路5.1 大屏UI布局与视觉设计很多人以为可视化大屏的核心是前端图表库其实真正决定大屏价值的是信息层级设计。我设计大屏的原则是上总要、中趋势、下明细顶部放海河沿岸整体旅游热力指数、当日客流总量、平均满意度三个核心指标中部放景区画像雷达图、游客类型分布饼图、景观标签词云底部放各景区的评论明细滚动列表和交通接驳信息。大屏整体采用深蓝色科技风因为这是旅游管理场景比较通用的大屏视觉基调数据突出、不干扰。尺寸按1920x1080设计用百分比和flex布局适配。大屏项目我用的Vue3 ECharts没有用太重的前端框架也没必要用。5.2 ECharts Vue3大屏项目搭建大屏的核心是ECharts图表的封装。我这里封装了一个通用的图表组件接收后端返回的配置JSON直接渲染对应类型的图表template div refchartRef classchart-container/div /template script setup import * as echarts from echarts import { onMounted, ref, watch } from vue const props defineProps({ option: { type: Object, required: true } }) const chartRef ref(null) let chart null onMounted(() { chart echarts.init(chartRef.value) chart.setOption(props.option) window.addEventListener(resize, () chart chart.resize()) }) /script景区画像雷达图的ECharts配置是后端动态生成的前端只负责渲染数据维度包括历史文化、自然风光、亲子友好、夜景打卡、设施完善五个维度。词云图我用的是echarts-wordcloud扩展展示评论里高频出现的景观特征词视觉效果最直观。5.3 Django后端API设计与数据推送大屏需要两类数据接口一类是一次性拉取的统计报表另一类是实时推送的客流数据。报表接口用Django实现返回JSON格式大屏页面加载时统一请求实时推送用Flask Flask-SocketIO实现。实时推送的设计是这样的爬虫层每10分钟采集一次景区客流热力数据写入RedisFlask服务订阅Redis的键变化通过WebSocket推送到大屏前端Django完全不参与这条链路只负责历史数据的报表查询。这个设计的好处是长连接不会占用Django的worker进程Django的重启、部署都不会断掉实时推送。from flask import Flask from flask_socketio import SocketIO, emit import redis app Flask(__name__) socketio SocketIO(app, cors_allowed_origins*) r redis.Redis(hostlocalhost, port6379, db0) def stream_crowd_data(): pubsub r.pubsub() pubsub.subscribe(crowd:heat) for message in pubsub.listen(): if message[type] message: socketio.emit(crowd_update, message[data]) socketio.on(connect) def handle_connect(): socketio.start_background_task(stream_crowd_data)这里有个很值得注意的问题Flask-SocketIO长连接在反向代理后面必须要配置WebSocket升级头否则前端能建立连接但收不到推送。我用的是Nginx需要在配置里明确支持Upgrade和Connection头。6. 系统部署、性能优化与踩坑实录6.1 前后端分离部署到服务器的注意事项整个系统在生产环境部署时我采用了前后端完全分离的架构。前端大屏项目构建后的静态文件交给Nginx托管Django后端用Gunicorn启动Flask实时推送服务单独用一个端口起Redis和MySQL分别部署在同一台服务器的不同容器里。部署最容易出错的是CORS跨域问题。大屏页面在Nginx的80端口Django接口在8000端口Flask推送在9000端口前端访问后端接口必然跨域。Django端我用了django-cors-headers库配置里要把大屏域名加进白名单CORS_ALLOWED_ORIGINS [ http://your-dashboard-domain.com, http://localhost:8080 ]Flask端因为用了Flask-SocketIOcors_allowed_origins*在开发环境没问题生产环境建议也改成具体域名避免安全隐患。6.2 大屏加载性能优化大屏首屏加载速度直接影响用户体验我做了三个优化。第一个是接口并发请求大屏有七八个图表组件如果串行请求要等最慢的接口我改成前端Promise.all同时请求首屏时间从4秒降到1.5秒。第二个是ECharts按需引入只注册大屏用到的柱状图、折线图、雷达图、词云图组件打包体积少了接近一半。第三个是后端聚合查询直接用Django的annotate一次算出分组统计不要在Python层做循环聚合。6.3 几个印象深刻的坑第一个坑是字符编码。评论数据来自不同平台UTF-8、GBK、繁体中文混在一起入库后画像系统的分词经常报编码错误。最终我在爬虫清洗环节统一做text.encode(utf-8, errorsignore)入库前再强制转一次才彻底解决。第二个坑是ECharts在数据为空时渲染报错。海河沿岸有些冷门景区的评论数量很少画像雷达图五个维度全是0ECharts默认渲染会出异常。我的处理是后端接口里做一个判断如果总数小于10条返回一个数据样本不足的占位图而不是强出雷达图。第三个坑是时间字段的时区问题。爬虫拿到的评论时间有的带时区、有的不带直接用会导致大屏时间轴错乱。统一做法是爬虫落库时全部转成datetime.now()前端只负责展示不做时区转换。第四个坑是Redis里的客流热力数据堆积。每10分钟写入一次如果不设过期时间一个月后Redis内存涨得很快。我写入时统一加了EX 1800半小时过期大屏只关心最近的数据历史统计全部从MySQL里查。整个系统从爬虫到画像、再到大屏展示链路比较长但每一层单独拎出来都是可以复用的模块。我在实际落地中最深的体会有两点一是画像系统的价值不在于算法多高级而在于标签体系是否贴合业务场景海河沿岸的城市双修管理者真正关心的是哪些景区需要修补游客感知如何这套关键词标签方案直接回答这些问题二是职责边界一定要清晰Django、Flask、爬虫、前端各管一段出了问题能在十分钟内定位到是哪一层的故障。如果你也在做类似的数据采集加可视化项目建议先把数据流转路径画清楚再动手写代码能少走很多弯路。