提取 collect/formatters/themes 等 pipeline 模块,新增 wecom 分条、RSS、delta、Agent 工作流测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
271 lines
9.7 KiB
Python
271 lines
9.7 KiB
Python
"""生成早报 Markdown(完整版 + 企微短版)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import sys
|
||
from datetime import datetime, timezone, timedelta
|
||
from pathlib import Path
|
||
|
||
from daily.agent_workflow import is_agent_mode, run_agent_workflow
|
||
from daily.config import LOG_DIR, OUTPUT_DIR, env
|
||
from daily.cursor_editor import (
|
||
apply_descriptions,
|
||
is_enabled as cursor_editor_enabled,
|
||
run_editorial,
|
||
theme_line_from_editorial,
|
||
)
|
||
from daily.format_wecom import build_wecom_report, finalize_wecom_skill_groups, replace_wecom_skill_sections
|
||
from daily.github.trending import trending_data_source_note
|
||
from daily.news.fetch import (
|
||
format_cn_news_section,
|
||
format_news_section,
|
||
prepare_wecom_cn_news_items,
|
||
prepare_wecom_news_items,
|
||
)
|
||
from daily.pipeline.collect import collect_report_context
|
||
from daily.pipeline.formatters import (
|
||
build_highlights,
|
||
fetch_latest_release_title,
|
||
format_github_repo_section,
|
||
format_skill_section,
|
||
prepare_github_item,
|
||
prepare_skill_item,
|
||
)
|
||
from daily.pipeline.localize import localize_descriptions_in_place
|
||
from daily.pipeline.snapshot import load_snapshot, save_snapshot
|
||
from daily.pipeline.themes import detect_theme_line, theme_clusters
|
||
from daily.report_data import build_full_payload, data_json_path, save_json
|
||
from daily.skills_group import group_skills_by_source
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _now_cst() -> datetime:
|
||
return datetime.now(timezone(timedelta(hours=8)))
|
||
|
||
|
||
def generate_report() -> tuple[str, str, Path, Path]:
|
||
ctx = collect_report_context(_now_cst())
|
||
now = _now_cst()
|
||
prev_ids = load_snapshot()
|
||
|
||
save_json(
|
||
data_json_path(ctx.date_str),
|
||
build_full_payload(
|
||
ctx.llm_input,
|
||
meta={
|
||
"generated_at": now.isoformat(),
|
||
"report_mode": "agent" if is_agent_mode() else "classic",
|
||
"cursor_editor": cursor_editor_enabled() and not is_agent_mode(),
|
||
},
|
||
),
|
||
)
|
||
|
||
agent_wecom: str | None = None
|
||
if is_agent_mode():
|
||
agent_wecom = run_agent_workflow(
|
||
ctx.llm_input,
|
||
date_str=ctx.date_str,
|
||
time_str=ctx.time_str,
|
||
updated=ctx.updated,
|
||
)
|
||
if not agent_wecom:
|
||
logger.warning("Agent 工作流失败,回退 classic 模式")
|
||
|
||
editorial_theme: str | None = None
|
||
editorial_highlights: list[str] | None = None
|
||
if agent_wecom is None:
|
||
editorial = run_editorial(ctx.llm_input, date_str=ctx.date_str)
|
||
if editorial:
|
||
apply_descriptions(
|
||
trending=ctx.trending,
|
||
hot=ctx.hot,
|
||
github_trending=ctx.github_trending,
|
||
github_emerging=ctx.github_emerging,
|
||
github_topic=ctx.github_topic,
|
||
ai_news=ctx.ai_news,
|
||
cn_ai_news=ctx.cn_ai_news,
|
||
descriptions=editorial.get("descriptions") or {},
|
||
)
|
||
editorial_theme = theme_line_from_editorial(editorial) or None
|
||
hl = editorial.get("highlights") or []
|
||
editorial_highlights = hl if hl else None
|
||
|
||
localize_descriptions_in_place(
|
||
ctx.trending,
|
||
ctx.hot,
|
||
ctx.github_trending,
|
||
ctx.github_emerging,
|
||
ctx.github_topic,
|
||
ctx.ai_news,
|
||
ctx.cn_ai_news,
|
||
)
|
||
|
||
limits = ctx.limits
|
||
themes = theme_clusters(ctx.feed)
|
||
lines = [
|
||
f"# 早报 · {ctx.date_str}",
|
||
"",
|
||
f"> 生成时间:{now.strftime('%Y-%m-%d %H:%M')} (UTC+8) ",
|
||
f"> skills 数据更新:{ctx.updated} ",
|
||
"> 数据来源:[skills.sh/trending](https://skills.sh/trending) · [skills.sh/hot](https://skills.sh/hot) · 国际/国内 AI RSS",
|
||
"",
|
||
"---",
|
||
"",
|
||
f"## 一、Skills Trending Top {limits.trending_n}",
|
||
"",
|
||
*format_skill_section(ctx.trending),
|
||
"---",
|
||
"",
|
||
f"## 二、Skills Hot Top {limits.hot_n}",
|
||
"",
|
||
*format_skill_section(ctx.hot, hot=True),
|
||
"",
|
||
"---",
|
||
"",
|
||
f"## 三、GitHub Trending Top {limits.github_limit}",
|
||
"",
|
||
trending_data_source_note(),
|
||
"",
|
||
]
|
||
|
||
if ctx.github_trending:
|
||
lines.extend(format_github_repo_section(ctx.github_trending))
|
||
else:
|
||
lines.append("*GitHub Trending 获取失败,请检查网络或配置 GITHUB_TOKEN。*")
|
||
lines.append("")
|
||
|
||
lines.extend(
|
||
["---", "", f"## 四、新兴项目 Top {limits.emerging_limit}", "", "> 数据来源:GitHub Search API(需 `GITHUB_TOKEN`)", ""]
|
||
)
|
||
if ctx.github_emerging:
|
||
lines.extend(format_github_repo_section(ctx.github_emerging, show_created=True))
|
||
else:
|
||
lines.append("*新兴项目获取失败或未配置 GITHUB_TOKEN。*")
|
||
lines.append("")
|
||
|
||
lines.extend(
|
||
["---", "", f"## 五、Topic `{ctx.topic_name}` Top {limits.topic_limit}", "", "> 数据来源:GitHub Search API(需 `GITHUB_TOKEN`)", ""]
|
||
)
|
||
if ctx.github_topic:
|
||
lines.extend(format_github_repo_section(ctx.github_topic))
|
||
else:
|
||
lines.append(f"*Topic `{ctx.topic_name}` 热点获取失败或未配置 GITHUB_TOKEN。*")
|
||
lines.append("")
|
||
|
||
section_no = 6
|
||
lines.extend(format_news_section(ctx.ai_news, section_no=section_no))
|
||
section_no += 1
|
||
lines.extend(format_cn_news_section(ctx.cn_ai_news, section_no=section_no))
|
||
section_no += 1
|
||
|
||
watch = (env("GITHUB_REPOS") or "").strip()
|
||
if watch:
|
||
lines.extend(["---", "", f"## {section_no}、关注仓库 Release", ""])
|
||
section_no += 1
|
||
for repo in [r.strip() for r in watch.split(",") if r.strip()]:
|
||
release = fetch_latest_release_title(repo)
|
||
lines.append(f"- **{repo}**:{release or '暂无 release'}")
|
||
lines.append("")
|
||
|
||
lines.extend(["---", "", f"## {section_no}、主题聚类", ""])
|
||
for theme, examples in themes:
|
||
lines.append(f"### {theme}")
|
||
for ex in examples:
|
||
lines.append(f"- {ex}")
|
||
lines.append("")
|
||
|
||
pick_src = ctx.trending[0].get("source", "") if ctx.trending else ""
|
||
pick_name = ctx.trending[0].get("title", "") if ctx.trending else ""
|
||
pick_command = (
|
||
f"npx skills add {pick_src}/{pick_name}"
|
||
if pick_src and pick_name
|
||
else "npx skills add vercel-labs/skills/find-skills"
|
||
)
|
||
|
||
lines.extend(["---", "", "## 安装示例", "", "```bash"])
|
||
for item in ctx.trending[:4]:
|
||
src, name = item.get("source", ""), item.get("title", "")
|
||
if src and name:
|
||
lines.append(f"npx skills add {src}/{name}")
|
||
lines.extend(["```", "", f"*企微短版见 `output/{ctx.date_str}.wecom.md`*"])
|
||
|
||
markdown = "\n".join(lines)
|
||
if agent_wecom:
|
||
gt = group_skills_by_source(
|
||
ctx.trending, limit=limits.wecom_trending, pool_size=limits.skill_pool
|
||
)
|
||
gh = group_skills_by_source(ctx.hot, limit=limits.wecom_hot, pool_size=limits.skill_pool)
|
||
wecom_md = replace_wecom_skill_sections(agent_wecom, trending=gt, hot=gh)
|
||
else:
|
||
wecom_md = build_wecom_report(
|
||
date_str=ctx.date_str,
|
||
time_str=ctx.time_str,
|
||
updated=ctx.updated,
|
||
highlights=editorial_highlights
|
||
or build_highlights(
|
||
ctx.trending,
|
||
ctx.hot,
|
||
ctx.github_trending,
|
||
ctx.github_emerging,
|
||
ctx.ai_news,
|
||
ctx.cn_ai_news,
|
||
),
|
||
theme_line=editorial_theme or detect_theme_line(ctx.feed),
|
||
ai_news=prepare_wecom_news_items(ctx.ai_news),
|
||
cn_ai_news=prepare_wecom_cn_news_items(ctx.cn_ai_news),
|
||
trending=[
|
||
prepare_skill_item(item, prev_ids, r)
|
||
for r, item in enumerate(
|
||
finalize_wecom_skill_groups(
|
||
group_skills_by_source(
|
||
ctx.trending, limit=limits.wecom_trending, pool_size=limits.skill_pool
|
||
)
|
||
),
|
||
1,
|
||
)
|
||
],
|
||
hot=[
|
||
prepare_skill_item(item, prev_ids, r)
|
||
for r, item in enumerate(
|
||
finalize_wecom_skill_groups(
|
||
group_skills_by_source(
|
||
ctx.hot, limit=limits.wecom_hot, pool_size=limits.skill_pool
|
||
)
|
||
),
|
||
1,
|
||
)
|
||
],
|
||
repos=[prepare_github_item(item) for item in ctx.github_trending[: limits.wecom_github]],
|
||
emerging=[prepare_github_item(item) for item in ctx.github_emerging[: limits.wecom_emerging]],
|
||
topic_name=ctx.topic_name,
|
||
topic_repos=[prepare_github_item(item) for item in ctx.github_topic[: limits.wecom_topic]],
|
||
pick_command=pick_command,
|
||
)
|
||
|
||
save_snapshot(ctx.feed, ctx.date_str)
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
out_md = OUTPUT_DIR / f"{ctx.date_str}.md"
|
||
out_wecom = OUTPUT_DIR / f"{ctx.date_str}.wecom.md"
|
||
out_md.write_text(markdown, encoding="utf-8")
|
||
out_wecom.write_text(wecom_md, encoding="utf-8")
|
||
return markdown, wecom_md, out_md, out_wecom
|
||
|
||
|
||
def main() -> int:
|
||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
log_file = LOG_DIR / f"{_now_cst():%Y-%m-%d}.log"
|
||
try:
|
||
_, wecom_md, out_md, out_wecom = generate_report()
|
||
nbytes = len(wecom_md.encode("utf-8"))
|
||
msg = f"[{_now_cst():%H:%M:%S}] OK -> {out_md}, {out_wecom} ({nbytes} bytes)\n"
|
||
log_file.write_text(msg, encoding="utf-8")
|
||
print(msg.strip())
|
||
return 0
|
||
except Exception as exc:
|
||
msg = f"[{_now_cst():%H:%M:%S}] FAIL: {exc}\n"
|
||
log_file.write_text(msg, encoding="utf-8")
|
||
print(msg.strip(), file=sys.stderr)
|
||
return 1
|