""" Redbook (小红书) state accessor. Provides convenient access to Redbook app state with comparison utilities. """ from __future__ import annotations import json import re from functools import cached_property from pathlib import Path from typing import Any, Sequence from bench_env.task.base import BaseApp from bench_env.task.utils import norm _DEFAULTS_PATH = Path(__file__).resolve().parents[3] / "apps" / "RedBook" / "data" / "defaults.json" _REDBOOK_DATA_DIR = _DEFAULTS_PATH.parent _REDBOOK_USERS_JSON_PATH = _REDBOOK_DATA_DIR / "users.json" _REDBOOK_NOTES_JSON_PATH = _REDBOOK_DATA_DIR / "notes.json" _DEFAULTS = json.loads(_DEFAULTS_PATH.read_text(encoding="utf-8")) _REDBOOK_USERS_JSON_CACHE: dict[str, dict[str, Any]] | None = None _REDBOOK_NOTES_JSON_CACHE: list[dict[str, Any]] | None = None _REDBOOK_NOTES_BY_ID_CACHE: dict[str, dict[str, Any]] | None = None # HomePage category key → Chinese label mapping. # Aligned with apps/RedBook/res/strings.ts — update here when categories change. _CATEGORY_LABELS: dict[str, str] = { "recommend": "推荐", "video": "视频", "live": "直播", "short_drama": "短剧", "avatar": "头像", "fashion": "穿搭", "wallpaper": "壁纸", "food": "美食", "emotions": "情感", "music": "音乐", "nails": "美甲", "funny": "搞笑", "crafts": "手工", "travel": "旅行", "makeup": "彩妆", "hairstyle": "发型", "dance": "舞蹈", "drawing": "绘画", "reading": "读书", "home_decor": "家装", "celebrity": "明星", "movies_and_tv": "影视", "games": "游戏", "photography": "摄影", "anime": "动漫", "home_2": "家居", "cars": "汽车", "weight_loss": "减脂", "study": "学习", "skincare": "护肤", "wedding": "婚礼", "stationery": "文具", "sneakers": "潮鞋", "career": "职场", "culture": "文化", "pets": "萌宠", "tech": "科技", "fitness": "健身", "variety_shows": "综艺", "bags": "箱包", "science": "科学", "baby": "母婴", "homepage_art": "艺术", "psychology": "心理", "motorcycle": "机车", "campus": "校园", "sports": "体育", "outdoor": "户外", "figures": "潮玩", "camping": "露营", "social_science": "社科", "humanities": "人文", } REDBOOK_GENERAL_SETTING_VALUES = { "使用移动流量下载": "mobileDownload", "视频HDR效果": "videoHDR", "使用移动网络改善浏览体验": "mobileNetwork", "视频和直播默认静音": "muteVideo", "默认播放图文笔记声音": "playAudio", } REDBOOK_NOTIFICATION_SETTING_VALUES = { "赞和收藏": "likeCollect", "评论": "comment", "新增关注": "newFollow", "@我": "atMe", "购物及售后": "storeNotif", } REDBOOK_PRIVACY_SETTING_VALUES = { "一键防护": "oneClickProtect", "展示聊天标识": "showChatStatus", "只允许关注的人评论": "onlyFollowComment", "允许下载全部笔记": "allowDownload", "给我推荐可能认识的人": "recommendPeople", } REDBOOK_ONLINE_STATUS_VALUES = { "公开": "public", "好友": "friends", "关闭": "closed", } REDBOOK_LANGUAGE_VALUES = { "简体中文": "zh-CN", "英文": "en-US", } REDBOOK_SEARCH_SORT_VALUES = { "最新": "latest", "最多点赞": "likes", "最多评论": "comments", "最多收藏": "collects", } REDBOOK_SEARCH_KEYWORDS = [ "OOTD", "护肤", "美食", "探店", "旅行", "教程", "读书", ] REDBOOK_COLLECTIBLE_KEYWORDS = [ "护肤", "教程", "读书", "家居", ] REDBOOK_FEED_NOTE_KEYWORDS = [ "博士生", "动物", "生日", "分享", ] REDBOOK_REPLYABLE_FEED_NOTE_KEYWORDS = [ "博士生", "动物", "生日", "分享", ] REDBOOK_KEYWORD_PARAM = { "type": "enum", "values": ["数分", "美食", "探店", "分享"], "default": "数分", } REDBOOK_FOLLOWING_USER_PARAM = { "type": "enum", "values": ["西柚慢行", "安静岛"], "default": "西柚慢行", } REDBOOK_PUBLISH_CHANGES = [ "redbook.user.publishedNoteIds", "redbook.notes", "redbook.publishDraft", ] # JS `parseFloat` leading-numeric-prefix grammar: optional sign, then # - integer or fractional part (with optional decimal point and digits), or .digits # - optional exponent (e/E with optional sign and digits) _PARSE_FLOAT_PREFIX_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?") def _parse_float_prefix(raw: str) -> float: """Match JS `parseFloat` semantics: parse the leading numeric prefix, ignoring trailing junk. `parseFloat("1.2.3") === 1.2`; `parseFloat("1e3") === 1000`; `parseFloat("abc") === NaN` (returned as 0.0 here). """ m = _PARSE_FLOAT_PREFIX_RE.match(raw) if not m: return 0.0 try: return float(m.group(0)) except ValueError: return 0.0 def _strip_first(raw: str, ch: str) -> str: """Remove the first occurrence of `ch` from `raw` (case-insensitive when `ch` is ASCII). Mirrors JS `String.prototype.replace(literal, '')` which replaces ONLY the first match. """ idx = raw.lower().find(ch.lower()) if ch.isascii() else raw.find(ch) if idx < 0: return raw return raw[:idx] + raw[idx + len(ch):] def _parse_count(value: Any) -> float: if value is None: return 0.0 if isinstance(value, (int, float)): return float(value) if (value == value) else 0.0 # filters NaN raw = str(value).replace("+", "").strip() if not raw: return 0.0 # Match the TS `parseRedBookCount`: substring test + first-occurrence strip, then ×10000. if "万" in raw: return _parse_float_prefix(_strip_first(raw, "万")) * 10000 if "w" in raw.lower(): return _parse_float_prefix(_strip_first(raw, "w")) * 10000 return _parse_float_prefix(raw) def _load_users_json() -> dict[str, dict[str, Any]]: global _REDBOOK_USERS_JSON_CACHE if _REDBOOK_USERS_JSON_CACHE is not None: return _REDBOOK_USERS_JSON_CACHE try: raw = json.loads(_REDBOOK_USERS_JSON_PATH.read_text(encoding="utf-8")) or [] users = raw if isinstance(raw, list) else [] except Exception: users = [] _REDBOOK_USERS_JSON_CACHE = { str(user.get("id")): user for user in users if isinstance(user, dict) and user.get("id") } return _REDBOOK_USERS_JSON_CACHE def _load_notes_json() -> list[dict[str, Any]]: global _REDBOOK_NOTES_JSON_CACHE if _REDBOOK_NOTES_JSON_CACHE is not None: return _REDBOOK_NOTES_JSON_CACHE try: raw = json.loads(_REDBOOK_NOTES_JSON_PATH.read_text(encoding="utf-8")) or [] _REDBOOK_NOTES_JSON_CACHE = raw if isinstance(raw, list) else [] except Exception: _REDBOOK_NOTES_JSON_CACHE = [] return _REDBOOK_NOTES_JSON_CACHE def _load_notes_by_id() -> dict[str, dict[str, Any]]: global _REDBOOK_NOTES_BY_ID_CACHE if _REDBOOK_NOTES_BY_ID_CACHE is not None: return _REDBOOK_NOTES_BY_ID_CACHE _REDBOOK_NOTES_BY_ID_CACHE = { str(note.get("id")): note for note in _load_notes_json() if isinstance(note, dict) and note.get("id") } return _REDBOOK_NOTES_BY_ID_CACHE _REDBOOK_BASE_PARSED_COUNTS_CACHE: dict[str, tuple[int, int, int]] | None = None _REDBOOK_BASE_COMMENT_TO_NOTE_CACHE: dict[str, str] | None = None _REDBOOK_BASE_FEED_IDS_CACHE: tuple[str, ...] | None = None _REDBOOK_BASE_USER_IDS_CACHE: tuple[str, ...] | None = None # Sentinel used by `view_note` to distinguish "key not in state" from "key present with value None". _MISSING: Any = object() def _load_base_comment_to_note() -> dict[str, str]: """Reverse index: base comment id → its parent note id. Used for two things: - Filtering `state.comments` entries that are *patches* on base comments (handled in the base commentList loop) vs. runtime-only new comments. - Inverting the "which base notes are touched by state.comments?" query from O(F·C) full-scan to O(|state.comments|) dict lookup. """ global _REDBOOK_BASE_COMMENT_TO_NOTE_CACHE if _REDBOOK_BASE_COMMENT_TO_NOTE_CACHE is not None: return _REDBOOK_BASE_COMMENT_TO_NOTE_CACHE out: dict[str, str] = {} for note in _load_notes_json(): if not isinstance(note, dict) or not note.get("id"): continue nid = str(note["id"]) for c in note.get("commentList") or []: if isinstance(c, dict) and c.get("id") is not None: out[str(c["id"])] = nid _REDBOOK_BASE_COMMENT_TO_NOTE_CACHE = out return out def _load_base_feed_ids() -> tuple[str, ...]: """Module-cached base feed order. Returns an immutable tuple.""" global _REDBOOK_BASE_FEED_IDS_CACHE if _REDBOOK_BASE_FEED_IDS_CACHE is not None: return _REDBOOK_BASE_FEED_IDS_CACHE _REDBOOK_BASE_FEED_IDS_CACHE = tuple( str(note["id"]) for note in _load_notes_json() if isinstance(note, dict) and note.get("id") ) return _REDBOOK_BASE_FEED_IDS_CACHE def _load_base_user_ids() -> tuple[str, ...]: """Module-cached base user-id list. Returns an immutable tuple.""" global _REDBOOK_BASE_USER_IDS_CACHE if _REDBOOK_BASE_USER_IDS_CACHE is not None: return _REDBOOK_BASE_USER_IDS_CACHE _REDBOOK_BASE_USER_IDS_CACHE = tuple(_load_users_json().keys()) return _REDBOOK_BASE_USER_IDS_CACHE def _load_base_parsed_counts() -> dict[str, tuple[int, int, int]]: """Pre-parse base note count fields once. Base notes' likes/collections/comments are often Chinese-formatted strings like '1.2万'. Without pre-parsing we re-run `_parse_count` on every `view_note` call (4000+ notes × 3 fields = ~12K regex/string ops per materialization). Pre-parsing turns this into one dict lookup. """ global _REDBOOK_BASE_PARSED_COUNTS_CACHE if _REDBOOK_BASE_PARSED_COUNTS_CACHE is not None: return _REDBOOK_BASE_PARSED_COUNTS_CACHE out: dict[str, tuple[int, int, int]] = {} for nid, note in _load_notes_by_id().items(): out[nid] = ( int(_parse_count(note.get("likes"))), int(_parse_count(note.get("collections"))), int(_parse_count(note.get("comments"))), ) _REDBOOK_BASE_PARSED_COUNTS_CACHE = out return out class Redbook(BaseApp): """ Redbook state accessor. Usage: rb = Redbook(input.apps["redbook"]) rb.user_id rb.liked_notes # With init state for comparison rb = Redbook(input.apps["redbook"], init=input.apps_init["redbook"]) rb.removed_from_liked() """ # ========================================================================= # User properties # ========================================================================= # # `state_*`, `user`, `liked_notes`, etc. are all `@cached_property` because # `BaseApp.get(path)` walks the state via regex-based path parsing — calling # it 4000+ times in a single `view_notes_by_id` materialization dominates # the profile. Caching once per Redbook instance is safe because the # backing state is treated as immutable for the lifetime of the instance # (see docstring on `view_notes_by_id`). # @cached_property def user(self) -> dict[str, Any]: """Current user object.""" return self.state_user() def state_user(self) -> dict[str, Any]: user = self.get("user", {}) return user if isinstance(user, dict) else {} @property def user_id(self) -> str: return self.user.get("id", "") @property def user_name(self) -> str: return self.user.get("name", "") # ========================================================================= # User interaction lists # ========================================================================= @cached_property def liked_notes(self) -> list[str]: """Note IDs the user has liked.""" return self.user.get("likedNotes", []) @cached_property def collected_notes(self) -> list[str]: """Note IDs the user has collected.""" return self.user.get("collectedNotes", []) @cached_property def following_ids(self) -> list[str]: """User IDs the user is following.""" return self.user.get("followingIds", []) @cached_property def follower_ids(self) -> list[str]: """User IDs following the current user.""" return self.user.get("followerIds", []) @cached_property def published_notes(self) -> list[str]: """Note IDs the user has published.""" return self.user.get("publishedNoteIds", []) # ── Set-shaped lookups (hot path in view_note) ───────────────────── @cached_property def _liked_notes_set(self) -> set[str]: return {str(nid) for nid in self.liked_notes} @cached_property def _collected_notes_set(self) -> set[str]: return {str(nid) for nid in self.collected_notes} @cached_property def _following_ids_set(self) -> set[str]: return {str(uid) for uid in self.following_ids} @cached_property def _liked_comments_by_note(self) -> dict[str, set[str]]: raw = self.user.get("likedCommentsByNote") or {} return { str(nid): {str(cid) for cid in (cids or [])} for nid, cids in raw.items() } @property def first_liked_note_id(self) -> str | None: liked = set(self.liked_notes) for note_id in self.feed_ids: if note_id in liked: return note_id return None @property def first_collected_note_id(self) -> str | None: collected = set(self.collected_notes) for note_id in self.feed_ids: if note_id in collected: return note_id return None # ========================================================================= # Entity accessors # ========================================================================= @cached_property def state_notes(self) -> dict[str, Any]: notes = self.get("notes", {}) return notes if isinstance(notes, dict) else {} @cached_property def state_comments(self) -> dict[str, Any]: comments = self.get("comments", {}) return comments if isinstance(comments, dict) else {} @cached_property def state_users(self) -> dict[str, Any]: users = self.get("users", {}) return users if isinstance(users, dict) else {} @cached_property def _state_comment_keys(self) -> set[str]: """All comment ids that have a state.comments entry (patch or tombstone).""" return set(self.state_comments.keys()) @cached_property def _state_comments_by_note_id(self) -> dict[str, list[dict[str, Any]]]: """Bucket state.comments entries by their declared `noteId`. We DO NOT filter patches on base comments here — instead `view_note` filters per-note using THAT note's base commentList ids. This preserves the prior semantic that an entry whose id matches a base comment of note B but whose `noteId` points to note A still appears as a runtime comment under A. """ out: dict[str, list[dict[str, Any]]] = {} for cid, comment in self.state_comments.items(): if not isinstance(comment, dict): continue nid = str(comment.get("noteId") or "") if not nid: continue cid_str = str(cid) # Prefer the entry's own `id` field; fall back to the dict key when missing. if comment.get("id"): normalized = comment else: normalized = {**comment, "id": cid_str} out.setdefault(nid, []).append(normalized) return out @cached_property def _base_notes_with_comment_overlay(self) -> set[str]: """Base note IDs whose `commentList` has at least one comment touched by `state.comments`. Uses the module-cached `_load_base_comment_to_note` reverse index, so the cost is O(|state.comments|) (typically tiny) instead of O(F·C) full-scan. """ state_keys = self._state_comment_keys if not state_keys: return set() comment_to_note = _load_base_comment_to_note() return { comment_to_note[str(cid)] for cid in state_keys if str(cid) in comment_to_note } @property def base_notes_by_id(self) -> dict[str, dict[str, Any]]: return _load_notes_by_id() @property def base_users_by_id(self) -> dict[str, dict[str, Any]]: return _load_users_json() @property def base_feed_ids(self) -> tuple[str, ...]: return _load_base_feed_ids() @property def base_user_ids(self) -> tuple[str, ...]: return _load_base_user_ids() @property def notes_by_id(self) -> dict[str, dict]: """All visible notes by ID, using base + runtime view semantics.""" return self.view_notes_by_id @property def users_by_id(self) -> dict[str, dict]: """All visible users by ID, using base + runtime view semantics.""" return self.view_users_by_id @cached_property def feed_ids(self) -> list[str]: """Visible public feed note IDs. Order mirrors the frontend `buildRedBookView` semantics: runtime-only notes (newly published or seeded via `defaults.notes`) come first, sorted by `createdAt` desc; then `base.feedIds` in declared order, with tombstoned entries removed. Cached on the instance — do not reuse a `Redbook` after mutating its backing `state`; construct a new instance instead. """ base_feed = [str(nid) for nid in self.base_feed_ids if nid] base_feed_set = set(base_feed) tombstones: set[str] = set() runtime_only: list[tuple[str, float]] = [] for note_id, value in self.state_notes.items(): nid = str(note_id) if not nid: continue if value is None: tombstones.add(nid) continue if nid in base_feed_set: continue created_at = 0.0 if isinstance(value, dict): try: created_at = float(value.get("createdAt") or 0) except (TypeError, ValueError): created_at = 0.0 runtime_only.append((nid, created_at)) runtime_only.sort(key=lambda item: item[1], reverse=True) out: list[str] = [] seen: set[str] = set() for nid, _ in runtime_only: if nid in seen: continue seen.add(nid) out.append(nid) for nid in base_feed: if nid in seen or nid in tombstones: continue seen.add(nid) out.append(nid) return out @property def chats(self) -> list[dict]: """Chat list.""" return self.get_list("chats") @property def hot_search(self) -> list[dict[str, Any]]: """Search landing hot-search list from runtime state.""" return self.get_list("hotSearch") or list(_DEFAULTS.get("hotSearch", []) or []) # ========================================================================= # Settings # ========================================================================= @property def settings(self) -> dict[str, Any]: return self.get("settings", {}) @property def general_settings(self) -> dict[str, Any]: return self.settings.get("general", {}) def count_value(self, value: Any) -> float: return _parse_count(value) def require_note(self, note_id: str) -> dict[str, Any]: note = self.get_note(note_id) if note is None: raise ValueError(f"Note '{note_id}' not found in state") return note def first_feed_note(self) -> dict[str, Any]: if not self.feed_ids: raise ValueError("Feed is empty") return self.require_note(self.feed_ids[0]) def first_feed_note_with_title_keyword(self, keyword: str) -> dict[str, Any]: for note_id in self.feed_ids: note = self.require_note(note_id) if str(keyword) in str(note["title"]): return note raise ValueError(f"No feed note with title containing '{keyword}'") def first_search_note(self, keyword: str) -> dict[str, Any]: note = self.find_note_by_keyword(keyword) if note is None: raise ValueError(f"No search result note for keyword '{keyword}'") return note def require_user_entity(self, user_id: str) -> dict[str, Any]: user = self.get_user_entity(user_id) if user is None and str(self.user.get("id") or "") == str(user_id): user = self.user if user is None: raise ValueError(f"User '{user_id}' not found in state") return user def require_user_by_name(self, name: str) -> dict[str, Any]: user = self.find_user_by_name(name) if user is None: raise ValueError(f"User '{name}' not found in state") return user def note_author(self, note: dict[str, Any]) -> dict[str, Any]: return self.require_user_entity(str(note["authorId"])) def note_field(self, note: dict[str, Any], field: str) -> Any: if field == "authorName": return self.note_author(note)["name"] return note[field] def search_note_field(self, keyword: str, field: str) -> Any: return self.note_field(self.first_search_note(keyword), field) def user_field_by_name(self, username: str, field: str) -> Any: return self.require_user_by_name(username)[field] def first_collected_note(self) -> dict[str, Any]: collected_id = self.first_collected_note_id if collected_id is None: raise ValueError("Collected notes list is empty") return self.require_note(collected_id) def first_collected_author_field(self, field: str) -> Any: return self.note_author(self.first_collected_note())[field] def user_note_count(self, user_id: str) -> int: return sum(1 for note in self.notes_by_id.values() if str(note["authorId"]) == str(user_id)) def followed_user_note_count(self, username: str) -> int: return self.user_note_count(self.require_user_by_name(username)["id"]) def user_notes(self, user_id: str) -> list[dict[str, Any]]: """指定用户发布的所有笔记。当前用户按 publishedNoteIds 顺序返回。""" target_id = str(user_id or "") if target_id == self.user_id: return [ note for note_id in self.published_notes if (note := self.view_note(str(note_id))) is not None ] notes: list[dict[str, Any]] = [] for note_id in self.feed_ids: note = self.notes_by_id.get(note_id) if note is None: continue if str(note.get("authorId") or "") != target_id: continue notes.append(note) return notes def user_notes_by_name(self, username: str) -> list[dict[str, Any]]: return self.user_notes(self.require_user_by_name(username)["id"]) def _sort_notes_by_metric( self, notes: list[dict[str, Any]], metric: str, *, ascending: bool, ) -> list[dict[str, Any]]: if metric == "likes": key = lambda n: _parse_count(n.get("likes")) elif metric in ("collections", "collects"): key = lambda n: _parse_count(n.get("collections")) elif metric == "comments": key = lambda n: _parse_count(n.get("comments")) else: raise ValueError(f"Unsupported metric: {metric!r}") ordered = sorted(notes, key=key, reverse=not ascending) return ordered def search_top_notes_by_likes( self, keyword: str, *, top_n: int = 2 ) -> list[dict[str, Any]]: """搜索结果前若干篇按点赞数降序排序并截取前 top_n;候选不足时抛错以暴露数据设计问题。""" if int(top_n) <= 0: return [] prefix = self.search_notes(keyword)[: max(int(top_n), 10)] ordered = self._sort_notes_by_metric(prefix, "likes", ascending=False) if len(ordered) < int(top_n): raise ValueError( f"Redbook search for {keyword!r} yielded only {len(ordered)} results; " f"expected ≥ {top_n}" ) return ordered[: int(top_n)] def most_liked_search_note(self, keyword: str) -> dict[str, Any]: """搜索结果前 10 篇里点赞最多的一篇。""" top = self.search_top_notes_by_likes(keyword, top_n=1) if not top: raise ValueError(f"No search result for keyword '{keyword}'") return top[0] def user_max_liked_note(self, username: str) -> dict[str, Any]: notes = self.user_notes_by_name(username) if not notes: raise ValueError(f"User '{username}' has no notes") return self._sort_notes_by_metric(notes, "likes", ascending=False)[0] def user_min_collected_note(self, username: str) -> dict[str, Any]: notes = self.user_notes_by_name(username) if not notes: raise ValueError(f"User '{username}' has no notes") return self._sort_notes_by_metric(notes, "collections", ascending=True)[0] def user_max_collected_note(self, username: str) -> dict[str, Any]: notes = self.user_notes_by_name(username) if not notes: raise ValueError(f"User '{username}' has no notes") return self._sort_notes_by_metric(notes, "collections", ascending=False)[0] def user_best_worst_notes(self, username: str) -> tuple[dict[str, Any], dict[str, Any]]: """返回用户 (max-likes 笔记, min-collections 笔记),且保证两者是不同笔记。""" top_liked = self.user_max_liked_note(username) min_collected = self.user_min_collected_note(username) if str(top_liked["id"]) == str(min_collected["id"]): raise ValueError( f"User {username!r} max-liked note == min-collected note; " "choose a different author for this task" ) return top_liked, min_collected def first_root_comment(self, note_id: str) -> dict[str, Any]: note = self.require_note(note_id) for comment in note.get("commentList", []): if not comment.get("replyToId"): return comment raise ValueError(f"Note '{note_id}' has no root comment") def first_chat(self) -> dict[str, Any]: if not self.chats: raise ValueError("Chat list is empty") return self.chats[0] def first_chat_last_message(self) -> str: chat = self.first_chat() messages = chat.get("messages", []) if not messages: raise ValueError(f"Chat '{chat.get('userId')}' has no messages") return str(messages[-1]["content"]) # ========================================================================= # Note operations # ========================================================================= @cached_property def view_notes_by_id(self) -> dict[str, dict[str, Any]]: """All visible notes by ID. `feed_ids` already merges runtime-only notes. Cached on the instance — do not reuse a `Redbook` after mutating its backing `state`; construct a new instance instead. """ out: dict[str, dict[str, Any]] = {} for note_id in self.feed_ids: note = self.view_note(str(note_id)) if note is not None: out[str(note["id"])] = note return out @cached_property def view_users_by_id(self) -> dict[str, dict[str, Any]]: """All visible users by ID. Cached on the instance — see `view_notes_by_id`.""" user_ids = [self.user_id, *self.base_user_ids, *self.state_users.keys()] out: dict[str, dict[str, Any]] = {} for user_id in user_ids: user = self.view_user(str(user_id)) if user is not None: out[str(user["id"])] = user return out def base_note(self, note_id: str) -> dict[str, Any] | None: return self.base_notes_by_id.get(str(note_id)) def state_note(self, note_id: str) -> dict[str, Any] | None: if str(note_id) not in self.state_notes: return None value = self.state_notes.get(str(note_id)) return value if isinstance(value, dict) else None def base_user(self, user_id: str) -> dict[str, Any] | None: return self.base_users_by_id.get(str(user_id)) def state_user_entity(self, user_id: str) -> dict[str, Any] | None: if str(user_id) not in self.state_users: return None value = self.state_users.get(str(user_id)) return value if isinstance(value, dict) else None def view_note(self, note_id: str) -> dict[str, Any] | None: """Resolve a single note for the view. Hot path (~99% of notes when user has no interactions): a clean base note with no overlay returns the base reference unmodified — no dict clone, no derivation. Treat the returned dict as **read-only**; callers must not mutate it. Slow path: clone base + state overlay, derive counts from user truth fields, merge runtime comments + apply comment patches/tombstones/likes. """ note_id = str(note_id) state_notes = self.state_notes state_value = state_notes.get(note_id) if note_id in state_notes else _MISSING if state_value is None: # tombstone return None base = self.base_notes_by_id.get(note_id) # ── Fast path ───────────────────────────────────────────────────────── if ( state_value is _MISSING and base is not None and note_id not in self._liked_notes_set and note_id not in self._collected_notes_set and not self._liked_comments_by_note.get(note_id) and note_id not in self._state_comments_by_note_id and note_id not in self._base_notes_with_comment_overlay ): return base # ── Slow path ───────────────────────────────────────────────────────── # 不变量: store/seed 写 `state.notes` 时必须保持 `state.notes[id]["id"] == id` # (`apps/RedBook/state.ts` 的 `addNote` 与 bench `_publish_note` 都遵守)。 # 因此用 `note_id` (dict key) 作为唯一锚点查 liked/collected/runtime-comments, # 与 `view_notes_by_id` 用 dict key 作为索引保持一致 —— 调用方拿 feed id 来 lookup # 不会 miss。若调用方违反不变量,feedIds 与 view_notes_by_id 会以不同 id 索引。 merged_source = state_value if isinstance(state_value, dict) else base if merged_source is None: return None note = dict(merged_source) liked_comment_ids = self._liked_comments_by_note.get(note_id, frozenset()) state_comments = self.state_comments base_comments_out: list[Any] = [] hidden_base_comments = 0 # Track ids claimed by THIS note's base commentList; runtime-only entries # carrying the same id are patches handled here, not separate comments. this_note_base_comment_ids: set[str] = set() for comment in note.get("commentList") or []: if not isinstance(comment, dict): base_comments_out.append(comment) continue comment_id = str(comment.get("id") or "") if comment_id: this_note_base_comment_ids.add(comment_id) state_comment = state_comments.get(comment_id, _MISSING) if comment_id else _MISSING if state_comment is None: hidden_base_comments += 1 continue if isinstance(state_comment, dict): merged_comment = {"id": comment_id, **state_comment} else: merged_comment = comment if str(merged_comment.get("id")) in liked_comment_ids: merged_comment = {**merged_comment, "likes": _parse_count(merged_comment.get("likes")) + 1} base_comments_out.append(merged_comment) runtime_comments_raw = self._state_comments_by_note_id.get(note_id, []) runtime_comments = [ rc for rc in runtime_comments_raw if str(rc.get("id") or "") not in this_note_base_comment_ids ] if liked_comment_ids and runtime_comments: runtime_comments = [ {**rc, "likes": _parse_count(rc.get("likes")) + 1} if str(rc.get("id")) in liked_comment_ids else rc for rc in runtime_comments ] # Use pre-parsed base counts when the note has no state overlay; otherwise # parse from the (likely numeric) state value. if state_value is _MISSING: parsed = _load_base_parsed_counts().get(note_id) if parsed is not None: base_likes, base_collections, base_comments_n = parsed else: base_likes = int(_parse_count(merged_source.get("likes"))) base_collections = int(_parse_count(merged_source.get("collections"))) base_comments_n = int(_parse_count(merged_source.get("comments"))) else: base_likes = int(_parse_count(merged_source.get("likes"))) base_collections = int(_parse_count(merged_source.get("collections"))) base_comments_n = int(_parse_count(merged_source.get("comments"))) liked = note_id in self._liked_notes_set collected = note_id in self._collected_notes_set note["likes"] = base_likes + (1 if liked else 0) note["collections"] = base_collections + (1 if collected else 0) note["comments"] = max(0, base_comments_n - hidden_base_comments + len(runtime_comments)) note["commentList"] = [*runtime_comments, *base_comments_out] return note def view_user(self, user_id: str) -> dict[str, Any] | None: user_id = str(user_id) if user_id == self.user_id: user = dict(self.user) user["following"] = len(self.following_ids) user["followers"] = len(user.get("followerIds") or []) return user state_has_key = user_id in self.state_users state_value = self.state_users.get(user_id) if state_has_key and state_value is None: return None base = self.base_user(user_id) if isinstance(state_value, dict): user = dict(state_value) elif base: user = dict(base) else: return None if user_id in {str(item) for item in self.following_ids}: user["followers"] = int(_parse_count(user.get("followers")) + 1) return user def get_note(self, note_id: str) -> dict | None: """Get visible note by ID.""" return self.view_note(note_id) def search_notes(self, keyword: str) -> list[dict[str, Any]]: """Search notes in the same order as the app search result page.""" k = str(keyword or "").lower().strip() if not k: return [] results: list[dict[str, Any]] = [] for note_id in self.feed_ids: note = self.get_note(note_id) if not note: continue title = str(note.get("title") or "").lower() content = str(note.get("content") or "").lower() category = str(note.get("category") or "").lower() if k in title or k in content or k in category: results.append(note) return results def sorted_search_notes(self, keyword: str, sort: str = "comprehensive") -> list[dict[str, Any]]: """Return notes ordered like the search result page.""" notes = list(self.search_notes(keyword)) if sort == "latest": notes.sort(key=lambda item: float(item.get("createdAt") or 0), reverse=True) elif sort == "likes": notes.sort(key=lambda item: _parse_count(item.get("likes")), reverse=True) elif sort == "comments": notes.sort(key=lambda item: _parse_count(item.get("comments")), reverse=True) elif sort == "collects": notes.sort(key=lambda item: _parse_count(item.get("collections")), reverse=True) return notes def find_note_by_keyword(self, keyword: str) -> dict | None: """Backward-compatible helper: first search result under default ordering.""" notes = self.search_notes(keyword) return notes[0] if notes else None def find_note_by_keyword_sorted(self, keyword: str, sort: str) -> dict | None: notes = self.sorted_search_notes(keyword, sort) return notes[0] if notes else None def note_has_comment(self, note_id: str, content: str, user_id: str | None = None) -> bool: """ Check if note has a specific comment. Args: note_id: Note ID content: Comment content to find user_id: Optional user ID (defaults to current user) Returns: True if comment exists """ note = self.get_note(note_id) if not note: return False uid = user_id or self.user_id comments = note.get("commentList", []) return any( c.get("userId") == uid and c.get("content") == content for c in comments ) def note_has_reply( self, note_id: str, content: str, reply_to_id: str, user_id: str | None = None ) -> bool: """ Check if note has a specific reply comment. Args: note_id: Note ID content: Reply content reply_to_id: ID of comment being replied to user_id: Optional user ID Returns: True if reply exists """ note = self.get_note(note_id) if not note: return False uid = user_id or self.user_id comments = note.get("commentList", []) return any( c.get("userId") == uid and c.get("content") == content and c.get("replyToId") == reply_to_id for c in comments ) # ========================================================================= # User operations # ========================================================================= def get_user_entity(self, user_id: str) -> dict | None: """Get visible user by ID.""" return self.view_user(user_id) def find_user_by_name(self, name: str) -> dict | None: """ Find user by name (partial match, case insensitive). Args: name: Search name Returns: User dict or None """ name_lower = name.lower() for uid in self.base_user_ids: user = self.view_user(uid) or {} if name_lower in (user.get("name") or "").lower(): return user for uid, user in self.view_users_by_id.items(): if uid in self.base_user_ids: continue if name_lower in (user.get("name") or "").lower(): return user return None def is_following(self, user_id: str) -> bool: """Check if current user follows given user.""" return user_id in self.following_ids @staticmethod def sample_followed_user_name(env_state: dict[str, Any], rng: Any) -> str: rb = Redbook(env_state["apps"]["redbook"]) preferred: list[str] = [] fallback: list[str] = [] for uid in rb.following_ids: if uid not in rb.users_by_id: continue user = rb.users_by_id[uid] name = user.get("name") if not name: continue fallback.append(name) loc = str(user.get("location") or "").strip() if loc and loc != "未知": preferred.append(name) names = preferred or fallback if not names: raise ValueError("No followed users found in redbook state") return rng.choice(names) @staticmethod def sample_followed_user_name_with_notes(env_state: dict[str, Any], rng: Any) -> str: """专给 CheckFollowingUserNoteCount 用:要求笔记数 >= 5,避免抽到 0/1 篇用户让任务退化为微不足道。""" MIN_NOTES = 5 rb = Redbook(env_state["apps"]["redbook"]) candidates: list[str] = [] for uid in rb.following_ids: if uid not in rb.users_by_id: continue user = rb.users_by_id[uid] name = user.get("name") if not name: continue if rb.user_note_count(uid) < MIN_NOTES: continue candidates.append(name) if not candidates: raise ValueError( f"No followed users with >= {MIN_NOTES} notes (followingIds notes 太少, " "无法采样有意义的'数笔记'任务)" ) return rng.choice(candidates) @staticmethod def sample_unfollowed_user_name(env_state: dict[str, Any], rng: Any) -> str: rb = Redbook(env_state["apps"]["redbook"]) followed = set(rb.following_ids) preferred: list[str] = [] fallback: list[str] = [] for uid, user in rb.users_by_id.items(): if uid in followed or uid == rb.user_id: continue name = user.get("name") if not name: continue fallback.append(name) loc = str(user.get("location") or "").strip() if loc and loc != "未知": preferred.append(name) names = preferred or fallback if not names: raise ValueError("No unfollowed users found in redbook state") return rng.choice(names) @staticmethod def sample_user_name(env_state: dict[str, Any], rng: Any) -> str: rb = Redbook(env_state["apps"]["redbook"]) # Prefer users with non-zero likesAndCollections, so tasks like # "看看TA的获赞与收藏" don't default to an always-zero user. preferred: list[str] = [] fallback: list[str] = [] for uid, user in rb.users_by_id.items(): if uid == rb.user_id: continue name = user.get("name") if not name: continue fallback.append(name) try: val = rb.count_value(user.get("likesAndCollections") or 0) except Exception: val = 0 loc = str(user.get("location") or "").strip() # Prefer users that have both non-zero likesAndCollections and a valid location. if val > 0 and loc and loc != "未知": preferred.append(name) # Fallback order: non-zero likesAndCollections (even if location unknown) -> any user if not preferred: nonzero = [] for uid, user in rb.users_by_id.items(): if uid == rb.user_id: continue name = user.get("name") if not name: continue try: val = rb.count_value(user.get("likesAndCollections") or 0) except Exception: val = 0 if val > 0: nonzero.append(name) names = nonzero or fallback else: names = preferred if not names: raise ValueError("No users found in redbook state") return rng.choice(names) @staticmethod def _sample_keyword_from_title(title: str, rng: Any) -> str: raw = str(title or "").strip() if not raw: return "" # Split by common separators, prefer meaningful chunks. parts = [p.strip() for p in re.split(r"[||丨·•…—\\-–::,,。.!!??\\s]+", raw) if p.strip()] if parts: # Avoid returning a single very short punctuation-like chunk. cand = [p for p in parts if len(p) >= 2] or parts pick = rng.choice(cand) # Clamp overly long chunks for "包含关键字" tasks. return pick[:6] return raw[:4] @staticmethod def sample_feed_title_keyword(env_state: dict[str, Any], rng: Any) -> str: """Sample a keyword that appears in visible HomePage 'discover' feed titles.""" rb = Redbook(env_state["apps"]["redbook"]) titles = [ str(n.get("title") or "").strip() for n in rb.visible_discover_notes_for_category("recommend", limit=40) ] titles = [t for t in titles if t] if not titles: raise ValueError("No feed note titles found in redbook state") for _ in range(10): kw = Redbook._sample_keyword_from_title(rng.choice(titles), rng) if kw: return kw return titles[0][:4] @staticmethod def sample_uncollected_feed_title_keyword(env_state: dict[str, Any], rng: Any) -> str: """采样 keyword:来自当前可见 feed 中**尚未收藏**的笔记标题。 用于 "收藏含关键词的笔记" 这类任务,避免命中 seed 已收藏的笔记导致 diff 永远为空。 若所有可见 feed 笔记都已收藏(极端情况),抛错让上游意识到 seed 配置异常。 """ rb = Redbook(env_state["apps"]["redbook"]) collected = set(rb.user.get("collectedNotes") or []) titles = [ str(n.get("title") or "").strip() for n in rb.visible_discover_notes_for_category("recommend", limit=40) if str(n.get("id")) not in collected ] titles = [t for t in titles if t] if not titles: raise ValueError("No uncollected feed note titles found in redbook state") for _ in range(10): kw = Redbook._sample_keyword_from_title(rng.choice(titles), rng) if kw: return kw return titles[0][:4] @staticmethod def sample_unliked_feed_title_keyword(env_state: dict[str, Any], rng: Any) -> str: """采样 keyword:来自当前可见 feed 中**尚未点赞**的笔记标题。 与 `sample_uncollected_feed_title_keyword` 对称,避免 "点赞含关键词的笔记" 类任务命中 seed 已点赞笔记。 """ rb = Redbook(env_state["apps"]["redbook"]) liked = set(rb.user.get("likedNotes") or []) titles = [ str(n.get("title") or "").strip() for n in rb.visible_discover_notes_for_category("recommend", limit=40) if str(n.get("id")) not in liked ] titles = [t for t in titles if t] if not titles: raise ValueError("No unliked feed note titles found in redbook state") for _ in range(10): kw = Redbook._sample_keyword_from_title(rng.choice(titles), rng) if kw: return kw return titles[0][:4] @staticmethod def sample_replyable_feed_title_keyword(env_state: dict[str, Any], rng: Any) -> str: """Sample a keyword from a visible discover note title that has a root comment.""" rb = Redbook(env_state["apps"]["redbook"]) titles = [ str(n.get("title") or "").strip() for n in rb.visible_discover_replyable_notes("recommend", limit=40) ] if not titles: # Fall back to any feed title; task may still be solvable if comments exist elsewhere. return Redbook.sample_feed_title_keyword(env_state, rng) for _ in range(10): kw = Redbook._sample_keyword_from_title(rng.choice(titles), rng) if kw: return kw return titles[0][:4] def has_liked(self, note_id: str) -> bool: """Check if current user has liked given note.""" return note_id in self.liked_notes def has_collected(self, note_id: str) -> bool: """Check if current user has collected given note.""" return note_id in self.collected_notes # ========================================================================= # Home (Discover) visibility helpers — mirror apps/RedBook/pages/HomePage.tsx # ========================================================================= def visible_discover_notes_for_category(self, category_key: str, limit: int = 40) -> list[dict[str, Any]]: """Notes visible in HomePage 'discover' tab for a given category key. `displayCount` 已经从 state 移除(UI 滚动状态进了 React 本地 useState), 这里维持原先 limit=40 / display_count=20 的语义,bench 视角永远取 top-20。 category 现住在 `_temp.activeCategory`,但本函数只按 category_key 参数过滤, 不再依赖 state 里的 active category 字段。 """ display_count = 20 label = _CATEGORY_LABELS.get(str(category_key or "")) if not label: return [] out: list[dict[str, Any]] = [] for note_id in self.feed_ids: note = self.notes_by_id.get(note_id) if not note: continue if str(note.get("category") or "") != str(label): continue out.append(note) if len(out) >= min(limit, max(display_count, 1)): break return out def visible_discover_replyable_notes(self, category: str = "recommend", limit: int = 40) -> list[dict[str, Any]]: """Visible discover notes that have at least one root comment.""" notes = self.visible_discover_notes_for_category(category, limit=limit) out: list[dict[str, Any]] = [] for note in notes: comments = note.get("commentList") or [] if not comments: continue if any(not c.get("replyToId") for c in comments): out.append(note) return out # ========================================================================= # Chat operations # ========================================================================= def get_chat(self, user_id: str) -> dict | None: """Get chat with specific user.""" for chat in self.chats: if chat.get("userId") == user_id: return chat return None def chat_has_message(self, target_user_id: str, content: str) -> bool: """ Check if chat contains a message with given content from current user. Args: target_user_id: Chat target user ID content: Message content Returns: True if message exists """ chat = self.get_chat(target_user_id) if not chat: return False return any( m.get("senderId") == self.user_id and m.get("content") == content for m in chat.get("messages", []) ) def check_chat_exact_message_to( self, username: str, message: str, *, field: str | None = None, ) -> dict[str, Any]: """验证是否给指定用户发过**内容精确等于 message** 的私信(不要求是最后一条)。 与 `check_chat_sent_to` 的差异: - 前者: keyword 子串匹配 + **只看最后一条**本人消息 - 后者(本方法): 精确等于 + **遍历所有**本人消息(任一条匹配即通过) 当任务要求"发一句 X"(X 是完整内容,不是子串),用本方法。 """ if field is None: field = f"dm_to_{username}" user = self.require_user_by_name(username) passed = self.chat_has_message(str(user["id"]), str(message)) chat = self.get_chat(str(user["id"])) last_actual = "" if chat is not None: for m in reversed(chat.get("messages", [])): if str(m.get("senderId") or "") == self.user_id: last_actual = str(m.get("content") or "") break return { "field": field, "expected": str(message), "actual": last_actual or "(no message from me)", "passed": passed, } def check_chat_sent_to( self, username: str, *keywords: str, field: str | None = None, ) -> dict[str, Any]: """验证是否给指定用户发了包含所有关键词的私信。""" if field is None: field = f"dm_to_{username}" user = self.require_user_by_name(username) chat = self.get_chat(str(user["id"])) actual = "" if chat is not None: for message in reversed(chat.get("messages", [])): if str(message.get("senderId") or "") != self.user_id: continue actual = str(message.get("content") or "") break passed = bool(actual) and all(keyword in actual for keyword in keywords) return { "field": field, "expected": f"dm to '{username}' with {list(keywords)}", "actual": actual or "(none)", "passed": passed, } # ========================================================================= # Comparison helpers (require init state) # ========================================================================= def added_to_liked(self) -> set[str]: """Notes liked since init.""" return self.list_added("user.likedNotes") def removed_from_liked(self) -> set[str]: """Notes unliked since init.""" return self.list_removed("user.likedNotes") def added_to_collected(self) -> set[str]: """Notes collected since init.""" return self.list_added("user.collectedNotes") def removed_from_collected(self) -> set[str]: """Notes uncollected since init.""" return self.list_removed("user.collectedNotes") def added_to_following_ids(self) -> set[str]: """Users followed since init.""" return self.list_added("user.followingIds") def removed_from_following_ids(self) -> set[str]: """Users unfollowed since init.""" return self.list_removed("user.followingIds") def check_note_collected( self, note_id: str, *, field: str = "collected", ) -> dict[str, Any]: """验证指定笔记在本次任务中被新收藏(init 未收藏 → current 已收藏,CRUD "增"用 diff)。 调用方必须提供 init(`Redbook(state, init=...)`);否则无法区分"Agent 新增" 与"原本就存在",与 CRUD 约束冲突。 """ if not self.has_init: raise ValueError( "Redbook.check_note_collected requires an init state — Create/Delete " "judgments must be done via diff (see bench_env/docs/task/TASK_AUTHORING_GUIDE.md §2.5)" ) note_id_str = str(note_id) assert not self.init.has_collected(note_id_str), ( f"Upstream bug: note {note_id_str} already collected in init" ) added = self.added_to_collected() passed = note_id_str in added return { "field": field, "expected": f"note {note_id_str} newly collected", "actual": sorted(added) if added else "(no new collected notes)", "passed": passed, } def check_note_liked( self, note_id: str, *, field: str = "liked", ) -> dict[str, Any]: """验证指定笔记在本次任务中被新点赞(init 未点赞 → current 已点赞,diff 判定)。 与 `check_note_collected` 对称——要求 init,断言 init 未点赞该笔记 (否则 sampler 上游让"已点赞笔记"作为目标,任务无解,应直接报上游 bug)。 """ if not self.has_init: raise ValueError( "Redbook.check_note_liked requires an init state — Create/Delete " "judgments must be done via diff" ) note_id_str = str(note_id) assert not self.init.has_liked(note_id_str), ( f"Upstream bug: note {note_id_str} already liked in init" ) added = self.added_to_liked() passed = note_id_str in added return { "field": field, "expected": f"note {note_id_str} newly liked", "actual": sorted(added) if added else "(no new liked notes)", "passed": passed, } def check_note_uncollected( self, note_id: str, *, field: str = "uncollected", ) -> dict[str, Any]: """验证指定笔记在本次任务中被取消收藏(init 已收藏 → current 未收藏,diff 判定)。 与 `check_note_collected` 互为反向。要求 init,断言 init 已收藏该笔记 (上游若让"未收藏笔记"作为"取消收藏"目标也是 bug)。 """ if not self.has_init: raise ValueError( "Redbook.check_note_uncollected requires an init state — diff judgment needs init" ) note_id_str = str(note_id) assert self.init.has_collected(note_id_str), ( f"Upstream bug: note {note_id_str} was not collected in init, cannot uncollect" ) removed = self.removed_from_collected() passed = note_id_str in removed return { "field": field, "expected": f"note {note_id_str} uncollected", "actual": sorted(removed) if removed else "(no uncollected notes)", "passed": passed, } def has_new_published_note_contains( self, expected_substring: str ) -> tuple[bool, str]: expected_substring = str(expected_substring or "") for title, body, _actual in self._iter_publish_targets( new_only=True, allow_draft=True, ): in_body = expected_substring != "" and expected_substring in body in_title = expected_substring != "" and expected_substring in title if in_body or (in_title and body.strip() == ""): return True, (body if body.strip() != "" else title) draft = self.get("publishDraft", {}) or {} draft_text = str(draft.get("text") or "") if isinstance(draft, dict) else "" return False, draft_text or "" @staticmethod def _compact_norm(value: str) -> str: return norm(str(value or "")).replace(" ", "") @staticmethod def _fold_space(value: str) -> str: return re.sub(r"\s+", " ", str(value or "")).strip() def _new_published_note_ids(self) -> set[str]: if not self.has_init: return set() try: return {str(value) for value in (self.init.published_notes or [])} except Exception: return set() def _iter_publish_targets( self, *, new_only: bool = False, allow_draft: bool = False, ): initial_ids = self._new_published_note_ids() if new_only else set() for note_id in self.published_notes: note_id = str(note_id or "") if note_id and note_id in initial_ids: continue note = self.get_note(note_id) if not note: continue title = str(note.get("title") or "") body = str(note.get("desc") or note.get("content") or "") yield title, body, f"title={title}, desc={body[:40]}" if allow_draft: draft = self.get("publishDraft", {}) or {} if isinstance(draft, dict): title = str(draft.get("title") or "") body = str(draft.get("text") or "") yield title, body, body or title # ========================================================================= # Check methods — return standard dict for check_goals # ========================================================================= def check_following( self, user_id: str, *, expected: bool = True, field: str = "following" ) -> dict[str, Any]: """验证是否已关注指定用户。""" actual = self.is_following(user_id) user = self.users_by_id.get(user_id) name = str(user["name"]) if user else user_id return { "field": field, "expected": f"following {name}" if expected else f"not following {name}", "actual": "following" if actual else "not following", "passed": actual == expected, } def check_note_commented( self, note_id: str, comment: str, user_id: str | None = None, *, field: str = "comment", ) -> dict[str, Any]: """验证笔记下是否有指定评论。""" commented = self.note_has_comment(note_id, comment, user_id) return { "field": field, "expected": f"comment {comment!r} on {note_id}", "actual": "commented" if commented else "no comment", "passed": commented, } def check_note_published( self, title_pred=None, content_pred=None, *, title_exact: str | None = None, title_keywords: Sequence[str] = (), content_keywords: Sequence[str] = (), text_keywords: Sequence[str] = (), content_lines: Sequence[str] = (), new_only: bool = False, allow_draft: bool = False, field: str = "post_note", ) -> dict[str, Any]: """检查是否发了满足条件的小红书笔记。 所有条件取 AND:同时传 title_exact + title_pred 时两者都必须满足, 同时传 content_keywords + content_pred 时两者也都必须满足。 只有全部条件通过的笔记才算匹配。 Args: title_pred: ``(title: str) -> bool`` (optional) content_pred: ``(desc: str) -> bool`` (optional) title_exact: 标题精确匹配(去首尾空白后比较) title_keywords: 标题归一化后需包含的关键词列表 content_keywords: 正文需包含的关键词列表 text_keywords: 标题+正文合并后归一化需包含的关键词列表 content_lines: 标题+正文折叠空白后需包含的多行内容 new_only: 仅检查 init 之后新增的已发布笔记 allow_draft: 允许草稿 `publishDraft` 作为 fallback field: check result 中的 field 名 """ expected: dict[str, Any] = { "published": True, "title_exact": title_exact, "title_keywords": list(title_keywords), "content_keywords": list(content_keywords), "text_keywords": list(text_keywords), "content_lines": list(content_lines), "new_only": new_only, "allow_draft": allow_draft, "uses_title_pred": title_pred is not None, "uses_content_pred": content_pred is not None, } for title_raw, desc, actual in self._iter_publish_targets( new_only=new_only, allow_draft=allow_draft, ): title = str(title_raw).strip() content_text = desc if desc.strip() else title combined = self._fold_space(f"{title} {desc}") title_exact_ok = title == str(title_exact).strip() if title_exact is not None else True title_keyword_missing = [ kw for kw in title_keywords if self._compact_norm(kw) not in self._compact_norm(title) ] keyword_missing = [kw for kw in content_keywords if kw not in desc] text_keyword_missing = [ kw for kw in text_keywords if self._compact_norm(kw) not in self._compact_norm(f"{title} {desc}") ] line_missing = [ line for line in content_lines if self._fold_space(line) not in combined ] t_ok = ( title_exact_ok and not title_keyword_missing and (title_pred(title) if title_pred else True) ) c_ok = ( (not keyword_missing) and (not text_keyword_missing) and (not line_missing) and (content_pred(desc) if content_pred else True) ) if t_ok and c_ok: return { "field": field, "expected": expected, "actual": actual, "passed": True, } return { "field": field, "expected": expected, "actual": "no matching post", "passed": False, }