|
|
@@ -15,7 +15,7 @@ import requests
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
-VERSION = "2.0.4"
|
|
|
+VERSION = "2.1.0"
|
|
|
|
|
|
|
|
|
# === 配置管理 ===
|
|
|
@@ -47,6 +47,27 @@ def load_config():
|
|
|
"FEISHU_MESSAGE_SEPARATOR": config_data["notification"][
|
|
|
"feishu_message_separator"
|
|
|
],
|
|
|
+ "SILENT_PUSH": {
|
|
|
+ "ENABLED": config_data["notification"]
|
|
|
+ .get("silent_push", {})
|
|
|
+ .get("enabled", False),
|
|
|
+ "TIME_RANGE": {
|
|
|
+ "START": config_data["notification"]
|
|
|
+ .get("silent_push", {})
|
|
|
+ .get("time_range", {})
|
|
|
+ .get("start", "08:00"),
|
|
|
+ "END": config_data["notification"]
|
|
|
+ .get("silent_push", {})
|
|
|
+ .get("time_range", {})
|
|
|
+ .get("end", "22:00"),
|
|
|
+ },
|
|
|
+ "ONCE_PER_DAY": config_data["notification"]
|
|
|
+ .get("silent_push", {})
|
|
|
+ .get("once_per_day", True),
|
|
|
+ "RECORD_RETENTION_DAYS": config_data["notification"]
|
|
|
+ .get("silent_push", {})
|
|
|
+ .get("push_record_retention_days", 7),
|
|
|
+ },
|
|
|
"WEIGHT_CONFIG": {
|
|
|
"RANK_WEIGHT": config_data["weight"]["rank_weight"],
|
|
|
"FREQUENCY_WEIGHT": config_data["weight"]["frequency_weight"],
|
|
|
@@ -216,6 +237,81 @@ def html_escape(text: str) -> str:
|
|
|
)
|
|
|
|
|
|
|
|
|
+# === 推送记录管理 ===
|
|
|
+class PushRecordManager:
|
|
|
+ """推送记录管理器"""
|
|
|
+
|
|
|
+ def __init__(self):
|
|
|
+ self.record_dir = Path("output") / ".push_records"
|
|
|
+ self.ensure_record_dir()
|
|
|
+ self.cleanup_old_records()
|
|
|
+
|
|
|
+ def ensure_record_dir(self):
|
|
|
+ """确保记录目录存在"""
|
|
|
+ self.record_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+
|
|
|
+ def get_today_record_file(self) -> Path:
|
|
|
+ """获取今天的记录文件路径"""
|
|
|
+ today = get_beijing_time().strftime("%Y%m%d")
|
|
|
+ return self.record_dir / f"push_record_{today}.json"
|
|
|
+
|
|
|
+ def cleanup_old_records(self):
|
|
|
+ """清理过期的推送记录"""
|
|
|
+ retention_days = CONFIG["SILENT_PUSH"]["RECORD_RETENTION_DAYS"]
|
|
|
+ current_time = get_beijing_time()
|
|
|
+
|
|
|
+ for record_file in self.record_dir.glob("push_record_*.json"):
|
|
|
+ try:
|
|
|
+ date_str = record_file.stem.replace("push_record_", "")
|
|
|
+ file_date = datetime.strptime(date_str, "%Y%m%d")
|
|
|
+ file_date = pytz.timezone("Asia/Shanghai").localize(file_date)
|
|
|
+
|
|
|
+ if (current_time - file_date).days > retention_days:
|
|
|
+ record_file.unlink()
|
|
|
+ print(f"清理过期推送记录: {record_file.name}")
|
|
|
+ except Exception as e:
|
|
|
+ print(f"清理记录文件失败 {record_file}: {e}")
|
|
|
+
|
|
|
+ def has_pushed_today(self) -> bool:
|
|
|
+ """检查今天是否已经推送过"""
|
|
|
+ record_file = self.get_today_record_file()
|
|
|
+
|
|
|
+ if not record_file.exists():
|
|
|
+ return False
|
|
|
+
|
|
|
+ try:
|
|
|
+ with open(record_file, "r", encoding="utf-8") as f:
|
|
|
+ record = json.load(f)
|
|
|
+ return record.get("pushed", False)
|
|
|
+ except Exception as e:
|
|
|
+ print(f"读取推送记录失败: {e}")
|
|
|
+ return False
|
|
|
+
|
|
|
+ def record_push(self, report_type: str):
|
|
|
+ """记录推送"""
|
|
|
+ record_file = self.get_today_record_file()
|
|
|
+ now = get_beijing_time()
|
|
|
+
|
|
|
+ record = {
|
|
|
+ "pushed": True,
|
|
|
+ "push_time": now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
+ "report_type": report_type,
|
|
|
+ }
|
|
|
+
|
|
|
+ try:
|
|
|
+ with open(record_file, "w", encoding="utf-8") as f:
|
|
|
+ json.dump(record, f, ensure_ascii=False, indent=2)
|
|
|
+ print(f"推送记录已保存: {report_type} at {now.strftime('%H:%M:%S')}")
|
|
|
+ except Exception as e:
|
|
|
+ print(f"保存推送记录失败: {e}")
|
|
|
+
|
|
|
+ def is_in_time_range(self, start_time: str, end_time: str) -> bool:
|
|
|
+ """检查当前时间是否在指定时间范围内"""
|
|
|
+ now = get_beijing_time()
|
|
|
+ current_time = now.strftime("%H:%M")
|
|
|
+ return start_time <= current_time <= end_time
|
|
|
+
|
|
|
+
|
|
|
# === 数据获取 ===
|
|
|
class DataFetcher:
|
|
|
"""数据获取器"""
|
|
|
@@ -1778,18 +1874,18 @@ def render_html_content(
|
|
|
<div class="info-item">
|
|
|
<span class="info-label">新闻总数</span>
|
|
|
<span class="info-value">"""
|
|
|
-
|
|
|
+
|
|
|
html += f"{total_titles} 条"
|
|
|
-
|
|
|
+
|
|
|
# 计算筛选后的热点新闻数量
|
|
|
hot_news_count = sum(len(stat["titles"]) for stat in report_data["stats"])
|
|
|
-
|
|
|
+
|
|
|
html += """</span>
|
|
|
</div>
|
|
|
<div class="info-item">
|
|
|
<span class="info-label">热点新闻</span>
|
|
|
<span class="info-value">"""
|
|
|
-
|
|
|
+
|
|
|
html += f"{hot_news_count} 条"
|
|
|
|
|
|
html += """</span>
|
|
|
@@ -1799,7 +1895,7 @@ def render_html_content(
|
|
|
<span class="info-value">"""
|
|
|
|
|
|
now = get_beijing_time()
|
|
|
- html += now.strftime('%m-%d %H:%M')
|
|
|
+ html += now.strftime("%m-%d %H:%M")
|
|
|
|
|
|
html += """</span>
|
|
|
</div>
|
|
|
@@ -1823,10 +1919,10 @@ def render_html_content(
|
|
|
# 处理主要统计数据
|
|
|
if report_data["stats"]:
|
|
|
total_count = len(report_data["stats"])
|
|
|
-
|
|
|
+
|
|
|
for i, stat in enumerate(report_data["stats"], 1):
|
|
|
count = stat["count"]
|
|
|
-
|
|
|
+
|
|
|
# 确定热度等级
|
|
|
if count >= 10:
|
|
|
count_class = "hot"
|
|
|
@@ -1836,7 +1932,7 @@ def render_html_content(
|
|
|
count_class = ""
|
|
|
|
|
|
escaped_word = html_escape(stat["word"])
|
|
|
-
|
|
|
+
|
|
|
html += f"""
|
|
|
<div class="word-group">
|
|
|
<div class="word-header">
|
|
|
@@ -1851,62 +1947,68 @@ def render_html_content(
|
|
|
for j, title_data in enumerate(stat["titles"], 1):
|
|
|
is_new = title_data.get("is_new", False)
|
|
|
new_class = "new" if is_new else ""
|
|
|
-
|
|
|
+
|
|
|
html += f"""
|
|
|
<div class="news-item {new_class}">
|
|
|
<div class="news-number">{j}</div>
|
|
|
<div class="news-content">
|
|
|
<div class="news-header">
|
|
|
<span class="source-name">{html_escape(title_data["source_name"])}</span>"""
|
|
|
-
|
|
|
+
|
|
|
# 处理排名显示
|
|
|
ranks = title_data.get("ranks", [])
|
|
|
if ranks:
|
|
|
min_rank = min(ranks)
|
|
|
max_rank = max(ranks)
|
|
|
rank_threshold = title_data.get("rank_threshold", 10)
|
|
|
-
|
|
|
+
|
|
|
# 确定排名等级
|
|
|
if min_rank <= 3:
|
|
|
rank_class = "top"
|
|
|
elif min_rank <= rank_threshold:
|
|
|
- rank_class = "high"
|
|
|
+ rank_class = "high"
|
|
|
else:
|
|
|
rank_class = ""
|
|
|
-
|
|
|
+
|
|
|
if min_rank == max_rank:
|
|
|
rank_text = str(min_rank)
|
|
|
else:
|
|
|
rank_text = f"{min_rank}-{max_rank}"
|
|
|
-
|
|
|
+
|
|
|
html += f'<span class="rank-num {rank_class}">{rank_text}</span>'
|
|
|
-
|
|
|
+
|
|
|
# 处理时间显示
|
|
|
time_display = title_data.get("time_display", "")
|
|
|
if time_display:
|
|
|
# 简化时间显示格式,将波浪线替换为~
|
|
|
- simplified_time = time_display.replace(" ~ ", "~").replace("[", "").replace("]", "")
|
|
|
- html += f'<span class="time-info">{html_escape(simplified_time)}</span>'
|
|
|
-
|
|
|
+ simplified_time = (
|
|
|
+ time_display.replace(" ~ ", "~")
|
|
|
+ .replace("[", "")
|
|
|
+ .replace("]", "")
|
|
|
+ )
|
|
|
+ html += (
|
|
|
+ f'<span class="time-info">{html_escape(simplified_time)}</span>'
|
|
|
+ )
|
|
|
+
|
|
|
# 处理出现次数
|
|
|
count_info = title_data.get("count", 1)
|
|
|
if count_info > 1:
|
|
|
html += f'<span class="count-info">{count_info}次</span>'
|
|
|
-
|
|
|
+
|
|
|
html += """
|
|
|
</div>
|
|
|
<div class="news-title">"""
|
|
|
-
|
|
|
+
|
|
|
# 处理标题和链接
|
|
|
escaped_title = html_escape(title_data["title"])
|
|
|
link_url = title_data.get("mobile_url") or title_data.get("url", "")
|
|
|
-
|
|
|
+
|
|
|
if link_url:
|
|
|
escaped_url = html_escape(link_url)
|
|
|
html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
|
|
|
else:
|
|
|
html += escaped_title
|
|
|
-
|
|
|
+
|
|
|
html += """
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -1924,7 +2026,7 @@ def render_html_content(
|
|
|
for source_data in report_data["new_titles"]:
|
|
|
escaped_source = html_escape(source_data["source_name"])
|
|
|
titles_count = len(source_data["titles"])
|
|
|
-
|
|
|
+
|
|
|
html += f"""
|
|
|
<div class="new-source-group">
|
|
|
<div class="new-source-title">{escaped_source} · {titles_count}条</div>"""
|
|
|
@@ -1932,7 +2034,7 @@ def render_html_content(
|
|
|
# 为新增新闻也添加序号
|
|
|
for idx, title_data in enumerate(source_data["titles"], 1):
|
|
|
ranks = title_data.get("ranks", [])
|
|
|
-
|
|
|
+
|
|
|
# 处理新增新闻的排名显示
|
|
|
rank_class = ""
|
|
|
if ranks:
|
|
|
@@ -1941,7 +2043,7 @@ def render_html_content(
|
|
|
rank_class = "top"
|
|
|
elif min_rank <= title_data.get("rank_threshold", 10):
|
|
|
rank_class = "high"
|
|
|
-
|
|
|
+
|
|
|
if len(ranks) == 1:
|
|
|
rank_text = str(ranks[0])
|
|
|
else:
|
|
|
@@ -1955,17 +2057,17 @@ def render_html_content(
|
|
|
<div class="new-item-rank {rank_class}">{rank_text}</div>
|
|
|
<div class="new-item-content">
|
|
|
<div class="new-item-title">"""
|
|
|
-
|
|
|
+
|
|
|
# 处理新增新闻的链接
|
|
|
escaped_title = html_escape(title_data["title"])
|
|
|
link_url = title_data.get("mobile_url") or title_data.get("url", "")
|
|
|
-
|
|
|
+
|
|
|
if link_url:
|
|
|
escaped_url = html_escape(link_url)
|
|
|
html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
|
|
|
else:
|
|
|
html += escaped_title
|
|
|
-
|
|
|
+
|
|
|
html += """
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -2511,6 +2613,23 @@ def send_to_webhooks(
|
|
|
"""发送数据到多个webhook平台"""
|
|
|
results = {}
|
|
|
|
|
|
+ if CONFIG["SILENT_PUSH"]["ENABLED"]:
|
|
|
+ push_manager = PushRecordManager()
|
|
|
+ time_range_start = CONFIG["SILENT_PUSH"]["TIME_RANGE"]["START"]
|
|
|
+ time_range_end = CONFIG["SILENT_PUSH"]["TIME_RANGE"]["END"]
|
|
|
+
|
|
|
+ if not push_manager.is_in_time_range(time_range_start, time_range_end):
|
|
|
+ now = get_beijing_time()
|
|
|
+ print(f"静默模式:当前时间 {now.strftime('%H:%M')} 不在推送时间范围 {time_range_start}-{time_range_end} 内,跳过推送")
|
|
|
+ return results
|
|
|
+
|
|
|
+ if CONFIG["SILENT_PUSH"]["ONCE_PER_DAY"]:
|
|
|
+ if push_manager.has_pushed_today():
|
|
|
+ print(f"静默模式:今天已推送过,跳过本次推送")
|
|
|
+ return results
|
|
|
+ else:
|
|
|
+ print(f"静默模式:今天首次推送")
|
|
|
+
|
|
|
report_data = prepare_report_data(stats, failed_ids, new_titles, id_to_name, mode)
|
|
|
|
|
|
feishu_url = CONFIG["FEISHU_WEBHOOK_URL"]
|
|
|
@@ -2554,6 +2673,11 @@ def send_to_webhooks(
|
|
|
if not results:
|
|
|
print("未配置任何webhook URL,跳过通知发送")
|
|
|
|
|
|
+ # 如果成功发送了任何通知,且启用了每天只推一次,则记录推送
|
|
|
+ if CONFIG["SILENT_PUSH"]["ENABLED"] and CONFIG["SILENT_PUSH"]["ONCE_PER_DAY"] and any(results.values()):
|
|
|
+ push_manager = PushRecordManager()
|
|
|
+ push_manager.record_push(report_type)
|
|
|
+
|
|
|
return results
|
|
|
|
|
|
|