- 新增常驻调度器 daily/scheduler.py + run-scheduler.ps1(定时生成/推送) - 新增 daily/bridge_manager.py:Windows 兼容的 Cursor SDK 桥接 - 新增 skills/daily-featured-pick 首推 Skill 与叙事轴/去重逻辑 - 新闻抓取窗口、GitHub 搜索、企微 delta 模式等多项改进 - 补充设计文档与 superpowers 计划/规范 - 新增对应测试(scheduler、featured_pick、github_search、news_fetch_window 等) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1103 lines
40 KiB
Python
1103 lines
40 KiB
Python
"""企微早报排版。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from daily.config import wecom_skill_desc_limit
|
||
from daily.localize import LocalizeJob, localize_brief_descriptions, needs_chinese
|
||
from daily.skills_group import group_skills_by_source
|
||
from daily.text_utils import trim_brief
|
||
|
||
ICONS = {
|
||
"header": "📰",
|
||
"highlights": "💡",
|
||
"trending": "📈",
|
||
"hot": "🔥",
|
||
"github": "🐙",
|
||
"emerging": "🌱",
|
||
"topic": "🤖",
|
||
"ainews": "🌍",
|
||
"cnainews": "🇨🇳",
|
||
"pick": "📦",
|
||
"theme": "🎯",
|
||
"file": "📄",
|
||
}
|
||
|
||
|
||
def _skill_line(rank: int, item: dict[str, Any], *, badge: str = "") -> list[str]:
|
||
source = item.get("source", "?")
|
||
installs = item.get("installs_fmt", "?")
|
||
link = item.get("link", "")
|
||
desc = item.get("desc_short", "")
|
||
badge_prefix = f"{badge} " if badge else ""
|
||
if item.get("cluster"):
|
||
count = int(item.get("cluster_count") or 1)
|
||
sample = item.get("cluster_titles") or item.get("title", "")
|
||
label = f"**{source}** · {count} skills · **{installs}**"
|
||
if link:
|
||
head = f"{rank}. {badge_prefix}[{label}]({link})"
|
||
else:
|
||
head = f"{rank}. {badge_prefix}{label}"
|
||
lines = [head]
|
||
if sample or desc:
|
||
hint = desc or sample
|
||
lines.append(f" {hint}")
|
||
return lines
|
||
title = item.get("title", "?")
|
||
if link:
|
||
head = f"{rank}. {badge_prefix}[**{title}**]({link}) · `{source}` · **{installs}**"
|
||
else:
|
||
head = f"{rank}. {badge_prefix}**{title}** · `{source}` · **{installs}**"
|
||
lines = [head]
|
||
if desc:
|
||
lines.append(f" {desc}")
|
||
return lines
|
||
|
||
|
||
def _ai_news_link_label(item: dict[str, Any], *, merged: bool = False) -> str:
|
||
title = (item.get("title") or "?").strip() or "?"
|
||
source = (item.get("source_name") or "").strip()
|
||
if merged and source:
|
||
return f"{source} - {title}"
|
||
desc = (item.get("desc_short") or "").strip()
|
||
if desc:
|
||
return desc
|
||
return title
|
||
|
||
|
||
def _ai_news_lines(items: list[dict[str, Any]], *, merged: bool = False) -> list[str]:
|
||
lines: list[str] = []
|
||
for i, item in enumerate(items, 1):
|
||
label = _ai_news_link_label(item, merged=merged)
|
||
link = item.get("link", "")
|
||
source = item.get("source_name", "?")
|
||
pub = item.get("published_fmt", "")
|
||
desc = (item.get("desc_short") or "").strip()
|
||
pub_suffix = "" if merged else (f" · {pub}" if pub else "")
|
||
if link:
|
||
if merged and desc:
|
||
head = f"{i}. [{label}]({link}) — {desc}{pub_suffix}"
|
||
elif merged:
|
||
head = f"{i}. [{label}]({link}){pub_suffix}"
|
||
else:
|
||
head = f"{i}. [{label}]({link}) · `{source}`{pub_suffix}"
|
||
elif merged and desc:
|
||
head = f"{i}. {label} — {desc}{pub_suffix}"
|
||
else:
|
||
head = f"{i}. {label} · `{source}`{pub_suffix}"
|
||
lines.append(head)
|
||
return lines
|
||
|
||
|
||
_NEWS_BLOCK_END = re.compile(
|
||
r"\n\n(📈|🔥|🐙|🌱|🤖|📦|🎯|💡|🌍|🇨🇳|📰|🔧)",
|
||
)
|
||
|
||
|
||
def _build_merged_news_block(
|
||
items: list[dict[str, Any]],
|
||
tech_items: list[dict[str, Any]] | None = None,
|
||
) -> str:
|
||
combined = list(items or [])
|
||
if tech_items:
|
||
combined.extend(tech_items)
|
||
if not combined:
|
||
return ""
|
||
parts = [f"📰 **AI 时讯精选 Top {len(combined)}**"]
|
||
parts.extend(_ai_news_lines(combined, merged=True))
|
||
return "\n".join(parts) + "\n"
|
||
|
||
|
||
def _replace_merged_news_block(
|
||
md: str,
|
||
items: list[dict[str, Any]],
|
||
tech_items: list[dict[str, Any]] | None = None,
|
||
) -> str:
|
||
if not items and not tech_items:
|
||
return md
|
||
block = _build_merged_news_block(items, tech_items)
|
||
start_pat = re.compile(
|
||
r"^📰 \*\*AI 时讯精选[^\n]*\*\*\s*$",
|
||
re.MULTILINE,
|
||
)
|
||
match = start_pat.search(md)
|
||
if not match:
|
||
anchor = re.search(r"^(💡|🎯).*$", md, re.MULTILINE)
|
||
if anchor:
|
||
insert_at = anchor.end()
|
||
return md[:insert_at] + "\n\n" + block + md[insert_at:].lstrip("\n")
|
||
anchor2 = re.search(r"^(🌍|🇨🇳|📈|🔧).*$", md, re.MULTILINE)
|
||
if anchor2:
|
||
insert_at = anchor2.start()
|
||
return md[:insert_at] + block + md[insert_at:].lstrip("\n")
|
||
return md.rstrip() + "\n\n" + block
|
||
start = match.start()
|
||
tail = md[match.end() :]
|
||
end_rel = _NEWS_BLOCK_END.search(tail)
|
||
end = match.end() + (end_rel.start() if end_rel else len(tail))
|
||
return md[:start] + block + md[end:].lstrip("\n")
|
||
|
||
|
||
def _remove_news_blocks(md: str, icons: tuple[str, ...], *, label_must_contain: str = "") -> str:
|
||
out = md
|
||
for icon in icons:
|
||
start_pat = re.compile(rf"^{re.escape(icon)} \*\*[^\n]+\*\*\s*$", re.MULTILINE)
|
||
while True:
|
||
match = None
|
||
for candidate in start_pat.finditer(out):
|
||
if label_must_contain and label_must_contain not in candidate.group(0):
|
||
continue
|
||
match = candidate
|
||
break
|
||
if not match:
|
||
break
|
||
start = match.start()
|
||
tail = out[match.end() :]
|
||
end_rel = _NEWS_BLOCK_END.search(tail)
|
||
end = match.end() + (end_rel.start() if end_rel else len(tail))
|
||
out = out[:start] + out[end:].lstrip("\n")
|
||
return out
|
||
|
||
|
||
def _replace_news_block(
|
||
md: str,
|
||
icon: str,
|
||
items: list[dict[str, Any]],
|
||
label: str,
|
||
*,
|
||
merged: bool = False,
|
||
) -> str:
|
||
if not items:
|
||
return md
|
||
if merged:
|
||
start_pat = re.compile(
|
||
rf"^{re.escape(icon)} \*\*{re.escape(label)}[^\n]*\*\*\s*$",
|
||
re.MULTILINE,
|
||
)
|
||
else:
|
||
start_pat = re.compile(rf"^{re.escape(icon)} \*\*[^\n]+\*\*\s*$", re.MULTILINE)
|
||
match = start_pat.search(md)
|
||
block = (
|
||
f"{icon} **{label} Top {len(items)}**\n"
|
||
+ "\n".join(_ai_news_lines(items, merged=merged))
|
||
+ "\n"
|
||
)
|
||
if not match:
|
||
anchor = re.search(r"^(💡|🎯).*$", md, re.MULTILINE)
|
||
if anchor:
|
||
insert_at = anchor.end()
|
||
return md[:insert_at] + "\n\n" + block + md[insert_at:].lstrip("\n")
|
||
# 插在 🌍/🇨🇳 原位置,或 Skills 区块前
|
||
anchor2 = re.search(r"^(🌍|🇨🇳|📈).*$", md, re.MULTILINE)
|
||
if anchor2:
|
||
insert_at = anchor2.start()
|
||
return md[:insert_at] + block + md[insert_at:].lstrip("\n")
|
||
return md.rstrip() + "\n\n" + block
|
||
start = match.start()
|
||
tail = md[match.end() :]
|
||
end_rel = _NEWS_BLOCK_END.search(tail)
|
||
end = match.end() + (end_rel.start() if end_rel else len(tail))
|
||
return md[:start] + block + md[end:].lstrip("\n")
|
||
|
||
|
||
def replace_wecom_news_sections(
|
||
md: str,
|
||
*,
|
||
ai_news: list[dict[str, Any]] | None = None,
|
||
cn_ai_news: list[dict[str, Any]] | None = None,
|
||
tech_ai_news: list[dict[str, Any]] | None = None,
|
||
merged: bool = False,
|
||
) -> str:
|
||
"""用 Python 整理后的新闻列表替换 Agent/模板中的时讯区块。"""
|
||
if merged and (ai_news or tech_ai_news):
|
||
out = _remove_news_blocks(md, ("🌍", "🇨🇳"))
|
||
out = _remove_news_blocks(out, ("📰",), label_must_contain="AI 时讯精选")
|
||
out = _remove_news_blocks(out, ("🔧",), label_must_contain="技术类时讯")
|
||
return _replace_merged_news_block(out, ai_news or [], tech_ai_news)
|
||
out = md
|
||
if cn_ai_news:
|
||
out = _replace_news_block(out, "🇨🇳", cn_ai_news, "国内 AI 时讯")
|
||
if ai_news:
|
||
out = _replace_news_block(out, "🌍", ai_news, "国际 AI 时讯")
|
||
return out
|
||
|
||
|
||
WECOM_GITHUB_DESC_LIMIT = 40
|
||
|
||
|
||
def _github_repo_lines(repos: list[dict[str, Any]], *, show_created: bool = False) -> list[str]:
|
||
lines: list[str] = []
|
||
for i, repo in enumerate(repos, 1):
|
||
name = repo["repo"]
|
||
url = repo["url"]
|
||
lang = repo.get("language", "")
|
||
stars_today = repo.get("stars_today_fmt", "")
|
||
total = repo.get("total_stars_fmt", "")
|
||
created = repo.get("created_at", "")
|
||
meta_parts: list[str] = []
|
||
if lang:
|
||
meta_parts.append(lang)
|
||
if stars_today:
|
||
meta_parts.append(f"+{stars_today} today")
|
||
elif total:
|
||
meta_parts.append(f"⭐{total}")
|
||
if show_created and created:
|
||
meta_parts.append(f"创建于 {created}")
|
||
meta = f" · {' · '.join(meta_parts)}" if meta_parts else ""
|
||
lines.append(f"{i}. [{name}]({url}){meta}")
|
||
desc = repo.get("wecom_desc") or repo.get("desc_short") or repo.get("description", "")
|
||
if desc:
|
||
lines.append(f" {desc}")
|
||
return lines
|
||
|
||
|
||
def finalize_wecom_github_repos(
|
||
items: list[dict[str, Any]],
|
||
*,
|
||
desc_limit: int | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""为企微 GitHub 条目生成简短中文简介。"""
|
||
if desc_limit is None:
|
||
desc_limit = WECOM_GITHUB_DESC_LIMIT
|
||
limit = desc_limit if desc_limit > 0 else 40
|
||
copies: list[tuple[str, dict[str, Any]]] = []
|
||
jobs: list[LocalizeJob] = []
|
||
for item in items:
|
||
copy = dict(item)
|
||
desc = (copy.get("description") or copy.get("desc_short") or "").strip()
|
||
key = f"github:{copy.get('repo', '?')}"
|
||
if needs_chinese(desc) or len(desc) > limit:
|
||
jobs.append(LocalizeJob(key, desc, limit))
|
||
else:
|
||
copy["wecom_desc"] = desc
|
||
copies.append((key, copy))
|
||
|
||
zh_map = localize_brief_descriptions(jobs, archive=True)
|
||
out: list[dict[str, Any]] = []
|
||
for key, copy in copies:
|
||
if key in zh_map:
|
||
copy["wecom_desc"] = zh_map[key]
|
||
elif "wecom_desc" not in copy:
|
||
fallback = (copy.get("description") or copy.get("desc_short") or "").strip()
|
||
copy["wecom_desc"] = _brief_fallback_desc(fallback, limit) if fallback else ""
|
||
out.append(copy)
|
||
return out
|
||
|
||
|
||
def _fallback_skill_desc(item: dict[str, Any]) -> str:
|
||
if item.get("cluster"):
|
||
count = int(item.get("cluster_count") or 1)
|
||
source = item.get("source") or "unknown"
|
||
sample = item.get("cluster_titles") or item.get("title") or ""
|
||
return f"{count} agent skills from {source}, including {sample}"
|
||
title = item.get("title") or "skill"
|
||
source = item.get("source") or "unknown"
|
||
return f"{title} skill from {source}"
|
||
|
||
|
||
def _skill_group_key(item: dict[str, Any]) -> str:
|
||
return str(item.get("id") or f"{item.get('source')}/{item.get('title')}")
|
||
|
||
|
||
def finalize_wecom_skill_groups(
|
||
items: list[dict[str, Any]],
|
||
*,
|
||
desc_limit: int | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""为企微 Skills 榜生成一句简要中文简介(与完整版 .md 长描述分离)。"""
|
||
if desc_limit is None:
|
||
desc_limit = wecom_skill_desc_limit()
|
||
limit = desc_limit if desc_limit > 0 else 48
|
||
copies: list[tuple[str, dict[str, Any]]] = []
|
||
jobs: list[LocalizeJob] = []
|
||
for item in items:
|
||
copy = dict(item)
|
||
desc = (copy.get("description") or "").strip()
|
||
if not desc:
|
||
desc = _fallback_skill_desc(copy)
|
||
key = _skill_group_key(copy)
|
||
job_key = f"wecom:{key}"
|
||
if needs_chinese(desc) or len(desc) > limit:
|
||
jobs.append(LocalizeJob(job_key, desc, limit))
|
||
else:
|
||
copy["wecom_desc"] = desc
|
||
copies.append((job_key, copy))
|
||
|
||
zh_map = localize_brief_descriptions(jobs, archive=True)
|
||
out: list[dict[str, Any]] = []
|
||
for job_key, copy in copies:
|
||
if job_key in zh_map:
|
||
copy["wecom_desc"] = zh_map[job_key]
|
||
elif "wecom_desc" not in copy:
|
||
copy["wecom_desc"] = _brief_fallback_desc(
|
||
(copy.get("description") or "").strip() or _fallback_skill_desc(copy),
|
||
limit,
|
||
)
|
||
out.append(copy)
|
||
return out
|
||
|
||
|
||
def _brief_fallback_desc(text: str, limit: int) -> str:
|
||
return trim_brief(text, limit)
|
||
|
||
|
||
def _grouped_skill_to_wecom_item(
|
||
item: dict[str, Any],
|
||
*,
|
||
desc_limit: int | None = None,
|
||
) -> dict[str, Any]:
|
||
if desc_limit is None:
|
||
desc_limit = wecom_skill_desc_limit()
|
||
limit = desc_limit if desc_limit > 0 else 48
|
||
installs = int(item.get("installs") or 0)
|
||
installs_fmt = item.get("installs_fmt") or str(installs)
|
||
desc = (item.get("wecom_desc") or item.get("description") or "").strip()
|
||
if not desc and item.get("cluster"):
|
||
desc = item.get("cluster_titles") or ""
|
||
if not item.get("wecom_desc"):
|
||
desc = _brief_fallback_desc(desc, limit)
|
||
return {
|
||
"id": str(item.get("id") or f"{item.get('source', '?')}/{item.get('title', '')}"),
|
||
"title": item.get("title", ""),
|
||
"source": item.get("source", "?"),
|
||
"installs_fmt": installs_fmt,
|
||
"link": item.get("link", ""),
|
||
"desc_short": desc,
|
||
"badge": item.get("badge", ""),
|
||
"cluster": bool(item.get("cluster")),
|
||
"cluster_count": item.get("cluster_count"),
|
||
"cluster_titles": item.get("cluster_titles"),
|
||
}
|
||
|
||
|
||
def build_skills_board_section(icon_key: str, board_label: str, items: list[dict[str, Any]]) -> str:
|
||
prepared = finalize_wecom_skill_groups(items)
|
||
wecom_items = [_grouped_skill_to_wecom_item(x) for x in prepared]
|
||
lines = [f"{ICONS[icon_key]} **{board_label} Top {len(wecom_items)}**"]
|
||
for rank, item in enumerate(wecom_items, 1):
|
||
lines.extend(_skill_line(rank, item))
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _move_to_wecom_skill_item(move: dict[str, Any]) -> dict[str, Any]:
|
||
installs = int(move.get("installs") or 0)
|
||
return {
|
||
"title": move.get("title", "?"),
|
||
"source": move.get("source", "?"),
|
||
"installs_fmt": move.get("installs_fmt") or str(installs),
|
||
"link": move.get("link", ""),
|
||
"description": (move.get("description") or "").strip(),
|
||
}
|
||
|
||
|
||
def _move_to_skill_row(move: dict[str, Any]) -> dict[str, Any]:
|
||
title = str(move.get("title") or "?")
|
||
source = str(move.get("source") or "?")
|
||
installs = int(move.get("installs") or 0)
|
||
sid = str(move.get("id") or f"{source}/{title}")
|
||
return {
|
||
"id": sid,
|
||
"title": title,
|
||
"source": source,
|
||
"installs": installs,
|
||
"link": move.get("link", ""),
|
||
"description": (move.get("description") or "").strip(),
|
||
}
|
||
|
||
|
||
def _flatten_skill_board_item(item: dict[str, Any]) -> list[dict[str, Any]]:
|
||
if item.get("cluster"):
|
||
source = str(item.get("source") or "?")
|
||
installs = int(item.get("installs") or 0)
|
||
titles = [str(t) for t in (item.get("cluster_skills") or []) if t]
|
||
if not titles:
|
||
titles = [str(item.get("title") or "?")]
|
||
top_title = str(item.get("title") or titles[0])
|
||
rows: list[dict[str, Any]] = []
|
||
for title in titles:
|
||
link = item.get("link", "")
|
||
if title != top_title:
|
||
link = f"https://www.skills.sh/{source}/{title}"
|
||
rows.append(
|
||
{
|
||
"id": f"{source}/{title}",
|
||
"title": title,
|
||
"source": source,
|
||
"installs": installs,
|
||
"link": link,
|
||
"description": (item.get("description") or "").strip(),
|
||
}
|
||
)
|
||
return rows
|
||
title = str(item.get("title") or "?")
|
||
source = str(item.get("source") or "?")
|
||
return [
|
||
{
|
||
"id": str(item.get("id") or f"{source}/{title}"),
|
||
"title": title,
|
||
"source": source,
|
||
"installs": int(item.get("installs") or 0),
|
||
"link": item.get("link", ""),
|
||
"description": (item.get("description") or "").strip(),
|
||
}
|
||
]
|
||
|
||
|
||
def _skill_keys_in_board_item(item: dict[str, Any]) -> set[str]:
|
||
if item.get("cluster"):
|
||
source = str(item.get("source") or "?")
|
||
titles = item.get("cluster_skills") or [item.get("title", "")]
|
||
return {f"{source}/{t}" for t in titles if t}
|
||
return {_skill_group_key(item)}
|
||
|
||
|
||
def _prepare_grouped_wecom_skills(
|
||
flat_rows: list[dict[str, Any]],
|
||
*,
|
||
limit: int,
|
||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||
if not flat_rows:
|
||
return [], set()
|
||
grouped = group_skills_by_source(flat_rows, limit=limit, pool_size=max(len(flat_rows), limit))
|
||
prepared = finalize_wecom_skill_groups(grouped)
|
||
wecom_items = [_grouped_skill_to_wecom_item(x) for x in prepared]
|
||
keys: set[str] = set()
|
||
for item in grouped:
|
||
keys.update(_skill_keys_in_board_item(item))
|
||
return wecom_items[:limit], keys
|
||
|
||
|
||
def _normalize_skill_source_groups(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""将条目规范为按 source 合并的榜单项(展示始终为合并态)。"""
|
||
flat: list[dict[str, Any]] = []
|
||
for item in items:
|
||
flat.extend(_flatten_skill_board_item(item))
|
||
if not flat:
|
||
return []
|
||
return group_skills_by_source(flat, limit=len(flat), pool_size=len(flat))
|
||
|
||
|
||
def _skill_primary_id(item: dict[str, Any]) -> str:
|
||
return str(item.get("id") or f"{item.get('source')}/{item.get('title')}" or "").strip()
|
||
|
||
|
||
def _source_from_skill_key(key: str) -> str:
|
||
from daily.skills_group import source_from_skill_key
|
||
|
||
return source_from_skill_key(key)
|
||
|
||
|
||
def expand_skill_recent_keys(keys: set[str] | None) -> set[str]:
|
||
from daily.skills_group import expand_skill_recent_keys as _expand
|
||
|
||
return _expand(keys)
|
||
|
||
|
||
def _merge_skill_board_items(
|
||
moves: list[dict[str, Any]],
|
||
full_items: list[dict[str, Any]],
|
||
limit: int,
|
||
*,
|
||
exclude_keys: set[str] | None = None,
|
||
recent_keys: set[str] | None = None,
|
||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||
"""异动优先,不足时用深池补满;按 source 合并态取条。
|
||
|
||
周去重按 source(含从 skill id 展开);同日避开其它榜时也按 source。
|
||
"""
|
||
from daily.delta import skill_id as move_skill_id
|
||
|
||
exclude = exclude_keys or set()
|
||
recent = expand_skill_recent_keys(recent_keys)
|
||
exclude_sources = {_source_from_skill_key(k) for k in exclude if k}
|
||
exclude_sources.update(k for k in exclude if k)
|
||
|
||
def _blocked(item: dict[str, Any]) -> bool:
|
||
primary = _skill_primary_id(item)
|
||
source = str(item.get("source") or "").strip()
|
||
if primary and primary in recent:
|
||
return True
|
||
if source and source in recent:
|
||
return True
|
||
if primary and primary in exclude:
|
||
return True
|
||
if source and (source in exclude_sources or source in exclude):
|
||
return True
|
||
return False
|
||
|
||
groups: list[dict[str, Any]] = []
|
||
seen_sources: set[str] = set()
|
||
|
||
move_rows: list[dict[str, Any]] = []
|
||
seen_move_ids: set[str] = set()
|
||
for move in moves:
|
||
key = move_skill_id(move)
|
||
if not key or key in seen_move_ids:
|
||
continue
|
||
move_source = str(move.get("source") or "").strip()
|
||
if key in exclude or (move_source and move_source in exclude_sources):
|
||
continue
|
||
if key in recent or (move_source and move_source in recent):
|
||
continue
|
||
seen_move_ids.add(key)
|
||
move_rows.append(_move_to_skill_row(move))
|
||
|
||
for group in _normalize_skill_source_groups(move_rows):
|
||
source = str(group.get("source") or "?")
|
||
if source in seen_sources or _blocked(group):
|
||
continue
|
||
seen_sources.add(source)
|
||
groups.append(group)
|
||
if len(groups) >= limit:
|
||
break
|
||
|
||
if len(groups) < limit:
|
||
for group in _normalize_skill_source_groups(full_items):
|
||
if len(groups) >= limit:
|
||
break
|
||
source = str(group.get("source") or "?")
|
||
if source in seen_sources or _blocked(group):
|
||
continue
|
||
seen_sources.add(source)
|
||
groups.append(group)
|
||
|
||
if not groups:
|
||
return [], set()
|
||
prepared = finalize_wecom_skill_groups(groups[:limit])
|
||
wecom_items = [_grouped_skill_to_wecom_item(x) for x in prepared]
|
||
# 供同日 Hot 排除:主键 + source
|
||
keys: set[str] = set()
|
||
for item in groups[:limit]:
|
||
primary = _skill_primary_id(item)
|
||
if primary:
|
||
keys.add(primary)
|
||
source = str(item.get("source") or "").strip()
|
||
if source:
|
||
keys.add(source)
|
||
return wecom_items[:limit], keys
|
||
|
||
|
||
def build_skills_delta_sections(
|
||
trending_moves: list[dict[str, Any]],
|
||
hot_moves: list[dict[str, Any]],
|
||
*,
|
||
trending_full: list[dict[str, Any]] | None = None,
|
||
hot_full: list[dict[str, Any]] | None = None,
|
||
trending_limit: int = 5,
|
||
hot_limit: int = 5,
|
||
pad: bool = False,
|
||
recent_trending: set[str] | None = None,
|
||
recent_hot: set[str] | None = None,
|
||
) -> str:
|
||
sections: list[str] = []
|
||
trending_keys: set[str] = set()
|
||
if pad:
|
||
skill_recent = expand_skill_recent_keys(
|
||
(recent_trending or set()) | (recent_hot or set())
|
||
)
|
||
t_items, trending_keys = _merge_skill_board_items(
|
||
trending_moves,
|
||
trending_full or [],
|
||
trending_limit,
|
||
recent_keys=skill_recent,
|
||
)
|
||
if t_items:
|
||
lines = [f"{ICONS['trending']} **Skills Trending Top {len(t_items)}**"]
|
||
for rank, item in enumerate(t_items, 1):
|
||
lines.extend(_skill_line(rank, item))
|
||
sections.append("\n".join(lines))
|
||
h_items, _ = _merge_skill_board_items(
|
||
hot_moves,
|
||
hot_full or [],
|
||
hot_limit,
|
||
exclude_keys=trending_keys,
|
||
recent_keys=skill_recent,
|
||
)
|
||
if h_items:
|
||
lines = [f"{ICONS['hot']} **Skills Hot Top {len(h_items)}**"]
|
||
for rank, item in enumerate(h_items, 1):
|
||
lines.extend(_skill_line(rank, item))
|
||
sections.append("\n".join(lines))
|
||
return "\n\n".join(sections)
|
||
|
||
if trending_moves:
|
||
flat = [_move_to_skill_row(m) for m in trending_moves]
|
||
items, _ = _prepare_grouped_wecom_skills(flat, limit=len(flat))
|
||
lines = [f"{ICONS['trending']} **Skills Trending 变化**"]
|
||
for rank, item in enumerate(items, 1):
|
||
lines.extend(_skill_line(rank, item))
|
||
sections.append("\n".join(lines))
|
||
if hot_moves:
|
||
flat = [_move_to_skill_row(m) for m in hot_moves]
|
||
items, _ = _prepare_grouped_wecom_skills(flat, limit=len(flat))
|
||
lines = [f"{ICONS['hot']} **Skills Hot 变化**"]
|
||
for rank, item in enumerate(items, 1):
|
||
lines.extend(_skill_line(rank, item))
|
||
sections.append("\n".join(lines))
|
||
return "\n\n".join(sections)
|
||
|
||
|
||
def _github_move_to_repo(move: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"repo": move.get("repo", "?"),
|
||
"url": move.get("url", ""),
|
||
"language": move.get("language", ""),
|
||
"stars_today_fmt": move.get("stars_today_fmt", ""),
|
||
"total_stars_fmt": move.get("total_stars_fmt", ""),
|
||
"created_at": move.get("created_at", ""),
|
||
"description": move.get("description", ""),
|
||
"desc_short": (move.get("description") or "").strip(),
|
||
}
|
||
|
||
|
||
def _merge_github_board_items(
|
||
moves: list[dict[str, Any]],
|
||
full_repos: list[dict[str, Any]],
|
||
limit: int,
|
||
*,
|
||
recent_repos: set[str] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
recent = recent_repos or set()
|
||
seen: set[str] = set()
|
||
merged: list[dict[str, Any]] = []
|
||
for move in moves:
|
||
repo = _github_move_to_repo(move)
|
||
key = str(repo.get("repo") or "")
|
||
if not key or key in seen or key in recent:
|
||
continue
|
||
seen.add(key)
|
||
merged.append(repo)
|
||
for repo in full_repos:
|
||
if len(merged) >= limit:
|
||
break
|
||
key = str(repo.get("repo") or "")
|
||
if not key or key in seen or key in recent:
|
||
continue
|
||
seen.add(key)
|
||
merged.append(repo)
|
||
return finalize_wecom_github_repos(merged)[:limit]
|
||
|
||
|
||
def build_github_delta_sections(
|
||
movement: dict[str, Any],
|
||
*,
|
||
topic_name: str,
|
||
github_trending: list[dict[str, Any]] | None = None,
|
||
github_emerging: list[dict[str, Any]] | None = None,
|
||
github_topic: list[dict[str, Any]] | None = None,
|
||
trending_limit: int = 5,
|
||
emerging_limit: int = 5,
|
||
topic_limit: int = 5,
|
||
pad: bool = False,
|
||
recent_board_keys: dict[str, set[str]] | None = None,
|
||
) -> str:
|
||
sections: list[str] = []
|
||
recent = recent_board_keys or {}
|
||
if pad:
|
||
github_recent = (
|
||
(recent.get("github_trending") or set())
|
||
| (recent.get("github_emerging") or set())
|
||
| (recent.get("github_topic") or set())
|
||
)
|
||
mapping = [
|
||
("github_trending_moves", "github_trending", github_trending or [], trending_limit, "github", "GitHub Trending", False),
|
||
("github_emerging_moves", "github_emerging", github_emerging or [], emerging_limit, "emerging", "GitHub 新兴", True),
|
||
("github_topic_moves", "github_topic", github_topic or [], topic_limit, "topic", f"Topic `{topic_name}`", False),
|
||
]
|
||
for move_key, board_key, full_repos, limit, icon_key, label, show_created in mapping:
|
||
repos = _merge_github_board_items(
|
||
movement.get(move_key) or [],
|
||
full_repos,
|
||
limit,
|
||
recent_repos=github_recent,
|
||
)
|
||
github_recent |= {str(r.get("repo") or "") for r in repos if r.get("repo")}
|
||
if not repos:
|
||
continue
|
||
lines = [f"{ICONS[icon_key]} **{label} Top {len(repos)}**"]
|
||
lines.extend(_github_repo_lines(repos, show_created=show_created))
|
||
sections.append("\n".join(lines))
|
||
return "\n\n".join(sections)
|
||
|
||
mapping = [
|
||
("github_trending_moves", "github", "GitHub Trending 变化", False),
|
||
("github_emerging_moves", "emerging", "GitHub 新兴 变化", True),
|
||
("github_topic_moves", "topic", f"Topic `{topic_name}` 变化", False),
|
||
]
|
||
for key, icon_key, label, show_created in mapping:
|
||
moves = movement.get(key) or []
|
||
if not moves:
|
||
continue
|
||
lines = [f"{ICONS[icon_key]} **{label}**"]
|
||
repos = finalize_wecom_github_repos([_github_move_to_repo(m) for m in moves])
|
||
lines.extend(_github_repo_lines(repos, show_created=show_created))
|
||
sections.append("\n".join(lines))
|
||
return "\n\n".join(sections)
|
||
|
||
|
||
_SKILL_SECTIONS = re.compile(
|
||
r"📈 \*\*Skills Trending.*?(?=🐙 \*\*GitHub Trending)",
|
||
re.DOTALL,
|
||
)
|
||
_SKILL_TRENDING_BLOCK = re.compile(r"📈 \*\*Skills Trending[^\n]*\n(?:.*?\n)*?(?=\n🔥 \*\*Skills Hot|\n🐙 |\n🌱 |\n🤖 |\Z)", re.DOTALL)
|
||
_SKILL_HOT_BLOCK = re.compile(r"🔥 \*\*Skills Hot[^\n]*\n(?:.*?\n)*?(?=\n🐙 |\n🌱 |\n🤖 |\Z)", re.DOTALL)
|
||
_GITHUB_SECTIONS = re.compile(r"🐙 \*\*GitHub Trending.*", re.DOTALL)
|
||
|
||
|
||
def _strip_board_sections(md: str) -> str:
|
||
md = _SKILL_TRENDING_BLOCK.sub("", md)
|
||
md = _SKILL_HOT_BLOCK.sub("", md)
|
||
if _GITHUB_SECTIONS.search(md):
|
||
md = _GITHUB_SECTIONS.sub("", md)
|
||
return re.sub(r"\n{3,}", "\n\n", md).rstrip()
|
||
|
||
|
||
def resolve_wecom_board_items(
|
||
*,
|
||
mode: str,
|
||
movement: dict[str, Any],
|
||
trending: list[dict[str, Any]],
|
||
hot: list[dict[str, Any]],
|
||
topic_name: str,
|
||
github_trending: list[dict[str, Any]] | None = None,
|
||
github_emerging: list[dict[str, Any]] | None = None,
|
||
github_topic: list[dict[str, Any]] | None = None,
|
||
wecom_trending: int = 5,
|
||
wecom_hot: int = 5,
|
||
wecom_github: int = 5,
|
||
wecom_emerging: int = 5,
|
||
wecom_topic: int = 5,
|
||
pad: bool = False,
|
||
date_str: str | None = None,
|
||
trending_pad: list[dict[str, Any]] | None = None,
|
||
hot_pad: list[dict[str, Any]] | None = None,
|
||
github_trending_pad: list[dict[str, Any]] | None = None,
|
||
github_emerging_pad: list[dict[str, Any]] | None = None,
|
||
github_topic_pad: list[dict[str, Any]] | None = None,
|
||
) -> dict[str, list[dict[str, Any]]]:
|
||
"""返回最终企微正文各榜 items(与 replace_wecom_board_sections 同源),供写回 shown。"""
|
||
from daily.delta import load_recent_board_keys, partition_skill_moves_for_wecom
|
||
|
||
if mode != "delta":
|
||
return {
|
||
"skills_trending": list(trending),
|
||
"skills_hot": list(hot),
|
||
"github_trending": list(github_trending or []),
|
||
"github_emerging": list(github_emerging or []),
|
||
"github_topic": list(github_topic or []),
|
||
}
|
||
|
||
recent_board_keys: dict[str, set[str]] = {}
|
||
skill_recent: set[str] = set()
|
||
github_recent: set[str] = set()
|
||
if pad and date_str:
|
||
recent_board_keys = load_recent_board_keys(date_str)
|
||
# Trending / Hot 共用周去重:任一类出现过的 source 两边都不再展示
|
||
skill_recent = expand_skill_recent_keys(
|
||
(recent_board_keys.get("skills_trending") or set())
|
||
| (recent_board_keys.get("skills_hot") or set())
|
||
)
|
||
# GitHub 三榜共用周去重:任一类出现过的 repo 各榜都不再展示
|
||
github_recent = (
|
||
(recent_board_keys.get("github_trending") or set())
|
||
| (recent_board_keys.get("github_emerging") or set())
|
||
| (recent_board_keys.get("github_topic") or set())
|
||
)
|
||
|
||
t_moves, h_moves = partition_skill_moves_for_wecom(
|
||
movement.get("skills_trending_moves") or [],
|
||
movement.get("skills_hot_moves") or [],
|
||
)
|
||
if pad:
|
||
t_items, trending_keys = _merge_skill_board_items(
|
||
t_moves,
|
||
trending_pad if trending_pad else trending,
|
||
wecom_trending,
|
||
recent_keys=skill_recent,
|
||
)
|
||
h_items, _ = _merge_skill_board_items(
|
||
h_moves,
|
||
hot_pad if hot_pad else hot,
|
||
wecom_hot,
|
||
exclude_keys=trending_keys,
|
||
recent_keys=skill_recent,
|
||
)
|
||
gt_items = _merge_github_board_items(
|
||
movement.get("github_trending_moves") or [],
|
||
github_trending_pad if github_trending_pad else (github_trending or []),
|
||
wecom_github,
|
||
recent_repos=github_recent,
|
||
)
|
||
github_recent |= {str(r.get("repo") or "") for r in gt_items if r.get("repo")}
|
||
ge_items = _merge_github_board_items(
|
||
movement.get("github_emerging_moves") or [],
|
||
github_emerging_pad if github_emerging_pad else (github_emerging or []),
|
||
wecom_emerging,
|
||
recent_repos=github_recent,
|
||
)
|
||
github_recent |= {str(r.get("repo") or "") for r in ge_items if r.get("repo")}
|
||
gtopic_items = _merge_github_board_items(
|
||
movement.get("github_topic_moves") or [],
|
||
github_topic_pad if github_topic_pad else (github_topic or []),
|
||
wecom_topic,
|
||
recent_repos=github_recent,
|
||
)
|
||
return {
|
||
"skills_trending": t_items,
|
||
"skills_hot": h_items,
|
||
"github_trending": gt_items,
|
||
"github_emerging": ge_items,
|
||
"github_topic": gtopic_items,
|
||
}
|
||
|
||
t_flat = [_move_to_skill_row(m) for m in t_moves]
|
||
h_flat = [_move_to_skill_row(m) for m in h_moves]
|
||
t_items, _ = _prepare_grouped_wecom_skills(t_flat, limit=len(t_flat) or 1) if t_flat else ([], set())
|
||
h_items, _ = _prepare_grouped_wecom_skills(h_flat, limit=len(h_flat) or 1) if h_flat else ([], set())
|
||
return {
|
||
"skills_trending": t_items,
|
||
"skills_hot": h_items,
|
||
"github_trending": finalize_wecom_github_repos(
|
||
[_github_move_to_repo(m) for m in (movement.get("github_trending_moves") or [])]
|
||
),
|
||
"github_emerging": finalize_wecom_github_repos(
|
||
[_github_move_to_repo(m) for m in (movement.get("github_emerging_moves") or [])]
|
||
),
|
||
"github_topic": finalize_wecom_github_repos(
|
||
[_github_move_to_repo(m) for m in (movement.get("github_topic_moves") or [])]
|
||
),
|
||
}
|
||
|
||
|
||
def replace_wecom_board_sections(
|
||
md: str,
|
||
*,
|
||
mode: str,
|
||
movement: dict[str, Any],
|
||
trending: list[dict[str, Any]],
|
||
hot: list[dict[str, Any]],
|
||
topic_name: str,
|
||
github_trending: list[dict[str, Any]] | None = None,
|
||
github_emerging: list[dict[str, Any]] | None = None,
|
||
github_topic: list[dict[str, Any]] | None = None,
|
||
wecom_trending: int = 5,
|
||
wecom_hot: int = 5,
|
||
wecom_github: int = 5,
|
||
wecom_emerging: int = 5,
|
||
wecom_topic: int = 5,
|
||
pad: bool = False,
|
||
date_str: str | None = None,
|
||
trending_pad: list[dict[str, Any]] | None = None,
|
||
hot_pad: list[dict[str, Any]] | None = None,
|
||
github_trending_pad: list[dict[str, Any]] | None = None,
|
||
github_emerging_pad: list[dict[str, Any]] | None = None,
|
||
github_topic_pad: list[dict[str, Any]] | None = None,
|
||
) -> str:
|
||
from daily.delta import load_recent_board_keys, partition_skill_moves_for_wecom
|
||
|
||
recent_board_keys: dict[str, set[str]] = {}
|
||
if pad and date_str:
|
||
recent_board_keys = load_recent_board_keys(date_str)
|
||
|
||
if mode == "delta":
|
||
t_moves, h_moves = partition_skill_moves_for_wecom(
|
||
movement.get("skills_trending_moves") or [],
|
||
movement.get("skills_hot_moves") or [],
|
||
)
|
||
skills_sec = build_skills_delta_sections(
|
||
t_moves,
|
||
h_moves,
|
||
trending_full=trending_pad if pad and trending_pad else trending,
|
||
hot_full=hot_pad if pad and hot_pad else hot,
|
||
trending_limit=wecom_trending,
|
||
hot_limit=wecom_hot,
|
||
pad=pad,
|
||
recent_trending=recent_board_keys.get("skills_trending"),
|
||
recent_hot=recent_board_keys.get("skills_hot"),
|
||
)
|
||
github_sec = build_github_delta_sections(
|
||
movement,
|
||
topic_name=topic_name,
|
||
github_trending=github_trending_pad if pad and github_trending_pad else github_trending,
|
||
github_emerging=github_emerging_pad if pad and github_emerging_pad else github_emerging,
|
||
github_topic=github_topic_pad if pad and github_topic_pad else github_topic,
|
||
trending_limit=wecom_github,
|
||
emerging_limit=wecom_emerging,
|
||
topic_limit=wecom_topic,
|
||
pad=pad,
|
||
recent_board_keys=recent_board_keys,
|
||
)
|
||
board_block = "\n\n".join(x for x in [skills_sec, github_sec] if x)
|
||
md = _strip_board_sections(md)
|
||
if board_block:
|
||
return md + "\n\n" + board_block + "\n"
|
||
return md + "\n"
|
||
|
||
trending_sec = build_skills_board_section("trending", "Skills Trending", trending)
|
||
hot_sec = build_skills_board_section("hot", "Skills Hot", hot)
|
||
replacement = f"{trending_sec}\n\n{hot_sec}\n\n"
|
||
if _SKILL_SECTIONS.search(md):
|
||
return _SKILL_SECTIONS.sub(replacement, md)
|
||
github_marker = "🐙 **GitHub Trending"
|
||
idx = md.find(github_marker)
|
||
if idx >= 0:
|
||
return md[:idx] + replacement + md[idx:]
|
||
return md.rstrip() + "\n\n" + replacement
|
||
|
||
|
||
def replace_wecom_skill_sections(
|
||
md: str,
|
||
*,
|
||
trending: list[dict[str, Any]],
|
||
hot: list[dict[str, Any]],
|
||
mode: str = "full",
|
||
movement: dict[str, Any] | None = None,
|
||
topic_name: str = "llm",
|
||
github_trending: list[dict[str, Any]] | None = None,
|
||
github_emerging: list[dict[str, Any]] | None = None,
|
||
github_topic: list[dict[str, Any]] | None = None,
|
||
wecom_trending: int = 5,
|
||
wecom_hot: int = 5,
|
||
wecom_github: int = 5,
|
||
wecom_emerging: int = 5,
|
||
wecom_topic: int = 5,
|
||
pad: bool = False,
|
||
date_str: str | None = None,
|
||
trending_pad: list[dict[str, Any]] | None = None,
|
||
hot_pad: list[dict[str, Any]] | None = None,
|
||
github_trending_pad: list[dict[str, Any]] | None = None,
|
||
github_emerging_pad: list[dict[str, Any]] | None = None,
|
||
github_topic_pad: list[dict[str, Any]] | None = None,
|
||
) -> str:
|
||
"""用 Python 合并后的 Skills 榜替换或插入 Agent 早报中的对应区块。"""
|
||
return replace_wecom_board_sections(
|
||
md,
|
||
mode=mode,
|
||
movement=movement or {},
|
||
trending=trending,
|
||
hot=hot,
|
||
topic_name=topic_name,
|
||
github_trending=github_trending,
|
||
github_emerging=github_emerging,
|
||
github_topic=github_topic,
|
||
wecom_trending=wecom_trending,
|
||
wecom_hot=wecom_hot,
|
||
wecom_github=wecom_github,
|
||
wecom_emerging=wecom_emerging,
|
||
wecom_topic=wecom_topic,
|
||
pad=pad,
|
||
date_str=date_str,
|
||
trending_pad=trending_pad,
|
||
hot_pad=hot_pad,
|
||
github_trending_pad=github_trending_pad,
|
||
github_emerging_pad=github_emerging_pad,
|
||
github_topic_pad=github_topic_pad,
|
||
)
|
||
|
||
|
||
def _format_pick_link(pick_command: str, *, title: str = "", url: str = "") -> str:
|
||
cmd = pick_command.strip()
|
||
if not cmd:
|
||
return ""
|
||
label = title.strip()
|
||
if cmd.startswith("http://") or cmd.startswith("https://"):
|
||
if not label:
|
||
m = re.match(r"https?://github\.com/([^/\s#?]+/[^/\s#?]+)", cmd)
|
||
label = m.group(1) if m else cmd
|
||
return f"[{label}]({cmd})"
|
||
m = re.match(r"npx skills add (\S+)", cmd)
|
||
if m:
|
||
skill_path = m.group(1)
|
||
if not label:
|
||
label = skill_path.split("/")[-1]
|
||
href = url.strip() or f"https://skills.sh/{skill_path}"
|
||
return f"[{label}]({href})"
|
||
return f"`{cmd}`"
|
||
|
||
|
||
def build_wecom_report(
|
||
*,
|
||
date_str: str,
|
||
time_str: str,
|
||
updated: str,
|
||
highlights: list[str],
|
||
theme_line: str,
|
||
trending: list[dict[str, Any]],
|
||
hot: list[dict[str, Any]],
|
||
repos: list[dict[str, Any]],
|
||
emerging: list[dict[str, Any]],
|
||
topic_name: str,
|
||
topic_repos: list[dict[str, Any]],
|
||
ai_news: list[dict[str, Any]] | None = None,
|
||
cn_ai_news: list[dict[str, Any]] | None = None,
|
||
merged_ai_news: list[dict[str, Any]] | None = None,
|
||
merged_tech_ai_news: list[dict[str, Any]] | None = None,
|
||
pick_command: str,
|
||
pick_why: str = "",
|
||
pick_title: str = "",
|
||
pick_url: str = "",
|
||
include_boards: bool = True,
|
||
) -> str:
|
||
lines = [
|
||
f"{ICONS['header']} **早报 · {date_str}**",
|
||
f"> ⏱ {time_str} · 数据截至 {updated}",
|
||
"",
|
||
f"{ICONS['highlights']} **今日速览**",
|
||
]
|
||
for point in highlights[:3]:
|
||
lines.append(f"> {point}")
|
||
lines.append("")
|
||
lines.append(f"{ICONS['theme']} {theme_line}")
|
||
lines.append("")
|
||
|
||
if merged_ai_news or merged_tech_ai_news:
|
||
block = _build_merged_news_block(merged_ai_news or [], merged_tech_ai_news)
|
||
if block:
|
||
lines.append(block.rstrip())
|
||
lines.append("")
|
||
else:
|
||
if ai_news:
|
||
lines.append(f"{ICONS['ainews']} **国际 AI 时讯 Top {len(ai_news)}**")
|
||
lines.extend(_ai_news_lines(ai_news))
|
||
lines.append("")
|
||
|
||
if cn_ai_news:
|
||
lines.append(f"{ICONS['cnainews']} **国内 AI 时讯 Top {len(cn_ai_news)}**")
|
||
lines.extend(_ai_news_lines(cn_ai_news))
|
||
lines.append("")
|
||
|
||
if include_boards:
|
||
lines.append(f"{ICONS['trending']} **Skills Trending Top {len(trending)}**")
|
||
for rank, item in enumerate(trending, 1):
|
||
lines.extend(_skill_line(rank, item, badge=item.get("badge", "")))
|
||
lines.append("")
|
||
|
||
lines.append(f"{ICONS['hot']} **Skills Hot Top {len(hot)}**")
|
||
for rank, item in enumerate(hot, 1):
|
||
lines.extend(_skill_line(rank, item, badge=item.get("badge", "")))
|
||
lines.append("")
|
||
|
||
if repos:
|
||
lines.append(f"{ICONS['github']} **GitHub Trending Top {len(repos)}**")
|
||
lines.extend(_github_repo_lines(finalize_wecom_github_repos(repos)))
|
||
lines.append("")
|
||
|
||
if emerging:
|
||
lines.append(f"{ICONS['emerging']} **新兴项目 Top {len(emerging)}**")
|
||
lines.extend(_github_repo_lines(finalize_wecom_github_repos(emerging), show_created=True))
|
||
lines.append("")
|
||
|
||
if topic_repos:
|
||
lines.append(f"{ICONS['topic']} **Topic `{topic_name}` Top {len(topic_repos)}**")
|
||
lines.extend(_github_repo_lines(finalize_wecom_github_repos(topic_repos)))
|
||
lines.append("")
|
||
|
||
lines.append(f"{ICONS['pick']} **今日首推**")
|
||
lines.append(_format_pick_link(pick_command, title=pick_title, url=pick_url))
|
||
if pick_why:
|
||
lines.append(f"> {pick_why}")
|
||
|
||
return "\n".join(lines)
|