很多团队都有"盯 X"的需求:
这些信息如果靠人工刷,既容易遗漏又占用精力。更好的方式是让程序自动发现新推文,并直接推送到团队日常使用的飞书或企业微信群。
本文实现一个轻量服务,满足以下要求:
定时触发(每 N 分钟)
└→ 轮询:对每个订阅调用搜索接口,取最新推文
└→ SQLite:INSERT OR IGNORE 去重,新推文状态记为 pending
└→ 发送器:取出 pending 记录,合并成消息
├→ 飞书自定义机器人(签名 + 消息卡片)
└→ 企业微信群机器人(markdown)
└→ 成功标记为 sent,失败保留 pending 等待下轮重试这里把"发现新推文"和"发送消息"拆成两个阶段,中间用 SQLite 中的状态字段衔接。这样做的好处是:即使发送环节失败(比如机器人被限流),推文也已经入库,下一轮会自动重发,不会丢失。
本文示例使用第三方接口 TwexAPI 的高级搜索接口获取推文。它支持 X 原生搜索语法,sortBy 设为 Latest 时按时间倒序返回结果。账号订阅可以用 from: 运算符实现,多个账号用 OR 连接,这样一次请求就能覆盖多个账号。
# config.py 示例代码
import os
BASE_URL = "https://api.twexapi.io"
API_HEADERS = {"Authorization": f"Bearer {os.environ['DATA_API_KEY']}"}
FEISHU_WEBHOOK = os.environ.get("FEISHU_WEBHOOK") # https://open.feishu.cn/open-apis/bot/v2/hook/xxx
FEISHU_SECRET = os.environ.get("FEISHU_SECRET") # 开启签名校验后获得的密钥
WECOM_WEBHOOK = os.environ.get("WECOM_WEBHOOK") # https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
SUBSCRIPTIONS = [
{"name": "竞品官号", "query": "(from:account_a OR from:account_b) -filter:replies"},
{"name": "品牌舆情", "query": "\"YourBrand\" -filter:retweets"},
]
POLL_INTERVAL = 300 # 秒# poller.py 示例代码
import time
import requests
from config import BASE_URL, API_HEADERS
def fetch_latest(query: str) -> list[dict]:
"""只取第一页:轮询场景下,两次轮询之间的新推文通常不会超过一页。"""
for attempt in range(3):
r = requests.post(f"{BASE_URL}/twitter/advanced_search/page", headers=API_HEADERS,
json={"searchTerms": [query], "sortBy": "Latest"}, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 10)))
continue
r.raise_for_status()
return [t for t in r.json().get("data") or [] if t]
return []轮询只取第一页(最多 20 条)。如果某个关键词在 5 分钟内的新推文经常超过 20 条,说明它的热度已经不适合逐条推送,应该改为"每小时汇总"的形式,或者在查询中加上 min_faves: 之类的条件,只推送有一定互动量的内容。
去重最可靠的方式是以 tweet_id 作为主键,借助数据库的唯一约束实现幂等写入:
# store.py 示例代码
import sqlite3
import time
DDL = """
CREATE TABLE IF NOT EXISTS tweets (
tweet_id TEXT PRIMARY KEY,
sub_name TEXT NOT NULL,
author TEXT,
text TEXT,
url TEXT,
created_at TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending / sent / skipped
retries INTEGER NOT NULL DEFAULT 0,
inserted REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_status ON tweets(status);
CREATE TABLE IF NOT EXISTS subs_seen (sub_name TEXT PRIMARY KEY);
"""
class Store:
def __init__(self, path="tweets.db"):
self.db = sqlite3.connect(path)
self.db.executescript(DDL)
def is_first_run(self, sub: str) -> bool:
return self.db.execute("SELECT 1 FROM subs_seen WHERE sub_name=?", (sub,)).fetchone() is None
def save(self, sub: str, tweets: list[dict]) -> int:
"""写入新推文;首次运行的订阅直接标记为 skipped,作为基线。"""
status = "skipped" if self.is_first_run(sub) else "pending"
n = 0
with self.db:
for t in tweets:
u = t.get("user") or {}
cur = self.db.execute(
"INSERT OR IGNORE INTO tweets VALUES (?,?,?,?,?,?,?,0,?)",
(t["tweet_id"], sub, u.get("screen_name"),
t.get("full_text") or t.get("text") or "",
f"https://x.com/{u.get('screen_name')}/status/{t['tweet_id']}",
t.get("created_at"), status, time.time()))
n += cur.rowcount
self.db.execute("INSERT OR IGNORE INTO subs_seen VALUES (?)", (sub,))
return n if status == "pending" else 0
def pending(self, limit=10) -> list[tuple]:
return self.db.execute(
"SELECT tweet_id, sub_name, author, text, url FROM tweets "
"WHERE status='pending' AND retries < 5 ORDER BY inserted LIMIT ?", (limit,)).fetchall()
def mark(self, ids: list[str], ok: bool):
with self.db:
if ok:
self.db.executemany("UPDATE tweets SET status='sent' WHERE tweet_id=?",
[(i,) for i in ids])
else:
self.db.executemany("UPDATE tweets SET retries=retries+1 WHERE tweet_id=?",
[(i,) for i in ids])设计要点:
INSERT OR IGNORE + 主键:重复的推文会被自动忽略,rowcount 为 0。这比"先查询再插入"更简洁,也不存在并发竞争问题;skipped,从第二轮开始才推送真正的新内容;retries,超过 5 次就不再尝试,避免一条格式有问题的消息无限重试;在飞书群的"设置 → 群机器人 → 添加机器人"中选择"自定义机器人",即可获得一个 Webhook 地址,格式为 https://open.feishu.cn/open-apis/bot/v2/hook/xxxx。
飞书提供三种安全设置:自定义关键词、IP 白名单和签名校验。推荐开启签名校验,这样即使 Webhook 地址泄露,没有密钥也无法发送消息。
根据飞书文档,签名的计算方式是:以 timestamp + "\n" + 密钥 作为 HMAC-SHA256 的密钥,对空字符串进行签名,再做 Base64 编码。时间戳的单位是秒,与当前时间相差不能超过 1 小时。
这里有一个容易出错的地方:拼接后的字符串是用作 HMAC 的 key,而被签名的消息是空的。很多人会把它们写反,导致返回 19021 签名校验失败。
飞书消息卡片支持 Markdown 内容和按钮,适合展示推文。请求体中 msg_type 为 interactive,卡片内容放在 card 字段中:
# feishu.py 示例代码
import base64
import hashlib
import hmac
import time
import requests
from config import FEISHU_WEBHOOK, FEISHU_SECRET
def gen_sign(timestamp: int, secret: str) -> str:
string_to_sign = f"{timestamp}\n{secret}"
code = hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest()
return base64.b64encode(code).decode("utf-8")
def build_card(title: str, items: list[tuple]) -> dict:
elements = []
for _id, sub, author, text, url in items:
elements.append({"tag": "markdown",
"content": f"**@{author}**:{text[:300]}\n[查看原文]({url})"})
elements.append({"tag": "hr"})
return {
"schema": "2.0",
"header": {"title": {"tag": "plain_text", "content": title}, "template": "blue"},
"body": {"elements": elements[:-1]},
}
def send_feishu(title: str, items: list[tuple]) -> bool:
payload = {"msg_type": "interactive", "card": build_card(title, items)}
if FEISHU_SECRET:
ts = int(time.time())
payload.update(timestamp=str(ts), sign=gen_sign(ts, FEISHU_SECRET))
r = requests.post(FEISHU_WEBHOOK, json=payload, timeout=10)
data = r.json()
if data.get("code") != 0:
print("feishu error:", data) # 19021 签名失败;11232 触发限流
return False
return True飞书自定义机器人的几个限制需要特别注意:
11232 限流错误;code 是否为 0。在企业微信群中添加"消息推送"(原"群机器人"),即可获得 Webhook 地址:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx。地址中的 key 就是凭证,务必妥善保管。
企业微信群机器人支持 text、markdown、markdown_v2、图片、图文、文件、语音和模板卡片等消息类型。对于推文推送,markdown 类型是比较合适的选择:支持加粗、链接、引用和三种内置字体颜色。
# wecom.py 示例代码
import requests
from config import WECOM_WEBHOOK
MAX_BYTES = 4000 # 文档限制 4096 字节,预留余量
def build_markdown(title: str, items: list[tuple]) -> str:
parts = [f"### {title}"]
for _id, sub, author, text, url in items:
block = f"\n**@{author}** <font color=\"comment\">{sub}</font>\n> {text[:200]}\n[查看原文]({url})"
if len("\n".join(parts + [block]).encode("utf-8")) > MAX_BYTES:
parts.append("\n……更多内容见下一条消息")
break
parts.append(block)
return "\n".join(parts)
def send_wecom(title: str, items: list[tuple]) -> bool:
payload = {"msgtype": "markdown", "markdown": {"content": build_markdown(title, items)}}
r = requests.post(WECOM_WEBHOOK, json=payload, timeout=10)
data = r.json()
if data.get("errcode") != 0:
print("wecom error:", data)
return False
return True企业微信的限制:
errcode 是否为 0。另外,markdown 消息中引用块 > 只对当前行生效。如果推文正文中有换行,建议先把换行替换为空格,再放进引用块,否则排版会乱。
# main.py 示例代码
import time
from config import SUBSCRIPTIONS, POLL_INTERVAL, FEISHU_WEBHOOK, WECOM_WEBHOOK
from poller import fetch_latest
from store import Store
from feishu import send_feishu
from wecom import send_wecom
def clean(items):
return [(i, s, a, (t or "").replace("\n", " "), u) for i, s, a, t, u in items]
def run_once(store: Store):
for sub in SUBSCRIPTIONS:
n = store.save(sub["name"], fetch_latest(sub["query"]))
if n:
print(f"[{sub['name']}] 新推文 {n} 条")
time.sleep(1)
batch = clean(store.pending(limit=8)) # 每次最多合并 8 条
if not batch:
return
title = f"X 动态提醒({len(batch)} 条)"
ok = True
if FEISHU_WEBHOOK:
ok &= send_feishu(title, batch)
if WECOM_WEBHOOK:
ok &= send_wecom(title, batch)
store.mark([b[0] for b in batch], ok)
if __name__ == "__main__":
store = Store()
while True:
try:
run_once(store)
except Exception as e: # 单轮失败不影响下一轮
print("round failed:", e)
time.sleep(POLL_INTERVAL)这里有一个简化处理:同时配置两个渠道时,只要有一个发送失败,整批都会在下一轮重发,成功的那个渠道可能收到重复消息。如果对重复比较敏感,可以在表中为每个渠道分别记录发送状态。
systemd 守护上面的循环进程,配置 Restart=always;也可以去掉 while 循环,改为每 5 分钟由 cron 执行一次 run_once,配合 flock 防止重叠;template 改为 red),并 @ 相关负责人;只订阅和推送公开内容;推送时附原文链接,而不是大段复制原文;不针对个人做持续监控;遵守数据来源平台的服务条款、企业内部的信息安全规定和相关法律法规。
code;说明:文中代码为示例代码,飞书和企业微信的消息格式及限制以其官方文档的最新版本为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。