Зачем: автоген генерит несколько статей подряд (4 категории за 15 минут).
Раньше у всех published_at=NOW(), и на сайте они появлялись скопом, выглядя
как спам. Теперь распределяем по слотам сайта (по умолчанию 09/13/17/21 МСК).
Архитектура:
src/services/dripScheduler.js — nextDripSlot() читает app_settings.SITE_PUBLISH_SLOTS
(CSV 'HH:MM,HH:MM,...'), default '09:00,13:00,17:00,21:00'. Перебирает дни
вперёд (14 дней горизонт), для каждого слота проверяет ±60 мин окно —
если уже есть опубликованная статья, считает занятым. Первый свободный
слот возвращается. Slots в MSK, конвертируются в UTC для сравнения с NOW().
При approve draft → published:
routes/drafts.js — PATCH /:id/approve по умолчанию ставит slot, ?immediate=true
публикует немедленно. GET /next-slot возвращает ближайший свободный для
UI-предпросмотра.
draftAutoApprove.js — авто-approve в 07:00 МСК тоже использует slot.
Публичные SQL дополнены 'AND published_at <= NOW()' чтобы будущие статьи
не светились на сайте раньше времени:
- services/articles.js: 7 мест (list/get/count/hero/grid/popular/recent)
- routes/categories.js: GET /:slug/articles
- routes/scheduledPosts.js: TG-publish source query
Опционально: SITE_PUBLISH_SLOTS можно редактировать в /admin/settings.
Problem: engine container has no mount to /var/www/zeropost-uploads/, so
fs.existsSync() always returned false. This made image_url=null for every
Zero draft, and the TG runner fell back to sendMessage (text-only, no avatar).
Fix:
- zeroCharacter.js: drop fs.existsSync entirely; AVAILABLE_POSES is now a
static Set synced with what's actually on uploads-server (23 poses
including avatar/eureka/lock/gears/etc. — earlier I incorrectly thought
there were only 10). FALLBACK_POSE = 'coffee' (more on-brand than avatar).
- zeroNotes.js: image_url unconditionally '/uploads/zero-{pose}.webp';
pickPose now also looks at the generated content (not only theme).
- zeroNotesRunner.js: sendPhoto by URL (Telegram fetches the image itself).
No more form-data/createReadStream — full URL composed from
ZERO_PUBLIC_BASE_URL setting (default https://zeropost.ru).
- zeroAdmin.js: ZERO_PUBLIC_BASE_URL added to CONFIG_KEYS so it's editable
via /admin/zero settings panel.
Existing draft #1 backfilled via SQL UPDATE (pose=eureka → image_url set).
- requireAdmin softened: doesn't require x-user-id when users.is_admin column
is absent (same pattern as /api/admin/zero). Auth still enforced via the
global x-internal-secret middleware.
- /api/admin/blog-topics/:id now supports PATCH (topic/tags/priority/is_used).
- /api/admin/blog-topics/generate reads category name from categories table
instead of using a hardcoded 4-entry map, so AI-generation works for any
user-created category.
Endpoints under /api/admin/categories:
GET — list all with metrics (article_count, topic_count, autogen status)
POST — create + auto-create autogen_settings row (defaults: enabled, 1/day, 12:00 MSK)
PATCH — update name/desc/icon/color/sort_order/is_active (slug is immutable FK)
DELETE — soft (is_active=false) by default; ?force=true tries hard delete
but refuses if any articles/topics still reference the slug
Migration: ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true
Public GET /api/categories now filters WHERE COALESCE(is_active,true)=true.
- GET/PATCH /api/admin/zero/config — read/write all Zero settings via app_settings
- scheduler reads GENERATE_HOUR / APPROVE_HOUR dynamically (no restart needed)
- generateDraft uses PUBLISH_HOUR for scheduled_at (was hardcoded 13)
- requireAdmin softened — works both with and without users.is_admin column
(prod has no is_admin; auth is provided by x-internal-secret + web cookie)
Та же проблема что и у обложек статей:
старый промт 'Topic essence: первые 250 символов' был слишком абстрактным
→ модель рисовала свой дефолт (тёмный фон + геометрия)
Новый промт (3 некофликтующих параметра):
VISUAL CONCEPT: конкретный предмет из getPostVisualConcept()
SETTING: физический антураж (8 вариантов по seed из текста поста)
LIGHTING + COLOR TEMPERATURE: конкретный свет
getPostVisualConcept(): 8 тематических категорий × 4-6 концептов
AI, automation, cybersec, code, marketing, money, education, health
+ 12 универсальных концептов
Seed = hash первых 80 символов поста → детерминировано но уникально
8. SMTP: emailService.js (nodemailer), templates (welcome/payment/low_credits)
/api/admin/email/test — тест отправки
app_settings category=smtp (HOST/PORT/USER/PASS/FROM/ENABLED)
9. Maintenance mode: middleware в index.js, MAINTENANCE_MODE в engine settings
При true → 503 для всех запросов кроме /uploads и /api/settings
10. Blog topic bank:
DB: blog_topics(category,topic,is_used,source,priority)
40 тем мигрированы из хардкода (source=hardcoded)
autogen.js: getNextTopic берёт из DB, fallback на TOPIC_BANK
admin API: GET/POST /blog-topics, DELETE /:id, POST /generate (AI +10)
routes/admin.js:
GET /autogen — настройки+статистика+очередь+размеры банков тем
PATCH /autogen/:category — enabled/per_day/run_hour/run_minute
POST /autogen/:category/run — ручной запуск генерации
POST /autogen/queue — добавить тему с приоритетом
DELETE /autogen/queue/:id — удалить тему
routes/admin.js: GET /logs — объединённые ошибки из 3 источников:
generation_jobs (status=failed), ai_usage (!succeeded), scheduled_posts (status=failed)
Сортировка по времени, топ-5 частых ошибок, группировка по типу
routes/admin.js: GET /queue (stats+recent30+stuck), POST /queue/:id/retry, DELETE /queue/stuck
stuck = processing > 5 min → сбрасываем в failed
retry = pending + requeue через Bull
DB: promo_codes, promo_usages tables
routes/admin.js: CRUD /api/admin/promos (GET/POST/PATCH/DELETE)
routes/billing.js: POST /api/billing/apply-promo
Валидация: exists, active, not expired, not exhausted, not used by this user
type=credits → начисляет через billing.credit()
Проблема: VISUAL CONCEPT и STYLE из COVER_STYLES противоречили друг другу
(sculptor on marble + frosted glass style = модель рисовала свой дефолт)
Решение: 3 простых параметра на статью без конфликтов:
SUBJECT: что изображено (из getVisualMetaphor)
SETTING: антураж (12 вариантов по articleId % 12):
oak desk | marble | slate | workbench | velvet | dawn mist |
terracotta | city night | concrete | library | frost | brick
LIGHTING: конкретный свет (golden hour, studio, rim light, etc.)
COLOR TEMPERATURE: warm amber / cool whites / etc.
Убраны STYLE/PALETTE/MOOD/COMPOSITION блоки которые путали модель.
Теперь каждая статья = уникальная физическая сцена с конкретным светом.
COVER_STYLES: 4 → 12 стилей (amber-terrain, violet-gradient, monochrome-sharp,
coral-horizon, neon-circuits, blueprint-tech, glass-morphism, retro-wave,
zen-minimal, data-cosmos, editorial-ink, teal-architecture)
Теперь стиль повторяется раз в 12 статей (было каждые 4)
buildCoverPrompt(): новая структура промта:
- VISUAL CONCEPT: тематическая метафора из getVisualMetaphor()
- STYLE/PALETTE/MOOD/COMPOSITION: стиль из ротации
Промт явно ставит концепцию первой → картинка отражает тему статьи
getVisualMetaphor(): 10 тематических категорий (cybersec, AI, automation,
data, deepfake, code, marketing, email, vector/rag, prompt engineering)
+ 10 универсальных метафор. Детерминированный выбор по хешу заголовка.
DB: inbox_messages (text, ai_type, ai_reply, status), channels.tg_webhook_enabled
Engine routes/inbox.js:
POST /api/tg-webhook/:channelId — получаем комментарии от TG (публичный)
GET /api/inbox/:channelId — список сообщений с фильтром
GET /api/inbox/stats/:channelId — статистика по типам
POST /api/inbox/:id/reply — отправить ответ в TG
POST /api/inbox/:id/status — изменить статус
POST /api/inbox/:channelId/setup-webhook — зарегистрировать TG webhook
AI: claude haiku → {type, reply} JSON
До: wall.post без attachments → картинка игнорировалась
После:
1. photos.getWallUploadServer → upload_url
2. POST upload_url с файлом (local path или download) → server/photo/hash
3. photos.saveWallPhoto → owner_id + photo_id
4. wall.post с attachments=photo{owner_id}_{id}
При ошибке загрузки фото — публикуем без картинки (graceful degradation)
Поддерживает как локальные /uploads/ файлы так и внешние URL
config/index.js: добавили ROUTERAI_BASE_URL, ROUTERAI_API_KEY в список keys
который загружается из app_settings через reloadAi()
Без этого ключ был NULL и все генерации падали в local SVG fallback
- generatePost: customPrompt или channel.ai_style_prompt → добавляется к userPrompt
- routes/generate.js: принимает customPrompt, передаёт в очередь
- workers/generation.js: передаёт customPrompt в generatePost и generateArticle