v0.2.7: 修复Redis连接 + 启动管理后台

- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
2026-05-17 02:22:18 +08:00
commit 83cbfaf03f
164 changed files with 25195 additions and 0 deletions

View 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"]

View 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()

View 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()

View File

@@ -0,0 +1,3 @@
flask>=3.0
requests>=2.28
python-telegram-bot>=20.0

View 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)

View 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)

View 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()

View 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}",
)