Compare commits
40 Commits
9b40f2cd7a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4694093080 | |||
| e1f2f44365 | |||
| 90f6b474a1 | |||
| 1f25adff00 | |||
| 174c3a17c1 | |||
| 1ced06fa2d | |||
| 630287f02f | |||
| f40bb27953 | |||
| 48e0bae495 | |||
| 45c3f2b562 | |||
| c920d3bcd5 | |||
| 214bf307c7 | |||
| 799816f66a | |||
| 749d717a94 | |||
| a09ee4a5fb | |||
| 325ebe7759 | |||
| bdff84e579 | |||
| b02bdba4e6 | |||
| 7b115deaa1 | |||
| 59e604a67b | |||
| 2f7af84ddc | |||
| 4ffadc6baa | |||
| 29788a8f9d | |||
| 5b5f703078 | |||
| eca072a172 | |||
| 707047a7af | |||
| 08a2628824 | |||
| efe85632f5 | |||
| c147c9e839 | |||
| dccb662298 | |||
| f9d1deae58 | |||
| 0a842476d7 | |||
| e5965e2804 | |||
| d9cbbc5fbf | |||
| 5852b9f439 | |||
| cd471d67a9 | |||
| bede92a520 | |||
| 525870c709 | |||
| 31b31b75b8 | |||
| c40ef90ad1 |
+19
@@ -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"]
|
||||||
@@ -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 если нужно)
|
||||||
@@ -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/
|
||||||
@@ -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 };
|
||||||
@@ -11,7 +11,9 @@ const statsRoutes = require('./src/routes/stats');
|
|||||||
const notesRoutes = require('./src/routes/notes');
|
const notesRoutes = require('./src/routes/notes');
|
||||||
const seriesRoutes = require('./src/routes/series');
|
const seriesRoutes = require('./src/routes/series');
|
||||||
const categoriesRoutes = require('./src/routes/categories');
|
const categoriesRoutes = require('./src/routes/categories');
|
||||||
|
const categoriesAdminRoutes = require('./src/routes/categoriesAdmin');
|
||||||
const autogenRoutes = require('./src/routes/autogen');
|
const autogenRoutes = require('./src/routes/autogen');
|
||||||
|
const draftsRoutes = require('./src/routes/drafts');
|
||||||
const userPostsRoutes = require('./src/routes/userPosts');
|
const userPostsRoutes = require('./src/routes/userPosts');
|
||||||
const settingsRoutes = require('./src/routes/settings');
|
const settingsRoutes = require('./src/routes/settings');
|
||||||
const photoSearchRoutes = require('./src/routes/photo-search');
|
const photoSearchRoutes = require('./src/routes/photo-search');
|
||||||
@@ -29,6 +31,20 @@ require('./src/services/metricsCollector').startAutoCollect();
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
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 path = require('path');
|
||||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
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');
|
const inboxRoutes = require('./src/routes/inbox');
|
||||||
app.use('/api', inboxRoutes); // включает /api/tg-webhook/:channelId
|
app.use('/api', inboxRoutes); // включает /api/tg-webhook/:channelId
|
||||||
|
|
||||||
|
// Заметки Зеро — публичная часть (для сайта zeropost.ru/zero)
|
||||||
|
app.use('/api/zero', require('./src/routes/zero'));
|
||||||
|
|
||||||
// Simple internal auth middleware
|
// Simple internal auth middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const secret = req.headers['x-internal-secret'];
|
const secret = req.headers['x-internal-secret'];
|
||||||
@@ -96,6 +115,7 @@ app.use('/api/notes', notesRoutes);
|
|||||||
app.use('/api/series', seriesRoutes);
|
app.use('/api/series', seriesRoutes);
|
||||||
app.use('/api/categories', categoriesRoutes);
|
app.use('/api/categories', categoriesRoutes);
|
||||||
app.use('/api/autogen', autogenRoutes);
|
app.use('/api/autogen', autogenRoutes);
|
||||||
|
app.use('/api/drafts', draftsRoutes);
|
||||||
app.use('/api/user-posts', userPostsRoutes);
|
app.use('/api/user-posts', userPostsRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/photo-search', photoSearchRoutes);
|
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', inboxRoutes);
|
||||||
app.use('/api', require('./src/routes/drafts'));
|
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) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
|
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
|
||||||
});
|
});
|
||||||
@@ -122,6 +146,19 @@ const start = async () => {
|
|||||||
// Автоматический ретрай SVG-заглушек
|
// Автоматический ретрай SVG-заглушек
|
||||||
require('./src/services/coverRetry').start();
|
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)
|
// Ежедневные авто-черновики (каждые 30 мин проверяем каналы с auto_draft_enabled)
|
||||||
const draftSvc = require('./src/services/draftService');
|
const draftSvc = require('./src/services/draftService');
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
@@ -133,6 +170,9 @@ const start = async () => {
|
|||||||
// Первый запуск через 5 мин после старта
|
// Первый запуск через 5 мин после старта
|
||||||
setTimeout(() => draftSvc.generateDailyDrafts().catch(() => {}), 5 * 60 * 1000);
|
setTimeout(() => draftSvc.generateDailyDrafts().catch(() => {}), 5 * 60 * 1000);
|
||||||
|
|
||||||
|
// Заметки Зеро — генерация в 13:00 МСК + авто-одобрение в 07:00 МСК
|
||||||
|
require('./src/workers/zeroNotesScheduler').start();
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
console.log(`[Engine] Running on port ${config.port}`);
|
console.log(`[Engine] Running on port ${config.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+10
@@ -18,6 +18,7 @@
|
|||||||
"fast-xml-parser": "^4.5.6",
|
"fast-xml-parser": "^4.5.6",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
}
|
}
|
||||||
@@ -1661,6 +1662,15 @@
|
|||||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
"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": {
|
"node_modules/nth-check": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node index.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"fast-xml-parser": "^4.5.6",
|
"fast-xml-parser": "^4.5.6",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -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-----
|
||||||
|
|
||||||
@@ -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 — тематические серии статей
|
// series — тематические серии статей
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS series (
|
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_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_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_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');
|
console.log('[DB] Migrations applied');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+163
-4
@@ -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; }
|
function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; }
|
||||||
|
|
||||||
async function requireAdmin(req, res) {
|
async function requireAdmin(req, res) {
|
||||||
|
// x-internal-secret уже проверен глобальным middleware (см. index.js).
|
||||||
|
// Опционально — если есть users.is_admin (multi-user окружение, как на dev2) — проверим;
|
||||||
|
// если колонки нет (минимальный prod), доверяем секрету.
|
||||||
const adminId = uid(req);
|
const adminId = uid(req);
|
||||||
if (!adminId) { res.status(401).json({ error: 'x-user-id required' }); return null; }
|
if (!adminId) return 'system';
|
||||||
const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]);
|
try {
|
||||||
if (!u?.is_admin) { res.status(403).json({ error: 'Forbidden' }); return null; }
|
const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]);
|
||||||
return 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/dashboard
|
// GET /api/admin/dashboard
|
||||||
@@ -490,3 +498,154 @@ router.delete('/autogen/queue/:id', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
} 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}`); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -204,3 +204,28 @@ router.get('/:slug', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
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
@@ -40,40 +40,91 @@ router.patch('/settings/:category', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/autogen/queue — очередь тем
|
// GET /api/autogen/queue — очередь тем
|
||||||
|
// GET /api/autogen/queue — черновики созданные сегодня (планируется завтра)
|
||||||
router.get('/queue', async (_, res) => {
|
router.get('/queue', async (_, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await query(
|
const { rows } = await query(`
|
||||||
`SELECT * FROM content_queue ORDER BY priority DESC, created_at ASC LIMIT 100`
|
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);
|
res.json(rows);
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
} 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) => {
|
router.post('/queue', async (req, res) => {
|
||||||
try {
|
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' });
|
if (!category || !topic) return res.status(400).json({ error: 'category and topic required' });
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`INSERT INTO content_queue (category, topic, tags, keywords, priority)
|
`INSERT INTO blog_topics (category, topic, source, priority, is_used)
|
||||||
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
VALUES ($1,$2,'manual',$3,false) ON CONFLICT DO NOTHING RETURNING *`,
|
||||||
[category, topic, JSON.stringify(tags), JSON.stringify(keywords), priority]
|
[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 }); }
|
} 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) => {
|
router.delete('/queue/:id', async (req, res) => {
|
||||||
try {
|
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 });
|
res.json({ ok: true });
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/autogen/topics — банк тем
|
// GET /api/autogen/topics — банк тем из БД (с fallback на TOPIC_BANK)
|
||||||
router.get('/topics', async (_, res) => {
|
// ?category=slug — только одна категория
|
||||||
res.json(TOPIC_BANK);
|
// ?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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const { query } = require('../config/db');
|
|||||||
// GET /api/categories
|
// GET /api/categories
|
||||||
router.get('/', async (_, res) => {
|
router.get('/', async (_, res) => {
|
||||||
try {
|
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);
|
res.json(rows);
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
} 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 offset = parseInt(req.query.offset) || 0;
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`SELECT id,slug,title,excerpt,cover_url,tags,category,author,reading_time,published_at
|
`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`,
|
ORDER BY published_at DESC LIMIT $2 OFFSET $3`,
|
||||||
[req.params.slug, limit, offset]
|
[req.params.slug, limit, offset]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
+125
-75
@@ -1,101 +1,151 @@
|
|||||||
/**
|
// Роуты для работы с черновиками (draft review flow)
|
||||||
* 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 — удалить
|
|
||||||
*/
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { query } = require('../config/db');
|
const { query } = require('../config/db');
|
||||||
const channelsSvc = require('../services/channels');
|
const { scheduleForArticle } = require('../services/articleAutoPublish');
|
||||||
const draftSvc = require('../services/draftService');
|
const { nextDripSlot, describeNextSlot } = require('../services/dripScheduler');
|
||||||
|
const { generateCover, COVER_STYLES } = require('../services/covers');
|
||||||
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;
|
|
||||||
|
|
||||||
|
// GET /api/drafts — список черновиков
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const channel = await channelsSvc.getFullChannel(req.params.channelId);
|
const { rows } = await query(
|
||||||
if (!channel) return res.status(404).json({ error: 'Channel not found' });
|
`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({ ok: true, message: `Генерирую ${count} черновиков...`, count });
|
);
|
||||||
|
res.json(rows);
|
||||||
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}`));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!res.headersSent) res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/drafts — все черновики текущего пользователя
|
// GET /api/drafts/cover-styles — доступные стили обложки
|
||||||
router.get('/drafts', async (req, res) => {
|
router.get('/cover-styles', (req, res) => {
|
||||||
const userId = uid(req);
|
res.json(COVER_STYLES.map(s => ({ id: s.name, name: s.name })));
|
||||||
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/:channelId/channel — черновики конкретного канала
|
// PATCH /api/drafts/:id — редактировать черновик (title, content, excerpt)
|
||||||
router.get('/drafts/:channelId/channel', async (req, res) => {
|
router.patch('/:id', 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 {
|
try {
|
||||||
const result = await draftSvc.listDrafts({ channelId: req.params.channelId, status, limit: parseInt(limit), offset: parseInt(offset) });
|
const id = parseInt(req.params.id);
|
||||||
res.json(result);
|
const { title, excerpt, content } = req.body;
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
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 — редактировать
|
// PATCH /api/drafts/:id/approve — одобрить черновик вручную.
|
||||||
router.patch('/drafts/:id', async (req, res) => {
|
// По умолчанию ставит published_at в следующий свободный slot (drip distribution).
|
||||||
|
// ?immediate=true — публикует сразу (published_at = NOW).
|
||||||
|
router.patch('/:id/approve', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await draftSvc.updateDraft(req.params.id, req.body);
|
const id = parseInt(req.params.id);
|
||||||
res.json({ ok: true });
|
const immediate = req.query.immediate === 'true' || req.body?.immediate === true;
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/drafts/:id/approve
|
const slot = immediate ? new Date() : await nextDripSlot();
|
||||||
router.post('/drafts/:id/approve', async (req, res) => {
|
|
||||||
const userId = uid(req);
|
const { rows } = await query(
|
||||||
try {
|
`UPDATE articles SET status='published', published_at=$2
|
||||||
const result = await draftSvc.approveDraft(req.params.id, {
|
WHERE id=$1 AND status='draft'
|
||||||
scheduledAt: req.body.scheduled_at,
|
RETURNING id, title, slug, published_at`,
|
||||||
userId,
|
[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) {
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/drafts/:id/reject
|
// GET /api/drafts/next-slot — посмотреть какой будет слот для следующего approve
|
||||||
router.post('/drafts/:id/reject', async (req, res) => {
|
router.get('/next-slot', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
await draftSvc.rejectDraft(req.params.id);
|
const info = await describeNextSlot();
|
||||||
res.json({ ok: true });
|
res.json({ at: info.at, msk: info.mskLabel });
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/drafts/:id
|
// POST /api/drafts/approve-all — авто-одобрение всех (ручной вызов)
|
||||||
router.delete('/drafts/:id', async (req, res) => {
|
router.post('/approve-all', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await query('DELETE FROM post_drafts WHERE id=$1', [req.params.id]);
|
const { runDraftAutoApprove } = require('../../draftAutoApprove');
|
||||||
|
await runDraftAutoApprove();
|
||||||
res.json({ ok: true });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ router.post('/backfill-channel/:channelId', async (req, res) => {
|
|||||||
let sql = `
|
let sql = `
|
||||||
SELECT a.id, a.slug, a.title, a.category, a.published_at
|
SELECT a.id, a.slug, a.title, a.category, a.published_at
|
||||||
FROM articles a
|
FROM articles a
|
||||||
WHERE a.status='published'
|
WHERE a.status='published' AND a.published_at <= NOW()
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM scheduled_posts sp
|
SELECT 1 FROM scheduled_posts sp
|
||||||
WHERE sp.channel_id=$1 AND sp.article_id=a.id AND sp.status IN ('pending','sent')
|
WHERE sp.channel_id=$1 AND sp.article_id=a.id AND sp.status IN ('pending','sent')
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -250,7 +250,7 @@ async function generateArticle(channel, opts = {}) {
|
|||||||
config.ai.models.article,
|
config.ai.models.article,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
userPrompt,
|
userPrompt,
|
||||||
{ maxTokens: 4000, temperature: 0.85 }
|
{ maxTokens: 3000, temperature: 0.85 }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!useEditPass) {
|
if (!useEditPass) {
|
||||||
@@ -277,7 +277,7 @@ async function generateArticle(channel, opts = {}) {
|
|||||||
config.ai.models.article,
|
config.ai.models.article,
|
||||||
editorPrompt,
|
editorPrompt,
|
||||||
`Вот черновик для редактуры:\n\n${draft.text}`,
|
`Вот черновик для редактуры:\n\n${draft.text}`,
|
||||||
{ maxTokens: 4000, temperature: 0.75 }
|
{ maxTokens: 3000, temperature: 0.75 }
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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)
|
// 1. При публикации статьи engine вызывает scheduleForArticle(articleId)
|
||||||
// 2. Находим все системные каналы с auto_publish_enabled=true где (categories пустой ИЛИ категория статьи там есть)
|
// 2. Находим системные каналы с auto_publish_enabled=true где категория подходит
|
||||||
// 3. Для каждого канала ищем ближайший подходящий момент:
|
// 3. scheduled_at = articles.published_at (если оно в будущем),
|
||||||
// - если delay_min > 0 → now + delay_min
|
// иначе now + delay_min (или NOW). Слоты канала publish_slots больше
|
||||||
// - иначе — ближайший publish_slot канала в будущем
|
// НЕ используются для выбора времени — единый источник это published_at.
|
||||||
// - если у канала нет слотов и delay=0 — публикуем сразу (scheduled_at = NOW)
|
// 4. Дедуп: один article × один channel = одна запись (skip если pending/sent)
|
||||||
// 4. Дедуп: один article × один channel = одна запись в scheduled_posts (skip если уже есть pending/sent)
|
// 5. Раннер (scheduledPostsRunner) отправит когда scheduled_at <= NOW,
|
||||||
// 5. Создаём scheduled_posts с pending status — runner отработает по cron'у
|
// с защитой от залпа (пропускает посты старше SKIP_OLDER_THAN_H часов).
|
||||||
|
|
||||||
const { query } = require('../config/db');
|
const { query } = require('../config/db');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подобрать ближайший момент публикации для канала.
|
* Момент публикации в TG = момент появления на сайте (published_at статьи).
|
||||||
|
* Если published_at не задан/в прошлом — используем delay_min или NOW.
|
||||||
|
* @param {object} channel
|
||||||
|
* @param {object} article — должен содержать published_at
|
||||||
* @returns Date
|
* @returns Date
|
||||||
*/
|
*/
|
||||||
async function pickScheduleTime(channel) {
|
async function pickScheduleTime(channel, article) {
|
||||||
const now = new Date();
|
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) {
|
if (channel.auto_publish_delay_min > 0) {
|
||||||
return new Date(now.getTime() + channel.auto_publish_delay_min * 60_000);
|
return new Date(now.getTime() + channel.auto_publish_delay_min * 60_000);
|
||||||
}
|
}
|
||||||
// Ищем publish_slots
|
return now;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +49,7 @@ async function pickScheduleTime(channel) {
|
|||||||
*/
|
*/
|
||||||
async function scheduleForArticle(articleId) {
|
async function scheduleForArticle(articleId) {
|
||||||
const { rows: arts } = await query(
|
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]
|
[articleId]
|
||||||
);
|
);
|
||||||
if (!arts.length || arts[0].status !== 'published') return [];
|
if (!arts.length || arts[0].status !== 'published') return [];
|
||||||
@@ -72,23 +67,23 @@ async function scheduleForArticle(articleId) {
|
|||||||
|
|
||||||
const created = [];
|
const created = [];
|
||||||
for (const ch of channels) {
|
for (const ch of channels) {
|
||||||
// Дедуп
|
// Дедуп — одна запись на (channel, article) в активных статусах
|
||||||
const { rows: existing } = await query(
|
const { rows: existing } = await query(
|
||||||
`SELECT id FROM scheduled_posts
|
`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`,
|
LIMIT 1`,
|
||||||
[ch.id, article.id]
|
[ch.id, article.id]
|
||||||
);
|
);
|
||||||
if (existing.length) continue;
|
if (existing.length) continue;
|
||||||
|
|
||||||
const scheduledAt = await pickScheduleTime(ch);
|
const scheduledAt = await pickScheduleTime(ch, article);
|
||||||
const { rows: inserted } = await query(
|
const { rows: inserted } = await query(
|
||||||
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
|
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
|
||||||
VALUES ($1,$2,$3,'pending') RETURNING *`,
|
VALUES ($1,$2,$3,'pending') RETURNING *`,
|
||||||
[ch.id, article.id, scheduledAt]
|
[ch.id, article.id, scheduledAt]
|
||||||
);
|
);
|
||||||
created.push(inserted[0]);
|
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;
|
return created;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function estimateReadingTime(text) {
|
|||||||
*/
|
*/
|
||||||
async function listArticles({ limit = 20, offset = 0, tag = null, category = null } = {}) {
|
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
|
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 = [];
|
const params = [];
|
||||||
if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; }
|
if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; }
|
||||||
if (category) { params.push(category); sql += ` AND category=$${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
|
`SELECT a.*, j.tokens_in, j.tokens_out
|
||||||
FROM articles a
|
FROM articles a
|
||||||
LEFT JOIN generation_jobs j ON j.id = a.job_id
|
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]
|
[slug]
|
||||||
);
|
);
|
||||||
if (!rows.length) return null;
|
if (!rows.length) return null;
|
||||||
@@ -62,7 +62,7 @@ async function getArticleBySlug(slug) {
|
|||||||
async function getAllTags() {
|
async function getAllTags() {
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`SELECT DISTINCT jsonb_array_elements_text(tags) as tag, COUNT(*) as cnt
|
`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`
|
GROUP BY tag ORDER BY cnt DESC LIMIT 30`
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
@@ -207,7 +207,7 @@ async function getHomeArticles() {
|
|||||||
// Hero — самая свежая опубликованная статья с обложкой
|
// Hero — самая свежая опубликованная статья с обложкой
|
||||||
const heroRes = await query(
|
const heroRes = await query(
|
||||||
`${select} FROM articles
|
`${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`
|
ORDER BY published_at DESC LIMIT 1`
|
||||||
);
|
);
|
||||||
const hero = heroRes.rows[0] || null;
|
const hero = heroRes.rows[0] || null;
|
||||||
@@ -219,7 +219,7 @@ async function getHomeArticles() {
|
|||||||
SELECT ${select.replace('SELECT ', '')},
|
SELECT ${select.replace('SELECT ', '')},
|
||||||
ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn
|
ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn
|
||||||
FROM articles
|
FROM articles
|
||||||
WHERE status='published' AND id <> $1
|
WHERE status='published' AND published_at <= NOW() AND id <> $1
|
||||||
) t WHERE rn <= 3
|
) t WHERE rn <= 3
|
||||||
ORDER BY category, rn`,
|
ORDER BY category, rn`,
|
||||||
[heroId]
|
[heroId]
|
||||||
@@ -234,7 +234,7 @@ async function getHomeArticles() {
|
|||||||
// Популярное за 30 дней: топ-3 по views (только если views > 0)
|
// Популярное за 30 дней: топ-3 по views (только если views > 0)
|
||||||
const popRes = await query(
|
const popRes = await query(
|
||||||
`${select} FROM articles
|
`${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`
|
ORDER BY views DESC, published_at DESC LIMIT 3`
|
||||||
);
|
);
|
||||||
const popular = popRes.rows;
|
const popular = popRes.rows;
|
||||||
@@ -246,7 +246,7 @@ async function getHomeArticles() {
|
|||||||
const usedArr = Array.from(usedIds).filter(Boolean);
|
const usedArr = Array.from(usedIds).filter(Boolean);
|
||||||
const recentRes = await query(
|
const recentRes = await query(
|
||||||
`${select} FROM articles
|
`${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`,
|
ORDER BY published_at DESC LIMIT 6`,
|
||||||
[usedArr.length ? usedArr : [0]]
|
[usedArr.length ? usedArr : [0]]
|
||||||
);
|
);
|
||||||
|
|||||||
+154
-38
@@ -59,7 +59,7 @@ const TOPIC_BANK = {
|
|||||||
* Берёт следующую тему из очереди или из банка тем.
|
* Берёт следующую тему из очереди или из банка тем.
|
||||||
*/
|
*/
|
||||||
async function getNextTopic(category) {
|
async function getNextTopic(category) {
|
||||||
// Сначала из очереди (по приоритету)
|
// 1. Приоритетная очередь (content_queue)
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`SELECT * FROM content_queue
|
`SELECT * FROM content_queue
|
||||||
WHERE category=$1 AND status='pending'
|
WHERE category=$1 AND status='pending'
|
||||||
@@ -69,33 +69,46 @@ async function getNextTopic(category) {
|
|||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
return { id: rows[0].id, topic: rows[0].topic, tags: rows[0].tags || [], keywords: rows[0].keywords || [] };
|
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(
|
const { rows: usedTopics } = await query(
|
||||||
`SELECT source_topic FROM articles WHERE category=$1 AND source_topic IS NOT NULL`,
|
`SELECT source_topic FROM articles WHERE category=$1 AND source_topic IS NOT NULL`,
|
||||||
[category]
|
[category]
|
||||||
);
|
);
|
||||||
const usedSet = new Set(usedTopics.map(r => r.source_topic.toLowerCase().trim()));
|
const usedSet = new Set(usedTopics.map(r => r.source_topic?.toLowerCase().trim()).filter(Boolean));
|
||||||
|
const unused = bank.filter(t => !usedSet.has(t.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 pool = unused.length > 0 ? unused : bank;
|
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: [] };
|
return { id: null, topic, tags: [], keywords: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +116,23 @@ async function getNextTopic(category) {
|
|||||||
* Запускает генерацию одной статьи для категории.
|
* Запускает генерацию одной статьи для категории.
|
||||||
*/
|
*/
|
||||||
async function runAutogenForCategory(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);
|
const { id: queueId, topic, tags, keywords } = await getNextTopic(category);
|
||||||
console.log(`[Autogen] category=${category} topic="${topic.slice(0, 60)}"`);
|
console.log(`[Autogen] category=${category} topic="${topic.slice(0, 60)}"`);
|
||||||
|
|
||||||
@@ -111,7 +141,7 @@ async function runAutogenForCategory(category) {
|
|||||||
topic,
|
topic,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
keywords,
|
keywords,
|
||||||
autoPublish: true,
|
autoPublish: false, // draft review flow
|
||||||
category,
|
category,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,6 +169,8 @@ async function runAutogenForCategory(category) {
|
|||||||
await query(`UPDATE content_queue SET status='failed' WHERE id=$1`, [queueId]);
|
await query(`UPDATE content_queue SET status='failed' WHERE id=$1`, [queueId]);
|
||||||
}
|
}
|
||||||
return { ok: false, error: err.message };
|
return { ok: false, error: err.message };
|
||||||
|
} finally {
|
||||||
|
await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,21 +202,70 @@ async function runAutogen({ forceCategory = null } = {}) {
|
|||||||
params = [currentHour, currentMinute - 5, currentMinute + 5];
|
params = [currentHour, currentMinute - 5, currentMinute + 5];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows: settings } = await query(
|
// Сначала берём ВСЕ активные категории (независимо от времени),
|
||||||
`SELECT * FROM autogen_settings ${whereClause} ORDER BY category`,
|
// затем применяем ротацию — выбираем 4 из 8 по дню года.
|
||||||
params
|
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) {
|
if (!settings.length) {
|
||||||
console.log('[Autogen] Nothing to generate at this time');
|
console.log('[Autogen] Nothing to generate at this time');
|
||||||
return { processed: 0, results: [] };
|
return { processed: 0, results: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
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);
|
const result = await runAutogenForCategory(s.category);
|
||||||
results.push({ category: s.category, ...result });
|
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));
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,16 +276,51 @@ async function runAutogen({ forceCategory = null } = {}) {
|
|||||||
/**
|
/**
|
||||||
* Получить статус автогенерации.
|
* Получить статус автогенерации.
|
||||||
*/
|
*/
|
||||||
async function getAutogenStatus() {
|
/**
|
||||||
const { rows: settings } = await query(
|
* Возвращает категории которые активны сегодня по ротации (4 из 8).
|
||||||
`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,
|
function getTodayCategories(allCategories, dailyCount = 4) {
|
||||||
(SELECT COUNT(*) FROM articles a WHERE a.category=s.category AND a.status='published') as article_count
|
if (allCategories.length <= dailyCount) return allCategories.map(c => c.category || c);
|
||||||
FROM autogen_settings s
|
const now = new Date();
|
||||||
LEFT JOIN categories c ON c.slug=s.category
|
const start = Date.UTC(now.getUTCFullYear(), 0, 0);
|
||||||
ORDER BY s.category`
|
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 };
|
||||||
|
|||||||
+42
-7
@@ -466,13 +466,34 @@ async function generateCoverViaPollinations({ prompt }) {
|
|||||||
* Выбирает наиболее подходящую рубрику для обложки статьи.
|
* Выбирает наиболее подходящую рубрику для обложки статьи.
|
||||||
* Дешёвый haiku-вызов: ~50 токенов. При ошибке — случайная рубрика.
|
* Дешёвый 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 || rubrics.length === 0) return null;
|
||||||
if (rubrics.length === 1) return rubrics[0];
|
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 {
|
try {
|
||||||
const res = await axios.post(
|
const res = await axios.post(
|
||||||
`${config.ai.baseUrl}/chat/completions`,
|
`${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 raw = res.data?.choices?.[0]?.message?.content?.trim() || '0';
|
||||||
const idx = parseInt(raw.replace(/\D/g, '')) || 0;
|
const idx = parseInt(raw.replace(/\D/g, '')) || 0;
|
||||||
const safeIdx = Math.min(Math.max(idx, 0), rubrics.length - 1);
|
const safeIdx = Math.min(Math.max(idx, 0), available.length - 1);
|
||||||
return rubrics[safeIdx];
|
selected = available[safeIdx];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Cover] selectRubric failed, using random:', err.message.slice(0, 80));
|
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 }) {
|
async function generateCover({ articleId, title, tags = [], channelId = null }) {
|
||||||
@@ -514,7 +548,7 @@ async function generateCover({ articleId, title, tags = [], channelId = null })
|
|||||||
let styleName;
|
let styleName;
|
||||||
const rubrics = channelStyle?.image_rubrics;
|
const rubrics = channelStyle?.image_rubrics;
|
||||||
if (Array.isArray(rubrics) && rubrics.length > 0) {
|
if (Array.isArray(rubrics) && rubrics.length > 0) {
|
||||||
selectedRubric = await selectRubric({ title, tags, rubrics });
|
selectedRubric = await selectRubric({ title, tags, rubrics, channelId });
|
||||||
styleName = selectedRubric?.id || 'rubric';
|
styleName = selectedRubric?.id || 'rubric';
|
||||||
console.log(`[Cover] article=${articleId} channel=${channelId} rubric=${styleName}`);
|
console.log(`[Cover] article=${articleId} channel=${channelId} rubric=${styleName}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -526,6 +560,7 @@ async function generateCover({ articleId, title, tags = [], channelId = null })
|
|||||||
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric });
|
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric });
|
||||||
|
|
||||||
let img;
|
let img;
|
||||||
|
let usedPath = 'routerai';
|
||||||
|
|
||||||
// Единственный провайдер картинок: routerai /responses + gpt-5-image-mini
|
// Единственный провайдер картинок: routerai /responses + gpt-5-image-mini
|
||||||
// Цена: ~₽2.72/картинка (4175 image tokens, high quality, quality param не работает)
|
// Цена: ~₽2.72/картинка (4175 image tokens, high quality, quality param не работает)
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
@@ -63,9 +63,11 @@ const IMAGE_PALETTES = {
|
|||||||
* Генерирует картинку к посту через GPT-5 /v1/responses + image_generation.
|
* Генерирует картинку к посту через GPT-5 /v1/responses + image_generation.
|
||||||
*/
|
*/
|
||||||
async function generatePostImage({ post, channel, style = {} }) {
|
async function generatePostImage({ post, channel, style = {} }) {
|
||||||
// Если задано несколько стилей через запятую — случайно выбираем один
|
// Если задано несколько стилей через запятую — случайно выбираем один.
|
||||||
const styleList = (style.image_style || 'flat-illustration')
|
// Если стиль не задан или 'auto' — ротация из трёх редакторских стилей.
|
||||||
.split(',').map(s => s.trim()).filter(s => s && s !== '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 pickedStyle = styleList[Math.floor(Math.random() * styleList.length)] || 'flat-illustration';
|
||||||
const imageStyle = IMAGE_STYLES[pickedStyle] || IMAGE_STYLES['flat-illustration'];
|
const imageStyle = IMAGE_STYLES[pickedStyle] || IMAGE_STYLES['flat-illustration'];
|
||||||
const palette = style.image_custom_colors
|
const palette = style.image_custom_colors
|
||||||
@@ -75,15 +77,36 @@ async function generatePostImage({ post, channel, style = {} }) {
|
|||||||
// Извлекаем суть поста для промпта (первые 250 символов)
|
// Извлекаем суть поста для промпта (первые 250 символов)
|
||||||
const postExcerpt = post.replace(/[#*_`>]/g, '').slice(0, 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}.` : ''}
|
const SCENES = [
|
||||||
Channel context: ${channel.niche || channel.name}.
|
{ setting: 'warm oak desktop surface, afternoon sunlight from left window', lighting: 'golden hour soft shadows', temp: 'warm amber' },
|
||||||
${style.image_prompt_instructions ? `\nChannel visual guidelines: ${style.image_prompt_instructions}` : ''}
|
{ 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.
|
const prompt = `Generate a 16:9 editorial illustration for a social media post.
|
||||||
Strictly: no text, no letters, no logos, no faces of real people.`;
|
|
||||||
|
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
|
// Единственный провайдер: routerai /responses + gpt-5-image-mini
|
||||||
// Цена: ~₽2.72/картинка. quality параметр не работает, всегда high.
|
// Цена: ~₽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 };
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const FormData = require('form-data');
|
|||||||
const { query } = require('../config/db');
|
const { query } = require('../config/db');
|
||||||
const settings = require('./settings');
|
const settings = require('./settings');
|
||||||
const zeroChar = require('./zeroCharacter');
|
const zeroChar = require('./zeroCharacter');
|
||||||
|
const { tgSend } = require('./tgSend');
|
||||||
|
|
||||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
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).
|
* Если caption длиннее 1024 — режется (TG hard-limit для sendPhoto). Для длинных постов лучше посылать без cover (sendMessage до 4096).
|
||||||
*/
|
*/
|
||||||
async function publishToTelegram({ channel, text, photoUrl, article }) {
|
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 || DEFAULT_BUTTON_TEXT;
|
||||||
// Inline-кнопка — только если есть статья и кнопка не отключена
|
|
||||||
const buttonText = channel.auto_publish_button_text === null
|
|
||||||
? null
|
|
||||||
: (channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT);
|
|
||||||
let reply_markup = undefined;
|
let reply_markup = undefined;
|
||||||
if (article && buttonText) {
|
if (article && buttonText) {
|
||||||
reply_markup = {
|
reply_markup = { inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]] };
|
||||||
inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
// Единый модуль отправки (multipart для локальных файлов, URL для внешних, fallback sendMessage)
|
||||||
if (photoUrl) {
|
return tgSend({
|
||||||
const localPath = resolveLocalPhoto(photoUrl);
|
botToken: channel.bot_token,
|
||||||
if (localPath) {
|
chatId: channel.tg_channel_id,
|
||||||
// Шлём файл напрямую через multipart — TG не пойдёт сам ходить за URL
|
text,
|
||||||
const form = new FormData();
|
photoUrl,
|
||||||
form.append('chat_id', String(channel.tg_channel_id));
|
replyMarkup: reply_markup,
|
||||||
form.append('caption', text.slice(0, 1024));
|
parseMode: 'Markdown',
|
||||||
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,
|
|
||||||
});
|
|
||||||
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 }) {
|
async function publishToVK({ channel, text, photoUrl, article }) {
|
||||||
@@ -213,7 +180,7 @@ async function publishToMax({ channel, text, photoUrl, article }) {
|
|||||||
throw new Error('MAX не настроен');
|
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 token = channel.max_access_token;
|
||||||
const chatId = channel.max_channel_id;
|
const chatId = channel.max_channel_id;
|
||||||
const headers = { Authorization: token, 'Content-Type': 'application/json' };
|
const headers = { Authorization: token, 'Content-Type': 'application/json' };
|
||||||
@@ -347,10 +314,12 @@ async function publishOne(scheduledPost) {
|
|||||||
} catch(_) { /* внешний URL или файл не найден — считаем реальным */ }
|
} catch(_) { /* внешний URL или файл не найден — считаем реальным */ }
|
||||||
|
|
||||||
if (coverIsReal) {
|
if (coverIsReal) {
|
||||||
photoUrl = article.cover_url.startsWith('http')
|
// Telegram не принимает .webp по URL — используем .png версию
|
||||||
? article.cover_url
|
const coverForTg = article.cover_url.replace(/\.webp$/, '.png');
|
||||||
: `https://zeropost.ru${article.cover_url}`;
|
photoUrl = coverForTg.startsWith('http')
|
||||||
console.log(`[scheduled-runner] cover=${article.cover_url.split('/').pop()} article=${article.id}`);
|
? coverForTg
|
||||||
|
: `https://zeropost.ru${coverForTg}`;
|
||||||
|
console.log(`[scheduled-runner] cover=${coverForTg.split('/').pop()} article=${article.id}`);
|
||||||
} else {
|
} else {
|
||||||
const attempts = scheduledPost.cover_regen_attempts || 0;
|
const attempts = scheduledPost.cover_regen_attempts || 0;
|
||||||
const MAX_REGEN_ATTEMPTS = 3; // 3 × 15 мин = 45 мин максимум ждём
|
const MAX_REGEN_ATTEMPTS = 3; // 3 × 15 мин = 45 мин максимум ждём
|
||||||
@@ -427,11 +396,31 @@ async function publishOne(scheduledPost) {
|
|||||||
return { messageId, channel, article };
|
return { messageId, channel, article };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Защита от залпа: посты просроченные более чем на SKIP_OLDER_THAN_H часов
|
||||||
|
// не отправляются (помечаются 'skipped'). Иначе после простоя раннера или
|
||||||
|
// массового ретрая всё накопившееся улетело бы в канал пачкой.
|
||||||
|
const SKIP_OLDER_THAN_H = 3;
|
||||||
|
|
||||||
async function runScheduled() {
|
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(
|
const { rows } = await query(
|
||||||
`SELECT * FROM scheduled_posts
|
`SELECT * FROM scheduled_posts
|
||||||
WHERE status='pending' AND scheduled_at <= NOW()
|
WHERE status='pending' AND scheduled_at <= NOW()
|
||||||
ORDER BY scheduled_at ASC LIMIT 20`
|
ORDER BY scheduled_at ASC LIMIT 1`
|
||||||
);
|
);
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const sp of rows) {
|
for (const sp of rows) {
|
||||||
@@ -453,7 +442,7 @@ async function runScheduled() {
|
|||||||
console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`);
|
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 };
|
module.exports = { runScheduled, publishOne, renderTemplate, DEFAULT_TEMPLATE, DEFAULT_BUTTON_TEXT };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,58 +1,36 @@
|
|||||||
// Маппинг постов на иллюстрации с персонажем Зеро.
|
// Маппинг постов на иллюстрации с персонажем Зеро.
|
||||||
// 15 поз хранятся как /var/www/zeropost-uploads/zero-{name}.webp
|
// Позы хранятся как /uploads/zero-{name}.webp и сервируются отдельным nginx
|
||||||
//
|
// (zeropost-uploads-server). Engine их по файловой системе не видит — общается
|
||||||
// Логика выбора:
|
// с ними только через публичный URL. Поэтому "доступность" позы определяется
|
||||||
// 1. Если в title/excerpt есть triggers — берём соответствующую эмоциональную/активную позу
|
// статическим списком AVAILABLE_POSES; добавил новую позу — добавь в список.
|
||||||
// 2. Иначе — берём позу по категории
|
|
||||||
// 3. Если в локации файла нет — fallback на 'avatar'
|
|
||||||
|
|
||||||
const fs = require('fs');
|
// Полный список поз, доступных в /uploads/zero-{name}.webp на проде.
|
||||||
const path = require('path');
|
// Источник: 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/content.
|
||||||
|
|
||||||
// Эмоциональные/активные позы — выбираются по ключевым словам в title/excerpt.
|
|
||||||
// Порядок важен: первое срабатывание побеждает.
|
|
||||||
const EMOTIONAL_TRIGGERS = [
|
const EMOTIONAL_TRIGGERS = [
|
||||||
// "Получилось / заработало / победа" → victory
|
|
||||||
{ pose: 'victory', words: ['получилось', 'заработало', 'победа', 'отличный результат', 'удалось', 'успех'] },
|
{ pose: 'victory', words: ['получилось', 'заработало', 'победа', 'отличный результат', 'удалось', 'успех'] },
|
||||||
|
|
||||||
// "Не работает / сломалось / провал" → facepalm
|
|
||||||
{ pose: 'facepalm', words: ['не работает', 'сломал', 'ошибк', 'провал', 'факап', 'fail', 'баг', 'неудач', 'облажал'] },
|
{ pose: 'facepalm', words: ['не работает', 'сломал', 'ошибк', 'провал', 'факап', 'fail', 'баг', 'неудач', 'облажал'] },
|
||||||
|
|
||||||
// "Нашёл / открыл / классный" → eureka
|
|
||||||
{ pose: 'eureka', words: ['нашёл', 'нашел', 'открыл', 'классн', 'крутая фича', 'интересн', 'wow', 'неожиданн'] },
|
{ pose: 'eureka', words: ['нашёл', 'нашел', 'открыл', 'классн', 'крутая фича', 'интересн', 'wow', 'неожиданн'] },
|
||||||
|
|
||||||
// "Запутался / непонятно / разбираемся" → confused
|
|
||||||
{ pose: 'confused', words: ['запутал', 'непонятно', 'разбира', 'разобрат', 'странн', 'не пойму', 'почему'] },
|
{ pose: 'confused', words: ['запутал', 'непонятно', 'разбира', 'разобрат', 'странн', 'не пойму', 'почему'] },
|
||||||
|
|
||||||
// "Устал / долго / ночь" → tired
|
|
||||||
{ pose: 'tired', words: ['устал', 'долго', 'часами', 'ночь', 'утром понял', 'выгорел'] },
|
{ pose: 'tired', words: ['устал', 'долго', 'часами', 'ночь', 'утром понял', 'выгорел'] },
|
||||||
|
|
||||||
// "Изучаю / разбор / гайд / шпаргалка" → reading или present
|
|
||||||
{ pose: 'reading', words: ['изуча', 'разбор', 'шпаргалк', 'гайд', 'мануал', 'документац'] },
|
{ pose: 'reading', words: ['изуча', 'разбор', 'шпаргалк', 'гайд', 'мануал', 'документац'] },
|
||||||
{ pose: 'present', words: ['как сделать', 'туториал', 'инструкц', 'объясн', 'показыва', 'учимся'] },
|
{ pose: 'present', words: ['как сделать', 'туториал', 'инструкц', 'объясн', 'показыва', 'учимся'] },
|
||||||
|
|
||||||
// "Расследую / разбираю / копаю" → magnifier
|
|
||||||
{ pose: 'magnifier', words: ['расследова', 'разбираю', 'копа', 'докопат', 'под капот', 'как устроен'] },
|
{ pose: 'magnifier', words: ['расследова', 'разбираю', 'копа', 'докопат', 'под капот', 'как устроен'] },
|
||||||
|
|
||||||
// "Аналитика / метрики / графики" → chart
|
|
||||||
{ pose: 'chart', words: ['метрик', 'аналитик', 'график', 'статистик', 'цифр', 'данные показ', 'результат за'] },
|
{ pose: 'chart', words: ['метрик', 'аналитик', 'график', 'статистик', 'цифр', 'данные показ', 'результат за'] },
|
||||||
// "Запуск / деплой" → rocket
|
|
||||||
{ pose: 'rocket', words: ['деплой', 'запустил', 'релиз', 'в продакш', 'залил', 'выкатил', 'запуск проект'] },
|
{ pose: 'rocket', words: ['деплой', 'запустил', 'релиз', 'в продакш', 'залил', 'выкатил', 'запуск проект'] },
|
||||||
// "Баг / отладка" → bug
|
|
||||||
{ pose: 'bug', words: ['баг', 'ошибк', 'дебаг', 'отлаживал', 'починил', 'не работало'] },
|
{ pose: 'bug', words: ['баг', 'ошибк', 'дебаг', 'отлаживал', 'починил', 'не работало'] },
|
||||||
// "Рекомендация / топ" → thumbsup
|
|
||||||
{ pose: 'thumbsup', words: ['рекомендую', 'советую', 'топ-', 'лучший', 'отличный инструмент', 'понравилось'] },
|
{ pose: 'thumbsup', words: ['рекомендую', 'советую', 'топ-', 'лучший', 'отличный инструмент', 'понравилось'] },
|
||||||
// "Плавание / спорт" → swimming
|
|
||||||
{ pose: 'swimming', words: ['плавани', 'бассейн', 'плыть', 'тренировк', 'спортивн'] },
|
{ pose: 'swimming', words: ['плавани', 'бассейн', 'плыть', 'тренировк', 'спортивн'] },
|
||||||
// "Думаю / вопрос" → thinking
|
|
||||||
{ pose: 'thinking', words: ['думаю', 'размышляю', 'не знаю точно', 'интересный вопрос', 'а что если'] },
|
{ pose: 'thinking', words: ['думаю', 'размышляю', 'не знаю точно', 'интересный вопрос', 'а что если'] },
|
||||||
// "Исследование" → telescope
|
{ pose: 'telescope', words: ['исследова', 'смотрю внимательно', 'нашёл интересн', 'открытие'] },
|
||||||
{ pose: 'telescope', words: ['исследова', 'изучаю', 'смотрю внимательно', 'нашёл интересн', 'открытие'] },
|
|
||||||
|
|
||||||
// "Подумать / поразмышлять / медитация" → meditate
|
|
||||||
{ pose: 'meditate', words: ['подумать', 'размышл', 'осмысл', 'мысли вслух', 'рефлекс'] },
|
{ pose: 'meditate', words: ['подумать', 'размышл', 'осмысл', 'мысли вслух', 'рефлекс'] },
|
||||||
|
{ pose: 'sleep', words: ['засыпа', 'спать', 'отдых', 'выходной', 'утро понедельник'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Категорийные позы — fallback если эмоциональных триггеров не нашлось
|
// Категорийные позы — fallback если эмоциональных триггеров не нашлось
|
||||||
@@ -63,62 +41,49 @@ const CATEGORY_POSES = {
|
|||||||
'ai-dev': 'coding',
|
'ai-dev': 'coding',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK_POSE = 'avatar';
|
const FALLBACK_POSE = 'coffee'; // 'coffee' — наш дефолтный кофейный Зеро (лучше чем avatar в большинстве случаев)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выбирает имя позы Зеро под пост.
|
* Выбирает имя позы Зеро под пост.
|
||||||
* @param {{ title?: string, excerpt?: string, category?: string }} ctx
|
* @param {{ title?: string, excerpt?: string, category?: string, content?: string }} ctx
|
||||||
* @returns {{ pose: string, path: string|null, exists: boolean }}
|
* @returns {{ pose: string, source: string }}
|
||||||
*/
|
*/
|
||||||
function pickPose({ title = '', excerpt = '', category = '' }) {
|
function pickPose({ title = '', excerpt = '', category = '', content = '' }) {
|
||||||
const haystack = `${title} ${excerpt}`.toLowerCase();
|
const haystack = `${title} ${excerpt} ${content}`.toLowerCase();
|
||||||
|
|
||||||
// 1. Эмоциональные триггеры
|
// 1. Эмоциональные триггеры
|
||||||
for (const t of EMOTIONAL_TRIGGERS) {
|
for (const t of EMOTIONAL_TRIGGERS) {
|
||||||
for (const w of t.words) {
|
for (const w of t.words) {
|
||||||
if (haystack.includes(w)) {
|
if (haystack.includes(w)) {
|
||||||
return resolve(t.pose, 'emotional');
|
return safe(t.pose, 'emotional');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. По категории
|
// 2. По категории
|
||||||
const catPose = CATEGORY_POSES[category];
|
const catPose = CATEGORY_POSES[category];
|
||||||
if (catPose) return resolve(catPose, 'category');
|
if (catPose) return safe(catPose, 'category');
|
||||||
|
|
||||||
// 3. Fallback
|
// 3. Fallback
|
||||||
return resolve(FALLBACK_POSE, 'fallback');
|
return safe(FALLBACK_POSE, 'fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(name, source) {
|
function safe(pose, source) {
|
||||||
const localPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
|
// Если позы нет в списке доступных — откатываемся на coffee
|
||||||
const exists = fs.existsSync(localPath);
|
if (!AVAILABLE_POSES.has(pose)) {
|
||||||
// Если позы нет — пробуем avatar
|
return { pose: FALLBACK_POSE, source: `${source}-fallback(${pose})` };
|
||||||
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` };
|
|
||||||
}
|
|
||||||
return { pose: name, path: null, exists: false, source };
|
|
||||||
}
|
}
|
||||||
return { pose: name, path: exists ? localPath : null, exists, source };
|
return { pose, source };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список доступных поз (для UI).
|
* Список доступных поз (для UI).
|
||||||
*/
|
*/
|
||||||
function listAvailablePoses() {
|
function listAvailablePoses() {
|
||||||
const out = [];
|
return [...AVAILABLE_POSES].sort().map(name => ({
|
||||||
for (const name of [
|
name,
|
||||||
'avatar', 'coding', 'tools', 'lock', 'gears',
|
url: `/uploads/zero-${name}.webp`,
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { pickPose, listAvailablePoses, CATEGORY_POSES, EMOTIONAL_TRIGGERS };
|
module.exports = { pickPose, listAvailablePoses, AVAILABLE_POSES, CATEGORY_POSES, EMOTIONAL_TRIGGERS };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user