mirror of
https://github.com/Hxnxe/CyberSentinel-AI.git
synced 2026-10-27 02:50:58 +00:00
chore: publish clean CyberSentinel AI release
This commit is contained in:
1 parent
9f8a3486d1
commit
526b634da2
393 files changed
+9226
-1389179
No files matched your search
@@ -0,0 +1,5 @@
|
||||
"""工具模块导出。"""
|
||||
|
||||
from utils.blacklist_manager import BlacklistManager
|
||||
|
||||
__all__ = ["BlacklistManager"]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 构建。")
|
||||
Reference in new issue
Block a user