做跨境电商运营的人,应该都经历过这种场景:盯了二十个核心关键词,每天最关心的就是竞品在搜索结果里排第几,自己的产品是涨了还是跌了。手动去搜、去数、去记表格,一天两次还能忍,坚持一个星期绝对崩溃。我就是被这种重复劳动逼到没办法,才写了这套内部代号为 1949AI 的轻量化竞品排名监控系统。
它做的事情很朴素,就四件:按关键词批量采集竞品在搜索结果中的排名,把每次结果落库保存,对比上一次快照找出明显波动,最后用邮件把变化推给需要看的人。整套东西跑在一台普通云主机上,不依赖重型组件,代码量也不大,胜在稳定和够用。说白了,这就是一个让你每天花三分钟看邮件,就能掌握竞品动态的小工具。
这套方案适合谁?一类是跨境电商运营和选品团队,需要持续用数据盯竞品,但又不值得为此订阅一年几万块的商业排名监控服务;另一类是喜欢自己动手的个人卖家,想用最低成本验证自己的选品假设。只要你会写 Python 脚本,哪怕没做过系统设计,照着这篇文章的模块拆解,也能搭出一套能用的版本。
接下来我把这个项目的完整设计思路、核心代码、踩坑记录都列出来。关于项目代号里的 AI 部分,我也会单独讲清楚它在整个链路里的位置,以及为什么我的建议是"AI 先不要急着上"。
1. 项目背景:为什么我非要自己写一套监控
1.1 手动监控竞品排名有多痛
先算一笔账。假设你维护 10 个核心关键词,每个关键词盯 10 个竞品 ASIN,一共就是 100 个监控对象。每天早中晚手动查三次,每次要翻搜索结果页、定位竞品位置、把排名填进表格,一次至少半小时,一天就是一个多小时。遇到页面刷新慢、广告位和自然位交错的情况,统计误差还特别大,回头分析趋势时数据根本不敢信。
更麻烦的是,手工记录只能看到"今天排第几",很难回答"这个竞品是什么时候开始涨的""那次排名变化和它的促销活动有没有关系"。这些分析需要连续、稳定的历史数据,靠人肉记录几乎不可能。我需要的其实不是一张表格,而是时间序列——而时间序列恰恰是机器最擅长生成的东西。
所以这个项目的出发点非常明确:把人工的重复劳动换成自动化脚本,把每次采集结果沉淀下来,形成可查询的趋势数据。这不是炫技,纯粹是运营效率问题。
1.2 项目代号 1949AI 是怎么来的
内部项目编号正好轮到 1949 这个号,AI 是 Automation Intelligence 的缩写,意思是自动化带来的决策智能。我当时跟团队说得很直白:这个项目先不谈人工智能,先把自动化做扎实,让数据每天按时、按格式、按人该看的方式送到眼前,这才是真正的 AI 落地。
实际设计里,我确实留了一个可选的 AI 增强模块:当检测出排名变化后,可以把变化清单交给大模型接口,生成一段自然语言摘要,附在邮件顶部。但默认是关闭的,因为这一步骤会对第三方 API 产生依赖,和"轻量化"原则冲突。第三部分我会专门讲这个开关的设计思路。
1.3 系统上线后实际效果
项目跑起来之后,最直观的变化是我每天不用再打开平台搜索页了。早上到公司打开邮箱,就能看到昨晚到今晨的竞品排名变动列表,哪些关键词下竞品冲进了前三,哪些竞品跌出了首页,一目了然。上线两周后,我通过记录发现某个竞品在核心关键词上连续三天稳步上升,进一步追踪发现它换了主图并上了秒杀。顺着这条线索,我们调整了广告出价策略,守住了自己的位置。
这个案例说明一个道理:排名数据本身不产生价值,连续、可对比的排名变化才能产生价值。而自动化要做的就是把"连续"这件事稳定下来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计:轻量化不是少写代码,是少养系统
2.1 核心功能拆解成四个模块
我在设计第一版时给自己定了一条原则:每个模块只做一件事,可以单独测试、单独替换。整套系统最后被拆成了四块——采集、存储、检测、通知。
- 采集模块负责输入关键词和竞品 ASIN,输出当前排名结果
- 存储模块负责把每次采集结果写入 SQLite,并提供快照查询能力
- 检测模块负责对比当前结果和上次快照,找出排名变化超过阈值的记录
- 通知模块负责把变化记录渲染成 HTML 邮件,推给收件人
这四块是串行执行的,没有任何消息中间件,也没有相互之间的 RPC 调用。任何一个环节出问题,日志里一眼就能定位到具体模块。模块拆分的另一个好处是替换成本低,比如今天用 BeautifulSoup 做解析,明天平台页面改成动态渲染了,我可以只把采集模块换掉,其他三块完全不动。
2.2 技术选型不是越新越好,是越省心越好
很多人一听到自动化监控,第一反应就是 Kafka、Celery、Redis、分布式采集集群。但回到我们的真实需求:100 个监控对象、一天三次、单机处理,这个量级连一台普通机器 1% 的性能都用不满,上分布式不是技术需要,而是技术包袱。
选型时我格外看重维护成本,最终敲定的组合是:
| 环节 | 选型 | 选择理由 |
|---|---|---|
| 语言 | Python 3.9+ | 生态成熟,requests、BeautifulSoup、基础库都很稳定 |
| 采集 | requests + BeautifulSoup | 目标页面服务端渲染时最省资源;遇到 JS 渲染再升 Playwright |
| 存储 | SQLite | 单文件、零部署、够用;百万条以内完全无压力 |
| 调度 | APScheduler / Crontab | 定时任务用最简单的方式解决,不需要独立调度中心 |
| 通知 | smtplib + HTML 模板 | Python 原生库直接搞定,不依赖第三方推送服务 |
| 配置 | YAML | 配置和代码分离,改关键词、换邮箱不用动代码 |
这套组合的核心逻辑是"默认路径尽量简单,扩展能力用开关控制"。requests 能抓到数据就不用 Playwright,SQLite 能存下数据就不用 MySQL,crontab 能定时就不用分布式调度。每个决定都是在问自己同一个问题:当前这个阶段,值不值得为它引入额外的运维负担?
2.3 我对"轻量化"的两层理解
第一层是技术轻量化:依赖少、代码量少、单机可跑。第二层是运维轻量化:部署简单、日志清晰、不需要专人维护基础设施。很多时候一个项目技术上不重,但运维上很重,比如引了十几二十个依赖包,每个包都要升级维护,这本身就是成本。
1949AI 的设计目标明确:让运营人员每天看几封邮件就能掌握竞品动态。为了达到这个目标,我现在愿意牺牲一部分"高级功能",换取系统稳定性和可维护性。先把自动化跑稳,再谈那些花活,这是做工具系统的底层逻辑。
3. 批量采集与排名数据落库的实现细节
3.1 采集任务怎么拆才不会互相拖累
采集模块最忌讳写成一个超大循环,一个请求超时会拖垮整批任务。我把循环结构设计成"关键词外层、ASIN 内层",并为每个 ASIN 单独包一层异常处理。这样即使某个 ASIN 因页面结构变化解析失败,也不会影响后续其他 ASIN 的采集。
核心代码大致长这样:
python复制# core/scraper.py
import random
import time
import requests
from bs4 import BeautifulSoup
class RankScraper:
def __init__(self, delay_range=(2, 5), timeout=15):
self.session = requests.Session()
self.delay_range = delay_range
self.timeout = timeout
self.user_agents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36",
]
def fetch_search_page(self, base_url, keyword):
headers = {
"User-Agent": random.choice(self.user_agents),
"Accept-Language": "en-US,en;q=0.9",
}
params = {"field-keywords": keyword}
resp = self.session.get(
base_url, headers=headers, params=params, timeout=self.timeout
)
resp.raise_for_status()
# 请求之间随机延时,避免出现固定频率的访问特征
time.sleep(random.uniform(*self.delay_range))
return resp.text
def parse_rank(self, html, target_asin):
soup = BeautifulSoup(html, "lxml")
items = soup.select("div[data-asin]")
for index, item in enumerate(items, start=1):
asin = item.get("data-asin", "")
if asin == target_asin:
title_node = item.select_one("h2")
title = title_node.get_text(strip=True) if title_node else ""
return {
"asin": asin,
"rank": index,
"title": title,
}
return None
这里有个容易被忽略的点:parse_rank 里的 rank 直接用了 index + 1,这个数值只在单页场景下正确。如果目标关键词的搜索结果超过了 1 页,就需要计算 (page - 1) * page_size + position。我在第一版里图省事没做翻页,后来发现好几个大词的自然排名都在第二页,这才补上了翻页逻辑。虽然排名监控主要关心前 50 名,但翻页能力不能没有,不然换词的时候又得改代码。
3.2 数据库设计:一张表就够了
数据库结构我压到了最简单,一张 rank_records 表,字段只保留必要信息:关键词、ASIN、排名、标题、采集时间。标题字段看起来不起眼,但实际分析时很有用——竞品改标题是重要的运营动作信号,顺手记录下来比回头再去查方便得多。
sql复制CREATE TABLE IF NOT EXISTS rank_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
asin TEXT NOT NULL,
rank INTEGER,
title TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_keyword_asin_time
ON rank_records(keyword, asin, created_at);
存储模块封装了写入和快照查询两个能力。写入用 executemany 批量插入,避免一次任务插入几百条时反复打开连接;快照查询则用了一个小技巧,查每个 keyword + asin 组合下 id 最大的那条记录,也就是每个监控对象最近一次采集结果。
python复制# core/storage.py
import sqlite3
class RankStorage:
def __init__(self, db_path):
self.db_path = db_path
self._init_db()
def _connect(self):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
return conn
def _init_db(self):
with self._connect() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS rank_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
asin TEXT NOT NULL,
rank INTEGER,
title TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_keyword_asin_time
ON rank_records(keyword, asin, created_at)
""")
def save_records(self, records):
if not records:
return
with self._connect() as conn:
conn.executemany(
"INSERT INTO rank_records (keyword, asin, rank, title) VALUES (?, ?, ?, ?)",
[(r["keyword"], r["asin"], r["rank"], r["title"]) for r in records],
)
def get_latest_snapshot_map(self):
with self._connect() as conn:
rows = conn.execute("""
SELECT keyword, asin, rank, title, created_at
FROM rank_records
WHERE id IN (
SELECT MAX(id) FROM rank_records GROUP BY keyword, asin
)
""").fetchall()
return {f"{r['keyword']}|{r['asin']}": r for r in rows}
为什么用 MAX(id) 而不是 MAX(created_at)?这是我在实际踩过坑之后换的写法。如果同一批次里有两条记录的创建时间精确到秒级别相同,MAX(created_at) 有可能查出两条记录,而 id 是绝对递增的,用 MAX(id) 查出来的快照一定是最新一次采集的结果,不会出现歧义。
3.3 采集频率与基本反爬策略
采集频率我建议克制一点,不要贪多。100 个监控对象、每天三次,单次任务量才几百个请求,对目标平台来说微不足道,但依然要遵守基本礼貌:请求之间加随机延时,轮换 UA,不要用固定频率去访问。我实测下来,2 到 5 秒的随机延时体验最平衡——跑完 100 个对象大约 5 到 8 分钟,完全在可接受范围内,又能明显降低被识别为脚本的概率。
如果你监控的站点做了更严格的风控,比如滑块验证、签名参数之类的,那就不是简单加延时能解决的了。我的建议是及时止损,不要硬刚风控,改走平台官方 API 更稳妥。另外提醒一句,不管用什么方案采集,都要遵守目标平台的 robots 协议和用户协议,合理控制频率,把数据用在自身选品和运营决策上,别去做影响平台正常运营的批量抓取。
4. 变化检测、邮件通知与 AI 摘要模块
4.1 变化检测:不要每次变动都通知
变化检测是整个系统的核心判断逻辑。我的做法很简单:对比旧快照和新记录,计算排名差值,超过阈值才产生告警。
排名的升降方向一开始很容易搞混,我这边约定:旧排名减新排名,结果为正数代表排名上升,负数代表排名下降。比如从第 10 名升到第 5 名,10 - 5 = 5,正 5。从第 5 名掉到第 8 名,5 - 8 = -3,负 3。
python复制# core/detector.py
def detect_changes(previous_map, current_records, threshold=3):
changes = []
for record in current_records:
key = f"{record['keyword']}|{record['asin']}"
previous = previous_map.get(key)
if previous is None:
# 首次收录的 ASIN,没有历史排名,不触发告警
continue
diff = previous["rank"] - record["rank"]
if abs(diff) >= threshold:
changes.append({
**record,
"old_rank": previous["rank"],
"diff": diff,
})
return sorted(changes, key=lambda x: abs(x["diff"]), reverse=True)
阈值默认 3 位。这个值不是拍脑袋定的,我总结过一段时间的数据:多数竞品的排名在 1 到 2 位之间波动时,往往只是自然波动,没有决策价值;而超过 3 位的变动,通常对应着上架、改价格、断货、广告策略调整这类实际运营动作。当然,阈值也可以做成配置项,如果你对排名特别敏感,调到 1 或 2 都行,只是要做好频繁收邮件的心理准备。
有一个细节要注意:首次收录的 ASIN 不触发告警。因为程序第一次采集它时,没有任何历史数据做对比,硬要触发的话,每次新加监控对象都会误报一堆"排名变化",影响对真实问题的判断。
4.2 邮件通知:标准库也能做得很稳
邮件通知我用 Python 标准库 smtplib 直接实现,不引入第三方发送服务。它的好处是配置简单、几乎零成本,只要有 SMTP 授权码就能发。
python复制# core/notifier.py
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
class MailNotifier:
def __init__(self, host, port, username, password, use_ssl=True):
self.host = host
self.port = port
self.username = username
self.password = password
self.use_ssl = use_ssl
def send_html(self, subject, html, to_emails):
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = self.username
msg["To"] = ", ".join(to_emails)
msg.attach(MIMEText(html, "html", "utf-8"))
if self.use_ssl:
server = smtplib.SMTP_SSL(self.host, self.port, timeout=15)
else:
server = smtplib.SMTP(self.host, self.port, timeout=15)
server.starttls()
with server:
server.login(self.username, self.password)
server.sendmail(self.username, to_emails, msg.as_string())
邮件正文我渲染成 HTML 表格,按变动幅度从大到小排序,让人一眼看到最严重的条目。HTML 模板用简单的 f-string 拼接就够,不用上 Jinja2 这类模板引擎,毕竟渲染场景太简单了。
python复制def render_html(changes):
rows = ""
for item in changes:
color = "#16a34a" if item["diff"] > 0 else "#dc2626"
rows += f"""
<tr>
<td>{item['keyword']}</td>
<td>{item['asin']}</td>
<td>{item['old_rank']}</td>
<td>{item['rank']}</td>
<td style="color:{color}; font-weight:bold;">{item['diff']:+.0f}</td>
<td>{item.get('title', '')[:60]}</td>
</tr>
"""
return f"""
<html>
<body>
<h3>竞品排名变动通知</h3>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;">
<tr>
<th>关键词</th><th>ASIN</th><th>原排名</th>
<th>现排名</th><th>变动</th><th>标题</th>
</tr>
{rows}
</table>
<p style="color:#666; font-size:12px;">本邮件由 1949AI 自动化监控系统发送</p>
</body>
</html>
"""
这里要注意,HTML 里的用户输入字段最好做一下转义,竞品标题里可能出现特殊字符,不转义轻则样式错乱,重则邮件内容被邮箱服务商拦截。我加了一个简单的 html.escape,几十行内就把问题解决了。
4.3 通知降噪:什么时候该发,什么时候不该发
告警系统最大的敌人是噪音。如果每天发几十封邮件,同事很快就会把你的发件地址拖进过滤规则,之后真正重要的告警也看不到。所以我在通知策略上做了两层降噪。
第一层是阈值过滤,已经在检测模块实现了,低于阈值的波动不发。第二层是去重逻辑,同一个 ASIN 在同一个关键词下,24 小时内只触发一次告警。具体做法是在检测结果里加一个标记表,记录每个 key 上次告警时间,如果距离上次告警不足 24 小时,即使这次又超过阈值,也会被压下来,避免竞品在 10 名和 13 名之间反复横跳时,系统不停地轰炸你。
4.4 可选的 AI 摘要模块:为什么默认关闭
项目代号里有 AI,我总得给 AI 一个合适的位子。我的设计是在检测模块之后加一个可选步骤:把变化清单整理成一段自然语言摘要,插到邮件正文最顶部。比如:
"过去 24 小时有 5 个排名变化超过阈值。其中无线性耳机品类下竞品 B0XXXX 上升 12 位至第 6 名,可能与昨日降价 15% 有关;运动蓝牙音箱品类下竞品 B0YYYY 下降 8 位至第 21 名,疑似主图更换后点击率下降。"
这个摘要可以直接调用大模型接口生成。但我把它做成开关,默认关闭,原因有三个:一是第三方 API 接口不稳定,宁可系统先保证核心链路稳定,也不要因为外部依赖导致整个任务失败;二是每次调用有费用,高频运行会积累成本;三是摘要在大多数情况下只是锦上添花,真正做决策的人还是会去看表格里的具体数据。
这个设计的启发是:AI 功能应该是一种扩展能力,而不是基础依赖。先把基础自动化跑稳了,再决定要不要让 AI 帮你把数据翻译成白话文。
5. 定时调度:让任务每天自动跑起来
5.1 APscheduler 配置:比 crontab 更可控
我最终用 APScheduler 统一管理定时任务,主要是看中它在进程内管理、支持复杂触发规则、错过执行时间后可以补跑。BlockingScheduler 适合我这个单进程场景,配置也简单。
python复制# main.py
import logging
