Race condition (дубли статей) устранён окончательно:
1. pg_advisory_lock по ключу категории в начале runAutogenForCategory —
если два процесса запускаются одновременно, второй ждёт первого.
pg_advisory_unlock в finally — освобождается всегда, даже при ошибке.
2. После получения lock — повторная проверка 'уже генерировали сегодня'
(SELECT articles WHERE category AND created_at >= CURRENT_DATE).
Если да — skip без генерации. Это защита от случая когда первый процесс
завершился пока второй ждал lock.
3. draftAutoApprove catch-up при старте: если engine стартовал после 07:00
и есть непрогнанные вчерашние черновики — одобряет их сразу.
Раньше deploy в 07:27 приводил к тому что черновики зависали навсегда.
getAutogenStatus теперь считает:
- topic_count_free: свободных тем для генерации
- topic_count: всего тем в банке
- drafts_today: черновиков созданных за 24ч
- cat_icon, cat_color: из таблицы categories
Убрана ссылка на несуществующую content_queue (queue_count).
GET /api/autogen/topics теперь возвращает темы из blog_topics (свободные,
sorted by priority DESC), backward-compat: возвращает строки как раньше.
Fallback на TOPIC_BANK если для категории нет тем в БД.
Проблема: одна и та же тема из blog_topics использовалась несколько раз
(см. 7 дублей в БД). Причина: getNextTopic читал is_used=false, возвращал
тему, но помечал её использованной только ПОСЛЕ INSERT статьи (через 30-60 сек
пока AI генерирует текст). За это время повторный запуск видел ту же is_used=false
и выбирал её снова.
Фикс: атомарная операция UPDATE...RETURNING с FOR UPDATE SKIP LOCKED —
тема помечается is_used=true В МОМЕНТ ВЫБОРА, до вызова AI.
Параллельные или повторные запуски видят is_used=true и пропускают тему.
Дополнительно: fallback TOPIC_BANK теперь перемешивает пул случайно
(вместо Math.random() на весь массив) — более равномерное распределение.
Проблема: брали черновики за 36ч → при нескольких деплоях за день или при
накоплении старых черновиков draftAutoApprove раскладывал их все по слотам
подряд → очередь на несколько дней вместо 1.
Исправления:
- WHERE created_at AT TIME ZONE 'Europe/Moscow' вчерашний день (00:00-23:59)
- LIMIT 4 — не больше количества слотов в день
- раскладываем только min(drafts, freeSlots) черновиков, лишние остаются draft
- если черновиков > слотов — пишем в лог предупреждение
Было: runDraftAutoApprove брал ВСЕ черновики подряд и вызывал nextDripSlot()
для каждого — слоты набивались на несколько дней вперёд.
Теперь:
- берём только черновики созданные за последние 36 часов (вчерашние)
- слоты — только сегодняшний день (getTodaySlots из SITE_PUBLISH_SLOTS)
- занятые слоты пропускаем, не затираем уже запланированное
- если слотов меньше черновиков — ставим оставшихся в последний слот
Механика как у заметок Зеро:
17:00 → генерация 4 черновиков (лежат в /admin/drafts, можно редактировать)
07:00 след.дня → авто-одобрение, слоты сегодняшнего дня (08:11/12:11/16:11/20:11)
Ручное одобрение → nextDripSlot (ближайший свободный)
ЭТАП 4 — расписание публикаций (раз и навсегда):
1. Синхронизация сайт ↔ Telegram:
- articleAutoPublish.pickScheduleTime теперь ставит TG-пост на
articles.published_at (тот же момент, когда статья появляется на сайте).
Слоты канала publish_slots больше НЕ выбирают время независимо.
- Единый источник времени — published_at (drip-слот сайта).
- Слоты сайта и ТГ синхронизированы в БД: 08:11/12:11/16:11/20:11.
2. Защита от залпа (scheduledPostsRunner):
- посты просроченные >3ч → status='skipped' (не спамим канал задним числом).
- публикация по ОДНОМУ за тик (LIMIT 1), не пачкой.
3. Заметки Зеро (zeroNotes) — механика как у черновиков статей:
- nextPublishSlot всегда = ЗАВТРА publishHour (сегодня сгенерили → завтра
публикуем). Час настраивается ZERO_NOTES_PUBLISH_HOUR (13:00).
- autoApproveOldDrafts (09:00 МСК): если не подтверждён к утру дня
публикации — авто-одобряется и выходит в свой слот.
- publishReady limit:1 — строго одна заметка за тик.
Настройки в app_settings: SITE_PUBLISH_SLOTS, ZERO_SITE_URL_BASE,
ZERO_NOTES_APPROVE_HOUR=9, GENERATE_HOUR=13, PUBLISH_HOUR=13.
Проблема: scheduledPostsRunner (статьи) и zeroNotesRunner (заметки Зеро)
держали каждый свою копию sendPhoto/sendMessage. Логика разъезжалась —
у статей фото работало через multipart, у заметок ломалось (URL-режим →
'wrong type of web page content'). Чинили в одном месте — в другом
оставалось сломано.
Решение: src/services/tgSend.js — единый источник правды:
- resolveLocalPhoto: /uploads/* → локальный файл (multipart), внешний → URL
- tgSend({botToken, chatId, text, photoUrl, replyMarkup, parseMode}):
фото+caption<=1024 → sendPhoto (multipart для локальных, URL для внешних)
иначе → sendMessage (текст<=4096)
- extractTgError: единый разбор ошибок Telegram
zeroNotesRunner и scheduledPostsRunner.publishToTelegram теперь оба зовут
tgSend. Кнопка-на-сайт у статей и reply_markup у заметок сохранены.
parseMode: статьи — Markdown, заметки Зеро — без разметки (текст от первого
лица с произвольными символами не должен падать на parse entities).
VK/MAX публикация не затронута (там свой resolveLocalPhoto/FormData).
The URL-mode I added earlier (Telegram fetches photo from our public URL)
hit 'wrong type of the web page content' from Telegram servers — likely
because traefik / Cloudflare serves something non-image to bot User-Agent,
or the worker proxy mangles things on the way back. Either way it stopped
working.
Today we added the persistent /var/www/zeropost-uploads volume to the engine
container, so the file is now directly accessible inside engine. Switching
back to multipart upload via fs.createReadStream — same pattern as
scheduledPostsRunner uses for article covers (and which works fine).
ZERO_PUBLIC_BASE_URL setting is no longer needed but harmless if left.
Зачем: автоген генерит несколько статей подряд (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 универсальных метафор. Детерминированный выбор по хешу заголовка.