最近很多人应该都在关注 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 | 解析 HTML |
| lxml | BeautifulSoup 的 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, headers=headers, timeout=REQUEST_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 里包含大量标签、脚本和样式,比如:
<p><strong>FT-63 TURBO</strong> is <span>COMING 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.py
db.py负责数据库初始化和状态读写。
import os import sqlite3 from datetime import datetime from config import DATA_DIR, DB_PATH def get_connection(): os.makedirs(DATA_DIR, exist_ok=True) 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, headers=headers, timeout=REQUEST_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 = f"FT-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(description="FT-63 TURBO 新品状态监控") parser.add_argument("--once", action="store_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 lang="zh-CN"> <head> <meta charset="UTF-8"> <title>FansToys FT-63 TURBO Pre-Order</title> <style> .price { color: red; } </style> </head> <body> <h1>FansToys FT-63 TURBO</h1> <p>New item is <strong>COMING SOON</strong>!</p> <p>Please stay tuned.</p> </body> </html>然后在 Python 交互环境里测试:
from tracker import extract_text, detect_status, is_brand_related html = open("sample.html", encoding="utf-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 环境可以用crontab:
crontab -e加入一行,每 30 分钟执行一次:
*/30 * * * * cd /path/to/ft63-tracker && /path/to/venv/bin/python tracker.py --once >> logs/tracker.log 2>&1Windows 环境下,可以使用“任务计划程序”,创建基本任务时选择“每天”,并在“操作”中设置程序为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</span><span>SOON,直接字符串匹配可能就失效了。这时可以把文本中的空白符统一替换为空格再匹配。
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( level=logging.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 打包部署,放到服务器上稳定运行
- 把关键词匹配改成更智能的语义判断,减少误报
动手改一版属于你自己的监控工具,比单纯收藏这篇文章更有价值。如果运行过程中遇到什么问题,欢迎对照常见问题清单排查,也建议把页面抽取的文本先打印出来看看,很多问题都会一目了然。