去年冬天我朋友圈里有人晒了一晚三百块的五星级酒店位置还在市中心。我第一反应是“手快”直到他自己说漏了嘴——那是 OTA 平台某个深夜放出的闪购价存活时间不到十分钟抢到的都是盯着屏幕的人。说实话那种“神价”我也想要但让我每天抱着手机反复刷新我坚持不了三天。后来我换了个思路把这件事交给 Python。我花了两个周末从零搭了一套 OTA 自动化监控系统定时去抓在线旅游平台的价格数据算折扣率一旦发现低于阈值比如 1 折的神价立刻把酒店名、日期、原价、到手价一起推到手机上。这篇文章就是把整套架构完整拆给你看怎么定位 OTA 的价格接口、怎么解析和判价、怎么通知、怎么部署成 7x24 小时常驻服务以及我踩过的坑。先说清楚这里的 OTA 是 Online Travel Agency也就是在线旅游平台不是 Over-The-Air 的远程升级。这套方案不是黑客技巧也不需要复杂的逆向工程只要你有 Python 基础、会打开浏览器开发者工具就能跟着复现。适合谁适合那些经常盯酒店价格、对“异常低价”有需求但又不想人肉刷屏的人也适合刚学爬虫、想找一个完整监控项目练手的朋友。1. 为什么盯 OTA 神价必须上程序我手动刷了一个月后的三个认知先说结论人肉盯屏这件事本质上就是和程序抢时间而且大概率抢不过。我手动刷了一个月之后总结出三个死穴每一个都戳中我放弃手动监控的理由。1.1 死穴一神价的“存活时间”比刷新周期还短OTA 平台的限时闪购、夜间特价、会员专属价往往不是全天存在的。我见过最夸张的一次某个海边度假酒店在晚上十一点左右放出一波尾房价格从 1200 直接掉到 180五分钟内就被扫空。你手动刷 App 的节奏是什么白天上班两小时看一次晚上睡前再刷一遍。就算你敬业到每十分钟刷一次五分钟存活的低价你也大概率看不到。程序就不一样它可以三分钟跑一轮覆盖全天 24 小时哪怕凌晨四点的价格跳水也能捕捉到。时效性这个维度程序是碾压级优势。1.2 死穴二多平台多酒店的人工覆盖是伪命题我的诉求不只是盯一家平台、一家酒店。我想比较几个主流 OTA 的价格同时关注几个常去城市的酒店。人工操作就变成先打开 A 平台搜酒店再切到 B 平台比价然后回到 A 平台看有没有新券最后打开备忘录记价格……这个流程重复十次人会疯。程序的好处是它可以把这个流程拆解成任务列表。每个任务包含平台标识、城市、入住日期、酒店范围然后排队执行。我可以一次配置几十个任务让脚本循环跑不再需要人肉切换 App 和记笔记。1.3 需求拆解监控对象、价格锚点与触发条件动手写代码之前建议先把自己的需求拆清楚不然很容易做成一个“什么都采集、什么都解析、最后什么都不敢推”的大杂烩。我的原始需求很简单维度我的定义实现方式监控对象指定城市酒店列表里的目标房型接口参数控制 cityId / checkIn / checkOut价格锚点门市价 listPrice叠加历史 30 天均价首次抓取入库后续增量更新触发条件到手价 ÷ 锚点价 ≤ 0.1判价模块计算折扣率通知方式手机推送需要在 30 秒内看到Server酱、钉钉机器人、邮件拆完之后你会发现这个系统本质上就是一个“低价比对器”采集价格、计算折扣、阈值触发、通知用户。难点不在“抓数据”这个动作而在于怎么稳定地采集、准确地判价、不误报不漏报。后面四个章节就是围绕这四个环节展开的。2. 整体架构设计一条从“轮询”到“手机响铃”的流水线很多人一上来就写爬虫写完了发现没法长期跑要么平台改了字段要么进程崩了没人管要么通知重复轰炸。我建议先把架构想清楚再写代码。2.1 六层管线调度层、采集层、解析层、判价层、通知层、存储层我的系统分六层每一层只干一件事调度层决定多久跑一轮每轮跑哪些任务。我用 APScheduler 的 BlockingScheduler 做定时调度不用 cron 是因为 Python 进程内可以带运行状态方便传递上下文。采集层负责发 HTTP 请求拿到 OTA 接口返回的 JSON 或页面 HTML。注意这里的核心是“模拟真人请求”后面专门讲。解析层从原始响应里抽取出结构化数据统一成一条记录酒店 ID、名称、房型、入住日期、门市价、到手价、币种、抓取时间。判价层计算折扣率和历史锚点比对决定这条记录是否值得通知。通知层把值得通知的记录通过 Server酱、钉钉机器人、邮件推出去。存储层把所有抓取结果落库。个人项目用 SQLite 足够没必要上 MySQL 或者 Redis只有当你并发任务特别多、想去重历史通知时才考虑 Redis。流水线的逻辑是单向的采集之后才能解析解析之后才能判价判价命中之后才通知。每层之间用函数接口传递不要把所有逻辑写在同一个大函数里否则后面任何一个环节出问题你都很难定位。2.2 核心技术选型为什么是 Python SQLite APScheduler选型这块说下我的理由Python 不用多解释HTTP 请求有 requests / httpx解析有 json / BeautifulSoup生态成熟写起来快。SQLite 是因为这个项目的数据量真的很小——假设每天抓 500 条价格记录一个月也就 1.5 万条SQLite 单文件就能轻松扛住而且备份就是拷贝一个文件特别适合个人项目。APScheduler 选它的原因是可以精确控制“间隔时间”。我用 interval 模式每三分钟跑一轮任务它还支持线程池后续想并行采集多个平台把 max_instances 调大就行。用原生 Python 加三个库整个项目的依赖非常轻。你看那些重型框架什么消息队列、容器编排对这个场景来说都是过度设计。2.3 最小可运行骨架代码下面是整个系统最核心的调度骨架我把它压到最小方便你理解全局结构。真实项目里每个模块会再拆文件。import logging from apscheduler.schedulers.blocking import BlockingScheduler from collector import Collector from parser import PriceParser from judge import DiscountJudge from notifier import Notifier from storage import SQLiteStorage logging.basicConfig(levellogging.INFO, format%(asctime)s %(levelname)s %(message)s) def monitor_one_round(): storage SQLiteStorage(ota_price.db) judge DiscountJudge(threshold0.1, storagestorage) notifier Notifier() collector Collector() parser PriceParser() tasks collector.build_tasks( cities[上海, 杭州], dates[2025-06-01, 2025-06-02], ) for task in tasks: try: raw collector.fetch(task) items parser.parse(raw, task) for item in items: if judge.should_alert(item): notifier.send(item) except Exception as exc: logging.exception(task failed: %s, task) if __name__ __main__: scheduler BlockingScheduler(timezoneAsia/Shanghai) scheduler.add_job(monitor_one_round, interval, minutes3, idota_monitor) scheduler.start()这个骨架解决的是“整体流向”问题。你先跑通这个流程再回头完善每一层的细节。3. 采集层实战先搞清楚 OTA 的“低价数据”藏在哪个接口采集层是整个系统里最需要耐心的一环因为“数据藏在哪”不是靠猜的而是要去看浏览器实际发了什么请求。3.1 用浏览器开发者工具定位价格接口我的方法很简单打开 Chrome 的无痕窗口按 F12 进入开发者工具切到 Network 面板把筛选条件切到 XHR 或者 Fetch/XHR然后在页面上正常操作选城市、选日期、选价格排序。操作一个动作观察一个请求。重点看在响应里能搜到价格数字的那个请求。方法是在 Network 面板里按 CtrlF输入你当前肉眼看到的某个价格的数字比如你在页面上看到某酒店显示 380 元就在请求响应里搜“380”。如果命中了这个请求就是我们要的接口。找到之后把这个请求复制成 cURL导入到 Postman 或者直接放到 Python 里转成 requests 代码。接下来要做的是参数化哪些是城市 ID、入住日期、离店日期、页码、排序方式。把这些参数抽出来就能变成一个可循环的采集任务。有一种情况要注意有些 OTA 页面是服务端渲染返回的是完整 HTML不是 JSON。这时候你可以搜“price”关键字或者找 HTML 里的 script 标签中叫__NEXT_DATA__或window.__INITIAL_STATE__的全局变量。这类数据结构通常比 XHR 接口更稳定反而好解析。3.2 请求伪装与限速像真人一样访问定位好接口后下一步是让请求看起来像真人发的。我的请求头通常至少包含这几个字段headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) , Referer: https://hotel.example.com/, Accept-Language: zh-CN,zh;q0.9, Accept: application/json, text/plain, */*, }这里面 Referer 特别容易被忽略但很多平台接口会校验它。缺失 Referer 或者 Referer 和接口域名不匹配可能直接被风控拦掉。限速也很重要。我每个任务发完请求后至少随机 sleep 0.5 到 1.5 秒。这个延迟不是浪费时间而是保护你的 IP。单线程状态下三分钟一轮、每轮十几个任务这个频率对平台来说基本无感。我最开始曾一分钟抓 60 次结果不到半天就撞上了滑块验证码那才是真的浪费时间。3.3 接口参数与签名遇到加密参数时的取舍现在很多 OTA 接口会在 URL 拼接一个 sign 或 token 参数看起来像 MD5 或者更复杂的签名算法。我的建议是不要一上来就逆向签名算法那个投入产出比太低。优先找降级方案很多人会直接调 App 的接口实际上网页端和 H5 的接口往往更老旧、加密更少或者去看看“列表页”和“详情页”哪个能拿到价格有些平台详情页接口不加密只是数据粒度更细。还可以检查一下有没有不需要签名的城市列表接口用来间接换取酒店信息。如果你确定这个平台所有关键接口都有强校验那我的建议是换个思路改走页面 HTML 解析虽然慢一点但至少可用。逆向 JavaScript 签名是一个无底洞尤其是当你只想做一个个人监控脚本时耐心应该花在更能产生价值的地方比如判价逻辑和稳定性。4. 判价模块从原始报文到“1折神价”的折扣计算逻辑采集到数据只是第一步真正决定系统有没有价值的是判价模块。如果这里不严谨你会遇到两种情况好几天一条不推或者一天推几十条假低价。4.1 价格字段拆解listPrice 和 actualPrice 的猫腻OTA 接口里常见的价格字段大概是这几个名字listPrice、actualPrice、originalPrice、salePrice、promoPrice不同平台叫法不一样。大部分情况下listPrice是门市价划线价actualPrice是你最终要付的钱。但这里有一个坑有的平台返回的商品是“不含税价”要等下单那一步才会告诉你税费多少早餐、取消政策也是影响实际成本的因素。所以我在解析层做了一件事把接口里所有“看起来像价格”的字段全部列出来先用人工比对确定哪个对应页面上的实际显示再写死成映射关系。在代码里推荐用容错式解析。因为接口字段改名字是家常便饭你要做的是兜底和告警def parse_price_item(raw: dict) - dict | None: list_price raw.get(listPrice) or raw.get(originalPrice) actual_price raw.get(actualPrice) or raw.get(salePrice) or raw.get(promoPrice) if not list_price or not actual_price: # 字段缺失时留痕方便排查接口改动 logging.warning(price field missing, raw keys: %s, list(raw.keys())) return None if actual_price 0: return None return { hotel_id: raw.get(hotelId), hotel_name: raw.get(hotelName), room_type: raw.get(roomTypeName), check_in: raw.get(checkIn), list_price: float(list_price), actual_price: float(actual_price), }日志里的raw.keys()很重要。当平台改了字段你会第一时间在日志里看到异常而不是判价模块静默失效。4.2 折扣率计算与历史价格锚点拿到门市价和到手价之后折扣率就是actual_price / list_price这没错。但问题在于很多平台的划线价是虚高的。比如酒店平常卖 400门市价标 888忽然搞个 288 的促销算下来折扣率只有 0.32你会觉得“还行”但不是什么神价。真正可怕的是那种平时卖 400、今天卖 139门市价 1288折扣率 0.108这种才是异常低价。所以我在判价时引入了一个历史价格锚点def should_alert(item, history_median_price: float | None) - bool: if not history_median_price: return False discount item[actual_price] / item[list_price] if discount THRESHOLD: return False # 如果到手价比历史中位数还高说明是门市价太低导致的“假折扣” if item[actual_price] history_median_price * 1.5: logging.info(above history anchor, skip: %s, item[hotel_name]) return False return True历史锚点的计算我推荐用过去 30 天内同一酒店同一房型的到手价中位数而不是平均数。中位数抗干扰能力强不会因为某天异常高价把平均值拉上天。4.3 阈值判断与误报过滤阈值设多少要看你的目标。我把“神价”定义为折扣率小于等于 0.1也就是一折及以下。但敏感情况是OTA 有时会放出一两个“测试价”或“错误价”比如 0.01 元、0.1 元这类数据不是真实可下单的推了只会狼来了。我做了两道过滤第一价格必须大于 0.05 倍的历史中位数太离谱就丢第二同一任务连续两轮抓取都命中阈值才真正推送。第二道过滤的代价是延迟一轮大约三分钟但换来的是少了很多空欢喜。4.4 判价模块完整流程我把判价流程串起来给你看。它的输入是解析层吐出的结构化价格记录输出是一个“是否推送”的布尔值。核心逻辑就是先算实时折扣率再用历史锚点过滤最后检查连续命中状态。一条真正值得推送的神价在通知里应该长这样酒店名、房型、入住日期、门市价、到手价、折扣率、平台跳转链接。只要这七个信息齐全用户就能在手机上快速判断要不要点进去。5. 通知层让低价信息第一时间出现在手机上的几种姿势判价模块决定“要不要推”通知层决定“怎么推”。我试过几种方案最后是 Server酱和钉钉机器人混合用。5.1 通知渠道横向对比我没有用短信因为短信成本高、延迟也没优势。更适合个人监控的是这些渠道接入难度时效性适合场景Server酱极低一个 HTTP POST秒级个人微信提醒适合单人使用钉钉机器人低配置 webhook 和加签秒级微信群/团队共享监控结果企业微信机器人低秒级企业内部通知SMTP 邮件中可能延迟几十秒到几分钟兜底记录适合归档我的选择是重要神价折扣率小于 0.05同时发 Server酱和钉钉机器人一般低价只发 Server酱每一条推送都会同步发一封邮件作为归档。这样即使某一个渠道临时挂了还有另一个能顶上。5.2 Server酱与钉钉机器人的接入代码Server酱的接入非常简单。它本质上是往一个 URL 上发 POST把 title 和 desp 作为表单参数传过去你的微信就会收到服务号消息。代码大概是import requests SEND_KEY 你的 SendKey def send_serverchan(title: str, description: str): url fhttps://sctapi.ftqq.com/{SEND_KEY}.send resp requests.post(url, data{title: title, desp: description}, timeout10) return resp.json()钉钉机器人稍微复杂一点因为要加签。流程是拿 secret 和时间戳拼字符串做 HMAC-SHA256 签名拼到 webhook URL 后面import time import hmac import hashlib import base64 import urllib.parse secret SEC你的加签密钥 access_token 你的access_token timestamp str(round(time.time() * 1000)) string_to_sign f{timestamp}\n{secret}.encode() hmac_code hmac.new(secret.encode(), string_to_sign, digestmodhashlib.sha256).digest() sign urllib.parse.quote_plus(base64.b64encode(hmac_code)) webhook_url ( https://oapi.dingtalk.com/robot/send f?access_token{access_token}timestamp{timestamp}sign{sign} ) payload { msgtype: markdown, markdown: { title: OTA神价命中, text: ### OTA 神价命中\n\n- 酒店某某度假酒店\n- 房型豪华大床房\n- 日期2025-06-01\n- 原价1288\n- 到手价139\n- 折扣约 1.1 折, }, } requests.post(webhook_url, jsonpayload, timeout10)这里有一个细节钉钉的加签时间戳必须和请求时间基本一致误差超过一个小时会被拒绝所以每次发送前都要重新计算不要把签名结果缓存起来。5.3 通知去重同一低价只推一次如果不做去重你会被同一个酒店的通知反复轰炸。一个简单的方案是在 SQLite 里建一张sent_log表字段包括 hotel_id、check_in、actual_price、first_sent_at。推送前查一下如果这条记录在 30 分钟内已经出现过相同价格就直接跳过。我也踩过“价格反复横跳”的坑某平台接口在晚间会把价格从 139 改回 1288再改回 139如果每次变化都通知你会疯掉。去重条件我建议至少包含hotel_id check_in actual_price三个字段而不是只看酒店名因为同一个酒店不同的入住日期价格差别很大。6. 部署和稳定性从本机脚本变成 7x24 小时常驻服务写好判价和通知接下来是让它稳定跑起来。这一章是我觉得最“反常识”的部分很多人以为写完采集和解析就大功告成实际上部署和稳定性才是整个项目里最花时间的。6.1 部署形态选择本机、云服务器还是 NAS先说我试过的三种方式本机直接跑python main.py最方便适合白天调试算法。缺点是电脑一睡眠、断网或者你带着笔记本去开会监控就停了。如果你只是短期蹲一个活动本机没问题。轻量云服务器成本几十块一个月2 核 2G 内存跑这个脚本绰绰有余。这是我最推荐的方案因为它在公网环境下比较接近真实运行场景网络环境也更稳定。家里的 NAS 或软路由如果你已经有一台常年开机的设备直接部署成 Docker 容器。这样依赖隔离、升级方便。不管选哪种核心目标只有一句话这进程必须常年活着不能依赖某个人的电脑睡眠习惯。6.2 用 supervisor 托底进程我用 supervisor 做进程守护配置很简单。安装好 supervisor 后写一个配置文件[program:ota_monitor] commandpython main.py directory/home/ubuntu/ota_monitor autostarttrue autorestarttrue redirect_stderrtrue stdout_logfile/home/ubuntu/ota_monitor/logs/monitor.logautorestarttrue的意思是只要进程非正常退出supervisor 会自动把它拉起来。这样处理得很彻底哪怕 Python 抛了异常把进程带崩几秒后它又会恢复运行。如果你偏好容器化Dockerfile 也不复杂。关键是设置restart: unless-stopped让 Docker 在进程退出时自动重启容器。两种方案选一个就行不要两个都上增加运维复杂度。6.3 日志与异常恢复日志是排查问题的眼睛。我用 Python 标准库 logging加了按天滚动的 FileHandler保留最近 7 天。格式统一存时间、级别、模块名、消息内容。每次程序启动时我会在日志里打一行“monitor started”这样你能确认它到底有没有被 supervisor 正常拉起来。异常恢复分两层底层请求异常由采集层捕获并重试上面调度层的任务循环也要 catch 住所有异常避免一个任务失败导致整个进程退出。我见过太多人写爬虫某次网络抖动直接抛出TimeoutException进程就没了。异常处理不是可有可无是产品级稳定性的基本盘。6.4 踩坑录字段漂移、时区、封禁、误报这四类坑我全都踩过逐个说第一接口字段漂移。某天晚上平台把actualPrice改成了payPrice我的解析层拿不到数据判价模块静默返回整整两天没推一条消息。排查方式就是看日志里的raw.keys()告警。修复也很简单容错解析里多写一个 or 分支。第二时区错乱。OTA 页面显示的入住日期一般是本地时间而服务器返回的 JSON 里时间戳可能是 UTC。我一开始没注意结果把“明天”的日期存成了“今天”通知里看起来就是入住日期差了一天。对策是所有日期字段在解析层统一转成Asia/Shanghai时区的YYYY-MM-DD格式再入库。第三IP 封禁。刚开始我开了一个高并发脚本每秒钟发好几次请求结果撞上了滑块验证码。对策就是限速加指数退避一旦检测到验证码响应任务暂停 5 到 30 分钟让风控冷却。记住一条原则个人监控不是抢票不需要多高的并发宁可慢也不要被封。第四误报。某平台的“满减券”状态没固定导致第一次抓到的实际价格极低第二次抓就恢复正常。我的对策就是前面提到的“连续两轮命中才推送”牺牲三分钟延迟换取通知可信度。7. 合规边界与风险意识自动化监控的分寸感写到这里我要认真聊一下“薅羊毛”这件事的边界。虽然标题是这么起的但我不想教你做黑产或者黄牛也不想让你把这个系统用在破坏平台秩序的地方。7.1 技术是工具用途决定性质你用 Python 去监控公开页面的价格信息本质上是把“人肉看价格”这个动作自动化了。这个动作本身属于个人效率工具的范畴但你需要注意几点数据来源只采集公开页面、公开接口的数据不要破解登录态、绕过付费墙、攻击漏洞。请求压力控制频率给平台服务器留足余量。你个人的监控需求不应该对平台造成可见的流量压力。数据用途采集到的数据仅限个人做消费决策不要二次分发、打包售卖、做商业比价站点。交易环节只做“提醒”不要做全自动下单。OTA 下单涉及支付、实名、风控、退款规则自动化下单不仅容易产生法律纠纷还会影响真正有需要的用户。“薅羊毛”的正确姿势是用工具提升信息获取效率帮你在合法合规的前提下第一时间发现公开的优惠信息然后自己去 App 里完成下单。而不是写脚本去抢限量库存、囤房倒卖、刷单占资源。7.2 实操中的自检清单我在自己的项目里列了一个自检清单每次改完代码上线前都会过一遍[ ] 是否遵守目标平台的 robots.txt 和用户协议[ ] 请求频率是否低于人工操作频率单任务是否做了随机延迟[ ] 数据是否只保存在本地数据库未对外公开[ ] 是否没有绕过登录、验证码、签名等访问控制机制[ ] 通知里是否为每条预警附上官方跳转链接保留人工决策[ ] 是否设置了请求异常自动降频和停止机制把这六条过了你的项目大概率就是一个良性工具。如果哪一条你做不到我建议你停下来想一想这个项目到底是为了方便自己还是已经滑向灰色地带。最后分享一个我实际操作中的感受这套监控系统连续跑了几个月真正命中一折神价的次数并不多但它彻底改变了我蹲价格的节奏——我不再需要一遍遍刷手机每天起床看一遍推送记录就够了。所有代码加起来不到一千行其中一多半是容错和日志。如果你想复现建议先从一个城市、一个平台、一个日期范围开始跑通之后再加复杂度。给每个请求加上随机延迟让它慢一点、稳一点你会发现稳定比速度值钱得多。