From e521a41447f4179235196ebdb320cb2c28982fe7 Mon Sep 17 00:00:00 2001 From: niracler Date: Sat, 29 Aug 2026 10:35:22 +0800 Subject: [PATCH] feat: add a privacy-safe Folo RSS workflow --- .agents/skills/folo-cli/SKILL.md | 196 ++++++++++++++++ .github/scripts/audit.py | 105 +++++++-- .github/scripts/audit_folo.py | 311 +++++++++++++++++++++++++ .github/workflows/check-links.yml | 12 + .linkspector.yml | 27 ++- .pre-commit-config.yaml | 2 +- AGENTS.md | 4 +- CHANGELOG.md | 16 ++ README.md | 22 +- audit/folo-snapshot.json | 66 ++++++ docs/folo-rss.md | 113 +++++++++ tests/fixtures/folo/analytics.json | 9 + tests/fixtures/folo/collections.json | 8 + tests/fixtures/folo/lists.json | 5 + tests/fixtures/folo/subscriptions.json | 10 + tests/fixtures/folo/unread.json | 7 + tests/test_audit.py | 76 ++++++ tests/test_audit_folo.py | 83 +++++++ 18 files changed, 1037 insertions(+), 35 deletions(-) create mode 100644 .agents/skills/folo-cli/SKILL.md create mode 100644 .github/scripts/audit_folo.py create mode 100644 audit/folo-snapshot.json create mode 100644 docs/folo-rss.md create mode 100644 tests/fixtures/folo/analytics.json create mode 100644 tests/fixtures/folo/collections.json create mode 100644 tests/fixtures/folo/lists.json create mode 100644 tests/fixtures/folo/subscriptions.json create mode 100644 tests/fixtures/folo/unread.json create mode 100644 tests/test_audit.py create mode 100644 tests/test_audit_folo.py diff --git a/.agents/skills/folo-cli/SKILL.md b/.agents/skills/folo-cli/SKILL.md new file mode 100644 index 0000000..0f6f4c5 --- /dev/null +++ b/.agents/skills/folo-cli/SKILL.md @@ -0,0 +1,196 @@ +--- +name: folo-cli +description: Use Folo CLI to manage RSS subscriptions, lists, timeline entries, unread state, collections, feed discovery, and OPML imports or exports. Trigger when the user asks to inspect or change content in their Folo account. +--- + +# Folo CLI Skill + +Adapted for Codex from the +[official Folo CLI skill](https://github.com/RSSNext/Folo/blob/main/apps/cli/skill.md). + +## Trigger Conditions + +Use this skill when a user asks to: + +- Manage RSS subscriptions +- Browse timeline entries +- Read entry details or readability content +- Mark entries as read/unread +- Search feeds/lists or trending sources +- Import/export OPML +- Check unread counts + +## Preconditions + +1. Node.js and npm are installed so the CLI can be executed with `npx`. +2. Authentication is configured: + - `npx --yes folocli@latest login` (recommended, opens browser and auto-logins) + - or let 1Password inject `FOLO_TOKEN` into the authorized process at runtime. + +Never request, reveal, print, or persist a Folo session token. Do not place it in +repository files, shell history, command arguments, logs, or chat. Follow the +repository's 1Password policy whenever token-based authentication is necessary. + +## Execution Policy + +- Prefer `npx --yes folocli@latest ...` for all agent runs. +- Do not require `npm install -g folocli`. +- No separate update preflight is needed. Using `folocli@latest` is the update strategy. +- If a user already has a working global `folo` binary, it is acceptable, but `npx --yes folocli@latest` remains the recommended default in docs and automation. +- Before mutations, inspect the exact target and confirm that it matches the user's request. +- Treat bulk removal, list deletion, OPML import, and batch mark-read operations as consequential changes; summarize their scope before executing them. + +## Output Contract + +Default output is JSON with a stable envelope: + +```json +{ + "ok": true, + "data": {}, + "error": null +} +``` + +Errors return: + +```json +{ + "ok": false, + "data": null, + "error": { + "code": "UNAUTHORIZED", + "message": "Token is invalid or expired." + } +} +``` + +You can switch output mode: + +- `--format json` (default) +- `--format table` +- `--format plain` + +## Core Workflows + +### 1. Timeline Reading + +1. Fetch timeline: + - `npx --yes folocli@latest timeline --limit 10` +2. Get entry detail: + - `npx --yes folocli@latest entry get ` +3. Get readability content: + - `npx --yes folocli@latest entry read ` + +### 2. Subscription Management + +1. Discover: + - `npx --yes folocli@latest search discover ` +2. Add subscription: + - `npx --yes folocli@latest subscription add --feed ` + - or `npx --yes folocli@latest subscription add --list ` +3. List subscriptions: + - `npx --yes folocli@latest subscription list` + +### 3. Unread Processing + +1. Check unread total: + - `npx --yes folocli@latest unread count` +2. List unread subscriptions: + - `npx --yes folocli@latest unread list` +3. Read unread entries: + - `npx --yes folocli@latest timeline --unread-only --limit 20` +4. Mark read: + - `npx --yes folocli@latest entry mark-read ` + - or batch: `npx --yes folocli@latest entry mark-all-read --view articles` + +### 4. Collection Operations + +- Add: `npx --yes folocli@latest collection add ` +- Remove: `npx --yes folocli@latest collection remove ` +- List: `npx --yes folocli@latest collection list --limit 20` + +### 5. OPML Import / Export + +- Export: + - `npx --yes folocli@latest opml export --output backup.opml` +- Import: + - `npx --yes folocli@latest opml import feeds.opml` + +## Pagination Pattern + +`npx --yes folocli@latest timeline` returns: + +- `entries` +- `nextCursor` +- `hasNext` + +Loop until `hasNext` is `false`: + +1. `npx --yes folocli@latest timeline --limit 20` +2. Read `nextCursor` +3. `npx --yes folocli@latest timeline --limit 20 --cursor ` +4. Repeat + +## Command Reference + +- `npx --yes folocli@latest login [--timeout ]` +- `npx --yes folocli@latest logout` +- `npx --yes folocli@latest whoami` +- `npx --yes folocli@latest auth login [--timeout ]` +- `npx --yes folocli@latest auth logout` +- `npx --yes folocli@latest auth whoami` + +- `npx --yes folocli@latest timeline [--view ] [--limit ] [--unread-only] [--cursor ]` +- `npx --yes folocli@latest timeline --feed [--limit ] [--cursor ]` +- `npx --yes folocli@latest timeline --list [--limit ] [--cursor ]` +- `npx --yes folocli@latest timeline --category [--view ] [--limit ]` + +- `npx --yes folocli@latest subscription list [--view ] [--category ]` +- `npx --yes folocli@latest subscription add --feed [--category ] [--view ] [--private]` +- `npx --yes folocli@latest subscription add --list [--category ] [--view ]` +- `npx --yes folocli@latest subscription remove [--target feed|list|url]` +- `npx --yes folocli@latest subscription update [--target feed|list] [--category ] [--title ] [--view <type>] [--private|--public]` + +- `npx --yes folocli@latest entry get <entryId>` +- `npx --yes folocli@latest entry read <entryId>` +- `npx --yes folocli@latest entry mark-read <entryId>` +- `npx --yes folocli@latest entry mark-unread <entryId>` +- `npx --yes folocli@latest entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]` + +- `npx --yes folocli@latest feed get <feedId|feedUrl>` +- `npx --yes folocli@latest feed refresh <feedId>` +- `npx --yes folocli@latest feed analytics <feedId>` + +- `npx --yes folocli@latest list ls` +- `npx --yes folocli@latest list get <listId>` +- `npx --yes folocli@latest list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]` +- `npx --yes folocli@latest list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]` +- `npx --yes folocli@latest list delete <listId>` +- `npx --yes folocli@latest list add-feed <listId> --feed <feedId>` +- `npx --yes folocli@latest list remove-feed <listId> --feed <feedId>` + +- `npx --yes folocli@latest search discover <keyword> [--type feeds|lists]` +- `npx --yes folocli@latest search rsshub <keyword> [--lang <lang>]` +- `npx --yes folocli@latest search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]` + +- `npx --yes folocli@latest collection list [--limit <n>] [--cursor <datetime>]` +- `npx --yes folocli@latest collection add <entryId> [--view <type>]` +- `npx --yes folocli@latest collection remove <entryId>` + +- `npx --yes folocli@latest opml export [--output <file>]` +- `npx --yes folocli@latest opml import <file> [--items <url1,url2,...>]` + +- `npx --yes folocli@latest unread count` +- `npx --yes folocli@latest unread list [--view <type>]` + +## Error Recovery + +- `UNAUTHORIZED` + - Re-login with `npx --yes folocli@latest login`. + - If token-based login is required, use 1Password runtime injection for `FOLO_TOKEN`. +- `HTTP_4xx` / `HTTP_5xx` + - Retry with `--verbose` for request details, while ensuring no authentication material is included in captured output. + - Verify `--api-url` if using a non-default endpoint. +- `INVALID_ARGUMENT` + - Run `npx --yes folocli@latest <command> --help` to inspect accepted options. diff --git a/.github/scripts/audit.py b/.github/scripts/audit.py index f500d27..7fcab08 100644 --- a/.github/scripts/audit.py +++ b/.github/scripts/audit.py @@ -12,16 +12,20 @@ from __future__ import annotations import argparse +import json import re import sys import tomllib from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, Callable SENTINEL_START = "<!-- AUDIT:START -->" SENTINEL_END = "<!-- AUDIT:END -->" LIMITS_PATH = Path("audit/limits.toml") +FOLO_SNAPSHOT_PATH = Path("audit/folo-snapshot.json") +FOLO_SNAPSHOT_MAX_AGE = timedelta(days=35) USD_TO_CNY = 7.2 # rough April 2026 rate @@ -195,6 +199,15 @@ def render_full(cats: list[Category]) -> str: "h3_active": lambda cats, h3: sum(c.active for c in cats if c.h3 == h3), } +FOLO_METRICS: dict[str, tuple[str, ...]] = { + "folo_attention_minutes": ("attention", "budgeted_minutes_per_week"), + "folo_core_count": ("lanes", "core", "source_count"), + "folo_changelog_count": ("lanes", "changelog", "source_count"), + "folo_uncategorized_count": ("totals", "uncategorized_sources"), + "folo_abnormal_count": ("totals", "abnormal_sources"), + "folo_core_max_source_share": ("lanes", "core", "max_source_share_percent"), +} + def load_limits(path: Path) -> list[dict[str, Any]]: if not path.exists(): @@ -204,8 +217,50 @@ def load_limits(path: Path) -> list[dict[str, Any]]: return data.get("items", []) -def compute_metric(item: dict[str, Any], cats: list[Category]) -> float: +def load_folo_snapshot( + path: Path = FOLO_SNAPSHOT_PATH, + *, + now: datetime | None = None, +) -> dict[str, Any] | None: + """Load a fresh Folo snapshot; missing, invalid, or stale means unavailable.""" + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + generated = datetime.fromisoformat( + str(data["generated_at"]).replace("Z", "+00:00") + ).astimezone(UTC) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return None + current = (now or datetime.now(UTC)).astimezone(UTC) + if generated > current + timedelta(minutes=5): + return None + if current - generated > FOLO_SNAPSHOT_MAX_AGE: + return None + return data + + +def _lookup_number(data: dict[str, Any], path: tuple[str, ...]) -> float | None: + value: Any = data + for key in path: + if not isinstance(value, dict) or key not in value: + return None + value = value[key] + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) + + +def compute_metric( + item: dict[str, Any], + cats: list[Category], + folo_snapshot: dict[str, Any] | None = None, +) -> float | None: name = item["metric"] + if name in FOLO_METRICS: + if folo_snapshot is None: + return None + return _lookup_number(folo_snapshot, FOLO_METRICS[name]) fn = METRICS.get(name) if fn is None: raise ValueError(f"unknown metric: {name!r} in item {item.get('name', '?')}") @@ -214,22 +269,39 @@ def compute_metric(item: dict[str, Any], cats: list[Category]) -> float: return fn(cats) -def format_value(value: float) -> str: - return str(round(value)) - - -def status_cell(current: float, limit: float | None) -> str: +def format_value(value: float | None, unit: str | None = None) -> str: + if value is None: + return "N/A" + rounded = str(int(value)) if value.is_integer() else f"{value:.1f}" + if unit == "minutes": + return f"{rounded} 分钟" + if unit == "percent": + return f"{rounded}%" + return rounded + + +def status_cell( + current: float | None, + limit: float | None, + unit: str | None = None, +) -> str: + if current is None: + return "⚪ N/A" if limit is None: return "—" diff = current - limit if diff > 0: - return f"🚨 超 {format_value(diff)}" + return f"🚨 超 {format_value(diff, unit)}" if diff == 0: return "🟡 持平" - return f"✅ 留白 {format_value(-diff)}" + return f"✅ 留白 {format_value(-diff, unit)}" -def render_inline(cats: list[Category], limits: list[dict[str, Any]]) -> str: +def render_inline( + cats: list[Category], + limits: list[dict[str, Any]], + folo_snapshot: dict[str, Any] | None = None, +) -> str: """Compact limits dashboard for embedding in README.md between sentinels.""" out: list[str] = [] out.append("### 📊 体量盘点") @@ -238,6 +310,10 @@ def render_inline(cats: list[Category], limits: list[dict[str, Any]]) -> str: "> 由 [.github/scripts/audit.py](.github/scripts/audit.py) " "依据 [audit/limits.toml](audit/limits.toml) 自动生成,pre-commit hook 刷新。" ) + if folo_snapshot is None and any( + item.get("metric") in FOLO_METRICS for item in limits + ): + out.append("> Folo 快照缺失或已超过 35 天;相关指标显示 `N/A`,不会按 0 处理。") out.append("") if not limits: @@ -247,13 +323,14 @@ def render_inline(cats: list[Category], limits: list[dict[str, Any]]) -> str: out.append("| # | 维度 | 当前 | 上限 | 状态 | 备注 |") out.append("|---|------|------|------|------|------|") for i, item in enumerate(limits, 1): - current = compute_metric(item, cats) + current = compute_metric(item, cats, folo_snapshot) limit = item.get("limit") - limit_cell = format_value(float(limit)) if limit is not None else "—" - status = status_cell(current, float(limit) if limit is not None else None) + unit = item.get("unit") + limit_cell = format_value(float(limit), unit) if limit is not None else "—" + status = status_cell(current, float(limit) if limit is not None else None, unit) note = item.get("note", "") out.append( - f"| {i} | {item['name']} | {format_value(current)} | " + f"| {i} | {item['name']} | {format_value(current, unit)} | " f"{limit_cell} | {status} | {note} |" ) @@ -272,7 +349,7 @@ def update_in_place(readme: Path, limits_path: Path) -> bool: cats = audit(readme) limits = load_limits(limits_path) - inline = render_inline(cats, limits) + inline = render_inline(cats, limits, load_folo_snapshot()) new_block = f"{SENTINEL_START}\n\n{inline}\n\n{SENTINEL_END}" pattern = re.compile( diff --git a/.github/scripts/audit_folo.py b/.github/scripts/audit_folo.py new file mode 100644 index 0000000..ea247e4 --- /dev/null +++ b/.github/scripts/audit_folo.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Generate a privacy-safe aggregate snapshot of the signed-in Folo account. + +The live command uses the repo-local Folo CLI session. The resulting JSON is +safe to commit: it contains only aggregate counts and rates, never account +metadata, source/list identifiers, source titles, URLs, or credentials. + +Usage: + python3 .github/scripts/audit_folo.py --output audit/folo-snapshot.json +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Iterable + +CLI = ("npx", "--yes", "folocli@latest") +CORE_LIST = "⚡ 每日核心" +DESSERT_LIST = "🧁 周六甜点" +CHANGELOG_LIST = "🛠 Changelog" +ENTERTAINMENT_CATEGORY = "🎮 ACG 与娱乐" + +ATTENTION_BUDGET = { + "core": 60, + "dessert": 75, + "changelog": 15, + "entertainment": 30, +} + + +class AuditError(RuntimeError): + """A sanitized live-data collection failure.""" + + +def run_cli(*args: str) -> Any: + """Run Folo CLI without exposing its stored session or raw error output.""" + command = [*CLI, *args] + label = " ".join(args[:2]) + try: + proc = subprocess.run(command, text=True, capture_output=True, timeout=120) + except subprocess.TimeoutExpired as exc: + raise AuditError(f"Folo CLI command timed out: {label}") from exc + if proc.returncode: + raise AuditError(f"Folo CLI command failed: {label}") + try: + result = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise AuditError(f"Folo CLI returned invalid JSON: {label}") from exc + if not result.get("ok"): + raise AuditError(f"Folo API request failed: {label}") + return result.get("data") + + +def fetch_live_data(workers: int = 8) -> dict[str, Any]: + """Collect the minimum live payload needed to build aggregate metrics.""" + subscriptions = run_cli("subscription", "list")["subscriptions"] + owned_lists = run_cli("list", "ls") + unread = run_cli("unread", "list") + collections = run_cli("collection", "list", "--limit", "100") + + direct_ids = { + row["feedId"] for row in subscriptions if isinstance(row.get("feeds"), dict) + } + list_ids = { + feed_id + for row in owned_lists + for feed_id in (row.get("feedIds") or []) + } + analytics_ids = sorted(direct_ids | list_ids) + + analytics: dict[str, float] = {} + failures = 0 + + def fetch_one(feed_id: str) -> tuple[str, float]: + data = run_cli("feed", "analytics", feed_id) + record = (data.get("analytics") or {}).get(feed_id) or {} + return feed_id, max(float(record.get("updatesPerWeek") or 0), 0) + + with ThreadPoolExecutor(max_workers=max(1, workers)) as pool: + futures = {pool.submit(fetch_one, feed_id): feed_id for feed_id in analytics_ids} + for future in as_completed(futures): + try: + feed_id, updates = future.result() + analytics[feed_id] = updates + except AuditError: + failures += 1 + analytics[futures[future]] = 0 + + return { + "subscriptions": subscriptions, + "lists": owned_lists, + "unread": unread, + "collections": collections, + "analytics": analytics, + "analytics_failures": failures, + } + + +def _as_datetime(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) + except ValueError: + return None + + +def _deduplicated_stars(entries: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[str] = set() + result: list[dict[str, Any]] = [] + for row in entries: + entry_id = str((row.get("entries") or {}).get("id") or "") + if not entry_id or entry_id in seen: + continue + seen.add(entry_id) + result.append(row) + return result + + +def _lane_snapshot( + feed_ids: set[str], + analytics: dict[str, float], + unread_by_feed: dict[str, int], + stars_by_feed: Counter[str], + star_window_days: int, +) -> dict[str, int | float]: + updates = {feed_id: analytics.get(feed_id, 0) for feed_id in feed_ids} + updates_per_week = sum(updates.values()) + max_share = ( + max(updates.values(), default=0) / updates_per_week * 100 + if updates_per_week > 0 + else 0 + ) + starred = sum(stars_by_feed.get(feed_id, 0) for feed_id in feed_ids) + expected_entries = updates_per_week * max(star_window_days, 1) / 7 + hit_rate = starred / max(expected_entries, starred, 1) * 100 + return { + "source_count": len(feed_ids), + "estimated_entries_per_week": round(updates_per_week, 1), + "unread_entries": sum(unread_by_feed.get(feed_id, 0) for feed_id in feed_ids), + "starred_entries_in_sample": starred, + "estimated_star_hit_rate_percent": round(hit_rate, 1), + "max_source_share_percent": round(max_share, 1), + } + + +def build_snapshot(data: dict[str, Any], *, now: datetime | None = None) -> dict[str, Any]: + """Normalize raw CLI payloads into a privacy-safe aggregate snapshot.""" + now = (now or datetime.now(UTC)).astimezone(UTC) + subscriptions = data.get("subscriptions") or [] + direct = [row for row in subscriptions if isinstance(row.get("feeds"), dict)] + owned_lists = data.get("lists") or [] + analytics = {str(k): float(v or 0) for k, v in (data.get("analytics") or {}).items()} + + list_feed_ids = { + str(row.get("title")): {str(feed_id) for feed_id in (row.get("feedIds") or [])} + for row in owned_lists + } + core_ids = list_feed_ids.get(CORE_LIST, set()) + dessert_ids = list_feed_ids.get(DESSERT_LIST, set()) + changelog_ids = list_feed_ids.get(CHANGELOG_LIST, set()) + entertainment_ids = { + str(row.get("feedId")) + for row in direct + if row.get("category") == ENTERTAINMENT_CATEGORY and not row.get("isPrivate") + } + + unread_data = data.get("unread") or {} + unread_by_feed = { + str(row.get("feedId")): int(row.get("unreadCount") or 0) + for row in (unread_data.get("items") or []) + if row.get("feedId") + } + + raw_stars = (data.get("collections") or {}).get("entries") or [] + stars = _deduplicated_stars(raw_stars) + stars_by_feed: Counter[str] = Counter() + star_dates: list[datetime] = [] + for row in stars: + feed_id = str((row.get("feeds") or {}).get("id") or "") + if feed_id: + stars_by_feed[feed_id] += 1 + created = _as_datetime((row.get("collections") or {}).get("createdAt")) + if created: + star_dates.append(created) + star_window_days = ( + max(1, (max(star_dates) - min(star_dates)).days + 1) if star_dates else 1 + ) + + direct_ids = {str(row.get("feedId")) for row in direct} + total_updates = sum(analytics.get(feed_id, 0) for feed_id in direct_ids) + high_volume_zero_star = sum( + 1 + for feed_id in direct_ids + if analytics.get(feed_id, 0) > 25 and stars_by_feed.get(feed_id, 0) == 0 + ) + low_frequency_starred = sum( + 1 + for feed_id in direct_ids + if analytics.get(feed_id, 0) <= 2 and stars_by_feed.get(feed_id, 0) > 0 + ) + + lanes = { + "core": _lane_snapshot( + core_ids, analytics, unread_by_feed, stars_by_feed, star_window_days + ), + "dessert": _lane_snapshot( + dessert_ids, analytics, unread_by_feed, stars_by_feed, star_window_days + ), + "changelog": _lane_snapshot( + changelog_ids, analytics, unread_by_feed, stars_by_feed, star_window_days + ), + "entertainment": _lane_snapshot( + entertainment_ids, analytics, unread_by_feed, stars_by_feed, star_window_days + ), + } + + snapshot = { + "schema_version": 1, + "generated_at": now.isoformat(timespec="seconds").replace("+00:00", "Z"), + "window_days": 7, + "status": "ok" if not data.get("analytics_failures") else "partial", + "totals": { + "direct_subscriptions": len(direct), + "owned_lists": len(owned_lists), + "timeline_visible_sources": sum( + not bool(row.get("hideFromTimeline")) for row in direct + ), + "timeline_hidden_sources": sum( + bool(row.get("hideFromTimeline")) for row in direct + ), + "private_sources": sum(bool(row.get("isPrivate")) for row in direct), + "uncategorized_sources": sum(not row.get("category") for row in direct), + "abnormal_sources": sum( + bool((row.get("feeds") or {}).get("errorMessage")) for row in direct + ), + "unread_entries": int(unread_data.get("total") or 0), + "estimated_entries_per_week": round(total_updates, 1), + "analytics_failures": int(data.get("analytics_failures") or 0), + }, + "attention": { + "budgeted_minutes_per_week": sum(ATTENTION_BUDGET.values()), + "core_minutes": ATTENTION_BUDGET["core"], + "dessert_minutes": ATTENTION_BUDGET["dessert"], + "changelog_minutes": ATTENTION_BUDGET["changelog"], + "entertainment_minutes": ATTENTION_BUDGET["entertainment"], + }, + "lanes": lanes, + "star_signals": { + "sample_size": len(stars), + "sample_window_days": star_window_days, + "starred_source_count": len(stars_by_feed), + "high_volume_zero_star_sources": high_volume_zero_star, + "low_frequency_starred_sources": low_frequency_starred, + }, + } + validate_snapshot_privacy(snapshot) + return snapshot + + +def validate_snapshot_privacy(snapshot: dict[str, Any]) -> None: + """Reject fields or values that could disclose Folo source metadata.""" + forbidden_keys = {"account", "feed_id", "feedid", "list_id", "listid", "title", "url"} + + def walk(value: Any) -> None: + if isinstance(value, dict): + for key, child in value.items(): + if key.lower() in forbidden_keys: + raise ValueError(f"privacy-unsafe snapshot key: {key}") + walk(child) + elif isinstance(value, list): + for child in value: + walk(child) + elif isinstance(value, str) and ("http://" in value or "https://" in value): + raise ValueError("privacy-unsafe URL in snapshot") + + walk(snapshot) + + +def write_snapshot(snapshot: dict[str, Any], output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(snapshot, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("audit/folo-snapshot.json")) + parser.add_argument("--workers", type=int, default=8) + args = parser.parse_args() + try: + snapshot = build_snapshot(fetch_live_data(args.workers)) + write_snapshot(snapshot, args.output) + except (AuditError, ValueError) as exc: + print(f"[audit-folo] {exc}", file=sys.stderr) + return 1 + print(f"[audit-folo] wrote privacy-safe snapshot to {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index efb85af..9bdab5f 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -3,6 +3,18 @@ name: Linkspector on: [pull_request] jobs: + audit: + name: runner / audit + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Run audit tests + run: python3 -m unittest discover -s tests -p 'test_*.py' + - name: Verify generated limits dashboard + run: | + python3 .github/scripts/audit.py --update README.md + git diff --exit-code -- README.md + check-links: name: runner / linkspector runs-on: ubuntu-24.04 diff --git a/.linkspector.yml b/.linkspector.yml index e66ed2e..7d2ead1 100644 --- a/.linkspector.yml +++ b/.linkspector.yml @@ -1,11 +1,26 @@ files: - README.md ignorePatterns: - - pattern: '^https://podcasts.apple.com/us/podcast/*$' - - pattern: '^https://www.zhihu.com/question/*$' - - pattern: '^https://zhuanlan.zhihu.com/p/*$' - - pattern: '^https://news.ycombinator.com/$' - - pattern: '^https://zh.moegirl.org.cn/$' - - pattern: '^https://www.damai.cn/*$' + - pattern: '^https://podcasts\.apple\.com/us/podcast/.*$' + - pattern: '^https://www\.zhihu\.com/question/.*$' + - pattern: '^https://zhuanlan\.zhihu\.com/p/.*$' + - pattern: '^https://news\.ycombinator\.com/$' + - pattern: '^https://zh\.moegirl\.org\.cn/$' + - pattern: '^https://www\.damai\.cn/?(?:.*)?$' + # These sites block automated checks or intermittently reject headless clients. + - pattern: '^https://arc\.net/?$' + - pattern: '^https://baike\.baidu\.com/item/.*$' + - pattern: '^https://book\.douban\.com/author/\d+/?$' + - pattern: '^https://movie\.douban\.com/celebrity/\d+/?$' + - pattern: '^https://www\.douban\.com/personage/\d+/?$' + - pattern: '^https://www\.bilibili\.com/video/[^/]+/?$' + - pattern: '^https://openai\.com/codex/?$' + - pattern: '^https://www\.sony\.com/electronics/support/product/wh-xb910n/downloads/?$' + - pattern: '^https://rsshub\.app/$' + - pattern: '^https://rsync\.samba\.org/$' + - pattern: '^https://www\.raspberrypi\.com/software/$' + - pattern: '^https://z-library\.sk/?$' + - pattern: '^https://xlog\.app/?$' + - pattern: '^https://www\.1point3acres\.com/$' useGitIgnore: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9182439..349a8b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,5 +14,5 @@ repos: name: plrom 体量盘点 (auto-update README) language: system entry: python3 .github/scripts/audit.py --update README.md - files: '^(README\.md|\.github/scripts/audit\.py|audit/limits\.toml)$' + files: '^(README\.md|\.github/scripts/audit(_folo)?\.py|audit/(limits\.toml|folo-snapshot\.json))$' pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 0b72976..ba4117c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ The repository uses linkspector to validate URLs in README.md: ```bash # Check all links (runs automatically via GitHub Actions) -npx linkspector . +npx --yes @umbrelladocs/linkspector check --showstat ``` Configuration is in [.linkspector.yml](.linkspector.yml) which ignores certain domains that frequently timeout or block bots. @@ -130,7 +130,7 @@ docs/update-guide # Documentation updates - **Name** (category) - Reason for removal ``` -4. **Check links**: `npx linkspector .` +4. **Check links**: `npx --yes @umbrelladocs/linkspector check --showstat` 5. **Create PR**: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1fce1..cb62058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ ## [Unreleased] +### Added + +- **Folo RSS 阅读体系** — 新增 `docs/folo-rss.md`,记录 8 个主题分类、「每日核心、周六甜点、Changelog」3 个阅读入口和每周 180 分钟注意力预算。 +- **Folo 匿名审计** — 新增 `.github/scripts/audit_folo.py` 和脱敏 fixture,可生成不含账号、源/List ID、标题、URL 或私密明细的 `audit/folo-snapshot.json`。 + +### Changed + +- **体量盘点** — Folo 快照缺失或超过 35 天时显示 `N/A`,pre-commit 和 CI 只读取快照,不登录 Folo。 +- **README 范围** — Folo 阅读规则和聚合指标不再显示在 README,避免维护流程干扰「心头好」清单;详细规则移至独立文档。 +- **链接检查命令** — 改用当前发布的 `@umbrelladocs/linkspector check` CLI,替换已不存在的 `linkspector` npm 包命令。 + +### Fixed + +- **历史外链** — 更新 Logitech、Western Digital、Sony、ImageMagick、Obsidian Skills 和 Pillow 的失效地址;移除无法确认官方替代账号的舞城王太郎链接及重复的 Pinboard 扩展条目。 +- **Linkspector 忽略规则** — 修正原有正则表达式,并仅忽略会阻止自动检查或间歇性拒绝无头客户端的站点。 + ## [2026.08.1] - 2026-08-09 ### Changed diff --git a/README.md b/README.md index 8ad89ba..bf0290f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ tags: [ "社区", "工具" ] cover: https://image.niracler.com/2025/11/b2ef6430e8e437cdbff99b1346badbfc.png summary: 关于我关注的人和物。这个主题很个人化,我喜欢的内容,别人或许会觉得不适合。毕竟这与我的阅历和经验密切相关。不过,我希望能列出一些我认为不错的东西,给你一些启发。 date: 2024-09-26 -modified: 2026-08-09 +modified: 2026-08-29 --> 关于我关注的人和物。这个主题很个人化,我喜欢的内容,别人或许会觉得不适合。毕竟这与我的阅历和经验密切相关。不过,我希望能列出一些我认为不错的东西,给你一些启发。 @@ -70,7 +70,7 @@ modified: 2026-08-09 漫画原作 - [山川直輝](https://twitter.com/yamakawaMGnaoki) - 《我家的英雄》以及《我立于百万生命之上》的原作作者,故事很有趣,就是带了好多政治方面的观念。而且两部作品动画化都暴死了。 -- [舞城王太郎](https://twitter.com/maijouotarou) - 设定非常有趣,但是故事也就如此。是《异度入侵》和《深渊融接》的原作作者。 +- 舞城王太郎 - 设定非常有趣,但是故事也就如此。是《异度入侵》和《深渊融接》的原作作者。 <details> <summary>弃坑列表</summary> @@ -228,8 +228,8 @@ modified: 2026-08-09 - 13600kf+4070s - 最近配的一台主机,但是还没发现更具体的用途,单纯只是用来完了两盘《文明 6 》。不过现在下班后都是用它了。在熟悉 MacOS 到 Windows 的过程。 - [LG 27UQ850V 27英寸 4K 显示器](https://item.jd.com/100081317949.html) - IPS 高清专业设计显示器,TypeC 90W,HDR400,内置音箱。 - [Filco 圣手 2 代双模机械键盘](https://www.zhihu.com/question/273691080) - 八年老键盘了,现在宿舍电脑就是用的它。 - - [Logitech MX Master 3](https://www.logitech.com.cn/zh-cn/products/mice/mx-master-3.910-005694.html) - ~~买回来的时候期待很高,不过现在吃灰了,我基本就只用笔记本的触摸板。因为用鼠标的话,右手要经常切换,其实挺影响效率的。~~ 现在是连着 windows 主机在用。 - - [西部数据(WD) 2TB 移动硬盘 USB 3.0](https://www.wd.com/products/portable-storage/my-passport.html) - 中间有段时间用于备份我的 MacBook Air 的数据,但是经常会因为太慢甚至将我电脑卡死,所以就更换用途了。现在是用来作为 AutoBangumi 的动画存储。 + - [Logitech MX Master 3](https://support.logi.com/hc/en-us/articles/360035271133-Getting-Started-MX-Master-3) - ~~买回来的时候期待很高,不过现在吃灰了,我基本就只用笔记本的触摸板。因为用鼠标的话,右手要经常切换,其实挺影响效率的。~~ 现在是连着 windows 主机在用。 + - [西部数据(WD) 2TB 移动硬盘 USB 3.0](https://www.westerndigital.com/en-sg/products/portable-drives/wd-my-passport-usb-3-0-hdd?sku=WDBPKJ0050BWT-WESN) - 中间有段时间用于备份我的 MacBook Air 的数据,但是经常会因为太慢甚至将我电脑卡死,所以就更换用途了。现在是用来作为 AutoBangumi 的动画存储。 - [Pioneer BDR-XD07B 便携式蓝光刻录机](https://www.amazon.com/Pioneer-BDR-XD07B-Slim-Portable-Burner/dp/B07ZJX5HSH)(¥356 二手) - 用来读取蓝光光盘,主要是去日本旅游买了几张 CD,被迫跟着要对应的配套设施了。 - [Logitech MX Keys](https://www.logitech.com/zh-cn/products/keyboards/mx-keys-mac-wireless-keyboard.920-009559.html) - 拿去公司上班用了 - [Magic Trackpad](https://support.apple.com/en-hk/111884) - pseudoyu 送的,现在在公司用。已经用了四五年 MacBook 的触摸板,鼠标变成超级用的不习惯了,于是就求救弄了一个😂。 @@ -276,7 +276,7 @@ modified: 2026-08-09 ### 🎧 耳机及音箱 - [AirPods Pro2](https://www.apple.com/cn/airpods-pro/) - 很糟糕的是,我已经是第三个 AirPods Pro 了, 前两个都被洗衣机洗了,现在已经不敢放到裤袋里面了 -- [Sony WH-XB910N](https://www.sony.com/electronics/headband-headphones/wh-xb910n) - 不知道是不是不是旗舰的原因,我觉得戴久了很不舒服,尤其是压着我眼镜架了。不过音质还是不错的,头戴式的降噪效果应该说比 AirPods Pro 2 也要好些。不常用,觉得不方便,**吃灰中,想出手了**。 +- [Sony WH-XB910N](https://www.sony.com/electronics/support/product/wh-xb910n/downloads) - 不知道是不是不是旗舰的原因,我觉得戴久了很不舒服,尤其是压着我眼镜架了。不过音质还是不错的,头戴式的降噪效果应该说比 AirPods Pro 2 也要好些。不常用,觉得不方便,**吃灰中,想出手了**。 - [小爱智能音箱](https://www.mi.com/aispeaker) - 也是基本上不能缺的设备,用来控制家里的各种智能家居。不过基本没有用来听音乐就是了。 - [HomePod mini](https://www.apple.com/homepod-mini/)(¥438 转转二手) - 二手入手了一个,继续践行「每月买一个物联网设备」的计划(不是😂)。音质比小爱好不少。 @@ -396,7 +396,7 @@ modified: 2026-08-09 - [zsh-fzf-history-search](https://github.com/joshskidmore/zsh-fzf-history-search) - 用于在 zsh 中使用 fzf 来搜索历史命令。 - [rsync](https://rsync.samba.org/) - 服务器间的文件同步命令。类 Linux 系统应该都是自带的,服务器之间的文件传输我都是靠它的。我最喜欢用的参数是 `-acvP` 。 - [rclone](https://rclone.org/) - 可以将本地文件传输到云存储上。用过 S3、R2、OSS 等云产品的同学应该都多多少少用过的。 -- [imagemagick](https://imagemagick.org/index.php) - 用于处理图片的命令行工具,我主要用它来批量转换图片格式。 +- [imagemagick](https://imagemagick.org/download/) - 用于处理图片的命令行工具,我主要用它来批量转换图片格式。 - [ffmpeg](https://ffmpeg.org/) - 用于处理视频的命令行工具,简单的视频切片、转码等操作都可以用它来完成。 - [onefetch](https://github.com/o2sh/onefetch) - 用 Rust 编写的命令行 Git 仓库信息展示工具,类似于 neofetch 但专门用于显示代码仓库的详细信息。 @@ -451,7 +451,7 @@ PS. 因为模型能力本身的增强,很多原有的用于开发流程的 Ski - [find-skills](https://skills.sh/vercel-labs/skills/find-skills) - 搜索和推荐适合你的 Skill,通过对话了解需求后提供个性化推荐。 - [我的 Skill 仓库](https://github.com/niracler/skill) - 个人 Skills 集合,涵盖工作流自动化(Git、云效、代码同步、工作回顾等)、写作辅助(校对、灵感、日记)、学习工具(Anki)和趣味转换(戏言风格)。 - [humanizer-zh](https://skills.sh/op7418/humanizer-zh/humanizer-zh) - 去除中文 AI 痕迹,让文字更像人写的。基于维基百科的 AI 写作特征指南,检测并修复夸大象征、宣传性语言、模糊归因等模式。 -- [obsidian-skills](https://github.com/nicholasrq/obsidian-skills) - 支持 Obsidian 特有语法:wikilinks、callouts、properties、Canvas 文件等。 +- [obsidian-skills](https://github.com/kepano/obsidian-skills) - 支持 Obsidian 特有语法:wikilinks、callouts、properties、Canvas 文件等。 - [ui-ux-pro-max](https://skills.sh/nextlevelbuilder/ui-ux-pro-max-skill/ui-ux-pro-max) - UI/UX 设计智能,支持 50 种风格、21 种配色方案、50 种字体组合,涵盖 React、Next.js、Vue 等 9 种技术栈。 - [slidev](https://skills.sh/antfu/skills/slidev) - 用 Markdown 创建开发者演示文稿(Slidev),支持代码高亮、动画、Vue 组件等。 - [antfu/skills](https://github.com/antfu/skills) - Anthony Fu 的 Vue 生态技能集合,包含 17 个技能:Vue、Nuxt、Vite、Vitest、VitePress、Pinia、UnoCSS、pnpm、Slidev 等,直接从官方文档同步。 @@ -581,7 +581,6 @@ PS. 因为模型能力本身的增强,很多原有的用于开发流程的 Ski ### 🌐 浏览器 - Safari - 怎么说呢,用了 Chrome 许久,然后又用了 Arc 许久,最后还是用奥卡姆剃刀原理,选择了 Safari。 - - [bookmarker for pinboard](https://apps.apple.com/de/app/bookmarker-for-pinboard/id1451400394?l=en&mt=12) - 用于将当前页面添加到 Pinboard。 - 1Password for Safari - 密码管理器,我是将 Safari 原生的填充给关了。 - AdGuard for Safari - 广告拦截器,不过用的不太久,还没能体验它和 uBlock Origin 的区别。 - bookmarker for pinboard - 用于将当前页面添加到 Pinboard。 @@ -647,7 +646,7 @@ PS. 因为模型能力本身的增强,很多原有的用于开发流程的 Ski - ~~[Apple Fitness+](https://www.apple.com/apple-fitness-plus/) - 订阅了 Apple One 之后就有了这个服务,替代掉之前使用的 Keep。不过使用频率堪忧。~~ 首先没有中文,二来也贵,所以还是停用了。 - ~~[Keep](https://www.gotokeep.com/) - 结合跳绳的方案其实还挺好的。~~ ~~之前用来做健身的,不过现在已经停用了,转去用 Fitness+。~~ 怎么说呢,keep 其实很适合新手根练,但是对于我这种已经有一定基础的人来说,就有点不够了。我现在已经是通过自己的安排,结合视频来练了。 - ~~[AutoSleep](https://autosleepapp.tantsissa.com/) - 用于监测我睡眠的 APP~~, 感觉没什么意义,现在已经停用了。 -- ~~[Pillow](https://www.neybox.com/pillow) - 用于监测我睡眠的 APP~~, 试着订阅了一年,但发现其实跟 AutoSleep 拉不开距离而且跟原生 Health 的睡眠监测体感也没什么区别,所以也停用了。 +- ~~[Pillow](https://pillow.app/) - 用于监测我睡眠的 APP~~, 试着订阅了一年,但发现其实跟 AutoSleep 拉不开距离而且跟原生 Health 的睡眠监测体感也没什么区别,所以也停用了。 - ~~[Pokemon Sleep](https://www.pokemon.com/us/app/pokemon-sleep) - 用于监测我睡眠的 APP,同时收集点小精灵的睡姿。(虽然睡眠质量还是很糟糕就是了~~)~~ 还是弃用了,明天都要定时打开,还是挺费精神的。现在就仅仅保留了 Apple Health - ~~[Zepp Life](https://apps.apple.com/us/app/zepp-life-formerly-mifit/id938688461) - 前身是小米运动,在没有使用小米手环之后,现在主要用来看体脂称上面的数据。(后面才知道这个 APP 是也会同步体重数据到 Apple Health 上的)~~ 原来小米运动还在,搞错了。 @@ -876,7 +875,6 @@ PS. 因为模型能力本身的增强,很多原有的用于开发流程的 Ski 1. 各端设备(手机、电脑、平板)上的应用软件 2. Tachimanga 漫画订阅源中的新作者 -3. Folo 平台上关注的内容源 内容规范: @@ -893,9 +891,9 @@ PS. 因为模型能力本身的增强,很多原有的用于开发流程的 Ski | # | 维度 | 当前 | 上限 | 状态 | 备注 | |---|------|------|------|------|------| -| 1 | 月订阅基础消费 (¥/月) | 727 | 666 | 🚨 超 61 | 钱的容量 | +| 1 | 月订阅基础消费 (¥/月) | 727.5 | 666 | 🚨 超 61.5 | 钱的容量 | | 2 | 关注的人 | 82 | 100 | ✅ 留白 18 | README 里你自己声明过的隐藏上限 | -| 3 | 软件工具总数 | 161 | — | — | 决策疲劳 | +| 3 | 软件工具总数 | 160 | — | — | 决策疲劳 | | 4 | 物理资产 (设备) | 55 | — | — | 物理空间 / 维护成本 | | 5 | 🤖 大模型工具 | 15 | — | — | 涨势最快,FOMO 重灾区 | | 6 | 沉睡库存 (不活跃总数) | 109 | — | — | 反指标:太多就该清 | diff --git a/audit/folo-snapshot.json b/audit/folo-snapshot.json new file mode 100644 index 0000000..0daf5f8 --- /dev/null +++ b/audit/folo-snapshot.json @@ -0,0 +1,66 @@ +{ + "attention": { + "budgeted_minutes_per_week": 180, + "changelog_minutes": 15, + "core_minutes": 60, + "dessert_minutes": 75, + "entertainment_minutes": 30 + }, + "generated_at": "2026-08-15T04:37:01Z", + "lanes": { + "changelog": { + "estimated_entries_per_week": 63.0, + "estimated_star_hit_rate_percent": 0.0, + "max_source_share_percent": 36.5, + "source_count": 25, + "starred_entries_in_sample": 0, + "unread_entries": 0 + }, + "core": { + "estimated_entries_per_week": 35.0, + "estimated_star_hit_rate_percent": 6.0, + "max_source_share_percent": 20.0, + "source_count": 15, + "starred_entries_in_sample": 55, + "unread_entries": 14 + }, + "dessert": { + "estimated_entries_per_week": 74.0, + "estimated_star_hit_rate_percent": 1.2, + "max_source_share_percent": 27.0, + "source_count": 9, + "starred_entries_in_sample": 23, + "unread_entries": 0 + }, + "entertainment": { + "estimated_entries_per_week": 1020.0, + "estimated_star_hit_rate_percent": 0.0, + "max_source_share_percent": 54.8, + "source_count": 23, + "starred_entries_in_sample": 7, + "unread_entries": 0 + } + }, + "schema_version": 1, + "star_signals": { + "high_volume_zero_star_sources": 11, + "low_frequency_starred_sources": 10, + "sample_size": 100, + "sample_window_days": 182, + "starred_source_count": 31 + }, + "status": "ok", + "totals": { + "abnormal_sources": 15, + "analytics_failures": 0, + "direct_subscriptions": 227, + "estimated_entries_per_week": 2067.0, + "owned_lists": 3, + "private_sources": 33, + "timeline_hidden_sources": 213, + "timeline_visible_sources": 14, + "uncategorized_sources": 0, + "unread_entries": 14 + }, + "window_days": 7 +} diff --git a/docs/folo-rss.md b/docs/folo-rss.md new file mode 100644 index 0000000..5a74095 --- /dev/null +++ b/docs/folo-rss.md @@ -0,0 +1,113 @@ +# Folo RSS 阅读体系 + +本文记录 Folo 的订阅组织、阅读预算、筛选规则和维护流程。README 只保留公开的「心头好」清单,不展示 Folo 阅读规则或账户聚合指标。 + +阅读原则见[《Feed 阅读的正确姿势》](https://niracler.com/feed-reading-posture/):Feed 是甜点而非主食。每周最多投入 180 分钟;到点即停,不把未读数当成必须还清的债务。 + +## 组织方式 + +- View 管媒介形态。 +- Category 管主题。 +- List 管阅读场景。 +- Action 和 Spotlight 管筛选与提醒,不替代源头减量。 +- Star 是月度升降级信号,不是稍后读队列。 + +Category 统一为: + +- 🪶 个人博客与朋友 +- 🧠 AI 与软件工程 +- 🏠 IoT 与智能家居 +- 🏢 组织与产品 +- 📰 新闻与精选摘要 +- 🎮 ACG 与娱乐 +- 🔒 私密内容 +- 🧊 冷冻观察 + +只维护以下 3 个公开 List: + +- `⚡ 每日核心`:不超过 15 个源,预计不超过 35 条/周。 +- `🧁 周六甜点`:深度内容,平时无需即时阅读。 +- `🛠 Changelog`:目标 25 个、上限 30 个核心软件源。 + +私密源不加入公开 List。私密源保留在「🔒 私密内容」,并从总时间线隐藏。异常私密源不移动到「🧊 冷冻观察」,避免打破私密分类边界。 + +## 阅读预算 + +| 入口 | 每周预算 | 处理方式 | +|------|----------|----------| +| ⚡ 每日核心 | 60 分钟 | 保留未读 | +| 🧁 周六甜点 | 75 分钟 | 周六集中阅读 | +| 🛠 Changelog | 15 分钟 | 普通更新周六阅读,关键变化即时提醒 | +| 娱乐 | 30 分钟 | 主动进入,结束后不保留未读债务 | + +总时间线只显示每日核心。其他内容从 View、Category 或 List 主动进入。 + +## Folo 设置 + +通用设置: + +- 开启「启动时仅显示未读」「隐藏已读」「隐藏私密订阅」「按日期分组」。 +- 开启「滚动离开后标记为已读」。 +- Social、Pictures 等单项内容进入视图后标记为已读。 +- 关闭「自动按域名分组」「悬停时标记为已读」「自动展开长社交内容」和 Dock 未读徽章。 +- 顶部常驻标签保留 Articles 与 Notifications。 + +「隐藏已读」会让零未读的 List 和 Category 暂时从侧边栏消失,但不会删除内容。 + +## 内容治理 + +高流量源按以下顺序处理: + +1. 替换为官方 changelog、日报、Newsletter 或精选 Feed。 +2. 没有合适替代时,隐藏到对应 Category。 +3. 最后才使用 Action Rule 屏蔽明确噪声。 + +NGA、Pixiv、网盘和论坛流作为不积累未读的娱乐入口。429、5xx、抓取故障和疑似休眠源进入「🧊 冷冻观察」,观察 30 天后再决定恢复或退订。低频不是删除理由;删除前必须备份并生成候选预览。 + +月度升降级使用以下信号: + +- 每周超过 25 条且 90~180 天零 Star:检查是否需要替换或隐藏。 +- 低频且收藏命中率高:候选提升到每日核心。 +- 高频但偶有精品:放入周六甜点。 +- Changelog 不按 Star 命中率淘汰。 + +## Changelog 与提醒 + +来源优先级: + +1. 官方产品 RSS。 +2. GitHub Releases Atom。 +3. 官方 release notes。 +4. 产品专属博客。 + +Spotlight 关注以下变化: + +- 安全:`CVE-|security|vulnerability|breach|漏洞|安全更新|泄露` +- 兼容性:`breaking|deprecated|deprecation|EOL|migration|弃用|下线|迁移` +- 商业与隐私:`pricing|price change|terms|privacy|涨价|价格|条款|隐私` + +Action Rule 使用 changelog Feed URL 正则限定来源。只有安全、Breaking、弃用、迁移、价格或隐私变化执行「通知 + Star」;普通更新留到周六阅读。 + +AI Timeline Prompt 使用既定阅读原则。AI 排序只作为第二层排序,不代替隐藏和源头减量。 + +## 每月维护 + +操作前先备份。原始备份只能保存在 Git 忽略的 `.tmp/`,不得提交到仓库。 + +1. 导出 OPML 和 Action Rules。 +2. 生成匿名快照: + + ```bash + python3 .github/scripts/audit_folo.py --output audit/folo-snapshot.json + ``` + +3. 运行审计测试: + + ```bash + python3 -m unittest discover -s tests -v + ``` + +4. 复核每日核心、Changelog、未分类、异常源和核心单源流量占比。 +5. 观察 7 天的核心流量;观察 30 天后处理冷冻源。 + +匿名快照只保存生成时间、聚合计数、各入口流量、Star 命中率和预计注意力。快照不得包含账号、Feed 或 List ID、标题、URL、凭据和私密明细。pre-commit 与 CI 只读取快照,不登录 Folo。 diff --git a/tests/fixtures/folo/analytics.json b/tests/fixtures/folo/analytics.json new file mode 100644 index 0000000..172ca3a --- /dev/null +++ b/tests/fixtures/folo/analytics.json @@ -0,0 +1,9 @@ +{ + "feed-a": 10, + "feed-b": 2, + "feed-c": 4, + "feed-d": 1, + "feed-e": 1, + "feed-f": 7, + "feed-g": 0 +} diff --git a/tests/fixtures/folo/collections.json b/tests/fixtures/folo/collections.json new file mode 100644 index 0000000..1824555 --- /dev/null +++ b/tests/fixtures/folo/collections.json @@ -0,0 +1,8 @@ +{ + "entries": [ + {"feeds": {"id": "feed-a"}, "entries": {"id": "entry-1"}, "collections": {"createdAt": "2026-06-01T00:00:00Z"}}, + {"feeds": {"id": "feed-a"}, "entries": {"id": "entry-1"}, "collections": {"createdAt": "2026-06-01T00:00:00Z"}}, + {"feeds": {"id": "feed-c"}, "entries": {"id": "entry-2"}, "collections": {"createdAt": "2026-06-08T00:00:00Z"}}, + {"feeds": {"id": "feed-f"}, "entries": {"id": "entry-3"}, "collections": {"createdAt": "2026-06-15T00:00:00Z"}} + ] +} diff --git a/tests/fixtures/folo/lists.json b/tests/fixtures/folo/lists.json new file mode 100644 index 0000000..ed63b93 --- /dev/null +++ b/tests/fixtures/folo/lists.json @@ -0,0 +1,5 @@ +[ + {"id": "list-core", "title": "⚡ 每日核心", "feedIds": ["feed-a", "feed-b"]}, + {"id": "list-dessert", "title": "🧁 周六甜点", "feedIds": ["feed-c"]}, + {"id": "list-changelog", "title": "🛠 Changelog", "feedIds": ["feed-d", "feed-e"]} +] diff --git a/tests/fixtures/folo/subscriptions.json b/tests/fixtures/folo/subscriptions.json new file mode 100644 index 0000000..527e499 --- /dev/null +++ b/tests/fixtures/folo/subscriptions.json @@ -0,0 +1,10 @@ +[ + {"feedId": "feed-a", "category": "🪶 个人博客与朋友", "isPrivate": false, "hideFromTimeline": false, "feeds": {"errorMessage": null}}, + {"feedId": "feed-b", "category": "🧠 AI 与软件工程", "isPrivate": false, "hideFromTimeline": false, "feeds": {"errorMessage": null}}, + {"feedId": "feed-c", "category": "📰 新闻与精选摘要", "isPrivate": false, "hideFromTimeline": true, "feeds": {"errorMessage": null}}, + {"feedId": "feed-d", "category": "🏢 组织与产品", "isPrivate": false, "hideFromTimeline": true, "feeds": {"errorMessage": null}}, + {"feedId": "feed-e", "category": "🧊 冷冻观察", "isPrivate": false, "hideFromTimeline": true, "feeds": {"errorMessage": "synthetic failure"}}, + {"feedId": "feed-f", "category": "🎮 ACG 与娱乐", "isPrivate": false, "hideFromTimeline": true, "feeds": {"errorMessage": null}}, + {"feedId": "feed-g", "category": "🔒 私密内容", "isPrivate": true, "hideFromTimeline": true, "feeds": {"errorMessage": null}}, + {"feedId": "list-core", "listId": "list-core", "lists": {"type": "list"}} +] diff --git a/tests/fixtures/folo/unread.json b/tests/fixtures/folo/unread.json new file mode 100644 index 0000000..6fe0c05 --- /dev/null +++ b/tests/fixtures/folo/unread.json @@ -0,0 +1,7 @@ +{ + "total": 20, + "items": [ + {"feedId": "feed-a", "unreadCount": 3}, + {"feedId": "feed-f", "unreadCount": 17} + ] +} diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..91b935b --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from datetime import UTC, datetime +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "audit", ROOT / ".github/scripts/audit.py" +) +assert SPEC and SPEC.loader +audit = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = audit +SPEC.loader.exec_module(audit) + + +class AuditSnapshotTest(unittest.TestCase): + def write_snapshot(self, generated_at: str) -> Path: + tempdir = tempfile.TemporaryDirectory() + self.addCleanup(tempdir.cleanup) + path = Path(tempdir.name) / "snapshot.json" + path.write_text( + json.dumps( + { + "generated_at": generated_at, + "attention": {"budgeted_minutes_per_week": 180}, + "lanes": { + "core": {"source_count": 15, "max_source_share_percent": 18}, + "changelog": {"source_count": 25}, + }, + "totals": {"uncategorized_sources": 0, "abnormal_sources": 4}, + } + ), + encoding="utf-8", + ) + return path + + def test_fresh_snapshot_is_loaded(self) -> None: + path = self.write_snapshot("2026-08-01T00:00:00Z") + snapshot = audit.load_folo_snapshot( + path, now=datetime(2026, 8, 15, tzinfo=UTC) + ) + self.assertIsNotNone(snapshot) + + def test_stale_snapshot_is_na_not_zero(self) -> None: + path = self.write_snapshot("2026-06-01T00:00:00Z") + snapshot = audit.load_folo_snapshot( + path, now=datetime(2026, 8, 15, tzinfo=UTC) + ) + self.assertIsNone(snapshot) + item = {"metric": "folo_uncategorized_count", "name": "test"} + self.assertIsNone(audit.compute_metric(item, [], snapshot)) + self.assertEqual(audit.status_cell(None, 0), "⚪ N/A") + + def test_inline_render_uses_units(self) -> None: + snapshot = json.loads(self.write_snapshot("2026-08-01T00:00:00Z").read_text()) + limits = [ + { + "name": "Feed 注意力", + "metric": "folo_attention_minutes", + "limit": 180, + "unit": "minutes", + } + ] + rendered = audit.render_inline([], limits, snapshot) + self.assertIn("180 分钟", rendered) + self.assertNotIn("N/A", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audit_folo.py b/tests/test_audit_folo.py new file mode 100644 index 0000000..98d1ac6 --- /dev/null +++ b/tests/test_audit_folo.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import unittest +from datetime import UTC, datetime +from pathlib import Path +from subprocess import TimeoutExpired +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests/fixtures/folo" +SPEC = importlib.util.spec_from_file_location( + "audit_folo", ROOT / ".github/scripts/audit_folo.py" +) +assert SPEC and SPEC.loader +audit_folo = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = audit_folo +SPEC.loader.exec_module(audit_folo) + + +def fixture(name: str): + return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) + + +class AuditFoloTest(unittest.TestCase): + def setUp(self) -> None: + self.data = { + "subscriptions": fixture("subscriptions"), + "lists": fixture("lists"), + "unread": fixture("unread"), + "collections": fixture("collections"), + "analytics": fixture("analytics"), + "analytics_failures": 0, + } + + def test_snapshot_aggregates_and_normalizes_stars(self) -> None: + snapshot = audit_folo.build_snapshot( + self.data, now=datetime(2026, 6, 15, tzinfo=UTC) + ) + + self.assertEqual(snapshot["totals"]["direct_subscriptions"], 7) + self.assertEqual(snapshot["totals"]["private_sources"], 1) + self.assertEqual(snapshot["totals"]["uncategorized_sources"], 0) + self.assertEqual(snapshot["totals"]["abnormal_sources"], 1) + self.assertEqual(snapshot["totals"]["timeline_visible_sources"], 2) + self.assertEqual(snapshot["totals"]["timeline_hidden_sources"], 5) + self.assertEqual(snapshot["attention"]["budgeted_minutes_per_week"], 180) + self.assertEqual(snapshot["star_signals"]["sample_size"], 3) + self.assertEqual(snapshot["star_signals"]["sample_window_days"], 15) + self.assertEqual(snapshot["lanes"]["core"]["source_count"], 2) + self.assertEqual(snapshot["lanes"]["core"]["unread_entries"], 3) + self.assertEqual( + snapshot["lanes"]["core"]["max_source_share_percent"], 83.3 + ) + + def test_snapshot_contains_no_source_metadata(self) -> None: + snapshot = audit_folo.build_snapshot( + self.data, now=datetime(2026, 6, 15, tzinfo=UTC) + ) + rendered = json.dumps(snapshot, ensure_ascii=False).lower() + + for forbidden in ("feed-a", "list-core", "entry-1", "http://", "https://"): + self.assertNotIn(forbidden, rendered) + audit_folo.validate_snapshot_privacy(snapshot) + + def test_privacy_validator_rejects_url_and_identifier_fields(self) -> None: + with self.assertRaises(ValueError): + audit_folo.validate_snapshot_privacy({"source": {"url": "redacted"}}) + with self.assertRaises(ValueError): + audit_folo.validate_snapshot_privacy({"source": "https://invalid.example"}) + + @patch.object(audit_folo.subprocess, "run") + def test_cli_timeout_is_sanitized(self, run) -> None: + run.side_effect = TimeoutExpired(cmd="redacted", timeout=120) + with self.assertRaisesRegex(audit_folo.AuditError, "timed out"): + audit_folo.run_cli("subscription", "list") + + +if __name__ == "__main__": + unittest.main()