Python实现网页状态监控:以FansToys新品预告为例
最近很多人应该都在关注 FansToys 的新品动态尤其是那条COMING SOON的 FT-63 TURBO 预告。对于收藏玩家来说新品从预告到开放预订、再到发售中间的状态变化非常关键但信息分散在官网、论坛、社交平台等多个渠道。与其每天手动刷新页面不如自己写一个轻量级的“新品状态监控”脚本让程序替我们盯住页面变化。本文就以 FansToys FT-63 TURBO 的预告信息为案例完整拆解一个基于 Python 的新品状态监控工具。你不需要很强的爬虫基础跟着文章把环境、代码、定时任务配好就能把“COMING SOON”变成自动通知。1. 背景与核心概念1.1 新品预告信息为什么难跟踪像 FansToys 这类第三方玩具品牌新品发布通常不会只在一个渠道公告。官网会有产品页社交媒体会发图第三方代理商店铺会提前挂出预订链接论坛里还会有玩家讨论帖。不同渠道的信息更新节奏不同状态描述也不统一。常见状态词大概有这么几类COMING SOON预告还没有开放预订PO/PRE-ORDER已经开放预订IN STOCK现货SOLD OUT售罄如果只关注一个型号比如 FT-63 TURBO手动刷新几天还能接受。但如果同时关注十几个型号还要对比不同渠道的价格和状态手工操作就容易遗漏关键节点。尤其是“开放预订”这个节点很多人因为没有第一时间收到消息错过了首发价。1.2 监控工具的通用模型这类状态监控本质上是一个“网页内容变化检测”问题。通用流程可以拆成五步抓取目标页面 HTML抽取页面文本内容匹配关键词判断当前状态与上次状态比对判断是否变化状态变化时发送通知对应到技术方案就是 Python 加四个基础组件requests抓取页面BeautifulSoup解析 HTML 文本SQLite持久化历史状态SMTP/ 消息推送发送通知这套方案的优点是轻量、好理解、便于扩展。不需要引入 Scrapy、Celery 这类重型框架单文件脚本就能跑起来。1.3 本文示例边界需要提前说明的是本文重点是“状态监控”的工程实现而不是对 FT-63 TURBO 这款产品本身的猜测和讨论。玩具的具体角色、尺寸、价格、发售日期请以 FansToys 官方渠道发布的信息为准。示例代码中的 URL 是占位符实际使用时替换成你要监控的页面地址即可。2. 环境准备与版本说明2.1 运行环境本文示例使用 Python 3.9操作系统可以是 Windows、Linux 或 macOS。代码中用到的库都是跨平台的不需要额外处理系统差异。主要依赖如下依赖用途requests发送 HTTP 请求抓取页面beautifulsoup4解析 HTMLlxmlBeautifulSoup 的 HTML 解析器schedule 或 cron定时执行本文使用系统 crontab 示例版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。建议在虚拟环境中安装依赖避免污染全局 Python 环境。2.2 安装依赖先创建一个项目目录mkdir ft63-tracker cd ft63-tracker创建虚拟环境并激活python -m venv venv source venv/bin/activate # Windows 下为 venv\Scripts\activate然后安装依赖pip install requests beautifulsoup4 lxml为了便于复现可以生成依赖清单pip freeeze requirements.txt2.3 项目目录结构项目文件组织如下ft63-tracker/ ├── config.py # 全局配置网址、关键词、通知参数 ├── db.py # SQLite 初始化与读写 ├── notify.py # 通知模块 ├── tracker.py # 主程序 ├── sample.html # 本地模拟页面用于测试解析逻辑 └── requirements.txt # 依赖清单如果是在正式项目中可以把配置和密钥放到环境变量或独立的配置中心。这里为了演示直观先把配置集中到config.py后面章节会单独讲如何安全地管理密钥。3. 核心实现原理3.1 页面抓取与请求头requests库发送 GET 请求很简单但直接裸请求容易碰到两个问题部分站点会拦截没有 User-Agent 的请求请求超时时间不设置程序可能长时间卡住所以抓取函数需要带 User-Agent 和超时时间同时做状态码检查。def fetch_page(url): headers {User-Agent: USER_AGENT} resp requests.get(url, headersheaders, timeoutREQUEST_TIMEOUT) resp.raise_for_status() if not resp.encoding or resp.encoding.lower() iso-8859-1: resp.encoding resp.apparent_encoding return resp.text这里有个细节resp.encoding如果识别错误中文页面很容易乱码。通过apparent_encoding从内容中推断编码能解决大部分乱码情况。3.2 HTML 文本抽取拿到 HTML 后不能直接做关键词匹配。HTML 里包含大量标签、脚本和样式比如pstrongFT-63 TURBO/strong is spanCOMING SOON/span!/p直接搜文本会匹配到标签属性也容易被内嵌脚本干扰。用 BeautifulSoup 把文本抽出来再匹配会更稳。def extract_text(html): soup BeautifulSoup(html, html.parser) for tag in soup([script, style, noscript]): tag.decompose() return .join(soup.stripped_strings)stripped_strings会去掉首尾空白拼接后的文本干净很多。实际项目中还可以根据页面结构缩小查找范围比如只解析main容器减少干扰。3.3 关键词与状态判定状态判定逻辑可以做成一个独立函数把文本转成大写然后按优先级检查关键词。def detect_status(text): upper_text text.upper() for keyword in KEYWORDS: if keyword.upper() in upper_text: return keyword.upper() return UNKNOWN关键词的顺序很关键。COMING SOON和PRE-ORDER如果同时出现在页面里说明商品已经进入了下一阶段应该优先匹配“更靠后”的状态。所以KEYWORDS列表要按状态演进顺序排列。3.4 状态持久化与去重如果每次都把状态写入数据库会产生大量无意义数据。更合理的做法是只保存“状态变化记录”和“最近一次确认时间”。SQLite 表结构如下CREATE TABLE IF NOT EXISTS item_status ( url TEXT PRIMARY KEY, keyword TEXT NOT NULL, status TEXT NOT NULL, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, update_count INTEGER DEFAULT 1 )url页面地址作为唯一标识keyword匹配到的关键词status归一化后的状态first_seen第一次发现该 URL 的时间last_seen最近一次检查的时间update_count状态变化次数状态变化时更新状态和时间戳状态没变化时只更新last_seen用于判断页面是否还可达。3.5 通知机制通知是整套工具真正“有用”的地方。最简单的通知方式是打印日志但这样还是需要人盯着终端。更实用的做法是接入邮件或消息推送。邮件方案可以在 Python 标准库smtplib基础上实现不依赖第三方平台适合自用。如果追求微信推送可以接入 Server酱原理也是发一个 HTTP 请求。代码里可以通过一个配置开关切换。4. 完整实战案例4.1 编写配置模块 config.py首先创建config.py把所有可调整的参数集中管理。import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) DATA_DIR os.path.join(BASE_DIR, data) DB_PATH os.path.join(DATA_DIR, tracker.db) # 替换成你要监控的实际页面地址 TARGET_URLS [ https://www.example.com/fanstoys/ft-63-turbo, ] # 状态关键词按状态演进顺序排列 KEYWORDS [COMING SOON, PRE-ORDER, AVAILABLE, SOLD OUT] # 品牌与型号关键字用于二次确认是否与目标产品相关 BRAND_KEYWORDS [FT-63, TURBO, FANS TOYS, FANSTOYS] 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 ) REQUEST_TIMEOUT 10 # 通知开关 NOTIFY_ENABLED False # 邮件通知参数 MAIL_HOST smtp.example.com MAIL_PORT 465 MAIL_USER MAIL_PASSWORD MAIL_TO BRAND_KEYWORDS的作用是防止误判。有些页面可能会把COMING SOON当通用文案放在网页底部如果不做二次确认就很容易误报。4.2 编写数据库模块 db.pydb.py负责数据库初始化和状态读写。import os import sqlite3 from datetime import datetime from config import DATA_DIR, DB_PATH def get_connection(): os.makedirs(DATA_DIR, exist_okTrue) return sqlite3.connect(DB_PATH) def init_db(): conn get_connection() conn.execute( CREATE TABLE IF NOT EXISTS item_status ( url TEXT PRIMARY KEY, keyword TEXT NOT NULL, status TEXT NOT NULL, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, update_count INTEGER DEFAULT 1 ) ) conn.commit() conn.close() def upsert_record(conn, url, status, keyword): now datetime.now().strftime(%Y-%m-%d %H:%M:%S) row conn.execute( SELECT status, update_count FROM item_status WHERE url ?, (url,), ).fetchone() if row is None: conn.execute( INSERT INTO item_status (url, keyword, status, first_seen, last_seen, update_count) VALUES (?, ?, ?, ?, ?, 1) , (url, keyword, status, now, now), ) conn.commit() return True, None old_status row[0] if old_status ! status: conn.execute( UPDATE item_status SET status ?, keyword ?, last_seen ?, update_count update_count 1 WHERE url ? , (status, keyword, now, url), ) conn.commit() return True, old_status conn.execute( UPDATE item_status SET last_seen ? WHERE url ?, (now, url), ) conn.commit() return False, old_statusupsert_record返回两个值第一个表示状态是否变化第二个是旧状态。主程序可以根据返回值决定是否发通知。4.3 编写通知模块 notify.py邮件通知模块如下import smtplib from email.mime.text import MIMEText from config import ( MAIL_HOST, MAIL_PASSWORD, MAIL_PORT, MAIL_TO, MAIL_USER, NOTIFY_ENABLED, ) def send_mail(subject, content): if not NOTIFY_ENABLED: return False if not (MAIL_USER and MAIL_PASSWORD and MAIL_TO): return False msg MIMEText(content, plain, utf-8) msg[Subject] subject msg[From] MAIL_USER msg[To] MAIL_TO try: with smtplib.SMTP_SSL(MAIL_HOST, MAIL_PORT) as server: server.login(MAIL_USER, MAIL_PASSWORD) server.sendmail(MAIL_USER, [MAIL_TO], msg.as_string()) return True except Exception as exc: print(f[notify] 邮件发送失败: {exc}) return False如果使用 QQ 邮箱或 163 邮箱MAIL_PASSWORD填的是授权码不是登录密码。这是新手最容易踩的坑。4.4 编写抓取与解析逻辑把抓取、文本抽取、状态判断整合到tracker.py中。为了便于测试将核心逻辑拆成独立函数。import argparse from datetime import datetime import requests from bs4 import BeautifulSoup from config import ( BRAND_KEYWORDS, KEYWORDS, REQUEST_TIMEOUT, TARGET_URLS, USER_AGENT, ) from db import get_connection, init_db, upsert_record from notify import send_mail def fetch_page(url): headers {User-Agent: USER_AGENT} resp requests.get(url, headersheaders, timeoutREQUEST_TIMEOUT) resp.raise_for_status() # 解决中文页面乱码问题 if not resp.encoding or resp.encoding.lower() iso-8859-1: resp.encoding resp.apparent_encoding return resp.text def extract_text(html): soup BeautifulSoup(html, html.parser) # 移除脚本和样式避免干扰文本匹配 for tag in soup([script, style, noscript]): tag.decompose() return .join(soup.stripped_strings) def detect_status(text): upper_text text.upper() for keyword in KEYWORDS: if keyword.upper() in upper_text: return keyword.upper() return UNKNOWN def is_brand_related(text): upper_text text.upper() return any(keyword in upper_text for keyword in BRAND_KEYWORDS)这里把detect_status和extract_text拆开方便后面写本地测试。4.5 编写主程序入口主程序逻辑就是前面说的五步流程。def process_url(conn, url): print(f[tracker] 开始检查: {url}) try: html fetch_page(url) text extract_text(html) except Exception as exc: print(f[tracker] 抓取失败: {exc}) return # 二次确认页面与目标产品相关 if not is_brand_related(text): print([tracker] 页面未匹配到品牌或型号关键字跳过) return status detect_status(text) changed, old_status upsert_record(conn, url, status, status) print(f[tracker] 当前状态: {status}) if changed: subject fFT-63 TURBO 状态变化: {old_status or 新记录} - {status} content ( f检测到 {url} 的状态变为 {status}\n f时间: {datetime.now()} ) send_mail(subject, content) print(f[tracker] 状态变化已发送通知: {old_status or 新记录} - {status}) else: print([tracker] 状态未变化) def main(): parser argparse.ArgumentParser(descriptionFT-63 TURBO 新品状态监控) parser.add_argument(--once, actionstore_true, help只执行一次) args parser.parse_args() init_db() conn get_connection() for url in TARGET_URLS: process_url(conn, url) conn.close() if args.once: return if __name__ __main__: main()整个程序的核心思想是“无状态检查”每次执行都重新抓取、重新判定然后和数据库中的历史状态比对。这样即使上次运行崩溃了下一次运行也能从数据库恢复状态不会漏报。4.6 本地模拟测试直接运行脚本去抓真实网站可能因为网络不稳定或页面结构问题导致排查困难。建议先用本地 HTML 文件验证解析逻辑。创建sample.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleFansToys FT-63 TURBO Pre-Order/title style .price { color: red; } /style /head body h1FansToys FT-63 TURBO/h1 pNew item is strongCOMING SOON/strong!/p pPlease stay tuned./p /body /html然后在 Python 交互环境里测试from tracker import extract_text, detect_status, is_brand_related html open(sample.html, encodingutf-8).read() text extract_text(html) print(text) print(is_brand_related(text)) print(detect_status(text))预期输出类似FansToys FT-63 TURBO Pre-Order New item is COMING SOON! Please stay tuned. True COMING SOON这里有个常见问题title里面出现了Pre-Order正文里是COMING SOON。因为KEYWORDS列表里COMING SOON排在前面程序会输出COMING SOON。如果实际页面里已经有明确的开放预订按钮关键字顺序应该调整或者只匹配页面指定区域。4.7 配置定时任务脚本本身支持手动执行python tracker.py --once要让它定时运行Linux 环境可以用crontabcrontab -e加入一行每 30 分钟执行一次*/30 * * * * cd /path/to/ft63-tracker /path/to/venv/bin/python tracker.py --once logs/tracker.log 21Windows 环境下可以使用“任务计划程序”创建基本任务时选择“每天”并在“操作”中设置程序为python.exe参数为脚本路径。注意 Python 解释器建议使用绝对路径避免环境变量问题。4.8 运行结果解读第一次运行时数据库没有记录程序会输出[tracker] 开始检查: https://www.example.com/fanstoys/ft-63-turbo [tracker] 当前状态: COMING SOON [tracker] 状态变化已发送通知: 新记录 - COMING SOON之后只要页面还是COMING SOON输出会变成[tracker] 当前状态: COMING SOON [tracker] 状态未变化当页面更新为“开放预订”时输出变为[tracker] 状态变化已发送通知: COMING SOON - PRE-ORDER查看数据库历史记录sqlite3 data/tracker.db SELECT * FROM item_status;可以看到update_count字段累计了状态变化次数last_seen记录了最近一次检查时间。5. 常见问题与排查思路问题现象常见原因解决思路页面返回 403请求被站点反爬拦截配置浏览器 User-Agent降低请求频率不要短时间大量抓取中文内容乱码页面编码识别错误检查resp.encoding使用apparent_encoding推断一直处于UNKNOWN关键词不匹配或页面结构变化打印抽取后的文本内容人工确认实际文案邮件通知收不到SMTP 授权码错误或端口不对检查授权码确认 SSL 端口查看发送日志数据库文件被锁多进程同时写入 SQLite使用单进程定时任务或开启 WAL 模式最容易忽视的问题是“页面结构变化”。第三方店铺的页面改版后关键词可能还在但文本被拆分到了不同节点。比如原来是COMING SOON改版后变成了COMING/spanspanSOON直接字符串匹配可能就失效了。这时可以把文本中的空白符统一替换为空格再匹配。import re text re.sub(r\s, , text)如果页面结构经常变化建议把抽取到的文本快照保存到日志或数据库方便定位问题。6. 最佳实践与工程建议6.1 控制抓取频率遵守站点规则监控工具的本质是轻量轮询不是爬虫系统。对目标站点要保持礼貌单个站点的抓取间隔不要低于 10 分钟先确认robots.txt允许抓取只抓取公开页面不登录、不绕过验证码如果页面提供 RSS 或官方通知接口优先使用脚本里可以通过time.sleep()控制请求间隔也可以更进一步把不同站点的抓取间隔配置化。6.2 配置与密钥管理config.py里的邮件密码、推送 Key 都是敏感信息。如果项目要提交到 Git建议使用.gitignore忽略config.py或使用config.local.py密钥通过环境变量注入示例配置只保留占位符一个简单做法是创建config.example.py提交到仓库真实config.py留在本机。6.3 用状态机管理关键词前面的KEYWORDS列表本质上是状态机。UNKNOWN - COMING SOON - PRE-ORDER - AVAILABLE - SOLD OUT用列表顺序表达状态优先级代码简单适合这种轻量场景。如果状态变多、逻辑变复杂建议引入枚举类把状态迁移规则单独抽出来减少硬编码。6.4 日志与异常处理定时任务最怕“静默失败”。程序异常如果不打印或记日志你可能根本不知道监控已经停了。推荐做法控制台输出统一格式方便调试定时任务把输出重定向到日志文件抓取异常单独记录连续失败多次可以告警logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, )6.5 安全与合规边界本文的所有代码都是基于“公开页面信息”做的状态检测。实际操作中需要注意不抓取需要登录后才能看到的非公开信息不通过技术手段绕过访问控制不加高并发压力测试目标站点抓取的数据仅用于个人学习或合法用途不批量搬运、不用于商业化牟利如果对某类数据的抓取行为是否合规不确定先联系站点运营方确认或者直接放弃该数据源。6.6 通知去重与升级状态监控最怕“重复轰炸”。当前代码在状态没变化时不会发通知只有在状态变化时发一次已经实现了基本的去重。但如果页面在某段时间内反复横跳比如一会儿COMING SOON一会儿PRE-ORDER数据库会记录多次变化通知也会多次发送。可以在notify.py里加上“冷静期”比如同一 URL 1 小时内最多发送一次通知。7. 总结与后续扩展本文以 FansToys FT-63 TURBO 的预告信息为切入点实现了一个完整的新品状态监控工具。核心流程是抓取页面、抽取文本、匹配关键词、持久化状态、变化时通知这套思路不仅适用于玩具新品也适用于任何需要监控网页状态变化的场景。如果你只是关注一个型号可以先把TARGET_URLS和BRAND_KEYWORDS改成你的目标页面跑几天看看效果。再往后可以尝试几个扩展方向把 SQLite 换成 PostgreSQL支持多用户协同增加一个简单的 Web 展示页面用图表展示状态变化历史接入企业微信或钉钉机器人把通知发到团队群用 Docker 打包部署放到服务器上稳定运行把关键词匹配改成更智能的语义判断减少误报动手改一版属于你自己的监控工具比单纯收藏这篇文章更有价值。如果运行过程中遇到什么问题欢迎对照常见问题清单排查也建议把页面抽取的文本先打印出来看看很多问题都会一目了然。