diff --git a/daily/format_wecom.py b/daily/format_wecom.py index 0dd25a0..3091e15 100644 --- a/daily/format_wecom.py +++ b/daily/format_wecom.py @@ -7,6 +7,7 @@ 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, skill_id as board_skill_id from daily.text_utils import trim_brief ICONS = { @@ -55,25 +56,177 @@ def _skill_line(rank: int, item: dict[str, Any], *, badge: str = "") -> list[str return lines -def _ai_news_lines(items: list[dict[str, Any]]) -> list[str]: +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): - title = item.get("title", "?") + 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", "") - pub_suffix = f" · {pub}" if pub else "" + desc = (item.get("desc_short") or "").strip() + pub_suffix = "" if merged else (f" · {pub}" if pub else "") if link: - head = f"{i}. [**{title}**]({link}) · `{source}`{pub_suffix}" + 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}. **{title}** · `{source}`{pub_suffix}" + head = f"{i}. {label} · `{source}`{pub_suffix}" lines.append(head) - if desc: - lines.append(f" > {desc}") 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): @@ -94,12 +247,45 @@ def _github_repo_lines(repos: list[dict[str, Any]], *, show_created: bool = Fals meta_parts.append(f"创建于 {created}") meta = f" · {' · '.join(meta_parts)}" if meta_parts else "" lines.append(f"{i}. [{name}]({url}){meta}") - desc = repo.get("desc_short") or repo.get("description", "") + desc = repo.get("wecom_desc") or repo.get("desc_short") or repo.get("description", "") if desc: - lines.append(f" > {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) @@ -173,6 +359,7 @@ def _grouped_skill_to_wecom_item( 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, @@ -196,39 +383,178 @@ def build_skills_board_section(icon_key: str, board_label: str, items: list[dict def _move_to_wecom_skill_item(move: dict[str, Any]) -> dict[str, Any]: installs = int(move.get("installs") or 0) - rank = move.get("rank", "?") - board_badge = (move.get("badge") or "").strip() - badge = f"[新入 #{rank}]" - if board_badge: - badge = f"{badge} {board_badge}" 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(), - "badge": badge, } +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 _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]]: + from daily.delta import skill_id as move_skill_id + + exclude = exclude_keys or set() + recent = recent_keys or set() + seen: set[str] = set() + flat: list[dict[str, Any]] = [] + for move in moves: + key = move_skill_id(move) + if not key or key in seen or key in exclude: + continue + seen.add(key) + flat.append(_move_to_skill_row(move)) + pad_pool = max(limit * 5, len(flat), 50) + for item in full_items: + if len(flat) >= pad_pool: + break + for row in _flatten_skill_board_item(item): + key = board_skill_id(row) + if not key or key in seen or key in exclude or key in recent: + continue + seen.add(key) + flat.append(row) + return _prepare_grouped_wecom_skills(flat, limit=limit) + + 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 = 10, + hot_limit: int = 10, + 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: + t_items, trending_keys = _merge_skill_board_items( + trending_moves, + trending_full or [], + trending_limit, + recent_keys=recent_trending, + ) + 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=recent_hot, + ) + 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: - prepared = finalize_wecom_skill_groups([_move_to_wecom_skill_item(m) for m in trending_moves]) - items = [_grouped_skill_to_wecom_item(x) for x in prepared] + 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, badge=item.get("badge", ""))) + lines.extend(_skill_line(rank, item)) sections.append("\n".join(lines)) if hot_moves: - prepared = finalize_wecom_skill_groups([_move_to_wecom_skill_item(m) for m in hot_moves]) - items = [_grouped_skill_to_wecom_item(x) for x in prepared] + 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, badge=item.get("badge", ""))) + lines.extend(_skill_line(rank, item)) sections.append("\n".join(lines)) return "\n\n".join(sections) @@ -243,12 +569,72 @@ def _github_move_to_repo(move: dict[str, Any]) -> dict[str, Any]: "created_at": move.get("created_at", ""), "description": move.get("description", ""), "desc_short": (move.get("description") or "").strip(), - "badge": f"[新入 #{move.get('rank', '?')}]", } -def build_github_delta_sections(movement: dict[str, Any], *, topic_name: str) -> str: +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: + 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 = 10, + emerging_limit: int = 10, + topic_limit: int = 10, + pad: bool = False, + recent_board_keys: dict[str, set[str]] | None = None, +) -> str: sections: list[str] = [] + recent = recent_board_keys or {} + if pad: + 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=recent.get(board_key), + ) + 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), @@ -259,13 +645,8 @@ def build_github_delta_sections(movement: dict[str, Any], *, topic_name: str) -> if not moves: continue lines = [f"{ICONS[icon_key]} **{label}**"] - for i, move in enumerate(moves, 1): - repo = _github_move_to_repo(move) - badge = repo.pop("badge", "") - chunk = _github_repo_lines([repo], show_created=show_created) - if chunk: - chunk[0] = f"{i}. {badge} " + chunk[0].split(". ", 1)[-1] - lines.extend(chunk) + 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) @@ -287,6 +668,105 @@ def _strip_board_sections(md: str) -> str: 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 = 10, + wecom_hot: int = 10, + wecom_github: int = 10, + wecom_emerging: int = 10, + wecom_topic: int = 10, + 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]] = {} + if pad and date_str: + recent_board_keys = load_recent_board_keys(date_str) + + 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=recent_board_keys.get("skills_trending"), + ) + h_items, _ = _merge_skill_board_items( + h_moves, + hot_pad if hot_pad else hot, + wecom_hot, + exclude_keys=trending_keys, + recent_keys=recent_board_keys.get("skills_hot"), + ) + return { + "skills_trending": t_items, + "skills_hot": h_items, + "github_trending": _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=recent_board_keys.get("github_trending"), + ), + "github_emerging": _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=recent_board_keys.get("github_emerging"), + ), + "github_topic": _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=recent_board_keys.get("github_topic"), + ), + } + + 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, *, @@ -295,16 +775,56 @@ def replace_wecom_board_sections( 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 = 10, + wecom_hot: int = 10, + wecom_github: int = 10, + wecom_emerging: int = 10, + wecom_topic: int = 10, + 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 partition_skill_moves_for_wecom + 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) - github_sec = build_github_delta_sections(movement, topic_name=topic_name) + 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: @@ -331,6 +851,21 @@ def replace_wecom_skill_sections( 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 = 10, + wecom_hot: int = 10, + wecom_github: int = 10, + wecom_emerging: int = 10, + wecom_topic: int = 10, + 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( @@ -340,6 +875,21 @@ def replace_wecom_skill_sections( 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, ) @@ -378,6 +928,8 @@ def build_wecom_report( 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 = "", @@ -396,15 +948,21 @@ def build_wecom_report( lines.append(f"{ICONS['theme']} {theme_line}") lines.append("") - if ai_news: - lines.append(f"{ICONS['ainews']} **国际 AI 时讯 Top {len(ai_news)}**") - lines.extend(_ai_news_lines(ai_news)) - 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 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)}**") @@ -419,17 +977,17 @@ def build_wecom_report( if repos: lines.append(f"{ICONS['github']} **GitHub Trending Top {len(repos)}**") - lines.extend(_github_repo_lines(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(emerging, show_created=True)) + 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(topic_repos)) + lines.extend(_github_repo_lines(finalize_wecom_github_repos(topic_repos))) lines.append("") lines.append(f"{ICONS['pick']} **今日首推**") diff --git a/daily/generate.py b/daily/generate.py index 61c3199..9a75178 100644 --- a/daily/generate.py +++ b/daily/generate.py @@ -22,17 +22,30 @@ from daily.config import ( LOG_DIR, OUTPUT_DIR, SNAPSHOT_FILE, + board_pool_size, env, env_int, full_desc_limit, news_summary_limit, + wecom_delta_pad, + wecom_news_desc_limit, + wecom_pad_pool_size, wecom_skill_desc_limit, ) +from daily.board_history import ( + BOARD_KEYS, + extract_shown_keys, + load_recent_shown_keys, + merge_wecom_shown_into_data, +) +from daily.board_select import board_select from daily.format_wecom import ( build_wecom_report, finalize_wecom_skill_groups, replace_wecom_board_sections, + replace_wecom_news_sections, replace_wecom_skill_sections, + resolve_wecom_board_items, ) from daily.agent_workflow import is_agent_mode, run_agent_workflow from daily.featured_pick import ( @@ -56,10 +69,17 @@ from daily.news.fetch import ( fetch_cn_ai_news, format_cn_news_section, format_news_section, + finalize_wecom_news_items, prepare_wecom_cn_news_items, prepare_wecom_news_items, + sync_wecom_news_rows, ) from daily.news.pushed_links import record_pushed_links +from daily.news.research import ( + fetch_ai_news_research, + format_research_news_section, + is_research_mode, +) from daily.push_gate import evaluate_push_gate from daily.report_data import ( build_full_payload, @@ -68,7 +88,6 @@ from daily.report_data import ( save_json, ) from daily.skills_board import format_installs, load_boards, load_feed -from daily.skills_group import group_skills_by_source THEME_RULES: list[tuple[str, str, list[str]]] = [ ("🎬", "AI 多媒体 / 视频", ["runcomfy", "remotion", "video", "seedance", "inpaint", "lipsync"]), @@ -140,7 +159,30 @@ def _localize_descriptions_in_place( seen_news.add(link) summary = (item.get("summary") or "").strip() if summary: - jobs.append(LocalizeJob(f"news:{link}", summary, news_limit)) + jobs.append( + LocalizeJob( + f"news:{link}", + summary, + news_limit if news_limit > 0 else wecom_news_desc_limit(), + ) + ) + + if cn_ai_news and cn_ai_news.get("enabled"): + seen_cn: set[str] = set() + for item in cn_ai_news.get("flat") or []: + link = item.get("link", "") + if not link or link in seen_cn: + continue + seen_cn.add(link) + summary = (item.get("summary") or "").strip() + if summary and needs_chinese(summary): + jobs.append( + LocalizeJob( + f"news:{link}", + summary, + news_limit if news_limit > 0 else wecom_news_desc_limit(), + ) + ) zh_map = localize_descriptions(jobs, archive=True) if not zh_map and not jobs: @@ -166,6 +208,11 @@ def _localize_descriptions_in_place( key = f"news:{item.get('link', '')}" if key in mapping: item["summary"] = mapping[key] + if cn_ai_news and cn_ai_news.get("enabled"): + for item in cn_ai_news.get("flat") or []: + key = f"news:{item.get('link', '')}" + if key in mapping: + item["summary"] = mapping[key] _apply_zh(zh_map) @@ -199,7 +246,29 @@ def _localize_descriptions_in_place( seen_news.add(link) summary = (item.get("summary") or "").strip() if needs_chinese(summary): - retry_jobs.append(LocalizeJob(f"news:{link}", summary, news_limit)) + retry_jobs.append( + LocalizeJob( + f"news:{link}", + summary, + news_limit if news_limit > 0 else wecom_news_desc_limit(), + ) + ) + if cn_ai_news and cn_ai_news.get("enabled"): + seen_cn: set[str] = set() + for item in cn_ai_news.get("flat") or []: + link = item.get("link", "") + if not link or link in seen_cn: + continue + seen_cn.add(link) + summary = (item.get("summary") or "").strip() + if needs_chinese(summary): + retry_jobs.append( + LocalizeJob( + f"news:{link}", + summary, + news_limit if news_limit > 0 else wecom_news_desc_limit(), + ) + ) if retry_jobs: _apply_zh(localize_descriptions(retry_jobs, archive=True)) @@ -334,6 +403,27 @@ def _prepare_github_item(item: dict[str, Any]) -> dict[str, Any]: return {**item, "desc_short": _wecom_desc(item.get("description", ""), 40)} +def _sync_movement_github_descriptions( + movement: dict[str, Any], + *, + github_trending: list[dict[str, Any]], + github_emerging: list[dict[str, Any]], + github_topic: list[dict[str, Any]], +) -> None: + """将已中文化的 GitHub 描述同步到 movement 新入榜条目(供 Delta 企微列表使用)。""" + by_repo: dict[str, str] = {} + for item in github_trending + github_emerging + github_topic: + repo = str(item.get("repo") or "") + desc = (item.get("description") or "").strip() + if repo and desc: + by_repo[repo] = desc + for key in ("github_trending_moves", "github_emerging_moves", "github_topic_moves"): + for move in movement.get(key) or []: + repo = str(move.get("repo") or "") + if repo in by_repo: + move["description"] = by_repo[repo] + + def _fetch_latest_release_title(repo: str) -> str | None: atom_url = f"https://github.com/{repo}/releases.atom" try: @@ -425,15 +515,17 @@ def generate_report() -> tuple[str, str, Path, Path]: skill_pool = max(10, env_int("DAILY_WECOM_SKILL_POOL", 200)) wecom_trending = env_int("DAILY_WECOM_TRENDING", 10) wecom_hot = env_int("DAILY_WECOM_HOT", 10) + pad_pool = wecom_pad_pool_size(max(wecom_trending, wecom_hot, 10)) + skill_pool = max(skill_pool, pad_pool) github_limit = env_int("DAILY_GITHUB_TRENDING_LIMIT", 10) wecom_github = env_int("DAILY_WECOM_GITHUB_TRENDING", env_int("DAILY_WECOM_REPOS", 10)) - github_fetch_n = max(github_limit, compare_n, wecom_github) + github_fetch_n = max(github_limit, compare_n, wecom_github, pad_pool) emerging_limit = env_int("DAILY_GITHUB_EMERGING_LIMIT", 10) wecom_emerging = env_int("DAILY_WECOM_GITHUB_EMERGING", 10) - emerging_fetch_n = max(emerging_limit, compare_n, wecom_emerging) + emerging_fetch_n = max(emerging_limit, compare_n, wecom_emerging, pad_pool) topic_limit = env_int("DAILY_GITHUB_TOPIC_LIMIT", 10) wecom_topic = env_int("DAILY_WECOM_GITHUB_TOPIC", 10) - topic_fetch_n = max(topic_limit, compare_n, wecom_topic) + topic_fetch_n = max(topic_limit, compare_n, wecom_topic, pad_pool) feed = load_feed(force=True) prev_ids = _load_snapshot() @@ -448,8 +540,27 @@ def generate_report() -> tuple[str, str, Path, Path]: github_emerging = fetch_emerging_repos(emerging_fetch_n, exclude=seen_repos) seen_repos.update(r["repo"] for r in github_emerging) topic_name, github_topic = fetch_topic_hot_repos(topic_fetch_n, exclude=seen_repos) - ai_news = fetch_ai_news() - cn_ai_news = fetch_cn_ai_news() + + news_merged = is_research_mode() + ai_news_research: dict[str, Any] | None = None + wecom_news: list[dict[str, Any]] = [] + wecom_tech_news: list[dict[str, Any]] = [] + if news_merged: + ai_news_research = fetch_ai_news_research(date_str=date_str) + wecom_news = list(ai_news_research.get("items") or []) + wecom_tech_news = list(ai_news_research.get("tech_items") or []) + ai_news = { + "enabled": ai_news_research.get("enabled", False), + "mode": "research", + "hours": ai_news_research.get("hours", 24), + "flat": ai_news_research.get("flat") or [], + "categories": [], + "stats": ai_news_research.get("stats") or {}, + } + cn_ai_news = {"enabled": False, "categories": [], "flat": [], "stats": {}} + else: + ai_news = fetch_ai_news() + cn_ai_news = fetch_cn_ai_news() wecom_limits = { "trending": wecom_trending, @@ -460,7 +571,56 @@ def generate_report() -> tuple[str, str, Path, Path]: "emerging": wecom_emerging, "topic": wecom_topic, "ai_news": env_int("DAILY_WECOM_AI_NEWS", 10), - "cn_ai_news": env_int("DAILY_WECOM_CN_AI_NEWS", 8), + "cn_ai_news": env_int("DAILY_WECOM_CN_AI_NEWS", 10), + } + pool = max(board_pool_size(), skill_pool, pad_pool) + recent_shown = load_recent_shown_keys(date_str) + selected_trending = board_select( + board="skills_trending", + items=trending, + recent_keys=recent_shown["skills_trending"], + limit=wecom_trending, + pool_size=pool, + kind="skill", + ) + selected_hot = board_select( + board="skills_hot", + items=hot, + recent_keys=recent_shown["skills_hot"], + limit=wecom_hot, + pool_size=pool, + kind="skill", + ) + selected_github = board_select( + board="github_trending", + items=github_trending, + recent_keys=recent_shown["github_trending"], + limit=wecom_github, + pool_size=pool, + kind="github", + ) + selected_emerging = board_select( + board="github_emerging", + items=github_emerging, + recent_keys=recent_shown["github_emerging"], + limit=wecom_emerging, + pool_size=pool, + kind="github", + ) + selected_topic = board_select( + board="github_topic", + items=github_topic, + recent_keys=recent_shown["github_topic"], + limit=wecom_topic, + pool_size=pool, + kind="github", + ) + boards_for_wecom = { + "skills_trending": selected_trending, + "skills_hot": selected_hot, + "github_trending": selected_github, + "github_emerging": selected_emerging, + "github_topic": selected_topic, } llm_input = build_llm_input( date_str=date_str, @@ -474,15 +634,22 @@ def generate_report() -> tuple[str, str, Path, Path]: ai_news=ai_news, cn_ai_news=cn_ai_news, wecom_limits=wecom_limits, + research_items=wecom_news if news_merged else None, + research_tech_items=wecom_tech_news if news_merged else None, + boards_for_wecom=boards_for_wecom, ) featured = apply_featured_pick(llm_input, date_str=date_str) movement = llm_input["movement"] eff_mode = llm_input["effective_wecom_mode"] - wecom_ai = prepare_wecom_news_items(ai_news, date_str=date_str) - wecom_cn = prepare_wecom_cn_news_items(cn_ai_news, date_str=date_str) + if news_merged: + wecom_ai: list[dict[str, Any]] = [] + wecom_cn: list[dict[str, Any]] = [] + else: + wecom_ai = prepare_wecom_news_items(ai_news, date_str=date_str) + wecom_cn = prepare_wecom_cn_news_items(cn_ai_news, date_str=date_str) push_gate = evaluate_push_gate( movement=movement, - ai_news_items=wecom_ai, + ai_news_items=wecom_news if news_merged else wecom_ai, cn_ai_news_items=wecom_cn, featured_pick=featured, ) @@ -526,6 +693,17 @@ def generate_report() -> tuple[str, str, Path, Path]: _localize_descriptions_in_place( trending, hot, github_trending, github_emerging, github_topic, ai_news, cn_ai_news ) + if not news_merged: + sync_wecom_news_rows(wecom_ai, ai_news.get("flat") or []) + sync_wecom_news_rows(wecom_cn, cn_ai_news.get("flat") or []) + finalize_wecom_news_items(wecom_ai, force_chinese=True) + finalize_wecom_news_items(wecom_cn, force_chinese=False) + _sync_movement_github_descriptions( + movement, + github_trending=github_trending, + github_emerging=github_emerging, + github_topic=github_topic, + ) themes = _theme_clusters(feed) @@ -534,7 +712,8 @@ def generate_report() -> tuple[str, str, Path, Path]: "", f"> 生成时间:{now.strftime('%Y-%m-%d %H:%M')} (UTC+8) ", f"> skills 数据更新:{updated} ", - "> 数据来源:[skills.sh/trending](https://skills.sh/trending) · [skills.sh/hot](https://skills.sh/hot) · 国际/国内 AI RSS", + "> 数据来源:[skills.sh/trending](https://skills.sh/trending) · [skills.sh/hot](https://skills.sh/hot)" + + (" · AI 时讯 Deep Research" if news_merged else " · 国际/国内 AI RSS"), "", "---", "", @@ -576,10 +755,14 @@ def generate_report() -> tuple[str, str, Path, Path]: lines.append("") section_no = 6 - lines.extend(format_news_section(ai_news, section_no=section_no)) - section_no += 1 - lines.extend(format_cn_news_section(cn_ai_news, section_no=section_no)) - section_no += 1 + if news_merged and ai_news_research is not None: + lines.extend(format_research_news_section(ai_news_research, section_no=section_no)) + section_no += 1 + else: + lines.extend(format_news_section(ai_news, section_no=section_no)) + section_no += 1 + lines.extend(format_cn_news_section(cn_ai_news, section_no=section_no)) + section_no += 1 watch = (env("GITHUB_REPOS") or "").strip() if watch: @@ -616,17 +799,85 @@ def generate_report() -> tuple[str, str, Path, Path]: lines.extend(["```", "", f"*企微短版见 `output/{date_str}.wecom.md`*"]) markdown = "\n".join(lines) - gt = group_skills_by_source(trending, limit=wecom_trending, pool_size=skill_pool) - gh = group_skills_by_source(hot, limit=wecom_hot, pool_size=skill_pool) - if agent_wecom: - wecom_md = replace_wecom_skill_sections( - agent_wecom, - trending=gt, - hot=gh, - mode=eff_mode, - movement=movement, - topic_name=topic_name, + gt = selected_trending + gh = selected_hot + gt_pad = board_select( + board="skills_trending", + items=trending, + recent_keys=recent_shown["skills_trending"], + limit=pad_pool, + pool_size=pool, + kind="skill", + ) + gh_pad = board_select( + board="skills_hot", + items=hot, + recent_keys=recent_shown["skills_hot"], + limit=pad_pool, + pool_size=pool, + kind="skill", + ) + wecom_github_items = [_prepare_github_item(item) for item in selected_github] + wecom_emerging_items = [_prepare_github_item(item) for item in selected_emerging] + wecom_topic_items = [_prepare_github_item(item) for item in selected_topic] + wecom_github_pad = [ + _prepare_github_item(item) + for item in board_select( + board="github_trending", + items=github_trending, + recent_keys=recent_shown["github_trending"], + limit=pad_pool, + pool_size=pool, + kind="github", ) + ] + wecom_emerging_pad = [ + _prepare_github_item(item) + for item in board_select( + board="github_emerging", + items=github_emerging, + recent_keys=recent_shown["github_emerging"], + limit=pad_pool, + pool_size=pool, + kind="github", + ) + ] + wecom_topic_pad = [ + _prepare_github_item(item) + for item in board_select( + board="github_topic", + items=github_topic, + recent_keys=recent_shown["github_topic"], + limit=pad_pool, + pool_size=pool, + kind="github", + ) + ] + delta_pad = eff_mode == "delta" and wecom_delta_pad() + board_kwargs = { + "mode": eff_mode, + "movement": movement, + "trending": gt, + "hot": gh, + "topic_name": topic_name, + "github_trending": wecom_github_items, + "github_emerging": wecom_emerging_items, + "github_topic": wecom_topic_items, + "wecom_trending": wecom_trending, + "wecom_hot": wecom_hot, + "wecom_github": wecom_github, + "wecom_emerging": wecom_emerging, + "wecom_topic": wecom_topic, + "pad": delta_pad, + "date_str": date_str, + "trending_pad": gt_pad, + "hot_pad": gh_pad, + "github_trending_pad": wecom_github_pad, + "github_emerging_pad": wecom_emerging_pad, + "github_topic_pad": wecom_topic_pad, + } + if agent_wecom: + wecom_md = replace_wecom_skill_sections(agent_wecom, **board_kwargs) else: wecom_md = build_wecom_report( date_str=date_str, @@ -635,8 +886,10 @@ def generate_report() -> tuple[str, str, Path, Path]: highlights=editorial_highlights or _build_highlights(trending, hot, github_trending, github_emerging, ai_news, cn_ai_news), theme_line=editorial_theme or _detect_theme_line(feed), - ai_news=wecom_ai, - cn_ai_news=wecom_cn, + ai_news=wecom_ai if not news_merged else None, + cn_ai_news=wecom_cn if not news_merged else None, + merged_ai_news=wecom_news if news_merged else None, + merged_tech_ai_news=wecom_tech_news if news_merged else None, trending=[ _prepare_skill_item(item, prev_ids, r) for r, item in enumerate(finalize_wecom_skill_groups(gt), 1) @@ -645,29 +898,40 @@ def generate_report() -> tuple[str, str, Path, Path]: _prepare_skill_item(item, prev_ids, r) for r, item in enumerate(finalize_wecom_skill_groups(gh), 1) ], - repos=[_prepare_github_item(item) for item in github_trending[:wecom_github]], - emerging=[_prepare_github_item(item) for item in github_emerging[:wecom_emerging]], + repos=wecom_github_items, + emerging=wecom_emerging_items, topic_name=topic_name, - topic_repos=[_prepare_github_item(item) for item in github_topic[:wecom_topic]], + topic_repos=wecom_topic_items, pick_command=pick_command, pick_why=pick_why, pick_title=pick_title, pick_url=pick_url, include_boards=(eff_mode == "full"), ) - wecom_md = replace_wecom_board_sections( - wecom_md, - mode=eff_mode, - movement=movement, - trending=gt, - hot=gh, - topic_name=topic_name, - ) + wecom_md = replace_wecom_board_sections(wecom_md, **board_kwargs) + + wecom_md = replace_wecom_news_sections( + wecom_md, + ai_news=wecom_news if news_merged else wecom_ai, + cn_ai_news=None if news_merged else wecom_cn, + tech_ai_news=wecom_tech_news if news_merged else None, + merged=news_merged, + ) if push_gate.should_push: - links = [x["link"] for x in wecom_ai + wecom_cn if x.get("link")] + if news_merged: + links = [x["link"] for x in wecom_news + wecom_tech_news if x.get("link")] + else: + links = [x["link"] for x in wecom_ai + wecom_cn if x.get("link")] record_pushed_links(date_str, links) + final_boards = resolve_wecom_board_items(**board_kwargs) + shown_keys = { + board: extract_shown_keys(board, final_boards.get(board) or []) + for board in BOARD_KEYS + } + llm_input = merge_wecom_shown_into_data(llm_input, shown_keys) + save_json( data_json_path(date_str), build_full_payload( @@ -675,6 +939,7 @@ def generate_report() -> tuple[str, str, Path, Path]: meta={ "generated_at": now.isoformat(), "report_mode": "agent" if is_agent_mode() else "classic", + "ai_news_mode": "research" if news_merged else "rss", "cursor_editor": cursor_editor_enabled() and not is_agent_mode(), "featured_pick": featured.get("title") if featured else None, "effective_wecom_mode": eff_mode, diff --git a/daily/report_data.py b/daily/report_data.py index c2d3799..f787bce 100644 --- a/daily/report_data.py +++ b/daily/report_data.py @@ -10,6 +10,7 @@ from daily.config import OUTPUT_DIR, env_int, wecom_mode from daily.delta import build_movement_baseline, build_movement_context, compare_depth, effective_wecom_mode from daily.news.fetch import prepare_wecom_cn_news_items, prepare_wecom_news_items from daily.skills_group import group_skills_by_source +from daily.text_utils import trim_brief def skill_id(item: dict[str, Any]) -> str: @@ -69,7 +70,10 @@ def _slim_news_items( "title": item.get("title", ""), "source_name": item.get("source_name", ""), "published_fmt": item.get("published_fmt", ""), - "summary": item.get("desc_short") or "", + "summary": trim_brief( + item.get("summary_plain") or item.get("desc_short") or "", + 120, + ), } ) if len(items) >= limit: @@ -106,10 +110,46 @@ def build_llm_input( ai_news: dict[str, Any], cn_ai_news: dict[str, Any], wecom_limits: dict[str, int], + research_items: list[dict[str, Any]] | None = None, + research_tech_items: list[dict[str, Any]] | None = None, + boards_for_wecom: dict[str, list[dict[str, Any]]] | None = None, ) -> dict[str, Any]: """供 Cursor 编辑的精简 JSON(不含完整 markdown)。""" news_limit = wecom_limits.get("ai_news", 10) cn_news_limit = wecom_limits.get("cn_ai_news", 8) + if research_items is not None: + slim_research = [ + { + "link": item.get("link", ""), + "title": item.get("title", ""), + "source_name": item.get("source_name", ""), + "published_fmt": item.get("published_fmt", ""), + "summary": trim_brief(item.get("desc_short") or "", 120), + } + for item in research_items[:news_limit] + ] + ai_news_payload = slim_research + tech_news_payload = [ + { + "link": item.get("link", ""), + "title": item.get("title", ""), + "source_name": item.get("source_name", ""), + "published_fmt": item.get("published_fmt", ""), + "summary": trim_brief(item.get("desc_short") or "", 120), + } + for item in (research_tech_items or []) + ] + cn_news_payload: list[dict[str, Any]] = [] + ai_news_mode = "research" + else: + ai_news_payload = _slim_news_items(ai_news, news_limit, date_str=date_str) if ai_news.get("enabled") else [] + cn_news_payload = ( + _slim_news_items(cn_ai_news, cn_news_limit, prepare=prepare_wecom_cn_news_items, date_str=date_str) + if cn_ai_news.get("enabled") + else [] + ) + ai_news_mode = "rss" + tech_news_payload: list[dict[str, Any]] = [] depth = compare_depth() trend_cmp = trending[:depth] hot_cmp = hot[:depth] @@ -117,19 +157,26 @@ def build_llm_input( emerging_cmp = github_emerging[:depth] topic_cmp = github_topic[:depth] - trending_slice = group_skills_by_source( - trending, - limit=wecom_limits.get("trending", 10), - pool_size=wecom_limits.get("trending_pool", _wecom_skill_pool()), - ) - hot_slice = group_skills_by_source( - hot, - limit=wecom_limits.get("hot", 10), - pool_size=wecom_limits.get("hot_pool", _wecom_skill_pool()), - ) - github_slice = github_trending[: wecom_limits.get("github", 5)] - emerging_slice = github_emerging[: wecom_limits.get("emerging", 3)] - topic_slice = github_topic[: wecom_limits.get("topic", 3)] + if boards_for_wecom: + trending_slice = boards_for_wecom.get("skills_trending") or [] + hot_slice = boards_for_wecom.get("skills_hot") or [] + github_slice = boards_for_wecom.get("github_trending") or [] + emerging_slice = boards_for_wecom.get("github_emerging") or [] + topic_slice = boards_for_wecom.get("github_topic") or [] + else: + trending_slice = group_skills_by_source( + trending, + limit=wecom_limits.get("trending", 10), + pool_size=wecom_limits.get("trending_pool", _wecom_skill_pool()), + ) + hot_slice = group_skills_by_source( + hot, + limit=wecom_limits.get("hot", 10), + pool_size=wecom_limits.get("hot_pool", _wecom_skill_pool()), + ) + github_slice = github_trending[: wecom_limits.get("github", 5)] + emerging_slice = github_emerging[: wecom_limits.get("emerging", 3)] + topic_slice = github_topic[: wecom_limits.get("topic", 3)] movement = build_movement_context( date_str=date_str, @@ -163,12 +210,10 @@ def build_llm_input( "topic": topic_name, "repos": [_slim_github(x) for x in topic_slice], }, - "ai_news": _slim_news_items(ai_news, news_limit, date_str=date_str) if ai_news.get("enabled") else [], - "cn_ai_news": _slim_news_items( - cn_ai_news, cn_news_limit, prepare=prepare_wecom_cn_news_items, date_str=date_str - ) - if cn_ai_news.get("enabled") - else [], + "ai_news": ai_news_payload, + "tech_ai_news": tech_news_payload, + "cn_ai_news": cn_news_payload, + "ai_news_mode": ai_news_mode, "movement": movement, "movement_baseline": movement_baseline, } diff --git a/tests/test_board_history.py b/tests/test_board_history.py index 61d8097..1e9adfc 100644 --- a/tests/test_board_history.py +++ b/tests/test_board_history.py @@ -60,6 +60,33 @@ class ShownKeysTests(unittest.TestCase): ) self.assertEqual(merged["wecom_shown_keys"]["github_trending"], ["shown/one"]) + def test_persist_shown_keys_differs_from_baseline_keys(self): + from daily.board_select import board_select + + raw = [{"repo": f"o/r{i}"} for i in range(10)] + recent = {f"o/r{i}" for i in range(3)} + selected = board_select( + board="github_trending", + items=raw, + recent_keys=recent, + limit=5, + pool_size=50, + kind="github", + ) + baseline_keys = [x["repo"] for x in raw[:5]] + shown = extract_shown_keys("github_trending", selected) + data = { + "movement_baseline": { + "github_trending": [{"repo": k} for k in baseline_keys], + }, + } + merged = merge_wecom_shown_into_data(data, {"github_trending": shown}) + self.assertNotEqual( + set(merged["wecom_shown_keys"]["github_trending"]), + {x["repo"] for x in merged["movement_baseline"]["github_trending"]}, + ) + self.assertEqual(shown, ["o/r3", "o/r4", "o/r5", "o/r6", "o/r7"]) + if __name__ == "__main__": unittest.main()