Compare commits

...

43 Commits

Author SHA1 Message Date
Aleksei Pavlov 4694093080 docs: HANDOFF 2026-06-24 — ZeroPost + PostCast session summary 2026-06-24 20:19:11 +03:00
Alexey Pavlov e1f2f44365 fix(max): migrate to platform-api2.max.ru + Russian Trusted CA bundle
MAX API URL updated to platform-api2.max.ru (mandatory before 2026-07-19).
Added Russian Trusted Root + Sub CA 2024 bundle as repo file — to be loaded
via NODE_EXTRA_CA_CERTS=/app/russian_trusted_bundle.pem (set in Coolify env).
2026-06-24 15:43:21 +03:00
Aleksei Pavlov 90f6b474a1 fix(autogen): remove sort_order from ORDER BY — column doesn't exist in autogen_settings
Critical: every runAutogen tick was failing with 'column sort_order does not exist'
since deploy of rotation feature. This means NO articles were generated on Jun 22
and draftAutoApprove also failed on Jun 23 07:00.

autogen_settings has no sort_order column — use ORDER BY category instead.
2026-06-23 10:11:51 +03:00
Aleksei Pavlov 1f25adff00 feat(autogen): ротация 4 из 8 категорий по дням
Каждый день генерируем 4 из 8 категорий по скользящему окну:
  День 1: cat[0..3], День 2: cat[1..4], ..., День 8: cat[7,0,1,2]

Логика: dayOfYear % totalCategories = сдвиг окна.
За 8 дней каждая категория выходит 4 раза, каждый день — новый набор.

getAutogenStatus теперь возвращает today_active=true/false — входит
ли категория в сегодняшнюю ротацию.

Слоты публикации откатаны к 4 (08:11/12:11/16:11/20:11).
2026-06-21 21:55:32 +03:00
Aleksei Pavlov 174c3a17c1 feat(autogen): next_topic в статусе — следующая тема для каждой категории 2026-06-21 21:38:12 +03:00
Aleksei Pavlov 1ced06fa2d fix(autogen): pg_advisory_lock + catch-up + double-check after lock
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 приводил к тому что черновики зависали навсегда.
2026-06-21 21:30:06 +03:00
Aleksei Pavlov 630287f02f feat(autogen/queue): queue = черновики сегодня (планируется завтра) 2026-06-21 21:17:09 +03:00
Aleksei Pavlov f40bb27953 fix(autogen/status): показываем topic_count_free/total из blog_topics
getAutogenStatus теперь считает:
  - topic_count_free: свободных тем для генерации
  - topic_count: всего тем в банке
  - drafts_today: черновиков созданных за 24ч
  - cat_icon, cat_color: из таблицы categories

Убрана ссылка на несуществующую content_queue (queue_count).
2026-06-21 21:08:29 +03:00
Aleksei Pavlov 48e0bae495 fix(autogen/topics): endpoint читает из blog_topics БД вместо хардкода TOPIC_BANK
GET /api/autogen/topics теперь возвращает темы из blog_topics (свободные,
sorted by priority DESC), backward-compat: возвращает строки как раньше.
Fallback на TOPIC_BANK если для категории нет тем в БД.
2026-06-21 21:00:08 +03:00
Aleksei Pavlov 45c3f2b562 fix(blog-topics): ai.chat returns object not string — extract .text before .replace
После обновления ai.js модуль возвращает { text, usage } вместо строки.
result.replace() падало с 'not a function'. Теперь берём .text если объект.
2026-06-21 20:57:28 +03:00
Aleksei Pavlov c920d3bcd5 fix(autogen): атомарный захват темы — исключает дубли статей
Проблема: одна и та же тема из 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() на весь массив) — более равномерное распределение.
2026-06-21 16:47:49 +03:00
Aleksei Pavlov 214bf307c7 fix(draftAutoApprove): строго вчерашние черновики, LIMIT 4, не больше слотов
Проблема: брали черновики за 36ч → при нескольких деплоях за день или при
накоплении старых черновиков draftAutoApprove раскладывал их все по слотам
подряд → очередь на несколько дней вместо 1.

Исправления:
  - WHERE created_at AT TIME ZONE 'Europe/Moscow' вчерашний день (00:00-23:59)
  - LIMIT 4 — не больше количества слотов в день
  - раскладываем только min(drafts, freeSlots) черновиков, лишние остаются draft
  - если черновиков > слотов — пишем в лог предупреждение
2026-06-21 16:42:37 +03:00
Aleksei Pavlov 799816f66a fix(drafts): авто-одобрение только вчерашних черновиков → слоты сегодняшнего дня
Было: runDraftAutoApprove брал ВСЕ черновики подряд и вызывал nextDripSlot()
для каждого — слоты набивались на несколько дней вперёд.

Теперь:
- берём только черновики созданные за последние 36 часов (вчерашние)
- слоты — только сегодняшний день (getTodaySlots из SITE_PUBLISH_SLOTS)
- занятые слоты пропускаем, не затираем уже запланированное
- если слотов меньше черновиков — ставим оставшихся в последний слот

Механика как у заметок Зеро:
  17:00 → генерация 4 черновиков (лежат в /admin/drafts, можно редактировать)
  07:00 след.дня → авто-одобрение, слоты сегодняшнего дня (08:11/12:11/16:11/20:11)
  Ручное одобрение → nextDripSlot (ближайший свободный)
2026-06-21 16:36:47 +03:00
Aleksei Pavlov 749d717a94 feat(publish): синхрон сайт/ТГ + защита от залпа + заметки Зеро по механике черновиков
ЭТАП 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.
2026-06-20 11:07:10 +03:00
Aleksei Pavlov a09ee4a5fb refactor(tg): единый модуль tgSend для всех публикаций в Telegram
Проблема: 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).
2026-06-20 10:43:50 +03:00
Aleksei Pavlov 325ebe7759 fix(zero): back to multipart photo upload (engine now has uploads volume)
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.
2026-06-20 10:26:13 +03:00
Aleksei Pavlov bdff84e579 feat(articles): drip scheduling — distribute published_at across day slots
Зачем: автоген генерит несколько статей подряд (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.
2026-06-19 13:01:36 +03:00
Aleksei Pavlov b02bdba4e6 fix(zero): URL-mode photo sending + static AVAILABLE_POSES (no fs check)
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).
2026-06-19 12:17:53 +03:00
Aleksei Pavlov 7b115deaa1 feat(blog-topics): PATCH endpoint + soften requireAdmin + dynamic category name
- 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.
2026-06-19 12:05:04 +03:00
Aleksei Pavlov 59e604a67b feat(categories): CRUD endpoints + is_active for archiving
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.
2026-06-19 11:55:04 +03:00
Aleksei Pavlov 2f7af84ddc fix(zero): add missing 'settings' require in scheduler 2026-06-19 11:19:24 +03:00
Aleksei Pavlov 4ffadc6baa feat(zero): /config endpoints + dynamic scheduler hours
- 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)
2026-06-19 11:16:58 +03:00
Aleksei Pavlov 29788a8f9d feat(zero): Zero notes — AI persona for @zeropostru daily posts
Adds character-driven AI-generated notes pipeline parallel to articles:
  - migration: zero_notes table (channel-scoped, status flow draft→approved→published)
  - services/zeroPrompt.js — 12 theme buckets, few-shots, anti-repeat by bucket,
    sha256 theme_hash for dedup
  - services/zeroNotes.js — generateDraft/approve/skip/edit/autoApproveOldDrafts
  - services/zeroNotesRunner.js — TG publication with multipart pose images,
    FOR UPDATE SKIP LOCKED claim, retry up to 3 attempts
  - workers/zeroNotesScheduler.js — 13:00 MSK generate, 07:00 MSK auto-approve,
    publish runner every 60s
  - routes/zero.js (public, no secret) — character bio + published notes for site
  - routes/zeroAdmin.js — full CRUD + manual generate button + regenerate

Settings (app_settings):
  ZERO_NOTES_CHANNEL_IDS  — csv int, channels to post for (required to enable)
  ZERO_NOTES_MODEL        — defaults to AI_MODEL_POST
  ZERO_SITE_URL_BASE      — optional, adds 'Open on site' inline button
2026-06-19 10:52:31 +03:00
Nik (Claude) 5b5f703078 fix: avoid repeating last 3 rubrics in cover selection (no more similar covers) 2026-06-19 01:09:52 +03:00
Nik (Claude) eca072a172 fix: start autogen scheduler in index.js (was missing, drafts never generated) 2026-06-19 00:55:48 +03:00
Nik (Claude) 707047a7af fix: scheduleForArticle picks unique slots, no collisions 2026-06-18 12:25:09 +03:00
Nik (Claude) 08a2628824 feat: add Dockerfile for Coolify deployment (volumes support) 2026-06-18 12:12:32 +03:00
Nik (Claude) efe85632f5 fix: use .png instead of .webp for Telegram photo URL 2026-06-17 09:27:25 +03:00
Nik (Claude) c147c9e839 fix: start scheduledPostsRunner in index.js 2026-06-17 09:06:28 +03:00
Nik (Claude) dccb662298 fix: move approve-all before /:id routes to prevent NaN routing conflict 2026-06-16 23:11:14 +03:00
Nik (Claude) f9d1deae58 fix: initialize usedPath in covers.js to prevent ReferenceError 2026-06-16 22:58:28 +03:00
Nik (Claude) 0a842476d7 fix: reduce article maxTokens 4000→3000 to avoid 400 on aiprimetech 2026-06-16 22:08:44 +03:00
Nik (Claude) e5965e2804 feat: POST /api/articles/:id/regenerate-cover for any status 2026-06-16 22:02:34 +03:00
Nik (Claude) d9cbbc5fbf fix: button always shows DEFAULT_BUTTON_TEXT when field is null/empty 2026-06-16 21:14:57 +03:00
Nik (Claude) 5852b9f439 feat: draft review flow — autogen→draft, auto-approve 07:00 MSK, /api/drafts routes 2026-06-16 09:17:10 +03:00
Nik (Claude) cd471d67a9 add start script for Coolify nixpacks 2026-06-15 22:44:05 +03:00
Alexey Pavlov bede92a520 feat: post image diversity — style rotation + random scene/concept + expanded AI concepts bank 2026-06-15 10:20:22 +03:00
Nik (Claude) 525870c709 chore: add full schema dump as source of truth (32 tables) 2026-06-15 09:28:13 +03:00
Ник (Claude) 31b31b75b8 fix: post images diversity — SUBJECT+SETTING+LIGHTING prompt
Та же проблема что и у обложек статей:
  старый промт '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 символов поста → детерминировано но уникально
2026-06-14 15:13:20 +03:00
Ник (Claude) c40ef90ad1 feat: SMTP, maintenance mode, blog topic bank UI
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)
2026-06-13 11:45:23 +03:00
Ник (Claude) 9b40f2cd7a feat: content defaults — applied on channel creation
DB: app_settings category=content (10 keys)
channels.js: createChannel reads DEFAULT_* from settings and applies to new channel
  DEFAULT_POST_LANGUAGE/LENGTH/STYLE/GOAL → channel.goal, language
  DEFAULT_IMAGE_ENABLED → channel.image_enabled
  DEFAULT_AI_STYLE_PROMPT → channel.ai_style_prompt
  DEFAULT_AUTO_DRAFT_COUNT/TIME → channel.auto_draft_count/time
channels: image_enabled BOOLEAN DEFAULT true
2026-06-13 11:22:08 +03:00
Ник (Claude) b5fa77ea01 feat: autogen blog admin API
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 — удалить тему
2026-06-13 10:35:50 +03:00
Ник (Claude) 6e1cd24b4e feat: error logs API
routes/admin.js: GET /logs — объединённые ошибки из 3 источников:
  generation_jobs (status=failed), ai_usage (!succeeded), scheduled_posts (status=failed)
  Сортировка по времени, топ-5 частых ошибок, группировка по типу
2026-06-13 10:23:24 +03:00
36 changed files with 5575 additions and 328 deletions
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-alpine
WORKDIR /app
# Зависимости
COPY package*.json ./
RUN npm ci --omit=dev
# Код
COPY . .
# Папка для uploads (будет примонтирована через volume)
RUN mkdir -p /var/www/zeropost-uploads
EXPOSE 3030
ENV PORT=3030
ENV NODE_ENV=production
CMD ["node", "index.js"]
+106
View File
@@ -0,0 +1,106 @@
# HANDOFF — ZeroPost + PostCast сессия 2026-06-19..24
## Состояние на момент завершения
### Серверы
- **prod2** (80.93.52.241): ZeroPost + PostCast + Fermiq + CRM4Auto + Gitea
- **Диск prod2**: 37GB used / 59GB (66%) — норма
- **newvps** (80.93.60.231): AgroTO production
---
## ZeroPost (zeropost.ru)
### Git commits финальные
- **zeropost-engine**: `e1f2f44` (fix MAX platform API)
- **zeropost-web**: `6f7c47a` (hero + metadata + OG для всех категорий)
### Что работает
- ✅ Автогенерация: 8 категорий, ротация 4 из 8 каждый день (dayOfYear % 8)
- ✅ Слоты публикации: 08:11/12:11/16:11/20:11 синхронны сайт ↔ ТГ
- ✅ Заметки Зеро: генерация 13:00 → подтверждение → публикация след. день 13:00
- ✅ tgSend.js — единый модуль отправки в TG (multipart для локальных файлов)
- ✅ Volume uploads примонтирован в engine (23+ позы Зеро видны)
- ✅ draftAutoApprove: только вчерашние черновики LIMIT 4, catch-up при старте
- ✅ pg_advisory_lock в autogen — нет дублей статей
- ✅ Защита от залпа: skip posts > 3ч, 1 пост за тик
### 8 категорий ZeroPost
| slug | name | генерация |
|------|------|-----------|
| ai-tools | ИИ-инструменты | 17:00 |
| cybersec | Кибербезопасность | 17:05 |
| automation | Автоматизация | 17:10 |
| ai-dev | Разработка с ИИ | 17:15 |
| comparisons | Сравнения | 17:20 |
| tutorials | Туториалы | 17:25 |
| opinions | Мнения | 17:30 |
| digest | Дайджест | 17:35 |
### Банк тем ZeroPost
- ai-tools: 23 свободных, ai-dev: 26, automation: 22, cybersec: 21
- comparisons/tutorials/opinions/digest: ~15 каждый
- Жанровые маркеры: [ТУТОРИАЛ][СРАВНЕНИЕ][МНЕНИЕ][ДАЙДЖЕСТ]
### Настройки в app_settings (ZeroPost БД)
- SITE_PUBLISH_SLOTS: 08:11,12:11,16:11,20:11
- ZERO_SITE_URL_BASE: https://zeropost.ru/zero
- ZERO_NOTES_APPROVE_HOUR: 9
- ZERO_NOTES_GENERATE_HOUR: 13
- ZERO_NOTES_PUBLISH_HOUR: 13
---
## PostCast (postcast.ru)
### Git commits финальные
- **postcast-engine**: `4ec3239` — система категорий + банк тем + ротация
- **postcast-tool**: `cdd507f` — AutogenTab UI
### Что сделано в эту сессию
- ✅ Новые таблицы в БД: channel_categories, category_topics, channel_autogen_settings, best_time_stats
- ✅ posts: +source_topic, +source_category_id, +genre
- ✅ Engine: новый autogenNew.js с ротацией + pg_advisory_lock
- ✅ Engine routes: /api/channels/:id/categories + /api/channels/:id/autogen
- ✅ Tool: AutogenTab.js — полноценная вкладка «Автогенерация»
- ✅ Tool: /api/engine/channels/[channelId]/[[...path]] — catch-all proxy
- ✅ PostCast tool: вкладка «Автогенерация» в ChannelView
### PostCast архитектура
- Engine: port 3035, uuid rkvh8gvwydl9y9cgy0vuhjuq, DB: postcast@10.0.1.24
- Tool: port 3043, uuid c12chhkedih62hviw2uk4o93
- ENGINE_URL в tool: http://host.docker.internal:3035
### Что НЕ сделано (следующая сессия)
1. **Аналитика лучшего времени** — таблица best_time_stats создана, нужен воркер + UI
2. **Inbox + AI-ответы** — inbox_messages таблица есть, нужен webhook + classifier
3. **Мультиканальность** — один пост → адаптация под TG/VK/MAX (transform endpoint)
4. **Онбординг** — помощь новому пользователю настроить первый канал
5. **Scheduler PostCast** — запуск autogen по расписанию (сейчас только ручной)
---
## Инфраструктура
### prod2 — важные константы
- Coolify API token: `5|jWMs5bZf25KUKNEXWVOOCu5BBXO29OEmHeqTSqte3875ea84`
- ZeroPost engine uuid: `gtqe11a2cc6klt1ew9078fdn`
- ZeroPost web uuid: `y4iqlg41hpvl8tcs20wc720y`
- PostCast engine uuid: `rkvh8gvwydl9y9cgy0vuhjuq`
- PostCast tool uuid: `c12chhkedih62hviw2uk4o93`
- ENGINE_SECRET: `zeropost_internal_2026`
- ADMIN_PASSWORD (ZeroPost web): `ZeroPost2026!`
### Uploads volume
- Хост: /var/www/zeropost-uploads (481 файл включая zero-*.webp)
- ZeroPost engine: примонтирован в /var/www/zeropost-uploads ✅
- Coolify local_persistent_volumes id=14 (resource_type исправлен)
### Fermiq backups (prod2)
- /etc/cron.d/farm-backups: daily без фото (~23MB), weekly с фото (~1.5GB)
- Retention: daily 3 дня, weekly 28 дней
### Замеченные технические долги
- PostCast tool Coolify deploy сломан (SSH key issue) — деплоить через git pull + npm run build + docker restart
- whisper.cpp удалён с prod2 (был 2.1GB от AgroTO)
- ZeroPost web коммит d5a0fb2 в Coolify — не та версия (использовать manual build если нужно)
+9
View File
@@ -0,0 +1,9 @@
# Russian Trusted CA bundle
Сертификаты Минцифры (Root CA + Sub CA 2024), необходимые для TLS-соединения
с `platform-api2.max.ru` после миграции MAX 19.07.2026.
Используется через `NODE_EXTRA_CA_CERTS=/app/russian_trusted_bundle.pem`
(переменная задана в Coolify UI приложения).
Источник: https://gu-st.ru/content/lending/
+152
View File
@@ -0,0 +1,152 @@
// draftAutoApprove.js
// Каждый день в 07:00 МСК переводит черновики вчерашней генерации → 'published'
// и ставит их в слоты ТЕКУЩЕГО дня (08:11/12:11/16:11/20:11).
//
// Механика (как у заметок Зеро):
// 17:00 — автогенерация создаёт 4 черновика (по 1 на категорию)
// 07:00 след.дня — авто-одобрение: берём только черновики созданные ВЧЕРА,
// раскладываем по слотам сегодняшнего дня по порядку
// В любой момент — редактор может одобрить черновик вручную (кнопка в /admin/drafts)
// тогда статья получает ближайший свободный слот сегодня
const { query } = require('./src/config/db');
const { scheduleForArticle } = require('./src/services/articleAutoPublish');
const AUTO_APPROVE_HOUR_MSK = 7;
let lastRunDate = null;
/**
* Возвращает слоты сегодняшнего дня в UTC (из SITE_PUBLISH_SLOTS настройки).
* Слоты заданы в МСК (UTC+3).
*/
async function getTodaySlots() {
const { rows } = await query(
`SELECT value FROM app_settings WHERE key='SITE_PUBLISH_SLOTS' LIMIT 1`
);
const raw = rows[0]?.value || '08:11,12:11,16:11,20:11';
const parts = raw.split(',').map(s => s.trim()).filter(Boolean);
const now = new Date();
// Сегодня в МСК
const mskNow = new Date(now.getTime() + 3 * 60 * 60 * 1000);
const y = mskNow.getUTCFullYear();
const m = mskNow.getUTCMonth();
const d = mskNow.getUTCDate();
return parts.map(p => {
const [h, min] = p.split(':').map(Number);
// Конвертируем МСК → UTC (MSK = UTC+3)
const slot = new Date(Date.UTC(y, m, d, h - 3, min, 0, 0));
return slot;
}).sort((a, b) => a - b);
}
async function runDraftAutoApprove() {
try {
// Берём черновики созданные ВЧЕРА по МСК (между 00:00 и 23:59 вчера).
// Строго только вчерашние — чтобы при повторном деплое/рестарте не захватить
// старые черновики и не создать очередь на несколько дней.
const { rows: drafts } = await query(
`SELECT id, title, category, created_at FROM articles
WHERE status='draft'
AND created_at AT TIME ZONE 'Europe/Moscow' >= (CURRENT_DATE - INTERVAL '1 day')::date
AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date
ORDER BY created_at ASC
LIMIT 4`
);
if (!drafts.length) {
console.log('[DraftApprove] нет черновиков для авто-одобрения');
return;
}
console.log(`[DraftApprove] авто-одобряем ${drafts.length} черновиков → слоты сегодняшнего дня`);
// Получаем слоты сегодняшнего дня
const todaySlots = await getTodaySlots();
const now = new Date();
// Проверяем какие слоты уже заняты (pending или published сегодня)
const { rows: takenRows } = await query(
`SELECT scheduled_at FROM scheduled_posts
WHERE status IN ('pending','sent','sending')
AND scheduled_at >= NOW()::date
AND scheduled_at < (NOW()::date + INTERVAL '1 day')`,
);
const takenTimes = new Set(takenRows.map(r => new Date(r.scheduled_at).getTime()));
// Ищем свободные слоты (только в будущем и не занятые)
const freeSlots = todaySlots.filter(s => s > now && !takenTimes.has(s.getTime()));
if (freeSlots.length === 0) {
console.log('[DraftApprove] все сегодняшние слоты заняты — черновики остаются');
return;
}
if (drafts.length > freeSlots.length) {
console.log(`[DraftApprove] черновиков ${drafts.length} > свободных слотов ${freeSlots.length} — лишние останутся в draft`);
}
// Раскладываем черновики по свободным слотам (1 к 1, не больше числа слотов)
for (let i = 0; i < Math.min(drafts.length, freeSlots.length); i++) {
const draft = drafts[i];
const slot = freeSlots[i];
await query(
`UPDATE articles SET status='published', published_at=$2 WHERE id=$1`,
[draft.id, slot]
);
await scheduleForArticle(draft.id);
const mskLabel = slot.toLocaleString('ru-RU', {
timeZone: 'Europe/Moscow',
day: '2-digit', month: '2-digit',
hour: '2-digit', minute: '2-digit',
});
console.log(`[DraftApprove] article=${draft.id} "${draft.title.slice(0, 50)}" → ${mskLabel} МСК`);
}
} catch (err) {
console.error('[DraftApprove] error:', err.message);
}
}
function startDraftAutoApproveScheduler() {
console.log('[DraftApprove] scheduler started (auto-approve at 07:00 MSK, только вчерашние черновики)');
// Catch-up при старте: если сейчас уже после AUTO_APPROVE_HOUR_MSK —
// проверить есть ли вчерашние черновики которые пропустили тик.
(async () => {
const nowMsk = new Date(Date.now() + 3 * 60 * 60 * 1000);
const hourMsk = nowMsk.getUTCHours();
if (hourMsk >= AUTO_APPROVE_HOUR_MSK) {
try {
const { rows: missed } = await query(`
SELECT COUNT(*) AS cnt FROM articles
WHERE status='draft'
AND created_at AT TIME ZONE 'Europe/Moscow' >= (CURRENT_DATE - INTERVAL '1 day')::date
AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date
`);
if (parseInt(missed[0]?.cnt, 10) > 0) {
console.log('[DraftApprove] catch-up при старте: найдены пропущенные вчерашние черновики (' + missed[0].cnt + ' шт)');
await runAutoApprove();
}
} catch (err) { console.error('[DraftApprove] catch-up error:', err.message); }
}
})();
setInterval(() => {
const now = new Date();
const msk = new Date(now.getTime() + 3 * 60 * 60 * 1000);
const hour = msk.getUTCHours();
const minute = msk.getUTCMinutes();
const dateStr = msk.toISOString().slice(0, 10);
if (hour === AUTO_APPROVE_HOUR_MSK && minute === 0 && lastRunDate !== dateStr) {
lastRunDate = dateStr;
console.log(`[DraftApprove] triggered at ${msk.toISOString()}`);
runDraftAutoApprove();
}
}, 60_000);
}
module.exports = { startDraftAutoApproveScheduler, runDraftAutoApprove };
+40
View File
@@ -11,7 +11,9 @@ const statsRoutes = require('./src/routes/stats');
const notesRoutes = require('./src/routes/notes');
const seriesRoutes = require('./src/routes/series');
const categoriesRoutes = require('./src/routes/categories');
const categoriesAdminRoutes = require('./src/routes/categoriesAdmin');
const autogenRoutes = require('./src/routes/autogen');
const draftsRoutes = require('./src/routes/drafts');
const userPostsRoutes = require('./src/routes/userPosts');
const settingsRoutes = require('./src/routes/settings');
const photoSearchRoutes = require('./src/routes/photo-search');
@@ -29,6 +31,20 @@ require('./src/services/metricsCollector').startAutoCollect();
const app = express();
app.use(express.json());
// ── Maintenance mode middleware ──────────────────────────────
app.use((req, res, next) => {
// Пропускаем статику и admin endpoints (чтобы можно было отключить режим)
if (req.path.startsWith('/uploads') || req.path.startsWith('/api/settings')) return next();
const settings = require('./src/services/settings');
settings.get('MAINTENANCE_MODE', 'false').then(val => {
if (val === 'true' && !req.headers['x-internal-secret']) {
settings.get('MAINTENANCE_MESSAGE', 'Ведутся технические работы').then(msg => {
res.status(503).json({ error: msg, code: 'MAINTENANCE' });
});
} else next();
}).catch(() => next());
});
// Раздача загруженных файлов (обложки статей и т.п.)
const path = require('path');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
@@ -60,6 +76,9 @@ app.post('/api/billing/webhook',
const inboxRoutes = require('./src/routes/inbox');
app.use('/api', inboxRoutes); // включает /api/tg-webhook/:channelId
// Заметки Зеро — публичная часть (для сайта zeropost.ru/zero)
app.use('/api/zero', require('./src/routes/zero'));
// Simple internal auth middleware
app.use((req, res, next) => {
const secret = req.headers['x-internal-secret'];
@@ -96,6 +115,7 @@ app.use('/api/notes', notesRoutes);
app.use('/api/series', seriesRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/autogen', autogenRoutes);
app.use('/api/drafts', draftsRoutes);
app.use('/api/user-posts', userPostsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/photo-search', photoSearchRoutes);
@@ -110,6 +130,10 @@ app.use('/api/channels', require('./src/routes/polls'));
app.use('/api', inboxRoutes);
app.use('/api', require('./src/routes/drafts'));
// Заметки Зеро — админская часть (за internal-secret middleware)
app.use('/api/admin/zero', require('./src/routes/zeroAdmin'));
app.use('/api/admin/categories', categoriesAdminRoutes);
app.get('/health', (req, res) => {
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
});
@@ -122,6 +146,19 @@ const start = async () => {
// Автоматический ретрай SVG-заглушек
require('./src/services/coverRetry').start();
// Авто-одобрение черновиков в 07:00 МСК
require('./draftAutoApprove').startDraftAutoApproveScheduler();
// Публикация scheduled_posts (каждую минуту)
const { runScheduled } = require('./src/services/scheduledPostsRunner');
setInterval(() => runScheduled().catch(err => console.error('[Runner] error:', err.message)), 60_000);
console.log('[Runner] Scheduled posts runner started');
// Автогенерация черновиков (каждую минуту проверяем расписание)
const { runAutogen } = require('./src/services/autogen');
setInterval(() => runAutogen().catch(err => console.error('[Autogen] error:', err.message)), 60_000);
console.log('[Autogen] Scheduler started');
// Ежедневные авто-черновики (каждые 30 мин проверяем каналы с auto_draft_enabled)
const draftSvc = require('./src/services/draftService');
setInterval(async () => {
@@ -133,6 +170,9 @@ const start = async () => {
// Первый запуск через 5 мин после старта
setTimeout(() => draftSvc.generateDailyDrafts().catch(() => {}), 5 * 60 * 1000);
// Заметки Зеро — генерация в 13:00 МСК + авто-одобрение в 07:00 МСК
require('./src/workers/zeroNotesScheduler').start();
app.listen(config.port, () => {
console.log(`[Engine] Running on port ${config.port}`);
});
+10
View File
@@ -18,6 +18,7 @@
"fast-xml-parser": "^4.5.6",
"ioredis": "^5.11.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.11",
"pg": "^8.21.0",
"sharp": "^0.34.5"
}
@@ -1661,6 +1662,15 @@
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+3 -1
View File
@@ -4,7 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js"
},
"repository": {
"type": "git",
@@ -23,6 +24,7 @@
"fast-xml-parser": "^4.5.6",
"ioredis": "^5.11.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.11",
"pg": "^8.21.0",
"sharp": "^0.34.5"
}
+80
View File
@@ -0,0 +1,80 @@
process.chdir('/var/www/zeropost-engine');
require('dotenv').config();
const { query } = require('./src/config/db');
const ai = require('./src/services/ai');
const DELAY_MS = 3000;
const TIMEOUT_MS = 120000; // 2 мин на статью
const START_FROM_ID = parseInt(process.argv[2] || '0'); // можно передать start id
const sleep = ms => new Promise(r => setTimeout(r, ms));
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), ms))
]);
}
function parseContent(content, topic) {
const lines = content.split('\n').filter(Boolean);
let title = topic;
const h1 = lines.find(l => l.startsWith('# '));
if (h1) title = h1.replace(/^#\s+/, '').trim();
const firstPara = lines.find(l => l.length > 80 && !l.startsWith('#'));
const excerpt = firstPara ? firstPara.substring(0, 200) + (firstPara.length > 200 ? '...' : '') : '';
const wordCount = content.replace(/<[^>]+>/g, '').split(/\s+/).length;
const readingTime = Math.max(1, Math.round(wordCount / 200));
return { title, excerpt, readingTime };
}
async function regenArticle(article, blogChannel) {
const topic = article.source_topic || article.title;
try {
const result = await withTimeout(
ai.generateArticle(blogChannel, { topic }),
TIMEOUT_MS
);
if (!result?.content) return false;
const { title, excerpt, readingTime } = parseContent(result.content, topic);
await query(
`UPDATE articles SET title=$1, content=$2, excerpt=$3, reading_time=$4,
seo_title=$5, seo_descr=$6, updated_at=NOW() WHERE id=$7`,
[title, result.content, excerpt, readingTime,
title.substring(0,60), excerpt.substring(0,160), article.id]
);
console.log(`[Regen] OK id=${article.id} "${title.slice(0,60)}"`);
return true;
} catch (err) {
console.error(`[Regen] ERROR id=${article.id}: ${err.message}`);
return false;
}
}
async function main() {
const { rows: articles } = await query(
`SELECT id, title, source_topic, category FROM articles
WHERE status='published' ${START_FROM_ID ? `AND id >= ${START_FROM_ID}` : ''}
ORDER BY id ASC`
);
const { rows: channels } = await query(
`SELECT * FROM channels WHERE is_system=true AND is_active=true LIMIT 1`
);
console.log(`[Regen] ${articles.length} articles to process (from id=${START_FROM_ID || 'start'})`);
let ok = 0, fail = 0;
for (let i = 0; i < articles.length; i++) {
console.log(`[Regen] ${i+1}/${articles.length} id=${articles[i].id}`);
const success = await regenArticle(articles[i], channels[0]);
if (success) ok++; else fail++;
if (i < articles.length - 1) await sleep(DELAY_MS);
}
console.log(`[Regen] DONE: ${ok} OK, ${fail} FAILED`);
process.exit(0);
}
main().catch(e => { console.error(e); process.exit(1); });
+73
View File
@@ -0,0 +1,73 @@
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIG6DCCBNCgAwIBAgICEAUwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjQwNzE1MTI1MDQxWhcNMjkwNzE5MTI1MDQxWjBvMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi
IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1j0rkZECOt1S8o7I
JY+4YKAxuEa5xaHKHXT2EpkuC/0krqMOjUy2oPIRNgR5g8X0Jl6jamxeGLc4Q1tf
ju6or9oSRYThIUhRsFDQNBiBBEXoBgWxTfiKB2eyT97+pz5TBtBiRCPaLGRHYLRb
9Jz2HkJlxbtNPjtDrF5DPHym+mZ1M1z3hIQYAqJwLpsEBnsw/VxWMlxqHoeewd0h
uJMd71KQ5vOKlz7KrIZ6EobNNa6wItuvsfj3kYCK7O78uLHGXXFxdr8Hae9lMUmC
8F7AFwa+bO1LRlTlqW7rE3rLf+jj70N01N8T3o22v14YBaFBWQWncAVYD2JuL3tH
252+kdNOERf1fLbLRigJAbd+hOhWYlNf963TFDgnNPliHNIW72SygVBnI2V3JwO1
dp1hVKpK/zt8ziGdHW4gmOLTsH50YKdR4jNqUgQv4wASlKn9OpN6zHYc5G8h86fY
BM+zxE5ikGI+I/vIqBuI0eaDU92AWN/YjFLpu8tMu9kLRSCf1vug6FIfDPWVo7iP
ac/SI2v8jnnpaW7ph/Pz3WkzaG7ZZJsfFs+8dploWc6LOoDtbFBhMdGMxu024msC
0PSjZb5ODXPIaO2NsA7fMiAtZcoK6anTUJh4zOP/stA9qsJGNxdrEmiPXSmBZY/N
Y0wkZgZ6JTDhw7038bPvctkblJkCAwEAAaOCAYswggGHMB0GA1UdDgQWBBR3Pdk5
r0K93FvKduru/c4+YSkwXzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qws
hzAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADCBmAYIKwYBBQUH
AQEEgYswgYgwQAYIKwYBBQUHMAKGNGh0dHA6Ly9udWMtY2RwLnZvc2tob2QucnUv
Y2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQwRAYIKwYBBQUHMAKGOGh0dHA6Ly9u
dWMtY2RwLmRpZ2l0YWwuZ292LnJ1L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3J0
MIGFBgNVHR8EfjB8MDqgOKA2hjRodHRwOi8vbnVjLWNkcC52b3NraG9kLnJ1L2Nk
cC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMD6gPKA6hjhodHRwOi8vbnVjLWNkcC5k
aWdpdGFsLmdvdi5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG
9w0BAQsFAAOCAgEAmsINXtQ7wwUWvIeOr80MdJS/5G4xhyZOVEmeUorThquT672y
cCg3XCxc4fwbiZqSSbBqntQ7RtiTAKMYMvBageKoVHbzz+R4jX01tKcTx8cDePrz
dJ73bLNUorE7RU9QsW4KyiUeRmjMDV23AUlEvuQFTwgkHXvbac1BBdPn9CrssQuF
5EGohZKcQPFiAAc4SHbRNhlr7uAwgpc/erzI9EAcvA6BVAXcVKoeGpV01uexUgZ6
St5RP9UmDWNA7T4yVXWJ233N0Q8bl+6AswINQ3PosPu6yQQHQjr65YS06epK+AeI
6j+oGR4xI7EhTQhQvaobnGmX/8QQ7XDRYCP2HXYxiffnn/CfZ/BVyKLYeY1ZipjE
nzqdQIC2+Q3WtY8jsVRQMP38WFRmtsIt5snehnPTs5bKGVIcYzj3o3Ex/K7agEz0
zAJ0JR5ivXZOvNkT0g9x1v+S1IkU3e/nX1a+tpRquMtnHX0L2lXArNHUbaOO9EJt
d57WaIpofV5cVhhwShOgAuBc9UMJF3/n4t4RKiPxtsK8P67gcmphMhslj7AMYrYM
ej2NvQZY4m3ub3CPC/PrTjDONvb+8g5xrKtxBjYqC74HSB4dg9G3WimSDUuP2Su6
G2y2TUeyJuCvCLz289VoO0vg7cNdMobE3KCqAiiNhN2VBFxHAUKmUoRcRdw=
-----END CERTIFICATE-----
+38
View File
@@ -157,6 +157,36 @@ const migrate = async () => {
);
`);
// zero_notes — короткие заметки от AI-персонажа "Зеро"
// Программист с юмором, любит кофе. Постит 1 раз в день в 13:00 МСК.
// Поток: 13:00 генерится → вечером ручная проверка → 07:00 auto-approve → 13:00 публикация
await query(`
CREATE TABLE IF NOT EXISTS zero_notes (
id SERIAL PRIMARY KEY,
channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE,
content TEXT NOT NULL, -- 50-150 слов от первого лица
theme VARCHAR(500), -- о чём заметка (для логов и дедупа)
theme_bucket VARCHAR(50), -- ведро темы: bugs/tools/ai/coffee/musing/story/...
theme_hash VARCHAR(64), -- нормализованный хэш темы (sha256 первых 8 значимых слов)
pose VARCHAR(50), -- имя позы Зеро (zero-{pose}.webp)
image_url TEXT, -- /uploads/zero-{pose}.webp или внешний URL
status VARCHAR(20) DEFAULT 'draft', -- draft/approved/scheduled/published/failed/skipped
scheduled_at TIMESTAMPTZ, -- когда уйдёт в канал (13:00 МСК следующего дня)
approved_at TIMESTAMPTZ,
approved_by VARCHAR(100), -- 'auto' (07:00 cron) или email редактора
published_at TIMESTAMPTZ,
channel_message_id BIGINT, -- id сообщения в TG после публикации
tokens_in INTEGER,
tokens_out INTEGER,
model VARCHAR(100), -- какой моделью сгенерили
attempts INTEGER DEFAULT 0, -- сколько раз пытались опубликовать
error TEXT,
generation_meta JSONB DEFAULT '{}'::jsonb, -- доп. контекст: использованные триггеры позы, recentThemes hash и т.п.
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
`);
// series — тематические серии статей
await query(`
CREATE TABLE IF NOT EXISTS series (
@@ -185,8 +215,16 @@ const migrate = async () => {
CREATE INDEX IF NOT EXISTS idx_articles_status_pub ON articles(status, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_pub ON editor_notes(is_published, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug);
CREATE INDEX IF NOT EXISTS idx_zero_notes_status_sched ON zero_notes(status, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_zero_notes_channel ON zero_notes(channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_zero_notes_theme_hash ON zero_notes(theme_hash);
CREATE INDEX IF NOT EXISTS idx_zero_notes_published ON zero_notes(published_at DESC) WHERE status='published';
`);
// safe column alters (existing tables on prod may lack newer columns)
await query(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true`);
console.log('[DB] Migrations applied');
};
File diff suppressed because it is too large Load Diff
+325 -2
View File
@@ -9,11 +9,19 @@ const { query } = require('../config/db');
function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; }
async function requireAdmin(req, res) {
// x-internal-secret уже проверен глобальным middleware (см. index.js).
// Опционально — если есть users.is_admin (multi-user окружение, как на dev2) — проверим;
// если колонки нет (минимальный prod), доверяем секрету.
const adminId = uid(req);
if (!adminId) { res.status(401).json({ error: 'x-user-id required' }); return null; }
if (!adminId) return 'system';
try {
const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]);
if (!u?.is_admin) { res.status(403).json({ error: 'Forbidden' }); return null; }
if (u && u.is_admin === false) { res.status(403).json({ error: 'Forbidden' }); return null; }
return adminId;
} catch (err) {
// колонки is_admin нет — это нормально для prod конфига
return adminId;
}
}
// GET /api/admin/dashboard
@@ -326,3 +334,318 @@ router.delete('/queue/stuck', async (req, res) => {
res.json({ ok: true, cleared: rows.length });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── ERROR LOGS ───────────────────────────────────────────────
// GET /api/admin/logs — последние ошибки из всех источников
router.get('/logs', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const limit = Math.min(parseInt(req.query.limit || 50), 200);
try {
const [genFailed, aiErrors, scheduledFailed] = await Promise.all([
// Ошибки генерации
query(`
SELECT
'generation' as source,
j.id::text as entity_id,
j.type as operation,
j.error as message,
j.topic as context,
u.email as user_email,
j.created_at
FROM generation_jobs j
LEFT JOIN users u ON u.id = j.user_id
WHERE j.status = 'failed' AND j.error IS NOT NULL
ORDER BY j.created_at DESC LIMIT $1
`, [limit]),
// Ошибки AI провайдеров
query(`
SELECT
'ai_provider' as source,
id::text as entity_id,
(provider || '/' || request_type) as operation,
error_message as message,
left(model, 60) as context,
NULL as user_email,
created_at
FROM ai_usage
WHERE NOT succeeded AND error_message IS NOT NULL
ORDER BY created_at DESC LIMIT $1
`, [limit]),
// Ошибки публикации постов
query(`
SELECT
'publish' as source,
sp.id::text as entity_id,
(c.platform || ' publish') as operation,
'Failed scheduled post' as message,
left(sp.custom_text, 60) as context,
NULL as user_email,
sp.scheduled_at as created_at
FROM scheduled_posts sp
JOIN channels c ON c.id = sp.channel_id
WHERE sp.status = 'failed'
ORDER BY sp.scheduled_at DESC LIMIT 20
`),
]);
// Объединяем и сортируем
const all = [
...genFailed.rows,
...aiErrors.rows,
...scheduledFailed.rows,
].sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, limit);
// Группируем по типу ошибки для статистики
const errorGroups = {};
for (const e of all) {
const key = e.message?.split('\n')[0]?.slice(0, 80) || 'unknown';
errorGroups[key] = (errorGroups[key] || 0) + 1;
}
const topErrors = Object.entries(errorGroups)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([msg, cnt]) => ({ msg, cnt }));
res.json({ errors: all, total: all.length, topErrors });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── AUTOGEN BLOG ─────────────────────────────────────────────
// GET /api/admin/autogen — статус автогенерации блога
router.get('/autogen', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const { getAutogenStatus, TOPIC_BANK } = require('../services/autogen');
const status = await getAutogenStatus();
// Статистика статей по категориям за последние 7 дней
const { rows: recentStats } = await query(`
SELECT category, count(*)::int as cnt_7d,
max(created_at) as last_article_at
FROM articles
WHERE status='published' AND created_at > NOW() - INTERVAL '7 days'
GROUP BY category
`);
const byCategory = Object.fromEntries(recentStats.map(r => [r.category, r]));
// Очередь тем
const { rows: queueItems } = await query(
`SELECT * FROM content_queue ORDER BY priority DESC, created_at ASC LIMIT 20`
);
const topicBankSizes = Object.fromEntries(
Object.entries(TOPIC_BANK).map(([k, v]) => [k, v.length])
);
res.json({ settings: status, byCategory, queue: queueItems, topicBankSizes });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// PATCH /api/admin/autogen/:category — обновить настройки категории
router.patch('/autogen/:category', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const { enabled, per_day, run_hour, run_minute } = req.body;
const fields = []; const vals = []; let i = 1;
if (enabled !== undefined) { fields.push(`enabled=$${i++}`); vals.push(enabled); }
if (per_day !== undefined) { fields.push(`per_day=$${i++}`); vals.push(per_day); }
if (run_hour !== undefined) { fields.push(`run_hour=$${i++}`); vals.push(run_hour); }
if (run_minute !== undefined) { fields.push(`run_minute=$${i++}`); vals.push(run_minute); }
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(req.params.category);
await query(`UPDATE autogen_settings SET ${fields.join(',')} WHERE category=$${i}`, vals);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/admin/autogen/:category/run — запустить генерацию вручную
router.post('/autogen/:category/run', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
res.json({ ok: true, message: `Генерация категории ${req.params.category} запущена` });
const { runAutogenForCategory } = require('../services/autogen');
runAutogenForCategory(req.params.category).catch(e =>
console.error(`[Autogen manual] ${req.params.category}: ${e.message}`)
);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/admin/autogen/queue — добавить тему в очередь
router.post('/autogen/queue', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { category, topic, tags = [], keywords = [], priority = 5 } = req.body;
if (!category || !topic) return res.status(400).json({ error: 'category и topic обязательны' });
try {
const { rows: [item] } = await query(
`INSERT INTO content_queue (category, topic, tags, keywords, priority)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[category, topic, JSON.stringify(tags), JSON.stringify(keywords), priority]
);
res.json(item);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// DELETE /api/admin/autogen/queue/:id
router.delete('/autogen/queue/:id', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
await query('DELETE FROM content_queue WHERE id=$1', [req.params.id]);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── EMAIL ────────────────────────────────────────────────────
// POST /api/admin/email/test — тестовая отправка
router.post('/email/test', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { to } = req.body;
if (!to) return res.status(400).json({ error: 'to обязателен' });
try {
const email = require('../services/emailService');
const result = await email.send({
to,
subject: '✅ ZeroPost SMTP тест',
html: '<p>Если ты видишь это письмо — SMTP настроен правильно!</p>',
text: 'Если ты видишь это письмо — SMTP настроен правильно!',
});
if (result.skipped) return res.json({ ok: false, message: 'SMTP отключён или не настроен' });
if (result.error) return res.status(500).json({ error: result.error });
res.json({ ok: true, messageId: result.messageId });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── BLOG TOPIC BANK ──────────────────────────────────────────
// GET /api/admin/blog-topics — список тем по категории
router.get('/blog-topics', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { category, includeUsed = 'false', limit = 100 } = req.query;
try {
const where = category ? 'WHERE bt.category=$1' : '';
const args = category ? [category] : [];
const { rows } = await query(`
SELECT bt.*,
EXISTS(SELECT 1 FROM articles a WHERE a.source_topic=bt.topic) as is_published
FROM blog_topics bt
${where}
${includeUsed !== 'true' ? (where ? 'AND' : 'WHERE') + ' bt.is_used=false' : ''}
ORDER BY bt.priority DESC, bt.created_at ASC
LIMIT ${parseInt(limit)}
`, args);
// Статистика по категориям
const { rows: stats } = await query(`
SELECT category,
count(*)::int as total,
count(*) FILTER (WHERE is_used=false)::int as unused
FROM blog_topics GROUP BY category
`);
res.json({ topics: rows, stats });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/admin/blog-topics — добавить тему
router.post('/blog-topics', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { category, topic, tags = [], priority = 5 } = req.body;
if (!category || !topic) return res.status(400).json({ error: 'category и topic обязательны' });
try {
const { rows: [row] } = await query(
`INSERT INTO blog_topics (category, topic, tags, priority, source)
VALUES ($1,$2,$3,$4,'manual') ON CONFLICT DO NOTHING RETURNING *`,
[category, topic.trim(), tags, priority]
);
res.json(row || { error: 'Такая тема уже есть' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// PATCH /api/admin/blog-topics/:id — обновить тему (topic, tags, priority, is_used)
router.patch('/blog-topics/:id', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { topic, tags, priority, is_used } = req.body || {};
const fields = [];
const vals = [];
if (topic !== undefined) { fields.push(`topic=$${fields.length+1}`); vals.push(String(topic).trim()); }
if (tags !== undefined) { fields.push(`tags=$${fields.length+1}`); vals.push(Array.isArray(tags) ? tags : []); }
if (priority !== undefined) { fields.push(`priority=$${fields.length+1}`); vals.push(parseInt(priority, 10) || 5); }
if (is_used !== undefined) { fields.push(`is_used=$${fields.length+1}`); vals.push(!!is_used); }
if (!fields.length) return res.status(400).json({ error: 'нечего обновлять' });
vals.push(parseInt(req.params.id, 10));
try {
const { rows: [row] } = await query(
`UPDATE blog_topics SET ${fields.join(', ')} WHERE id=$${vals.length} RETURNING *`,
vals
);
if (!row) return res.status(404).json({ error: 'не найдено' });
res.json({ ok: true, topic: row });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// DELETE /api/admin/blog-topics/:id
router.delete('/blog-topics/:id', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
await query('DELETE FROM blog_topics WHERE id=$1', [req.params.id]);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/admin/blog-topics/generate — AI генерация новых тем для категории
router.post('/blog-topics/generate', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { category, count = 10 } = req.body;
if (!category) return res.status(400).json({ error: 'category обязателен' });
try {
res.json({ ok: true, message: `Генерирую ${count} тем для ${category}...` });
// Берём уже существующие темы для дедупликации
const { rows: existing } = await query('SELECT topic FROM blog_topics WHERE category=$1', [category]);
const existingTopics = existing.map(r => r.topic).join('\n');
const ai = require('../services/ai');
const config = require('../config');
// Имя и описание категории берём из БД (а не hardcoded)
const { rows: [catRow] } = await query(
'SELECT name, description FROM categories WHERE slug=$1',
[category]
);
const catName = catRow?.name || category;
const catDescr = catRow?.description ? ` (${catRow.description})` : '';
const system = `Ты редактор tech-блога. Генерируй темы для статей категории "${catName}"${catDescr}.
Темы должны быть: конкретными, практическими, интересными читателям.
Формат: точные заголовки статей, не категории.
Ответь ТОЛЬКО JSON-массивом строк без markdown.`;
const userMsg = `Придумай ${count} уникальных тем.${existingTopics ? `\n\nИзбегай повторений:\n${existingTopics.slice(0,800)}` : ''}`;
const aiResult = await ai.chat(
config.ai.models.topics || 'claude-haiku-4-5-20251001',
system, userMsg, 0.9, 600
);
// ai.chat возвращает { text, usage } или строку (backward compat)
const rawText = typeof aiResult === 'string' ? aiResult : aiResult.text;
const topics = JSON.parse(rawText.replace(/```json|```/g, '').trim());
let added = 0;
for (const topic of topics.slice(0, count)) {
if (!topic?.trim()) continue;
const { rows: [row] } = await query(
`INSERT INTO blog_topics (category, topic, source)
VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING RETURNING id`,
[category, topic.trim()]
);
if (row) added++;
}
console.log(`[BlogTopics] AI generated ${added} topics for ${category}`);
} catch (err) { console.error(`[BlogTopics] generate error: ${err.message}`); }
});
+25
View File
@@ -204,3 +204,28 @@ router.get('/:slug', async (req, res) => {
});
module.exports = router;
// POST /api/articles/:id/regenerate-cover — перегенерация обложки для любой статьи
router.post('/:id/regenerate-cover', async (req, res) => {
try {
const id = parseInt(req.params.id);
const { rows } = await query('SELECT id, title, tags FROM articles WHERE id=$1', [id]);
if (!rows.length) return res.status(404).json({ error: 'Article not found' });
await query('UPDATE articles SET cover_url=NULL WHERE id=$1', [id]);
const covers = require('../services/covers');
const art = rows[0];
const coverUrl = await covers.generateCover({
articleId: id,
title: art.title,
tags: art.tags || [],
channelId: 1,
});
if (coverUrl) await query('UPDATE articles SET cover_url=$1 WHERE id=$2', [coverUrl, id]);
res.json({ ok: true, cover_url: coverUrl });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
+65 -14
View File
@@ -40,40 +40,91 @@ router.patch('/settings/:category', async (req, res) => {
});
// GET /api/autogen/queue — очередь тем
// GET /api/autogen/queue — черновики созданные сегодня (планируется завтра)
router.get('/queue', async (_, res) => {
try {
const { rows } = await query(
`SELECT * FROM content_queue ORDER BY priority DESC, created_at ASC LIMIT 100`
);
const { rows } = await query(`
SELECT a.id, a.category, a.status, a.title, a.cover_url,
a.created_at, a.published_at,
c.name AS cat_name, c.icon AS cat_icon, c.color AS cat_color
FROM articles a
LEFT JOIN categories c ON c.slug = a.category
WHERE a.status = 'draft'
AND a.created_at >= CURRENT_DATE
ORDER BY a.category, a.created_at DESC
`);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/autogen/queue — добавить тему в очередь
// POST /api/autogen/queue — добавить тему в blog_topics (быстрый приоритет p9)
router.post('/queue', async (req, res) => {
try {
const { category, topic, tags = [], keywords = [], priority = 5 } = req.body;
const { category, topic, priority = 9 } = req.body;
if (!category || !topic) return res.status(400).json({ error: 'category and topic required' });
const { rows } = await query(
`INSERT INTO content_queue (category, topic, tags, keywords, priority)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[category, topic, JSON.stringify(tags), JSON.stringify(keywords), priority]
`INSERT INTO blog_topics (category, topic, source, priority, is_used)
VALUES ($1,$2,'manual',$3,false) ON CONFLICT DO NOTHING RETURNING *`,
[category, topic, priority]
);
res.json(rows[0]);
res.json(rows[0] || { ok: true, note: 'already exists' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// DELETE /api/autogen/queue/:id
// DELETE /api/autogen/queue/:id — удалить черновик статьи
router.delete('/queue/:id', async (req, res) => {
try {
await query(`DELETE FROM content_queue WHERE id=$1`, [req.params.id]);
const { rows: [art] } = await query(
`SELECT id FROM articles WHERE id=$1 AND status='draft'`,
[parseInt(req.params.id, 10)]
);
if (!art) return res.status(404).json({ error: 'draft not found' });
await query(`DELETE FROM articles WHERE id=$1 AND status='draft'`, [parseInt(req.params.id, 10)]);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/autogen/topics — банк тем
router.get('/topics', async (_, res) => {
res.json(TOPIC_BANK);
// GET /api/autogen/topics — банк тем из БД (с fallback на TOPIC_BANK)
// ?category=slug — только одна категория
// ?limit=N — максимум N тем на категорию (default 20)
// ?free=true — только неиспользованные (default true)
router.get('/topics', async (req, res) => {
try {
const { query } = require('../config/db');
const onlyFree = req.query.free !== 'false';
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
const catFilter = req.query.category || null;
const { rows } = await query(`
SELECT category, topic, priority, is_used, source
FROM blog_topics
WHERE ($1::text IS NULL OR category = $1)
AND ($2 = false OR is_used = false)
ORDER BY category, priority DESC, created_at ASC
LIMIT $3
`, [catFilter, onlyFree, limit * 10]); // берём с запасом, потом нарежем
// Группируем по категории, ограничиваем до limit на категорию
const grouped = {};
for (const row of rows) {
if (!grouped[row.category]) grouped[row.category] = [];
if (grouped[row.category].length < limit) {
grouped[row.category].push(row.topic);
}
}
// Если для какой-то категории нет тем в БД — fallback на TOPIC_BANK
for (const [cat, bank] of Object.entries(TOPIC_BANK)) {
if (!grouped[cat] || grouped[cat].length === 0) {
grouped[cat] = [...bank];
}
}
res.json(grouped);
} catch (err) {
console.error('[autogen/topics]', err.message);
res.json(TOPIC_BANK); // fallback
}
});
module.exports = router;
+2 -2
View File
@@ -5,7 +5,7 @@ const { query } = require('../config/db');
// GET /api/categories
router.get('/', async (_, res) => {
try {
const { rows } = await query('SELECT * FROM categories ORDER BY sort_order');
const { rows } = await query("SELECT * FROM categories WHERE COALESCE(is_active, true) = true ORDER BY sort_order");
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -17,7 +17,7 @@ router.get('/:slug/articles', async (req, res) => {
const offset = parseInt(req.query.offset) || 0;
const { rows } = await query(
`SELECT id,slug,title,excerpt,cover_url,tags,category,author,reading_time,published_at
FROM articles WHERE status='published' AND category=$1
FROM articles WHERE status='published' AND published_at <= NOW() AND category=$1
ORDER BY published_at DESC LIMIT $2 OFFSET $3`,
[req.params.slug, limit, offset]
);
+169
View File
@@ -0,0 +1,169 @@
/**
* CRUD для категорий — /api/admin/categories/*
*
* Auth: глобальный x-internal-secret middleware (см. index.js).
*
* При создании категории автоматически создаём строку в autogen_settings
* с дефолтами (enabled=true, per_day=1, run_hour=12, run_minute=0).
*
* Удаление: soft через is_active=false (чтобы не сломать существующие статьи).
* Hard-delete доступен только если у категории нет статей и тем (?force=true).
*/
const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const ALLOWED_COLORS = ['emerald', 'red', 'amber', 'blue', 'purple', 'pink', 'cyan', 'orange', 'lime', 'rose', 'slate', 'neutral'];
function validateSlug(s) {
if (!s || typeof s !== 'string') return 'slug обязателен';
if (!/^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$/.test(s)) {
return 'slug может содержать только латиницу, цифры и тире (2-50 символов)';
}
return null;
}
function sanitize(body) {
const out = {};
if (body.slug !== undefined) out.slug = String(body.slug || '').trim().toLowerCase();
if (body.name !== undefined) out.name = String(body.name || '').trim().slice(0, 100);
if (body.description !== undefined) out.description = String(body.description || '').trim().slice(0, 500);
if (body.icon !== undefined) out.icon = String(body.icon || '').trim().slice(0, 10);
if (body.color !== undefined) {
const c = String(body.color || '').trim().toLowerCase();
out.color = ALLOWED_COLORS.includes(c) ? c : 'emerald';
}
if (body.sort_order !== undefined) out.sort_order = Math.max(0, parseInt(body.sort_order, 10) || 0);
if (body.is_active !== undefined) out.is_active = !!body.is_active;
return out;
}
// GET /api/admin/categories — все категории + счётчики
router.get('/', async (_req, res) => {
try {
const { rows } = await query(`
SELECT
c.id, c.slug, c.name, c.description, c.icon, c.color, c.sort_order, c.is_active,
(SELECT COUNT(*) FROM articles a WHERE a.category = c.slug AND a.status='published') AS article_count,
(SELECT COUNT(*) FROM blog_topics bt WHERE bt.category = c.slug) AS topic_count,
(SELECT COUNT(*) FROM blog_topics bt WHERE bt.category = c.slug AND bt.is_used=false) AS topic_unused_count,
s.enabled AS autogen_enabled, s.per_day, s.run_hour, s.run_minute, s.last_run_at
FROM categories c
LEFT JOIN autogen_settings s ON s.category = c.slug
ORDER BY c.sort_order, c.id
`);
res.json({ ok: true, items: rows, count: rows.length });
} catch (err) {
console.error('[admin/categories GET] error:', err);
res.status(500).json({ error: err.message });
}
});
// POST /api/admin/categories — создать
router.post('/', async (req, res) => {
try {
const data = sanitize(req.body || {});
const slugErr = validateSlug(data.slug);
if (slugErr) return res.status(400).json({ error: slugErr });
if (!data.name) return res.status(400).json({ error: 'name обязателен' });
// Проверим уникальность slug
const { rows: existing } = await query('SELECT id FROM categories WHERE slug=$1', [data.slug]);
if (existing.length) return res.status(409).json({ error: `Категория с slug "${data.slug}" уже существует` });
// Создаём категорию
const { rows: [created] } = await query(`
INSERT INTO categories (slug, name, description, icon, color, sort_order, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [
data.slug,
data.name,
data.description || null,
data.icon || '📝',
data.color || 'emerald',
data.sort_order ?? 99,
data.is_active ?? true,
]);
// Авто-создаём autogen_settings (если не существует)
await query(`
INSERT INTO autogen_settings (category, enabled, per_day, run_hour, run_minute)
VALUES ($1, true, 1, 12, 0)
ON CONFLICT (category) DO NOTHING
`, [data.slug]);
res.status(201).json({ ok: true, category: created });
} catch (err) {
console.error('[admin/categories POST] error:', err);
res.status(500).json({ error: err.message });
}
});
// PATCH /api/admin/categories/:id — обновить (slug менять нельзя — он связан с articles)
router.patch('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'bad id' });
const data = sanitize(req.body || {});
delete data.slug; // slug менять нельзя — он foreign key для articles, blog_topics, autogen_settings
const keys = Object.keys(data);
if (!keys.length) return res.status(400).json({ error: 'нечего обновлять' });
const setSql = keys.map((k, i) => `${k} = $${i + 1}`).join(', ');
const values = keys.map(k => data[k]);
values.push(id);
const { rows: [updated] } = await query(
`UPDATE categories SET ${setSql} WHERE id = $${values.length} RETURNING *`,
values
);
if (!updated) return res.status(404).json({ error: 'category not found' });
res.json({ ok: true, category: updated });
} catch (err) {
console.error('[admin/categories PATCH] error:', err);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/admin/categories/:id — soft (is_active=false), либо hard если ?force=true и нет связей
router.delete('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'bad id' });
const force = req.query.force === 'true';
const { rows: [cat] } = await query('SELECT * FROM categories WHERE id=$1', [id]);
if (!cat) return res.status(404).json({ error: 'category not found' });
if (force) {
// Hard delete — но только если ничего не привязано
const { rows: [{ cnt: articles }] } = await query(
`SELECT COUNT(*)::int AS cnt FROM articles WHERE category=$1`, [cat.slug]
);
const { rows: [{ cnt: topics }] } = await query(
`SELECT COUNT(*)::int AS cnt FROM blog_topics WHERE category=$1`, [cat.slug]
);
if (articles > 0 || topics > 0) {
return res.status(409).json({
error: `Нельзя удалить полностью: ${articles} статей, ${topics} тем привязано к "${cat.slug}". Используй архивацию (is_active=false).`,
});
}
await query(`DELETE FROM autogen_settings WHERE category=$1`, [cat.slug]);
await query(`DELETE FROM categories WHERE id=$1`, [id]);
return res.json({ ok: true, deleted: 'hard' });
}
// Soft delete = архивация
await query(`UPDATE categories SET is_active=false WHERE id=$1`, [id]);
res.json({ ok: true, deleted: 'soft' });
} catch (err) {
console.error('[admin/categories DELETE] error:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+124 -74
View File
@@ -1,101 +1,151 @@
/**
* drafts.js — API для черновиков постов.
*
* POST /api/channels/:channelId/drafts/generate?count=3 — batch генерация
* GET /api/drafts — все черновики юзера
* GET /api/drafts/:channelId/channel — черновики канала
* PATCH /api/drafts/:id — редактировать текст
* POST /api/drafts/:id/approve — одобрить → scheduled_post
* POST /api/drafts/:id/reject — отклонить
* DELETE /api/drafts/:id — удалить
*/
// Роуты для работы с черновиками (draft review flow)
const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const channelsSvc = require('../services/channels');
const draftSvc = require('../services/draftService');
function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; }
// POST /api/channels/:channelId/drafts/generate
router.post('/channels/:channelId/drafts/generate', async (req, res) => {
const userId = uid(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
const count = Math.min(parseInt(req.query.count || req.body.count || 3), 10);
const withImage = req.body.withImage !== false;
const { scheduleForArticle } = require('../services/articleAutoPublish');
const { nextDripSlot, describeNextSlot } = require('../services/dripScheduler');
const { generateCover, COVER_STYLES } = require('../services/covers');
// GET /api/drafts — список черновиков
router.get('/', async (req, res) => {
try {
const channel = await channelsSvc.getFullChannel(req.params.channelId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
// Запускаем асинхронно — отвечаем сразу
res.json({ ok: true, message: `Генерирую ${count} черновиков...`, count });
draftSvc.generateBatch(channel, { count, userId, withImage })
.then(r => console.log(`[drafts] batch done ch=${channel.id}: ${r.generated} ok, ${r.errors.length} err`))
.catch(e => console.error(`[drafts] batch error: ${e.message}`));
const { rows } = await query(
`SELECT id, slug, title, excerpt, cover_url, category, tags, reading_time, created_at
FROM articles WHERE status='draft'
ORDER BY created_at DESC`
);
res.json(rows);
} catch (err) {
if (!res.headersSent) res.status(500).json({ error: err.message });
res.status(500).json({ error: err.message });
}
});
// GET /api/drafts — все черновики текущего пользователя
router.get('/drafts', async (req, res) => {
const userId = uid(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
const { status = 'pending', limit = 30, offset = 0 } = req.query;
try {
const result = await draftSvc.listDrafts({ userId, status, limit: parseInt(limit), offset: parseInt(offset) });
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
// GET /api/drafts/cover-styles — доступные стили обложки
router.get('/cover-styles', (req, res) => {
res.json(COVER_STYLES.map(s => ({ id: s.name, name: s.name })));
});
// GET /api/drafts/:channelId/channel — черновики конкретного канала
router.get('/drafts/:channelId/channel', async (req, res) => {
const userId = uid(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
const { status = 'pending', limit = 30, offset = 0 } = req.query;
// PATCH /api/drafts/:id — редактировать черновик (title, content, excerpt)
router.patch('/:id', async (req, res) => {
try {
const result = await draftSvc.listDrafts({ channelId: req.params.channelId, status, limit: parseInt(limit), offset: parseInt(offset) });
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
const id = parseInt(req.params.id);
const { title, excerpt, content } = req.body;
const fields = [], vals = [];
if (title !== undefined) { fields.push(`title=$${vals.push(title)}`); }
if (excerpt !== undefined) { fields.push(`excerpt=$${vals.push(excerpt)}`); }
if (content !== undefined) { fields.push(`content=$${vals.push(content)}`); }
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(id);
const { rows } = await query(
`UPDATE articles SET ${fields.join(',')} WHERE id=$${vals.length} AND status='draft' RETURNING id, title, slug`,
vals
);
if (!rows.length) return res.status(404).json({ error: 'Draft not found' });
res.json({ ok: true, article: rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PATCH /api/drafts/:id — редактировать
router.patch('/drafts/:id', async (req, res) => {
// PATCH /api/drafts/:id/approve — одобрить черновик вручную.
// По умолчанию ставит published_at в следующий свободный slot (drip distribution).
// ?immediate=true — публикует сразу (published_at = NOW).
router.patch('/:id/approve', async (req, res) => {
try {
await draftSvc.updateDraft(req.params.id, req.body);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
const id = parseInt(req.params.id);
const immediate = req.query.immediate === 'true' || req.body?.immediate === true;
// POST /api/drafts/:id/approve
router.post('/drafts/:id/approve', async (req, res) => {
const userId = uid(req);
try {
const result = await draftSvc.approveDraft(req.params.id, {
scheduledAt: req.body.scheduled_at,
userId,
const slot = immediate ? new Date() : await nextDripSlot();
const { rows } = await query(
`UPDATE articles SET status='published', published_at=$2
WHERE id=$1 AND status='draft'
RETURNING id, title, slug, published_at`,
[id, slot]
);
if (!rows.length) return res.status(404).json({ error: 'Draft not found' });
const scheduled = await scheduleForArticle(id);
const channelSlot = scheduled[0]?.scheduled_at;
const mskLabel = slot.toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
console.log(`[DraftApprove] manual approve article=${id} "${rows[0].title.slice(0,50)}" → ${immediate ? 'NOW' : 'slot ' + mskLabel}`);
res.json({
ok: true,
article: rows[0],
published_at: slot,
published_at_msk: mskLabel,
immediate,
scheduled_at: channelSlot,
});
res.json({ ok: true, ...result });
} catch (err) { res.status(500).json({ error: err.message }); }
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/drafts/:id/reject
router.post('/drafts/:id/reject', async (req, res) => {
// GET /api/drafts/next-slot — посмотреть какой будет слот для следующего approve
router.get('/next-slot', async (_req, res) => {
try {
await draftSvc.rejectDraft(req.params.id);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
const info = await describeNextSlot();
res.json({ at: info.at, msk: info.mskLabel });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/drafts/:id
router.delete('/drafts/:id', async (req, res) => {
// POST /api/drafts/approve-all — авто-одобрение всех (ручной вызов)
router.post('/approve-all', async (req, res) => {
try {
await query('DELETE FROM post_drafts WHERE id=$1', [req.params.id]);
const { runDraftAutoApprove } = require('../../draftAutoApprove');
await runDraftAutoApprove();
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/drafts/:id/regenerate-cover — перегенерировать обложку
router.post('/:id/regenerate-cover', async (req, res) => {
try {
const id = parseInt(req.params.id);
const { style } = req.body; // необязательно
// Берём статью
const { rows: arts } = await query(
`SELECT id, title, tags FROM articles WHERE id=$1 AND status='draft'`, [id]
);
if (!arts.length) return res.status(404).json({ error: 'Draft not found' });
// Получаем системный канал
const { rows: chans } = await query(
`SELECT id FROM channels WHERE is_system=true AND is_active=true LIMIT 1`
);
const channelId = chans[0]?.id || null;
// Если передан style — временно форсируем через channel_style
if (style && channelId) {
await query(
`UPDATE channel_style SET image_style=$1 WHERE channel_id=$2`,
[style, channelId]
);
}
const coverUrl = await generateCover({
articleId: id,
title: arts[0].title,
tags: arts[0].tags || [],
channelId,
});
if (coverUrl) {
await query(`UPDATE articles SET cover_url=$1 WHERE id=$2`, [coverUrl, id]);
}
console.log(`[DraftRegenCover] article=${id} cover=${coverUrl?.split('/').pop()}`);
res.json({ ok: true, cover_url: coverUrl });
} catch (err) {
console.error('[DraftRegenCover] error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+1 -1
View File
@@ -87,7 +87,7 @@ router.post('/backfill-channel/:channelId', async (req, res) => {
let sql = `
SELECT a.id, a.slug, a.title, a.category, a.published_at
FROM articles a
WHERE a.status='published'
WHERE a.status='published' AND a.published_at <= NOW()
AND NOT EXISTS (
SELECT 1 FROM scheduled_posts sp
WHERE sp.channel_id=$1 AND sp.article_id=a.id AND sp.status IN ('pending','sent')
+63
View File
@@ -0,0 +1,63 @@
/**
* routes/zero.js — публичные роуты для сайта zeropost.ru/zero
* Монтируется на /api/zero
*
* Без аутентификации — отдаём только published.
*/
const express = require('express');
const router = express.Router();
const zeroNotes = require('../services/zeroNotes');
const zPrompt = require('../services/zeroPrompt');
// GET /api/zero/notes?limit=20&offset=0&channel_id=1
router.get('/notes', async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
const offset = parseInt(req.query.offset) || 0;
const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null;
const items = await zeroNotes.listPublished({ channelId, limit, offset });
res.json({ ok: true, items, limit, offset });
} catch (err) {
console.error('[GET /api/zero/notes]', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/zero/notes/:id — одна published заметка
router.get('/notes/:id', async (req, res) => {
try {
const note = await zeroNotes.getById(parseInt(req.params.id));
if (!note || note.status !== 'published') {
return res.status(404).json({ error: 'not found' });
}
// Отдаём только публичные поля
res.json({
ok: true,
note: {
id: note.id,
channel_id: note.channel_id,
content: note.content,
theme: note.theme,
theme_bucket: note.theme_bucket,
pose: note.pose,
image_url: note.image_url,
published_at: note.published_at,
channel_message_id: note.channel_message_id,
},
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/zero/character — bio и описание для блока "Кто такой Зеро"
router.get('/character', async (_req, res) => {
res.json({
ok: true,
character: zPrompt.CHARACTER,
buckets: zPrompt.THEME_BUCKETS.map(b => ({ key: b.key, label: b.label })),
});
});
module.exports = router;
+269
View File
@@ -0,0 +1,269 @@
/**
* routes/zeroAdmin.js — админские роуты для управления заметками Зеро.
* Монтируется на /api/admin/zero
*
* Auth: x-user-id header → users.is_admin = true (та же конвенция что в routes/admin.js)
*/
const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const zeroNotes = require('../services/zeroNotes');
const zPrompt = require('../services/zeroPrompt');
function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; }
async function requireAdmin(req, res) {
// x-internal-secret уже проверен глобальным middleware (см. index.js).
// Опционально — если есть users.is_admin (multi-user окружение, как на dev2) — проверим;
// если колонки нет (минимальный prod), доверяем секрету.
const adminId = uid(req);
if (!adminId) return 'system'; // нет header — доверяем секрету
try {
const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]);
if (u && u.is_admin === false) { res.status(403).json({ error: 'Forbidden' }); return null; }
return adminId;
} catch (err) {
// колонки is_admin нет — это нормально для prod конфига
return adminId;
}
}
// ───────────────────────────────────────────────────────────────────────────
// СПИСКИ
// ───────────────────────────────────────────────────────────────────────────
// GET /api/admin/zero/notes?status=draft&channel_id=1&limit=50
router.get('/notes', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const status = req.query.status || null;
const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null;
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const params = [limit];
const where = [];
if (status) { params.push(status); where.push(`status = $${params.length}`); }
if (channelId) { params.push(channelId); where.push(`channel_id = $${params.length}`); }
const sqlWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
const { rows } = await query(
`SELECT id, channel_id, content, theme, theme_bucket, theme_hash,
pose, image_url, status, scheduled_at, approved_at, approved_by,
published_at, channel_message_id, model, tokens_in, tokens_out,
attempts, error, created_at, updated_at
FROM zero_notes
${sqlWhere}
ORDER BY
CASE status
WHEN 'draft' THEN 1
WHEN 'approved' THEN 2
WHEN 'scheduled' THEN 3
WHEN 'published' THEN 4
WHEN 'failed' THEN 5
WHEN 'skipped' THEN 6
ELSE 7
END,
scheduled_at ASC NULLS LAST,
created_at DESC
LIMIT $1`,
params
);
res.json({ ok: true, items: rows, count: rows.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/admin/zero/notes/:id — детали одной заметки (любой статус)
router.get('/notes/:id', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const n = await zeroNotes.getById(parseInt(req.params.id));
if (!n) return res.status(404).json({ error: 'not found' });
res.json({ ok: true, note: n });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/admin/zero/buckets — список ведёр (для UI: dropdown "Сгенерить с ведром X")
router.get('/buckets', async (req, res) => {
if (!await requireAdmin(req, res)) return;
res.json({
ok: true,
buckets: zPrompt.THEME_BUCKETS.map(b => ({ key: b.key, label: b.label, examples: b.examples })),
});
});
// ───────────────────────────────────────────────────────────────────────────
// ГЕНЕРАЦИЯ (КНОПКА)
// ───────────────────────────────────────────────────────────────────────────
// POST /api/admin/zero/generate
// body: { channel_id: int (обязательно), force_bucket?: string, allow_today_dup?: bool }
router.post('/generate', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const channelId = parseInt(req.body?.channel_id);
const forceBucket = req.body?.force_bucket || null;
const allowDup = !!req.body?.allow_today_dup;
if (!Number.isFinite(channelId)) {
return res.status(400).json({ error: 'channel_id required' });
}
// Если уже есть draft сегодня — по умолчанию запрещаем (защита от случайных кликов),
// но админ может передать allow_today_dup=true чтобы всё-таки сгенерить
if (!allowDup && await zeroNotes.hasDraftToday(channelId)) {
return res.status(409).json({
error: 'draft уже создан сегодня — передай allow_today_dup=true, чтобы пересоздать',
});
}
const draft = await zeroNotes.generateDraft(channelId, { forceBucket, allowDup });
if (!draft) {
return res.status(409).json({ error: 'не удалось создать draft (возможно дубль)' });
}
res.json({ ok: true, note: draft });
} catch (err) {
console.error('[POST /api/admin/zero/generate]', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/admin/zero/notes/:id/regenerate
// body: { force_bucket?: string } — стирает старый draft и создаёт новый
router.post('/notes/:id/regenerate', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id);
const old = await zeroNotes.getById(id);
if (!old) return res.status(404).json({ error: 'not found' });
if (!['draft', 'failed'].includes(old.status)) {
return res.status(400).json({ error: `нельзя перегенерить заметку в статусе ${old.status}` });
}
// Удаляем старый и генерим новый
await query('DELETE FROM zero_notes WHERE id=$1', [id]);
const fresh = await zeroNotes.generateDraft(old.channel_id, {
forceBucket: req.body?.force_bucket || null,
});
res.json({ ok: true, note: fresh, replaced: id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ───────────────────────────────────────────────────────────────────────────
// WORKFLOW
// ───────────────────────────────────────────────────────────────────────────
// PATCH /api/admin/zero/notes/:id — редактирование
router.patch('/notes/:id', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id);
const updated = await zeroNotes.editContent(id, {
content: req.body?.content,
theme: req.body?.theme,
pose: req.body?.pose,
imageUrl: req.body?.image_url,
scheduledAt: req.body?.scheduled_at ? new Date(req.body.scheduled_at) : undefined,
});
if (!updated) return res.status(404).json({ error: 'not found' });
res.json({ ok: true, note: updated });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/admin/zero/notes/:id/approve — ручное одобрение
router.post('/notes/:id/approve', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const adminId = uid(req);
const { rows: [u] } = await query('SELECT email FROM users WHERE id=$1', [adminId]);
const updated = await zeroNotes.approveManual(parseInt(req.params.id), u?.email || `admin#${adminId}`);
if (!updated) return res.status(404).json({ error: 'not found or wrong status' });
res.json({ ok: true, note: updated });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/admin/zero/notes/:id/skip — пропустить (не публиковать)
router.post('/notes/:id/skip', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const updated = await zeroNotes.skipNote(parseInt(req.params.id), req.body?.reason || 'skipped by admin');
if (!updated) return res.status(404).json({ error: 'not found or wrong status' });
res.json({ ok: true, note: updated });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/admin/zero/auto-approve — ручной триггер auto-approve (для тестов)
router.post('/auto-approve', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const rows = await zeroNotes.autoApproveOldDrafts();
res.json({ ok: true, approved: rows.length, ids: rows.map(r => r.id) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ───────────────────────────────────────────────────────────────────────────
// CONFIG (app_settings)
// ───────────────────────────────────────────────────────────────────────────
const settings = require('../services/settings');
const CONFIG_KEYS = [
{ key: 'ZERO_NOTES_CHANNEL_IDS', default: '', description: 'csv int id каналов, для которых работают заметки Зеро' },
{ key: 'ZERO_NOTES_MODEL', default: '', description: 'модель для генерации (пусто = AI_MODEL_POST)' },
{ key: 'ZERO_NOTES_GENERATE_HOUR', default: '13', description: 'час генерации в МСК (0-23)' },
{ key: 'ZERO_NOTES_APPROVE_HOUR', default: '7', description: 'час авто-одобрения в МСК (0-23)' },
{ key: 'ZERO_NOTES_PUBLISH_HOUR', default: '13', description: 'час публикации в МСК (0-23) — определяет scheduled_at' },
{ key: 'ZERO_SITE_URL_BASE', default: '', description: 'для inline-кнопки "Открыть на сайте"' },
{ key: 'ZERO_PUBLIC_BASE_URL', default: 'https://zeropost.ru', description: 'база URL для картинок (Telegram скачает по этому URL)' },
];
router.get('/config', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const out = {};
for (const { key, default: def } of CONFIG_KEYS) {
out[key] = await settings.get(key, def);
}
out._enabled = !!(out.ZERO_NOTES_CHANNEL_IDS && String(out.ZERO_NOTES_CHANNEL_IDS).trim());
out._keys_meta = CONFIG_KEYS;
res.json({ ok: true, config: out });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.patch('/config', async (req, res) => {
if (!await requireAdmin(req, res)) return;
try {
const body = req.body || {};
const allowed = new Set(CONFIG_KEYS.map(k => k.key));
const updated = {};
for (const [k, v] of Object.entries(body)) {
if (!allowed.has(k)) continue;
const value = v == null ? null : String(v);
await query(
`INSERT INTO app_settings (key, value, category, description, updated_at)
VALUES ($1, $2, 'zero_notes', $3, NOW())
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()`,
[k, value, CONFIG_KEYS.find(c => c.key === k)?.description || null]
);
updated[k] = value;
}
settings.invalidate();
res.json({ ok: true, updated });
} catch (err) { res.status(500).json({ error: err.message }); }
});
module.exports = router;
+2 -2
View File
@@ -250,7 +250,7 @@ async function generateArticle(channel, opts = {}) {
config.ai.models.article,
systemPrompt,
userPrompt,
{ maxTokens: 4000, temperature: 0.85 }
{ maxTokens: 3000, temperature: 0.85 }
);
if (!useEditPass) {
@@ -277,7 +277,7 @@ async function generateArticle(channel, opts = {}) {
config.ai.models.article,
editorPrompt,
`Вот черновик для редактуры:\n\n${draft.text}`,
{ maxTokens: 4000, temperature: 0.75 }
{ maxTokens: 3000, temperature: 0.75 }
);
return {
+34 -39
View File
@@ -1,50 +1,45 @@
// Авто-публикация статей в каналы.
//
// СИНХРОНИЗАЦИЯ С САЙТОМ (главный принцип):
// TG-пост ставится на ТО ЖЕ время, когда статья появляется на сайте —
// на articles.published_at. Сайт и Telegram больше не разъезжаются.
// (published_at выставляется dripScheduler'ом при approve черновика —
// в слоты SITE_PUBLISH_SLOTS = 08:11/12:11/16:11/20:11.)
//
// Логика:
// 1. При сохранении статьи со status='published' — engine вызывает scheduleForArticle(articleId)
// 2. Находим все системные каналы с auto_publish_enabled=true где (categories пустой ИЛИ категория статьи там есть)
// 3. Для каждого канала ищем ближайший подходящий момент:
// - если delay_min > 0 → now + delay_min
// - иначе — ближайший publish_slot канала в будущем
// - если у канала нет слотов и delay=0 — публикуем сразу (scheduled_at = NOW)
// 4. Дедуп: один article × один channel = одна запись в scheduled_posts (skip если уже есть pending/sent)
// 5. Создаём scheduled_posts с pending status — runner отработает по cron'у
// 1. При публикации статьи engine вызывает scheduleForArticle(articleId)
// 2. Находим системные каналы с auto_publish_enabled=true где категория подходит
// 3. scheduled_at = articles.published_at (если оно в будущем),
// иначе now + delay_min (или NOW). Слоты канала publish_slots больше
// НЕ используются для выбора времени — единый источник это published_at.
// 4. Дедуп: один article × один channel = одна запись (skip если pending/sent)
// 5. Раннер (scheduledPostsRunner) отправит когда scheduled_at <= NOW,
// с защитой от залпа (пропускает посты старше SKIP_OLDER_THAN_H часов).
const { query } = require('../config/db');
/**
* Подобрать ближайший момент публикации для канала.
* Момент публикации в TG = момент появления на сайте (published_at статьи).
* Если published_at не задан/в прошлом — используем delay_min или NOW.
* @param {object} channel
* @param {object} article — должен содержать published_at
* @returns Date
*/
async function pickScheduleTime(channel) {
async function pickScheduleTime(channel, article) {
const now = new Date();
// Главный путь: синхрон с сайтом — ставим на published_at статьи
if (article && article.published_at) {
const pub = new Date(article.published_at);
if (pub > now) return pub; // статья выйдет на сайте в будущем → TG тогда же
return now; // статья уже на сайте → публикуем в TG сейчас
}
// Fallback (нет published_at): прежнее поведение через delay
if (channel.auto_publish_delay_min > 0) {
return new Date(now.getTime() + channel.auto_publish_delay_min * 60_000);
}
// Ищем publish_slots
const { rows: slots } = await query(
`SELECT slot_hour, slot_minute FROM publish_slots
WHERE channel_id=$1 AND enabled=true
ORDER BY slot_hour, slot_minute`,
[channel.id]
);
if (slots.length === 0) {
return now; // публикуем сразу
}
// Сегодня — ближайший слот с временем > now
const todayMinutes = now.getHours() * 60 + now.getMinutes();
const futureToday = slots.find(s => s.slot_hour * 60 + s.slot_minute > todayMinutes);
if (futureToday) {
const t = new Date(now);
t.setHours(futureToday.slot_hour, futureToday.slot_minute, 0, 0);
return t;
}
// Все слоты на сегодня прошли — берём первый завтрашний
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(slots[0].slot_hour, slots[0].slot_minute, 0, 0);
return tomorrow;
return now;
}
/**
@@ -54,7 +49,7 @@ async function pickScheduleTime(channel) {
*/
async function scheduleForArticle(articleId) {
const { rows: arts } = await query(
`SELECT id, slug, title, category, status FROM articles WHERE id=$1`,
`SELECT id, slug, title, category, status, published_at FROM articles WHERE id=$1`,
[articleId]
);
if (!arts.length || arts[0].status !== 'published') return [];
@@ -72,23 +67,23 @@ async function scheduleForArticle(articleId) {
const created = [];
for (const ch of channels) {
// Дедуп
// Дедуп — одна запись на (channel, article) в активных статусах
const { rows: existing } = await query(
`SELECT id FROM scheduled_posts
WHERE channel_id=$1 AND article_id=$2 AND status IN ('pending','sent')
WHERE channel_id=$1 AND article_id=$2 AND status IN ('pending','sent','sending')
LIMIT 1`,
[ch.id, article.id]
);
if (existing.length) continue;
const scheduledAt = await pickScheduleTime(ch);
const scheduledAt = await pickScheduleTime(ch, article);
const { rows: inserted } = await query(
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
VALUES ($1,$2,$3,'pending') RETURNING *`,
[ch.id, article.id, scheduledAt]
);
created.push(inserted[0]);
console.log(`[auto-publish] article=${article.id} → channel=${ch.id} at ${scheduledAt.toISOString()}`);
console.log(`[auto-publish] article=${article.id} → channel=${ch.id} at ${scheduledAt.toISOString()} (synced to published_at)`);
}
return created;
}
+7 -7
View File
@@ -35,7 +35,7 @@ function estimateReadingTime(text) {
*/
async function listArticles({ limit = 20, offset = 0, tag = null, category = null } = {}) {
let sql = `SELECT id, slug, title, excerpt, cover_url, tags, category, author, reading_time, published_at
FROM articles WHERE status='published'`;
FROM articles WHERE status='published' AND published_at <= NOW()`;
const params = [];
if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; }
if (category) { params.push(category); sql += ` AND category=$${params.length}`; }
@@ -50,7 +50,7 @@ async function getArticleBySlug(slug) {
`SELECT a.*, j.tokens_in, j.tokens_out
FROM articles a
LEFT JOIN generation_jobs j ON j.id = a.job_id
WHERE a.slug=$1 AND a.status='published'`,
WHERE a.slug=$1 AND a.status='published' AND a.published_at <= NOW()`,
[slug]
);
if (!rows.length) return null;
@@ -62,7 +62,7 @@ async function getArticleBySlug(slug) {
async function getAllTags() {
const { rows } = await query(
`SELECT DISTINCT jsonb_array_elements_text(tags) as tag, COUNT(*) as cnt
FROM articles WHERE status='published'
FROM articles WHERE status='published' AND published_at <= NOW()
GROUP BY tag ORDER BY cnt DESC LIMIT 30`
);
return rows;
@@ -207,7 +207,7 @@ async function getHomeArticles() {
// Hero — самая свежая опубликованная статья с обложкой
const heroRes = await query(
`${select} FROM articles
WHERE status='published' AND cover_url IS NOT NULL
WHERE status='published' AND published_at <= NOW() AND cover_url IS NOT NULL
ORDER BY published_at DESC LIMIT 1`
);
const hero = heroRes.rows[0] || null;
@@ -219,7 +219,7 @@ async function getHomeArticles() {
SELECT ${select.replace('SELECT ', '')},
ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn
FROM articles
WHERE status='published' AND id <> $1
WHERE status='published' AND published_at <= NOW() AND id <> $1
) t WHERE rn <= 3
ORDER BY category, rn`,
[heroId]
@@ -234,7 +234,7 @@ async function getHomeArticles() {
// Популярное за 30 дней: топ-3 по views (только если views > 0)
const popRes = await query(
`${select} FROM articles
WHERE status='published' AND views > 0 AND published_at > NOW() - INTERVAL '30 days'
WHERE status='published' AND views > 0 AND published_at > NOW() - INTERVAL '30 days' AND published_at <= NOW()
ORDER BY views DESC, published_at DESC LIMIT 3`
);
const popular = popRes.rows;
@@ -246,7 +246,7 @@ async function getHomeArticles() {
const usedArr = Array.from(usedIds).filter(Boolean);
const recentRes = await query(
`${select} FROM articles
WHERE status='published' AND id <> ALL($1::int[])
WHERE status='published' AND published_at <= NOW() AND id <> ALL($1::int[])
ORDER BY published_at DESC LIMIT 6`,
[usedArr.length ? usedArr : [0]]
);
+153 -37
View File
@@ -59,7 +59,7 @@ const TOPIC_BANK = {
* Берёт следующую тему из очереди или из банка тем.
*/
async function getNextTopic(category) {
// Сначала из очереди (по приоритету)
// 1. Приоритетная очередь (content_queue)
const { rows } = await query(
`SELECT * FROM content_queue
WHERE category=$1 AND status='pending'
@@ -69,33 +69,46 @@ async function getNextTopic(category) {
if (rows.length) {
return { id: rows[0].id, topic: rows[0].topic, tags: rows[0].tags || [], keywords: rows[0].keywords || [] };
}
// Из банка — темы которые ещё не использовались
const bank = TOPIC_BANK[category] || TOPIC_BANK['ai-tools'];
// Получаем уже использованные темы по source_topic (точное совпадение)
// 2. DB-банк тем — атомарно захватываем следующую свободную тему.
// FOR UPDATE SKIP LOCKED + немедленный UPDATE is_used=true устраняет race condition:
// параллельные генерации не могут выбрать одну и ту же тему.
const { rows: dbTopics } = await query(`
UPDATE blog_topics
SET is_used=true, used_at=NOW()
WHERE id = (
SELECT bt.id FROM blog_topics bt
WHERE bt.category = $1
AND bt.is_used = false
AND NOT EXISTS (
SELECT 1 FROM articles a
WHERE a.source_topic = bt.topic AND a.category = $1
)
ORDER BY bt.priority DESC, bt.created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, topic
`, [category]);
if (dbTopics.length) {
return { id: null, topic: dbTopics[0].topic, tags: [], keywords: [], blog_topic_id: dbTopics[0].id };
}
// 3. Fallback: хардкод если DB-банк пустой или все темы использованы.
// Проверяем использованные темы (всё время) чтобы не повторяться.
const bank = TOPIC_BANK[category] || TOPIC_BANK['ai-tools'];
const { rows: usedTopics } = await query(
`SELECT source_topic FROM articles WHERE category=$1 AND source_topic IS NOT NULL`,
[category]
);
const usedSet = new Set(usedTopics.map(r => r.source_topic.toLowerCase().trim()));
// Также проверяем по заголовкам (fallback для старых статей без source_topic)
const { rows: usedTitles } = await query(
`SELECT title FROM articles WHERE category=$1 AND source_topic IS NULL AND status='published'`,
[category]
);
const titlesLower = usedTitles.map(r => r.title.toLowerCase());
const unused = bank.filter(t => {
const tLow = t.toLowerCase().trim();
if (usedSet.has(tLow)) return false;
// Fallback: проверяем по первым 30 символам заголовка
if (titlesLower.some(title => title.includes(tLow.slice(0, 30)))) return false;
return true;
});
const usedSet = new Set(usedTopics.map(r => r.source_topic?.toLowerCase().trim()).filter(Boolean));
const unused = bank.filter(t => !usedSet.has(t.toLowerCase().trim()));
// Если все темы уже использованы — берём рандомную (лучше повтор чем пустой контент)
const pool = unused.length > 0 ? unused : bank;
const topic = pool[Math.floor(Math.random() * pool.length)];
// Перемешиваем и берём первую (вместо случайного — детерминированно для одного запуска)
const shuffled = [...pool].sort(() => Math.random() - 0.5);
const topic = shuffled[0];
return { id: null, topic, tags: [], keywords: [] };
}
@@ -103,6 +116,23 @@ async function getNextTopic(category) {
* Запускает генерацию одной статьи для категории.
*/
async function runAutogenForCategory(category) {
// pg_advisory_lock: транзакционный lock по ключу категории.
// Гарантирует что только один процесс генерирует статью для данной категории.
// Устраняет race condition когда несколько тиков/запросов запускаются одновременно.
const lockKey = Math.abs(category.split('').reduce((h, c) => (Math.imul(31, h) + c.charCodeAt(0)) | 0, 0));
await query('SELECT pg_advisory_lock($1)', [lockKey]);
// После получения lock — проверяем ещё раз что за сегодня ещё не генерировали
const { rows: alreadyToday } = await query(
`SELECT id FROM articles WHERE category=$1 AND status='draft' AND created_at >= CURRENT_DATE LIMIT 1`,
[category]
);
if (alreadyToday.length) {
await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {});
console.log(`[Autogen] category=${category}: already generated today, skipping`);
return { ok: false, skipped: true, reason: 'already generated today' };
}
const { id: queueId, topic, tags, keywords } = await getNextTopic(category);
console.log(`[Autogen] category=${category} topic="${topic.slice(0, 60)}"`);
@@ -111,7 +141,7 @@ async function runAutogenForCategory(category) {
topic,
tags: tags,
keywords,
autoPublish: true,
autoPublish: false, // draft review flow
category,
});
@@ -139,6 +169,8 @@ async function runAutogenForCategory(category) {
await query(`UPDATE content_queue SET status='failed' WHERE id=$1`, [queueId]);
}
return { ok: false, error: err.message };
} finally {
await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {});
}
}
@@ -170,10 +202,58 @@ async function runAutogen({ forceCategory = null } = {}) {
params = [currentHour, currentMinute - 5, currentMinute + 5];
}
const { rows: settings } = await query(
`SELECT * FROM autogen_settings ${whereClause} ORDER BY category`,
// Сначала берём ВСЕ активные категории (независимо от времени),
// затем применяем ротацию — выбираем 4 из 8 по дню года.
const { rows: allEnabled } = await query(
`SELECT * FROM autogen_settings WHERE enabled=true ORDER BY category`,
[]
);
// Ротация: скользящее окно из 4 категорий сдвигается на 1 каждый день.
// Это гарантирует что за 8 дней каждая категория выйдет минимум 4 раза,
// и каждый день читатель видит другой набор.
const DAILY_COUNT = 4;
const total = allEnabled.length;
let categoriesForToday;
if (total <= DAILY_COUNT) {
// Категорий меньше или равно 4 — берём все
categoriesForToday = allEnabled.map(s => s.category);
} else {
// День года (0..364) определяет сдвиг окна
const now = new Date();
const start = Date.UTC(now.getUTCFullYear(), 0, 0);
const dayOfYear = Math.floor((now - start) / 86400000);
const offset = dayOfYear % total;
// Берём 4 категории начиная со сдвига (с wrap-around)
categoriesForToday = Array.from({ length: DAILY_COUNT }, (_, i) =>
allEnabled[(offset + i) % total].category
);
console.log(
'[Autogen] Ротация дня ' + dayOfYear + ' (offset=' + offset + '): ' +
categoriesForToday.join(', ')
);
}
// Теперь фильтруем по расписанию (если не forceCategory) — категория
// должна быть в списке дня И соответствовать текущему времени.
const { rows: allSettings } = await query(
`SELECT * FROM autogen_settings WHERE enabled=true ORDER BY run_hour, run_minute`,
[]
);
let settings;
if (forceCategory) {
settings = allSettings.filter(s => s.category === forceCategory);
} else {
// Время окна ±5 мин уже применено в whereClause — переиспользуем
const { rows: timeFiltered } = await query(
`SELECT * FROM autogen_settings ${whereClause} ORDER BY run_hour, run_minute`,
params
);
// Оставляем только категории дня из сработавших по времени
const todaySet = new Set(categoriesForToday);
settings = timeFiltered.filter(s => todaySet.has(s.category));
}
if (!settings.length) {
console.log('[Autogen] Nothing to generate at this time');
@@ -181,10 +261,11 @@ async function runAutogen({ forceCategory = null } = {}) {
}
const results = [];
for (const s of settings) {
for (let i = 0; i < settings.length; i++) {
const s = settings[i];
const result = await runAutogenForCategory(s.category);
results.push({ category: s.category, ...result });
if (settings.indexOf(s) < settings.length - 1) {
if (i < settings.length - 1) {
await new Promise(r => setTimeout(r, 5000));
}
}
@@ -195,16 +276,51 @@ async function runAutogen({ forceCategory = null } = {}) {
/**
* Получить статус автогенерации.
*/
async function getAutogenStatus() {
const { rows: settings } = await query(
`SELECT s.*, c.name as cat_name,
(SELECT COUNT(*) FROM content_queue q WHERE q.category=s.category AND q.status='pending') as queue_count,
(SELECT COUNT(*) FROM articles a WHERE a.category=s.category AND a.status='published') as article_count
FROM autogen_settings s
LEFT JOIN categories c ON c.slug=s.category
ORDER BY s.category`
/**
* Возвращает категории которые активны сегодня по ротации (4 из 8).
*/
function getTodayCategories(allCategories, dailyCount = 4) {
if (allCategories.length <= dailyCount) return allCategories.map(c => c.category || c);
const now = new Date();
const start = Date.UTC(now.getUTCFullYear(), 0, 0);
const dayOfYear = Math.floor((now - start) / 86400000);
const offset = dayOfYear % allCategories.length;
return Array.from({ length: dailyCount }, (_, i) =>
(allCategories[(offset + i) % allCategories.length].category || allCategories[(offset + i) % allCategories.length])
);
return settings;
}
module.exports = { runAutogen, runAutogenForCategory, getAutogenStatus, TOPIC_BANK };
async function getAutogenStatus() {
const { rows: settings } = await query(
`SELECT s.*, c.name as cat_name, c.icon as cat_icon, c.color as cat_color,
(SELECT COUNT(*) FROM articles a
WHERE a.category=s.category AND a.status='published') AS article_count,
(SELECT COUNT(*) FROM blog_topics bt
WHERE bt.category=s.category AND bt.is_used=false) AS topic_count_free,
(SELECT COUNT(*) FROM blog_topics bt
WHERE bt.category=s.category) AS topic_count,
(SELECT COUNT(*) FROM articles a
WHERE a.category=s.category AND a.status='draft'
AND a.created_at >= NOW() - INTERVAL '24 hours') AS drafts_today,
-- следующая тема которую возьмёт генерация
(SELECT bt.topic FROM blog_topics bt
WHERE bt.category=s.category AND bt.is_used=false
AND NOT EXISTS (
SELECT 1 FROM articles a
WHERE a.source_topic=bt.topic AND a.category=s.category
)
ORDER BY bt.priority DESC, bt.created_at ASC
LIMIT 1) AS next_topic
FROM autogen_settings s
LEFT JOIN categories c ON c.slug=s.category
ORDER BY s.run_hour, s.category`
);
// Добавим флаг today_active — входит ли категория в сегодняшнюю ротацию
const todaySet = new Set(getTodayCategories(settings));
return settings.map(s => ({
...s,
today_active: todaySet.has(s.category),
}));
}
module.exports = { runAutogen, runAutogenForCategory, getAutogenStatus, getTodayCategories, TOPIC_BANK };
+32 -8
View File
@@ -47,18 +47,42 @@ async function createChannel(userId, data) {
if (!name) throw new Error('name is required');
const client = await require('../config/db').query;
// Загружаем контентные дефолты
const settingsSvc = require('./settings');
const [
defLanguage, defLength, defStyle, defGoal,
defImageEnabled, defEmojiEnabled, defHashtags,
defDraftCount, defDraftTime, defStylePrompt,
] = await Promise.all([
settingsSvc.get('DEFAULT_POST_LANGUAGE', 'ru'),
settingsSvc.get('DEFAULT_POST_LENGTH', 'medium'),
settingsSvc.get('DEFAULT_POST_STYLE', 'informative'),
settingsSvc.get('DEFAULT_POST_GOAL', 'educational'),
settingsSvc.get('DEFAULT_IMAGE_ENABLED', 'true'),
settingsSvc.get('DEFAULT_EMOJI_ENABLED', 'true'),
settingsSvc.get('DEFAULT_HASHTAGS_IN_POST', 'false'),
settingsSvc.get('DEFAULT_AUTO_DRAFT_COUNT', '3'),
settingsSvc.get('DEFAULT_AUTO_DRAFT_TIME', '08:00'),
settingsSvc.get('DEFAULT_AI_STYLE_PROMPT', ''),
]);
// INSERT channel
// INSERT channel с дефолтами из системных настроек
const { rows: chRows } = await query(
`INSERT INTO channels
(user_id, name, tg_channel_id, tg_username, bot_token, niche, audience, goal, language, region)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
(user_id, name, tg_channel_id, tg_username, bot_token, niche, audience, goal, language, region,
image_enabled, ai_style_prompt, auto_draft_count, auto_draft_time)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING *`,
[
userId, name, tg_channel_id || null, tg_username || null, bot_token || null,
niche || null, audience || null, goal || 'educational',
language || 'ru', region || 'ru',
niche || null, audience || null,
goal || defGoal,
language || defLanguage,
region || 'ru',
defImageEnabled === 'true',
defStylePrompt || null,
parseInt(defDraftCount) || 3,
defDraftTime || '08:00',
]
);
const channel = chRows[0];
@@ -75,9 +99,9 @@ async function createChannel(userId, data) {
style.tone_custom || null,
style.formality || 'informal',
style.humor || 'moderate',
style.post_length || 'medium',
style.post_length || defLength,
style.structure || 'mixed',
style.emoji_level || 'moderate',
style.emoji_level || (defEmojiEnabled === 'true' ? 'moderate' : 'none'),
style.hashtags_mode || 'end',
style.cta_mode || 'sometimes',
JSON.stringify(style.example_posts || []),
+42 -7
View File
@@ -466,13 +466,34 @@ async function generateCoverViaPollinations({ prompt }) {
* Выбирает наиболее подходящую рубрику для обложки статьи.
* Дешёвый haiku-вызов: ~50 токенов. При ошибке — случайная рубрика.
*/
async function selectRubric({ title, tags = [], rubrics }) {
async function selectRubric({ title, tags = [], rubrics, channelId = null }) {
if (!rubrics || rubrics.length === 0) return null;
if (rubrics.length === 1) return rubrics[0];
const rubricList = rubrics.map((r, i) => `${i}. ${r.id}: ${r.desc}`).join('\n');
const userMsg = `Article title: "${title}"\nTags: ${tags.join(', ') || 'none'}\n\nRubrics:\n${rubricList}\n\nRespond with ONLY the index number (0-${rubrics.length - 1}) of the best matching rubric.`;
// Получаем последние использованные рубрики из БД (анти-повтор)
let recentlyUsed = [];
if (channelId) {
try {
const r = await query(
'SELECT last_rubrics_used FROM channel_style WHERE channel_id = $1',
[channelId]
);
recentlyUsed = Array.isArray(r.rows[0]?.last_rubrics_used)
? r.rows[0].last_rubrics_used
: [];
} catch (_) {}
}
// Доступные рубрики = все минус последние 3 использованные
// (если осталось < 2 — берём все, иначе AI выбирает только из свежих)
const recent = recentlyUsed.slice(-3);
let available = rubrics.filter(r => !recent.includes(r.id));
if (available.length < 2) available = rubrics;
const rubricList = available.map((r, i) => `${i}. ${r.id}: ${r.desc}`).join('\n');
const userMsg = `Article title: "${title}"\nTags: ${tags.join(', ') || 'none'}\n\nRubrics:\n${rubricList}\n\nRespond with ONLY the index number (0-${available.length - 1}) of the best matching rubric.`;
let selected = null;
try {
const res = await axios.post(
`${config.ai.baseUrl}/chat/completions`,
@@ -489,12 +510,25 @@ async function selectRubric({ title, tags = [], rubrics }) {
);
const raw = res.data?.choices?.[0]?.message?.content?.trim() || '0';
const idx = parseInt(raw.replace(/\D/g, '')) || 0;
const safeIdx = Math.min(Math.max(idx, 0), rubrics.length - 1);
return rubrics[safeIdx];
const safeIdx = Math.min(Math.max(idx, 0), available.length - 1);
selected = available[safeIdx];
} catch (err) {
console.warn('[Cover] selectRubric failed, using random:', err.message.slice(0, 80));
return rubrics[Math.floor(Math.random() * rubrics.length)];
selected = available[Math.floor(Math.random() * available.length)];
}
// Записываем в last_rubrics_used (храним последние 5)
if (channelId && selected) {
try {
const newList = [...recentlyUsed, selected.id].slice(-5);
await query(
'UPDATE channel_style SET last_rubrics_used = $1 WHERE channel_id = $2',
[JSON.stringify(newList), channelId]
);
} catch (_) {}
}
return selected;
}
async function generateCover({ articleId, title, tags = [], channelId = null }) {
@@ -514,7 +548,7 @@ async function generateCover({ articleId, title, tags = [], channelId = null })
let styleName;
const rubrics = channelStyle?.image_rubrics;
if (Array.isArray(rubrics) && rubrics.length > 0) {
selectedRubric = await selectRubric({ title, tags, rubrics });
selectedRubric = await selectRubric({ title, tags, rubrics, channelId });
styleName = selectedRubric?.id || 'rubric';
console.log(`[Cover] article=${articleId} channel=${channelId} rubric=${styleName}`);
} else {
@@ -526,6 +560,7 @@ async function generateCover({ articleId, title, tags = [], channelId = null })
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric });
let img;
let usedPath = 'routerai';
// Единственный провайдер картинок: routerai /responses + gpt-5-image-mini
// Цена: ~₽2.72/картинка (4175 image tokens, high quality, quality param не работает)
+115
View File
@@ -0,0 +1,115 @@
/**
* dripScheduler.js — равномерное распределение публикаций статей по дню.
*
* Зачем:
* Автогенерация даёт 4+ статей в день одним пачкой (одна за другой в течение
* часа). Если у каждой published_at=NOW(), на сайте они появляются скопом.
* Распределяем — растягиваем по слотам (например 09/13/17/21 МСК).
*
* Логика nextDripSlot():
* 1. Читаем app_settings.SITE_PUBLISH_SLOTS (CSV "HH:MM,HH:MM,...", default
* "09:00,13:00,17:00,21:00" — каждые 4 часа в МСК).
* 2. Перебираем дни вперёд начиная с сегодня:
* - для каждого слота вычисляем абсолютный UTC-момент
* - если слот ещё впереди и в этот час (slot ± 60 мин) нет другой
* статьи с published_at — этот слот наш
* 3. Если все слоты на 14 дней вперёд заняты — возвращаем NOW() (fallback).
*
* Учёт временной зоны:
* Slots задаются в Москве (UTC+3). Преобразуем slot.hour→UTC при сравнении.
*/
const { query } = require('../config/db');
const settings = require('./settings');
const MSK_OFFSET_MIN = 180;
const HORIZON_DAYS = 14;
const SLOT_BUSY_WINDOW_MIN = 60; // считаем слот занятым если в ±60 мин уже есть статья
async function getSlots() {
const raw = await settings.get('SITE_PUBLISH_SLOTS', '09:00,13:00,17:00,21:00');
const parts = String(raw).split(',').map(s => s.trim()).filter(Boolean);
const out = [];
for (const p of parts) {
const m = /^(\d{1,2}):(\d{2})$/.exec(p);
if (!m) continue;
const h = Math.max(0, Math.min(23, parseInt(m[1], 10)));
const min = Math.max(0, Math.min(59, parseInt(m[2], 10)));
out.push({ h, min });
}
return out.length ? out.sort((a, b) => a.h - b.h || a.min - b.min) : [{ h: 13, min: 0 }];
}
/**
* Превращает (год/месяц/день в локальной MSK, слот.h, слот.m) в Date в UTC.
* Возвращает абсолютный Date.
*/
function slotToUtcDate(mskYear, mskMonth, mskDay, slot) {
// Slot в MSK → UTC = MSK - 3h
const utcHour = slot.h - 3;
const d = new Date(Date.UTC(mskYear, mskMonth, mskDay, utcHour, slot.min, 0, 0));
return d;
}
/**
* Текущая дата в MSK (компоненты), для перебора дней начиная с сегодня.
*/
function mskDateParts(now = new Date()) {
const msk = new Date(now.getTime() + MSK_OFFSET_MIN * 60_000);
return {
year: msk.getUTCFullYear(),
month: msk.getUTCMonth(),
day: msk.getUTCDate(),
};
}
/**
* Главная функция. Возвращает ISO Date для нового published_at.
*/
async function nextDripSlot() {
const slots = await getSlots();
const now = new Date();
const startMsk = mskDateParts(now);
for (let offset = 0; offset < HORIZON_DAYS; offset++) {
const dayMsk = new Date(Date.UTC(startMsk.year, startMsk.month, startMsk.day + offset));
const y = dayMsk.getUTCFullYear();
const m = dayMsk.getUTCMonth();
const d = dayMsk.getUTCDate();
for (const slot of slots) {
const slotUtc = slotToUtcDate(y, m, d, slot);
if (slotUtc <= now) continue;
// Считаем слот занятым если есть статья (любого статуса draft/published)
// с published_at в окне ±SLOT_BUSY_WINDOW_MIN мин
const winStart = new Date(slotUtc.getTime() - SLOT_BUSY_WINDOW_MIN * 60_000);
const winEnd = new Date(slotUtc.getTime() + SLOT_BUSY_WINDOW_MIN * 60_000);
const { rows } = await query(
`SELECT 1 FROM articles
WHERE status='published'
AND published_at >= $1 AND published_at < $2
LIMIT 1`,
[winStart, winEnd]
);
if (rows.length === 0) return slotUtc;
}
}
// Все слоты заняты на горизонте — публикуем сразу
return now;
}
/**
* Утилита — описание следующего слота для логов/UI.
*/
async function describeNextSlot() {
const dt = await nextDripSlot();
const msk = new Date(dt.getTime() + MSK_OFFSET_MIN * 60_000);
const hh = String(msk.getUTCHours()).padStart(2, '0');
const mm = String(msk.getUTCMinutes()).padStart(2, '0');
const dd = String(msk.getUTCDate()).padStart(2, '0');
const mo = String(msk.getUTCMonth() + 1).padStart(2, '0');
return { at: dt, mskLabel: `${dd}.${mo} ${hh}:${mm} МСК` };
}
module.exports = { nextDripSlot, describeNextSlot, getSlots };
+111
View File
@@ -0,0 +1,111 @@
/**
* emailService.js — отправка email уведомлений через SMTP.
* Использует nodemailer. Настройки из app_settings (category=smtp).
*/
const nodemailer = require('nodemailer');
const settings = require('./settings');
let _transporter = null;
let _configHash = null;
async function getTransporter() {
const [host, port, user, pass, from, enabled] = await Promise.all([
settings.get('SMTP_HOST', ''),
settings.get('SMTP_PORT', '587'),
settings.get('SMTP_USER', ''),
settings.get('SMTP_PASS', ''),
settings.get('SMTP_FROM', 'ZeroPost <noreply@zeropost.ru>'),
settings.get('SMTP_ENABLED', 'false'),
]);
if (enabled !== 'true') return null;
if (!host || !user) return null;
const hash = `${host}:${port}:${user}:${pass}`;
if (_transporter && hash === _configHash) return _transporter;
_transporter = nodemailer.createTransport({
host, port: parseInt(port),
secure: parseInt(port) === 465,
auth: { user, pass },
tls: { rejectUnauthorized: false },
});
_configHash = hash;
return _transporter;
}
/**
* Отправить email.
* @param {string} to — адрес получателя
* @param {string} subject — тема
* @param {string} html — HTML тело
* @param {string} [text] — plain text fallback
*/
async function send({ to, subject, html, text }) {
const transporter = await getTransporter();
if (!transporter) {
console.log(`[Email] SMTP disabled or not configured, skip: ${subject}${to}`);
return { skipped: true };
}
const from = await settings.get('SMTP_FROM', 'ZeroPost <noreply@zeropost.ru>');
try {
const info = await transporter.sendMail({ from, to, subject, html, text });
console.log(`[Email] sent: ${subject}${to} (${info.messageId})`);
return { ok: true, messageId: info.messageId };
} catch (err) {
console.error(`[Email] send error: ${err.message}`);
return { error: err.message };
}
}
/**
* Шаблоны уведомлений
*/
const templates = {
welcome({ email, credits }) {
return {
subject: 'Добро пожаловать в ZeroPost!',
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2>Привет! 👋</h2>
<p>Рады видеть тебя в ZeroPost.</p>
<p>На твой счёт зачислено <b>${credits} кредитов</b> для начала работы.</p>
<p><a href="https://app.zeropost.ru" style="color:#6366f1">Открыть приложение →</a></p>
<hr style="margin:24px 0;border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">ZeroPost · Автоматизация контента</p>
</div>
`,
};
},
payment_success({ amount, plan, email }) {
return {
subject: `✅ Оплата ${amount}₽ прошла успешно`,
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2>Оплата подтверждена</h2>
<p>Тариф <b>${plan}</b> активирован.</p>
<p>Сумма: <b>${amount}₽</b></p>
<p><a href="https://app.zeropost.ru/billing" style="color:#6366f1">История платежей →</a></p>
<hr style="margin:24px 0;border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">ZeroPost · Автоматизация контента</p>
</div>
`,
};
},
low_credits({ credits, email }) {
return {
subject: '⚠️ Кредиты заканчиваются',
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2>Осталось ${credits} кредитов</h2>
<p>Пополни баланс чтобы продолжить генерацию контента.</p>
<p><a href="https://app.zeropost.ru/plans" style="color:#6366f1">Выбрать тариф →</a></p>
<hr style="margin:24px 0;border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">ZeroPost · Автоматизация контента</p>
</div>
`,
};
},
};
module.exports = { send, templates };
+157 -10
View File
@@ -63,9 +63,11 @@ const IMAGE_PALETTES = {
* Генерирует картинку к посту через GPT-5 /v1/responses + image_generation.
*/
async function generatePostImage({ post, channel, style = {} }) {
// Если задано несколько стилей через запятую — случайно выбираем один
const styleList = (style.image_style || 'flat-illustration')
.split(',').map(s => s.trim()).filter(s => s && s !== 'auto');
// Если задано несколько стилей через запятую — случайно выбираем один.
// Если стиль не задан или 'auto' — ротация из трёх редакторских стилей.
const DEFAULT_ROTATION = 'realistic-photo,3d-render,flat-illustration';
const rawStyle = style.image_style && style.image_style !== 'auto' ? style.image_style : DEFAULT_ROTATION;
const styleList = rawStyle.split(',').map(s => s.trim()).filter(Boolean);
const pickedStyle = styleList[Math.floor(Math.random() * styleList.length)] || 'flat-illustration';
const imageStyle = IMAGE_STYLES[pickedStyle] || IMAGE_STYLES['flat-illustration'];
const palette = style.image_custom_colors
@@ -75,15 +77,36 @@ async function generatePostImage({ post, channel, style = {} }) {
// Извлекаем суть поста для промпта (первые 250 символов)
const postExcerpt = post.replace(/[#*_`>]/g, '').slice(0, 250);
const prompt = `Editorial illustration for a social media post. Topic essence: "${postExcerpt}"
// Визуальная метафора — конкретный предмет/сцена на основе темы
const visualConcept = getPostVisualConcept(post, channel);
Style: ${imageStyle.prompt}.
${palette ? `Color palette: ${palette}.` : ''}
Channel context: ${channel.niche || channel.name}.
${style.image_prompt_instructions ? `\nChannel visual guidelines: ${style.image_prompt_instructions}` : ''}
// Антураж + свет — случайный выбор при каждой генерации (намеренно, не детерминированно)
const SCENES = [
{ setting: 'warm oak desktop surface, afternoon sunlight from left window', lighting: 'golden hour soft shadows', temp: 'warm amber' },
{ setting: 'white marble surface, clean studio', lighting: 'flat professional studio', temp: 'cool whites' },
{ setting: 'dark slate table, single focused overhead spotlight', lighting: 'dramatic single point', temp: 'high contrast' },
{ setting: 'weathered wooden workbench, overcast daylight', lighting: 'soft even overcast', temp: 'muted naturals' },
{ setting: 'black velvet surface, rim lighting from behind', lighting: 'rim lit glowing edges', temp: 'rich blacks gold' },
{ setting: 'glass surface over city lights at night', lighting: 'city glow from below', temp: 'multicolor bokeh' },
{ setting: 'antique library floor, surrounded by books, candlelight', lighting: 'warm candlelight side', temp: 'amber parchment' },
{ setting: 'frosted glass, winter morning, ice crystals at edges', lighting: 'diffused winter morning', temp: 'icy blues whites' },
{ setting: 'concrete urban rooftop at golden hour, city skyline behind', lighting: 'backlit warm haze', temp: 'golden urban' },
{ setting: 'minimalist white shelf, single object lit from above', lighting: 'clean overhead spotlight', temp: 'pure whites' },
{ setting: 'old wooden table in a sunlit greenhouse, plants around', lighting: 'dappled greenhouse light', temp: 'fresh greens warm' },
];
const scene = SCENES[Math.floor(Math.random() * SCENES.length)];
Composition: 16:9 wide format, balanced, suitable for social media.
Strictly: no text, no letters, no logos, no faces of real people.`;
const prompt = `Generate a 16:9 editorial illustration for a social media post.
VISUAL CONCEPT: ${visualConcept}
SETTING: ${scene.setting}
LIGHTING: ${scene.lighting}
COLOR TEMPERATURE: ${scene.temp}
${style.image_custom_colors ? `BRAND PALETTE: ${style.image_custom_colors}` : (palette ? `PALETTE: ${palette}` : '')}
STYLE: ${imageStyle.prompt}
${style.image_prompt_instructions ? `CHANNEL STYLE: ${style.image_prompt_instructions}` : ''}
RULES: no text, no letters, no logos, no real human faces.`;
// Единственный провайдер: routerai /responses + gpt-5-image-mini
// Цена: ~₽2.72/картинка. quality параметр не работает, всегда high.
@@ -142,3 +165,127 @@ Strictly: no text, no letters, no logos, no faces of real people.`;
}
module.exports = { generatePostImage, IMAGE_STYLES, IMAGE_PALETTES };
/**
* Извлекает визуальный концепт из текста поста.
* Конкретные, материальные образы — не абстрактные.
*/
function getPostVisualConcept(post, channel) {
const t = post.toLowerCase();
const niche = (channel?.niche || '').toLowerCase();
const combined = t + ' ' + niche;
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
const patterns = [
{
kw: ['ии', ' ai ', 'нейро', 'llm', 'gpt', 'claude', 'chatgpt', 'искусственн', 'neural'],
concepts: [
'A vintage typewriter with keys pressing by invisible force, paper emerging with glowing text',
'An old brass compass spinning and settling on a new direction, surrounded by scattered maps',
'A seed germinating in dark soil, roots and shoots emerging simultaneously, close-up macro',
'A master key held up to warm light, intricate cuts visible, golden bokeh background',
'A book opening by itself, pages turning rapidly, text rearranging mid-air in warm library',
'An optical prism splitting white light into full spectrum, mounted on dark velvet surface',
'A chess board mid-game, one piece hovering in the air about to move, dramatic side light',
'An hourglass frozen mid-flow, sand suspended in air, dark moody background',
'A single neuron with glowing dendrites branching outward, macro medical illustration style',
'A telescope pointed at a star map, constellation lines drawn in light, observatory dome open',
'A maze viewed from above, a single glowing path found through it, aerial minimalist',
'A blank canvas with a single brushstroke that transforms into a landscape, studio light',
'Two puzzle pieces clicking together mid-air, warm backlight, close-up macro',
'A vintage radio with dials, sound waves visible as light trails, dark wood surface',
'An open toolbox with glowing tools arranged precisely, overhead industrial light',
'A library ladder reaching impossibly high shelves disappearing into mist, warm amber',
],
},
{
kw: ['автомат', 'бот', 'automat', 'workflow', 'n8n', 'zapier', 'make', 'скрипт'],
concepts: [
'Vintage clockwork mechanism — interlocking brass gears in motion, macro close-up, amber light',
'A domino chain in the moment of falling, each piece a different color, motion blur',
'Factory assembly line condensed to a tabletop, small objects moving through stages, long exposure',
'A rube goldberg sequence frozen mid-action, multiple contraptions in motion',
'Time-lapse of a city intersection at night, light trails forming perfect flow patterns',
],
},
{
kw: ['взлом', 'хакер', 'безопасн', 'фишинг', 'вирус', 'cyber', 'hack', 'secur'],
concepts: [
'A vintage combination lock under dramatic side lighting, tumblers visible, dark background',
'A glass door with a hairline crack spreading, red emergency light leaking through fracture',
'An old steel safe door hanging slightly open, papers spilling out, harsh spotlight',
'A chain with one shattered link, chrome and steel, dramatic spotlight on break point',
],
},
{
kw: ['код', 'разработ', 'програм', 'code', 'develop', 'software', 'api', 'github'],
concepts: [
'A craftsman workbench covered in precision tools, each perfectly placed, workshop window light',
'An architect drafting table with blueprints unrolled, compass and ruler in use, desk lamp',
'Knitting needles mid-row on a complex pattern, wool threads crossing precisely, natural light',
'A mason building a wall one brick at a time, each brick different texture, golden hour',
],
},
{
kw: ['маркетинг', 'реклам', 'продвиж', 'seo', 'контент', 'growth', 'аудитор'],
concepts: [
'A megaphone lying on a table, vintage brass, city map spread underneath it',
'Seeds being planted in geometric rows, birds-eye view, garden tools aside, spring light',
'A lighthouse beam sweeping over foggy harbor, ships turning toward the light',
'A vendor market stall being set up attractively, colorful awning, morning light',
],
},
{
kw: ['деньг', 'финанс', 'инвест', 'бизнес', 'прибыл', 'доход', 'money', 'business'],
concepts: [
'A vintage scale perfectly balanced with different objects on each side, warm studio light',
'Stack of different vintage coins photographed from above, macro, warm lighting',
'A piggy bank on a wooden surface with a single coin mid-air above it, soft focus',
'Growing seedlings in small pots arranged by height, morning light through window',
],
},
{
kw: ['обучен', 'курс', 'урок', 'учеб', 'знан', 'навык', 'learn', 'educat'],
concepts: [
'Open textbook with handwritten notes in margins, pencil resting on page, desk lamp',
'Stack of colorful books with a cup of coffee, cozy reading corner, soft morning light',
'A graduation mortarboard on stack of books, warm sunlight from side',
'Hands writing in a notebook, pen visible, blurred background of bookshelf',
],
},
{
kw: ['здоровь', 'спорт', 'фитнес', 'еда', 'питан', 'health', 'fit', 'food'],
concepts: [
'Fresh vegetables arranged artfully on white surface, overhead shot, natural light',
'Running shoes on wooden floor, morning light casting long shadows',
'A glass of water with ice and mint, condensation visible, clean white background',
'Yoga mat rolled out near window with morning light streaming in',
],
},
];
for (const { kw, concepts } of patterns) {
if (kw.some(k => combined.includes(k))) {
return pick(concepts);
}
}
// Универсальные — нейтральные но конкретные
const generic = [
'A single lighthouse on rocky coast at dusk, warm light in tower, dramatic sky',
'An empty stage with single spotlight on plain wooden chair, theatre atmosphere',
'A vintage compass on worn leather journal, mountain wilderness background',
'A door slightly ajar with warm light escaping, curious hallway perspective',
'A single match being struck in complete darkness, dramatic flare close-up',
'A crossroads sign in fog, gravel road, dawn light breaking through',
'A paper boat on still water, single ripple expanding outward, minimalist',
'An old film projector casting beam of light, dust particles visible, cinema',
'A telescope pointed skyward from rooftop, city lights below, stars above',
'A bridge disappearing into morning fog, pedestrian perspective',
'A ceramic coffee cup with steam rising, morning light through window',
'An open notebook with a pen and fern plant, flat lay, natural light',
];
return pick(generic);
}
+41 -52
View File
@@ -14,6 +14,7 @@ const FormData = require('form-data');
const { query } = require('../config/db');
const settings = require('./settings');
const zeroChar = require('./zeroCharacter');
const { tgSend } = require('./tgSend');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
@@ -72,55 +73,21 @@ function renderTemplate(template, article) {
* Если caption длиннее 1024 — режется (TG hard-limit для sendPhoto). Для длинных постов лучше посылать без cover (sendMessage до 4096).
*/
async function publishToTelegram({ channel, text, photoUrl, article }) {
const base = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
// Inline-кнопка — только если есть статья и кнопка не отключена
const buttonText = channel.auto_publish_button_text === null
? null
: (channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT);
// Inline-кнопка «Читать на сайте» — только если есть статья и кнопка не отключена
const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT;
let reply_markup = undefined;
if (article && buttonText) {
reply_markup = {
inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]],
};
reply_markup = { inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]] };
}
if (photoUrl) {
const localPath = resolveLocalPhoto(photoUrl);
if (localPath) {
// Шлём файл напрямую через multipart — TG не пойдёт сам ходить за URL
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
form.append('caption', text.slice(0, 1024));
form.append('parse_mode', 'Markdown');
if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup));
form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
// Единый модуль отправки (multipart для локальных файлов, URL для внешних, fallback sendMessage)
return tgSend({
botToken: channel.bot_token,
chatId: channel.tg_channel_id,
text,
photoUrl,
replyMarkup: reply_markup,
parseMode: 'Markdown',
});
return res.data?.result?.message_id;
}
// Внешний URL — оставляем старое поведение
const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, {
chat_id: channel.tg_channel_id,
photo: photoUrl,
caption: text.slice(0, 1024),
parse_mode: 'Markdown',
reply_markup,
}, { timeout: 30000 });
return res.data?.result?.message_id;
}
const res = await axios.post(`${base}/bot${channel.bot_token}/sendMessage`, {
chat_id: channel.tg_channel_id,
text: text.slice(0, 4096),
parse_mode: 'Markdown',
disable_web_page_preview: !article, // если есть кнопка — превью сайта не нужно
reply_markup,
}, { timeout: 15000 });
return res.data?.result?.message_id;
}
async function publishToVK({ channel, text, photoUrl, article }) {
@@ -213,7 +180,7 @@ async function publishToMax({ channel, text, photoUrl, article }) {
throw new Error('MAX не настроен');
}
const BASE = 'https://platform-api.max.ru';
const BASE = 'https://platform-api2.max.ru';
const token = channel.max_access_token;
const chatId = channel.max_channel_id;
const headers = { Authorization: token, 'Content-Type': 'application/json' };
@@ -347,10 +314,12 @@ async function publishOne(scheduledPost) {
} catch(_) { /* внешний URL или файл не найден — считаем реальным */ }
if (coverIsReal) {
photoUrl = article.cover_url.startsWith('http')
? article.cover_url
: `https://zeropost.ru${article.cover_url}`;
console.log(`[scheduled-runner] cover=${article.cover_url.split('/').pop()} article=${article.id}`);
// Telegram не принимает .webp по URL — используем .png версию
const coverForTg = article.cover_url.replace(/\.webp$/, '.png');
photoUrl = coverForTg.startsWith('http')
? coverForTg
: `https://zeropost.ru${coverForTg}`;
console.log(`[scheduled-runner] cover=${coverForTg.split('/').pop()} article=${article.id}`);
} else {
const attempts = scheduledPost.cover_regen_attempts || 0;
const MAX_REGEN_ATTEMPTS = 3; // 3 × 15 мин = 45 мин максимум ждём
@@ -427,11 +396,31 @@ async function publishOne(scheduledPost) {
return { messageId, channel, article };
}
// Защита от залпа: посты просроченные более чем на SKIP_OLDER_THAN_H часов
// не отправляются (помечаются 'skipped'). Иначе после простоя раннера или
// массового ретрая всё накопившееся улетело бы в канал пачкой.
const SKIP_OLDER_THAN_H = 3;
async function runScheduled() {
// 1) Помечаем слишком старые pending как skipped (не спамим канал задним числом)
const { rows: skipped } = await query(
`UPDATE scheduled_posts
SET status='skipped',
error='auto-skipped: просрочено более ${SKIP_OLDER_THAN_H}ч'
WHERE status='pending'
AND scheduled_at < NOW() - INTERVAL '${SKIP_OLDER_THAN_H} hours'
RETURNING id, article_id`
);
if (skipped.length) {
console.log(`[scheduled-runner] skipped ${skipped.length} stale post(s): ${skipped.map(s => s.id).join(', ')}`);
}
// 2) Берём ОДИН готовый пост за тик (не пачкой) — публикации идут по одной
// с интервалом в минуту, даже если накопилось несколько свежих.
const { rows } = await query(
`SELECT * FROM scheduled_posts
WHERE status='pending' AND scheduled_at <= NOW()
ORDER BY scheduled_at ASC LIMIT 20`
ORDER BY scheduled_at ASC LIMIT 1`
);
const results = [];
for (const sp of rows) {
@@ -453,7 +442,7 @@ async function runScheduled() {
console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`);
}
}
return { processed: rows.length, results };
return { processed: rows.length, skipped: skipped.length, results };
}
module.exports = { runScheduled, publishOne, renderTemplate, DEFAULT_TEMPLATE, DEFAULT_BUTTON_TEXT };
+134
View File
@@ -0,0 +1,134 @@
/**
* tgSend.js — единый модуль отправки сообщений в Telegram.
*
* Зачем: раньше scheduledPostsRunner (статьи) и zeroNotesRunner (заметки Зеро)
* имели каждый свою копию логики sendPhoto/sendMessage. Они разъезжались —
* у статей фото работало, у заметок ломалось. Теперь оба зовут этот модуль.
*
* Главный принцип отправки фото:
* Если фото — это наш локальный файл (/uploads/...), шлём его как multipart
* (file stream). Telegram НЕ ходит за URL сам — это надёжнее (нет таймаутов
* CF-Worker'а, нет "wrong type of web page content"). Внешний URL (не наш) —
* отправляем как ссылку (Telegram скачает).
*
* Единственный источник правды по:
* - резолву локального пути картинки (resolveLocalPhoto)
* - лимитам Telegram (caption 1024 / message 4096)
* - обработке ошибок (extractTgError)
*/
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const FormData = require('form-data');
const settings = require('./settings');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
const TG_CAPTION_LIMIT = 1024; // hard-limit Telegram для caption у sendPhoto
const TG_MESSAGE_LIMIT = 4096; // hard-limit Telegram для text у sendMessage
/**
* Резолвит локальный путь для /uploads/* фото. Возвращает абсолютный путь к
* существующему файлу, либо null (внешний URL / файла нет / path traversal).
*/
function resolveLocalPhoto(photoUrl) {
if (!photoUrl) return null;
let pathname = photoUrl;
try {
// new URL кинет если относительный путь — тогда оставляем как есть
pathname = new URL(photoUrl).pathname;
} catch {
pathname = photoUrl;
}
if (!pathname.startsWith('/uploads/')) return null;
const filename = pathname.replace(/^\/uploads\//, '');
if (filename.includes('..') || filename.includes('/')) return null; // path traversal guard
const local = path.join(UPLOADS_DIR, filename);
if (!fs.existsSync(local)) return null;
return local;
}
/** Достаёт человекочитаемую ошибку из ответа Telegram/axios. */
function extractTgError(err) {
return err.response?.data?.description
|| err.response?.data?.error?.error_msg
|| err.message
|| 'unknown telegram error';
}
/**
* Универсальная отправка в Telegram.
*
* @param {object} o
* @param {string} o.botToken — токен бота канала
* @param {string} o.chatId — tg_channel_id
* @param {string} o.text — текст (станет caption если есть фото, иначе message)
* @param {string} [o.photoUrl] — /uploads/... или внешний URL (необязательно)
* @param {object} [o.replyMarkup] — inline_keyboard и т.п. (необязательно)
* @param {string} [o.parseMode] — 'Markdown' | 'HTML' | undefined (без разметки)
* @returns {Promise<number>} message_id
*
* Поведение:
* - фото есть + текст влезает в caption (1024) → sendPhoto
* · локальный файл → multipart (file stream)
* · внешний URL → photo=url (TG скачает)
* - иначе → sendMessage (текст до 4096, режется при превышении)
*/
async function tgSend({ botToken, chatId, text, photoUrl, replyMarkup, parseMode }) {
if (!botToken || !chatId) {
throw new Error('botToken или chatId не заданы');
}
const base = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const body = String(text || '');
const canUsePhoto = photoUrl && body.length <= TG_CAPTION_LIMIT;
if (canUsePhoto) {
const localPath = resolveLocalPhoto(photoUrl);
if (localPath) {
// Локальный файл → multipart
const form = new FormData();
form.append('chat_id', String(chatId));
form.append('caption', body);
if (parseMode) form.append('parse_mode', parseMode);
if (replyMarkup) form.append('reply_markup', JSON.stringify(replyMarkup));
form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${base}/bot${botToken}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60_000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
return res.data?.result?.message_id;
}
// Внешний URL → пусть Telegram сам скачает
const res = await axios.post(`${base}/bot${botToken}/sendPhoto`, {
chat_id: chatId,
photo: photoUrl,
caption: body,
parse_mode: parseMode,
reply_markup: replyMarkup,
}, { timeout: 30_000 });
return res.data?.result?.message_id;
}
// Без фото (или текст слишком длинный для caption) → sendMessage
const res = await axios.post(`${base}/bot${botToken}/sendMessage`, {
chat_id: chatId,
text: body.slice(0, TG_MESSAGE_LIMIT),
parse_mode: parseMode,
disable_web_page_preview: !replyMarkup, // если есть кнопка — превью сайта не нужно
reply_markup: replyMarkup,
}, { timeout: 15_000 });
return res.data?.result?.message_id;
}
module.exports = {
tgSend,
resolveLocalPhoto,
extractTgError,
TG_CAPTION_LIMIT,
TG_MESSAGE_LIMIT,
UPLOADS_DIR,
};
+32 -67
View File
@@ -1,58 +1,36 @@
// Маппинг постов на иллюстрации с персонажем Зеро.
// 15 поз хранятся как /var/www/zeropost-uploads/zero-{name}.webp
//
// Логика выбора:
// 1. Если в title/excerpt есть triggers — берём соответствующую эмоциональную/активную позу
// 2. Иначе — берём позу по категории
// 3. Если в локации файла нет — fallback на 'avatar'
// Позы хранятся как /uploads/zero-{name}.webp и сервируются отдельным nginx
// (zeropost-uploads-server). Engine их по файловой системе не видит — общается
// с ними только через публичный URL. Поэтому "доступность" позы определяется
// статическим списком AVAILABLE_POSES; добавил новую позу — добавь в список.
const fs = require('fs');
const path = require('path');
// Полный список поз, доступных в /uploads/zero-{name}.webp на проде.
// Источник: ls на uploads-server. Если добавляешь — синкни с этим списком.
const AVAILABLE_POSES = new Set([
'avatar', 'bug', 'chart', 'coding', 'coffee', 'confused', 'eureka', 'facepalm',
'gears', 'lock', 'magnifier', 'meditate', 'present', 'reading', 'rocket',
'sleep', 'swimming', 'telescope', 'thinking', 'thumbsup', 'tired', 'tools', 'victory',
]);
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
// Эмоциональные/активные позы — выбираются по ключевым словам в title/excerpt.
// Порядок важен: первое срабатывание побеждает.
// Эмоциональные/активные позы — выбираются по ключевым словам в title/excerpt/content.
const EMOTIONAL_TRIGGERS = [
// "Получилось / заработало / победа" → victory
{ pose: 'victory', words: ['получилось', 'заработало', 'победа', 'отличный результат', 'удалось', 'успех'] },
// "Не работает / сломалось / провал" → facepalm
{ pose: 'facepalm', words: ['не работает', 'сломал', 'ошибк', 'провал', 'факап', 'fail', 'баг', 'неудач', 'облажал'] },
// "Нашёл / открыл / классный" → eureka
{ pose: 'eureka', words: ['нашёл', 'нашел', 'открыл', 'классн', 'крутая фича', 'интересн', 'wow', 'неожиданн'] },
// "Запутался / непонятно / разбираемся" → confused
{ pose: 'confused', words: ['запутал', 'непонятно', 'разбира', 'разобрат', 'странн', 'не пойму', 'почему'] },
// "Устал / долго / ночь" → tired
{ pose: 'tired', words: ['устал', 'долго', 'часами', 'ночь', 'утром понял', 'выгорел'] },
// "Изучаю / разбор / гайд / шпаргалка" → reading или present
{ pose: 'reading', words: ['изуча', 'разбор', 'шпаргалк', 'гайд', 'мануал', 'документац'] },
{ pose: 'present', words: ['как сделать', 'туториал', 'инструкц', 'объясн', 'показыва', 'учимся'] },
// "Расследую / разбираю / копаю" → magnifier
{ pose: 'magnifier', words: ['расследова', 'разбираю', 'копа', 'докопат', 'под капот', 'как устроен'] },
// "Аналитика / метрики / графики" → chart
{ pose: 'chart', words: ['метрик', 'аналитик', 'график', 'статистик', 'цифр', 'данные показ', 'результат за'] },
// "Запуск / деплой" → rocket
{ pose: 'rocket', words: ['деплой', 'запустил', 'релиз', 'в продакш', 'залил', 'выкатил', 'запуск проект'] },
// "Баг / отладка" → bug
{ pose: 'bug', words: ['баг', 'ошибк', 'дебаг', 'отлаживал', 'починил', 'не работало'] },
// "Рекомендация / топ" → thumbsup
{ pose: 'thumbsup', words: ['рекомендую', 'советую', 'топ-', 'лучший', 'отличный инструмент', 'понравилось'] },
// "Плавание / спорт" → swimming
{ pose: 'swimming', words: ['плавани', 'бассейн', 'плыть', 'тренировк', 'спортивн'] },
// "Думаю / вопрос" → thinking
{ pose: 'thinking', words: ['думаю', 'размышляю', 'не знаю точно', 'интересный вопрос', 'а что если'] },
// "Исследование" → telescope
{ pose: 'telescope', words: ['исследова', 'изучаю', 'смотрю внимательно', 'нашёл интересн', 'открытие'] },
// "Подумать / поразмышлять / медитация" → meditate
{ pose: 'telescope', words: ['исследова', 'смотрю внимательно', 'нашёл интересн', 'открытие'] },
{ pose: 'meditate', words: ['подумать', 'размышл', 'осмысл', 'мысли вслух', 'рефлекс'] },
{ pose: 'sleep', words: ['засыпа', 'спать', 'отдых', 'выходной', 'утро понедельник'] },
];
// Категорийные позы — fallback если эмоциональных триггеров не нашлось
@@ -63,62 +41,49 @@ const CATEGORY_POSES = {
'ai-dev': 'coding',
};
const FALLBACK_POSE = 'avatar';
const FALLBACK_POSE = 'coffee'; // 'coffee' — наш дефолтный кофейный Зеро (лучше чем avatar в большинстве случаев)
/**
* Выбирает имя позы Зеро под пост.
* @param {{ title?: string, excerpt?: string, category?: string }} ctx
* @returns {{ pose: string, path: string|null, exists: boolean }}
* @param {{ title?: string, excerpt?: string, category?: string, content?: string }} ctx
* @returns {{ pose: string, source: string }}
*/
function pickPose({ title = '', excerpt = '', category = '' }) {
const haystack = `${title} ${excerpt}`.toLowerCase();
function pickPose({ title = '', excerpt = '', category = '', content = '' }) {
const haystack = `${title} ${excerpt} ${content}`.toLowerCase();
// 1. Эмоциональные триггеры
for (const t of EMOTIONAL_TRIGGERS) {
for (const w of t.words) {
if (haystack.includes(w)) {
return resolve(t.pose, 'emotional');
return safe(t.pose, 'emotional');
}
}
}
// 2. По категории
const catPose = CATEGORY_POSES[category];
if (catPose) return resolve(catPose, 'category');
if (catPose) return safe(catPose, 'category');
// 3. Fallback
return resolve(FALLBACK_POSE, 'fallback');
return safe(FALLBACK_POSE, 'fallback');
}
function resolve(name, source) {
const localPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
const exists = fs.existsSync(localPath);
// Если позы нет — пробуем avatar
if (!exists && name !== FALLBACK_POSE) {
const fbPath = path.join(UPLOADS_DIR, `zero-${FALLBACK_POSE}.webp`);
if (fs.existsSync(fbPath)) {
return { pose: FALLBACK_POSE, path: fbPath, exists: true, source: `${source}-fallback` };
function safe(pose, source) {
// Если позы нет в списке доступных — откатываемся на coffee
if (!AVAILABLE_POSES.has(pose)) {
return { pose: FALLBACK_POSE, source: `${source}-fallback(${pose})` };
}
return { pose: name, path: null, exists: false, source };
}
return { pose: name, path: exists ? localPath : null, exists, source };
return { pose, source };
}
/**
* Список доступных поз (для UI).
*/
function listAvailablePoses() {
const out = [];
for (const name of [
'avatar', 'coding', 'tools', 'lock', 'gears',
'eureka', 'confused', 'facepalm', 'victory', 'tired',
'reading', 'magnifier', 'chart', 'meditate', 'present',
'swimming', 'thinking', 'coffee', 'telescope', 'rocket', 'bug', 'sleep', 'thumbsup',
]) {
const p = path.join(UPLOADS_DIR, `zero-${name}.webp`);
out.push({ name, exists: fs.existsSync(p), path: p, url: `/uploads/zero-${name}.webp` });
}
return out;
return [...AVAILABLE_POSES].sort().map(name => ({
name,
url: `/uploads/zero-${name}.webp`,
}));
}
module.exports = { pickPose, listAvailablePoses, CATEGORY_POSES, EMOTIONAL_TRIGGERS };
module.exports = { pickPose, listAvailablePoses, AVAILABLE_POSES, CATEGORY_POSES, EMOTIONAL_TRIGGERS };
+336
View File
@@ -0,0 +1,336 @@
/**
* zeroNotes.js — сервис заметок от персонажа Зеро.
*
* Цикл одной заметки:
* 1) 13:00 МСК — generateDraft(channelId) → status='draft', scheduled_at=ЗАВТРА 13:00
* 2) Вечером — редактор вручную: approveManual / editContent / skip / regenerate
* 3) 09:00 МСК — autoApproveOldDrafts(): неподтверждённые draft → 'approved' (approved_by='auto')
* 4) 13:00 следующего дня — runner забирает 'approved' и публикует
*
* Здесь только бизнес-логика. Расписание — в src/workers/zeroNotesScheduler.js.
* Публикация в TG/на сайт — в src/services/zeroNotesRunner.js (отдельный шаг).
*/
const { query } = require('../config/db');
const settings = require('./settings');
const ai = require('./ai');
const zeroChar = require('./zeroCharacter');
const zPrompt = require('./zeroPrompt');
// ───────────────────────────────────────────────────────────────────────────
// УТИЛИТЫ ВРЕМЕНИ
// ───────────────────────────────────────────────────────────────────────────
const MSK_OFFSET_MIN = 3 * 60; // UTC+3
/** Текущее время в МСК как { hour, minute, dateYMD } */
function nowMsk() {
const now = new Date();
const msk = new Date(now.getTime() + MSK_OFFSET_MIN * 60_000);
return {
date: msk,
hour: msk.getUTCHours(),
minute: msk.getUTCMinutes(),
ymd: msk.toISOString().slice(0, 10),
};
}
/**
* Публикационный слот — ВСЕГДА СЛЕДУЮЩИЙ ДЕНЬ в publishHour МСК (13:00 по умолчанию).
* Механика как у черновиков статей: сегодня сгенерили → завтра в 13:00 публикуем
* (если подтвердили; иначе авто-одобрение в 09:00 того же дня).
* Возвращаем UTC Date для записи в TIMESTAMPTZ.
*/
function nextPublishSlot({ publishHourMsk = 13, publishMinMsk = 0 } = {}) {
const { date: nowM } = nowMsk();
const isFuture = false; // всегда планируем на завтра — заметка должна "отлежаться" сутки на подтверждение
const baseUtc = new Date(nowM.getTime() - MSK_OFFSET_MIN * 60_000);
baseUtc.setUTCHours(publishHourMsk - 3, publishMinMsk, 0, 0); // UTC-эквивалент МСК-часа
if (!isFuture) baseUtc.setUTCDate(baseUtc.getUTCDate() + 1);
return baseUtc;
}
// ───────────────────────────────────────────────────────────────────────────
// НАСТРОЙКИ
// ───────────────────────────────────────────────────────────────────────────
/**
* Список channel_id для которых работают заметки Зеро.
* Источник: app_settings.ZERO_NOTES_CHANNEL_IDS = "1,2,5" (csv int) или ENV.
* Если пусто — заметки выключены.
*/
async function getActiveChannelIds() {
const raw = await settings.get('ZERO_NOTES_CHANNEL_IDS', '');
if (!raw) return [];
return String(raw).split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite);
}
async function getModel() {
return await settings.get('ZERO_NOTES_MODEL', process.env.AI_MODEL_POST || 'claude-haiku-4-5-20251001');
}
async function getPublishHour() {
return parseInt(await settings.get('ZERO_NOTES_PUBLISH_HOUR', '13'), 10);
}
// ───────────────────────────────────────────────────────────────────────────
// ВЫБОРКИ
// ───────────────────────────────────────────────────────────────────────────
/**
* Последние N заметок (любого статуса кроме failed/skipped) — для anti-repeat в промпте.
*/
async function getRecentForPrompt(channelId, limit = 30) {
const { rows } = await query(
`SELECT id, theme, theme_bucket, theme_hash
FROM zero_notes
WHERE channel_id = $1
AND status NOT IN ('failed', 'skipped')
ORDER BY created_at DESC
LIMIT $2`,
[channelId, limit]
);
return rows;
}
async function getById(noteId) {
const { rows } = await query('SELECT * FROM zero_notes WHERE id=$1', [noteId]);
return rows[0] || null;
}
async function listByStatus(channelId, status, { limit = 50 } = {}) {
const { rows } = await query(
`SELECT * FROM zero_notes
WHERE channel_id=$1 AND status=$2
ORDER BY scheduled_at ASC NULLS LAST, created_at DESC
LIMIT $3`,
[channelId, status, limit]
);
return rows;
}
/**
* Был ли уже сгенерирован draft за сегодня (по МСК) для канала.
* Защита от двойного запуска scheduler-tick'а в одну минуту.
*/
async function hasDraftToday(channelId) {
const { rows } = await query(
`SELECT 1 FROM zero_notes
WHERE channel_id = $1
AND (created_at AT TIME ZONE 'Europe/Moscow')::date = (NOW() AT TIME ZONE 'Europe/Moscow')::date
LIMIT 1`,
[channelId]
);
return rows.length > 0;
}
// ───────────────────────────────────────────────────────────────────────────
// ГЕНЕРАЦИЯ
// ───────────────────────────────────────────────────────────────────────────
/**
* Сгенерировать черновик заметки для канала.
* Возвращает запись из zero_notes (status='draft').
*
* @param {number} channelId
* @param {object} [opts]
* @param {string} [opts.forceBucket] — принудительное ведро темы (для админки/ручного теста)
* @param {boolean} [opts.dryRun] — не сохранять в БД (вернуть только текст и план)
*/
async function generateDraft(channelId, opts = {}) {
const { forceBucket = null, dryRun = false, allowDup = false } = opts;
// 1. Канал должен существовать
const { rows: [channel] } = await query('SELECT id, name FROM channels WHERE id=$1', [channelId]);
if (!channel) throw new Error(`channel ${channelId} not found`);
// 2. Антидубль по дню (можно пробить через allowDup — для админской кнопки "regenerate")
if (!dryRun && !allowDup && await hasDraftToday(channelId)) {
console.log(`[zeroNotes] channel=${channelId} skip: draft уже создан сегодня`);
return null;
}
// 3. Промпт
const recent = await getRecentForPrompt(channelId, 30);
const prompt = zPrompt.buildPrompt({ recentNotes: recent, forceBucket });
// 4. Вызов AI
const model = await getModel();
const t0 = Date.now();
const { text, usage } = await ai.chat(
model,
prompt.system,
prompt.user,
{ maxTokens: 600, temperature: 0.85 }
);
const dtMs = Date.now() - t0;
// 5. Чистим артефакты (markdown-обёртки если вдруг)
const content = text
.replace(/^\s*```(?:markdown|md|text)?\s*/i, '')
.replace(/\s*```\s*$/i, '')
.trim();
// 6. Подбираем позу Зеро по тексту
const pose = zeroChar.pickPose({
title: prompt.themeHint,
excerpt: content.slice(0, 400),
category: 'ai-tools',
});
const imageUrl = `/uploads/zero-${pose.pose}.webp`;
// 7. theme_hash для дедупа
const themeHash = zPrompt.normalizeTheme(prompt.themeHint);
// 8. scheduled_at = ближайший слот публикации в МСК (час берётся из настроек)
const publishHour = await getPublishHour();
const scheduledAt = nextPublishSlot({ publishHourMsk: publishHour });
if (dryRun) {
return {
dryRun: true,
channel_id: channelId,
content,
theme: prompt.themeHint,
theme_bucket: prompt.bucket,
theme_hash: themeHash,
pose: pose.pose,
image_url: imageUrl,
scheduled_at: scheduledAt,
model,
tokens_in: usage.prompt_tokens,
tokens_out: usage.completion_tokens,
duration_ms: dtMs,
};
}
// 9. Сохраняем в БД
const { rows: [saved] } = await query(
`INSERT INTO zero_notes
(channel_id, content, theme, theme_bucket, theme_hash,
pose, image_url, status, scheduled_at,
tokens_in, tokens_out, model, generation_meta)
VALUES ($1,$2,$3,$4,$5,$6,$7,'draft',$8,$9,$10,$11,$12)
RETURNING *`,
[
channelId, content, prompt.themeHint, prompt.bucket, themeHash,
pose.pose, imageUrl, scheduledAt,
usage.prompt_tokens || null, usage.completion_tokens || null, model,
JSON.stringify({ pose_source: pose.source, duration_ms: dtMs, recent_buckets: recent.map(r => r.theme_bucket) }),
]
);
console.log(`[zeroNotes] channel=${channelId} draft #${saved.id} bucket=${prompt.bucket} pose=${pose.pose} ${dtMs}ms`);
return saved;
}
// ───────────────────────────────────────────────────────────────────────────
// ОДОБРЕНИЕ / РЕДАКТИРОВАНИЕ
// ───────────────────────────────────────────────────────────────────────────
async function approveManual(noteId, by = 'editor') {
const { rows: [n] } = await query(
`UPDATE zero_notes
SET status='approved', approved_at=NOW(), approved_by=$2, updated_at=NOW()
WHERE id=$1 AND status IN ('draft','approved')
RETURNING *`,
[noteId, by]
);
return n || null;
}
async function skipNote(noteId, reason = '') {
const { rows: [n] } = await query(
`UPDATE zero_notes
SET status='skipped', error=$2, updated_at=NOW()
WHERE id=$1 AND status IN ('draft','approved')
RETURNING *`,
[noteId, reason || null]
);
return n || null;
}
async function editContent(noteId, { content, theme, pose, imageUrl, scheduledAt }) {
const sets = ['updated_at=NOW()'];
const vals = [];
let i = 1;
if (content !== undefined) { sets.push(`content=$${i++}`); vals.push(content); }
if (theme !== undefined) { sets.push(`theme=$${i++}, theme_hash=$${i++}`); vals.push(theme, zPrompt.normalizeTheme(theme)); }
if (pose !== undefined) { sets.push(`pose=$${i++}, image_url=$${i++}`); vals.push(pose, `/uploads/zero-${pose}.webp`); }
if (imageUrl !== undefined) { sets.push(`image_url=$${i++}`); vals.push(imageUrl); }
if (scheduledAt !== undefined) { sets.push(`scheduled_at=$${i++}`); vals.push(scheduledAt); }
vals.push(noteId);
const { rows: [n] } = await query(
`UPDATE zero_notes SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
vals
);
return n || null;
}
/**
* Авто-одобрение: все draft со scheduled_at <= NOW()+8h → approved (approved_by='auto').
* Запускается в 07:00 МСК — переводит все вчерашние draft в готовые к публикации в 13:00.
*/
async function autoApproveOldDrafts() {
// Запускается в 09:00 МСК (ZERO_NOTES_APPROVE_HOUR). Авто-одобряет драфты,
// чей день публикации уже наступил (scheduled_at сегодня или раньше), а юзер
// не подтвердил вручную. Так заметка всё равно выйдет в свой слот (13:00).
const { rows } = await query(
`UPDATE zero_notes
SET status='approved', approved_at=NOW(), approved_by='auto', updated_at=NOW()
WHERE status='draft'
AND scheduled_at IS NOT NULL
AND scheduled_at <= NOW() + INTERVAL '6 hours'
RETURNING id, channel_id, theme_bucket`
);
if (rows.length) {
console.log(`[zeroNotes] auto-approved ${rows.length} draft(s) (не подтверждены к утру): ${rows.map(r => r.id).join(', ')}`);
}
return rows;
}
// ───────────────────────────────────────────────────────────────────────────
// ПУБЛИЧНОЕ API ДЛЯ САЙТА
// ───────────────────────────────────────────────────────────────────────────
async function listPublished({ channelId = null, limit = 20, offset = 0 } = {}) {
const params = [limit, offset];
let where = `status='published'`;
if (channelId) {
params.push(channelId);
where += ` AND channel_id=$${params.length}`;
}
const { rows } = await query(
`SELECT id, channel_id, content, theme, theme_bucket, pose, image_url,
published_at, channel_message_id
FROM zero_notes
WHERE ${where}
ORDER BY published_at DESC
LIMIT $1 OFFSET $2`,
params
);
return rows;
}
module.exports = {
// генерация
generateDraft,
// workflow
approveManual,
skipNote,
editContent,
autoApproveOldDrafts,
// выборки
getById,
listByStatus,
listPublished,
getRecentForPrompt,
hasDraftToday,
// настройки
getActiveChannelIds,
getModel,
getPublishHour,
// утилиты времени
nowMsk,
nextPublishSlot,
};
+147
View File
@@ -0,0 +1,147 @@
/**
* zeroNotesRunner.js — публикация approved-заметок Зеро в Telegram.
*
* Запускается scheduler'ом раз в минуту: publishReady().
*
* Логика:
* 1) Атомарно: status='approved' AND scheduled_at <= NOW() → status='sending', attempts++
* (FOR UPDATE SKIP LOCKED — защита от двойного раннера)
* 2) Шлём в TG:
* - если есть локальный pose-файл — sendPhoto с multipart
* - иначе sendMessage (только текст)
* - опционально inline-кнопка "Открыть на сайте" если ZERO_SITE_URL_BASE задан
* 3) Успех → status='published', published_at=NOW(), channel_message_id
* Ошибка → если attempts < MAX_ATTEMPTS вернуть в 'approved' для ретрая,
* иначе 'failed' c сохранением error
*/
const { query } = require('../config/db');
const settings = require('./settings');
const { tgSend, extractTgError } = require('./tgSend');
const MAX_ATTEMPTS = 3;
/**
* Берёт ОДНУ approved-заметку готовую к публикации (scheduled_at <= NOW),
* атомарно переводит её в 'sending', возвращает строку или null.
*/
async function claimNextReady() {
const { rows } = await query(`
UPDATE zero_notes
SET status='sending', attempts=attempts+1, updated_at=NOW()
WHERE id = (
SELECT id FROM zero_notes
WHERE status='approved' AND scheduled_at <= NOW()
ORDER BY scheduled_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *
`);
return rows[0] || null;
}
async function getChannel(channelId) {
const { rows: [c] } = await query('SELECT * FROM channels WHERE id=$1', [channelId]);
return c || null;
}
/**
* Кнопка "Читать на сайте" — ведёт на страницу заметок Зеро.
* ZERO_SITE_URL_BASE по умолчанию https://zeropost.ru/zero.
* Отдельной страницы /zero/:id пока нет, поэтому ведём на общий /zero
* (если появится — добавить `/${noteId}`).
*/
async function buildReplyMarkup(_noteId) {
const base = await settings.get('ZERO_SITE_URL_BASE', 'https://zeropost.ru/zero');
if (!base) return undefined;
return {
inline_keyboard: [[
{ text: '💬 Читать на сайте', url: base.replace(/\/$/, '') },
]],
};
}
async function sendToTelegram(note, channel) {
const reply_markup = await buildReplyMarkup(note.id);
// Заметки Зеро пишутся обычным текстом (без Markdown-разметки) — parseMode не задаём,
// чтобы случайные * и _ в тексте не ломали парсинг (была ошибка "can't parse entities").
return tgSend({
botToken: channel.bot_token,
chatId: channel.tg_channel_id,
text: note.content,
photoUrl: note.image_url,
replyMarkup: reply_markup,
parseMode: undefined,
});
}
async function markPublished(noteId, messageId) {
await query(
`UPDATE zero_notes
SET status='published', published_at=NOW(), channel_message_id=$2,
error=NULL, updated_at=NOW()
WHERE id=$1`,
[noteId, messageId || null]
);
}
async function markFailedOrRetry(note, errorMsg) {
const failPermanent = note.attempts >= MAX_ATTEMPTS;
const newStatus = failPermanent ? 'failed' : 'approved';
await query(
`UPDATE zero_notes
SET status=$2, error=$3, updated_at=NOW()
WHERE id=$1`,
[note.id, newStatus, errorMsg.slice(0, 1000)]
);
return { failPermanent, newStatus };
}
/**
* Основная функция — обрабатывает одну заметку готовую к публикации.
* Возвращает { processed: bool, noteId?, messageId?, error? }
*/
async function publishOne() {
const note = await claimNextReady();
if (!note) return { processed: false };
let channel;
try {
channel = await getChannel(note.channel_id);
if (!channel) throw new Error(`channel ${note.channel_id} not found`);
} catch (err) {
await markFailedOrRetry(note, err.message);
return { processed: true, noteId: note.id, error: err.message };
}
try {
const messageId = await sendToTelegram(note, channel);
await markPublished(note.id, messageId);
console.log(`[zeroNotes/runner] published #${note.id} → tg_msg=${messageId} channel=${channel.id}`);
return { processed: true, noteId: note.id, messageId };
} catch (err) {
const errMsg = extractTgError(err);
const r = await markFailedOrRetry(note, errMsg);
console.error(`[zeroNotes/runner] FAIL #${note.id} attempt=${note.attempts}${r.newStatus}: ${errMsg}`);
return { processed: true, noteId: note.id, error: errMsg, status: r.newStatus };
}
}
/**
* Запустить раннер: публикует до `limit` заметок за один тик.
* Возвращает количество обработанных.
*/
async function publishReady({ limit = 5 } = {}) {
let count = 0;
for (let i = 0; i < limit; i++) {
const r = await publishOne();
if (!r.processed) break;
count++;
}
return count;
}
module.exports = { publishReady, publishOne, claimNextReady };
+321
View File
@@ -0,0 +1,321 @@
/**
* zeroPrompt.js — генерация промпта для AI-персонажа Зеро.
*
* Зеро — программист с юмором, любит кофе, постит короткие заметки 50-150 слов
* от первого лица. 1 заметка в день в 13:00 МСК в @zeropostru.
*
* Экспорт:
* - buildPrompt({ recentNotes, forceBucket? }) → { system, user, bucket, themeHint }
* - normalizeTheme(theme) → theme_hash (для дедупа)
* - THEME_BUCKETS, CHARACTER — для тестов и UI
*/
const crypto = require('crypto');
// ───────────────────────────────────────────────────────────────────────────
// ПЕРСОНАЖ
// ───────────────────────────────────────────────────────────────────────────
const CHARACTER = {
name: 'Зеро',
bio: [
'Программист с многолетним опытом, любит копаться под капотом.',
'Постоянно носится с кофе — без него мысли не текут.',
'Работает с AI-API, Docker, Linux, self-hosted всем подряд, иногда ESP32 и Orange Pi.',
'Любит маленькие умные решения, не любит overengineering.',
'Юмор — лёгкий, дружелюбный, без сарказма и понта.',
],
voice: [
'Пишет от первого лица: "я", "у меня", "вчера сидел и…"',
'Обращается к читателю на "ты" — как к коллеге за соседним столом.',
'Может вставить лёгкое наблюдение или короткий вывод, но без пафоса и без "вот 5 советов".',
'Допустимы редкие технические термины, но без понтов и без "магических" фраз.',
'Если ошибается — спокойно об этом говорит, без самобичевания.',
],
forbidden: [
'НЕ начинай заметку со слова "Сегодня" или "Привет, друзья" — звучит как корпоративный SMM.',
'НЕ используй буллиты, заголовки, нумерованные списки. Это пост, а не статья.',
'НЕ ставь хэштеги — канал не SMM-помойка.',
'НЕ обещай "разобрать в следующем посте" и не делай тизеров.',
'НЕ пиши "Дорогие читатели", "Уважаемые подписчики" и подобный пафос.',
'НЕ используй кликбейт типа "Шок! Никто не знает, что..."',
'НЕ ставь больше одного эмодзи на заметку (и лучше вообще без них).',
'НЕ упоминай конкретные торговые марки в негативном ключе — нейтрально только.',
'НЕ задавай встречных вопросов и не уточняй задачу — сразу пиши заметку.',
'НЕ начинай ответ со слов "Понял", "Конечно", "Хорошо", "Сейчас напишу" — это служебные фразы, их быть не должно.',
],
};
// ───────────────────────────────────────────────────────────────────────────
// ВЁДРА ТЕМ — для разнообразия. Anti-repeat исключает последние N.
// ───────────────────────────────────────────────────────────────────────────
const THEME_BUCKETS = [
{
key: 'ai_industry',
label: 'AI-индустрия',
examples: [
'свежий релиз модели и что в нём цепляет на практике',
'неожиданный поворот в гонке провайдеров (OpenAI / Anthropic / Mistral / Qwen)',
'почему вчерашний хайп вокруг "AGI к концу года" так быстро сдулся',
'почему open-source модели догоняют закрытые быстрее, чем казалось',
],
},
{
key: 'tools',
label: 'Инструменты',
examples: [
'мини-обзор CLI-утилиты, которую недавно нашёл и теперь юзаю каждый день',
'почему перешёл с одного редактора на другой и о чём жалею',
'неожиданная фича в знакомом инструменте, мимо которой все ходят',
'связка двух простых тулзов, которая внезапно решила большую проблему',
],
},
{
key: 'bug_story',
label: 'Забавный баг',
examples: [
'баг, который правил три часа, а оказался опечаткой в одном символе',
'история про "у меня работает" между двумя одинаковыми машинами',
'крон, который убивал всё подряд по таймеру, и как я его ловил',
'таймзонный баг, всплывший только в проде в полночь',
],
},
{
key: 'dev_musing',
label: 'Размышление о разработке',
examples: [
'почему "написать с нуля" соблазнительно, но почти всегда плохая идея',
'почему документация устаревает быстрее, чем код',
'про разницу между "работает" и "понятно как чинить когда сломается"',
'когда абстракция помогает, а когда — закапывает',
],
},
{
key: 'workflow',
label: 'Воркфлоу',
examples: [
'как я организую черновики и почему перестал держать всё в голове',
'минимальный набор команд в shell, без которого больно',
'короткий цикл "правка → проверка" — почему он важнее любых фреймворков',
'как делю задачу на куски, чтобы не залипать на старте',
],
},
{
key: 'coffee_thoughts',
label: 'Кофейные мысли',
examples: [
'мысль, которая пришла на третьей чашке — про природу багов',
'почему утренний кофе и первая дебаг-сессия — лучшее сочетание',
'когда чашка кофе помогла больше, чем час чтения документации',
'почему я перестал писать код без чашки рядом',
],
},
{
key: 'anti_pattern',
label: 'Анти-паттерн',
examples: [
'почему "сейчас быстро добавим" обычно превращается в технический долг',
'про закомментированный код, который никто никогда не удалит',
'про конфиги, которые проще хардкодить, чем потом разгребать',
'когда логирование "на всякий случай" превращается в DDoS на диск',
],
},
{
key: 'iot_hw',
label: 'Железо и IoT',
examples: [
'как ESP32 учит покорности — отладка на железе vs на ноутбуке',
'про разницу между чтением даташита и реальным поведением чипа',
'забавный момент с relay-модулем и индуктивной нагрузкой',
'почему "просто прошить" редко бывает "просто"',
],
},
{
key: 'self_host',
label: 'Self-hosted',
examples: [
'почему свой сервер — это и свобода, и круглосуточная ответственность',
'про момент когда осознал, что Docker — не панацея',
'про nginx-конфиг, в котором я каждый раз заново разбираюсь',
'про бэкапы, которые не делал, пока не пришлось их искать',
],
},
{
key: 'observation',
label: 'Наблюдение',
examples: [
'про то, что весь "продвинутый" AI всё равно ломается на банальных edge-кейсах',
'что общего у git rebase и приготовления яичницы',
'почему 80% времени уходит на 20% задачи',
'как меняется восприятие старого кода через полгода',
],
},
{
key: 'story',
label: 'Короткая история',
examples: [
'"вчера сидел до утра, разбирался с одной строкой..."',
'как друг-фронтендер открыл для себя SSH и теперь не молчит',
'про деплой в пятницу вечером (несмотря на все мемы)',
'как один странный комментарий в коде сэкономил мне час',
],
},
{
key: 'quick_tip',
label: 'Короткий совет',
examples: [
'один shell-приём, который экономит мне минуту в день',
'почему стоит начинать день с просмотра вчерашних логов',
'короткий чек-лист перед "git push --force"',
'один параметр в curl, про который часто забывают',
],
},
];
const BUCKET_BY_KEY = Object.fromEntries(THEME_BUCKETS.map(b => [b.key, b]));
// ───────────────────────────────────────────────────────────────────────────
// FEW-SHOTS — эталоны стиля. Подаём 2 примера, чтобы AI поймал интонацию.
// ───────────────────────────────────────────────────────────────────────────
const FEW_SHOTS = [
{
bucket: 'bug_story',
text: `Три часа ловил баг, из-за которого крон-задача убивала docker-контейнер каждую минуту. Думал — память течёт, думал — health-check злой, думал — может я сам что-то спросонья сделал.
Оказалось, я когда-то давно положил в /etc/cron.d скрипт-watchdog, который должен был "поднимать" сервис если упал. Только условие "упал" он определял по статусу старой версии compose-файла. Сервис давно переехал, а watchdog продолжал честно его "поднимать" — пересоздавая контейнер заново.
Мораль скучная: всегда смотри, что у тебя крутится в /etc/cron.d. Особенно то, что ты сам туда положил полгода назад с пометкой "временно".`,
},
{
bucket: 'coffee_thoughts',
text: `Заметил странную закономерность: самые рабочие идеи приходят не за клавиатурой, а пока завариваю кофе. Между "нажал кнопку" и "налил в чашку" мозг как будто перестаёт держать задачу за рукав — и ровно в этот момент она сама собирается в голове.
Психологи это, наверное, как-то умно называют. Я для себя называю проще: думать руками не всегда полезно. Иногда полезно просто перестать.
Сейчас, кстати, как раз завариваю вторую. Если ещё через десять минут не пойму, как разрулить один баг — пойду варить третью.`,
},
];
// ───────────────────────────────────────────────────────────────────────────
// УТИЛИТЫ
// ───────────────────────────────────────────────────────────────────────────
const STOP_WORDS = new Set([
'и','в','на','с','по','для','от','до','из','к','а','но','что','как','это',
'про','же','бы','ли','не','то','я','ты','он','она','мы','вы','они','быть',
'мой','твой','наш','свой','этот','тот','очень','уже','ещё','еще','если',
]);
/**
* Нормализует тему в стабильный хэш для дедупа.
* Шаги: lower → выкинуть пунктуацию → split → выкинуть стоп-слова → sort → join первых 8.
*/
function normalizeTheme(theme) {
if (!theme) return '';
const words = String(theme)
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
.split(/\s+/)
.filter(w => w.length > 2 && !STOP_WORDS.has(w));
const top = words.sort().slice(0, 8);
return crypto.createHash('sha256').update(top.join(' ')).digest('hex').slice(0, 16);
}
/**
* Выбирает ведро для генерации.
* Anti-repeat: исключаем последние N использованных ведёр.
*/
function pickBucket({ recentBuckets = [], avoidLast = 5, forceBucket = null } = {}) {
if (forceBucket && BUCKET_BY_KEY[forceBucket]) return BUCKET_BY_KEY[forceBucket];
const recentSet = new Set(recentBuckets.slice(-avoidLast));
const pool = THEME_BUCKETS.filter(b => !recentSet.has(b.key));
const candidates = pool.length > 0 ? pool : THEME_BUCKETS;
return candidates[Math.floor(Math.random() * candidates.length)];
}
// ───────────────────────────────────────────────────────────────────────────
// СБОРКА ПРОМПТА
// ───────────────────────────────────────────────────────────────────────────
/**
* @param {object} opts
* @param {Array<{theme:string, theme_hash:string, theme_bucket:string, content?:string}>} opts.recentNotes — последние заметки для дедупа
* @param {string} [opts.forceBucket] — принудительное ведро (для теста или ручной генерации)
* @returns {{system:string, user:string, bucket:string, themeHint:string, fewShots:Array}}
*/
function buildPrompt({ recentNotes = [], forceBucket = null } = {}) {
const recentBuckets = recentNotes.map(n => n.theme_bucket).filter(Boolean);
const bucket = pickBucket({ recentBuckets, forceBucket });
// Подбираем подсказку темы — случайный example из ведра
const themeHint = bucket.examples[Math.floor(Math.random() * bucket.examples.length)];
// Список последних тем для антидубля (подаём в промпт)
const avoidList = recentNotes
.slice(0, 30)
.map(n => n.theme)
.filter(Boolean)
.map((t, i) => `${i + 1}. ${t}`)
.join('\n');
const system = [
`Ты — ${CHARACTER.name}. Голос Telegram-канала @zeropostru.`,
'',
'Кто ты:',
...CHARACTER.bio.map(s => `${s}`),
'',
'Как пишешь:',
...CHARACTER.voice.map(s => `${s}`),
'',
'Что НЕ делаешь:',
...CHARACTER.forbidden.map(s => `${s}`),
'',
'Формат ответа:',
'— Только текст заметки. Никаких заголовков, markdown, JSON, комментариев.',
'— Объём: 50–150 слов (это пост в TG, не эссе).',
'— 1–3 коротких абзаца. Между абзацами — пустая строка.',
'— Первая фраза не должна быть шаблонной ("Сегодня..." / "Привет..." / "Сейчас расскажу...").',
'— Первый символ ответа — уже начало самой заметки. Никаких преамбул, мета-комментариев, уточнений.',
].join('\n');
// Few-shots в виде блока
const shotsBlock = FEW_SHOTS
.map((s, i) => `### Пример ${i + 1} (ведро: ${s.bucket})\n${s.text}`)
.join('\n\n---\n\n');
const user = [
`Ведро темы: **${bucket.label}** (${bucket.key}).`,
`Подсказка темы: «${themeHint}». Можешь немного отойти, но в рамках ведра.`,
'',
'Вот примеры твоего стиля — держи похожую интонацию, длину, структуру:',
'',
shotsBlock,
'',
avoidList
? `Эти темы УЖЕ выходили в канале за последнее время — НЕ повторяй ни по сути, ни по углу подачи:\n${avoidList}\n`
: '',
'Выбери конкретную микро-тему внутри ведра сам — у тебя достаточно подсказок выше. Уточнений не жди.',
'Сразу пиши саму заметку. Никаких "Понял", "Сейчас расскажу", никаких пометок про задачу. Первая строка ответа = первая строка поста.',
].filter(Boolean).join('\n');
return {
system,
user,
bucket: bucket.key,
themeHint,
fewShots: FEW_SHOTS.map(s => s.bucket),
};
}
module.exports = {
buildPrompt,
normalizeTheme,
pickBucket,
THEME_BUCKETS,
BUCKET_BY_KEY,
CHARACTER,
FEW_SHOTS,
};
+105
View File
@@ -0,0 +1,105 @@
/**
* zeroNotesScheduler.js — расписание для заметок Зеро.
*
* Запускается из index.js: require('./src/workers/zeroNotesScheduler').start();
*
* Тик каждые 60 сек, в МСК:
* - 13:00 → generateDraft для каждого активного канала (планирует на завтра 13:00)
* - 07:00 → autoApproveOldDrafts (переводит вчерашние draft в approved)
* - публикация в TG — отдельный раннер (следующий шаг)
*
* Защита от двойного запуска: in-memory флаг по YMD-минуте.
*/
const zeroNotes = require('../services/zeroNotes');
const zeroRunner = require('../services/zeroNotesRunner');
const settings = require('../services/settings');
const TICK_MS = 60_000;
// Отметка последнего успешного тика по slot'у: { generate: '2026-06-19T13:00', approve: '2026-06-19T07:00' }
const lastRun = {};
async function generateHourMsk() { return parseInt(await settings.get('ZERO_NOTES_GENERATE_HOUR', '13'), 10); }
async function approveHourMsk() { return parseInt(await settings.get('ZERO_NOTES_APPROVE_HOUR', '7'), 10); }
function slotKey(ymd, hour) {
return `${ymd}T${String(hour).padStart(2, '0')}:00`;
}
async function runGeneration(ymd) {
const key = slotKey(ymd, await generateHourMsk());
if (lastRun.generate === key) return;
lastRun.generate = key;
const channelIds = await zeroNotes.getActiveChannelIds();
if (!channelIds.length) {
console.log('[zeroNotes/scheduler] 13:00 МСК — нет активных каналов (ZERO_NOTES_CHANNEL_IDS пусто)');
return;
}
console.log(`[zeroNotes/scheduler] 13:00 МСК — генерация для каналов: ${channelIds.join(', ')}`);
for (const channelId of channelIds) {
try {
const saved = await zeroNotes.generateDraft(channelId);
if (saved) {
console.log(`[zeroNotes/scheduler] channel=${channelId} → draft #${saved.id}, scheduled=${saved.scheduled_at?.toISOString?.() || saved.scheduled_at}`);
}
} catch (err) {
console.error(`[zeroNotes/scheduler] channel=${channelId} generation FAILED: ${err.message}`);
}
}
}
async function runAutoApprove(ymd) {
const key = slotKey(ymd, await approveHourMsk());
if (lastRun.approve === key) return;
lastRun.approve = key;
console.log(`[zeroNotes/scheduler] 07:00 МСК — авто-одобрение драфтов`);
try {
await zeroNotes.autoApproveOldDrafts();
} catch (err) {
console.error(`[zeroNotes/scheduler] auto-approve FAILED: ${err.message}`);
}
}
async function tick() {
const { hour, ymd } = zeroNotes.nowMsk();
try {
const [genHour, appHour] = [await generateHourMsk(), await approveHourMsk()];
if (hour === genHour) await runGeneration(ymd);
if (hour === appHour) await runAutoApprove(ymd);
// публикация approved-заметок в TG (каждую минуту)
// limit:1 — строго одна заметка за тик. Заметки генерятся 1/день, и даже
// если накопилось несколько approved (например после ручного ретрая), они
// НЕ улетят пачкой — будут публиковаться по одной с интервалом в минуту.
const published = await zeroRunner.publishReady({ limit: 1 });
if (published > 0) console.log(`[zeroNotes/scheduler] published ${published} note(s)`);
} catch (err) {
console.error(`[zeroNotes/scheduler] tick error: ${err.message}`);
}
}
let intervalRef = null;
function start() {
if (intervalRef) {
console.log('[zeroNotes/scheduler] already started');
return;
}
intervalRef = setInterval(tick, TICK_MS);
// первый тик через 30 сек после старта (даём engine стабильно подняться)
setTimeout(tick, 30_000);
Promise.all([generateHourMsk(), approveHourMsk()]).then(([gh, ah]) =>
console.log(`[zeroNotes/scheduler] started, tick every ${TICK_MS/1000}s, generate=${gh}:00 MSK, auto-approve=${ah}:00 MSK (dynamic)`)
).catch(() => {});
}
function stop() {
if (intervalRef) {
clearInterval(intervalRef);
intervalRef = null;
}
}
module.exports = { start, stop, tick, runGeneration, runAutoApprove };