Initial content-pipeline: pipeline orchestrator + CMS layer, unified Article model
This commit is contained in:
commit
9ff486c19a
6 files changed
+431
No files matched your search
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.build/
|
||||||
|
.astro/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
data/*.db
|
||||||
|
data/*.sqlite
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# content-pipeline
|
||||||
|
|
||||||
|
Оркестратор контентного конвейера + 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
|
||||||
|
|
||||||
|
- [ ] FastAPI-оркестратор (9876) — запуск pipeline
|
||||||
|
- [ ] SQLite-репозиторий вместо in-memory
|
||||||
|
- [ ] Блок-редактор (BlockNote/TipTap) для CMS-слоя
|
||||||
|
- [ ] Превью через iframe dev-сервера
|
||||||
|
- [ ] Привязка к deploy-пайплайну (rsync на хостинг)
|
||||||
|
- [ ] Статусы pipeline в UI (отслеживание задач)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Приватный. Концепт: развитие editor-pro + GEO.
|
||||||
+111
@@ -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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["es2020"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user