diff --git a/openspec/changes/extract-post-sources/.openspec.yaml b/openspec/changes/extract-post-sources/.openspec.yaml
new file mode 100644
index 0000000..63c288a
--- /dev/null
+++ b/openspec/changes/extract-post-sources/.openspec.yaml
@@ -0,0 +1,3 @@
+schema: spec-driven
+created: 2026-09-16
+skip_specs: true
\ No newline at end of file
diff --git a/openspec/changes/extract-post-sources/design.md b/openspec/changes/extract-post-sources/design.md
new file mode 100644
index 0000000..685674b
--- /dev/null
+++ b/openspec/changes/extract-post-sources/design.md
@@ -0,0 +1,125 @@
+# extract-post-sources
+
+## 1. sources/extract.py
+
+```python
+import re
+from urllib.parse import urlparse
+
+_MAX_PER_POST = 5
+
+# t.me/handle, t.me/s/handle, telegram.me/handle, @handle
+_TG_RE = re.compile(
+ r"(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})"
+)
+_AT_RE = re.compile(r"(?\"']+)", re.I)
+
+
+def _domain_of(url: str) -> str:
+ return (urlparse(url if "://" in url else "https://" + url).netloc or "").lower()
+
+
+def extract_sources_from_text(text: str, exclude_tg_url: str | None = None) -> list[dict]:
+ """Ищет TG-каналы и внешние сайты в тексте. exclude_tg_url — ссылка на сам пост
+ (t.me/channel/postid) — её отбрасываем, это не источник, а адрес поста."""
+ out: list[dict] = []
+ seen = set()
+
+ def add(kind, address, slug_hint, domain):
+ key = (kind, slug_hint or domain or address)
+ if key in seen:
+ return
+ seen.add(key)
+ out.append({"kind": kind, "address": address, "slug_hint": slug_hint, "domain": domain})
+
+ text = (text or "")[:4000]
+ for m in _TG_RE.finditer(text):
+ ch = m.group(1)
+ if exclude_tg_url and f"/{ch}/" in exclude_tg_url:
+ continue # ссылка на сам пост
+ add("telegram", f"https://t.me/{ch}", ch, None)
+ for m in _AT_RE.finditer(text):
+ add("telegram", f"https://t.me/{m.group(1)}", m.group(1), None)
+ for m in _URL_RE.finditer(text):
+ u = m.group(1).rstrip(".,;:!?)]}\"'")
+ dom = _domain_of(u)
+ if not dom:
+ continue
+ if dom in ("t.me", "telegram.me") or dom.endswith(".t.me"):
+ continue # уже обработали выше
+ add("site", u, None, dom)
+ if len(out) >= _MAX_PER_POST:
+ break
+ return out[:_MAX_PER_POST]
+```
+
+Ограничение `_MAX_PER_POST=5`: чтобы не захламлять карточку десятками ссылок.
+
+## 2. web/app.py
+
+```python
+from sources.sources import parse_address, add_or_update_source
+
+def _post_sources(post: dict) -> list[dict]:
+ """Список источников, найденных в тексте поста: + флаг known (уже в реестре)."""
+ from sources.extract import extract_sources_from_text
+ found = extract_sources_from_text(post.get("text") or "", post.get("url"))
+ if not found:
+ return []
+ with db() as conn:
+ slugs = {r[0] for r in conn.execute("SELECT slug FROM sources")}
+ for f in found:
+ f["known"] = f["slug_hint"] in slugs
+ return found
+```
+
+В `candidates()` и `selected()`: `post_sources=_post_sources(sel) if sel else []`.
+
+POST `/sources/add-from-post`:
+
+```python
+@app.post("/sources/add-from-post")
+def sources_add_from_post(request: Request, address: str = Form(...),
+ kind: str = Form(""), crawler: str = Form("telegram"),
+ src: str = Form("candidates"), selected: int = Form(0), ...):
+ _require_auth(request)
+ parsed = parse_address(address, crawler)
+ if not parsed:
+ return RedirectResponse(url=_cand_back(..., did="⚠️ Не удалось разобрать адрес"), ...)
+ slug = parsed["slug"]
+ with db() as conn:
+ exists = conn.execute("SELECT 1 FROM sources WHERE slug=?", (slug,)).fetchone()
+ if exists:
+ return RedirectResponse(url=_cand_back(..., did=f"Источник {slug} уже в реестре"), ...)
+ ok, res = add_or_update_source({"name": f"Из поста: {slug}", "crawler": crawler, **parsed})
+ did = f"➕ Источник {res} добавлен" if ok else f"❌ {res}"
+ return RedirectResponse(url=_cand_back(..., did=did), ...)
+```
+
+## 3. Шаблоны
+
+В candidates.html и selected.html (под бейджами, перед деталями):
+
+```html
+{% if post_sources %}
+
+ 🌐 Источники из поста:
+ {% for s in post_sources %}
+ {% if s.known %}
+ @{{ s.slug_hint or s.domain }} ✓
+ {% else %}
+
+ {% endif %}
+ {% endfor %}
+
+{% endif %}
+```
\ No newline at end of file
diff --git a/openspec/changes/extract-post-sources/proposal.md b/openspec/changes/extract-post-sources/proposal.md
new file mode 100644
index 0000000..6b72055
--- /dev/null
+++ b/openspec/changes/extract-post-sources/proposal.md
@@ -0,0 +1,32 @@
+# extract-post-sources
+
+Извлечение источников новостей из текста постов (TG-каналы/сайты) и быстрое добавление в реестр.
+
+## Why
+
+Пользователь: нужно извлекать источники новостей из полученных постов (каналы/сайты,
+откуда пришли новости), чтобы в следующий раз забирать новости напрямую из источника.
+Сейчас ссылки в тексте постов никак не используются: всё приходит из sources.yaml,
+а репосты/упоминания других каналов и сайтов теряются.
+
+## What Changes
+
+- **sources/extract.py** (новый): `extract_sources_from_text(text) -> list[dict]` —
+ находит в тексте ссылки на TG-каналы (t.me/handle, @handle, t.me/s/handle) и
+ внешние сайты (https://domain), исключает ссылки на сам пост (t.me/channel/postid),
+ дедуплицирует. Ограничение: не более 5 найденных на пост (шум).
+- **web/app.py**:
+ - helper `_post_sources(post)` → список {kind, address, slug_hint, known} для карточки
+ (known = slug_hint уже есть в sources).
+ - В render candidates/selected передаёт `post_sources`.
+ - POST `/sources/add-from-post`: {address} → parse_address + add_or_update_source
+ (переиспользует существующий CRUD). Возвращает redirect на ту же страницу с did-сообщением.
+- **web/templates/candidates.html / selected.html**: блок «🌐 Источники из поста» под бейджами:
+ для каждого найденного — `@handle` / `domain`, бейдж «в реестре» или кнопка «➕».
+
+## Impact
+
+- В карточках кандидатов/отобранных видны ссылки на внешние каналы/сайты из текста.
+- Один клик — добавить источник в реестр (yaml + БД, как ручное добавление).
+- Никаких LLM-вызовов — regex-извлечение, бесплатно. Дообогащение модели для
+ одобренных — отдельная задача (по кнопке, экономия токенов).
\ No newline at end of file
diff --git a/openspec/changes/manual-direction/.openspec.yaml b/openspec/changes/manual-direction/.openspec.yaml
new file mode 100644
index 0000000..454f4e0
--- /dev/null
+++ b/openspec/changes/manual-direction/.openspec.yaml
@@ -0,0 +1,3 @@
+schema: spec-driven
+created: 2026-09-16
+skip_specs: true
diff --git a/openspec/changes/manual-direction/design.md b/openspec/changes/manual-direction/design.md
new file mode 100644
index 0000000..d409d09
--- /dev/null
+++ b/openspec/changes/manual-direction/design.md
@@ -0,0 +1,55 @@
+# manual-direction
+
+## Design
+
+### web/app.py
+
+`post_reclassify(post_id, request, group_by, direction, own, q, src, manual_direction: str = Form(""))`:
+
+- передать `manual_direction` в `impl_reclassify`.
+
+`impl_reclassify(post_id, manual_direction: str = "")`:
+
+```python
+def impl_reclassify(post_id: int, manual_direction: str = ""):
+ from classifier.classify import classify_text
+ from classifier.keywords import DIRECTIONS_CANON
+ conn = _db()
+ p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
+ if not p:
+ conn.close()
+ raise ValueError("notfound")
+ md = (manual_direction or "").strip()
+ if md in DIRECTIONS_CANON:
+ # ручное направление: без LLM, classified=1, как словарное попадание
+ conn.execute(
+ "UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=1 WHERE id=?",
+ (md, "low", 1, "", post_id),
+ )
+ conn.commit()
+ conn.close()
+ return
+ res = classify_text(dict(p))
+ ... # как раньше
+```
+
+### web/templates/selected.html
+
+В форме reclassify (кнопка «🤖 Обработать моделью») добавить select:
+
+```html
+
+```
+
+Поведение:
+- «— модель —»: прежнее поведение (LLM).
+- выбор направления: установится принудительно, без LLM.
diff --git a/openspec/changes/manual-direction/proposal.md b/openspec/changes/manual-direction/proposal.md
new file mode 100644
index 0000000..82614e4
--- /dev/null
+++ b/openspec/changes/manual-direction/proposal.md
@@ -0,0 +1,29 @@
+# manual-direction
+
+Возможность вручную указывать/менять направление поста при анализе.
+
+## Why
+
+Пост без классификации отображается со значком «?» (published.html: `direction or '?'`).
+Кнопка «Обработать моделью» перезапускает LLM, но если модель не выдаёт каноническое
+направление — пост так и остаётся без него. Пользователю нужен способ явно задать
+направление вручную (linux/tech/politics/games/electronics/llm) — например, когда
+классификатор ошибся или не смог определить тему.
+
+## What Changes
+
+- **web/app.py** `post_reclassify`/`impl_reclassify`: новый параметр `manual_direction`
+ (select в форме). Если задан и входит в DIRECTIONS_CANON — направление пишется в БД
+ напрямую (без LLM), `classified=1`, relevance/interest — как при словарном попадании.
+- **web/templates/selected.html**: перед кнопкой «🤖 Обработать моделью» — select
+ направлений («— модель —» по умолчанию = оставить как есть). Если выбрать конкретное
+ направление — задастся принудительно.
+- **web/templates/candidates.html**: аналогичный select у формы reclassify? В кандидатах
+ reclassify нет (только «Отобрать»/«Отклонить») — добавление не требуется, но для
+ полноты добавим select кнопке reclassify в selected.html (единственное место с reclassify).
+
+## Impact
+
+- Пользователь может вручную исправить/добавить направление, не дожидаясь LLM.
+- Даёт пост классификацию даже если модель «не знает» — важно для публикации
+ (published.html без «?»).
diff --git a/sources/extract.py b/sources/extract.py
new file mode 100644
index 0000000..d6aade3
--- /dev/null
+++ b/sources/extract.py
@@ -0,0 +1,69 @@
+# VESTI — извлечение потенциальных источников из текста поста (без LLM, regex).
+import re
+from urllib.parse import urlparse
+
+_MAX_PER_POST = 5
+
+# t.me/handle, t.me/s/handle, telegram.me/handle
+_TG_RE = re.compile(
+ r"(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})"
+)
+# @handle (не email, не на конце слова)
+_AT_RE = re.compile(r"(?\"']+)", re.I)
+
+_TG_DOMAINS = ("t.me", "telegram.me")
+
+
+def _domain_of(url: str) -> str:
+ return (urlparse(url if "://" in url else "https://" + url).netloc or "").lower()
+
+
+def extract_sources_from_text(text: str, exclude_tg_url: str | None = None) -> list[dict]:
+ """Ищет TG-каналы и внешние сайты в тексте поста.
+
+ exclude_tg_url — url самого поста (t.me/channel/postid): ссылку на него
+ отбрасываем (это адрес поста, а не источник).
+
+ Возвращает список dict: {"kind": "telegram"|"site", "address": str,
+ "slug_hint": str|None, "domain": str|None}
+ максимум _MAX_PER_POST элементов.
+ """
+ out: list[dict] = []
+ seen: set[tuple] = set()
+
+ def add(kind: str, address: str, slug_hint: str | None, domain: str | None):
+ key = (kind, slug_hint or domain or address)
+ if key in seen:
+ return
+ seen.add(key)
+ out.append({"kind": kind, "address": address, "slug_hint": slug_hint, "domain": domain})
+
+ text = (text or "")[:4000]
+
+ for m in _TG_RE.finditer(text):
+ ch = m.group(1)
+ if exclude_tg_url and f"/{ch}/" in exclude_tg_url:
+ continue # ссылка на сам пост
+ add("telegram", f"https://t.me/{ch}", ch, None)
+ if len(out) >= _MAX_PER_POST:
+ break
+
+ for m in _AT_RE.finditer(text):
+ add("telegram", f"https://t.me/{m.group(1)}", m.group(1), None)
+ if len(out) >= _MAX_PER_POST:
+ break
+
+ for m in _URL_RE.finditer(text):
+ u = m.group(1).rstrip(".,;:!?)]}\"'")
+ dom = _domain_of(u)
+ if not dom:
+ continue
+ if dom in _TG_DOMAINS or dom.endswith(".t.me"):
+ continue # уже обработано выше
+ add("site", u, None, dom)
+ if len(out) >= _MAX_PER_POST:
+ break
+
+ return out[:_MAX_PER_POST]
\ No newline at end of file