v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
8
cloudsearch_enrich/Dockerfile
Normal file
8
cloudsearch_enrich/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY *.py .
|
||||
EXPOSE 9530 9532
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9530/health')"
|
||||
CMD ["sh", "-c", "python search_enricher.py & python feishu_bot.py & python subscription_monitor.py & wait"]
|
||||
319
cloudsearch_enrich/feishu_bot.py
Normal file
319
cloudsearch_enrich/feishu_bot.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
CloudSearch 飞书 Bot v1.0.0
|
||||
替代 Telegram Bot,支持 /search /subscribe 命令 + Webhook 推送
|
||||
通过飞书开放平台事件订阅接收消息
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("feishubot")
|
||||
|
||||
# ── 飞书配置 ──────────────────────────────────
|
||||
APP_ID = os.environ.get("FEISHU_APP_ID", "")
|
||||
APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
|
||||
VERIFY_TOKEN = os.environ.get("FEISHU_VERIFY_TOKEN", "")
|
||||
WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
|
||||
CLOUDSEARCH_API = os.environ.get("CLOUDSEARCH_API", "http://app:9527")
|
||||
DB_PATH = os.environ.get("BOT_DB_PATH", "/data/bot.db")
|
||||
|
||||
# ── 飞书API ───────────────────────────────────
|
||||
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
|
||||
|
||||
_tenant_token = None
|
||||
_token_expire = 0
|
||||
|
||||
def get_tenant_token() -> str:
|
||||
"""获取飞书 tenant_access_token(缓存2h)"""
|
||||
global _tenant_token, _token_expire
|
||||
if _tenant_token and time.time() < _token_expire:
|
||||
return _tenant_token
|
||||
resp = requests.post(FEISHU_TOKEN_URL, json={
|
||||
"app_id": APP_ID, "app_secret": APP_SECRET
|
||||
}, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取飞书Token失败: {data}")
|
||||
_tenant_token = data["tenant_access_token"]
|
||||
_token_expire = time.time() + data.get("expire", 7200) - 300
|
||||
logger.info("飞书 tenant_token 已刷新")
|
||||
return _tenant_token
|
||||
|
||||
def send_feishu_msg(open_id: str, content: str, msg_type: str = "text"):
|
||||
"""发送飞书消息"""
|
||||
body = {
|
||||
"receive_id": open_id,
|
||||
"msg_type": msg_type,
|
||||
"content": json.dumps({"text": content}) if msg_type == "text" else content
|
||||
}
|
||||
resp = requests.post(
|
||||
FEISHU_SEND_URL,
|
||||
headers={"Authorization": f"Bearer {get_tenant_token()}"},
|
||||
json=body, timeout=10
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"发送飞书消息失败: {data}")
|
||||
return data.get("code") == 0
|
||||
|
||||
def send_feishu_card(open_id: str, card: dict):
|
||||
"""发送飞书卡片消息"""
|
||||
body = {
|
||||
"receive_id": open_id,
|
||||
"msg_type": "interactive",
|
||||
"content": json.dumps(card)
|
||||
}
|
||||
resp = requests.post(
|
||||
FEISHU_SEND_URL,
|
||||
headers={"Authorization": f"Bearer {get_tenant_token()}"},
|
||||
json=body, timeout=10
|
||||
)
|
||||
return resp.json().get("code") == 0
|
||||
|
||||
def send_webhook(text: str):
|
||||
"""通过 Webhook 推送通知(用于订阅变更)"""
|
||||
if not WEBHOOK_URL:
|
||||
return
|
||||
try:
|
||||
requests.post(WEBHOOK_URL, json={
|
||||
"msg_type": "text",
|
||||
"content": {"text": text}
|
||||
}, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook推送失败: {e}")
|
||||
|
||||
# ── Bot 核心逻辑 ────────────────────────────────
|
||||
class FeishuBot:
|
||||
def __init__(self):
|
||||
self.db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self.db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_id TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
last_check TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(open_id, keyword)
|
||||
)
|
||||
""")
|
||||
self.db.commit()
|
||||
logger.info("订阅数据库就绪")
|
||||
|
||||
def handle_text(self, open_id: str, text: str):
|
||||
"""处理文本消息"""
|
||||
text = text.strip()
|
||||
if text.startswith("/search"):
|
||||
keyword = text.replace("/search", "", 1).strip()
|
||||
return self._cmd_search(open_id, keyword)
|
||||
elif text.startswith("/subscribe"):
|
||||
keyword = text.replace("/subscribe", "", 1).strip()
|
||||
return self._cmd_subscribe(open_id, keyword)
|
||||
elif text.startswith("/unsub"):
|
||||
keyword = text.replace("/unsub", "", 1).strip()
|
||||
return self._cmd_unsub(open_id, keyword)
|
||||
elif text.startswith("/mysubs"):
|
||||
return self._cmd_mysubs(open_id)
|
||||
elif text.startswith("/help") or text.lower() == "help":
|
||||
return self._cmd_help(open_id)
|
||||
else:
|
||||
return self._cmd_search(open_id, text) # 默认搜索
|
||||
|
||||
def _cmd_help(self, open_id: str):
|
||||
help_text = (
|
||||
"🔍 CloudSearch Bot\n\n"
|
||||
"命令:\n"
|
||||
"/search 关键词 — 搜索网盘资源\n"
|
||||
"直接输入关键词也可以搜索\n"
|
||||
"/subscribe 关键词 — 订阅关键词\n"
|
||||
"/unsub 关键词 — 取消订阅\n"
|
||||
"/mysubs — 查看我的订阅\n"
|
||||
"/help — 帮助"
|
||||
)
|
||||
send_feishu_msg(open_id, help_text)
|
||||
|
||||
def _cmd_search(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /search 流浪地球2\n或直接输入关键词")
|
||||
return
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{CLOUDSEARCH_API}/api/query",
|
||||
json={"q": keyword}, timeout=15
|
||||
)
|
||||
results = []
|
||||
for line in resp.text.strip().split("\n"):
|
||||
try:
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "result":
|
||||
results.append(d)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not results:
|
||||
send_feishu_msg(open_id, f"😞 未找到「{keyword}」的相关资源")
|
||||
return
|
||||
|
||||
# 构建飞书卡片
|
||||
elements = []
|
||||
for i, r in enumerate(results[:5]):
|
||||
title = (r.get("title") or r.get("content", ""))[:50]
|
||||
cloud = r.get("cloud_type", "?").upper()
|
||||
pwd = r.get("password", "")
|
||||
pwd_str = f" 🔑{pwd}" if pwd else ""
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": f"**{i+1}.** [{cloud}] {title}{pwd_str}"}
|
||||
})
|
||||
|
||||
card = {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"🔎 {keyword} — {len(results)}个结果"},
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": elements + [{
|
||||
"tag": "action",
|
||||
"actions": [{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "🌐 查看更多"},
|
||||
"type": "primary",
|
||||
"url": f"{CLOUDSEARCH_API}/?q={keyword}"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
send_feishu_card(open_id, card)
|
||||
|
||||
except Exception as e:
|
||||
send_feishu_msg(open_id, f"❌ 搜索失败: {e}")
|
||||
|
||||
def _cmd_subscribe(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /subscribe 流浪地球")
|
||||
return
|
||||
try:
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (open_id, keyword) VALUES (?, ?)",
|
||||
(open_id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
send_feishu_msg(open_id, f"✅ 已订阅「{keyword}」,有新结果会通知你")
|
||||
except Exception as e:
|
||||
send_feishu_msg(open_id, f"❌ 订阅失败: {e}")
|
||||
|
||||
def _cmd_unsub(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /unsub 流浪地球")
|
||||
return
|
||||
cur = self.db.execute(
|
||||
"DELETE FROM subscriptions WHERE open_id=? AND keyword=?",
|
||||
(open_id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
if cur.rowcount > 0:
|
||||
send_feishu_msg(open_id, f"✅ 已取消订阅「{keyword}」")
|
||||
else:
|
||||
send_feishu_msg(open_id, f"未找到「{keyword}」的订阅")
|
||||
|
||||
def _cmd_mysubs(self, open_id: str):
|
||||
rows = self.db.execute(
|
||||
"SELECT keyword, created_at FROM subscriptions WHERE open_id=? ORDER BY created_at DESC",
|
||||
(open_id,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
send_feishu_msg(open_id, "你还没有订阅任何关键词")
|
||||
return
|
||||
text = "📋 我的订阅:\n"
|
||||
for kw, dt in rows:
|
||||
text += f"• {kw} ({dt[:10]})\n"
|
||||
send_feishu_msg(open_id, text)
|
||||
|
||||
def check_subscriptions(self):
|
||||
"""检查所有订阅,有新结果时推送通知"""
|
||||
subs = self.db.execute("SELECT DISTINCT keyword FROM subscriptions").fetchall()
|
||||
for (kw,) in subs:
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{CLOUDSEARCH_API}/api/query",
|
||||
json={"q": kw}, timeout=10
|
||||
)
|
||||
count = sum(1 for line in resp.text.split("\n")
|
||||
if '"type":"result"' in line)
|
||||
if count > 0:
|
||||
# 通知所有订阅此关键词的用户
|
||||
users = self.db.execute(
|
||||
"SELECT open_id FROM subscriptions WHERE keyword=?",
|
||||
(kw,)
|
||||
).fetchall()
|
||||
for (uid,) in users:
|
||||
send_feishu_msg(uid, f"🔔「{kw}」有新资源({count}个)!\n/search {kw}")
|
||||
# Webhook 也推送
|
||||
send_webhook(f"🔔 关键词「{kw}」发现 {count} 个新资源")
|
||||
except Exception as e:
|
||||
logger.error(f"检查订阅[{kw}]失败: {e}")
|
||||
|
||||
# ── Flask Web 服务 ─────────────────────────────
|
||||
bot = FeishuBot()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok", "bot": "feishu"})
|
||||
|
||||
@app.route("/feishu/event", methods=["POST"])
|
||||
def feishu_event():
|
||||
"""飞书事件订阅回调"""
|
||||
body = request.get_json()
|
||||
logger.info(f"飞书事件: {json.dumps(body, ensure_ascii=False)[:300]}")
|
||||
|
||||
# Token 验证(首次配置URL时)
|
||||
if body.get("type") == "url_verification":
|
||||
token = body.get("token", "")
|
||||
if token == VERIFY_TOKEN:
|
||||
return jsonify({"challenge": body.get("challenge", "")})
|
||||
return jsonify({"error": "invalid token"}), 403
|
||||
|
||||
# 事件回调验证
|
||||
if "header" in body:
|
||||
# 收到消息事件
|
||||
event = body.get("event", {})
|
||||
msg_type = event.get("message", {}).get("message_type", "")
|
||||
if msg_type == "text":
|
||||
content = event["message"].get("content", "{}")
|
||||
try:
|
||||
text = json.loads(content).get("text", "")
|
||||
except json.JSONDecodeError:
|
||||
text = content
|
||||
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
|
||||
if text and open_id:
|
||||
bot.handle_text(open_id, text)
|
||||
|
||||
return jsonify({"code": 0})
|
||||
|
||||
@app.route("/feishu/check", methods=["POST"])
|
||||
def trigger_check():
|
||||
"""手动触发订阅检查"""
|
||||
bot.check_subscriptions()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
# ── 启动入口 ───────────────────────────────────
|
||||
def main():
|
||||
if not APP_ID:
|
||||
logger.warning("FEISHU_APP_ID 未设置,Bot 无法接收消息(仅 Webhook 可用)")
|
||||
logger.info("飞书 Bot 启动,端口9531")
|
||||
app.run(host="0.0.0.0", port=9532)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
319
cloudsearch_enrich/feishu_bot_tmp.py
Normal file
319
cloudsearch_enrich/feishu_bot_tmp.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
CloudSearch 飞书 Bot v1.0.0
|
||||
替代 Telegram Bot,支持 /search /subscribe 命令 + Webhook 推送
|
||||
通过飞书开放平台事件订阅接收消息
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("feishubot")
|
||||
|
||||
# ── 飞书配置 ──────────────────────────────────
|
||||
APP_ID = os.environ.get("FEISHU_APP_ID", "")
|
||||
APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
|
||||
VERIFY_TOKEN = os.environ.get("FEISHU_VERIFY_TOKEN", "")
|
||||
WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
|
||||
CLOUDSEARCH_API = os.environ.get("CLOUDSEARCH_API", "http://app:9527")
|
||||
DB_PATH = os.environ.get("BOT_DB_PATH", "/data/bot.db")
|
||||
|
||||
# ── 飞书API ───────────────────────────────────
|
||||
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
|
||||
|
||||
_tenant_token = None
|
||||
_token_expire = 0
|
||||
|
||||
def get_tenant_token() -> str:
|
||||
"""获取飞书 tenant_access_token(缓存2h)"""
|
||||
global _tenant_token, _token_expire
|
||||
if _tenant_token and time.time() < _token_expire:
|
||||
return _tenant_token
|
||||
resp = requests.post(FEISHU_TOKEN_URL, json={
|
||||
"app_id": APP_ID, "app_secret": APP_SECRET
|
||||
}, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取飞书Token失败: {data}")
|
||||
_tenant_token = data["tenant_access_token"]
|
||||
_token_expire = time.time() + data.get("expire", 7200) - 300
|
||||
logger.info("飞书 tenant_token 已刷新")
|
||||
return _tenant_token
|
||||
|
||||
def send_feishu_msg(open_id: str, content: str, msg_type: str = "text"):
|
||||
"""发送飞书消息"""
|
||||
body = {
|
||||
"receive_id": open_id,
|
||||
"msg_type": msg_type,
|
||||
"content": json.dumps({"text": content}) if msg_type == "text" else content
|
||||
}
|
||||
resp = requests.post(
|
||||
FEISHU_SEND_URL,
|
||||
headers={"Authorization": f"Bearer {get_tenant_token()}"},
|
||||
json=body, timeout=10
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"发送飞书消息失败: {data}")
|
||||
return data.get("code") == 0
|
||||
|
||||
def send_feishu_card(open_id: str, card: dict):
|
||||
"""发送飞书卡片消息"""
|
||||
body = {
|
||||
"receive_id": open_id,
|
||||
"msg_type": "interactive",
|
||||
"content": json.dumps(card)
|
||||
}
|
||||
resp = requests.post(
|
||||
FEISHU_SEND_URL,
|
||||
headers={"Authorization": f"Bearer {get_tenant_token()}"},
|
||||
json=body, timeout=10
|
||||
)
|
||||
return resp.json().get("code") == 0
|
||||
|
||||
def send_webhook(text: str):
|
||||
"""通过 Webhook 推送通知(用于订阅变更)"""
|
||||
if not WEBHOOK_URL:
|
||||
return
|
||||
try:
|
||||
requests.post(WEBHOOK_URL, json={
|
||||
"msg_type": "text",
|
||||
"content": {"text": text}
|
||||
}, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook推送失败: {e}")
|
||||
|
||||
# ── Bot 核心逻辑 ────────────────────────────────
|
||||
class FeishuBot:
|
||||
def __init__(self):
|
||||
self.db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self.db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_id TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
last_check TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(open_id, keyword)
|
||||
)
|
||||
""")
|
||||
self.db.commit()
|
||||
logger.info("订阅数据库就绪")
|
||||
|
||||
def handle_text(self, open_id: str, text: str):
|
||||
"""处理文本消息"""
|
||||
text = text.strip()
|
||||
if text.startswith("/search"):
|
||||
keyword = text.replace("/search", "", 1).strip()
|
||||
return self._cmd_search(open_id, keyword)
|
||||
elif text.startswith("/subscribe"):
|
||||
keyword = text.replace("/subscribe", "", 1).strip()
|
||||
return self._cmd_subscribe(open_id, keyword)
|
||||
elif text.startswith("/unsub"):
|
||||
keyword = text.replace("/unsub", "", 1).strip()
|
||||
return self._cmd_unsub(open_id, keyword)
|
||||
elif text.startswith("/mysubs"):
|
||||
return self._cmd_mysubs(open_id)
|
||||
elif text.startswith("/help") or text.lower() == "help":
|
||||
return self._cmd_help(open_id)
|
||||
else:
|
||||
return self._cmd_search(open_id, text) # 默认搜索
|
||||
|
||||
def _cmd_help(self, open_id: str):
|
||||
help_text = (
|
||||
"🔍 CloudSearch Bot\n\n"
|
||||
"命令:\n"
|
||||
"/search 关键词 — 搜索网盘资源\n"
|
||||
"直接输入关键词也可以搜索\n"
|
||||
"/subscribe 关键词 — 订阅关键词\n"
|
||||
"/unsub 关键词 — 取消订阅\n"
|
||||
"/mysubs — 查看我的订阅\n"
|
||||
"/help — 帮助"
|
||||
)
|
||||
send_feishu_msg(open_id, help_text)
|
||||
|
||||
def _cmd_search(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /search 流浪地球2\n或直接输入关键词")
|
||||
return
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{CLOUDSEARCH_API}/api/query",
|
||||
json={"q": keyword}, timeout=15
|
||||
)
|
||||
results = []
|
||||
for line in resp.text.strip().split("\n"):
|
||||
try:
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "result":
|
||||
results.append(d)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not results:
|
||||
send_feishu_msg(open_id, f"😞 未找到「{keyword}」的相关资源")
|
||||
return
|
||||
|
||||
# 构建飞书卡片
|
||||
elements = []
|
||||
for i, r in enumerate(results[:5]):
|
||||
title = (r.get("title") or r.get("content", ""))[:50]
|
||||
cloud = r.get("cloud_type", "?").upper()
|
||||
pwd = r.get("password", "")
|
||||
pwd_str = f" 🔑{pwd}" if pwd else ""
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": f"**{i+1}.** [{cloud}] {title}{pwd_str}"}
|
||||
})
|
||||
|
||||
card = {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"🔎 {keyword} — {len(results)}个结果"},
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": elements + [{
|
||||
"tag": "action",
|
||||
"actions": [{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "🌐 查看更多"},
|
||||
"type": "primary",
|
||||
"url": f"{CLOUDSEARCH_API}/?q={keyword}"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
send_feishu_card(open_id, card)
|
||||
|
||||
except Exception as e:
|
||||
send_feishu_msg(open_id, f"❌ 搜索失败: {e}")
|
||||
|
||||
def _cmd_subscribe(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /subscribe 流浪地球")
|
||||
return
|
||||
try:
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (open_id, keyword) VALUES (?, ?)",
|
||||
(open_id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
send_feishu_msg(open_id, f"✅ 已订阅「{keyword}」,有新结果会通知你")
|
||||
except Exception as e:
|
||||
send_feishu_msg(open_id, f"❌ 订阅失败: {e}")
|
||||
|
||||
def _cmd_unsub(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /unsub 流浪地球")
|
||||
return
|
||||
cur = self.db.execute(
|
||||
"DELETE FROM subscriptions WHERE open_id=? AND keyword=?",
|
||||
(open_id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
if cur.rowcount > 0:
|
||||
send_feishu_msg(open_id, f"✅ 已取消订阅「{keyword}」")
|
||||
else:
|
||||
send_feishu_msg(open_id, f"未找到「{keyword}」的订阅")
|
||||
|
||||
def _cmd_mysubs(self, open_id: str):
|
||||
rows = self.db.execute(
|
||||
"SELECT keyword, created_at FROM subscriptions WHERE open_id=? ORDER BY created_at DESC",
|
||||
(open_id,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
send_feishu_msg(open_id, "你还没有订阅任何关键词")
|
||||
return
|
||||
text = "📋 我的订阅:\n"
|
||||
for kw, dt in rows:
|
||||
text += f"• {kw} ({dt[:10]})\n"
|
||||
send_feishu_msg(open_id, text)
|
||||
|
||||
def check_subscriptions(self):
|
||||
"""检查所有订阅,有新结果时推送通知"""
|
||||
subs = self.db.execute("SELECT DISTINCT keyword FROM subscriptions").fetchall()
|
||||
for (kw,) in subs:
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{CLOUDSEARCH_API}/api/query",
|
||||
json={"q": kw}, timeout=10
|
||||
)
|
||||
count = sum(1 for line in resp.text.split("\n")
|
||||
if '"type":"result"' in line)
|
||||
if count > 0:
|
||||
# 通知所有订阅此关键词的用户
|
||||
users = self.db.execute(
|
||||
"SELECT open_id FROM subscriptions WHERE keyword=?",
|
||||
(kw,)
|
||||
).fetchall()
|
||||
for (uid,) in users:
|
||||
send_feishu_msg(uid, f"🔔「{kw}」有新资源({count}个)!\n/search {kw}")
|
||||
# Webhook 也推送
|
||||
send_webhook(f"🔔 关键词「{kw}」发现 {count} 个新资源")
|
||||
except Exception as e:
|
||||
logger.error(f"检查订阅[{kw}]失败: {e}")
|
||||
|
||||
# ── Flask Web 服务 ─────────────────────────────
|
||||
bot = FeishuBot()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok", "bot": "feishu"})
|
||||
|
||||
@app.route("/feishu/event", methods=["POST"])
|
||||
def feishu_event():
|
||||
"""飞书事件订阅回调"""
|
||||
body = request.get_json()
|
||||
logger.info(f"飞书事件: {json.dumps(body, ensure_ascii=False)[:300]}")
|
||||
|
||||
# Token 验证(首次配置URL时)
|
||||
if body.get("type") == "url_verification":
|
||||
token = body.get("token", "")
|
||||
if token == VERIFY_TOKEN:
|
||||
return jsonify({"challenge": body.get("challenge", "")})
|
||||
return jsonify({"error": "invalid token"}), 403
|
||||
|
||||
# 事件回调验证
|
||||
if "header" in body:
|
||||
# 收到消息事件
|
||||
event = body.get("event", {})
|
||||
msg_type = event.get("message", {}).get("message_type", "")
|
||||
if msg_type == "text":
|
||||
content = event["message"].get("content", "{}")
|
||||
try:
|
||||
text = json.loads(content).get("text", "")
|
||||
except json.JSONDecodeError:
|
||||
text = content
|
||||
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
|
||||
if text and open_id:
|
||||
bot.handle_text(open_id, text)
|
||||
|
||||
return jsonify({"code": 0})
|
||||
|
||||
@app.route("/feishu/check", methods=["POST"])
|
||||
def trigger_check():
|
||||
"""手动触发订阅检查"""
|
||||
bot.check_subscriptions()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
# ── 启动入口 ───────────────────────────────────
|
||||
def main():
|
||||
if not APP_ID:
|
||||
logger.warning("FEISHU_APP_ID 未设置,Bot 无法接收消息(仅 Webhook 可用)")
|
||||
logger.info("飞书 Bot 启动,端口9531")
|
||||
app.run(host="0.0.0.0", port=9531)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
cloudsearch_enrich/requirements.txt
Normal file
3
cloudsearch_enrich/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
requests>=2.28
|
||||
python-telegram-bot>=20.0
|
||||
132
cloudsearch_enrich/search_enricher.py
Normal file
132
cloudsearch_enrich/search_enricher.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
CloudSearch Search Enricher v1.0.0
|
||||
搜索结果增强:TMDB匹配 + 过期检测 + 内容去重
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from tmdb_enricher import TMDBEnricher
|
||||
|
||||
logger = logging.getLogger("enricher")
|
||||
|
||||
|
||||
class SearchEnricher:
|
||||
"""搜索结果增强器"""
|
||||
|
||||
def __init__(self, tmdb_api_key: str = "", cache_ttl: int = 86400):
|
||||
self.tmdb = TMDBEnricher(tmdb_api_key, cache_ttl=cache_ttl) if tmdb_api_key else None
|
||||
|
||||
def enrich_results(self, results: List[Dict], keyword: str = "") -> List[Dict]:
|
||||
"""批量增强搜索结果"""
|
||||
if not results:
|
||||
return results
|
||||
|
||||
enriched = []
|
||||
titles_to_lookup = []
|
||||
|
||||
# 收集需要查 TMDB 的标题
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
if title and self.tmdb:
|
||||
titles_to_lookup.append(title)
|
||||
|
||||
# 批量查询 TMDB
|
||||
tmdb_results = {}
|
||||
if titles_to_lookup and self.tmdb:
|
||||
tmdb_results = self.tmdb.enrich_batch(titles_to_lookup[:20], max_concurrent=5)
|
||||
|
||||
# 应用增强
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
media = tmdb_results.get(title)
|
||||
|
||||
enriched_item = dict(r)
|
||||
if media:
|
||||
enriched_item.update({
|
||||
"tmdb_id": media.tmdb_id,
|
||||
"tmdb_url": media.tmdb_url,
|
||||
"poster": media.poster_url,
|
||||
"backdrop": media.backdrop_url,
|
||||
"rating": media.rating,
|
||||
"rating_count": media.rating_count,
|
||||
"year": media.year,
|
||||
"genres": media.genres,
|
||||
"description": media.description,
|
||||
"media_type": media.media_type,
|
||||
"directors": media.directors,
|
||||
"actors": media.actors[:5],
|
||||
"enriched": True,
|
||||
})
|
||||
|
||||
# 自动生成更好的标题
|
||||
if media.year and media.rating:
|
||||
enriched_item["display_title"] = (
|
||||
f"{title} ({media.year}) ⭐{media.rating}"
|
||||
)
|
||||
|
||||
enriched.append(enriched_item)
|
||||
|
||||
return enriched
|
||||
|
||||
def enrich_single(self, title: str, keyword: str = "") -> Optional[Dict]:
|
||||
"""增强单个标题"""
|
||||
if not self.tmdb:
|
||||
return None
|
||||
media = self.tmdb.enrich(title)
|
||||
if not media:
|
||||
return None
|
||||
return {
|
||||
"title": title,
|
||||
"tmdb_id": media.tmdb_id,
|
||||
"poster": media.poster_url,
|
||||
"rating": media.rating,
|
||||
"year": media.year,
|
||||
"genres": media.genres,
|
||||
"description": media.description,
|
||||
"media_type": media.media_type,
|
||||
}
|
||||
|
||||
|
||||
# Flask API wrapper
|
||||
def create_enricher_api(tmdb_key: str = ""):
|
||||
from flask import Flask, request, jsonify
|
||||
app = Flask(__name__)
|
||||
enricher = SearchEnricher(tmdb_key)
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
return jsonify({"status": "ok", "version": "1.0.0"})
|
||||
|
||||
@app.route("/enrich", methods=["POST"])
|
||||
def enrich():
|
||||
data = request.get_json() or {}
|
||||
results = data.get("results", [])
|
||||
keyword = data.get("keyword", "")
|
||||
|
||||
if not results:
|
||||
return jsonify({"error": "results required"}), 400
|
||||
|
||||
enriched = enricher.enrich_results(results, keyword)
|
||||
return jsonify({"results": enriched, "count": len(enriched)})
|
||||
|
||||
@app.route("/lookup", methods=["POST"])
|
||||
def lookup():
|
||||
data = request.get_json() or {}
|
||||
title = data.get("title", "")
|
||||
if not title:
|
||||
return jsonify({"error": "title required"}), 400
|
||||
|
||||
info = enricher.enrich_single(title)
|
||||
return jsonify(info or {})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
api_key = os.getenv("TMDB_API_KEY", "")
|
||||
port = int(os.getenv("PORT", "9530"))
|
||||
app = create_enricher_api(api_key)
|
||||
logger.info(f"Enricher API on port {port}")
|
||||
app.run(host="0.0.0.0", port=port)
|
||||
204
cloudsearch_enrich/subscription_monitor.py
Normal file
204
cloudsearch_enrich/subscription_monitor.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
CloudSearch Subscription Monitor v1.0.0
|
||||
关键词订阅 + 新资源检测 + 多渠道通知
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import logging
|
||||
import requests
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger("subscription")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
chat_id: int
|
||||
keyword: str
|
||||
new_count: int
|
||||
results: List[dict]
|
||||
channel: str = "telegram" # telegram / feishu / dingtalk
|
||||
|
||||
|
||||
class SubscriptionMonitor:
|
||||
"""订阅监控:定时搜索关键词,发现新资源后推送通知"""
|
||||
|
||||
def __init__(self, api_base: str, db_path: str = "/data/subscriptions.db",
|
||||
tg_bot_token: str = None):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.tg_token = tg_bot_token
|
||||
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self.db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
last_result_hash TEXT,
|
||||
last_check TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(chat_id, keyword)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sent_notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER,
|
||||
result_hash TEXT,
|
||||
sent_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id)
|
||||
);
|
||||
""")
|
||||
self.db.commit()
|
||||
|
||||
def check_all(self, batch_size: int = 10) -> List[Notification]:
|
||||
"""检查所有订阅,返回需要通知的列表"""
|
||||
subs = self.db.execute(
|
||||
"SELECT id, chat_id, keyword, last_result_hash FROM subscriptions ORDER BY last_check ASC LIMIT ?",
|
||||
(batch_size,)
|
||||
).fetchall()
|
||||
|
||||
notifications = []
|
||||
for sub_id, chat_id, keyword, last_hash in subs:
|
||||
try:
|
||||
result = self._search(keyword)
|
||||
new_hash = self._hash_results(result)
|
||||
|
||||
if new_hash and new_hash != last_hash:
|
||||
new_results = self._filter_new(sub_id, result, last_hash)
|
||||
if new_results:
|
||||
notifications.append(Notification(
|
||||
chat_id=chat_id,
|
||||
keyword=keyword,
|
||||
new_count=len(new_results),
|
||||
results=new_results[:5],
|
||||
))
|
||||
|
||||
# 更新状态
|
||||
self.db.execute(
|
||||
"UPDATE subscriptions SET last_result_hash=?, last_check=datetime('now','localtime') WHERE id=?",
|
||||
(new_hash, sub_id)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Check failed: {keyword} - {e}")
|
||||
|
||||
self.db.commit()
|
||||
return notifications
|
||||
|
||||
def _search(self, keyword: str) -> list:
|
||||
"""搜索关键词"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.api_base}/api/query",
|
||||
json={"q": keyword},
|
||||
timeout=20
|
||||
)
|
||||
results = []
|
||||
for line in resp.text.strip().split("\n"):
|
||||
try:
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "result":
|
||||
results.append({
|
||||
"title": d.get("title", ""),
|
||||
"url": d.get("share_url", ""),
|
||||
"cloud": d.get("cloud_type", ""),
|
||||
"source": d.get("source", ""),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Search error: {e}")
|
||||
return []
|
||||
|
||||
def _hash_results(self, results: list) -> str:
|
||||
"""计算结果哈希"""
|
||||
import hashlib
|
||||
key = "|".join(
|
||||
r.get("url", "")[:50] for r in sorted(
|
||||
results, key=lambda x: x.get("url", "")
|
||||
)
|
||||
)
|
||||
return hashlib.md5(key.encode()).hexdigest()
|
||||
|
||||
def _filter_new(self, sub_id: int, results: list, last_hash: str) -> list:
|
||||
"""过滤出新结果"""
|
||||
new = []
|
||||
for r in results:
|
||||
rhash = str(hash(r.get("url", "")))
|
||||
existing = self.db.execute(
|
||||
"SELECT id FROM sent_notifications WHERE subscription_id=? AND result_hash=?",
|
||||
(sub_id, rhash)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
new.append(r)
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO sent_notifications (subscription_id, result_hash) VALUES (?,?)",
|
||||
(sub_id, rhash)
|
||||
)
|
||||
return new
|
||||
|
||||
def notify_telegram(self, notif: Notification):
|
||||
"""通过 Telegram 发送通知"""
|
||||
if not self.tg_token:
|
||||
return
|
||||
text = f"🔔 *{notif.keyword}* 有新资源!({notif.new_count}个)\n\n"
|
||||
for i, r in enumerate(notif.results[:5]):
|
||||
title = r.get("title", "")[:40]
|
||||
url = r.get("url", "")
|
||||
cloud = r.get("cloud", "?").upper()
|
||||
text += f"{i+1}. [{cloud}] [{title}]({url})\n"
|
||||
|
||||
try:
|
||||
requests.post(
|
||||
f"https://api.telegram.org/bot{self.tg_token}/sendMessage",
|
||||
json={
|
||||
"chat_id": notif.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"TG notify failed: {e}")
|
||||
|
||||
def notify_feishu(self, notif: Notification, webhook_url: str):
|
||||
"""通过飞书发送通知"""
|
||||
text = f"🔔 {notif.keyword} 有新资源!({notif.new_count}个)\n"
|
||||
for r in notif.results[:5]:
|
||||
text += f"• [{r.get('cloud','?').upper()}] {r.get('title','')[:40]} {r.get('url','')}\n"
|
||||
try:
|
||||
requests.post(webhook_url, json={
|
||||
"msg_type": "text",
|
||||
"content": {"text": text}
|
||||
}, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error(f"Feishu notify failed: {e}")
|
||||
|
||||
def run_loop(self, interval_minutes: int = 15):
|
||||
"""循环运行"""
|
||||
logger.info(f"Subscription monitor started (interval={interval_minutes}min)")
|
||||
while True:
|
||||
try:
|
||||
notifs = self.check_all()
|
||||
for n in notifs:
|
||||
self.notify_telegram(n)
|
||||
if notifs:
|
||||
logger.info(f"Sent {len(notifs)} notifications")
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor error: {e}")
|
||||
time.sleep(interval_minutes * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api = os.getenv("CLOUDSEARCH_API", "http://127.0.0.1:9527")
|
||||
token = os.getenv("TG_BOT_TOKEN", "")
|
||||
interval = int(os.getenv("CHECK_INTERVAL", "15"))
|
||||
monitor = SubscriptionMonitor(api, tg_bot_token=token)
|
||||
monitor.run_loop(interval)
|
||||
183
cloudsearch_enrich/tg_bot.py
Normal file
183
cloudsearch_enrich/tg_bot.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
CloudSearch Telegram Bot v1.0.0
|
||||
提供: /search /subscribe /hot /help
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import (
|
||||
Application, CommandHandler, MessageHandler,
|
||||
CallbackQueryHandler, ContextTypes, filters
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("tgbot")
|
||||
|
||||
|
||||
class CloudSearchBot:
|
||||
def __init__(self, token: str, api_base: str, db_path: str = "/data/bot.db"):
|
||||
self.token = token
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self.db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
last_check TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
UNIQUE(chat_id, keyword)
|
||||
)
|
||||
""")
|
||||
self.db.commit()
|
||||
|
||||
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(
|
||||
"🔍 *CloudSearch Bot* v1.0\n\n"
|
||||
"命令:\n"
|
||||
"/search 关键词 — 搜索网盘资源\n"
|
||||
"/hot — 热门搜索\n"
|
||||
"/subscribe 关键词 — 订阅关键词\n"
|
||||
"/unsub 关键词 — 取消订阅\n"
|
||||
"/mysubs — 我的订阅\n"
|
||||
"/help — 帮助",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
async def search(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
keyword = " ".join(context.args) if context.args else ""
|
||||
if not keyword:
|
||||
await update.message.reply_text("用法: /search 流浪地球2")
|
||||
return
|
||||
|
||||
msg = await update.message.reply_text(f"🔎 搜索中: *{keyword}*...", parse_mode="Markdown")
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.api_base}/api/query",
|
||||
json={"q": keyword},
|
||||
timeout=15
|
||||
)
|
||||
|
||||
# Parse NDJSON response
|
||||
results = []
|
||||
content_info = None
|
||||
for line in resp.text.strip().split("\n"):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("type") == "result":
|
||||
results.append(data)
|
||||
elif data.get("type") == "stats":
|
||||
content_info = data.get("content_info")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not results:
|
||||
await msg.edit_text(f"😞 未找到「{keyword}」的相关资源")
|
||||
return
|
||||
|
||||
# Format top 5 results
|
||||
text = f"🔎 *{keyword}* — {len(results)} 个结果\n\n"
|
||||
for i, r in enumerate(results[:5]):
|
||||
title = (r.get("title") or r.get("content", ""))[:40]
|
||||
cloud = r.get("cloud_type", "?").upper()
|
||||
url = r.get("share_url", "")
|
||||
pwd = r.get("password", "")
|
||||
pwd_str = f" 🔑`{pwd}`" if pwd else ""
|
||||
text += f"{i+1}. [{cloud}] [{title}]({url}){pwd_str}\n"
|
||||
|
||||
keyboard = [[
|
||||
InlineKeyboardButton("🌐 查看更多", url=f"{self.api_base}/?q={keyword}")
|
||||
]]
|
||||
await msg.edit_text(
|
||||
text,
|
||||
parse_mode="Markdown",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await msg.edit_text(f"❌ 搜索失败: {e}")
|
||||
|
||||
async def subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
keyword = " ".join(context.args) if context.args else ""
|
||||
if not keyword:
|
||||
await update.message.reply_text("用法: /subscribe 流浪地球")
|
||||
return
|
||||
|
||||
try:
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (chat_id, keyword) VALUES (?, ?)",
|
||||
(update.effective_chat.id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
await update.message.reply_text(f"✅ 已订阅: *{keyword}*", parse_mode="Markdown")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 订阅失败: {e}")
|
||||
|
||||
async def unsub(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
keyword = " ".join(context.args) if context.args else ""
|
||||
self.db.execute(
|
||||
"DELETE FROM subscriptions WHERE chat_id=? AND keyword=?",
|
||||
(update.effective_chat.id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
await update.message.reply_text(f"🗑 已取消: *{keyword}*", parse_mode="Markdown")
|
||||
|
||||
async def mysubs(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
subs = self.db.execute(
|
||||
"SELECT keyword, created_at FROM subscriptions WHERE chat_id=? ORDER BY created_at DESC LIMIT 20",
|
||||
(update.effective_chat.id,)
|
||||
).fetchall()
|
||||
if not subs:
|
||||
await update.message.reply_text("📭 暂无订阅")
|
||||
return
|
||||
text = "📋 *我的订阅*\n" + "\n".join(f"• {s[0]}" for s in subs)
|
||||
await update.message.reply_text(text, parse_mode="Markdown")
|
||||
|
||||
async def hot(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
try:
|
||||
resp = requests.get(f"{self.api_base}/api/rankings/hot?limit=10", timeout=10)
|
||||
data = resp.json()
|
||||
keywords = data if isinstance(data, list) else data.get("keywords", [])
|
||||
text = "🔥 *热门搜索*\n" + "\n".join(
|
||||
f"{i+1}. {kw.get('keyword', str(kw))}" for i, kw in enumerate(keywords[:10])
|
||||
)
|
||||
except:
|
||||
text = "🔥 获取热门失败,请稍后重试"
|
||||
await update.message.reply_text(text, parse_mode="Markdown")
|
||||
|
||||
async def help_cmd(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await self.start(update, context)
|
||||
|
||||
def run(self):
|
||||
app = Application.builder().token(self.token).build()
|
||||
app.add_handler(CommandHandler("start", self.start))
|
||||
app.add_handler(CommandHandler("search", self.search))
|
||||
app.add_handler(CommandHandler("s", self.search))
|
||||
app.add_handler(CommandHandler("hot", self.hot))
|
||||
app.add_handler(CommandHandler("subscribe", self.subscribe))
|
||||
app.add_handler(CommandHandler("sub", self.subscribe))
|
||||
app.add_handler(CommandHandler("unsub", self.unsub))
|
||||
app.add_handler(CommandHandler("mysubs", self.mysubs))
|
||||
app.add_handler(CommandHandler("help", self.help_cmd))
|
||||
|
||||
logger.info("Bot starting...")
|
||||
app.run_polling()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
token = os.getenv("TG_BOT_TOKEN", "")
|
||||
api = os.getenv("CLOUDSEARCH_API", "http://127.0.0.1:9527")
|
||||
bot = CloudSearchBot(token, api)
|
||||
bot.run()
|
||||
179
cloudsearch_enrich/tmdb_enricher.py
Normal file
179
cloudsearch_enrich/tmdb_enricher.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
CloudSearch TMDB Enricher v1.0.0
|
||||
自动匹配影视元数据:海报、评分、简介、年份、类型
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from dataclasses import dataclass, field
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TMDB_API_BASE = "https://api.themoviedb.org/3"
|
||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaInfo:
|
||||
"""影视元数据"""
|
||||
title: str = ""
|
||||
original_title: str = ""
|
||||
year: str = ""
|
||||
poster_url: str = ""
|
||||
backdrop_url: str = ""
|
||||
rating: str = ""
|
||||
rating_count: int = 0
|
||||
description: str = ""
|
||||
genres: List[str] = field(default_factory=list)
|
||||
media_type: str = "" # movie / tv
|
||||
tmdb_id: int = 0
|
||||
directors: List[str] = field(default_factory=list)
|
||||
actors: List[str] = field(default_factory=list)
|
||||
region: str = ""
|
||||
duration: str = ""
|
||||
seasons: int = 0
|
||||
episodes: int = 0
|
||||
source: str = "tmdb"
|
||||
tmdb_url: str = ""
|
||||
|
||||
|
||||
class TMDBEnricher:
|
||||
"""TMDB 影视信息增强器"""
|
||||
|
||||
# 常见网盘文件名模式 → 影视标题提取
|
||||
TITLE_PATTERNS = [
|
||||
# [4K] 流浪地球2 (2023)
|
||||
(r'\[.*?\]\s*(.+?)\s*[\((](\d{4})[\))]', 2),
|
||||
# 流浪地球2.2023.4K
|
||||
(r'(.+?)\.(\d{4})\.(?:4K|1080[Pp]|2160[Pp]|HD)', 2),
|
||||
# 流浪地球2 2023
|
||||
(r'(.+?)\s+(\d{4})\s', 2),
|
||||
# S01E01 格式
|
||||
(r'(.+?)[\.\s][Ss](\d{2})[Ee](\d{2})', 1),
|
||||
]
|
||||
|
||||
def __init__(self, api_key: str, language: str = "zh-CN",
|
||||
cache_ttl: int = 86400):
|
||||
self.api_key = api_key
|
||||
self.language = language
|
||||
self.cache_ttl = cache_ttl
|
||||
self._cache: Dict[str, tuple] = {} # key → (data, timestamp)
|
||||
|
||||
def enrich(self, title: str, media_type: str = None) -> Optional[MediaInfo]:
|
||||
"""根据标题查询 TMDB 元数据"""
|
||||
clean_title, year = self._extract_title_year(title)
|
||||
|
||||
cache_key = f"{clean_title}:{year}:{media_type}"
|
||||
if cache_key in self._cache:
|
||||
data, ts = self._cache[cache_key]
|
||||
if time.time() - ts < self.cache_ttl:
|
||||
return data
|
||||
|
||||
# 智能判断类型
|
||||
if not media_type:
|
||||
media_type = self._guess_type(clean_title)
|
||||
|
||||
info = self._search(clean_title, year, media_type)
|
||||
if info:
|
||||
self._cache[cache_key] = (info, time.time())
|
||||
return info
|
||||
|
||||
def enrich_batch(self, titles: List[str], max_concurrent: int = 5) -> Dict[str, MediaInfo]:
|
||||
"""批量查询"""
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
results = {}
|
||||
with ThreadPoolExecutor(max_workers=max_concurrent) as ex:
|
||||
futures = {ex.submit(self.enrich, t): t for t in titles}
|
||||
for f in as_completed(futures):
|
||||
try:
|
||||
results[futures[f]] = f.result()
|
||||
except Exception as e:
|
||||
logger.warning(f"TMDB enrich failed: {futures[f]} - {e}")
|
||||
return results
|
||||
|
||||
def _extract_title_year(self, title: str) -> tuple:
|
||||
"""从文件名提取标题和年份"""
|
||||
import re
|
||||
for pattern, year_group in self.TITLE_PATTERNS:
|
||||
m = re.search(pattern, title, re.IGNORECASE)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
year = m.group(year_group) if year_group <= len(m.groups()) else ""
|
||||
# 去掉常见的后缀
|
||||
name = re.sub(r'\s*[\[((].*?(?:完结|全\d+集|更新).*?[\]))]', '', name)
|
||||
return name.strip(), year
|
||||
return title.strip(), ""
|
||||
|
||||
def _guess_type(self, title: str) -> str:
|
||||
"""根据标题特征判断电影/电视剧"""
|
||||
import re
|
||||
tv_patterns = [
|
||||
r'[Ss]\d{2}[Ee]\d{2}', r'第[一二三四五六七八九十\d]+季',
|
||||
r'[Ss]eason\s*\d+', r'全\d+集', r'更新至\d+',
|
||||
]
|
||||
for p in tv_patterns:
|
||||
if re.search(p, title):
|
||||
return "tv"
|
||||
return "movie"
|
||||
|
||||
def _search(self, title: str, year: str = "", media_type: str = "movie") -> Optional[MediaInfo]:
|
||||
"""搜索 TMDB"""
|
||||
try:
|
||||
# 搜索
|
||||
search_type = "tv" if media_type == "tv" else "movie"
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"query": title,
|
||||
"language": self.language,
|
||||
"page": 1,
|
||||
}
|
||||
if year:
|
||||
params["year" if search_type == "movie" else "first_air_date_year"] = year
|
||||
|
||||
resp = requests.get(
|
||||
f"{TMDB_API_BASE}/search/{search_type}",
|
||||
params=params, timeout=10
|
||||
)
|
||||
data = resp.json()
|
||||
results = data.get("results", [])
|
||||
|
||||
if not results and search_type == "movie":
|
||||
# 电视剧也试一下
|
||||
resp2 = requests.get(
|
||||
f"{TMDB_API_BASE}/search/tv",
|
||||
params=params, timeout=10
|
||||
)
|
||||
data2 = resp2.json()
|
||||
results = data2.get("results", [])
|
||||
|
||||
if not results:
|
||||
return None
|
||||
|
||||
item = results[0]
|
||||
return self._parse_result(item, media_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"TMDB search error: {title} - {e}")
|
||||
return None
|
||||
|
||||
def _parse_result(self, item: dict, media_type: str) -> MediaInfo:
|
||||
"""解析 TMDB 返回"""
|
||||
mid = item.get("id", 0)
|
||||
is_tv = media_type == "tv" or item.get("media_type") == "tv"
|
||||
|
||||
return MediaInfo(
|
||||
title=item.get("title") or item.get("name", ""),
|
||||
original_title=item.get("original_title") or item.get("original_name", ""),
|
||||
year=str(item.get("release_date", item.get("first_air_date", ""))[:4]),
|
||||
poster_url=f"{TMDB_IMAGE_BASE}{item['poster_path']}" if item.get("poster_path") else "",
|
||||
backdrop_url=f"{TMDB_IMAGE_BASE}{item['backdrop_path']}" if item.get("backdrop_path") else "",
|
||||
rating=str(round(item.get("vote_average", 0), 1)),
|
||||
rating_count=item.get("vote_count", 0),
|
||||
description=(item.get("overview") or "")[:500],
|
||||
genres=[g.get("name", "") for g in item.get("genre_ids", [])],
|
||||
media_type="tv" if is_tv else "movie",
|
||||
tmdb_id=mid,
|
||||
tmdb_url=f"https://www.themoviedb.org/{'tv' if is_tv else 'movie'}/{mid}",
|
||||
)
|
||||
Reference in New Issue
Block a user