农产品公开数据应用:OpenClaw 抓取农产品价格、产销公开数据,实现农产品行情动态监测

一、为什么需要农产品行情动态监测

农产品价格与产销信息,直接关系着种植户的收益、流通环节的利润以及终端消费者的生活成本。过去,这类信息大多散落在各级政府网站、批发市场公告、行业协会简报和电商平台页面中,更新时间不一、格式千差万别。对于农业经营者、分析师和供应链管理者来说,依靠人工逐页查看、逐条复制粘贴,既耗时又容易遗漏关键变化。蔬菜、水果、粮油、肉禽蛋等品种价格往往在几天甚至几小时内就出现明显波动,如果不能及时捕捉这些变化,就可能错过最佳销售窗口,或者在采购环节付出更高成本。

本文以 OpenClaw 作为数据采集与自动化编排框架,围绕公开可获取的农产品价格和产销数据,构建一套从采集、清洗、标准化、存储到动态监测的完整方案。文章会从数据源梳理、开发环境搭建、请求抓取、页面解析、数据清洗、指标计算、告警预警、可视化展示,一直到定时调度和部署运维,逐层展开实战过程。所有示例代码都基于 Python 生态中成熟稳定的库来实现,读者可以按照文章顺序复现整个系统。全文采用模块化思路设计,即使更换数据源或增加新的监测品种,也只需在配置或采集器层面做少量扩展,而无需推翻整体架构。

需要特别说明的是,本文所处理的数据均来自公开渠道,采集过程遵循网络爬虫的通行规范,控制请求频率、尊重目标平台的服务能力,并仅用于行情研究、经营决策辅助等合规场景。下面先来分析当前农产品数据应用面临的主要问题,再进入 OpenClaw 技术方案的详细设计。

二、农产品公开数据现状与常见痛点

农产品公开数据并不缺乏,缺的是低成本、高效率的整合与应用手段。从数据来源看,农业农村部门会定期发布批发市场价格、主要农产品供需形势分析、季度产销报告;各地大型批发市场会公示当日蔬菜、水果、水产的批发价与成交量;一些行业协会和农产品信息平台会发布产地收购价、销地批发价、零售价等参考数据;此外,电商平台和社区团购页面也会展示部分生鲜品类的实时售价。这些数据本身覆盖面较广,但在实际应用中却存在明显问题。

2.1 数据分散,缺少统一视图

不同数据源之间彼此独立,发布平台、页面结构、更新频率、品种命名都没有统一标准。例如同一个品种的番茄,有的平台写作"西红柿",有的写作"番茄(粉果)",还有的按照"精品果""统货"进行分级。要形成跨市场、跨区域的统一行情视图,必须先将这些名称、单位、分级口径进行规范化处理。

2.2 更新节奏不一致

有的批发市场每天上午更新当日价格,有的政府平台按周、按月发布统计分析,还有一些电商页面近乎实时变动。对于经营者来说,及时性要求往往比较高,尤其是蔬菜、水产这类易腐品类。如果不能按统一节奏定时采集,监测系统就会变成"滞后信息库",失去决策价值。

2.3 页面结构变动频繁

公开网页经常因改版、栏目调整、接口升级而导致原来的抓取逻辑失效。一个稳定的采集系统,需要把抓取规则和业务逻辑解耦,当某个页面的 HTML 结构变化时,只需要调整对应的解析配置,而不是重写整个程序。

2.4 原始数据质量参差不齐

公开数据中常见的质量问题包括:缺失值、单位混用(公斤、市斤、吨、箱、筐)、日期格式不一致、异常值(明显偏离历史区间的价格)、重复记录等。如果不对这些问题进行处理,后续计算环比、同比、波动率等指标时就会产生误导性结果。

下面的表格对常见的农产品公开数据类型进行了归纳,这也是本文后续采集和监测的重点对象。

数据类别 典型内容 常见来源 更新频率
批发价格 品种、规格、批发价、成交量 大型批发市场、农业信息平台 每日或每半日
产地收购价 品种、产地、地头收购价 行业协会、产地信息服务商 每日或每周
零售价格 品种、零售价、所在区域 商超、电商、物价监测平台 每日或实时
产销分析 种植面积、产量、上市量、供需判断 政府农业部门、研究机构 每周、每月或按季
气象与物流 产区天气、运输成本、到货量 气象平台、物流平台 实时或每日

从这张表可以看出,要想实现农产品行情的动态监测,核心不是"能不能拿到数据",而是"如何把多源、异构、不同节奏的公开数据稳定地汇聚到统一体系中,并持续产出有价值的行情指标"。这正是 OpenClaw 要做的事情。

三、OpenClaw 在数据采集中的定位与整体架构

OpenClaw 在本项目中不是一个单独的小脚本,而是一套用于编排数据采集任务、执行网页读取与解析、管理任务状态和输出结果的自动化框架。它负责把"采集器""解析器""清洗器""存储层""监测器"这些模块串联起来,形成一条可持续运转的数据流水线。对于开发者来说,使用 OpenClaw 的好处主要体现在三个方面:一是任务编排清晰,采集、解析、入库、告警可以拆分成独立步骤;二是调度能力强,可以按分钟、小时、天等不同周期执行;三是扩展方便,新增一个数据源只需要增加对应的采集配置和少量解析代码。

3.1 总体架构

整个系统的架构可以用下面的流程表示。数据首先从各类公开网页进入采集层,由采集器按照约定的频率请求页面并保存原始 HTML;随后由解析层提取结构化字段;清洗层负责统一单位、修正日期、处理缺失值和异常值;标准化层把不同来源的品种名称映射到统一的品种字典;存储层将最终结果写入数据库;监测层基于历史数据计算价格指标,并在触发预警条件时发出通知;最上层是可视化与查询接口,面向分析人员提供图表和报表。

flowchart TD A[公开数据源] --> B[OpenClaw 任务编排] B --> C[采集器 Collector] C --> D[原始 HTML 缓存] D --> E[解析器 Parser] E --> F[清洗与标准化] F --> G[数据库存储] G --> H[指标计算与监测] H --> I[告警通知] H --> J[可视化展示]

这一分层设计的核心原则是"职责单一、边界清晰"。采集器不关心页面里有哪些字段,解析器不负责请求网络,清洗层不知道数据来自哪个网站。这样当某个批发市场改版时,只需要调整解析器中对应的选择器;当数据源增加时,只需要新增一个采集器配置和配套的解析器,其他模块完全不受影响。

3.2 模块职责说明

  • 任务编排器:负责定义每个数据源的任务模板,包括请求地址、请求方式、执行周期、超时时间、重试次数、输出位置等。
  • 采集器:基于任务配置发起 HTTP 请求,处理重试、限速、随机延迟,并把响应内容按原始形式保存到缓存目录,便于回溯和调试。
  • 解析器:读取原始 HTML,按照每个数据源事先配置的字段规则提取标题、表格、价格、日期等内容,输出半结构化的记录。
  • 清洗与标准化:对半结构化记录进行类型转换、单位统一、品种映射、缺失值补齐、异常值标记。
  • 存储层:将标准化结果写入 SQLite 或 MySQL,维护原始表、清洗表、指标表等多层数据。
  • 监测器:基于历史窗口计算价格环比、同比、波动幅度、移动平均等指标,并执行阈值告警。
  • 通知与展示:通过邮件、企业微信、钉钉等渠道发送预警,同时提供图表和报表接口。

接下来的章节会按照这条流水线的顺序,从数据源梳理开始,逐步把每个模块落地为可运行的代码。

四、公开数据源梳理与选择策略

在实际项目中,选择数据源不能只追求数量,更要看重数据的稳定性、可获取性和结构化程度。通常建议优先选择页面结构相对固定、数据以表格形式呈现、更新频率符合业务需要的来源。对于政府类和大型批发市场类平台,多数情况下数据是公开公示的,且网页结构在较长时间内保持稳定,适合作为核心数据源。电商平台页面虽然信息丰富,但往往包含动态加载、登录限制和更强的反爬策略,稳定性较差,一般作为补充数据源,并且需要更谨慎地控制采集行为。

4.1 数据源评估维度

在正式开发前,可以先对候选数据源做一个简单的评估,建议从以下维度打分:

  • 覆盖品种:是否包含目标监测的蔬菜、水果、粮油、肉禽蛋等品类。
  • 区域覆盖面:是否包含产地、销地、批发市场等不同环节的数据。
  • 更新频率:能否满足每日甚至更高频的监测需求。
  • 页面稳定性:页面结构是否长期稳定,是否存在明显反爬机制。
  • 数据规范性:品种名称、单位、日期等字段是否相对统一。
  • 合规风险:平台是否允许合理频率的数据采集,是否有明确的服务条款限制。

通过这个评估,可以确定哪些数据源作为"主力源",哪些作为"校验源",哪些暂时不适合纳入。例如,某个批发市场如果每天上午定时发布完整的价格表,且页面为静态 HTML 表格,就非常适合作为主力源;而某个电商页面虽然价格更新及时,但页面接口频繁变动,可以作为价格趋势校验的辅助来源,但不宜作为系统稳定性的依赖。

4.2 配置化描述数据源

为了让 OpenClaw 能够灵活管理多个数据源,建议把每个数据源的元信息写成配置文件,而不是硬编码在代码里。下面是一个典型的 YAML 配置示例,描述了一个批发市场价格页面的采集任务。

yaml 复制代码
sources:
  - name: demo_wholesale_market
    display_name: 示例批发市场
    base_url: "https://example-market.com/price"
    method: GET
    schedule: "0 6 * * *"
    timeout: 20
    retry: 3
    rate_limit_seconds: 2
    headers:
      User-Agent: "Mozilla/5.0"
    encoding: "utf-8"
    fields:
      - name: variety
        selector: "table.price-table tr td:nth-child(1)"
      - name: spec
        selector: "table.price-table tr td:nth-child(2)"
      - name: wholesale_price
        selector: "table.price-table tr td:nth-child(3)"
      - name: volume
        selector: "table.price-table tr td:nth-child(4)"
      - name: publish_date
        selector: "div.publish-time"

这份配置把请求地址、调度时间、重试策略、限速间隔、请求头和字段选择器全部集中在一起。采集器和解析器读取配置后即可按统一流程执行,后续新增数据源时只需要增加一段配置,并补充必要的品种映射规则,不需要修改主程序。这也是整个系统能够长期维护、不断扩展的重要基础。

五、开发环境准备与项目结构

本文的实现基于 Python 3.10 及以上版本。建议使用虚拟环境隔离依赖,避免与系统已有的包发生冲突。核心依赖包括:requests 用于发起网络请求,BeautifulSoup4 和 lxml 用于 HTML 解析,pandas 用于数据处理,PyYAML 用于读取配置文件,APScheduler 用于定时调度,SQLAlchemy 或 pymysql 用于数据库访问。如果后续需要进行可视化,还可以安装 matplotlib 或 pyecharts。

5.1 创建项目并安装依赖

bash 复制代码
mkdir openclaw-agri-monitor
cd openclaw-agri-monitor
python -m venv venv
source venv/bin/activate
pip install requests beautifulsoup4 lxml pandas pyyaml apscheduler sqlalchemy

在 Windows 环境下,激活虚拟环境的命令为 venv\Scripts\activate。安装完成后,可以通过下面的命令确认主要依赖版本。

bash 复制代码
python -c "import requests, bs4, pandas, yaml; print('依赖安装完成')"

5.2 项目目录结构

text 复制代码
openclaw-agri-monitor/
├── config/
│   └── sources.yaml
├── data/
│   ├── raw/
│   ├── stage/
│   └── output/
├── src/
│   ├── __init__.py
│   ├── config_loader.py
│   ├── collector.py
│   ├── parser.py
│   ├── cleaner.py
│   ├── normalizer.py
│   ├── storage.py
│   ├── monitor.py
│   ├── notifier.py
│   └── scheduler.py
├── scripts/
│   └── run_once.py
├── requirements.txt
└── README.md

其中 data/raw 保存采集到的原始 HTML,data/stage 保存解析后的中间结果,data/output 保存清洗后的最终数据。原始 HTML 的保留很重要,一旦后续发现解析结果异常,可以随时回到原始页面进行排查,而不必重新请求目标网站。

六、采集器实现:请求、重试与限速

采集器是整个流水线的入口。它的职责是根据任务配置发起 HTTP 请求,把响应内容保存到本地,并处理可能出现的网络异常。一个健壮的采集器必须考虑以下几点:设置合理的超时时间、在失败时进行有限次重试、对同一站点保持合理的请求间隔、使用接近真实浏览器的请求头、保存请求时间与响应信息用于排障。

6.1 基础请求封装

下面给出一个通用的请求封装函数。它读取单个数据源配置,按照配置中的参数发起请求,失败时按指数退避策略重试,并记录每次请求的状态。

python 复制代码
import time
import random
import logging
from pathlib import Path
import requests
logger = logging.getLogger(name)
class FetchError(Exception):
pass
class Collector:
def init(self, raw_dir: str = "data/raw", sleep_range=(1.0, 3.0)):
self.raw_dir = Path(raw_dir)
self.raw_dir.mkdir(parents=True, exist_ok=True)
self.sleep_range = sleep_range
self.session = requests.Session()
def fetch(self, source: dict) -> Path:
    url = source["base_url"]
    method = source.get("method", "GET").upper()
    timeout = source.get("timeout", 20)
    retry = source.get("retry", 3)
    headers = source.get("headers", {})
    params = source.get("params", {})
last_error = None
for attempt in range(1, retry + 1):
    try:
        response = self.session.request(
            method=method,
            url=url,
            headers=headers,
            params=params,
            timeout=timeout,
        )
        response.raise_for_status()
        if source.get("encoding"):
            response.encoding = source["encoding"]
    file_path = self._save_raw(source["name"], response.text)
    logger.info("采集成功: %s, 第 %s 次尝试", source["name"], attempt)
    return file_path
except requests.RequestException as exc:
    last_error = exc
    logger.warning("采集失败: %s, 第 %s 次尝试, 原因: %s", source["name"], attempt, exc)
    if attempt < retry:
        time.sleep(random.uniform(*self.sleep_range) * attempt)
raise FetchError(f"{source['name']} 采集失败: {last_error}")
def save_raw(self, name: str, content: str) -> Path:
save_dir = self.raw_dir / name
save_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y%m%d%H%M%S")
file_path = save_dir / f"{timestamp}.html"
file_path.write_text(content, encoding="utf-8")
return file_path

这段代码把所有网络异常统一捕获,并在每次失败后等待一个随机间隔后重试。随机间隔的目的是让请求时间不那么机械,降低对目标站点的集中压力。每次成功采集的页面会以时间戳命名保存,避免后续采集覆盖历史页面。通过保存原始 HTML,解析器可以离线读取本地文件进行开发调试,不必反复访问目标网站。

6.2 请求频率控制

对同一站点的请求频率控制,既是对目标平台的基本尊重,也是保证自己采集器稳定运行的手段。可以在爬取循环中加入一个简单的限速器,记录上一次请求时间,在下次请求前补齐最小间隔。

python 复制代码
class RateLimiter:
    def __init__(self, interval: float):
        self.interval = interval
        self.last_request_time = 0.0
def wait(self):
    elapsed = time.time() - self.last_request_time
    wait_seconds = max(0.0, self.interval - elapsed)
    if wait_seconds > 0:
        time.sleep(wait_seconds)
    self.last_request_time = time.time()

如果采集的品种较多,需要翻页获取完整价格表,可以在翻页请求之间调用 RateLimiter.wait()。对于公开数据采集,一般建议把同站点的请求间隔设置在 1 秒到 3 秒之间,并根据目标站点的实际承载能力适当调整。切忌为了抢时间而无节制地高频请求,否则轻则导致自己的 IP 被临时限制,重则给平台造成不必要的压力,也不符合合规采集的基本要求。

七、解析器实现:从 HTML 中提取结构化数据

采集器拿到的是 HTML 文本,下一步需要把这些文本转换成结构化字段。解析器的输入是本地保存的原始 HTML 文件,输出是字段与目标名称一一对应的记录列表。使用 BeautifulSoup 可以方便地按照标签、类名、CSS 选择器定位元素;如果页面结构复杂,也可以配合 lxml 或 XPath 进行更精细的定位。

7.1 单页价格表解析

假设某个批发市场价格页面的 HTML 大致结构如下,表格的每一行包含品种、规格、批发价和成交量。

html 复制代码
<table class="price-table">
  <thead>
    <tr><th>品种</th><th>规格</th><th>批发价</th><th>成交量</th></tr>
  </thead>
  <tbody>
    <tr><td>西红柿</td><td>粉果</td><td>4.20</td><td>15000</td></tr>
    <tr><td>黄瓜</td><td>长条</td><td>3.80</td><td>12000</td></tr>
  </tbody>
</table>
<div class="publish-time">2026-09-06</div>

解析器可以从配置中读取字段选择器,也可以直接编写针对该页面的解析函数。下面给出一个基于 BeautifulSoup 的解析实现。

python 复制代码
from bs4 import BeautifulSoup
from pathlib import Path
class PriceTableParser:
def parse(self, html_path: Path, source_name: str) -> list[dict]:
html = html_path.read_text(encoding="utf-8")
soup = BeautifulSoup(html, "lxml")
publish_date = ""
date_node = soup.select_one("div.publish-time")
if date_node:
publish_date = date_node.get_text(strip=True)
    records = []
    table = soup.select_one("table.price-table")
    if not table:
        return records
for row in table.select("tbody tr"):
    cells = row.find_all("td")
    if len(cells) &lt; 4:
        continue
    records.append({
        "source": source_name,
        "variety": cells[0].get_text(strip=True),
        "spec": cells[1].get_text(strip=True),
        "wholesale_price": cells[2].get_text(strip=True),
        "volume": cells[3].get_text(strip=True),
        "publish_date": publish_date,
        "crawled_at": html_path.stem,
    })
return records</code></pre>
解析时需要注意,网页中的空白字符、换行符和不可见空格会影响文本提取结果,因此统一使用 get_text(strip=True) 去掉首尾空白。如果单元格内还包含子标签,比如价格后面跟着单位或备注,需要根据实际结构进一步处理。保留 crawled_at 字段可以追踪每条数据来源于哪一次采集,便于后续审计和重跑。
7.2 处理翻页和分页数据
很多价格页面不会把所有品种放在同一页,而是按字母或类别分页展示。解析器本身不需要关注翻页逻辑,翻页应该在采集阶段完成:采集器先请求第一页,解析出总页数或"下一页"链接,再依次请求后续页面并分别保存原始 HTML。为了保持简单,可以在采集器外层加入一个页面遍历函数,每次请求之间调用限速器。
def collect_paginated(collector, parser, source):
    first_page = collector.fetch(source)
    html = first_page.read_text(encoding="utf-8")
    soup = BeautifulSoup(html, "lxml")
    total_pages = 1
    page_info = soup.select_one("span.total-page")
    if page_info:
        total_pages = int(page_info.get_text(strip=True))
all_records = parser.parse(first_page, source["name"])
limiter = RateLimiter(source.get("rate_limit_seconds", 2))
for page in range(2, total_pages + 1):
limiter.wait()
page_source = dict(source)
page_source["base_url"] = f"{source['base_url']}?page={page}"
page_path = collector.fetch(page_source)
all_records.extend(parser.parse(page_path, source["name"]))
return all_records
这段逻辑先把第一页保存并解析,然后根据页面上的总页数信息继续抓取后续页面。如果某些页面没有明确的总页数标记,而是通过"下一页"按钮判断是否结束,可以把循环条件改为"存在下一页链接"。无论采用哪种方式,都要保证翻页请求之间有足够的间隔。
八、数据清洗与标准化
从网页提取出来的数据仍然是字符串形式的,而且各个数据源的品种名称、单位、日期写法可能完全不同。清洗与标准化环节要完成四件事:类型转换、单位统一、品种名称映射、异常值与缺失值处理。这一步处理得越扎实,后续的指标计算和趋势判断就越可靠。
8.1 类型转换与单位统一
价格字段需要从字符串转换成浮点数,成交量需要转换成整数。在转换之前,要先把包含单位、逗号、货币符号的内容清理干净。例如"4.20元/公斤""4,200.00""约 3.8"等写法都应转换成标准数值。下面是一个价格清洗函数。
import re
def parse_price(raw: str) -> float | None:
if not raw:
return None
text = raw.strip().replace(",", "").replace(",", "")
text = re.sub(r"[^\d.]", "", text)
try:
value = float(text)
except ValueError:
return None
return round(value, 2)
def parse_volume(raw: str) -> int | None:
if not raw:
return None
text = re.sub(r"[^\d]", "", raw.strip())
if not text:
return None
return int(text)
这里把除数字和小数点以外的字符全部去除,对于货币符号、单位文字等都能很好地剥离。实际业务中可能存在"吨""公斤""斤"混用的情况,单纯提取数字并不足以统一口径,因此还需要根据原始单位进行换算。下面给出一个单位换算映射,统一把重量单位换算为公斤。
UNIT_TO_KG = {
    "公斤": 1.0,
    "千克": 1.0,
    "kg": 1.0,
    "斤": 0.5,
    "市斤": 0.5,
    "吨": 1000.0,
    "克": 0.001,
}
def extract_unit(raw: str, default: str = "公斤") -> str:
if not raw:
return default
for unit in UNIT_TO_KG:
if unit in raw:
return unit
return default
在解析时同时保留原始价格文本和提取出的单位,清洗阶段再按照换算系数统一成标准单位,并新增一个 wholesale_price_per_kg 字段。
8.2 品种名称映射
不同平台的品种叫法差异很大,例如"西红柿""番茄""洋柿子"可能指向同一种农产品。建立品种标准字典,把各平台的原始名称映射到统一标准名称,是跨源监测的前提。下面给出一个简单的映射表示例。
VARIETY_ALIAS = {
    "西红柿": "番茄",
    "洋柿子": "番茄",
    "蕃茄": "番茄",
    "卷心菜": "甘蓝",
    "包菜": "甘蓝",
    "土豆": "马铃薯",
    "洋芋": "马铃薯",
    "地瓜": "红薯",
}
def normalize_variety(raw: str) -> str:
name = raw.strip()
return VARIETY_ALIAS.get(name, name)
为了保证映射规则的可持续维护,正式项目中建议把品种映射放到独立配置文件里,而不是写在代码中。随着采集数据源增多,映射字典会不断扩大,可以通过人工审核逐步修正,也可以结合相似度算法对未识别品种给出推荐标准名,再由人工确认。
8.3 缺失值与异常值处理
对于价格数据,缺失值可以根据业务场景选择删除或保留标记。如果某个品种在当天没有交易,价格字段为空是正常现象,不应简单用前一天价格填充,否则会造成虚假的价格连续性。更合理的做法是保留空值,并在后续计算指标时跳过该记录。对于明显异常的价格,可以使用历史均值加标准差的方法进行识别。
import pandas as pd
def flag_outliers(df: pd.DataFrame, column: str, window: int = 30) -> pd.DataFrame:
df = df.copy()
df["rolling_mean"] = df[column].rolling(window, min_periods=3).mean()
df["rolling_std"] = df[column].rolling(window, min_periods=3).std()
upper = df["rolling_mean"] + 3 * df["rolling_std"]
lower = df["rolling_mean"] - 3 * df["rolling_std"]
df["is_outlier"] = (df[column] > upper) | (df[column] < lower)
return df
被标记为异常值的记录不应直接删除,而应该进入单独的"待审核"表,由业务人员判断是真实的价格波动还是数据错误。因为农产品价格受天气、节日、突发事件影响,有时出现大幅涨跌并不一定是错误数据,如果简单过滤反而会丢失重要信号。
九、数据存储设计
清洗完成后的数据需要写入数据库,便于后续查询和指标计算。对于个人项目或小规模部署,SQLite 足够使用,且无需额外安装数据库服务;对于团队协作或数据量较大的场景,可以切换到 MySQL 或 PostgreSQL。无论选择哪种数据库,表结构都应该尽可能清晰,区分原始数据、标准化数据和指标数据。
9.1 标准化价格表
下面给出一个使用 SQLAlchemy 定义的标准化价格表,包含来源、品种、标准品种、规格、价格、单位、成交量、发布日期和采集时间等字段。
from sqlalchemy import create_engine, Column, String, Float, Integer, Date, DateTime, Index
from sqlalchemy.orm import declarative_base
from datetime import datetime
Base = declarative_base()
class PriceRecord(Base):
tablename = "price_records"
id = Column(Integer, primary_key=True, autoincrement=True)
source = Column(String(64), nullable=False)
source_name = Column(String(128))
variety_raw = Column(String(64))
variety_std = Column(String(64), index=True)
spec = Column(String(64))
region = Column(String(64))
price = Column(Float, nullable=False)
price_unit = Column(String(16), default="公斤")
volume = Column(Integer)
volume_unit = Column(String(16))
publish_date = Column(Date, index=True)
crawled_at = Column(DateTime, default=datetime.utcnow)
is_outlier = Column(Integer, default=0)
table_args = (
Index("ix_source_date_variety", "source", "publish_date", "variety_std"),
)
为品种、发布日期以及"来源 + 日期 + 标准品种"的组合建立索引,可以显著提升后续按品种查询历史行情、按日期聚合、以及去重检查的效率。去重逻辑可以在写入前执行:如果同一来源、同一发布日期、同一标准品种和规格已经存在,则跳过或更新,避免重复采集导致的数据冗余。
9.2 写入与去重
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
class Storage:
def init(self, db_url: str = "sqlite:///agri_monitor.db"):
self.engine = create_engine(db_url, echo=False)
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine)
def upsert(self, records: list[dict]) -> int:
    inserted = 0
    with self.Session() as session:
        for item in records:
            exists = session.execute(
                select(PriceRecord).where(
                    PriceRecord.source == item["source"],
                    PriceRecord.publish_date == item["publish_date"],
                    PriceRecord.variety_std == item["variety_std"],
                    PriceRecord.spec == item["spec"],
                )
            ).scalar_one_or_none()
            if exists:
                exists.price = item["price"]
                exists.volume = item["volume"]
                exists.crawled_at = datetime.utcnow()
            else:
                session.add(PriceRecord(**item))
                inserted += 1
        session.commit()
    return inserted
这里使用"存在则更新、不存在则插入"的 upsert 策略,能够有效避免同一天重复采集带来的重复记录。对于需要保留完整历史版本的需求,可以在写入时不更新,而是通过数据版本号或流水号保留每次采集快照;但通常情况下,每日价格以最新一次采集为准即可。
十、行情指标计算:环比、同比与波动率
只有把原始价格转化成有业务含义的指标,动态监测才真正发挥作用。农产品行情监测中最常用的指标包括:日环比、周环比、月环比、同比、移动平均、价格波动率、价格区间和涨跌幅度。这些指标能帮助分析人员快速判断某个品种近期是上涨、下跌还是平稳,以及波动是否超出正常范围。
10.1 环比与同比
环比反映的是相邻周期的变化幅度,同比反映的是与上年同期相比的变化。对于每日更新的批发价格,日环比等于当日价格与前一日价格的差值除以前一日价格。下面给出基于 pandas 的计算示例。
def calc_price_metrics(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df = df.sort_values(["variety_std", "publish_date"])
    df["prev_price"] = df.groupby("variety_std")["price"].shift(1)
    df["day_over_day"] = (df["price"] - df["prev_price"]) / df["prev_price"] * 100
    df["ma7"] = df.groupby("variety_std")["price"].transform(
        lambda x: x.rolling(7, min_periods=3).mean()
    )
    df["volatility_7d"] = df.groupby("variety_std")["price"].transform(
        lambda x: x.rolling(7, min_periods=3).std() / x.rolling(7, min_periods=3).mean() * 100
    )
    return df
这里按标准品种分组,先使用 shift(1) 得到前一日价格,再计算日环比。7 日移动平均用于平滑短期波动,7 日波动率用滚动标准差除以滚动均值得到,反映价格在近一周内的离散程度。需要注意的是,分组后如果某个品种存在日期缺失,简单 shift 可能跨过多天,更严谨的做法是先将日期补齐,再计算相邻交易日的变化。
10.2 同比计算
同比计算需要将当日价格与上年同日期价格进行对比。农产品供需具有明显的季节性,例如春节前蔬菜价格通常上涨,夏季时令水果集中上市价格下行。因此同比指标对判断"当前价格水平是否正常"非常有帮助。
def attach_year_over_year(df: pd.DataFrame, years: int = 1) -> pd.DataFrame:
    df = df.copy()
    df["publish_date"] = pd.to_datetime(df["publish_date"])
    df["date_last_year"] = df["publish_date"] - pd.DateOffset(years=years)
    df = df.reset_index(drop=True)
last_year_prices = df[["variety_std", "publish_date", "price"]].rename(
    columns={
        "publish_date": "date_last_year",
        "price": "price_last_year",
    }
)
merged = df.merge(
    last_year_prices,
    on=["variety_std", "date_last_year"],
    how="left",
)
merged["year_over_year"] = (
    (merged["price"] - merged["price_last_year"]) / merged["price_last_year"] * 100
)
return merged
当历史数据不足一年时,同比字段会自然出现空值,这是正常现象。系统运行满一年后,同比指标就会逐步完整起来。对于缺乏去年同期数据的品种,可以先使用移动平均或季节性基准作为替代参考。
十一、动态监测与智能告警
动态监测的核心在于"自动发现问题",而不是等分析人员每天打开报表逐一看。监测器会根据预设规则扫描最新的行情指标,当某个品种的日涨跌幅、连续涨跌天数、价格偏离移动平均的幅度或波动率超过阈值时,触发告警并把消息推送给相关责任人。
11.1 阈值规则设计
告警规则应该尽量简单、可解释。例如可以设置这样几条基础规则:当日价格较前一日上涨或下跌超过 5%;价格连续 3 天上涨或下跌;当前价格较 7 日移动平均偏离超过 8%;7 日波动率超过历史同期水平。下面用表格列出规则和适用场景。
规则名称
触发条件
适用场景
单日大涨
日环比大于 5%
突发事件、供应收缩预警
单日大跌
日环比小于 -5%
集中上市、需求走弱预警
连续上涨
连续 3 日环比为正
趋势性上涨识别
偏离均线
价格偏离 7 日均线超过 8%
短期价格异常识别
波动放大
7 日波动率高于近 30 日均值
市场不确定性提示
这些阈值并不是固定不变的。不同品种的价格弹性差异很大,叶菜类短期波动往往大于粮油类,因此最好按品种类别设置差异化的阈值,并通过历史数据回测来校准。
11.2 告警生成与去重
监测器通常每轮采集完成后运行一次,扫描所有品种的最新指标,把满足规则条件的品种整理成告警列表。为了避免同一品种在同一触发条件下反复推送,需要做告警去重。可以在数据库中记录已发送的告警,只有当同一品种、同一规则连续触发若干次,或者距离上次发送超过一定时间,才再次通知。
class AlertRule:
    def __init__(self, name: str, threshold: float, direction: str = "both"):
        self.name = name
        self.threshold = threshold
        self.direction = direction
def matches(self, value: float) -> bool:
    if value is None:
        return False
    if self.direction == "up":
        return value > self.threshold
    if self.direction == "down":
        return value < self.threshold
    return abs(value) > self.threshold
def scan_alerts(metrics_df, rules: list[AlertRule]) -> list[dict]:
alerts = []
for _, row in metrics_df.iterrows():
for rule in rules:
value = row.get(rule.metric, None)
if rule.matches(value):
alerts.append({
"variety": row["variety_std"],
"price": row["price"],
"metric": rule.metric,
"value": round(value, 2) if value is not None else None,
"rule": rule.name,
"date": row["publish_date"],
})
return alerts
在实际系统中,AlertRule 的指标名、方向和阈值都可以从配置文件读取。告警结果可以先写入数据库,再由通知模块统一发送。通知渠道可以采用邮件、企业微信机器人或钉钉自定义机器人,推荐使用消息摘要的方式一次性发送全部告警,而不是每条告警单独推送,避免消息过多造成干扰。
11.3 通知消息示例
下面展示一个告警消息的文本模板,仅用于说明通知内容的组织方式,不涉及具体推送代码。
农产品行情监测提醒 2026-09-06
番茄:5.40 元/公斤,日环比 +6.20%,触发"单日大涨"规则
黄瓜:3.12 元/公斤,连续 3 日上涨,当前价格偏离 7 日均线 +8.60%
马铃薯:2.80 元/公斤,7 日波动率 9.40%,高于近 30 日平均水平
消息中同时给出品种、当前价格、触发指标和对应规则,接收者可以快速判断是否需要进一步关注,而不必回查系统页面。
十二、可视化展示与报表输出
行情监测离不开图表。原始数据和指标表虽然完整,但直接阅读数字的效率远低于查看趋势图。对于价格监测来说,最常用的图表包括单品种价格走势折线图、多品种价格对比图、涨跌幅柱状图,以及品种与日期的价格热力图。下面以 matplotlib 为例给出核心绘图代码,也可以替换为 pyecharts 以生成交互式图表。
12.1 单品种价格走势
import matplotlib.pyplot as plt
import pandas as pd
def plot_price_trend(df: pd.DataFrame, variety: str, save_path: str = "price_trend.png"):
subset = df[df["variety_std"] == variety].sort_values("publish_date")
if subset.empty:
return
plt.figure(figsize=(12, 6))
plt.plot(subset["publish_date"], subset["price"], marker="o", linewidth=1.5, label="实际价格")
plt.plot(subset["publish_date"], subset["ma7"], linestyle="--", linewidth=1.5, label="7 日移动平均")
plt.title(f"{variety} 价格走势")
plt.xlabel("日期")
plt.ylabel("价格(元/公斤)")
plt.xticks(rotation=45)
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=150)
plt.close()
将实际价格与 7 日移动平均画在同一张图中,可以直观看出短期波动是否偏离中期趋势。当实际价格向上或向下远离移动平均线时,往往意味着短期市场出现了较快变化,需要结合成交量、产地天气等信息进一步判断原因。
12.2 多品种涨幅对比
def plot_price_changes(metrics_df: pd.DataFrame, save_path: str = "price_changes.png"):
    latest = metrics_df.sort_values("publish_date").groupby("variety_std").tail(1)
    latest = latest.dropna(subset=["day_over_day"]).sort_values("day_over_day")
    if latest.empty:
        return
colors = ["#d9534f" if v < 0 else "#5cb85c" for v in latest["day_over_day"]]
plt.figure(figsize=(10, max(6, len(latest) * 0.35)))
plt.barh(latest["variety_std"], latest["day_over_day"], color=colors)
plt.axvline(0, color="#333", linewidth=0.8)
plt.title("主要品种日环比涨跌幅")
plt.xlabel("日环比(%)")
plt.tight_layout()
plt.savefig(save_path, dpi=150)
plt.close()
横向条形图适合品种数量较多的情况,绿色代表上涨,红色代表下跌,柱子的长度直观反映涨跌幅度。这种图可以每天生成一次,配合告警消息一起发送给业务人员,作为当天行情的快速概览。
12.3 价格热力图
如果要观察多个品种在一个月内的价格变化节奏,可以使用价格热力图,横轴为日期,纵轴为品种,颜色深浅表示价格高低。这样能够快速发现哪些品种在近期出现了颜色明显变深或变浅的区域,从而定位异常时段。
import matplotlib.dates as mdates
def plot_price_heatmap(df: pd.DataFrame, save_path: str = "price_heatmap.png"):
pivot = df.pivot_table(index="variety_std", columns="publish_date", values="price")
pivot = pivot.sort_index()
plt.figure(figsize=(14, max(6, len(pivot) * 0.45)))
im = plt.imshow(pivot.values, aspect="auto", cmap="YlOrRd", interpolation="nearest")
plt.colorbar(im, label="价格(元/公斤)")
plt.xticks(range(len(pivot.columns)), [d.strftime("%m-%d") for d in pivot.columns], rotation=45)
plt.yticks(range(len(pivot.index)), pivot.index)
plt.xlabel("日期")
plt.ylabel("品种")
plt.title("农产品价格热力图")
plt.tight_layout()
plt.savefig(save_path, dpi=150)
plt.close()
热力图对数据的完整度有一定要求,缺失值较多的品种会造成色块空缺。可以在绘图前对缺失日期做前向填充或标记为灰色,但要注意不能把填充后的价格误以为是真实采集值,图表的注释中应说明数据填充方式。
十三、定时调度与 OpenClaw 任务编排
动态监测必须依赖定时任务持续运行。OpenClaw 通过配置化的调度规则,把"采集-解析-清洗-入库-监测-告警"这条流水线按固定节奏自动执行。对于多数批发市场价格数据,每天早晨采集一次即可;对于需要更高频次的品种,可以设置多次采集。调度策略既要满足业务时效性,也要避免给目标站点造成压力。
13.1 使用 APScheduler 实现调度
APScheduler 是 Python 中常用的调度库,支持 Cron 表达式和固定间隔触发。下面给出一个调度主程序,每日早上 6 点执行一次全流程,另外在上午 10 点和下午 4 点各执行一次价格监测和告警扫描。
from apscheduler.schedulers.blocking import BlockingScheduler
def run_pipeline():
from src.pipeline import collect_all, clean_all, store_all, monitor_all
raw_files = collect_all()
stage_records = clean_all(raw_files)
store_all(stage_records)
monitor_all()
def run_monitor_only():
from src.pipeline import monitor_all
monitor_all()
scheduler = BlockingScheduler()
scheduler.add_job(run_pipeline, "cron", hour=6, minute=0, id="daily_collect")
scheduler.add_job(run_monitor_only, "cron", hour=10, minute=0, id="morning_monitor")
scheduler.add_job(run_monitor_only, "cron", hour=16, minute=0, id="afternoon_monitor")
if name == "main":
scheduler.start()
这里把全量采集安排在早晨 6 点,适合批发市场公布当日价格后的时间窗口。上午和下午只运行监测与告警,不重复请求数据源。具体时间应根据目标站点的更新规律调整,避免在数据尚未发布时采集到不完整页面。
13.2 失败重试与任务状态记录
定时任务在无人值守的环境中运行,必须考虑失败情况。建议在每次任务执行后将状态写入日志表或运行日志文件,包括任务开始时间、结束时间、处理条数、失败原因等。下面是一个简单的运行日志记录函数。
import json
from datetime import datetime
def write_run_log(task_name: str, status: str, message: str, extra: dict | None = None):
log_entry = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"task": task_name,
"status": status,
"message": message,
"extra": extra or {},
}
with open("run_log.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
通过查看运行日志,可以快速确认某次采集是否成功、解析了多少条记录、是否有异常。如果某个数据源连续多次失败,应该及时告警给维护人员,而不是让系统静默地缺失数据。采集失败的原因可能包括:目标网站改版、网络抖动、IP 被限制、页面返回空数据等。对常见失败原因分类记录,有助于快速定位和恢复。
十四、异常恢复与运维保障
在生产环境中,数据采集系统最常见的挑战不是首次开发,而是长期运行过程中的各种"意外"。页面结构变化导致解析失败、目标站点临时不可用、磁盘空间不足、网络出口波动、数据库锁冲突等,都可能让某次任务失败。一个可靠的系统需要具备异常恢复能力,尽量减少人工干预。
14.1 原始数据保留与重跑
由于采集器会保留每次请求的原始 HTML,当解析逻辑修复后,可以直接对历史原始文件重新执行解析和清洗,而不必重新访问目标网站。这在页面改版导致历史数据缺失时非常有用。重跑脚本可以遍历 data/raw 目录,按数据源和日期重新生成标准化记录。
def reparse_history(raw_dir: str = "data/raw"):
    raw_dir = Path(raw_dir)
    parser = PriceTableParser()
    all_records = []
    for html_file in raw_dir.rglob("*.html"):
        source_name = html_file.parent.name
        all_records.extend(parser.parse(html_file, source_name))
    return all_records
重跑逻辑只读取本地文件,不会增加目标站点压力,可以在解析规则修改后放心执行。解析完成后,再通过 upsert 逻辑更新数据库,保持数据一致性。
14.2 数据完整性检查
每天任务结束后,建议自动执行一次数据完整性检查,例如:核对每个数据源当天是否有记录、目标品种是否齐全、价格是否落在合理区间、是否有重复记录等。下面给出一个简单的检查函数,统计当天各数据源的记录数并输出缺失情况。
def check_daily_completeness(db_session, check_date, expected_sources):
    results = {}
    for source in expected_sources:
        count = (
            db_session.query(PriceRecord)
            .filter(
                PriceRecord.source == source,
                PriceRecord.publish_date == check_date,
            )
            .count()
        )
        results[source] = count
    missing = [s for s, c in results.items() if c == 0]
    return {"date": check_date, "counts": results, "missing": missing}
如果某个数据源当天记录数为零,可能意味着采集失败、目标网站未更新,或者解析器没有匹配到任何数据。无论是哪种原因,都需要有对应的告警,而不是让缺失数据悄悄进入后续统计。完整性检查可以和监测器放在同一次任务中执行。
14.3 日志与监控
生产环境建议使用结构化日志,把采集、解析、存储、监测各环节的关键事件统一记录。可以使用 Python 标准库 logging 并配置按天滚动保存。对于需要更完善监控的场景,可以将运行状态指标推送到监控平台,但对个人项目来说,本地日志和微信/邮件告警已经能够满足大多数需求。
十五、合规采集与数据使用边界
任何涉及网络数据采集的项目,都必须把合规性放在重要位置。公开数据不等于可以无限制抓取,合理使用需要关注以下几个方面。
15.1 遵守平台规则
在采集某个网站前,应先查看网站的 robots.txt 文件和服务条款,确认是否允许自动抓取,以及允许抓取的范围。robots.txt 中通常会声明哪些路径禁止爬虫访问。对于明确禁止自动抓取的平台,不应强行采集,而应寻找官方开放接口或其他合规数据源替代。
15.2 控制请求频率
即使目标平台没有明确禁止,也应把请求频率控制在合理范围,避免对服务器造成不必要的负载。本文前面实现的限速器、指数退避重试,就是为了在保证采集效率的同时降低对目标站点的影响。采集时间应尽量避开目标站点的高峰访问时段。
15.3 明确数据用途
采集到的公开数据应当用于行情研究、经营决策辅助等自身业务场景,不应将原始数据以付费产品或绕过授权的方式对外二次分发。在使用数据时,建议保留来源信息,一方面便于溯源核对,另一方面也是对数据发布方的尊重。
15.4 保护个人信息
农产品价格和产销数据通常不涉及个人隐私,但在采集过程中仍应避免抓取与业务无关的页面,更不要采集任何包含个人信息、账号信息或受保护内容的数据。如果某些页面混合了经营主体信息,应只保留与价格、品种、日期等业务字段相关的部分。
合规意识不仅是法律风险问题,也影响采集系统的长期稳定性。一个尊重平台、控制频率、用途清晰的采集系统,才能在长时间运行中保持可用。
十六、完整流程串联与示例
前面各章分别实现了采集器、解析器、清洗模块、存储模块、监测器和调度器。现在把这些模块串成一条完整的流水线,用一个统一入口执行。下面给出 pipeline.py 的核心结构。
from pathlib import Path
import pandas as pd
from src.config_loader import load_sources
from src.collector import Collector
from src.parser import PriceTableParser
from src.cleaner import clean_records
from src.normalizer import normalize_records
from src.storage import Storage
from src.monitor import scan_alerts, build_metrics
def collect_all():
sources = load_sources("config/sources.yaml")
collector = Collector(raw_dir="data/raw")
parser = PriceTableParser()
all_records = []
for source in sources:
try:
raw_path = collector.fetch(source)
records = parser.parse(raw_path, source["name"])
all_records.extend(records)
except Exception as exc:
write_run_log("collect", "failed", str(exc), {"source": source["name"]})
return all_records
def clean_all(raw_records):
stage = clean_records(raw_records)
return normalize_records(stage)
def store_all(records):
storage = Storage("sqlite:///agri_monitor.db")
return storage.upsert(records)
def monitor_all():
storage = Storage("sqlite:///agri_monitor.db")
df = pd.read_sql("SELECT * FROM price_records", storage.engine)
metrics_df = build_metrics(df)
alerts = scan_alerts(metrics_df, default_rules)
if alerts:
send_alerts(alerts)
check_daily_completeness(storage.Session(), pd.Timestamp.today().date(), expected_sources)
write_run_log("monitor", "ok", f"扫描完成,触发告警 {len(alerts)} 条")
这段代码把各模块按顺序连接起来,并在每个环节捕获异常、记录日志。调度器定时调用 run_pipeline(),即可实现无人值守的每日行情采集与监测。对于数据源较多的情况,建议把不同数据源的采集和解析放在独立任务中,避免单个数据源失败影响其他数据源的入库。
十七、进一步优化方向
前面实现的系统已经具备完整的数据采集、清洗、存储、监测和告警能力,可以满足中小规模的农产品行情监测需求。随着数据源增多、监测品种扩展和用户数量增长,还可以从以下几个方面继续优化。
17.1 增量采集与变化检测
对于数据量大、更新频繁的页面,如果每次都保存完整 HTML,会占用较多磁盘空间。可以考虑在请求时带上 If-Modified-Since 或 ETag 头,或者在保存前对页面内容计算哈希,只在内容发生变化时才保存新版本。这样既能保留历史版本,又能减少不必要的存储开销。
17.2 品种字典的自动扩充
人工维护品种映射表虽然准确,但随着数据源增多,维护成本会不断上升。可以引入相似度匹配和人工审核相结合的机制,对未能映射的品种名称,先通过编辑距离或向量相似度推荐候选标准名,再由人工批量确认。这样既保证准确率,又降低维护工作量。
17.3 价格预测与辅助决策
当历史数据积累到一定规模后,可以在监测基础上引入简单的预测模型,例如移动平均外推、季节性分解、线性回归或梯度提升模型,对后续价格区间做出预测。需要注意的是,农产品价格受天气、政策、物流等多种因素影响,单一模型很难持续准确,因此预测结果应作为辅助参考,而不是绝对判断。
17.4 多端展示与查询服务
当多个业务人员需要查看行情时,可以为系统增加一个 Web 查询界面,提供按品种、日期、区域、来源的筛选和图表展示。也可以把标准化数据导出为 CSV 或 Excel,供分析师在本地进一步处理。通过 API 提供价格查询能力,还便于与企业内部的其他业务系统集成。
十八、总结
本文从农产品公开数据的应用价值出发,围绕 OpenClaw 框架构建了一套完整的农产品价格与产销数据动态监测系统。文章首先分析了当前农产品数据的分散性、时效性和质量问题,然后从整体架构入手,逐步实现了数据源配置管理、请求采集与限速、HTML 解析、数据清洗与标准化、数据库存储、行情指标计算、动态告警、可视化展示、定时调度和异常恢复等核心环节。
这套方案的特点在于模块化与配置化。采集器、解析器、清洗器、存储层和监测器职责明确,新增数据源或更换品种时只需调整配置和少量解析代码,系统主体无需修改。原始 HTML 的保留和重跑机制,让页面改版引起的问题能够快速恢复。限速与合规设计的引入,则保证了系统可以长期稳定地运行。
农产品市场价格波动频繁,及时掌握价格与产销动态,对于种植规划、采购决策、库存管理和风险预警都有直接价值。通过 OpenClaw 把公开数据自动汇聚、持续更新、动态监测,能够显著降低人工整理成本,提高行情判断的时效性和准确性。随着数据积累和模型能力增强,这套系统还可以进一步扩展为价格预测、区域比价和供应链风险预警等更丰富的应用。
相关推荐
1001101_QIA1 小时前
从 CPU 到 GPU:高速缓存、指令并行与 CUDA 执行原理入门
java·后端·spring
API快乐传递者1 小时前
淘宝海外商品详情接口实战指南:从全球开放平台到跨境铺货的全链路方案
java·前端·数据库
邪修king1 小时前
Re: Linux系统篇(十八):进程篇(七): 进程深度解析:从 fork 创建到退出的完整旅程(附写时拷贝原理 + 代码实战)
java·linux·运维
m0_587383001 小时前
24小时自助健身系统源码实战:从架构设计到部署落地
java·架构·系统架构·需求分析
卓怡学长2 小时前
w156一周穿搭App的设计与实现
java·spring boot·spring·maven·intellij-idea
三8442 小时前
redis三大机制:配置、持久化、主从复制(getshell 原理地基)
开发语言·php
hope_wisdom2 小时前
C/C++数据结构之二叉树的遍历
c语言·数据结构·c++·二叉树·深度优先·广度优先
万年咸鱼2 小时前
Java PrintStream 详解:从基础用法到实战技巧
java·开发语言·python