From 9ff486c19a76cac07fa39f1f430b5a9613460024 Mon Sep 17 00:00:00 2001 From: n1kt4r Date: Wed, 9 Sep 2026 22:51:29 +0500 Subject: [PATCH] Initial content-pipeline: pipeline orchestrator + CMS layer, unified Article model --- .gitignore | 10 ++++ README.md | 76 +++++++++++++++++++++++++++++ src/pipeline.ts | 111 ++++++++++++++++++++++++++++++++++++++++++ src/store.ts | 94 +++++++++++++++++++++++++++++++++++ src/types.ts | 127 ++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 13 +++++ 6 files changed, 431 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 src/pipeline.ts create mode 100644 src/store.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6acc21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.build/ +.astro/ +.env +.env.local +*.tsbuildinfo +.DS_Store +data/*.db +data/*.sqlite diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8802ce --- /dev/null +++ b/README.md @@ -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. diff --git a/src/pipeline.ts b/src/pipeline.ts new file mode 100644 index 0000000..91ffe07 --- /dev/null +++ b/src/pipeline.ts @@ -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; +} + +/** + * Конфигурация pipeline: какие шаги и в каком порядке. + * Редактируемо — можно менять порядок/добавлять шаги. + */ +export const DEFAULT_PIPELINE: PipelineStep['name'][] = [ + 'generate', + 'optimize', + 'publish', +]; + +export class Pipeline { + private tasks = new Map(); + private handlers = new Map(); + + /** Зарегистрировать обработчик шага. */ + register(step: PipelineStep['name'], handler: PipelineStepHandler): void { + this.handlers.set(step, handler); + } + + /** + * Запустить конвейер для списка статей. + * @returns ID задачи (для отслеживания статуса в CMS-слое). + */ + async start(articleIds: string[], pipeline = DEFAULT_PIPELINE): Promise { + 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 { + 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()]; + } +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..3f3d93c --- /dev/null +++ b/src/store.ts @@ -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(); + + /** Создать статью (черновик). */ + create(input: Partial
): 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 | 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, ''); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5946526 --- /dev/null +++ b/src/types.ts @@ -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 = { + 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
): 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, ''); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e90cd5e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "lib": ["es2020"], + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}