跳到主要内容

蜜雪冰城新品监测

收藏0

每天自动监测蜜雪冰城新品/上新/联名/限定动态,多源交叉验证过滤伪标旧闻,推送最近一周真新品到 QQ(OneBot 网关,纯 Python 免依赖)

让 AI 安装

Minsaroun最新版本 0.1.0更新于 2026-11-03下载 0 次

mixue_monitor.py

12.1 KB
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# mixue_monitor.py —— 蜜雪冰城新品/活动监测(QQ 推送)
4# 机制:cn.bing.com RSS 搜索(免 key)轮询多个关键词,过滤出"新品/上新/联名/新口味/限定"类
5# 时效条目(默认最近 3 天),去重后通过 OneBot 推送到老师 QQ。
6# 用法:
7# python3 mixue_monitor.py # 正常巡检:抓取→过滤→去重→推送
8# python3 mixue_monitor.py --dry-run # 只打印将推送内容,不推送不改状态
9# python3 mixue_monitor.py --days 7 # 放宽时效窗口到 7 天
10# python3 mixue_monitor.py --list # 查看 state 里已推送记录
11import argparse
12import json
13import os
14import re
15import subprocess
16import sys
17import time
18import urllib.parse
19from datetime import datetime, timedelta, timezone as dt_tz
20
21BASE = os.path.dirname(os.path.abspath(__file__))
22CONFIG_FILE = os.path.join(BASE, 'mixue_config.json')
23STATE_FILE = os.path.join(BASE, 'mixue_state.json')
24LOG = os.path.join(BASE, 'mixue_monitor.log')
25UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
26
27# 搜索关键词(覆盖新品/上新/联名/回归/限定等角度)
28QUERIES = [
29 '蜜雪冰城 新品',
30 '蜜雪冰城 上新',
31 '蜜雪冰城 联名',
32 '蜜雪冰城 新品上市',
33 '蜜雪冰城 新口味',
34 '蜜雪冰城 限定',
35]
36
37# 命中即视为"值得看"的关键词(标题里出现任一即候选)
38INTEREST_KW = ['新品', '上新', '上市', '联名', '新口味', '限定', '回归', '新包装',
39 '新杯', '新系列', '首发', '奶麻薯', '雪王', '周边', '快闪', '联名款']
40# 相关性必须命中(防止"蜜"字等无关结果混入)
41RELEVANT_KW = ['蜜雪', '雪王', 'mixue', 'Mixue', 'MXBC', '蜜雪冰城']
42# 排除词(纯字典/百科/无关页/财经/导购/回顾文/实测文)
43EXCLUDE_KW = ['百度百科', '汉语', '字典', '国学', '爱企查', '企查查', '加盟', '招商', '招聘',
44 '股票', '股吧', '财务报表', '港股', '开户', '十大股东', '工商信息', '财报',
45 '怎么选', '购买指南', '评测', '测评报告', '哪个牌子', '对比测评',
46 '实测', '攻略', '教程',
47 '必喝Top5', '必喝Top']
48
49# 已核实的老官宣/噪音链接(精确 URL 排除,不用宽泛标题句式——避免误杀真新品官宣)
50EXCLUDE_LINKS = [
51 'https://www.weibo.com/1704709632/ReZUptowL', # 8/25 奶麻薯首发官宣(Bing 伪标 10/27)
52]
53
54WINDOW_DAYS = 7 # 默认只看最近 N 天的条目
55
56
57def load_cfg():
58 try:
59 with open(CONFIG_FILE) as f:
60 return json.load(f)
61 except Exception:
62 return {}
63
64
65CFG = load_cfg()
66OWNER_QQ = os.environ.get('QQ_USER_ID') or str(CFG.get('qq_user_id') or '')
67ONE_BOT_URL = os.environ.get('ONE_BOT_URL') or CFG.get('one_bot_url') or 'http://127.0.0.1:3000/send_private_msg'
68ONE_BOT_TOKEN = os.environ.get('ONE_BOT_TOKEN') or CFG.get('one_bot_token') or ''
69
70
71def log(msg):
72 line = f'{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} {msg}'
73 print(line, flush=True)
74 try:
75 with open(LOG, 'a') as f:
76 f.write(line + '\n')
77 except Exception:
78 pass
79
80
81def http_get(url):
82 try:
83 r = subprocess.run(['curl', '-sL', '--max-time', '20', '-A', UA, url],
84 capture_output=True, text=True, timeout=25)
85 return r.stdout
86 except Exception:
87 return ''
88
89
90def bing_rss(query, count=20):
91 """cn.bing.com RSS 搜索(免 key)。返回 [(title, link, pub_date, desc)]"""
92 url = 'https://cn.bing.com/search?q=%s&format=rss&count=%d' % (
93 urllib.parse.quote(query), count)
94 xml = http_get(url)
95 if not xml or '<item>' not in xml:
96 return []
97 out = []
98 for it in re.findall(r'<item>(.*?)</item>', xml, flags=re.S):
99 def g(tag):
100 m = re.search(r'<%s[^>]*>(.*?)</%s>' % (tag, tag), it, flags=re.S)
101 return m.group(1).strip() if m else ''
102 title = g('title')
103 link = g('link')
104 pub = g('pubDate')
105 desc = g('description')
106 if title and link:
107 out.append({'title': title, 'link': link, 'pub': pub, 'desc': desc})
108 return out
109
110
111def parse_pub(s):
112 """Bing RSS 中文格式日期:'周二, 27 10月 2026 20:16:00 GMT' → datetime 或 None"""
113 if not s:
114 return None
115 # 中文格式:'周X, 27 10月 2026 20:16:00 GMT'
116 m = re.search(r'(\d{1,2}) (\d{1,2})月 (\d{4}) (\d{2}):(\d{2}):(\d{2})', s)
117 if m:
118 try:
119 return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1)),
120 int(m.group(4)), int(m.group(5)), int(m.group(6)))
121 except ValueError:
122 return None
123 # 也可能 '27 Oct 2026 ...' 或 '2026-10-27'
124 for fmt in ('%a, %d %b %Y %H:%M:%S %Z', '%d %b %Y %H:%M:%S %Z',
125 '%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
126 try:
127 return datetime.strptime(s.strip(), fmt)
128 except ValueError:
129 continue
130 return None
131
132
133def is_interesting(item):
134 t = item['title']
135 low = t
136 for ex in EXCLUDE_KW:
137 if ex in low:
138 return False
139 if not any(k in low for k in RELEVANT_KW):
140 return False
141 if not any(k in low for k in INTEREST_KW):
142 return False
143 # 精确 URL 黑名单(老官宣/已核实噪音,按链接排不误伤真新品)
144 if any(item['link'].startswith(u) for u in EXCLUDE_LINKS):
145 return False
146 return True
147
148
149# ---- 多源交叉验证(治本方案)----
150# 原理:真正的新品官宣会被多个来源同时报道(微博官宣 + B站/抖音视频 + sina 新闻转载),
151# 而 Bing 伪标日期的老官宣通常是"孤立微博"(只有微博一条被标成近期)。
152# 做法:对每条候选,提取标题里的产品词,用「蜜雪冰城 + 产品词」二次搜索,
153# 统计窗口内出现的非微博来源域名数:≥1 源佐证 = 真新品;0 源 = 疑似旧闻。
154
155# 已知具体产品词(提取产品关键词时优先匹配)
156PROD_WORDS = ['芋泥', '奶麻薯', '大樱桃', '樱桃酸奶', '抹茶大新地', '雪王大新地',
157 '大新地', '抹茶大圣代', '大圣代', '鲜牛乳', '小丸子', '冰杯']
158
159
160def extract_prod_kw(title):
161 """从标题提取产品关键词:优先取 #话题#(去品牌/模板词),其次已知产品词,最后标题清洗兜底"""
162 for tag in re.findall(r'#([^#]+)#', title):
163 tt = re.sub(r'蜜雪冰城|雪王|mixue|Mixue|新品|上市|上新|联名|回归|限定|新口味|'
164 r'新包装|新系列|首发|快闪|周边|啦', '', tag).strip()
165 if len(tt) >= 2:
166 return tt[:10]
167 for w in PROD_WORDS:
168 if w in title:
169 return w
170 t = re.sub(r'蜜雪冰城|雪王|新品|上市啦|上新|联名|回归|限定|新口味|新包装|新系列|'
171 r'首发|快闪|周边|Get|get|一杯|双重口感|转\+关|请喝|杯|!|啦|上市|'
172 r',|,|。|\.|(|)|\(|\)', ' ', title)
173 t = re.sub(r'\s+', ' ', t).strip()
174 if t and len(t) <= 12:
175 return t
176 return ''
177
178
179def cross_verify(it, cutoff):
180 """多源交叉验证:返回 (通过与否, 说明)。窗口内 ≥1 个非微博来源佐证 → 通过"""
181 kw = extract_prod_kw(it['title'])
182 if not kw:
183 return False, '提取不出产品词'
184 hits = bing_rss('蜜雪冰城 ' + kw, count=15)
185 sources = set()
186 for h in hits:
187 if h['link'] == it['link']:
188 continue # 自己不算佐证
189 dom = urllib.parse.urlparse(h['link']).netloc
190 if 'weibo.com' in dom:
191 continue # 微博不算佐证(Bing 伪标日期重灾区)
192 if not is_interesting(h):
193 continue # 佐证也要是蜜雪相关(挡字典/百科噪音)
194 p = parse_pub(h.get('pub', ''))
195 if p and p.replace(tzinfo=dt_tz.utc) >= cutoff:
196 sources.add(dom)
197 if sources:
198 return True, '佐证: ' + ','.join(sorted(sources))
199 return False, '无其他来源佐证(疑似伪标旧闻)'
200
201
202def load_state():
203 if os.path.exists(STATE_FILE):
204 try:
205 with open(STATE_FILE, encoding='utf-8') as f:
206 return json.load(f)
207 except Exception:
208 pass
209 return {'seen': {}, 'last_run': None}
210
211
212def save_state(st):
213 with open(STATE_FILE, 'w', encoding='utf-8') as f:
214 json.dump(st, f, ensure_ascii=False, indent=2)
215
216
217def send_qq(text):
218 if not ONE_BOT_TOKEN or not OWNER_QQ:
219 log('(通知未配置:ONE_BOT_TOKEN / QQ_USER_ID 未设置,仅打印)')
220 return ''
221 payload = {'user_id': str(OWNER_QQ), 'message': text}
222 try:
223 r = subprocess.run(
224 ['curl', '-s', '--max-time', '15', '-X', 'POST', ONE_BOT_URL,
225 '-H', 'Authorization: Bearer %s' % ONE_BOT_TOKEN,
226 '-H', 'Content-Type: application/json',
227 '-d', json.dumps(payload, ensure_ascii=False)],
228 capture_output=True, text=True, timeout=20)
229 return r.stdout
230 except Exception as exc:
231 log('推送失败: %s' % exc)
232 return ''
233
234
235def fmt_item(it):
236 return f"・{it['title']}\n {it['link']}"
237
238
239def main():
240 ap = argparse.ArgumentParser()
241 ap.add_argument('--dry-run', action='store_true')
242 ap.add_argument('--days', type=int, default=WINDOW_DAYS)
243 ap.add_argument('--list', action='store_true')
244 args = ap.parse_args()
245
246 if args.list:
247 st = load_state()
248 for k, v in st['seen'].items():
249 print(f'{v.get("time", "?")} {k}')
250 return
251
252 days = args.days if args.days > 0 else WINDOW_DAYS
253 cutoff = datetime.now(dt_tz.utc) - timedelta(days=days)
254 st = load_state()
255 seen = st.get('seen', {})
256
257 all_items = []
258 for q in QUERIES:
259 try:
260 items = bing_rss(q)
261 except Exception as e:
262 log(f'搜索 {q} 失败: {e}')
263 items = []
264 log(f'搜索「{q}」→ {len(items)} 条')
265 all_items.extend(items)
266
267 # 去重(同 link 只留一次)
268 by_link = {}
269 for it in all_items:
270 by_link.setdefault(it['link'], it)
271
272 # 过滤:相关性 + 时效
273 fresh = []
274 for it in by_link.values():
275 if not is_interesting(it):
276 continue
277 pub = parse_pub(it.get('pub', ''))
278 if pub is None:
279 # 日期解析不了就当作近期,靠去重兜底
280 fresh.append((it, None))
281 elif pub.replace(tzinfo=dt_tz.utc) >= cutoff:
282 fresh.append((it, pub))
283 fresh.sort(key=lambda x: ((x[1] or datetime.now(dt_tz.utc)).replace(tzinfo=dt_tz.utc) if x[1] else datetime.now(dt_tz.utc)), reverse=True)
284
285 # 多源交叉验证:窗口内需有 ≥1 个其他来源佐证,孤立微博(伪标旧闻)不推
286 verified = []
287 for it, pub in fresh:
288 ok, why = cross_verify(it, cutoff)
289 if ok:
290 verified.append((it, pub))
291 else:
292 log(f'交叉验证未通过({why}):{it["title"][:40]}')
293 fresh = verified
294
295 # 从未推送的(按 title+link 去重)
296 new_items = []
297 for it, pub in fresh:
298 key = it['link']
299 if key in seen:
300 continue
301 new_items.append((it, pub, key))
302
303 log(f'过滤后 {len(fresh)} 条相关,其中未推送 {len(new_items)} 条')
304
305 if not new_items:
306 log('没有新的蜜雪新品条目,跳过')
307 st['last_run'] = datetime.now().isoformat()
308 save_state(st)
309 return
310
311 lines = ['🍦 蜜雪冰城动态(最近 %d 天)' % days]
312 for it, pub, key in new_items[:8]:
313 lines.append(fmt_item(it))
314 if pub:
315 lines[-1] += f"\n 时间:{pub.strftime('%m-%d %H:%M')}"
316 text = '\n'.join(lines)
317
318 if args.dry_run:
319 log('[dry-run] 将推送:\n' + text)
320 log(f'[dry-run] 共 {len(new_items[:8])} 条(不推送、不写 state)')
321 return
322
323 resp = send_qq(text)
324 log(f'推送响应: {resp}')
325 for it, pub, key in new_items[:8]:
326 seen[key] = {'time': datetime.now().isoformat(), 'title': it['title']}
327 st['last_run'] = datetime.now().isoformat()
328 save_state(st)
329 log(f'已推送并记录 {len(new_items[:8])} 条')
330
331
332if __name__ == '__main__':
333 main()

文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。