Compare commits

...
3 Commits
6 changed files with 461 additions and 1 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.build/
.astro/
.env
.env.local
*.tsbuildinfo
.DS_Store
data/*.db
data/*.sqlite
+106 -1
View File
@@ -1,3 +1,108 @@
# content-pipeline
Оркестратор контентного конвейера (editor-pro -> GEO -> публикация) + CMS-слой для управления публикациями
Оркестратор контентного конвейера + CMS-слой для управления публикациями.
**Не CMS как таковая, а развитие editor-pro + GEO через более глубкую интеграцию.**
## Что это
Гибрид двух слоёв в едином UI:
```
┌─────────────────────────────────────────┐
│ UI-панель (один экран) │
├─────────────────────────────────────────┤
│ 1. Pipeline-оркестратор │ ← управление конвейером
│ (editor-pro → GEO → публикация) │ запуск, статус, ретрай
│ │
│ 2. CMS-слой │ ← удобная правка
│ (список статей / редактор / │ BlockNote + превью
│ превью / публикации) │
├─────────────────────────────────────────┤
│ FastAPI-оркестратор на 9876 │
│ (уже есть для SEO-проектов) │
└─────────────────────────────────────────┘
```
## Архитектура
### Единая модель данных (`src/types.ts`)
Один объект «статья» используют оба слоя:
- `slug`, `title`, `body` — базовые поля
- `utm` — UTM-теги для персонализации hero
- `geo` — GEO-мета (сущности, ключевые запросы)
- `status` — draft / in-review / published / archived
- `editorProTaskId` — связь с editor-pro
Валидация и slugify — единые для обоих слоёв (никакого дублирования).
### Pipeline-оркестратор (`src/pipeline.ts`)
Шаги: `generate` (editor-pro) → `optimize` (GEO) → `publish`.
- Запуск конвейера для списка статей
- Статусы шагов (pending/running/success/failed)
- Ретрай до 3 раз при ошибке (экспоненциальный backoff)
- Делегирует тяжёлую работу шагам (не выполняет сам)
### Хранилище (`src/store.ts`)
Единый источник данных для обоих слоёв:
- CRUD статей
- Фильтрация по статусу (для CMS)
- Поиск по заголовку/тексту
- В MVP — in-memory Map; в прода — SQLite/Postgres (тот же интерфейс)
## Почему не форк CMS
Готовые CMS (AstroAdmin, astro-cms, EmDash) — CRUD для контента, не pipeline-оркестратор.
Они не знают про editor-pro, GEO, UTM-теги. Им пришлось бы писать слой адаптации —
свой слой проще и точнее.
## Roadmap
### MVP (базовый конвейер)
- [ ] FastAPI-оркестратор (9876) — запуск pipeline
- [ ] SQLite-репозиторий вместо in-memory
- [ ] Блок-редактор (BlockNote/TipTap) для CMS-слоя
- [ ] Превью через iframe dev-сервера
- [ ] Привязка к deploy-пайплайну (rsync на хостинг)
- [ ] Статусы pipeline в UI (отслеживание задач)
### Расширение типов контента (отдельные направления)
Каждый тип — **отдельное направление**, которое нужно глубоко прорабатывать
и тестировать для качества. Не делать всё сразу — получится хрупкий конвейер,
тормозящий на a2000 (12GB GPU).
Приоритет: **подкасты → видео**.
#### Текст (уже работает)
- editor-pro → GEO → публикация
- низкая сложность
#### Подкасты (средняя сложность)
- ACE-Step 1.5 для музыки (~34s batch), Silero TTS для голоса
- GEO через транскрипт + метаданные
- GPU-нагрузка на a2000: одна операция за раз (не параллельно)
#### Видео (высокая сложность)
- генерация или сборка из стоков/скринов
- storage больших файлов (артефакты pipeline, не в статье)
- GEO: описание + теги + таймкоды (менее зрелый тренд)
Общие требования для мультимедиа:
- фоновые задачи с очередью (текст за секунды, видео/подкаст за минуты/часы)
- отдельный storage для аудио/видео артефактов
- разная логика GEO по сравнению с текстом
## License
Приватный. Концепт: развитие editor-pro + GEO.
+111
View File
@@ -0,0 +1,111 @@
/**
* Pipeline-оркестратор — движок контентного конвейера.
*
* Шаги: generate (editor-pro) -> optimize (GEO) -> publish.
* Запускает конвейер для списка статей, управляет статусами шагов,
* делает ретрай при ошибке.
*
* НЕ выполняет тяжёлую работу сам — делегирует шагам (editor-pro, GEO).
* Это оркестратор, как в spark-mind.
*/
import type { PipelineStep, PipelineStatus, PipelineTask } from './types.js';
/** Интерфейс шага pipeline. Реализуются через editor-pro / GEO. */
export interface PipelineStepHandler {
name: PipelineStep['name'];
/** Выполнить шаг. Возвращает обновлённую статью или бросает ошибку. */
run(article: any): Promise<any>;
}
/**
* Конфигурация pipeline: какие шаги и в каком порядке.
* Редактируемо — можно менять порядок/добавлять шаги.
*/
export const DEFAULT_PIPELINE: PipelineStep['name'][] = [
'generate',
'optimize',
'publish',
];
export class Pipeline {
private tasks = new Map<string, PipelineTask>();
private handlers = new Map<PipelineStep['name'], PipelineStepHandler>();
/** Зарегистрировать обработчик шага. */
register(step: PipelineStep['name'], handler: PipelineStepHandler): void {
this.handlers.set(step, handler);
}
/**
* Запустить конвейер для списка статей.
* @returns ID задачи (для отслеживания статуса в CMS-слое).
*/
async start(articleIds: string[], pipeline = DEFAULT_PIPELINE): Promise<string> {
const task: PipelineTask = {
id: `task-${Date.now()}`,
articleIds,
steps: pipeline.map((name) => ({
name,
status: 'pending' as PipelineStatus,
details: {},
})),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this.tasks.set(task.id, task);
return task.id;
}
/**
* Выполнить один шаг для одной статьи.
* Ретрай до 3 раз при ошибке.
*/
async runStep(
task: PipelineTask,
stepName: PipelineStep['name'],
article: any,
onProgress?: (task: PipelineTask) => void,
): Promise<any> {
const step = task.steps.find((s) => s.name === stepName)!;
const attempts = 3;
for (let i = 0; i < attempts; i++) {
const handler = this.handlers.get(stepName);
if (!handler) {
throw new Error(`Незарегистрированный шаг: ${stepName}`);
}
step.status = 'running';
step.details.startedAt = new Date().toISOString();
try {
const result = await handler.run(article);
step.status = 'success';
step.details.finishedAt = new Date().toISOString();
step.details.output = 'ok';
task.updatedAt = new Date().toISOString();
onProgress?.(task);
return result;
} catch (err) {
step.details.error = String(err);
if (i === attempts - 1) {
step.status = 'failed';
}
// ждём перед ретраем (экспоненциальный backoff)
await new Promise((r) => setTimeout(r, 500 * (i + 1)));
}
}
return null;
}
/** Получить статус задачи. */
getTask(id: string): PipelineTask | undefined {
return this.tasks.get(id);
}
/** Все задачи. */
allTasks(): PipelineTask[] {
return [...this.tasks.values()];
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Хранилище статей — единый источник данных.
*
* И pipeline-оркестратор, И CMS-слой работают с ОДНИМИ теми же статьями.
* Никакого дублирования между слоями.
*
* В MVP — in-memory Map. В прода — SQLite/Postgres (через тот же интерфейс).
*/
import type { Article } from './types.js';
export class ArticleStore {
private articles = new Map<string, Article>();
/** Создать статью (черновик). */
create(input: Partial<Article>): Article {
const now = new Date().toISOString();
const slug = input.slug ?? (input.title ? slugify(input.title) : `art-${Date.now()}`);
const article: Article = {
slug,
title: input.title ?? '',
description: input.description,
body: input.body ?? '',
utm: input.utm,
geo: input.geo,
status: input.status ?? 'draft',
editorProTaskId: input.editorProTaskId,
createdAt: now,
updatedAt: now,
};
this.articles.set(slug, article);
return article;
}
/** Получить статью по slug. */
get(slug: string): Article | undefined {
return this.articles.get(slug);
}
/** Обновить статью. */
update(slug: string, patch: Partial<Article>): Article | undefined {
const a = this.articles.get(slug);
if (!a) return undefined;
const updated: Article = { ...a, ...patch, updatedAt: new Date().toISOString() };
this.articles.set(slug, updated);
return updated;
}
/** Удалить статью. */
delete(slug: string): boolean {
return this.articles.delete(slug);
}
/** Все статьи. */
all(): Article[] {
return [...this.articles.values()];
}
/** Статьи по статусу (для фильтрации в CMS). */
byStatus(status: Article['status']): Article[] {
return this.all().filter((a) => a.status === status);
}
/** Статьи по тегу/категории. */
byTag(tag: string): Article[] {
return this.all().filter((a) => a.body.includes(`#${tag}`) || a.title.includes(tag));
}
/** Поиск по заголовку/тесту. */
search(query: string): Article[] {
const q = query.toLowerCase();
return this.all().filter(
(a) => a.title.toLowerCase().includes(q) || a.body.toLowerCase().includes(q),
);
}
}
/** Утилиты для slug (дублируется в types, но удобно для store). */
function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[а-яё]/g, (c) =>
({
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh',
з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o',
п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts',
ч: 'ch', ш: 'sh', щ: 'sch', ь: '', ы: 'y', ъ: '', э: 'e', ю: 'yu', я: 'ya',
}[c] ?? c),
)
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Единая модель данных — «статья».
* Используется И pipeline-оркестратором, И CMS-слоем.
*
* Ключевой принцип: один объект, два слоя (оркестратор + CMS)
* работают с ОДНИМИ и теми же данными. Никакого дублирования.
*/
export interface Article {
/** Уникальный slug (URL страницы). */
slug: string;
/** Заголовок. */
title: string;
/** Краткое описание (meta description). */
description?: string;
/** Тело статьи (HTML или markdown). */
body: string;
/** UTM-теги для персонализации hero (utm_source, utm_medium...). */
utm?: {
source?: string;
medium?: string;
campaign?: string;
term?: string;
content?: string;
};
/** GEO-мета: оптимизированные сущности, ключевые запросы. */
geo?: {
entities?: string[];
keywords?: string[];
optimized?: boolean;
};
/** Статус публикации. */
status: 'draft' | 'in-review' | 'published' | 'archived';
/** ID editor-pro задачи, генерировавшей статью (если есть). */
editorProTaskId?: string;
/** Время создания / обновления. */
createdAt: string;
updatedAt: string;
}
export type ArticleStatus = Article['status'];
export const STATUSES: ArticleStatus[] = [
'draft',
'in-review',
'published',
'archived',
];
export const STATUS_LABELS: Record<ArticleStatus, string> = {
draft: 'Черновик',
'in-review': 'На проверке',
published: 'Опубликовано',
archived: 'Архив',
};
/** Состояния pipeline-шагов. */
export const PIPELINE_STATUSES = [
'pending',
'running',
'success',
'failed',
] as const;
export type PipelineStatus = (typeof PIPELINE_STATUSES)[number];
/** Определение pipeline-шага. */
export interface PipelineStep {
/** Имя шага: generate | optimize | publish. */
name: 'generate' | 'optimize' | 'publish';
/** Статус выполнения. */
status: PipelineStatus;
/** Детали: ошибки, время, ссылка на результат. */
details: {
startedAt?: string;
finishedAt?: string;
error?: string;
output?: string;
};
}
/** Задача pipeline — запуск конвейера для списка статей. */
export interface PipelineTask {
id: string;
/** ID статей, которые обрабатывает задача. */
articleIds: string[];
/** Шаги в порядке выполнения. */
steps: PipelineStep[];
createdAt: string;
updatedAt: string;
}
/**
* Валидация статьи. Возвращает список ошибок (пусто = ок).
* Работает и в pipeline, и в CMS — единый контракт.
*/
export function validateArticle(a: Partial<Article>): string[] {
const errors: string[] = [];
if (!a.slug || !a.slug.trim()) errors.push('slug обязателен');
if (!a.title || !a.title.trim()) errors.push('title обязателен');
if (!a.body || !a.body.trim()) errors.push('body обязателен');
if (a.status && !STATUSES.includes(a.status)) {
errors.push(`неверный status: ${a.status}`);
}
return errors;
}
/**
* Генерация slug из заголовка (kebab-case).
* Единая логика для pipeline и CMS.
*/
export function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[а-яё]/g, (c) =>
({
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh',
з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o',
п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts',
ч: 'ch', ш: 'sh', щ: 'sch', ь: '', ы: 'y', ъ: '', э: 'e', ю: 'yu', я: 'ya',
}[c] ?? c),
)
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2020",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"lib": ["es2020"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}