diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..7779f8f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,158 @@ +# ZeroPost — Roadmap (план фич) + +Живой документ. Обновлять по мере выполнения. +Последнее обновление: 2026-06-01 + +--- + +## Контекст / архитектура + +- **zeropost.ru** (web) — блог + админка. PM2 `zeropost-web` (pm_id=5, порт 3042), `/var/www/zeropost-web`. +- **app.zeropost.ru** (tool) — SaaS для управления каналами TG/VK/MAX. PM2 `zeropost-tool` (pm_id=4, порт 3041), `/var/www/zeropost-tool`. +- **engine** — общий движок API + LLM-pipeline. PM2 `zeropost-engine` (pm_id=3, порт 3030), `/var/www/zeropost-engine`. +- БД: PostgreSQL, db=`zeropost`. +- Cron: `*/10 * * * * zeropost-autogen.sh`, `* * * * * zeropost-publish-scheduled.sh` (обе очереди). +- Internal secret: `zeropost_internal_2026`. TG-прокси: CF Worker в `app_settings.TELEGRAM_API_BASE`. + +--- + +## ✅ DONE + +### Photo-search (Yandex) +- `user_posts.image_credit` (JSONB), `users.is_admin` (BOOLEAN). +- Engine: proxy `/api/photo-search/*`. Tool: `/system` (admin-only), `PhotoSearchModal`. +- Web: категория `photo_search` скрыта из blog-админки. + +### Auto-publish статей блога → каналы +- `channels.auto_publish_{enabled,categories,delay_min,template,with_cover,button_text,image_source}`. +- Engine: `articleAutoPublish.js`, `scheduledPostsRunner.js`, `/api/scheduled-posts/*`. +- Хук в `PATCH/POST articles` при `draft→published`. Typeahead `/api/articles/admin/search`. +- Cron обновлён — дёргает обе очереди (`user_posts` + `scheduled_posts`). +- Web: `ArticlePicker`, `AutoPublishTab`, 4 вкладки в `ChannelEditor`. + +### Журнальная главная страница zeropost.ru +- Engine: `/api/articles/home` (hero / byCategory / popular / recent). +- Web: `CategoryRow`, `PopularBlock`, `RecentBlock` (группировка Сегодня/Вчера/Эта неделя). +- `ArticleCard` с 3 размерами (hero/regular/compact) + цветной category badge без дублей. +- Header упрощён (2 пункта desktop + расширенное мобильное меню). + +### Персонаж Зеро +- 15 поз: `avatar, coding, tools, lock, gears, eureka, confused, facepalm, victory, tired, reading, magnifier, chart, meditate, present` → `/var/www/zeropost-uploads/zero-{name}.webp`. +- `src/services/zeroCharacter.js` — выбор позы по тексту/категории статьи (эмоциональные триггеры + категорийные). +- `channels.auto_publish_image_source = 'alternating'` — чётные посты = AI-обложка, нечётные = Зеро. +- `scheduledPostsRunner` — multipart upload (не URL) чтобы CF Worker не падал. + +### Промпт Зеро +- `src/services/promptBuilder.js` — секция `author_persona` в `buildArticleSystemPrompt`. +- `src/services/articles.js` — `blogChannel.author_persona` (голос, правила, запрещённые фразы). +- Статьи теперь пишутся от первого лица с личными историями. + +### TG-канал @zeropostru — запуск +- Welcome-пост от Зеро с аватаром + кнопкой (msg_id=13, закреплён). +- 4 статьи опубликованы (по одной на каждую категорию), режим alternating. + +### zeropost.ru — страница Зеро + TG-продвижение +- `/about/zero` — страница с описанием персонажа + галерея 8 поз. +- Footer — TG-банер с аватаром Зеро на каждой странице. +- Конец каждой статьи — блок «Понравилась заметка? → В канал». +- `/about` — ссылка «Познакомьтесь с Зеро». + +--- + +## 🚀 ПЛАН: что делать дальше + +### СЕЙЧАС (не отложить) + +**A. Revoke бота** — токен @zeropostru_bot засветился в этом чате. Зайди в @BotFather → /mybots → выбери бота → API Token → Revoke. Потом обнови в `/admin/channels/1` → Настройки → Bot Token. + +**B. Статья на Habr** — главный бесплатный способ получить первые 200-500 живых читателей. Тема: «Я сделал блог, который ведёт ИИ с персонажем-маскотом. Как работает pipeline». Напишу черновик — дай команду. + +--- + +### app.zeropost.ru — приоритетный порядок + +#### P1. Календарь публикаций (1–2 дня) +Самый частый запрос у SMM-инструментов. Без него непонятно «что когда выходит». +- Страница `/calendar` — визуальная сетка (неделя + месяц). +- Данные: `user_posts.scheduled_at` + `publish_slots` канала. +- Карточки по цветам: draft=серый, scheduled=синий, published=зелёный, failed=красный. +- Drag & drop между датами → PATCH `scheduled_at`. +- Фильтр по каналу. + +#### P2. Превью под платформу (0.5–1 день) +Сейчас пишешь пост и не знаешь как он будет выглядеть. +- Компонент `PostPreview` в `ChannelView` справа от textarea. +- Рендерит Markdown как TG: **жирный**, _курсив_, обрезка caption 1024, превью ссылки. +- Переключатель TG / VK / MAX — разные ограничения форматирования. + +#### P3. Шаблоны постов (0.5 дня) +Ускоряет создание поста в 3 раза. +- 7 кнопок-пресетов: Новость, Анонс, Кейс, Лонгрид, Подборка, Опрос-разбор, Личное мнение. +- Каждый — готовая структура поста + hint для AI. +- В `ChannelView` рядом с кнопкой «Идеи тем». + +#### P4. Аналитика постов (2–3 дня) +Без метрик невозможно понять что заходит. +- Таблица `post_metrics(user_post_id, captured_at, views, forwards, reactions JSONB)`. +- Воркер раз в 15 мин: пуллит views через TG embed для постов < 7 дней. +- В `ChannelView` у каждого поста — строчка «👁 N ↗ N ❤️ N». +- График: «лучший день/час для публикации» по медиане views. + +#### P5. URL → черновик (1–2 дня) +Killer feature которой нет у конкурентов в таком качестве. +- Вставил ссылку → AI читает статью/YouTube/TG-пост → пишет пост в стиле канала. +- `POST /api/generate/from-url`. Для статей: cheerio + og-meta. Для YouTube: yt-dlp транскрипт. + +#### P6. Комментарии + AI-ответы (4–7 дней) +Большая фича, отдельный спринт. +- TG webhook → unified inbox. +- Классификатор haiku: вопрос/спам/похвала/троллинг. +- Предложенный AI-ответ с кнопкой «отправить». + +#### P7–P10. Опросы, хештеги, best-time, URL-shortener +По 0.5–1 дню каждое, делаем после P1–P5. + +--- + +### zeropost.ru — мелкий должок + +| Задача | Срочность | +|---|---| +| Revoke бота @zeropostru_bot | 🔴 СЕЙЧАС | +| Статья на Habr про ZeroPost | 🟠 На этой неделе | +| Кнопка «Бэкфилл статей» в AutoPublishTab | 🟡 Низкая | +| История публикаций канала | 🟡 Низкая | +| Балансы внешних сервисов в /system | 🟡 Низкая | + +--- + +### zeropost.ru — рост аудитории (без бюджета) + +| Действие | Ожидаемый результат | Когда | +|---|---|---| +| Habr: «Как я сделал AI-блог с маскотом» | 200–500 переходов | На этой неделе | +| vc.ru: то же самое | 100–300 переходов | На этой неделе | +| 5 комментариев в AI-чатах TG | 20–50 подписчиков | Постоянно | +| Взаимный пиар с 3 каналами (tgstat.ru) | 50–150 подписчиков | 1–2 недели | +| Reddit r/artificial | 50–200 переходов | На этой неделе | +| SEO (органика) | Долгосрочно | Само, 2–3 мес | + +--- + +## Технический долг + +- ENGINE_URL default в `lib/engine.js` tool'а = 3040, должен быть 3030. +- `per_day` в `autogen_settings` не используется в логике (только `run_hour:run_minute`). +- TOPIC_BANK заканчивается — нужен AI-генератор новых тем. +- VK: публикация без фото (нужен 2-step `photos.getWallUploadServer`). +- MAX: заглушка `throw new Error('не реализована')`. + +--- + +## Не делаем + +- Холст / визуальный редактор. +- Stories / Reels. +- Команда / роли (до 5+ клиентов). +- White Label. +- Парсинг конкурентов через MTProto. diff --git a/app/api/admin/settings/[key]/route.js b/app/api/admin/settings/[key]/route.js new file mode 100644 index 0000000..ec2a50a --- /dev/null +++ b/app/api/admin/settings/[key]/route.js @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function PUT(req, { params }) { + const admin = await requireAdmin(); + if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + try { + const { key } = await params; + const body = await req.json(); + const row = await engine.updateSetting(key, body?.value); + return NextResponse.json(row); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} diff --git a/app/api/admin/settings/route.js b/app/api/admin/settings/route.js new file mode 100644 index 0000000..3f27a61 --- /dev/null +++ b/app/api/admin/settings/route.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +// GET /api/admin/settings?category=photo_search +export async function GET(req) { + const admin = await requireAdmin(); + if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + try { + const { searchParams } = new URL(req.url); + const category = searchParams.get('category') || undefined; + const rows = await engine.listSettings(category); + return NextResponse.json(rows); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} diff --git a/app/api/auth/login/route.js b/app/api/auth/login/route.js index fff57fd..b04b286 100644 --- a/app/api/auth/login/route.js +++ b/app/api/auth/login/route.js @@ -16,19 +16,23 @@ export async function POST(req) { } const hash = await bcrypt.hash(password, 10); const { rows } = await q( - `INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`, + `INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name,is_admin`, [email, hash] ); const user = rows[0]; const s = await getSession(); s.userId = user.id; s.email = user.email; + s.isAdmin = !!user.is_admin; await s.save(); return NextResponse.json({ ok: true, user }); } // login - const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]); + const { rows } = await q( + `SELECT id,email,password,name,is_admin FROM users WHERE email=$1`, + [email] + ); if (!rows.length) { return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 }); } @@ -41,6 +45,10 @@ export async function POST(req) { s.userId = user.id; s.email = user.email; s.name = user.name; + s.isAdmin = !!user.is_admin; await s.save(); - return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } }); + return NextResponse.json({ + ok: true, + user: { id: user.id, email: user.email, name: user.name, isAdmin: !!user.is_admin }, + }); } diff --git a/app/api/photo-search/by-query/route.js b/app/api/photo-search/by-query/route.js new file mode 100644 index 0000000..4121c36 --- /dev/null +++ b/app/api/photo-search/by-query/route.js @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function POST(req) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { + const body = await req.json(); + const data = await engine.photoSearchByQuery(body); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: err.status || 500 } + ); + } +} diff --git a/app/api/photo-search/profiles/route.js b/app/api/photo-search/profiles/route.js new file mode 100644 index 0000000..7d6dc4a --- /dev/null +++ b/app/api/photo-search/profiles/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function GET() { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { + const data = await engine.photoSearchProfiles(); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} diff --git a/app/api/photo-search/quota/route.js b/app/api/photo-search/quota/route.js new file mode 100644 index 0000000..e108bcb --- /dev/null +++ b/app/api/photo-search/quota/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function GET() { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { + const data = await engine.photoSearchQuota(); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} diff --git a/app/system/page.js b/app/system/page.js new file mode 100644 index 0000000..194189b --- /dev/null +++ b/app/system/page.js @@ -0,0 +1,27 @@ +import { redirect } from 'next/navigation'; +import { requireUser } from '@/lib/session'; +import Header from '@/components/Header'; +import SystemSettings from '@/components/SystemSettings'; + +export const dynamic = 'force-dynamic'; + +export default async function SystemPage() { + const user = await requireUser(); + if (!user) redirect('/login'); + if (!user.isAdmin) redirect('/'); + + return ( + <> +
+
+
+

Системные настройки

+

+ Конфигурация внешних сервисов (поиск фото, билинги и т.п.). Видно только админам. +

+
+ +
+ + ); +} diff --git a/components/ChannelView.js b/components/ChannelView.js index e4a0586..ee8c7fb 100644 --- a/components/ChannelView.js +++ b/components/ChannelView.js @@ -4,8 +4,9 @@ import Link from 'next/link'; import { ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings, Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart, - MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2 + MessageSquare, Pencil, X, Send, Clock, Search, Camera, ExternalLink } from 'lucide-react'; +import PhotoSearchModal from './PhotoSearchModal'; const GOAL_LABELS = { educational: 'Обучение', news: 'Новости', @@ -22,6 +23,15 @@ const TRANSFORMS = [ { action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' }, ]; +// Хвостовая подпись «📷 Фото: domain» — добавляется к посту, можно убрать +function buildCaption(domain) { + return domain ? `\n\n📷 Фото: ${domain}` : ''; +} +// Удаляет существующую подпись из текста поста (по любому домену) +function stripCaption(text) { + return (text || '').replace(/\n{1,2}📷\s*Фото:\s*[^\n]+\s*$/u, '').trimEnd(); +} + export default function ChannelView({ channel }) { const [topic, setTopic] = useState(''); const [generating, setGenerating] = useState(false); @@ -36,8 +46,12 @@ export default function ChannelView({ channel }) { // Картинка const [image, setImage] = useState(null); + const [imageCredit, setImageCredit] = useState(null); // { domain, sourceUrl, title } | null const [genImage, setGenImage] = useState(false); + // Photo search modal + const [showPhotoSearch, setShowPhotoSearch] = useState(false); + // Трансформации const [transforming, setTransforming] = useState(false); @@ -74,7 +88,6 @@ export default function ChannelView({ channel }) { const [history, setHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); - // Подгрузка истории при монтировании useEffect(() => { loadHistory(); }, []); async function loadHistory() { @@ -86,6 +99,23 @@ export default function ChannelView({ channel }) { } catch {} finally { setLoadingHistory(false); } } + function clearImage() { + setImage(null); + setImageCredit(null); + // Если в посте была подпись «📷 Фото: …» — убираем её при удалении фото + if (post) setPost(p => stripCaption(p)); + } + + function applyPhotoPick({ imageUrl, credit }) { + setImage(imageUrl); + setImageCredit(credit || null); + // Подменяем (или добавляем) caption + if (post && credit?.domain) { + setPost(p => stripCaption(p) + buildCaption(credit.domain)); + } + setShowPhotoSearch(false); + } + async function savePost(status = 'draft', scheduledAt = null) { if (!post) return; setPublishing(true); @@ -93,12 +123,12 @@ export default function ChannelView({ channel }) { try { let id = savedPostId; if (!id) { - // Создаём const res = await fetch('/api/user-posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - channel_id: channel.id, content: post, image_url: image, + channel_id: channel.id, content: post, + image_url: image, image_credit: imageCredit, topic: topic.trim(), status, scheduled_at: scheduledAt, }), }); @@ -107,11 +137,15 @@ export default function ChannelView({ channel }) { id = data.id; setSavedPostId(id); } else { - // Обновляем const res = await fetch(`/api/user-posts/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: post, image_url: image, status, scheduled_at: scheduledAt }), + body: JSON.stringify({ + content: post, + image_url: image, + image_credit: imageCredit, + status, scheduled_at: scheduledAt, + }), }); if (!res.ok) throw new Error((await res.json()).error || 'Ошибка'); } @@ -137,6 +171,7 @@ export default function ChannelView({ channel }) { setPost(null); setSavedPostId(null); setImage(null); + setImageCredit(null); setTopic(''); } catch (err) { setError(err.message); } finally { setPublishing(false); } @@ -151,6 +186,7 @@ export default function ChannelView({ channel }) { setPost(null); setSavedPostId(null); setImage(null); + setImageCredit(null); setTopic(''); } @@ -183,14 +219,14 @@ export default function ChannelView({ channel }) { if (!final) throw new Error('Таймаут — попробуй ещё раз'); if (final.status === 'failed') throw new Error(final.error || 'Генерация упала'); - // Сохраняем предыдущий вариант в variants if (asVariant && post) { - setVariants(v => [...v, { content: post, tokens, image }]); + setVariants(v => [...v, { content: post, tokens, image, imageCredit }]); } setPost(final.result); setTokens({ in: final.tokens_in, out: final.tokens_out }); - setImage(null); // сбрасываем картинку при новом посте + setImage(null); + setImageCredit(null); } catch (err) { setError(err.message); } finally { @@ -210,10 +246,10 @@ export default function ChannelView({ channel }) { }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Ошибка'); - // Сохраняем текущий в варианты - setVariants(v => [...v, { content: post, tokens, image }]); + setVariants(v => [...v, { content: post, tokens, image, imageCredit }]); setPost(data.content); setImage(null); + setImageCredit(null); } catch (err) { setError(err.message); } finally { @@ -234,6 +270,7 @@ export default function ChannelView({ channel }) { const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки'); setImage(data.url); + setImageCredit(null); // сгенерированная — без credit'а } catch (err) { setError(err.message); } finally { @@ -245,12 +282,13 @@ export default function ChannelView({ channel }) { const v = variants[idx]; setVariants(arr => { const next = arr.filter((_, i) => i !== idx); - next.push({ content: post, tokens, image }); + next.push({ content: post, tokens, image, imageCredit }); return next; }); setPost(v.content); setTokens(v.tokens); setImage(v.image); + setImageCredit(v.imageCredit || null); } async function copy() { @@ -299,7 +337,6 @@ export default function ChannelView({ channel }) { - {/* Список идей */} {showIdeas && ideas.length > 0 && (
@@ -384,7 +421,6 @@ export default function ChannelView({ channel }) {
- {/* Сам пост — редактируемый или нет */} {editing ? (