chore: publish clean CyberSentinel AI release

This commit is contained in:
ubuntu-master committed 2026-06-07 02:05:48 +08:00
1 parent 9f8a3486d1
commit 526b634da2
393 files changed
+9226 -1389179

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
"""工具模块导出。"""
from utils.blacklist_manager import BlacklistManager
__all__ = ["BlacklistManager"]
+109
View File
@@ -0,0 +1,109 @@
import requests
import re
import html
import time
from datetime import datetime
from utils.logger import get_logger
logger = get_logger('article_fetcher')
class ArticleFetcher:
def __init__(self):
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Cache-Control': 'max-age=0'
}
self.cookies = {
'Hm_lvt_0f7c5559638e1f2b5f9b2b6d8d3d0d9e': '1705457362',
'Hm_lpvt_0f7c5559638e1f2b5f9b2b6d8d3d0d9e': '1705457362'
}
@staticmethod
def _retry_with_backoff(func, max_retries=3, initial_delay=1):
"""使用退避策略的重试装饰器"""
def wrapper(*args, **kwargs):
delay = initial_delay
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_retries - 1:
raise
logger.warning(f"操作失败,{delay}秒后重试: {str(e)}")
time.sleep(delay)
delay *= 2
return wrapper
def get_BruceFeIix_articles(self):
'''获取今日url'''
current_date = datetime.now().strftime("%Y-%m-%d")
base_url = f'https://raw.githubusercontent.com/BruceFeIix/picker/refs/heads/master/archive/daily/{current_date[:4]}/{current_date}.md'
logger.info(f"开始获取BruceFeIix文章,日期: {current_date}")
logger.info(f"请求URL: {base_url}")
@self._retry_with_backoff
def fetch_articles():
response = requests.get(base_url, headers=self.headers)
response.raise_for_status()
matches = re.findall(
r'(?:[\d]+\.\s*)?(?P<title>.*?)(?:\]\(|\s+)?(https://mp\.weixin\.qq\.com/[^\s\)]+)(?:\)|$)',
response.text
)
logger.info(f"找到 {len(matches)} 篇文章")
return [(self._clean_title(title), url.rstrip(')')) for title, url in matches]
try:
articles = fetch_articles()
logger.info(f"成功获取BruceFeIix文章,共 {len(articles)} 篇")
return articles
except Exception as e:
logger.error(f"获取BruceFeIix文章失败: {str(e)}")
return []
def get_doonsec_articles(self):
'''获取洞见文章url'''
logger.info("开始获取洞见文章")
@self._retry_with_backoff
def fetch_articles():
response = requests.get(
'https://wechat.doonsec.com/rss.xml',
headers=self.headers,
cookies=self.cookies
)
response.encoding = 'utf-8'
decoded_string = html.unescape(response.text)
logger.debug(f"响应编码: {response.encoding}")
logger.debug(f"响应内容前100字符: {response.text[:100]}")
matches = re.findall(
r'<item><title>(.*?)</title><link>(https://mp\.weixin\.qq\.com/.*?)</link>',
decoded_string
)
logger.info(f"找到 {len(matches)} 篇文章")
return [(self._clean_title(title), url) for title, url in matches]
try:
articles = fetch_articles()
logger.info(f"成功获取洞见文章,共 {len(articles)} 篇")
return articles
except Exception as e:
logger.error(f"获取洞见文章失败: {str(e)}")
return []
@staticmethod
def _clean_title(title):
"""清理和标准化文章标题"""
# 移除多余的标记和格式
title = re.sub(r'[\[\]【】\(\)]', '', title)
# 移除HTML标签
title = re.sub(r'<[^>]+>', '', title)
# 移除多余的空白字符
title = ' '.join(title.split())
# 移除标题中的链接
title = re.sub(r'https?://\S+', '', title)
return title.strip()
+331
View File
@@ -0,0 +1,331 @@
import json
import os
from datetime import datetime
from utils.logger import get_logger
from utils.blog_manager import BlogManager
import re
logger = get_logger('article_manager')
class ArticleManager:
def __init__(self, analyzer, blog_manager=None):
self.analyzer = analyzer
self.blog_manager = blog_manager or BlogManager()
self.categories = {
"漏洞分析": "🔍",
"安全研究": "🔬",
"威胁情报": "🎯",
"安全工具": "🛠️",
"最佳实践": "📚",
"吃瓜新闻": "🍉",
"其他": "📌"
}
# 分类别名映射
self.category_aliases = {
" 漏洞分析": "漏洞分析", # 处理前导空格
"漏洞利用": "漏洞分析", # 将漏洞利用归类到漏洞分析
}
# 创建必要的目录
self.data_dir = "data/articles"
self.results_dir = "results/daily_articles"
os.makedirs(self.data_dir, exist_ok=True)
os.makedirs(self.results_dir, exist_ok=True)
# 加载已处理的URL和分类文章
self.processed_urls_file = os.path.join(self.data_dir, "processed_urls.json")
self.categorized_articles_file = os.path.join(self.data_dir, "categorized_articles.json")
self.processed_urls = self._load_processed_urls()
self.categorized_articles = self._load_categorized_articles()
def _load_processed_urls(self):
"""加载已处理的URL列表"""
if os.path.exists(self.processed_urls_file):
try:
with open(self.processed_urls_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载已处理URL文件失败: {str(e)}")
return []
return []
def _load_categorized_articles(self):
"""加载已分类的文章"""
if os.path.exists(self.categorized_articles_file):
try:
with open(self.categorized_articles_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载分类文章文件失败: {str(e)}")
return {category: [] for category in self.categories.keys()}
return {category: [] for category in self.categories.keys()}
def _save_processed_urls(self):
"""保存已处理的URL列表"""
try:
with open(self.processed_urls_file, 'w', encoding='utf-8') as f:
json.dump(self.processed_urls, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"保存已处理URL文件失败: {str(e)}")
def _save_categorized_articles(self):
"""保存分类文章"""
try:
with open(self.categorized_articles_file, 'w', encoding='utf-8') as f:
json.dump(self.categorized_articles, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"保存分类文章文件失败: {str(e)}")
def filter_new_articles(self, articles):
"""过滤出新文章"""
new_articles = []
for title, url in articles:
if url not in self.processed_urls:
new_articles.append((title, url))
return new_articles
def process_articles(self, articles):
"""处理新文章"""
if not articles:
return None
# 分析文章
analysis_result = self.analyzer.analyze_article_titles(articles)
if not analysis_result or not analysis_result['filtered_articles']:
return None
# 更新已处理的URL和分类文章
today = datetime.now().strftime('%Y-%m-%d')
try:
# 首先加载现有的分类文章数据
today_articles = {category: [] for category in self.categories.keys()}
# 保留今天已有的文章
for category, articles_list in self.categorized_articles.items():
today_articles[category] = [
article for article in articles_list
if isinstance(article, dict) and article.get('date') == today
]
# 创建URL集合用于去重(包含所有已存在的URL)
existing_urls = set(self.processed_urls)
for category_articles in today_articles.values():
for article in category_articles:
if isinstance(article, dict) and 'url' in article:
existing_urls.add(article['url'])
# 处理新文章
new_articles_added = False
for article in analysis_result['filtered_articles']:
if 'url' not in article:
logger.warning(f"跳过缺少URL的文章: {article.get('title', 'Unknown')}")
continue
# 检查URL是否已经存在
if article['url'] in existing_urls:
logger.info(f"跳过已存在的文章: {article.get('title', article['url'])}")
continue
# 添加到已处理URL集合
existing_urls.add(article['url'])
if article['url'] not in self.processed_urls:
self.processed_urls.append(article['url'])
# 处理分类
category = article.get('category', '其他')
category = self.category_aliases.get(category, category)
if category not in self.categories:
logger.warning(f"未知分类: {category},将归类为'其他'")
category = '其他'
# 创建文章数据
article_data = {
'title': article.get('title', 'Unknown'),
'url': article['url'],
'date': today
}
# 添加新文章到对应分类
today_articles[category].append(article_data)
logger.info(f"添加新文章到{category}分类: {article_data['title']}")
new_articles_added = True
# 更新分类文章数据
# 保留非今天的文章,并添加今天的文章
for category in self.categories.keys():
old_articles = [
article for article in self.categorized_articles.get(category, [])
if isinstance(article, dict) and article.get('date') != today
]
self.categorized_articles[category] = old_articles + today_articles[category]
# 只有在添加了新文章时才保存数据
if new_articles_added:
self._save_processed_urls()
self._save_categorized_articles()
logger.info("保存更新后的文章数据")
else:
logger.info("没有新文章需要添加,跳过保存")
return analysis_result
except Exception as e:
logger.error(f"处理文章时发生错误: {str(e)}", exc_info=True)
return None
def generate_daily_report(self, analysis_result, daily_report_path):
"""生成每日安全资讯报告并发布到博客
Args:
analysis_result (dict): 包含过滤后文章信息的字典
daily_report_path (str): 报告文件的完整路径
"""
# 检查 analysis_result 是否有效
if not analysis_result or not analysis_result.get('filtered_articles'):
logger.warning("没有有效的分析结果,无法生成每日报告")
return
today = datetime.now()
date_str = today.strftime('%Y-%m-%d')
# 使用传入的路径,不再自行构造
# filename = os.path.join(self.results_dir, f"{date_str}.md")
filename = daily_report_path # 使用传入的路径
logger.info(f"准备生成每日报告: {filename}")
# 用于去重的集合
processed_urls = set()
processed_titles = set()
# 读取已有的文章(如果存在)
existing_categorized_articles = {cat: [] for cat in self.categories.keys()}
if os.path.exists(filename):
try:
with open(filename, 'r', encoding='utf-8') as f:
content = f.read()
# 解析已有内容中的文章
for category in self.categories.keys():
pattern = f"### {self.categories[category]} {category}\n\n([^#]*?)(?=\n###|\n## |$)"
matches = re.findall(pattern, content, re.DOTALL)
if matches:
articles_text = matches[0]
article_matches = re.findall(r'\* \[(.*?)\]\((.*?)\)', articles_text)
for title, url in article_matches:
processed_urls.add(url)
processed_titles.add(title)
existing_categorized_articles[category].append({
'title': title,
'url': url,
'date': date_str
})
except Exception as e:
logger.error(f"读取已有文件失败: {str(e)}")
# 按分类整理新文章
for article in analysis_result['filtered_articles']:
# 获取文章信息
title = article.get('title', '无标题')
url = article.get('url', '#')
# 检查URL和标题是否已处理过
if url in processed_urls or title in processed_titles:
logger.info(f"跳过重复文章: {title}")
continue
# 添加到已处理集合
processed_urls.add(url)
processed_titles.add(title)
# 处理分类
category = article.get('category', '其他')
# 处理分类别名
category = self.category_aliases.get(category, category)
# 如果分类不在预定义分类中,归类到"其他"
if category not in self.categories:
logger.warning(f"未知分类: {category},将归类为'其他'")
category = '其他'
# 添加文章到对应分类
existing_categorized_articles[category].append({
'title': title,
'url': url,
'date': date_str
})
# 生成markdown内容
content = f"""
# 安全资讯日报 {date_str}
> 本文由AI自动生成,基于对安全相关仓库、CVE和最新安全研究成果的自动化分析。
>
> 更新时间:{today.strftime('%Y-%m-%d %H:%M:%S')}
<!-- more -->
## 今日资讯
"""
# 添加分类文章
for category, emoji in self.categories.items():
articles = existing_categorized_articles[category]
if articles:
content += f"\n### {emoji} {category}\n\n"
# 按日期倒序排序,处理空值情况
articles.sort(
key=lambda x: x.get('date') or '0000-00-00', # 使用默认值处理空值
reverse=True
)
# 添加文章
for article in articles:
title = article.get('title', '无标题').strip() # 去除标题前后空格
url = article.get('url', '#')
content += f"* [{title}]({url})\n"
# 添加AI分析结果
content += "\n## 安全分析\n"
# 从AI分析目录读取今日的分析结果
ai_analysis_file = os.path.join("results/ai_analysis", f"Ai_{date_str.replace('-', '_')}.md")
if os.path.exists(ai_analysis_file):
try:
with open(ai_analysis_file, 'r', encoding='utf-8') as f:
ai_content = f.read()
# 移除原始文件的标题(如果有)
ai_content = ai_content.replace("# AI 安全分析日报", "").strip()
content += ai_content
except Exception as e:
logger.error(f"读取AI分析文件失败: {str(e)}")
content += "\n> 暂无AI分析结果\n"
else:
content += "\n> 暂无AI分析结果\n"
# 添加页脚
content += """
## 免责声明
本文内容由 AI 自动生成,仅供参考和学习交流。文章中的观点和建议不代表作者立场,使用本文信息需自行承担风险和责任。
"""
# 保存文件
try:
# 确保目录存在
os.makedirs(os.path.dirname(filename), exist_ok=True)
with open(filename, 'w', encoding='utf-8') as f:
f.write(content)
logger.info(f"已生成每日安全资讯报告: {filename}")
# 发布到博客
if self.blog_manager.publish_daily_report(date_str, content):
logger.info(f"已成功发布到博客: {date_str}")
else:
logger.error(f"发布到博客失败: {date_str}")
except Exception as e:
logger.error(f"保存每日报告失败: {str(e)}")
def set_blog_token(self, token):
"""设置博客认证token"""
self.blog_manager.set_token(token)
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
import json
import threading
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from config import BLACKLIST_DIR, BLACKLIST_REPOSITORIES, BLACKLIST_USERS
from utils.logger import get_logger
logger = get_logger("utils.blacklist_manager")
@dataclass
class _DynamicBlacklist:
users: set[str]
repositories: set[str]
user_records: list[dict[str, Any]]
repository_records: list[dict[str, Any]]
class BlacklistManager:
"""统一管理静态和动态黑名单。"""
def __init__(
self,
*,
static_users: list[str] | set[str] | None = None,
static_repositories: list[str] | set[str] | None = None,
base_dir: str | Path = BLACKLIST_DIR,
reload_interval_seconds: int = 3600,
) -> None:
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
self.user_file = self.base_dir / "user_blacklist.json"
self.repository_file = self.base_dir / "repository_blacklist.json"
self.reload_interval_seconds = max(60, int(reload_interval_seconds))
self.static_users = {self._normalize(x) for x in (static_users or BLACKLIST_USERS)}
self.static_repositories = {self._normalize(x) for x in (static_repositories or BLACKLIST_REPOSITORIES)}
self._lock = threading.Lock()
self._last_loaded_at: datetime | None = None
self._user_mtime = 0.0
self._repo_mtime = 0.0
self._dynamic = _DynamicBlacklist(set(), set(), [], [])
self.reload_if_needed(force=True)
@staticmethod
def _normalize(name: str | None) -> str:
if not name:
return ""
return str(name).strip().lower()
def _read_records(self, path: Path) -> list[dict[str, Any]]:
if not path.exists():
path.write_text("[]", encoding="utf-8")
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
except Exception as exc:
logger.warning("读取黑名单文件失败 %s: %s", path, str(exc))
return []
def _write_records(self, path: Path, records: list[dict[str, Any]]) -> None:
path.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
def reload_if_needed(self, *, force: bool = False) -> None:
with self._lock:
now = datetime.now()
should_reload = force
if self._last_loaded_at is None:
should_reload = True
else:
age = (now - self._last_loaded_at).total_seconds()
if age >= self.reload_interval_seconds:
should_reload = True
user_mtime = self.user_file.stat().st_mtime if self.user_file.exists() else 0.0
repo_mtime = self.repository_file.stat().st_mtime if self.repository_file.exists() else 0.0
if user_mtime != self._user_mtime or repo_mtime != self._repo_mtime:
should_reload = True
if not should_reload:
return
user_records = self._read_records(self.user_file)
repo_records = self._read_records(self.repository_file)
users = {self._normalize(item.get("name")) for item in user_records if item.get("name")}
repos = {self._normalize(item.get("name")) for item in repo_records if item.get("name")}
for item in repo_records:
owner = self._normalize(item.get("owner"))
if owner:
users.add(owner)
self._dynamic = _DynamicBlacklist(users, repos, user_records, repo_records)
self._last_loaded_at = now
self._user_mtime = self.user_file.stat().st_mtime if self.user_file.exists() else 0.0
self._repo_mtime = self.repository_file.stat().st_mtime if self.repository_file.exists() else 0.0
def is_blacklisted(self, name: str, entry_type: str) -> bool:
self.reload_if_needed()
normalized = self._normalize(name)
if not normalized:
return False
if entry_type == "user":
return normalized in self.static_users or normalized in self._dynamic.users
if entry_type == "repository":
return normalized in self.static_repositories or normalized in self._dynamic.repositories
raise ValueError("entry_type 必须是 'user' 或 'repository'")
def add_to_blacklist(self, name: str, entry_type: str, reason: str) -> bool:
normalized = self._normalize(name)
if not normalized:
return False
self.reload_if_needed()
if self.is_blacklisted(normalized, entry_type):
return False
with self._lock:
record = {
"name": name.strip(),
"added_at": datetime.now().isoformat(),
"reason": reason.strip() if reason else "无说明",
}
if entry_type == "user":
records = list(self._dynamic.user_records)
records.append(record)
self._write_records(self.user_file, records)
elif entry_type == "repository":
records = list(self._dynamic.repository_records)
if "/" in name:
record["owner"] = name.split("/", 1)[0].strip()
records.append(record)
self._write_records(self.repository_file, records)
else:
raise ValueError("entry_type 必须是 'user' 或 'repository'")
self.reload_if_needed(force=True)
logger.info("已加入黑名单: %s (%s)", name, entry_type)
return True
+261
View File
@@ -0,0 +1,261 @@
import json
import os
import requests
from datetime import datetime
from utils.logger import get_logger
logger = get_logger('blog_manager')
class BlogManager:
def __init__(self, base_url="http://127.0.0.1", token="", data_dir="data/blog"):
self.base_url = base_url.rstrip("/")
self.token = token.strip() if isinstance(token, str) else ""
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Content-Type': 'application/json;charset=UTF-8',
'Accept': 'application/json, text/plain, */*',
'Accept-Charset': 'utf-8',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'token': token
}
# 创建必要的目录
self.data_dir = data_dir
os.makedirs(self.data_dir, exist_ok=True)
# 加载文章ID映射
self.article_map_file = os.path.join(self.data_dir, "article_map.json")
self.article_map = self._load_article_map()
def _has_token(self):
"""Return whether blog publishing is configured."""
return bool(self.token)
def _headers_for_log(self):
"""Return request headers with sensitive values redacted for debug logs."""
safe_headers = self.headers.copy()
if safe_headers.get("token"):
safe_headers["token"] = "<redacted>"
return safe_headers
def _load_article_map(self):
"""加载文章ID映射"""
if os.path.exists(self.article_map_file):
try:
with open(self.article_map_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载文章映射文件失败: {str(e)}")
return {}
return {}
def _save_article_map(self):
"""保存文章ID映射"""
try:
with open(self.article_map_file, 'w', encoding='utf-8') as f:
json.dump(self.article_map, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"保存文章映射文件失败: {str(e)}")
def set_token(self, token):
"""设置认证token"""
self.token = token.strip() if isinstance(token, str) else ""
self.headers['token'] = self.token
def create_article(self, date_str):
"""创建新文章
Args:
date_str: 文章日期,格式为 YYYY-MM-DD
Returns:
int: 文章ID,如果创建失败返回None
"""
try:
if not self._has_token():
logger.info("BLOG_TOKEN 未配置,跳过博客文章创建")
return None
# 检查是否已经存在该日期的文章
if date_str in self.article_map:
logger.info(f"日期 {date_str} 的文章已存在,ID: {self.article_map[date_str]}")
return self.article_map[date_str]
# 准备创建文章的数据
data = {
"title": f"日报[{date_str}]",
"top": 0,
"tags": [],
"category": "安全资讯",
"createdAt": datetime.now().isoformat(),
"private": False,
"hidden": False,
"content": ""
}
# 发送创建请求
response = requests.post(
f"{self.base_url}/api/admin/article",
headers=self.headers,
json=data
)
if response.status_code == 201:
result = response.json()
if result['statusCode'] == 200 and 'data' in result:
article_id = result['data']['id']
# 保存文章ID映射
self.article_map[date_str] = article_id
self._save_article_map()
logger.info(f"成功创建文章,日期: {date_str}, ID: {article_id}")
return article_id
logger.error(f"创建文章失败,状态码: {response.status_code}")
return None
except Exception as e:
logger.error(f"创建文章时出错: {str(e)}")
return None
def update_article(self, article_id, content):
"""更新文章内容
Args:
article_id: 文章ID
content: 文章内容
Returns:
bool: 更新是否成功
"""
try:
if not self._has_token():
logger.info("BLOG_TOKEN 未配置,跳过博客文章更新")
return False
# 添加编码检查日志
logger.debug(f"更新文章内容前100字符: {content[:100]}")
# 记录请求信息
logger.debug(f"准备更新文章,ID: {article_id}")
logger.debug(f"请求头: {self._headers_for_log()}")
logger.debug(f"内容长度: {len(content)}")
try:
# 发送更新请求,移除 Accept-Encoding 头,让服务器返回未压缩的响应
headers = self.headers.copy()
headers.pop('Accept-Encoding', None)
response = requests.put(
f"{self.base_url}/api/admin/article/{article_id}",
headers=headers,
json={"content": content},
timeout=30
)
# 详细记录响应信息
logger.debug(f"更新文章响应状态码: {response.status_code}")
logger.debug(f"响应头: {dict(response.headers)}")
# 检查响应内容类型
content_type = response.headers.get('content-type', '')
logger.debug(f"响应Content-Type: {content_type}")
# 记录响应内容(前200个字符)
response_text = response.text[:200]
logger.debug(f"更新文章响应内容: {response_text}")
if response.status_code == 200:
try:
result = response.json()
if result.get('statusCode') == 200 and result.get('data', {}).get('modifiedCount', 0) > 0:
logger.info(f"成功更新文章,ID: {article_id}")
return True
else:
logger.error(f"更新文章失败,响应数据异常: {result}")
except json.JSONDecodeError as je:
logger.error(f"解析响应JSON失败: {str(je)},响应内容: {response_text}")
return False
logger.error(f"更新文章失败,状态码: {response.status_code},响应: {response_text}")
return False
except requests.RequestException as e:
logger.error(f"发送更新请求时出错: {str(e)}")
return False
except Exception as e:
logger.error(f"更新文章时出错: {str(e)}")
return False
def get_article_id(self, date_str):
"""获取指定日期的文章ID
Args:
date_str: 文章日期,格式为 YYYY-MM-DD
Returns:
int: 文章ID,如果不存在返回None
"""
return self.article_map.get(date_str)
def publish_daily_report(self, date_str, content):
"""发布每日报告
Args:
date_str: 文章日期,格式为 YYYY-MM-DD
content: 文章内容
Returns:
bool: 发布是否成功
"""
try:
# 首先获取或创建文章
article_id = self.get_article_id(date_str)
if article_id is None:
article_id = self.create_article(date_str)
if article_id is None:
return False
# 更新文章内容
return self.update_article(article_id, content)
except Exception as e:
logger.error(f"发布每日报告时出错: {str(e)}")
return False
def update_html_content(self, html_content):
"""更新博客的 HTML 内容"""
try:
if not self._has_token():
logger.info("BLOG_TOKEN 未配置,跳过博客 HTML 更新")
return False
# 准备更新的数据
data = {
"_id": "67b72d183cd136c9b24ab4ea", # 这里需要替换为实际的 ID
"name": "每日资讯",
"path": "/today",
"type": "file",
"html": html_content
}
logger.info("准备发送更新请求到博客")
# 发送更新请求
response = requests.put(
f"{self.base_url}/api/admin/customPage",
headers=self.headers,
json=data
)
logger.info(f"更新请求发送,状态码: {response.status_code}")
if response.status_code == 200:
logger.info("成功更新博客的 HTML 内容")
return True
else:
logger.error(f"更新博客 HTML 内容失败,状态码: {response.status_code}")
return False
except Exception as e:
logger.error(f"更新博客 HTML 内容时出错: {str(e)}")
return False
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
Diagnostic = dict[str, str]
def _status_line(status: str, name: str, message: str) -> str:
return f"[{status}] {name}: {message}"
def build_config_diagnostics(settings: Any) -> list[Diagnostic]:
"""Build a non-sensitive configuration report for new users."""
diagnostics: list[Diagnostic] = []
database_path = Path(str(settings.database_path)).expanduser()
diagnostics.append(
{
"name": "database",
"status": "OK",
"message": f"SQLite database will be stored at {database_path}",
}
)
github_tokens = list(getattr(settings, "github_tokens", []) or [])
if github_tokens:
diagnostics.append(
{
"name": "github",
"status": "OK",
"message": f"{len(github_tokens)} GitHub token(s) configured",
}
)
else:
diagnostics.append(
{
"name": "github",
"status": "WARN",
"message": "GITHUB_TOKENS or GITHUB_TOKEN is not configured; GitHub API rate limits will be low",
}
)
has_primary_ai = bool(str(getattr(settings, "primary_ai_key", "")).strip())
has_gemini = bool(str(getattr(settings, "gemini_ai_key", "")).strip())
has_backup_ai = bool(getattr(settings, "backup_ai_configs", []) or [])
if has_primary_ai or has_gemini or has_backup_ai:
providers = []
if has_primary_ai:
providers.append("primary OpenAI-compatible")
if has_backup_ai:
providers.append("backup OpenAI-compatible")
if has_gemini:
providers.append("Gemini")
diagnostics.append(
{
"name": "ai",
"status": "OK",
"message": "Configured provider(s): " + ", ".join(providers),
}
)
else:
diagnostics.append(
{
"name": "ai",
"status": "WARN",
"message": "No AI provider key configured; monitoring can start, but AI analysis calls will not produce results",
}
)
if str(getattr(settings, "blog_token", "")).strip():
diagnostics.append(
{
"name": "blog",
"status": "OK",
"message": f"Blog publishing enabled for {getattr(settings, 'blog_base_url', 'http://127.0.0.1')}",
}
)
else:
diagnostics.append(
{
"name": "blog",
"status": "SKIP",
"message": "BLOG_TOKEN is empty; blog publishing will be skipped and reports stay local",
}
)
return diagnostics
def format_config_diagnostics(diagnostics: list[Diagnostic]) -> str:
lines = ["CyberSentinel AI configuration check"]
lines.extend(_status_line(item["status"], item["name"], item["message"]) for item in diagnostics)
return "\n".join(lines) + "\n"
+127
View File
@@ -0,0 +1,127 @@
import csv
import os
from datetime import datetime
import hashlib
class SecurityResultWriter:
def __init__(self, output_dir='results'):
self.output_dir = output_dir
self.ensure_output_dir()
self.written_hashes = set() # 用于存储已写入记录的哈希值
def ensure_output_dir(self):
"""确保输出目录存在"""
if not os.path.exists(self.output_dir):
os.makedirs(self.output_dir)
def generate_record_hash(self, data):
"""生成记录的唯一哈希值"""
# 使用关键字段组合生成哈希
key_fields = [
str(data.get('type', '')),
str(data.get('id', '')),
str(data.get('url', '')),
str(data.get('summary', '')),
str(data.get('risk_level', '')),
]
hash_string = '|'.join(key_fields)
return hashlib.md5(hash_string.encode()).hexdigest()
def load_existing_hashes(self, filename):
"""加载已存在文件中的记录哈希值"""
if not os.path.exists(filename):
return
with open(filename, 'r', newline='', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
hash_value = self.generate_record_hash(row)
self.written_hashes.add(hash_value)
def write_to_csv(self, result, source_type):
"""将分析结果写入CSV"""
current_date = datetime.now().strftime('%Y-%m-%d')
filename = os.path.join(self.output_dir, f'security_analysis_{current_date}.csv')
# 统一的字段列表
fields = [
'timestamp',
'type', # 'CVE' 或 'Repository'
'id', # CVE ID 或仓库名称
'url', # 仓库URL或CVE URL
'summary', # 概述
'risk_level', # 风险等级
'status', # CVE的exploit_status或仓库的security_type
'key_points', # 关键点
'recommendation', # AI建议
'value_reason', # 价值原因
'description', # 详细描述
'is_valuable', # 是否高价值
'technical_details', # 技术细节
'affected_components', # 受影响组件
'exploit_status', # 利用状态
'security_type', # 安全类型
'update_type', # 更新类型
'commits_analyzed', # 分析的提交数
'files_changed', # 变更文件数
'analysis_confidence', # AI分析置信度
'mitigation_steps', # 缓解步骤
'impact_scope', # 影响范围
'detection_methods', # 检测方法
'references', # 相关引用
'tags', # 标签
'last_updated' # 最后更新时间
]
# 准备数据
data = {
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'type': 'CVE' if source_type == 'cve' else 'Repository',
'id': result.get('cve_id') if source_type == 'cve' else result.get('repository_name'),
'url': result.get('repository_url', ''),
'summary': result.get('summary', ''),
'risk_level': result.get('severity') if source_type == 'cve' else result.get('risk_level', ''),
'status': result.get('exploit_status') if source_type == 'cve' else result.get('security_type', ''),
'key_points': ' | '.join(result.get('key_points', [])),
'recommendation': result.get('recommendation', ''),
'value_reason': result.get('value_reason', ''),
'description': result.get('description', ''),
'is_valuable': str(result.get('is_valuable', False)),
'technical_details': ' | '.join(result.get('technical_details', [])),
'affected_components': ' | '.join(result.get('affected_components', [])),
'exploit_status': result.get('exploit_status', ''),
'security_type': result.get('security_type', ''),
'update_type': result.get('update_type', ''),
'commits_analyzed': result.get('commits_analyzed', 0),
'files_changed': result.get('files_changed', 0),
'analysis_confidence': result.get('analysis_confidence', ''),
'mitigation_steps': ' | '.join(result.get('mitigation_steps', [])),
'impact_scope': result.get('impact_scope', ''),
'detection_methods': ' | '.join(result.get('detection_methods', [])),
'references': ' | '.join(result.get('references', [])),
'tags': ' | '.join(result.get('tags', [])),
'last_updated': result.get('last_updated', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
}
# 生成记录哈希
record_hash = self.generate_record_hash(data)
# 如果是新文件,加载已存在的哈希值
if not self.written_hashes:
self.load_existing_hashes(filename)
# 检查是否重复
if record_hash in self.written_hashes:
return False
# 写入CSV
file_exists = os.path.exists(filename)
with open(filename, 'a', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fields)
if not file_exists:
writer.writeheader()
writer.writerow(data)
# 添加新记录的哈希值
self.written_hashes.add(record_hash)
return True
+984
View File
@@ -0,0 +1,984 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width initial-scale=1'>
<title>{title}</title>
<style>
:root {
/* 主色调 */
--primary-color: #6366f1;
--primary-light: #818cf8;
--primary-dark: #4f46e5;
/* 背景色系 */
--bg-color: #f8fafc;
--card-bg: #ffffff;
--modal-bg: rgba(255, 255, 255, 0.9);
/* 文字色系 */
--text-color: #1e293b;
--text-light: #64748b;
--text-muted: #94a3b8;
/* 状态色系 */
--critical-color: #ef4444;
--high-color: #f97316;
--medium-color: #eab308;
--low-color: #22c55e;
--info-color: #3b82f6;
/* 交互色系 */
--hover-color: #f1f5f9;
--border-color: #e2e8f0;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.03);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.05);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.05);
--shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.05);
/* 动画 */
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
/* 布局 */
--max-width: 1400px;
--header-height: 70px;
--spacing: 24px;
--radius: 12px;
--radius-lg: 16px;
}
@media (prefers-color-scheme: dark) {
:root {
/* 暗色模式背景色系 */
--bg-color: #0f172a;
--card-bg: #1e293b;
--modal-bg: rgba(30, 41, 59, 0.9);
/* 暗色模式文字色系 */
--text-color: #f1f5f9;
--text-light: #cbd5e1;
--text-muted: #94a3b8;
/* 暗色模式边框和交互 */
--hover-color: #334155;
--border-color: #334155;
/* 暗色模式阴影 */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1);
--shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.1);
}
.risk-critical {
background: rgba(239, 68, 68, 0.2);
}
.risk-high {
background: rgba(249, 115, 22, 0.2);
}
.risk-medium {
background: rgba(234, 179, 8, 0.2);
}
.risk-low {
background: rgba(34, 197, 94, 0.2);
}
.risk-info {
background: rgba(59, 130, 246, 0.2);
}
.loading {
background: linear-gradient(to right, var(--card-bg) 4%, var(--hover-color) 25%, var(--card-bg) 36%);
}
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-color);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-light);
}
/* 模态框滚动优化 */
.modal-content {
max-height: 90vh;
overflow-y: auto;
overflow-x: hidden;
}
/* 键盘快捷键提示 */
.keyboard-shortcut {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--card-bg);
padding: 12px;
border-radius: var(--radius);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg);
font-size: 0.9rem;
color: var(--text-light);
display: none;
z-index: 1000;
}
.keyboard-shortcut.show {
display: block;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 移动端优化 */
@media (max-width: 768px) {
.modal-content {
margin: 0;
height: 100vh;
max-height: 100vh;
border-radius: 0;
}
.close-btn {
top: 16px;
right: 16px;
}
.keyboard-shortcut {
display: none !important;
}
.filter-group {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 8px;
}
.filter-btn {
white-space: nowrap;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card-bg);
height: var(--header-height);
box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border-color);
}
.header-content {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--spacing);
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo-section {
display: flex;
align-items: center;
gap: 12px;
}
.logo-section h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--primary-color);
display: flex;
align-items: center;
gap: 8px;
}
.logo-section .emoji {
font-size: 1.8rem;
}
.search-bar {
flex: 1;
max-width: 500px;
margin: 0 var(--spacing);
position: relative;
}
.search-input {
width: 100%;
padding: 10px 16px;
padding-left: 40px;
border: 1px solid var(--border-color);
border-radius: 30px;
background: var(--bg-color);
font-size: 0.95rem;
color: var(--text-color);
transition: var(--transition);
}
.search-input:focus {
outline: none;
border-color: var(--primary-light);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-light);
pointer-events: none;
}
.stats-bar {
background: var(--card-bg);
margin: var(--spacing) auto;
max-width: var(--max-width);
padding: var(--spacing);
border-radius: var(--radius);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing);
}
.stat-card {
padding: 20px;
background: var(--bg-color);
border-radius: var(--radius);
border: 1px solid var(--border-color);
transition: var(--transition);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.stat-title {
font-size: 0.9rem;
color: var(--text-light);
margin-bottom: 8px;
}
.stat-value {
font-size: 1.8rem;
font-weight: 600;
color: var(--primary-color);
}
.filter-section {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing);
display: flex;
align-items: center;
gap: var(--spacing);
flex-wrap: wrap;
}
.filter-group {
display: flex;
gap: 8px;
}
.filter-btn {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 20px;
background: var(--card-bg);
color: var(--text-color);
font-size: 0.9rem;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
gap: 6px;
}
.filter-btn:hover {
background: var(--hover-color);
}
.filter-btn.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.filter-btn .count {
background: var(--bg-color);
color: var(--text-light);
padding: 2px 6px;
border-radius: 10px;
font-size: 0.8rem;
}
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--spacing);
flex: 1;
}
.list-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--spacing);
margin: var(--spacing) 0;
}
.cve-item {
background: var(--card-bg);
border-radius: var(--radius);
border: 1px solid var(--border-color);
overflow: hidden;
transition: var(--transition);
cursor: pointer;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.cve-item:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--primary-light);
}
.cve-info {
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.cve-icon {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
background: var(--bg-color);
color: var(--primary-color);
transition: var(--transition);
}
.cve-item:hover .cve-icon {
transform: scale(1.1);
background: var(--primary-color);
color: white;
}
.cve-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
padding-right: 40px;
}
.cve-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.risk-critical, .risk-high, .risk-medium, .risk-low, .risk-info {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
}
.risk-critical {
background: rgba(239, 68, 68, 0.1);
color: var(--critical-color);
}
.risk-critical::before {
content: "⚠️";
font-size: 0.9em;
}
.risk-high {
background: rgba(249, 115, 22, 0.1);
color: var(--high-color);
}
.risk-high::before {
content: "🔥";
font-size: 0.9em;
}
.risk-medium {
background: rgba(234, 179, 8, 0.1);
color: var(--medium-color);
}
.risk-medium::before {
content: "⚡";
font-size: 0.9em;
}
.risk-low {
background: rgba(34, 197, 94, 0.1);
color: var(--low-color);
}
.risk-low::before {
content: "✓";
font-size: 0.9em;
}
.risk-info {
background: rgba(59, 130, 246, 0.1);
color: var(--info-color);
}
.risk-info::before {
content: "ℹ️";
font-size: 0.9em;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
display: none;
opacity: 0;
transition: var(--transition);
z-index: 1000;
}
.modal-content {
background: var(--modal-bg);
backdrop-filter: blur(16px);
margin: 40px auto;
padding: 32px;
max-width: 900px;
width: 90%;
border-radius: var(--radius-lg);
position: relative;
box-shadow: var(--shadow-xl);
}
.close-btn {
position: absolute;
right: 24px;
top: 24px;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-light);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition);
}
.close-btn:hover {
background: var(--critical-color);
color: white;
transform: rotate(90deg);
}
.modal h2 {
font-size: 1.5rem;
color: var(--text-color);
margin-bottom: 24px;
padding-right: 40px;
}
.detail-section {
background: var(--card-bg);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 24px;
}
.detail-section h3 {
font-size: 1.1rem;
color: var(--primary-color);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.table-container {
border: 1px solid var(--border-color);
border-radius: var(--radius);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--bg-color);
font-weight: 500;
color: var(--text-light);
font-size: 0.9rem;
}
td {
font-size: 0.95rem;
}
tr:last-child td {
border-bottom: none;
}
.footer {
background: var(--card-bg);
padding: var(--spacing);
text-align: center;
color: var(--text-light);
font-size: 0.9rem;
border-top: 1px solid var(--border-color);
margin-top: auto;
}
@media (max-width: 768px) {
:root {
--spacing: 16px;
}
.header-content {
flex-direction: column;
padding: 12px var(--spacing);
height: auto;
}
.search-bar {
width: 100%;
max-width: none;
margin: 12px 0;
}
.filter-section {
flex-direction: column;
align-items: stretch;
}
.list-container {
grid-template-columns: 1fr;
}
.modal-content {
padding: 20px;
margin: 20px;
}
}
/* 加载动画 */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.loading {
animation: shimmer 2s infinite linear;
background: linear-gradient(to right, var(--bg-color) 4%, var(--hover-color) 25%, var(--bg-color) 36%);
background-size: 1000px 100%;
border-radius: var(--radius);
}
.loading-card {
height: 160px;
margin-bottom: var(--spacing);
}
</style>
</head>
<body>
<header class="header">
<div class="header-content">
<div class="logo-section">
<h1><span class="emoji">🛡️</span>{title}</h1>
</div>
<div class="search-bar">
<input type="text" class="search-input" placeholder="搜索 CVE ID、关键词、组件...">
<span class="search-icon">🔍</span>
</div>
</div>
</header>
<div class="stats-bar">
{stats}
</div>
<div class="filter-section">
{filters}
</div>
<div class="container">
<div class="list-container">
{content}
</div>
</div>
<div class="keyboard-shortcut">
<div>快捷键提示:</div>
<div>/ - 搜索</div>
<div>ESC - 关闭</div>
<div>← → - 切换风险等级</div>
</div>
<footer class="footer">
<p>本平台数据由 AI 自动分析生成,仅供参考。使用本平台信息时请自行承担相关风险。</p>
</footer>
<script>
// 加载状态管理
let isLoading = false;
function showLoading() {
isLoading = true;
const container = document.querySelector('.list-container');
container.innerHTML = '';
for (let i = 0; i < 6; i++) {
container.innerHTML += '<div class="cve-item loading loading-card"></div>';
}
}
function hideLoading() {
isLoading = false;
const loadingCards = document.querySelectorAll('.loading-card');
loadingCards.forEach(card => card.remove());
}
// 监听滚动事件
window.addEventListener('scroll', function() {
const header = document.querySelector('.header');
if (window.scrollY > 20) {
header.style.background = 'rgba(255, 255, 255, 0.9)';
} else {
header.style.background = 'var(--card-bg)';
}
});
// 模态框功能
function showModal(id) {
const modal = document.getElementById(id);
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => {
modal.style.opacity = '1';
});
}
function closeModal(id) {
const modal = document.getElementById(id);
modal.style.opacity = '0';
setTimeout(() => {
modal.style.display = 'none';
document.body.style.overflow = 'auto';
}, 300);
}
// 点击空白处关闭模态框
window.onclick = function(event) {
if (event.target.classList.contains('modal')) {
const modal = event.target;
closeModal(modal.id);
}
}
// 搜索功能增强
const searchInput = document.querySelector('.search-input');
searchInput.addEventListener('input', debounce(async function(e) {
const searchTerm = e.target.value.toLowerCase();
if (searchTerm.length < 2) return;
showLoading();
const items = document.querySelectorAll('.cve-item:not(.loading-card)');
items.forEach(item => {
const title = item.querySelector('.cve-title').textContent.toLowerCase();
const meta = item.querySelector('.cve-meta').textContent.toLowerCase();
const shouldShow = title.includes(searchTerm) || meta.includes(searchTerm);
if (shouldShow) {
item.style.display = 'flex';
item.style.opacity = '0';
item.style.transform = 'translateY(20px)';
requestAnimationFrame(() => {
item.style.opacity = '1';
item.style.transform = 'translateY(0)';
});
} else {
item.style.display = 'none';
}
});
setTimeout(hideLoading, 500);
}, 300));
// 防抖函数
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 筛选功能增强
document.querySelectorAll('.filter-btn').forEach(button => {
button.addEventListener('click', () => {
if (isLoading) return;
showLoading();
// 更新按钮状态
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
button.classList.add('active');
const selectedRisk = button.getAttribute('data-risk');
const items = document.querySelectorAll('.cve-item:not(.loading-card)');
items.forEach(item => {
const riskSpan = item.querySelector('[class^="risk-"]');
const risk = riskSpan ? riskSpan.textContent : '';
if (selectedRisk === 'ALL' || risk === selectedRisk) {
item.style.display = 'flex';
item.style.opacity = '0';
item.style.transform = 'translateY(20px)';
requestAnimationFrame(() => {
item.style.opacity = '1';
item.style.transform = 'translateY(0)';
});
} else {
item.style.display = 'none';
}
});
setTimeout(hideLoading, 500);
});
});
// 初始化动画优化
document.addEventListener('DOMContentLoaded', () => {
showLoading();
const items = document.querySelectorAll('.cve-item:not(.loading-card)');
items.forEach((item, index) => {
item.style.opacity = '0';
item.style.transform = 'translateY(20px)';
setTimeout(() => {
item.style.opacity = '1';
item.style.transform = 'translateY(0)';
}, index * 100);
});
setTimeout(hideLoading, 1000);
});
// 键盘快捷键支持
document.addEventListener('keydown', function(e) {
// 搜索框快捷键
if (e.key === '/' && !e.target.matches('input, textarea')) {
e.preventDefault();
searchInput.focus();
showKeyboardShortcut('按 ESC 退出搜索');
}
// ESC 键处理
if (e.key === 'Escape') {
const activeModal = document.querySelector('.modal[style*="display: flex"]');
if (activeModal) {
closeModal(activeModal.id);
} else if (document.activeElement === searchInput) {
searchInput.blur();
searchInput.value = '';
const event = new Event('input');
searchInput.dispatchEvent(event);
}
}
// 左右方向键切换风险等级
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.target.matches('input, textarea')) {
const buttons = Array.from(document.querySelectorAll('.filter-btn'));
const activeIndex = buttons.findIndex(btn => btn.classList.contains('active'));
let newIndex;
if (e.key === 'ArrowLeft') {
newIndex = activeIndex > 0 ? activeIndex - 1 : buttons.length - 1;
} else {
newIndex = activeIndex < buttons.length - 1 ? activeIndex + 1 : 0;
}
buttons[newIndex].click();
showKeyboardShortcut(`已切换到: ${buttons[newIndex].textContent.trim()}`);
}
});
// 快捷键提示
function showKeyboardShortcut(message) {
const shortcut = document.querySelector('.keyboard-shortcut');
if (message) {
shortcut.textContent = message;
}
shortcut.classList.add('show');
setTimeout(() => {
shortcut.classList.remove('show');
}, 2000);
}
// 搜索框焦点处理
searchInput.addEventListener('focus', () => {
showKeyboardShortcut('按 ESC 退出搜索');
});
// 模态框滚动优化
let currentModal = null;
let scrollPosition = 0;
function showModal(id) {
currentModal = document.getElementById(id);
scrollPosition = window.pageYOffset;
currentModal.style.display = 'flex';
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollPosition}px`;
document.body.style.width = '100%';
requestAnimationFrame(() => {
currentModal.style.opacity = '1';
});
}
function closeModal(id) {
const modal = document.getElementById(id);
modal.style.opacity = '0';
setTimeout(() => {
modal.style.display = 'none';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, scrollPosition);
currentModal = null;
}, 300);
}
// 触摸滑动支持
let touchStartY = 0;
let touchEndY = 0;
document.addEventListener('touchstart', e => {
touchStartY = e.touches[0].clientY;
}, false);
document.addEventListener('touchmove', e => {
if (!currentModal) return;
touchEndY = e.touches[0].clientY;
const modalContent = currentModal.querySelector('.modal-content');
const isAtTop = modalContent.scrollTop === 0;
const isAtBottom = modalContent.scrollHeight - modalContent.scrollTop === modalContent.clientHeight;
if ((isAtTop && touchEndY > touchStartY) || (isAtBottom && touchEndY < touchStartY)) {
e.preventDefault();
}
}, { passive: false });
document.addEventListener('touchend', e => {
if (!currentModal) return;
const diff = touchStartY - touchEndY;
const modalContent = currentModal.querySelector('.modal-content');
const isAtTop = modalContent.scrollTop === 0;
if (isAtTop && diff < -100) {
closeModal(currentModal.id);
}
}, false);
</script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
import logging
import os
from logging.handlers import RotatingFileHandler
def get_logger(name):
"""获取日志记录器"""
logger = logging.getLogger(name)
# 如果已经有处理器,直接返回
if logger.handlers:
return logger
logger.setLevel(logging.INFO)
# 创建日志目录
log_dir = 'logs'
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# 文件处理器
log_file = os.path.join(log_dir, f'{name}.log')
file_handler = RotatingFileHandler(
log_file,
maxBytes=10*1024*1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setLevel(logging.INFO)
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
# 设置日志格式
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
# 添加处理器
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger
File diff suppressed because it is too large. Load diff
+272
View File
@@ -0,0 +1,272 @@
import os
import re
from datetime import datetime
import shutil
import subprocess # <-- Import subprocess
# 修改相对导入为绝对导入
try:
from utils.logger import get_logger
except ImportError:
# 备用方案 - 如果作为模块导入时
try:
from .logger import get_logger
except ImportError:
# 如果logger模块不存在,创建一个简单的日志函数
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
def get_logger(name):
return logging.getLogger(name)
# Get logger for this module
logger = get_logger('vuepress_updater_simple') # 使用新的日志记录器名称
# --- Configuration ---
# Use absolute path for the blog's docs directory
# Adjust this path if your blog directory structure is different
BLOG_DOCS_PATH = os.path.normpath('/home/p/new/docs')
# Define Blog Root Path for running build commands
BLOG_ROOT_PATH = os.path.normpath('/home/p/new/')
# Derive other paths from the absolute BLOG_DOCS_PATH
NOTES_TS_PATH = os.path.join(BLOG_DOCS_PATH, '.vuepress', 'notes.ts')
DEMO_NOTES_DIR = os.path.join(BLOG_DOCS_PATH, 'notes', 'demo')
DEMO_README_PATH = os.path.join(DEMO_NOTES_DIR, 'README.md')
# --- End Configuration ---
def _add_frontmatter(filepath, date_str, create_time_str):
"""为 Markdown 文件添加 Frontmatter (保留此功能)"""
try:
frontmatter = f"""---
title: AI 安全分析日报 {date_str}
createTime: {create_time_str}
permalink: /notes/demo/{date_str}/
---
"""
with open(filepath, 'r+', encoding='utf-8') as f:
content = f.read()
f.seek(0, 0)
if not content.startswith('---'):
f.write(frontmatter + content)
logger.info(f"为文件 {filepath} 添加了前置元数据")
else:
logger.info(f"文件 {filepath} 已存在前置元数据,跳过")
return True
except Exception as e:
logger.error(f"为文件 {filepath} 添加前置元数据时出错: {e}")
return False
def copy_and_prepare_md(source_md_path, date_str):
"""复制源 Markdown 文件到博客目录,重命名并添加 Frontmatter。"""
try:
if not os.path.exists(source_md_path):
logger.error(f"源 Markdown 文件未找到: {source_md_path}")
return None
target_filename = f"{date_str}.md"
target_filepath = os.path.join(DEMO_NOTES_DIR, target_filename)
os.makedirs(DEMO_NOTES_DIR, exist_ok=True)
if os.path.exists(target_filepath):
logger.info(f"目标文件已存在,先删除: {target_filepath}")
os.remove(target_filepath)
shutil.copy2(source_md_path, target_filepath)
logger.info(f"已复制 {source_md_path} 到 {target_filepath}")
now = datetime.now()
create_time_str = now.strftime('%Y/%m/%d %H:%M:%S')
if _add_frontmatter(target_filepath, date_str, create_time_str):
return target_filepath
else:
if os.path.exists(target_filepath):
os.remove(target_filepath)
return None
except Exception as e:
logger.error(f"复制/准备 Markdown 文件 {source_md_path} 时出错: {e}")
return None
def update_readme_md(date_str):
"""更新 demo README.md 文件以包含指向新笔记的链接 (保留此功能)
**覆盖更新逻辑**:如果链接已存在,则移除旧链接,并将新链接添加到列表顶部。
"""
try:
if not os.path.exists(DEMO_README_PATH):
# 如果 README 不存在,尝试创建它或记录错误
logger.warning(f"README.md 文件未找到: {DEMO_README_PATH}。将尝试创建。")
try:
with open(DEMO_README_PATH, 'w', encoding='utf-8') as f:
f.write("""---
title: 安全资讯
createTime: 2025/03/28 00:07:14
permalink: /demo/
---
""") # 创建基础 README
logger.info(f"已创建空的 README.md: {DEMO_README_PATH}")
except Exception as create_e:
logger.error(f"创建 README.md 文件失败: {create_e}")
return False
new_link = f"- [{date_str}](./{date_str}.md)\n"
link_pattern = f"./{date_str}.md"
with open(DEMO_README_PATH, 'r', encoding='utf-8') as f:
lines = f.readlines()
list_start_index = -1
found_list_or_content = False
for i, line in enumerate(lines):
stripped_line = line.strip()
if stripped_line.startswith('- ['):
list_start_index = i
found_list_or_content = True
break
if stripped_line == '---' and i + 1 < len(lines) and lines[i+1].strip() == '' and not found_list_or_content:
list_start_index = i + 2
found_list_or_content = True
readme_updated = False
# --- 修改部分:实现覆盖更新 ---
# 先移除所有匹配当天日期的旧链接
initial_length = len(lines)
lines = [line for line in lines if link_pattern not in line]
removed_count = initial_length - len(lines)
if removed_count > 0:
logger.info(f"移除了 {removed_count} 个指向 '{date_str}' 的旧链接。")
readme_updated = True # 标记为需要更新
# 因为移除了行,需要重新计算插入点
# 这里简单处理:如果原来有列表,就在列表开头插入;否则在末尾插入
list_start_index = -1 # 重置
for i, line in enumerate(lines):
if line.strip().startswith('- ['):
list_start_index = i
break
if list_start_index == -1: # 如果移除后没列表了,尝试找 Frontmatter 后的空行
for i, line in enumerate(lines):
if line.strip() == '---' and i + 1 < len(lines) and lines[i+1].strip() == '':
list_start_index = i + 2
break
# 现在执行插入逻辑
# 如果找到了插入点(列表开头或 Frontmatter 后)
if list_start_index != -1:
# 确保插入点有效
list_start_index = max(0, list_start_index)
list_start_index = min(len(lines), list_start_index)
# 插入新链接
lines.insert(list_start_index, new_link)
logger.info(f"已将 '{date_str}' 的新链接插入到列表顶部 (或 Frontmatter 后)。")
readme_updated = True # 再次确认需要更新
# 如果没找到合适的插入点(例如文件是空的或格式不规范)
else:
logger.warning(f"在 {DEMO_README_PATH} 中未找到列表或 Frontmatter 后的插入点,将附加到文件末尾。")
if lines and not lines[-1].endswith('\n'):
lines[-1] += '\n'
if lines and lines[-1].strip() != '':
lines.append('\n')
lines.append(new_link)
readme_updated = True
# --- 结束修改部分 ---
if readme_updated:
with open(DEMO_README_PATH, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"成功更新 {DEMO_README_PATH} (覆盖模式)")
else:
# 如果没有移除旧链接,也没有找到插入点(理论上不应发生)
# 或者如果移除了但无法插入(理论上也不应发生)
# 但保留此日志以防万一
logger.info(f"{DEMO_README_PATH} 未执行更新。")
return True
except Exception as e:
logger.error(f"更新 {DEMO_README_PATH} 时出错: {e}")
return False
def update_blog_content(source_md_path):
"""简化版:仅复制 Markdown 文件并更新 README.md。"""
logger.info(f"开始简化博客内容更新流程: {source_md_path}")
try:
filename = os.path.basename(source_md_path)
match = re.search(r'(\d{4})_(\d{2})_(\d{2})', filename)
if not match:
logger.error(f"无法从文件名中提取日期: {filename}")
return False
year, month, day = match.groups()
date_str = f"{year}-{month}-{day}"
# 1. 复制和准备 Markdown 文件(包括添加 Frontmatter)
target_md_path = copy_and_prepare_md(source_md_path, date_str)
if not target_md_path:
logger.error("处理 Markdown 文件失败。中止更新。")
return False
logger.info(f"已准备博客文章: {target_md_path}")
# 2. 更新 README.md
if not update_readme_md(date_str):
logger.warning("更新 README.md 失败,但内容已复制。")
# 根据需要决定这是否算作整体失败
# return False
logger.info(f"成功完成 {date_str} 的博客内容更新(仅文件和 README)")
return True
except Exception as e:
logger.error(f"为 {source_md_path} 更新博客内容时出错: {e}")
return False
# 主执行块修改为调用简化版函数
if __name__ == '__main__':
print("测试简化版 VuePress 更新器 (仅内容)...")
# --- 测试设置 ---
# 保持测试文件的创建逻辑
test_source_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'results', 'ai_analysis'))
test_source_file = os.path.join(test_source_dir, f"Ai_{datetime.now().strftime('%Y_%m_%d')}.md")
os.makedirs(test_source_dir, exist_ok=True)
if not os.path.exists(test_source_file):
with open(test_source_file, 'w', encoding='utf-8') as f:
f.write("# Test Analysis Content\n\n这是简化流程的测试内容。")
print(f"创建了测试源文件: {test_source_file}")
# 确保目标目录和 README 存在,用于测试
os.makedirs(DEMO_NOTES_DIR, exist_ok=True)
if not os.path.exists(DEMO_README_PATH):
with open(DEMO_README_PATH, 'w', encoding='utf-8') as f:
# 使用三引号定义多行字符串
f.write("""---
title: 安全资讯
createTime: 2025/03/28 00:07:14
permalink: /demo/
---
- [2025-03-23](./2025-03-23.md)
""") # 模拟一个旧的 README
print(f"创建了测试 {DEMO_README_PATH}")
# --- 结束测试设置 ---
# 运行简化版更新
success = update_blog_content(test_source_file)
print(f"测试内容更新成功: {success}")
# 可以选择性地再次运行以测试幂等性
print("\n再次运行以测试幂等性...")
success_idem = update_blog_content(test_source_file)
print(f"第二次内容更新成功 (幂等性测试): {success_idem}")
print("\n检查以下文件是否按预期更新:")
print(f"- {DEMO_NOTES_DIR}/{datetime.now().strftime('%Y-%m-%d')}.md")
print(f"- {DEMO_README_PATH}")
print("注意:此脚本不再执行 VuePress 构建。")