# Changelog All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added - **Support view: an admin looks at an account read-only** (multi-user plan P2-05). "Посмотреть как" on the "Admin" page (`POST /api/admin/users/{id}/impersonate`, a reason required) marks the admin's dashboard session with the account for `NF_IMPERSONATION_MINUTES` (30 by default); never an API token or MCP, never on an admin or a blocked account, never nested. Requests then see the account's rows (`_uid` and Row-Level Security), every non-GET request to `/api` but leaving (`POST /api/auth/impersonation/end`) and signing out is refused with 403 `ReadOnlyView`, and spending is frozen: no YouTube unit (403 instead of a fetch), no new LLM call, cached answers only. A banner on every screen says whose eyes you are using; `/api/auth/me` returns `impersonation`. The start (with the reason), the end and the expiry go into the action log (group `support`). - **Two-factor sign-in** (multi-user plan P2-04). In the profile, an account turns on TOTP (RFC 6238, 6 digits, 30 s, one step of drift, a code never works twice) with any authenticator app -- the secret as text and an `otpauth://` link, no QR service -- and gets 10 one-time backup codes, shown once and stored as scrypt hashes. The secret is encrypted with `OWN_TOKENS_KEY` and covered by `cli.py rotate-secrets`. Sign-in then takes two steps: the password gives a one-time challenge (5 min) and the session comes only with a code (`POST /api/auth/login/2fa`); wrong codes count with wrong passwords in `login_attempts`. A reset by emailed link keeps 2FA; turning it off needs the password and a code; the operator has `cli.py disable-2fa EMAIL`. `NF_ADMIN_2FA=required` limits an admin without 2FA to its setup. API tokens need no code, but with 2FA on only a session that passed it can create one. The action log gets `2fa_enable`, `2fa_disable`, `2fa_failed`, `2fa_backup_used`, `2fa_backup_regenerate`. - **Manual runs for regular users, with a limit** (multi-user plan P2-11). A new tariff limit `manual_runs` ("Ручные запуски в час", default `NF_USER_MANUAL_RUNS_PER_HOUR=0` = admins only, as before) lets a regular user refresh stats (`POST /api/refresh`) and run the alert scan (`POST /api/events/scan`) by hand -- over their own watchlist only, on their own YouTube quota, that many times an hour per operation. Past the limit the answer is `429` with `Retry-After`; the counter (`manual_runs`, personal, under RLS) is in Postgres under an advisory lock, so it holds across restarts, replicas and parallel requests, and a failed run does not count. `GET /api/manual-runs` tells the dashboard when the next run is allowed: the "Обновить сейчас" button on "Данные" and the new "Проверить сейчас" on "Алерты" show "Снова можно в HH:MM". Admins are never limited. AI labelling, thumbnail vectors and the niche map stay admin-only; the decision table is in [docs/manual-runs.md](docs/manual-runs.md). - **Each user's time zone** (P2-03). `users.timezone` holds an IANA name (`Europe/Moscow`), checked with `zoneinfo` (old browser names such as `Asia/Calcutta` are stored as the current ones); `PUT /api/me/timezone` saves or clears it and `/api/auth/me` returns it with `defaultTimezone`. The "Profile" screen has a "Часовой пояс" card with "Как в браузере", also without `NF_MULTI_USER`; signed-in users who have none get the browser's zone saved silently. `NF_DEFAULT_TIMEZONE` is the zone of those who chose none. ### Changed - **The digest goes out at `DIGEST_HOUR` of each recipient's own time** (P2-03), once per local day: the mark in `meta` is now the UTC moment of the last send, counted for its local date in the zone in force, so daylight saving time and a change of zone neither repeat nor skip a day. With no zone saved and no `NF_DEFAULT_TIMEZONE`, it is the container's time as before; marks written before the upgrade still count. Calendar reminders write the planned time in the recipient's zone (UTC as before when there is none), and the best publishing hours on the brief and calendar screens are shown in the browser's time instead of UTC. Daily quotas stay on Pacific Time (YouTube units and searches) and UTC (LLM spend), now said so in `backend/README.md`. ### Fixed - **Dashboard: links from emails and signed-out navigation in an open tab** (found in a live run of the multi-user beta). A sign-up, reset or email-change link pasted into a tab where the dashboard was already open only changed the address and drew "Overview" with "Sign in first"; it now opens its own screen, as a freshly loaded page did. Signed out, a click on a menu section drew that section with 401 errors; it now stays on sign-in. Confirming a new email while signed in no longer says "Sign in" in the header, and on a narrow screen the niche, period and refresh controls wrap instead of scrolling the page sideways. - **The backend lockfile serves Python 3.10, 3.11 and Windows again.** A Dependabot update rewrote `backend/requirements.txt` with its pip updater, which dropped the per-version and per-OS lines of the `uv pip compile --universal` output (`exceptiongroup`, `tomli`, `colorama`, `pywin32`, the numpy and onnxruntime builds for 3.10 and 3.11). Docker's Python 3.12 on Linux did not notice; `make local-install` on 3.10/3.11 or Windows broke. The file is recompiled with uv, now with the same updates (fastapi 0.158.1, cryptography 50.0.2, fastembed 0.9.1, numpy 2.5.4, python-dotenv 1.2.4, opentelemetry-api 1.45.1, filelock 4.2.4 -- 4.1.0 on Python 3.10, the last that supports it), and `make lock-check` in CI fails on a lockfile that is not exactly what uv compiles. ### Security - **Row-Level Security holds against arbitrary SQL** (multi-user plan P1-02, the rest of it). With `POSTGRES_APP_PASSWORD` in `.env`, the dashboard and MCP open the connections for people's requests with the login `NF_DB_APP_ROLE` (`_app`) instead of `SET ROLE` from the owner's login. It is not the owner and cannot become it, so `RESET ROLE` or `SET ROLE` no longer lift the policies. - The user a session names in `app.user_id` now needs a signature (`app.user_sig`): SHA-256 over a secret only the owner reads, the number and the connection's process id. Without it a session sees no personal rows, so `set_config` cannot name someone else. Old policies are rewritten at start. - The services make the role a login with that password themselves at start; nothing else changes in `.env` or the compose files. Without the password everything works as before (`SET ROLE`), with a warning in the log. A login that fails falls back to `SET ROLE`, with a warning. - Sign-in, sign-up, the admin routes, the worker and the CLI stay on the owner's login: they act for every account. - Tables added later get their rights at start and through `ALTER DEFAULT PRIVILEGES`. Details: [docs/hosting.md](docs/hosting.md#row-level-security). ## [0.5.0] - 2026-10-24 The closed-beta round of the multi-user plan (P0 and P1, without billing): one copy on your own domain can now serve other people safely. People sign up and reset passwords by email, see and end their sessions, and get only their own rows even if a query forgets its filter (Row-Level Security). The admin has an action log, tariffs that cap channels, LLM spend that holds under parallel calls, and key rotation that keeps saved secrets readable. Hosting comes with HTTPS, backups, JSON logs, metrics and alerts, and the extension and MCP work against the domain with expiring, scoped tokens. The dashboard speaks English too. 100 MCP tools, 155 HTTP routes. ### Added - **Row-Level Security in Postgres** (multi-user plan P1-02). Personal tables (`PERSONAL_TABLES`) get the policy `nf_owner_rows`: a dashboard, API or MCP request sees and changes only its user's rows, even if a query forgets `AND user_id = ?`. Such requests run as the role `NF_DB_APP_ROLE` (`_app`, no sign-in, no `BYPASSRLS`), entered with `SET ROLE` with the user in `app.user_id`. Without it, personal tables are empty. - The worker, CLI, migrations, sign-in and the admin routes and tools stay on the owner's role: they work for every user. - Set up at start, with the same login and no change to `.env` or the compose files. A login that may not create roles keeps working as before and logs a warning. `NF_DB_RLS=0` turns it off. Details: [docs/hosting.md](docs/hosting.md#row-level-security). - **Action log** (multi-user plan P1-07). Sign-ins and failed sign-ins, sign-ups, password changes and resets, email changes, API tokens created and revoked, tariff changes, and what admins do to accounts and tariffs are written to `audit_log` with the time, who acted, on whom, the client address and short details -- never a password, a token or a secret. - The **Admin** page has a new "Action log" card with filters by event (or group), user (email or number) and period, and pages; the route is `GET /api/admin/audit`, for admins signed in to the dashboard only. - The log outlives a deleted account and is cleared by age: `NF_AUDIT_RETENTION_DAYS` (180 by default, 0 = forever), by the worker. - The rows of `admin_actions` (P0-04) move into it on the first start, and that table is dropped. - **Sessions you can see and end** (multi-user plan P1-04). - A sign-in now lasts 14 days after the last activity and 90 days at most, instead of a fixed 30 days (`NF_SESSION_IDLE_DAYS`, `NF_SESSION_MAX_DAYS`). Activity extends it at most once per 15 minutes (`NF_SESSION_TOUCH_MINUTES`), so requests do not write to the database. - The **Profile** page lists where you are signed in: browser, address, when it started and was last active, this device marked. You can end one session or sign out on all other devices. - Sessions from before the update keep working; their address and browser fill in on their next request. - **The Chrome extension works with a service on your domain** (multi-user plan P1-16, [extension/README.md](extension/README.md#a-service-on-your-domain)). Localhost stays the default. - Enter `https://YOUR-DOMAIN` as the service address and Chrome asks for access to that one host (`optional_host_permissions`). Switching the address away gives the old host's access back. - The extension signs in with a personal API token (`read` + `write`, from the MCP screen). It sends the token to a remote address only over `https`, and the popup says when a token is missing. - `NF_EXTENSION_IDS` (comma-separated) limits CORS to your own extensions. Without it a local copy answers any extension as before, and a server with sign-in answers none. - What publishing in the Chrome Web Store takes: [docs/chrome-web-store.md](docs/chrome-web-store.md). - **Sign-up, password reset and a profile page** (multi-user plan P1-06). - With mail set up (`SMTP_HOST`, `SMTP_FROM`, see `.env.example`), the sign-in screen offers "Create an account" and "Forgot password". Both mail a one-time link; the password is set only from it, so an account always belongs to someone who reads that inbox. `NF_REGISTRATION=closed` turns sign-up off and keeps the reset. - The answers never say whether an address is registered. Links are single use, kept only as hashes, and limited per client address and per recipient (`NF_MAIL_PER_IP_HOUR`, `NF_MAIL_PER_EMAIL_HOUR`). - The **Profile** page changes the password (other sessions and API tokens end) and the email (confirmed by a link to the new address). - Without mail nothing changes: accounts come from an admin. - **Encryption key rotation** (multi-user plan P1-12). `OWN_TOKENS_KEY` stays the key that encrypts. Earlier keys go into `OWN_TOKENS_KEY_OLD` (comma-separated) and are only used to read, so changing the key no longer breaks the saved channel tokens and notification secrets. - `cli.py rotate-secrets` (`make rotate-secrets`) counts what only an old key reads; with `--apply` it re-encrypts those values with the current key. Without `--apply` it is a dry run. - A value no key reads is logged with its field and account (never the value), and the admin screen shows how many there are. Before, alerts just stopped. - Steps: [docs/hosting.md](docs/hosting.md). Installs with one key work as before. - **Logs, metrics and operator alerts for a hosted copy** (multi-user plan P1-14, [docs/hosting.md](docs/hosting.md#monitoring)). - `NF_LOG_FORMAT=json` writes one JSON object per log line on stderr, with secrets masked and no emails or addresses; request lines name the route template, not the path. The production file turns it on. - `NF_METRICS_TOKEN` opens `/metrics` (Prometheus) on the dashboard: requests by route and status, the shared YouTube quota, the LLM spend, each worker step's last outcome and duration, the worker's last cycle. Caddy keeps it off the internet. - Operator alerts to the `.env` notifier when the shared YouTube pool is spent or a worker step fails, at most once per kind every `NF_OPERATOR_ALERT_HOURS`. - `SENTRY_DSN` sends unhandled errors to Sentry without cookies, headers, query strings or addresses (new dependency `sentry-sdk`). - **Hosting on your own domain** (`docker-compose.prod.yml`, `make prod-up`, [docs/hosting.md](docs/hosting.md)). Caddy is the only service published, on ports 80 and 443, and gets a Let's Encrypt certificate for `NF_DOMAIN`. It serves the dashboard and MCP over HTTPS (`/mcp`, personal API tokens) on the same name. Accounts, the Secure cookie, the allowed host and the trusted proxy are set for you. The dashboard and Postgres are not published at all. A `backup` service runs `pg_dump` every `BACKUP_INTERVAL_HOURS`, checks each dump by a trial restore into a scratch database, and keeps the newest `BACKUP_KEEP`. `make prod-backup` makes one right away. Compose refuses to start without `NF_DOMAIN` and `POSTGRES_PASSWORD`. - **Blocking and deleting accounts** (multi-user mode). On the Admin page an admin creates an account (its one-time password is shown once), blocks or unblocks it (sessions and API tokens, MCP included, stop at once) and deletes it by typing the email back; the same from the command line: `disable-user`, `enable-user`, `delete-user EMAIL --yes`. People delete their own account on the Plan page with their password, as the privacy page promises. Deleting removes every row with that user's id and their quota counters, revokes their own channels' OAuth tokens at Google, and leaves the shared YouTube data; each action is logged in `admin_actions`. - **English interface.** The dashboard, API errors and hints, score tooltips, plan labels, alerts, the digest, `/privacy`, `/terms` and the Chrome extension now come in Russian and English. The dashboard picks the language from your choice, then your saved setting, then the browser; switch it in the sidebar without a reload. Alerts and the digest use each recipient's saved language. The API answers in the language of `X-NF-Locale` or `Accept-Language`, else `NF_DEFAULT_LOCALE` (default `ru`, so existing installs and scripts see the same texts). New route `PUT /api/me/locale`; `/api/auth/me` gains a `locale` field. MCP scenarios (prompts) are written in the language the user saved in the dashboard, and tool hints over MCP follow it too; scenario titles and the CLI (`--help`, `doctor`) use `NF_DEFAULT_LOCALE`. - **Tariffs, configured by an admin** (SaaS sub-project 1, multi-user mode): daily limits, count limits and features per tariff; a base tariff Free that everyone starts on; users pick a free tariff themselves on the new "Тариф" screen; admins edit tariffs and users' tariffs on the new "Админка" screen. Prices and periods are stored and shown; nothing is charged yet. Nothing is deleted when a user moves to a smaller tariff. ### Changed - **The base tariff caps channels out of the box** (multi-user plan P1-10). Tracked channels stop at `NF_USER_MAX_TRACKED_CHANNELS` (200) and own channels at `NF_USER_MAX_OWN_CHANNELS` (5), because the worker refreshes every tracked channel from the shared quota pool. A tariff with no value of its own takes these; 0 in `.env` or an empty value in the tariff means no limit. Rows already there are kept. Single-user mode has no count limits. - **API tokens expire and have scopes** (multi-user plan P1-03). - A new token lives 90 days by default. You choose the lifetime when you create it (1 to 365 days, or no expiry) on the MCP screen, with `expiresInDays` in the API, or with `create-token --days`. - Each token has scopes: `read` for GET routes, `write` for changes, and `mcp` for MCP over HTTP (`--scopes` in the CLI). The API answers 403 `TokenScope` when a scope is missing, and MCP refuses a token without `mcp`. - An expired token is refused like a wrong one. - The MCP screen lists each token's expiry and scopes. - Existing tokens keep every scope and no expiry until revoked. - **Feeds no longer show what other users watch** (multi-user plan P1-05). With accounts on, these are now limited to your own watchlist: - title changes and the repackaging feed (dashboard and MCP); - the watchlist and change totals in `/api/stats`, `/api/health` and MCP `db_stats`; - the digest's rising channels, which also keep channels from the niches your channels are in. `/api/health` answers `{"ok": true}` to a visitor who has not signed in. Without accounts nothing changes. Collection and refreshes stay shared. - **MCP over HTTP without sign-in stays on loopback** (multi-user plan P1-15). Without `NF_MULTI_USER=1` there is no sign-in and every tool acts as user 1, so `server.py` now refuses to listen on any other address unless `MCP_ALLOW_UNAUTHENTICATED=1`, and `MCP_HOST` defaults to `127.0.0.1` instead of `0.0.0.0`. The compose `mcp-http` service (not published) sets the opt-in and works as before; the production file turns it off. - **Whose data a call touches is always named** (multi-user plan P1-01). No use case in `application/` falls back to user 1 any more: `user_id` is a required keyword argument, and a call that forgets it fails instead of working on the admin's data. With accounts on, the HTTP API and MCP over HTTP also refuse to name a user for a request nobody signed in to, where they used to act as user 1. Single-user mode, the CLI and the worker pass the local user explicitly and behave as before. - The README is rewritten as the page of the upcoming hosted service. Install instructions moved to [docs/self-hosting.md](docs/self-hosting.md). ### Fixed - **Repository housekeeping.** `docs/chrome-web-store.md` was linked from four pages but never committed (`.gitignore` skips `docs/*` except listed pages); it is tracked now. The README's route and section counts, the backend and dashboard READMEs (sign-up, sessions, profile, action log) are current. Added a code of conduct, `.editorconfig` and Dependabot updates; CI runs with a read-only token by default and cancels superseded PR runs. - **The LLM budget holds under parallel calls** (multi-user plan P1-09). - Before, the gateway checked the day's spend, called the model and only then recorded the cost. Parallel calls all passed the check and spent past the budget. - Now each call reserves its most possible cost first: the prompt and a whole answer at the model's OpenRouter price. The check runs under one lock against what was spent plus what other calls hold. The reservation is settled to the real cost after the answer. - The connection to the database is no longer held while the model answers. - An OpenRouter answer without `usage.cost` was recorded as free. It is now priced from its tokens. A model with no known price counts at `LLM_FALLBACK_USD_PER_MTOK` (15). - Signed-in users share `LLM_DAILY_BUDGET_USD` minus `LLM_WORKER_RESERVE_USD` (0.25), so background enrichment keeps going when users have spent the rest. - Two requests at once can no longer both take the last free row of a count limit (tracked or own channels, topics, saved items, drafts, API tokens). The count and the insert now run in one transaction under a lock per user and limit (multi-user plan P1-10). - A pooled database connection whose caller raised before `close()` is now rolled back and returned to the pool. Before, it stayed "idle in transaction" and held its locks. On 2026-10-18 a network error during the trending collection blocked every container's start and exhausted Postgres connections. ### Security - The YouTube API key is no longer put in request URLs: it goes in the `X-Goog-Api-Key` header. Network errors no longer carry the URL, and logs and HTTP 500 answers mask secrets. Rotate a key that may have been logged. - **Multi-user mode: shared data and the server's own work.** A transcript or a curated tag can be replaced or removed only by the user who added it or an admin; pasting over someone else's transcript returns the existing one. Starting the server's scheduled work by hand (refresh, AI enrichment, the alert scan, thumbnail vectors, the niche map; over HTTP and MCP) and resolving the background LLM's proposed tags need an admin, and the buttons are hidden from other users. Single-user mode is unchanged. - **Multi-user mode: password guessing is slowed down.** After 5 failed sign-ins for one email the next waits 1, 2, 4 ... up to 15 minutes; 30 failures from one address close sign-in from it for 15 minutes (HTTP 429, counters in Postgres). New and changed passwords refuse common long ones, keyboard runs and the email's own name. Behind a reverse proxy set `NF_TRUSTED_PROXIES` so limits count each real client, not the proxy. - **The shared YouTube quota is guarded.** Every call reserves its cost atomically before it is sent, from the user's share and from the shared pool. Parallel requests can no longer overshoot either, and the 100-searches-a-day counter no longer loses updates. Signed-in users share the pool minus `WORKER_RESERVE_UNITS` (1500), kept for the worker's stats snapshots and alerts. A spent pool answers 429 to users while the worker goes on. A call that never reached Google is refunded, and a failure to record the budget stops the call instead of letting it through uncounted. The Admin page adds up users' daily limits against the pool. - **One worker per database.** A second worker (a duplicate container, `--scale worker=2`) no longer spends the quota and sends every alert a second time. It waits as a standby on a Postgres advisory lock and takes over if the first stops. One user's failing own-channel sync no longer stops the others'. Alert delivery reads only the last `NOTIFY_WINDOW_DAYS` (7) of events, so turning notifications on sends news, not the whole history. ## [0.4.0] - 2026-10-04 The third round of plans (26-33, without 28 and 32, which wait for a connected channel): answers to "what should I make, where, and does it pay?" from the data already collected, with zero quota and no LLM. 100 MCP tools, 128 HTTP routes. Highlights: - **Where to enter:** a 0-100 score for every niche from six visible terms and a side-by-side comparison; what the outliers of a niche have in common against its ordinary videos. - **Another language:** outliers in one language and whether anything like them is made in another. - **Does it pay:** net profit from your own cost profiles, as a range, with the views a video needs to pay for itself. - **Work with others and on schedule:** collaboration partners of your size, and a content calendar with reminders the day before and when a draft is due. - **Hosted installations:** customers connect their own channels with the service's OAuth client; public privacy and terms pages. - **Fixes:** the daily sync of connected channels, policy signals reading parts of words, descriptions re-read within 30 days, a lighter `/api/health`. ### Added - **Content calendar** (plan 33). Drafts get a release time (`drafts.planned_at`, UTC; a new column) with `plan_draft`, which also returns the best publishing slots of the draft's niche or channel and whether the time is one of them; `content_calendar` lists a range with each draft's state (planned, overdue, published) and the drafts with no date. The worker raises a personal `draft_due` event the day before and when the time comes, sent to Telegram/webhook and shown in a new "Календарь" section of the digest, until the draft is linked to its video. MCP (100 tools), `POST /api/drafts/{id}/plan`, `GET /api/calendar` and the "Календарь" screen (week or month, drag a draft onto a day). Zero quota. - **Collaboration partners** (plan 31). `collab_candidates` takes the 200 most similar channels (embedding centroids) and keeps those at cosine 0.5+, between ×0.5 and ×2 of the channel's subscribers, with an upload in the last 30 days and no high template risk; each comes with similarity, size ratio, days since upload and 30-day subscriber growth, and `excluded` says how many were dropped and why. MCP (98 tools), `GET /api/channels/{id}/collabs`, a card on the channel screen and on "Мои каналы" with "В трекер". Only collected channels; YouTube gives no contacts. Zero quota. - **Net profit calculator** (plan 30). Personal cost profiles (a price per video, per minute of video and a monthly overhead, in USD; new table `cost_profiles`) and `profit_estimate` for a channel's month, a video or a niche's typical video: revenue at the low / mid / high RPM minus the costs, a verdict (profitable only when even the low end is above zero, loss only when even the high end is below it, otherwise uncertain) and the views a video needs to pay for itself. A connected channel uses its real RPM; a channel below the full YPP tier is warned that ad revenue may still be zero. MCP `list_cost_profiles`, `save_cost_profile`, `delete_cost_profile`, `profit_estimate` (97 tools), `/api/cost-profiles`, `GET /api/profit`, a card on the channel and niche screens, a profile editor on "Данные", a line in the extension's channel panel and a `profit` entry in `explain_scores`. AdSense only. Zero quota. - **What outliers of a niche have in common — by the numbers, no LLM** (plan 29). `outlier_traits` compares a niche's outliers (age-adjusted ×3 and up) with its ordinary videos (up to ×1.5), Shorts and long-form apart: number, "?", brackets, CAPS word or emoji in the title, video and title length, tags, weekend and time of day (UTC) of publishing. A difference is shown only with 10+ videos in each group and a gap of 15+ points (or medians 1.3× apart), with both sample sizes; a group where over half of the outliers are one channel's is flagged. MCP (93 tools), `GET /api/niches/{slug}/outlier-traits`, a card on the niche screen and in the brief, an `outlierTraits` entry in `explain_scores`. Zero quota. - **Where to enter — a score and a comparison for every niche** (plan 27). `rank_niches` scores each niche 0–100 from six terms it already computes (demand trend 25, supply growth 20, newcomers breaking out 20, RPM of the dominant category 15, templated channels 10, policy signals 10), every term with its points and raw value; unknown terms are left out and the rest re-weighted, and a niche whose trend is "insufficient-data" (or with under 60% of the weight known) gets no score instead of a low one. Cached for an hour. `compare_niches` shows 2–3 niches side by side with the overview numbers. MCP (92 tools), `GET /api/niches/ranking`, `GET /api/niches/compare`, the "Где заходить" screen (sortable by any column) and a `nicheRank` entry in `explain_scores`. Zero quota, no LLM. - **Language gaps — "formats that took off in one language, is it made in another?"** (plan 26). For a pair of languages, the outliers of the source language (at most 5 per channel, 100 in all) each get a verdict from their nearest videos in the target language by embedding (cosine >= 0.62; the multilingual model scores unrelated videos up to ~0.6 across languages, so the cut-off is higher than the 0.6 used within one language): `open` (nothing close), `thin` (close, none an outlier >= 2x), `covered` (a close one is an outlier). A card also shows how many other source-language channels repeated the format and the closest target-language videos; the result states the target corpus size and warns when it is under 200 videos ("open" can mean "not collected"). `language_gaps` (MCP, 90 tools), `GET /api/language-gaps`, `GET /api/language-gaps/languages`, `GET /api/videos/{id}/language-gap` (one video, any video; target defaults to the biggest other language), the "Другой язык" dashboard screen, an "На другом языке?" button in the extension's video panel and a `languageGap` entry in `explain_scores`. Zero quota, no LLM; works without pgvector. Reads only: `similar_videos` and `search_outliers` are unchanged. - **Topic alerts can search all of YouTube** — a topic with "На YouTube" ticked (`search_youtube` in `watch_topic`, `POST /api/topics/{id}/search`) is searched once a day for videos of the last 24 hours, newest first; new ones are stored with embeddings and no niche, and the hourly match raises the usual personal alert. One of the installation's 100 search.list calls a day per topic, at most 5 topics a day, the owner's share in multi-user mode; skipped when the day's searches are spent. Off by default. - **Customers can connect their own channels to a hosted installation** (plan 25) — `OWN_OAUTH_MODE=web` uses one OAuth client of the service, of type Web application, on an https redirect the status checks; customers only press "Подключить канал", and in multi-user mode only an admin sees which settings are missing. Revenue access is asked for separately — a "показывать доход" box when connecting and "Добавить доход" later — so the base consent is read-only YouTube + Analytics (`OA.BASE_SCOPES`). Public `/privacy` and `/terms` pages carry what Google's app verification and YouTube's API terms ask for (Limited Use, YouTube Terms of Service, Google Privacy Policy, revoking access, deletion), named from `NF_SERVICE_NAME`, `NF_OPERATOR_NAME`, `NF_CONTACT_EMAIL`. One's own installation behaves as before. ### Fixed Found in a code review of v0.2.0..v0.3.0 and the plan-25 work: - **The worker kept skipping the daily sync of connected channels** when a web-mode redirect was wrong: it still checked the overall "configured" flag. It now syncs whenever the client and the key are set. - **Policy signals no longer read parts of words as sensitive topics** — "Реакция" was finance ("акци"), "Аналог" finance ("налог"), "secure" health ("cure"), "syntax" finance, "Выбор ноутбука" politics. Every pattern now starts at a word boundary, and "выбор" counts only as "выборы". - **Descriptions are re-read within the 30-day window** — a video's `updated_at` moves with every counters-only refresh, which hid old descriptions from the daily re-read for up to ~55 days. Videos now go by a new `texts_refreshed_at` (else when first seen). - **Topic matches and milestones reach the morning digest** for users who get alerts that way; they were only in the dashboard feed. - **No "milestone" alert for an old crossing** — two subscriber snapshots more than 14 days apart no longer raise one. - **"Так же начинают заголовки" counts titles with punctuation or a leading emoji** ("How To: Make…", "🔥 how to make…"). - **`/api/health` is light again** — the freshness counts moved to their own `GET /api/freshness`, which only the "Данные" screen asks for; niche policy signals reuse one database connection instead of one per channel; video trajectories and format repeatability load only the videos they score (`load_window(video_ids=...)`, the channel baseline unchanged) -- on a real database the repeatability check went from 0.14 s to 0.03 s. ## [0.3.0] - 2026-10-04 The second round of plans (16-24): data kept fresh under YouTube's API policies, the 2027 Partner Program rules, and tools for deciding what to make -- topic alerts, format repeatability, video trajectories, signals for the monetization policies, your channel by format -- with every number saying whether it is YouTube data or an estimate. 89 MCP tools, 113 HTTP routes. Highlights: - **Decide what to make:** topic alerts, "Формат повторяем?", video trajectories and a comparison of up to five videos. - **Stay on the right side of YouTube:** signals for the three inauthentic-content categories, the 2027 YPP thresholds with subscriber milestones and a `milestone` alert, your channel's Shorts against long videos and watch hours toward the bar. - **Trust the numbers:** a catalog of every score (formula, inputs, minimum sample, YouTube data or estimate) behind "?" tips and the help page; old rows re-read from YouTube daily, nothing deleted; velocity kept on one view-counting rule across 2026-08-24. - **Fixes:** similar videos of other channels no longer empty behind a crowded channel (pgvector), tooltips no longer render HTML from YouTube data, niche-map clusters named without an LLM. ### Added - **Your channel by format** (plan 24) — the daily sync of a connected channel now also reads a year of YouTube Analytics by day and `creatorContentType` (`own_channel_daily`, personal). `own_channel_formats` (MCP, 89 tools), `GET /api/own/channels/{id}/formats` and a "Форматы" card on "Мои каналы" show Shorts, long videos and live apart: share of views and subscribers per 1,000 views over 90 days, whether weekly Shorts and long-form views move together (a correlation, not a cause), and long + live watch hours in 365 days toward the YPP bar -- 4,000 now, 8,000 from 2027-02-01 -- with the date the recent pace gets there (approximate, not YouTube's qualified watch hours). A query YouTube does not support for a channel leaves the rest of the sync untouched; disconnecting deletes these rows too. Thumbnail impressions and CTR (the Reporting API's reach reports) are not read yet. - **Signals for YouTube's monetization policies** (plan 22) — YouTube's channel monetization policies describe inauthentic content in three categories: generic or repetitive, unsatisfying (shock and emotional manipulation), and AI personas presented as experts on health, legal, financial or political topics. `policy_signals` / `niche_policy_signals` (MCP, 88 tools), `GET /api/channels/{id}/policy-signals` and `GET /api/niches/{slug}/policy-signals`, a card on the channel and niche screens and a line in the extension's channel panel show, per category, the signals visible from public data -- template risk and look-alike thumbnails, the share of shock-marker titles (English and Russian), a sensitive topic plus a synthetic-media disclosure or a faceless label -- with a level, reasons, examples and the policy text. No overall percentage: the decision is YouTube's, made by people, and the AI-persona category never goes above "watch". Zero quota. - **Where every number comes from** (plan 23) — one catalog, `domain/score_catalog.py`, says for every number on screen whether it is YouTube data or an estimate of niche-finder, how it is computed, from what, and from how much data it means something; thresholds are read from the modules that use them. A "?" next to the main scores (YPP line, milestones, template risk, niche trend, keyword columns, channel grade, repeatability, expected curve) and the video-card badges show it on hover, the help page lists it, `explain_scores` (MCP, 86 tools) and `GET /api/scores` return it. YouTube's derived-metrics policy asks API clients to keep their metrics visibly apart from API data; CONTRIBUTING.md makes the catalog part of adding any new score. - **Video trajectories and comparison** (plan 20) — how a video gathered views, by age (hours since publishing) rather than by date, from the worker's snapshots, next to the curve a usual video of its channel would make (median views x maturity curve, an estimate) and with title/thumbnail swaps marked. `video_trajectory` (MCP, 85 tools) and `GET /api/videos/trajectory` take up to five videos; the brief shows the source video's curve; a "сравнить" link on every video card fills a comparison of up to five on `#/compare/`; the extension's video panel shows a small chart instead of a snapshot count. Zero quota. - **"Формат повторяем?"** (plan 21) — before copying an outlier, see whether its format worked for other channels too. `format_repeatability` (MCP), `GET /api/videos/{id}/repeatability`, a card in the brief and a button in the extension's video panel take the video's embedding neighbours on other channels, score each with the usual outlier baseline, count every channel once by its best video and answer `repeatable` (3+ channels got >= 2x), `mixed`, `one_off` or `unknown` (too few similar videos collected), with examples and how many other channels start their titles the same way. The "Outlier → своё видео" scenario asks for it. Zero quota (84 MCP tools). - **Topic alerts** (plan 19) — name a topic in plain words on the "Алерты" screen, with `watch_topic` (MCP), `POST /api/topics` or the extension's "Следить за темой" button. Every hour the worker compares the topics with videos first seen in the last 48 hours from any source (RSS of tracked channels, niche collections, trending) and raises a `topic_match` alert for each one whose title+description embedding is close enough (cosine >= 0.6 by default). The alert is personal: `events.user_id` makes it visible and deliverable to the topic's owner only, in multi-user mode too. Videos seen before a topic existed never match it. Zero quota. New MCP tools `watch_topic`, `list_watched_topics`, `unwatch_topic` (83 in all). - **YPP rules from 2027-02-01 and subscriber milestones** (plan 17) — YouTube raises the Partner Program bar for new creators on 2027-02-01 (1,000 subscribers and 8,000 watch hours in 365 days or 20M Shorts views in 90 days, plus an activity bar). `yppEligibility` switches to those rules on that day and until then carries the same check under them as `upcoming`; the expanded tier keeps its 2026 numbers, since YouTube's page does not say it changes. `channel_analytics` and the extension's channel panel gain `milestones`: when the next subscriber milestones come at the pace of the last 30 and 90 days of snapshots, and whether 1,000 comes before the rules change -- an estimate, not YouTube data. A tracked channel passing a milestone between its two latest snapshots raises a new `milestone` alert (dashboard, extension, Telegram/webhook). ### Fixed - **Niche map clusters get a name without an LLM** — with `LLM_PROVIDER=none` a cluster was named after its channels' curated tags, which are usually empty, so most read "Untitled cluster". It now falls back to the words several of its channels share in their titles (one channel's brand never names a cluster), and only with neither stays untitled. - **Tooltips no longer render HTML from YouTube data** — a tooltip's text sits in a `data-tip` attribute, which the browser decodes back, and it was shown with `innerHTML`: a video title, tag or channel name containing markup (``) would run as HTML on hover, for example on the niche scatter chart. Tooltips are now escaped as a whole, and only `
` and `` are let through. - **Similar videos and thumbnails no longer come back empty behind a crowded channel** — the pgvector HNSW index hands back about 40 nearest rows and the WHERE filter runs after it, so "similar videos of other channels" returned nothing whenever a channel's own near-identical uploads filled those 40 (seen live on a channel with 476 uploads). `similar_videos`, `similar_thumbnails` / thumbnail search and the near-duplicate check of the metadata review now turn on pgvector 0.8's iterative scan (`hnsw.iterative_scan = strict_order`) for their query; older pgvector ignores it safely. ### Changed - **Old rows are re-read from YouTube daily** (plan 16) — YouTube's API policies allow keeping data fetched with a key at most 30 days unrefreshed, while the worker only refreshed videos up to 30 days old, and through `videos.batchGetStats`, which returns counters, not titles or descriptions. A new daily worker step re-reads every stored video and channel not refreshed for `REFRESH_STALE_DAYS` (25) with `videos.list` / `channels.list`, oldest first, capped by `REFRESH_STALE_MAX_VIDEOS` / `REFRESH_STALE_MAX_CHANNELS` (2,500 each, ~100 units a day at most); `WORKER_FRESHNESS=0` switches it off. Nothing is deleted: a row the API no longer returns is marked gone. `db_stats` gains `freshness`, the "Данные" screen a "Хранение данных" card, and SECURITY.md / PRIVACY.md say what is kept and what YouTube's derived-metrics approval would change. - **Velocity across the 2026-08-24 view-count change** (plan 18) — YouTube now counts a public view from the first frame, so `vph24h` and `acceleration` no longer pair a snapshot from before 2026-08-24 with one from after it, `calibrate_maturity_curve` leaves out videos whose history straddles the date, and the channel's views chart marks it. Histories that start after the change (most installations) are not affected. - README screenshots retaken for the 0.2.0 dashboard (21-item sidebar) and four new ones added: find a niche, alerts, repackaging, idea checker. ## [0.2.0] - 2026-10-04 Everything since the first public release: the tool count grew from 24 to 80 MCP tools plus 7 ready-made scenarios, the Chrome extension arrived, and the project learned to plan your own videos, watch your channels and run for a small team. Highlights: - **On top of YouTube:** a Chrome extension with panels on watch and channel pages and multiplier / views-per-hour badges on every thumbnail list. - **Plan your own video:** outlier to brief, metadata review with drafts linked to the published video, hook score, title scoring and suggestions, content gaps from comments, an idea checker. - **Read a niche deeper:** niche trend (growing / stable / cooling / saturated), niche map, template risk, sponsor map, similar thumbnails and thumbnail styles, repackaging history, RPM as a range, YPP thresholds. - **Stay on top of it:** alerts (new outlier, acceleration, title change, silence break, gone channel or video) on the dashboard, in the extension and to Telegram or a webhook, plus a morning digest; a free RSS watch for new uploads. - **Your own channels:** real YouTube Analytics numbers through your own Google OAuth client, and your real RPM against the estimate. - **Optional LLM** via OpenRouter or a local Ollama, with a daily budget. - **Multi-user mode (experimental):** invited accounts, per-user data, quota shares, personal API tokens and alert settings; see SECURITY.md. - **Foundations:** pgvector similarity search, a shared YouTube quota counter, HTTP rate limiting, PRIVACY.md and SECURITY.md. ### Added - **Documentation map and HTTP API reference** — `docs/README.md` says which document answers what; `docs/http-api.md` lists all 103 HTTP routes with their parameters, cost and MCP twin. It is generated from `backend/interfaces/http/api.py` by `scripts/gen_http_api_docs.py` (`make api-docs`), and CI fails when a route changes without regenerating it. The rest of `docs/` stays internal and out of the repository. - **Release packaging** — `scripts/package_extension.sh` (`make extension-zip`) packs the committed `extension/` into `dist/niche-finder-extension-v.zip`, and pushing a `v*` tag runs `.github/workflows/release.yml`: it checks the tag against `extension/manifest.json` and this changelog, then publishes the GitHub release with this section as notes and the extension zip attached. CONTRIBUTING.md describes the steps. - **"Алерты" screen and a "gone" mark on the channel screen** (plan 04 follow-up) — the dashboard now lists the alert events of your watchlist (`/api/events`) with a filter by type, "only new" and "mark all read", so a channel or video that disappeared is visible without Telegram. A channel the API stopped returning (confirmed after two misses 6+ hours apart) gets a red note on its screen; `channel_analytics` returns `gone` for it. The demo seed has a gone channel and two events, and the smoke test opens both screens. - **YPP-threshold filter in outlier search** (plan 12 follow-up) — `search_outliers` and `recently_added_outlier_channels` (MCP, HTTP, the "Найти нишу" and "Outlier-каналы" screens) take `min_ypp_status` (`subscribers-met` or `shorts-path-met`) to keep only channels past the YouTube Partner Program thresholds they visibly meet. Off by default; still not a monetization status, and a hidden subscriber count never passes. - **Niche RPM range in the extension** (plan 06 follow-up) — the channel panel shows the niche-model revenue with its RPM range next to the Social Blade one, and both revenue lines say they are ads only, without sponsorships. README and the help screen explain the range. - **SECURITY.md: why there is no CSRF token** — the `SameSite=Strict` session cookie plus the JSON / `X-NF-Client` rule (which forces a CORS preflight that only `chrome-extension://` passes) stand in for it; a new plain-form route or a GET that changes data would bypass both. - **SECURITY.md** (plan 15, sub-stage 5.11) — how to report a problem, what protects single-user and multi-user mode, and the checklist before giving anyone an account: HTTPS with `NF_COOKIE_SECURE` and `NF_ALLOWED_HOSTS`, `OWN_TOKENS_KEY`, backups, and YouTube's one-project and 30-day storage rules for a service other people use. - **Per-user LLM budget and rate limit** (plan 15, sub-stage 5.10) — the request rate limit counts per signed-in user instead of per address (sign-in itself stays limited per address), and each user may spend `NF_USER_DAILY_LLM_USD` (default 0.25, 0 = no limit) a day inside the installation-wide `LLM_DAILY_BUDGET_USD`. `llm_usage` is keyed by `(user_id, day, model)`; the worker's LLM work is booked to the local user. - **Per-user notifications** (plan 15, sub-stage 5.9) — each user sets their own Telegram bot and chat or webhook and mode (instant / digest / both / off) on the "Данные" screen or `/api/settings/notifications`, with a test button. Alerts and the digest cover only that user's watchlist; `alert_deliveries` is keyed by `(user_id, alert_key)`, so every user gets each alert once. The bot token and webhook address are encrypted with `OWN_TOKENS_KEY` and never read back. A user's webhook must be https to a public address, checked when saved and again right before every send, with redirects off (no SSRF into the server's own network). The local user without saved settings keeps `NOTIFY_*` from `.env`; the worker delivers and sends digests per user. - **Personal API tokens for the extension and MCP over HTTP** (plan 15, sub-stages 5.7–5.8) — with `NF_MULTI_USER=1`, `Authorization: Bearer nf_...` signs in like the session cookie. Tokens are created on the dashboard's MCP screen ("Личные токены") or with `cli.py create-token`, shown once, stored as SHA-256, listed with last use, and revocable; a token cannot manage tokens. The extension gets an "Токен доступа" setting (kept in `chrome.storage.local`, masked in the popup). The HTTP MCP services require a token (the SDK's `token_verifier`), tools act as the token's user and YouTube calls count against that user's budget; stdio stays local as user 1. - **Per-user YouTube budgets** (plan 15, sub-stages 5.5–5.6) — in multi-user mode each signed-in user spends a daily share of the installation's one YouTube key (`NF_USER_DAILY_UNITS`, default 2000; `NF_USER_DAILY_SEARCH_CALLS`, default 20; 0 = no limit). The YouTube client checks the share before every call and counts it after, so every quota-spending route is covered; running out answers 429 to that user only. The worker and single-user mode are not limited. `/api/auth/me` and the dashboard footer show what is left. - **Per-user data** (plan 15, sub-stage 5.4) — with `NF_MULTI_USER=1` every signed-in user has their own watchlist, swipe file, drafts, transcript queue and alert read marks, through HTTP; someone else's saved item or draft id answers 404. `tracked_channels` and `transcript_requests` are keyed by `(user_id, …)`, so two users can track the same channel or ask for the same transcript; the worker, the alert scan and thumbnail fingerprints handle each tracked channel once. Events stay shared facts with a new `events.channel_id` (filled from the payload for older rows): a user sees the events of their own watchlist, and "read" moved from `events.seen_at` to the personal `event_reads` (existing marks kept for the local user). A pasted transcript answers every user's request for it, and a new request for a video that already has one is ready at once. Isolation tests cover the application and HTTP layers. Single-user mode behaves as before. - **Multi-user groundwork** (plan 15, sub-stages 5.1–5.3, experimental) — `NF_MULTI_USER` (off by default: nothing changes). When on, `/api` needs a sign-in: invited accounts (`cli.py create-user` / `set-password` / `users`, no sign-up page), scrypt password hashes, a session token in an HttpOnly SameSite=Strict cookie (`nf_session`, `Secure` with `NF_COOKIE_SECURE` or HTTPS) stored only as SHA-256, a sign-in screen and a sign-out button in the dashboard. New tables `users` (user 1 = "local") and `sessions`; every personal table (`tracked_channels`, `saved_items`, `drafts`, `alert_deliveries`, `transcript_requests`, `llm_usage`) gets `user_id`, with existing rows moved to user 1; `SCHEMA_VERSION` 4. "Мои каналы" already reads as the signed-in user. Separating the rest per user is sub-stage 5.4 — until then do not give accounts to other people. One YouTube API key per installation (YouTube API policies III.D.1.c), per-user quota budgets later. - **Your own channels** (plan 14) — connect your channels through Google OAuth (your own "Desktop app" client, read-only scopes, loopback redirect with PKCE and a single-use 10-minute state) and get their real YouTube Analytics numbers: per video views, watch time, retention, subscribers gained and, on a monetized channel, revenue, CPM and RPM, for the last 28 days and lifetime. A "Мои каналы" screen (setup steps, connect, sync, disconnect), MCP `own_channels`, `own_vs_niche`, `rpm_calibration`, `sync_own_channels` (80 tools), `/api/own/*` routes, a daily worker step (`WORKER_OWN_SYNC`) and real numbers in `draft_outcomes` (`ownMetrics`). `rpm_calibration` sets your real RPM against the plan-06 range. The refresh token is Fernet-encrypted with `OWN_TOKENS_KEY` from the environment and never returned or logged; disconnecting revokes it at Google and deletes everything. New personal tables `own_channels`, `own_video_metrics`, `own_oauth_pending` with `user_id` (plan 15 rules; `domain/users.py`). Thumbnail CTR is not in the Analytics API and is not shown. `cryptography` is now a direct dependency (it was already installed). `PRIVACY.md` updated; a security review of the flow found no issues. - **Similar thumbnails** (plan 13) — MCP `similar_thumbnails`, `search_thumbnails`, `thumbnail_styles`, `embed_thumbnails` (76 tools), `/api/videos/{id}/similar-thumbnails`, `/api/thumbnails/search`, `/api/niches/{slug}/thumbnail-styles`, `POST /api/thumbnails/embed`, a "Стили превью" block with text search on the niche screen and "Похожие по картинке" in the brief. Thumbnails become local CLIP vectors (fastembed, ONNX on CPU, `Qdrant/clip-ViT-B-32-vision`; text search with `Qdrant/clip-ViT-B-32-text`): visually similar thumbnails, thumbnails matching a description, and a niche's k-means style groups with their median outlier score. New columns `videos.thumb_embedding` / `thumb_embedded_at` and, with pgvector, `thumb_embedding_v` with an HNSW index (search ~1 ms on a real database). An opt-in worker step (`WORKER_THUMB_EMBED`, off by default) reuses plan-05 archived images, downloads the rest from i.ytimg.com (zero API quota), keeps only the vector, re-embeds after a thumbnail swap and retries an unreadable image after 7 days. Models: ~0.6 GB of disk; the process peaks at ~0.7 GB RAM while embedding, ~60 ms of CPU per thumbnail (measured). `PRIVACY.md` updated. - **YPP thresholds** (plan 12) — `yppEligibility` in `channel_analytics` (MCP, channel screen) and `inspect_channel` (the extension's channel panel): which YouTube Partner Program thresholds a channel visibly meets — 500 / 1,000 subscribers, 3 uploads and 3M / 10M Shorts views in 90 days, the last two as a lower bound from the videos we collected; watch hours are not in the API and always shown as unknown. Statuses below-threshold / subscribers-met / shorts-path-met / unknown, each with a note that this is not a monetization status. The plan's spike found no dependable page signal (ads also run on non-partner channels; the "Join" button needs a signed-in viewer), so there is no "monetized" badge and nothing reads YouTube pages. No schema change, zero quota. - **Scenarios for Claude** (plan 11) — seven MCP prompts in `interfaces/mcp/prompts.py`, listed by the client (Claude Desktop: "+" → niche-finder): `find_niche`, `analyze_competitor`, `validate_idea`, `outlier_to_video`, `weekly_review`, `find_content_gaps`, `niche_health`. Each puts a Russian step-by-step instruction into the chat with the exact tools in order, its quota cost and the shape of the answer; the ones that collect call `db_stats` first and stop when the quota is short, and reading comments or saving a draft waits for the user's consent. Arguments carry descriptions for the client menu. A "Сценарии" section on the dashboard's MCP screen. `tests/test_mcp_prompts.py` fails if a scenario names a tool that does not exist. The tool count stays 72. - **Niche trend** (plan 08) — `saturation_v2` in `niche_overview` / `niche_overview_from_channel` (MCP and `/api/niches/{slug}`), a new `GET /api/niches/saturation` for every niche at once, a "Тренд ниши" block on the niche screen, a "Тренд" column in the niche list and on the cluster map. The last 30 days against the 90 before them, always in its own 120-day window whatever the requested period: supply (videos), demand (median views projected to day 30, so old and new videos compare; Shorts and long-form never mixed; videos under 3 days left out), entrants (channels created in the window) and newcomers (channels under 180 days with a video at outlier >= 2). Status growing / stable / cooling / saturated, or insufficient-data under 20 videos in either window; every signal is a reason with its numbers, and `confidence: low` marks a trend when most videos were not seen young, since it may only reflect how they were collected. Clusters show the trend but keep their opportunity order. The old `saturation_hint` stays. No schema change, zero quota. - **Content gaps** (plan 03) — MCP `content_gaps`, `GET` / `POST /api/niches/{slug}/content-gaps` and a "Пробелы в контенте" card on the niche screen. Questions and requests from the comments of a niche's most-viewed videos that no collected video or pasted transcript answers yet: similar questions are grouped (embeddings, or normalised text without them), coverage comes from `check_ideas` over titles plus cosine over transcript chunks, and gaps are ranked by demand (askers, likes, how many videos they asked under) x (1 - coverage), each with example comments and the nearest existing video. With an LLM the questions come from `comment_insights`; without one, from rules (question mark or explicit request, English and Russian) — noisier, and the answer says so. `GET` and the MCP default read only the cache (zero quota); `POST` / `fetch=true` reads uncached videos' comments, 1 unit each, and stops at an exhausted quota, listing what it skipped. The no-LLM cache (`video_insights`, task `comment_questions`) keeps question text and like count only, never the author; `PRIVACY.md` updated. `build_brief` takes an optional `gap_topic`, and the card's "В бриф" opens `#/brief/