ARTICLE DETAIL

资讯详情

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

Django农业数据可视化:爬虫采集、ORM建模与ECharts展示

Django农业数据可视化:爬虫采集、ORM建模与ECharts展示 简介基于Python Django与MySQL的农业生产可视化系统毕业设计项目面向PythonWeb初学者、课程设计与期末大作业场景系统围绕农业指标数据和气象数据两条主线实现爬虫采集、数据清洗、MySQL存储与可视化展示完整覆盖数据从获取到呈现的流程。资源包含爬虫采集脚本、数据入库脚本、Django后台配置以及可视化页面涉及requests、数据清洗、ORM模型和图表渲染等实践点技术栈为DjangoMySQL8.0.26Python3.9.16代码结构清晰注释易读适合二次开发。压缩包共85个文件仅1.35MB以py源码、pyc编译文件、HTML模板、CSS/JS样式脚本及字体图标资源为主同时附带SQL数据库脚本、依赖清单和说明文档便于快速恢复环境。目前已有316人学习项目经导师指导并获得高分功能完善、界面简洁、操作便捷下载后可直接在对应环境运行节省配置时间无论是用于课程答辩还是学习Django数据可视化流程都有很强的参考价值。1. 农业数据可视化为什么 Django 项目要同时接爬虫和气象接口一个完整的农业生产可视化系统不是只画几张折线图那么简单。它要先解决“数据从哪来、数据脏不脏、怎么变干净、最后怎么展示”这条链路。这套基于 Python 3.9 Django MySQL 8.0 的项目把农业指标数据和气象数据两条线都收进来一条用reptile_agriculture.py抓农业记录一条用reptile_meteorology.py拉气象资料再通过storeData.py做清洗和聚合最后在页面上用图表呈现。对正在做课程设计或刚学 Python Web 的人来说它能直观展示 Django 的 MVT、ORM、request/response、模板渲染到底怎么配合而不是在一个 hello world 项目里反复打转。2. Django 项目结构与数据模型把 agricultureDB.sql 变成可查询的 ORM 表收到项目后先看目录。Agriculture_Analysis-main下的djangoProject是 Django 工程的 settings 所在业务 app 在工程同级目录里压缩包里能看到storeData.py、两个爬虫脚本说明工程是“脚本 Web”混合结构。这种结构比把爬虫写进 Django 的views.py更清晰爬虫和清洗脚本是可以独立跑的批任务Django 只负责读库和输出页面。初学者容易把爬虫代码塞在视图里结果每次刷新页面都重新抓一次这是最典型的误用。2.1 Django 创建 app 的思路与 settings 注册djangoProject只是容器业务数据应该拆成独立 app。常见做法是执行python manage.py startapp analysis然后去settings.py的INSTALLED_APPS里补上analysis。如果建表需求来自现成的agricultureDB.sql可以先建好数据库再反向生成模型也可以用模型迁移正向建表。我更倾向先生成模型因为后续storeData.py和爬虫都会直接用 ORM 操作模型字段和抓取字段一次性对齐后面就不用反复ALTER TABLE。2.2 models.py 里的核心字段设计这里定义AgricultureRecord和WeatherRecord两张表分别承接农业指标和气象数据from django.db import models class AgricultureRecord(models.Model): region models.CharField(地区, max_length50) crop_type models.CharField(作物类型, max_length50, blankTrue) area models.FloatField(种植面积, nullTrue, blankTrue) yield_value models.FloatField(产量, nullTrue, blankTrue) record_date models.DateField(记录日期) source models.CharField(数据来源, max_length100, defaultreptile_agriculture) class Meta: db_table agriculture_record indexes [ models.Index(fields[region, record_date]), ] ordering [-record_date] class WeatherRecord(models.Model): city models.CharField(城市, max_length50) max_temp models.FloatField(最高气温, nullTrue, blankTrue) min_temp models.FloatField(最低气温, nullTrue, blankTrue) rainfall models.FloatField(降雨量, nullTrue, blankTrue) humidity models.FloatField(湿度, nullTrue, blankTrue) record_date models.DateField(记录日期) class Meta: db_table weather_record indexes [ models.Index(fields[city, record_date]), ]字段参数里nullTrue, blankTrue和数据库的NULL语义要分清。null控制数据库列是否允许为空blank控制 Django 表单校验是否必填。爬虫时常有某天缺降雨量两个参数同时设True前端拿到的 JSON 里就是nullECharts 画图时可以用connectNulls或areaStyle处理断裂。另外FloatField在 MySQL 里是 double对农业产量、降雨量这种连续量够用如果涉及金额或精确亩产我会换成DecimalField(max_digits10, decimal_places2)避免二进制浮点误差在累加时放大。2.3 用 ORM 执行查询和删除对象agricultureDB.sql给了现成表但字段命名字典型是很随意的比如output、per_area。直接查表能跑Django 的 ORM 查询语法就不一样了。拿“按地区按日查产量”来说ORM 写出来是这个感觉from django.db.models import Sum, Count from analysis.models import AgricultureRecord # 按天聚合输出给图表 rows (AgricultureRecord.objects .filter(record_date__range[2024-01-01, 2024-12-31]) .values(region, record_date) .annotate(total_yieldSum(yield_value), cntCount(id)) .order_by(record_date))这段代码用values分组annotate做聚合和 SQL 里的GROUP BY region, record_date等价。查询结果里没出现area字段底部没有only或defer限制时ORM 会默认把整行取出来数据量大时会影响接口响应。遇到过一位同事在视图里all()把所有记录丢给前端再做筛选页面卡了 3 秒。正确做法是在视图里建好查询集分页或按日期截断后再返回。删除对象也有讲究。爬虫跑重了库里会出现完全一样的(region, record_date, crop_type)组合。用 ORM 的countdelete清理干净且可回滚from django.db.models import Count duplicates (AgricultureRecord.objects .values(region, record_date, crop_type) .annotate(cntCount(id)) .filter(cnt__gt1)) for group in duplicates: qs AgricultureRecord.objects.filter( regiongroup[region], record_dategroup[record_date], crop_typegroup[crop_type], ) # 保留最早一条删除其余 keep_id qs.order_by(id).first().id qs.exclude(idkeep_id).delete()filter返回查询集exclude做取反delete是批删除操作不会触发默认 manager 之外的单条信号。这里用了三层过滤而不是id__in治的是“每组重复数量不同”的情况如果每组固定重复两条直接values(id).annotate(row_number...)更省事。最后提醒一句任何删除前先确认Meta.ordering因为first()拿哪一条是由order_by决定的写错了会把最新数据删掉。场景ORM 写法等价 SQL按地区查总量.values(region).annotate(sSum(yield_value))SELECT region, SUM(yield_value) ... GROUP BY region查日期区间.filter(record_date__range[2024-01-01, 2024-01-31])WHERE record_date BETWEEN ...删除重复.exclude(idkeep_id).delete()DELETE WHERE id ! keep_id只取两个字段.only(region, yield_value)SELECT region, yield_valueORM 的__range、__in、__contains都是 MySQL 查询条件的封装参数顺序和 SQL 严格对应。写完查询集先.query打印底层 SQL再优化索引。agricultureDB.sql里如果已经建了复合索引迁移模型时要注意db_index和 Meta 里indexes是否一致否则只能当替补索引用。3. 爬虫采集reptile_agriculture.py 与 reptile_meteorology.py 的取数与入库可视化系统的命门在数据新鲜度。农业指标往往来自政府公开的年度统计或行业站点气象数据则来自天气接口两者的更新频率不同抓取策略也不能一样。reptile_agriculture.py处理的是慢速变化的农业产量数据可能一周跑一次reptile_meteorology.py处理的是每天变化的气象数据至少一天一次。把这两个脚本拆开比写成同一个manage.py command更容易控制失败范围。3.1 为什么用 requests BeautifulSoup 而不是 Scrapy这个项目的爬虫体量小目标站点结构稳定用requests拿页面、BeautifulSoup解析就够。Scrapy的优势是并发抓取、中间件、Pipeline 定义代价是工程结构重对 Dango 初学阶段反而干扰。另一点是Scrapy的回调写法会让“解析一条数据就立刻写入 MySQL”变得别扭而requests django.setup()可以直接复用 Django ORM爬完一行存一行逻辑直观出错时也能顺着 Python traceback 找到是哪一行的数据没塞进去。3.2 爬虫脚本骨架django.setup() 与 ORM 入库脚本要独立执行又不想复制数据库连接配置最省事的方法是在脚本顶部初始化 Django# reptile_agriculture.py import os import django import requests from bs4 import BeautifulSoup os.environ.setdefault(DJANGO_SETTINGS_MODULE, djangoProject.settings) django.setup() from analysis.models import AgricultureRecord from datetime import datetime HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/agri, } def parse_page(html): soup BeautifulSoup(html, html.parser) rows soup.select(table tbody tr) records [] for row in rows: cells [td.get_text(stripTrue) for td in row.find_all(td)] if len(cells) 5: continue records.append({ region: cells[0], crop_type: cells[1], area: float(cells[2] or 0), yield_value: float(cells[3] or 0), record_date: datetime.strptime(cells[4], %Y-%m-%d).date(), }) return records def main(): for page in range(1, 4): resp requests.get(fhttps://example.com/agri?page{page}, headersHEADERS, timeout10) resp.encoding utf-8 data parse_page(resp.text) for item in data: AgricultureRecord.objects.update_or_create( regionitem[region], record_dateitem[record_date], crop_typeitem[crop_type], defaults{ area: item[area], yield_value: item[yield_value], } ) print(fpage {page}: {len(data)} rows) if __name__ __main__: main()这里update_or_create的查找条件要仔细设计。农业数据的自然键一般是“地区 作物 日期”把这个组合作为 lookup 条件defaults放会变化的度量字段。这样同一个地区同一天的记录重复抓取时不会插入新行而是原地更新。float()转换时如果原始页面是--或空字符串int 和 float 都会抛ValueError我在解析时先or 0兜底。气象脚本同理只是字段换成cityrecord_date。3.3 气象爬虫的容错与重试气象数据接口的响应结构比农业表格复杂经常有嵌套 JSON 和空数组。常见做法是把请求和解析拆开单独处理超时和限流import time import json import requests def fetch_weather(city, date): url https://meteorology.example.com/api params {city: city, date: date} for attempt in range(3): try: resp requests.get(url, paramsparams, timeout8) resp.raise_for_status() data resp.json() if data.get(status) ! 200: raise ValueError(fapi error: {data.get(message)}) return data except (requests.Timeout, requests.ConnectionError) as e: print(fattempt {attempt} failed: {e}) time.sleep(2 * (attempt 1)) return None参数表里timeout设成 8 秒而不是默认的无限等待。raise_for_status()会把 4xx/5xx 状态码转成异常避免拿到403后继续解析首页 HTML 然后报一个莫名其妙的定位错误。重试间隔用time.sleep(2 * (attempt 1))第二次失败等 4 秒第三次等 6 秒给对端留恢复窗口。真被限流时最明显的现象是连续报ConnectionError此时不要改重试只数先查是否被反爬策略拦截。3.4 两个脚本共用的参数表参数农业脚本气象脚本说明timeout108单次请求超时秒数page1-31分页控制headers必须建议气象接口有时需要api_key入库方式update_or_createbulk_create气象数据量大时批量插入运行频率周天由数据源更新周期决定气象脚本如果每天抓update_or_create的逐条更新在百万级数据下会慢得离谱我一般会在脚本里攒满 500 条后一次性bulk_create冲突时用 MySQL 的ON DUPLICATE KEY UPDATE替代。到这一步数据已经进了 MySQL但还不能直接画图因为原始表里可能还有缺测和重复项下一步交给storeData.py。4. storeData.py 数据清洗与聚合从爬虫原始数据到可视化指标把爬虫拿到的数据直接丢进图表结果经常是坐标轴上有断档、柱状图出现一个 9999、雨水量的单位里混着mm和in。storeData.py在这个项目里扮演的就是数据管道中的清洗和聚合角色。它不负责抓取只负责把agriculture_record和weather_record里的记录变成能直接给 ECharts 使用的指标。4.1 数据清洗为什么不能只靠 SQLSQL 能处理明显的重复和缺失但处理“指纹”类的脏数据比如同一个区域在两个数据源里写作“内蒙古”和“内蒙”SQL 的WHERE region 内蒙古就有边界问题。脚本里用 pandas 读一遍先归一化再写库好处是可以灵活写规则坏处是让服务端多了一层处理所以storeData.py通常按固定时间批量运行而不是做成 Django 同步接口。4.2 pandas 清洗流程与异常值处理下面的storeData.py简化版展示了完整清洗链import pandas as pd from django.db import transaction from analysis.models import AgricultureRecord def clean_agriculture(): # 从 ORM 拉出最近一年的原始数据转成 DataFrame qs (AgricultureRecord.objects .filter(record_date__gte2024-01-01) .values(id, region, crop_type, area, yield_value, record_date)) df pd.DataFrame.from_records(qs) # 字段归一化与类型转换 df[region] df[region].str.strip().replace({内蒙: 内蒙古, 新疆维吾尔自治区: 新疆}) df[record_date] pd.to_datetime(df[record_date], errorscoerce) df[yield_value] pd.to_numeric(df[yield_value], errorscoerce) # 删除关键字段缺失的行和完全重复的行 df df.dropna(subset[region, record_date, yield_value]) df df.drop_duplicates(subset[region, crop_type, record_date], keeplast) # 按业务规则过滤异常值比如亩产超过 2000 千克一律视为录入错误 df df[(df[yield_value] 0) (df[yield_value] 2000)] df df.dropna(subset[area]) with transaction.atomic(): for _, row in df.iterrows(): AgricultureRecord.objects.filter(pkrow[id]).update( regionrow[region], yield_valuerow[yield_value], record_daterow[record_date].date(), ) print(fclean done, {len(df)} rows)pd.to_datetime(errorscoerce)会把无法解析的日期变成NaT后续dropna会把它们整行删掉。replace字典里的映射可以扩展这是 SQL 里要写七八个CASE WHEN才能完成的事。transaction.atomic()保证要么整批更新完成要么失败回滚避免用户在页面上看到半新半旧的数据。4.3 聚合规则与写回方式清洗后的明细数据不适合直接给前端因为图表通常按日期看趋势按地区看对比。常见做法是再建一张聚合表agriculture_daily把明细按月/周汇总聚合维度聚合字段输出表省/地区SUM(yield_value),AVG(area)agriculture_region_daily作物类型COUNT(DISTINCT region),MAX(yield_value)agriculture_crop_weekly日期SUM(rainfall),AVG(humidity)weather_day_agg如果使用 MySQL 8.0可以直接用INSERT INTO ... ON DUPLICATE KEY UPDATE或临时表聚合但 Django 项目里更常用 ORMfrom django.db.models import Sum, Avg from analysis.models import AgricultureRecord agg_rows (AgricultureRecord.objects .values(record_date) .annotate(total_yieldSum(yield_value), avg_areaAvg(area)) .order_by(record_date)) for row in agg_rows: AgricultureDaily.objects.update_or_create( record_daterow[record_date], defaults{ total_yield: row[total_yield], avg_area: row[avg_area], } )这段脚本跑完后前端读AgricultureDaily就能直接得到日期和数值两个数组。注意aggregate和annotate的区别values后跟annotate是分组聚合生成每行一组直接aggregate是全局聚合只会返回一行。画趋势图需要前者做页面顶部的总产量卡片才用后者。跑完storeData.py后下一步是让 Django 视图把这张聚合表变成 JSON 接口。5. Django 视图、JSON 接口与 ECharts 图表数据准备好了可视化系统的“可视化”才真正开始。农业生产可视化页面最常见的交互套路是顶部一排放时间筛选、地区下拉框和指标切换按钮中间放折线图、柱状图和雷达图底部放数据表格。如果我们不用前后端分离框架直接在 Django 模板里嵌 ECharts工作量最小也符合项目定位。5.1 视图层返回 JsonResponse为了不让页面刷新前端通过 fetch 请求一个独立的 JSON 接口。Django 视图返回JsonResponse时一定要定义encoder或保证数据可序列化import json from django.http import JsonResponse from django.db.models import Sum from analysis.models import AgricultureDaily def yield_trend(request): region request.GET.get(region, ) start request.GET.get(start, 2024-01-01) end request.GET.get(end, 2024-12-31) qs AgricultureDaily.objects.filter(record_date__range[start, end]) if region: qs qs.filter(regionregion) rows qs.values(record_date, crop_type).annotate( total_yieldSum(total_yield), ).order_by(record_date) dates list(set(row[record_date].strftime(%Y-%m-%d) for row in rows)) dates.sort() series_map {} for row in rows: series_map.setdefault(row[crop_type], {dates: [], values: []}) series_map[row[crop_type]][dates].append(row[record_date].strftime(%Y-%m-%d)) series_map[row[crop_type]][values].append(row[total_yield]) return JsonResponse({ dates: dates, series: series_map, query: {region: region, start: start, end: end}, })JsonResponse默认只支持 dict传入 list 时要加safeFalse。我的习惯是永远在外面包一个对象带上query参数前端调试时能直观看到当前接口收到了什么筛选条件。日期字段用strftime转成字符串不然序列化器遇到date对象会报错。5.2 ECharts 初始化与 option 参数配置有了 JSON 接口页面里用 ECharts 展示就是标准的initsetOption。需要注意 Django 模板和 JavaScript 的冲突问题ECharts 的配置对象中经常出现{{ }}结构比如label: { formatter: {b}: {c} }Django 模板会把{{ b }}当变量渲染需要包一层{% verbatim %}{% verbatim %} div idyield-chart styleheight: 480px;/div script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script script fetch(/api/yield_trend/?region内蒙古start2024-01-01end2024-12-31) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(yield-chart)); chart.setOption({ tooltip: { trigger: axis }, legend: { data: Object.keys(data.series) }, xAxis: { type: category, data: data.dates, axisLabel: { rotate: 30 } }, yAxis: { type: value, name: 产量吨 }, series: Object.entries(data.series).map(([name, item]) ({ name: name, type: line, smooth: true, showSymbol: false, data: item.values })) }); window.addEventListener(resize, () chart.resize()); }); /script {% endverbatim %}这段代码里smooth: true让折线更平滑适合展示农业产量这类连续变化的指标showSymbol: false去掉每个点的圆点标号数据点多时避免渲染卡顿。tooltip.trigger: axis让鼠标在 x 轴附近滑动时同时显示同一日期下多个作物的产量这是折线图的常用配置。如果你的需求是看某个地区不同作物的横向比较果断换type: barECharts 会自动按xAxis.data定位柱子。5.3 reverse 处理页面跳转和菜单高亮可视化页面通常有多个子页面导航菜单的当前项高亮在 Django 里有个简单技巧用reverse得到当前 URL再和请求路径对比。from django.urls import reverse, resolve def menu_items(request): current_path request.path items [ {name: 农业总览, url: reverse(agriculture_dashboard)}, {name: 气象分析, url: reverse(weather_dashboard)}, {name: 数据管理, url: reverse(data_manage)}, ] for item in items: item[active] item[url] current_path return {menu_items: items}reverse(agriculture_dashboard)根据 URL name 生成路径比硬编码/agri/dashboard更健壮场景换到子目录部署时不用改模板。实际项目里菜单高亮还可能需要对子路径匹配比如/agri/2024/region/要高亮“农业总览”就要用resolve(current_path).url_name去和视图函数比对而不是直接比对路径字符串。5.4 重定向传递筛选条件从农业图表跳到气象图表时想把当前选中的地区和日期也带过去最常见的做法是redirect拼接查询参数from django.shortcuts import redirect from django.utils.http import urlencode def to_weather(request): base reverse(weather_dashboard) query urlencode({ region: request.GET.get(region, ), start: request.GET.get(start, ), end: request.GET.get(end, ), }) return redirect(f{base}?{query})urlencode会自动处理中文和空格地区名“内蒙古”传到 URL 里变成百分号编码目标视图再用request.GET.get就能正确解码。这个跳转逻辑放在视图里而不是a标签直接拼href是为了统一处理默认值和缺省参数。6. 部署到宝塔与定时跑批让可视化项目在服务器上常驻这一节算是落地的最后一公里配置 MySQL 8.0 连接部署到宝塔面板并让爬虫按计划自动更新。6.1 requirements.txt 依赖与 MySQL 连接配置项目根目录有requirements.txt里面至少需要包含 Django 5.x 或 4.x、mysqlclient 或 PyMySQL、requests、beautifulsoup4、pandas。MySQL 8.0 默认认证插件是caching_sha2_password用mysqlclient需要对应版本的libmysqlclient如果本机装不上去直接改用 PyMySQL在settings.py里放一行import pymysql pymysql.install_as_MySQLdb()然后在DATABASES里正常写ENGINE: django.db.backends.mysql。注意 MySQL 8.0 的字符集默认是utf8mb4_0900_ai_ci如果你的数据和表创建时间跨版本要确认识别中文时没有乱码。pandas 写入时还要设置charsetutf8mb4否则 DataFrame 里的中文很容易变成问号。6.2 宝塔部署 Django 的 uWSGI 配置宝塔面板上运行可视化系统常见做法是 Python 项目管理器 uWSGI。项目目录放到/www/wwwroot/Agriculture_Analysis-main虚拟环境用宝塔创建然后写一个uwsgi.ini[uwsgi] chdir /www/wwwroot/Agriculture_Analysis-main module djangoProject.wsgi:application master true http-socket 127.0.0.1:8001 chmod-socket 664 processes 4 threads 2 vacuum true virtualenv /www/wwwroot/Agriculture_Analysis-main/venv这里的module指向djangoProject.wsgi:application对应压缩包里的 Django 工程目录。配置http-socket而不是http 公网IP:80是让宝塔的 Nginx 反向代理去接公网请求uWSGI 服务本身不暴露到外部。如果页面加载不出 CSS多半是静态文件没有交给 Nginx 处理需要在 Nginx 配置里加location /static/ { alias /www/wwwroot/.../static/; }。6.3 定时执行爬虫与数据量验证部署稳定后让reptile_agriculture.py和storeData.py按各自节奏跑。宝塔面板有“计划任务”功能也可以直接用 crontab0 2 * * * cd /www/wwwroot/Agriculture_Analysis-main /www/wwwroot/.../venv/bin/python reptile_agriculture.py /tmp/agri.log 21 30 2 * * * cd /www/wwwroot/Agriculture_Analysis-main /www/wwwroot/.../venv/bin/python storeData.py /tmp/store.log 21第一条负责抓数据第二条负责清洗聚合两条错开半小时避免同时访问 MySQL 和目标站点。验证有没有跑成功最直接的是看日志和表行数tail -n 50 /tmp/agri.log mysql -u root -p -e SELECT COUNT(*), MIN(record_date), MAX(record_date) FROM agriculture_record;这里留一个我常用的技巧在storeData.py末尾打印一个汇总结果比如“最晚日期: 2024-06-30, 聚合行数: 1024”再用grep去查日志里的最后一次时间戳。这样比去 Django admin 后台翻记录快也方便写 shell 脚本对日志做监控。如果某天发现表里最大日期停留在三天前第一反应不是去看爬虫代码而是去目标网站确认数据源有没有改版反爬策略是否变化。本文还有配套的精品资源点击获取
返回列表