项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

1733 行
65 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""
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,
}