Files

547 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据存储模块 - JSON文件读写"""
import json
import re
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional
from src.config import get_timezone
from src.markdown_utils import dump_frontmatter, parse_frontmatter
def get_fetch_file(d: date = None, data_dir: str = "news-data") -> str:
"""获取fetch文件路径 (使用配置时区)"""
if d is None:
d = datetime.now(get_timezone()).date()
return f"{data_dir}/fetch-{d.isoformat()}.json"
def get_push_file(push_time: datetime = None, data_dir: str = "news-data") -> str:
"""生成push文件路径"""
if push_time is None:
push_time = datetime.now(get_timezone())
time_str = push_time.strftime("%Y-%m-%d-%H-%M-%S")
return f"{data_dir}/push-{time_str}.md"
def get_notify_file(d: date = None, data_dir: str = "news-data") -> str:
"""获取notify文件路径 (使用配置时区)"""
if d is None:
d = datetime.now(get_timezone()).date()
return f"{data_dir}/notify-{d.isoformat()}.md"
def save_notify_file(
filepath: str,
content: str,
metadata: Dict = None,
):
"""保存即时推送文件(Markdown格式),同一天的内容追加到同一文件"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
notify_time = datetime.now(get_timezone()).isoformat()
if metadata:
frontmatter_dict = metadata.copy()
else:
frontmatter_dict = {"pushTime": notify_time}
frontmatter = dump_frontmatter(frontmatter_dict)
new_content = f"---\n{frontmatter}---\n\n{content}\n\n------\n"
with open(path, "a", encoding="utf-8") as f:
f.write(new_content)
_SECTION_RE_CACHE: Dict[str, re.Pattern] = {}
def _section_re(section: str) -> re.Pattern:
"""获取/缓存 sentinel 正则。section 名做转义,允许字母数字下划线"""
if section not in _SECTION_RE_CACHE:
s = re.escape(section)
pattern = (
rf"<!--\s*SECTION:{s}\s*BEGIN\s*-->(.*?)<!--\s*SECTION:{s}\s*END\s*-->"
)
_SECTION_RE_CACHE[section] = re.compile(pattern, flags=re.DOTALL)
return _SECTION_RE_CACHE[section]
def extract_section(push_md: str, section: str) -> str:
"""从 push 文件内容中切出 <!-- SECTION:{section} BEGIN/END --> 之间的 markdown。
向后兼容:
- 新文件(带 sentinel): 返回 sentinel 边界内的原文(不去边界空行)
- 老文件(无 sentinel) 且 section == 'rss': 返回整个 push_md
- 老文件(无 sentinel) 且 section != 'rss': 返回空字符串
- sentinel 残缺(只有 BEGIN 没有 END): 返回空字符串
"""
match = _section_re(section).search(push_md)
if match:
return match.group(1)
# 老文件兜底:rss 段视为整个 body
has_any_sentinel = "<!-- SECTION:" in push_md
if section == "rss" and not has_any_sentinel:
return push_md
return ""
def load_recent_notify_content(
context_days: int = 3, data_dir: str = "news-data"
) -> str:
"""加载最近 context_days 天 notify 文件正文(去除 frontmatter,仅供 LLM 查重)
notify 文件由多个推送块用 `------` 分隔,每块带各自 frontmatter;这里逐块剥离
frontmatter 后用 `------` 重新拼接,保留事件全文。
"""
data_path = Path(data_dir)
if not data_path.exists():
return ""
tz = get_timezone()
today = datetime.now(tz).date()
blocks: List[str] = []
loaded_files = []
for i in range(context_days):
d = today - timedelta(days=i)
notify_file = data_path / f"notify-{d.isoformat()}.md"
if not notify_file.exists() or notify_file.stat().st_size == 0:
continue
try:
with open(notify_file, "r", encoding="utf-8") as f:
content = f.read()
except Exception:
continue
for block in content.split("------"):
if not block.strip():
continue
_, body = parse_frontmatter(block)
if body:
blocks.append(body)
loaded_files.append(notify_file.name)
if loaded_files:
print(
f" 📂 已加载 {len(loaded_files)} 个 notify 文件: {', '.join(loaded_files)}"
)
return "\n\n------\n\n".join(blocks)
def load_recent_push_content(
context_days: int = 3, data_dir: str = "news-data", section: str = "rss"
) -> str:
"""加载最近 context_days 天 push 文件中指定 section 的正文(去除 frontmatter,仅供 LLM 查重)。
Args:
section: sentinel 段名,默认 "rss"。老文件(无 sentinel) 且 section == "rss"
时会兜底返回整个 body(由 extract_section 处理),其它 section 在
老文件上返回空。
"""
data_path = Path(data_dir)
if not data_path.exists():
return ""
tz = get_timezone()
today = datetime.now(tz).date()
bodies: List[str] = []
loaded_files = []
for i in range(context_days):
d = today - timedelta(days=i)
pattern = f"push-{d.isoformat()}-*.md"
for push_file in sorted(data_path.glob(pattern)):
if push_file.stat().st_size == 0:
continue
try:
with open(push_file, "r", encoding="utf-8") as f:
content = f.read()
except Exception:
continue
section_md = extract_section(content, section)
if not section_md:
continue
# 老文件兜底路径会把整篇文件还回来,此时仍需剥离 frontmatter;
# 新文件 sentinel 内不含 frontmatter,parse_frontmatter 会原样返回。
_, body = parse_frontmatter(section_md)
body = body or section_md
body = body.strip()
if body:
bodies.append(body)
loaded_files.append(push_file.name)
if loaded_files:
print(
f" 📂 已加载 {len(loaded_files)} 个 push 文件 (section={section}): "
f"{', '.join(loaded_files)}"
)
return "\n\n------\n\n".join(bodies)
def get_last_push_file(data_dir: str = "news-data") -> Optional[str]:
"""从news-data目录找到最新的push文件"""
data_path = Path(data_dir)
if not data_path.exists():
return None
push_files = sorted(data_path.glob("push-*.md"))
return str(push_files[-1]) if push_files else None
def extract_push_time(filepath: str) -> Optional[datetime]:
"""从push文件名提取时间"""
try:
basename = Path(filepath).name
time_str = basename.replace("push-", "").replace(".md", "")
dt = datetime.strptime(time_str, "%Y-%m-%d-%H-%M-%S")
return dt.replace(tzinfo=get_timezone())
except (ValueError, AttributeError):
return None
def read_entries(filepath: str) -> List[Dict]:
"""读取fetch文件,返回entries列表"""
path = Path(filepath)
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("entries", [])
def read_fetch_data(filepath: str) -> Dict:
"""读取完整的fetch文件数据(包含meta和entries"""
path = Path(filepath)
if not path.exists():
return {"meta": {}, "entries": []}
# 检查文件是否为空
if path.stat().st_size == 0:
return {"meta": {}, "entries": []}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_fetch_file(filepath: str, meta: Dict, entries: List[Dict]):
"""保存fetch文件(JSON格式)"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
data = {"meta": meta, "entries": entries}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def append_entries(filepath: str, new_entries: List[Dict], meta: Dict = None):
"""追加条目到fetch文件"""
path = Path(filepath)
# 读取现有数据
if path.exists():
data = read_fetch_data(filepath)
else:
data = {"meta": meta or {}, "entries": []}
# 更新meta(如果提供了)
if meta:
data["meta"].update(meta)
# 去重:基于link字段
existing_links = {e.get("link") for e in data["entries"]}
for entry in new_entries:
if entry.get("link") not in existing_links:
data["entries"].append(entry)
existing_links.add(entry.get("link"))
# 保存
save_fetch_file(filepath, data["meta"], data["entries"])
return len(new_entries)
def format_entry(entry: Dict) -> str:
"""格式化单条条目为Markdown字符串"""
tags = entry.get("tags", [])
tags_str = json.dumps(tags, ensure_ascii=False) if tags else "[]"
score = entry.get("score", "")
summary = entry.get("summary", "")
return f"""## {entry["title"]}
---
source: {entry["source"]}
link: {entry["link"]}
published: {entry["published"]}
fetched_at: {entry["fetched_at"]}
tags: {tags_str}
score: {score}
summary: {summary}
---
{entry["content"]}
------
"""
def json_to_md(data: Dict) -> str:
"""
将JSON格式的fetch数据转换为Markdown格式,便于阅读
Args:
data: {"meta": {...}, "entries": [...]}
Returns:
Markdown格式的字符串
"""
meta = data.get("meta", {})
entries = data.get("entries", [])
lines = []
# 文件头部YAML frontmatter
if meta.get("date"):
lines.append("---")
lines.append(f'date: "{meta["date"]}"')
lines.append("---")
lines.append("")
# 条目
for entry in entries:
lines.append(format_entry(entry))
return "\n".join(lines)
def convert_fetch_json_to_md(json_filepath: str, md_filepath: str = None) -> str:
"""
将fetch JSON文件转换为Markdown文件
Args:
json_filepath: JSON文件路径
md_filepath: 输出MD文件路径,默认为同名.md
Returns:
生成的Markdown内容
"""
data = read_fetch_data(json_filepath)
md_content = json_to_md(data)
if md_filepath:
path = Path(md_filepath)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(md_content)
return md_content
def save_push_file(
filepath: str,
content: str,
source_count: int,
total_entries: int,
profile: str = "default",
metadata: Dict = None,
):
"""保存推送文件(Markdown格式)
Args:
profile: "morning" | "default" ← 早报或常规;写入 frontmatter,便于按 profile 分析
metadata: 元信息(可选),如果提供则使用 metadata,否则使用默认格式
"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
if metadata:
# 使用提供的 metadata
frontmatter_dict = metadata.copy()
# 添加推送时间和统计信息
frontmatter_dict["pushDate"] = datetime.now(get_timezone()).isoformat()
frontmatter_dict["sourceCount"] = source_count
frontmatter_dict["totalEntries"] = total_entries
else:
# 降级:使用默认格式
push_time = datetime.now(get_timezone())
frontmatter_dict = {
"profile": profile,
"pushDate": push_time.isoformat(),
"sourceCount": source_count,
"totalEntries": total_entries,
}
frontmatter = dump_frontmatter(frontmatter_dict)
full_content = f"---\n{frontmatter}---\n\n{content}"
with open(path, "w", encoding="utf-8") as f:
f.write(full_content)
def load_existing_links(filepath: str, threshold: int = 150) -> set:
"""加载文件中已有的链接(用于去重)
如果当天时间已超过 threshold 分钟,则只需加载当天文件;
否则需要同时加载当天和昨天的文件(用于处理跨天边界情况)。
Args:
filepath: 当天的 fetch 文件路径
threshold: 阈值(分钟),超过此时间只加载当天文件
"""
tz = get_timezone()
now = datetime.now(tz)
current_minutes = now.hour * 60 + now.minute
need_yesterday = current_minutes < threshold
if not need_yesterday:
if not filepath or not Path(filepath).exists():
return set()
entries = read_entries(filepath)
return {e.get("link") for e in entries if e.get("link")}
all_links = set()
if filepath and Path(filepath).exists():
all_links.update(
{e.get("link") for e in read_entries(filepath) if e.get("link")}
)
yesterday = (now - timedelta(days=1)).date()
yesterday_file = get_fetch_file(yesterday)
if Path(yesterday_file).exists():
all_links.update(
{e.get("link") for e in read_entries(yesterday_file) if e.get("link")}
)
return all_links
def cleanup_old_files(days: int = 7, data_dir: str = "news-data"):
"""清理超过days天的旧文件"""
data_path = Path(data_dir)
if not data_path.exists():
return
cutoff = datetime.now() - timedelta(days=days)
deleted_count = 0
for pattern in ["fetch-*.json", "fetch-*.md", "push-*.md", "notify-*.md"]:
for file in data_path.glob(pattern):
try:
date_str = (
file.name.replace("fetch-", "")
.replace("push-", "")
.replace("notify-", "")
.replace(".json", "")
.replace(".md", "")
)
date_parts = date_str.split("-")
if len(date_parts) >= 3:
file_date = date(
int(date_parts[0]), int(date_parts[1]), int(date_parts[2])
)
if file_date < cutoff.date():
file.unlink()
deleted_count += 1
print(f" 🗑️ 删除旧文件: {file.name}")
except (ValueError, OSError):
continue
# trending-history.json: 剪枝过期条目,保留文件本身
trending_path = data_path / "trending-history.json"
if trending_path.exists() and trending_path.stat().st_size > 0:
try:
history = load_trending_history(str(trending_path))
before = len(history.repos)
history.cleanup(today=datetime.now().date(), keep_days=days)
after = len(history.repos)
if after < before:
history.save()
print(f" ✂️ trending-history 剪枝: {before}{after} 条")
except Exception as e:
print(f" ⚠️ trending-history 剪枝失败: {e}")
if deleted_count > 0:
print(f" ✅ 清理完成: 删除了 {deleted_count} 个旧文件")
class TrendingHistory:
"""GitHub trending 已查阅 repo 索引。
repos 字段:url → last_seen_date (ISO YYYY-MM-DD)。
每次早报 cleanup 一次,touch 完所有今日 URL 后 save。
"""
def __init__(self, path: str, repos: Dict[str, str]):
self._path = path
self.repos: Dict[str, str] = dict(repos)
def __contains__(self, url: str) -> bool:
return url in self.repos
def touch(self, url: str, today: date) -> None:
self.repos[url] = today.isoformat()
def cleanup(self, today: date, keep_days: int) -> None:
cutoff = today - timedelta(days=keep_days)
self.repos = {
url: d
for url, d in self.repos.items()
if _parse_iso_date_safe(d) is not None and _parse_iso_date_safe(d) >= cutoff
}
def save(self) -> None:
path = Path(self._path)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"repos": self.repos,
"updated_at": datetime.now(get_timezone()).isoformat(),
}
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def _parse_iso_date_safe(s: str) -> Optional[date]:
try:
return date.fromisoformat(s)
except (ValueError, TypeError):
return None
def load_trending_history(path: str) -> TrendingHistory:
"""读取 trending-history.json;不存在返回空实例。"""
p = Path(path)
if not p.exists() or p.stat().st_size == 0:
return TrendingHistory(path, {})
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
return TrendingHistory(path, data.get("repos", {}))
except (json.JSONDecodeError, OSError):
print(f"⚠️ trending-history 读取失败,使用空索引: {path}")
return TrendingHistory(path, {})
_SECTION_ORDER = ("rss", "github", "hackernews", "insights")
def assemble_with_sentinels(sections: Dict[str, str]) -> str:
"""按固定顺序拼装四段 markdown,每段包 sentinel;空段整段省略。"""
parts: List[str] = []
for key in _SECTION_ORDER:
body = (sections.get(key) or "").strip()
if not body:
continue
parts.append(
f"<!-- SECTION:{key} BEGIN -->\n{body}\n<!-- SECTION:{key} END -->"
)
return "\n\n".join(parts)