feat: монорепо миграция, Discover/SearxNG улучшения
- Миграция на монорепозиторий (apps/frontend, apps/chat-service, etc.) - Discover: проверка SearxNG, понятное empty state при ненастроенном поиске - searxng.ts: валидация URL, проверка JSON-ответа, авто-добавление http:// - docker/searxng-config: настройки для JSON API SearxNG Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
5
apps/posts-mcs/.dockerignore
Normal file
5
apps/posts-mcs/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
*.db
|
||||
.git
|
||||
5
apps/posts-mcs/.gitignore
vendored
Normal file
5
apps/posts-mcs/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
dist/
|
||||
.env
|
||||
*.db
|
||||
14
apps/posts-mcs/Dockerfile
Normal file
14
apps/posts-mcs/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN mkdir -p data && chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 4001
|
||||
ENV PORT=4001
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
70
apps/posts-mcs/README.md
Normal file
70
apps/posts-mcs/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Publications Service — всё для публикаций
|
||||
|
||||
Сервис публикаций в папке `posts-microservice`: CRUD, планирование, rich-контент (изображения, видео, аудио, таблицы и т.д.), календарь выпусков и API для SearXNG.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd posts-microservice
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Сервис: `http://localhost:4001`
|
||||
|
||||
## API
|
||||
|
||||
### Публикации
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|-------|-----|----------|
|
||||
| POST | `/api/publications` | Создать |
|
||||
| GET | `/api/publications` | Список (`?status=`, `?q=`, `?limit=`, `?offset=`) |
|
||||
| GET | `/api/publications/calendar` | Календарь (`?year=`, `?month=`) |
|
||||
| GET | `/api/publications/:id` | По ID |
|
||||
| GET | `/api/publications/slug/:slug` | По slug |
|
||||
| PATCH | `/api/publications/:id` | Обновить |
|
||||
| DELETE | `/api/publications/:id` | Удалить |
|
||||
|
||||
### Поля публикации
|
||||
|
||||
- `title` — заголовок
|
||||
- `slug` — URL-slug (генерируется из title)
|
||||
- `excerpt` — краткое описание
|
||||
- `contentBlocks` — массив блоков (текст, изображения, видео, аудио, таблицы, embed)
|
||||
- `previewImage` — URL превью
|
||||
- `url` — ссылка на страницу
|
||||
- `author` — автор
|
||||
- `status` — `draft` | `scheduled` | `published`
|
||||
- `scheduledAt` — время публикации (ISO)
|
||||
- `publishedAt` — время фактической публикации
|
||||
|
||||
### Блоки контента (`contentBlocks`)
|
||||
|
||||
```json
|
||||
[
|
||||
{ "type": "text", "data": { "html": "<p>Текст</p>" } },
|
||||
{ "type": "image", "data": { "src": "https://...", "alt": "...", "caption": "..." } },
|
||||
{ "type": "video", "data": { "src": "https://...", "poster": "...", "caption": "..." } },
|
||||
{ "type": "audio", "data": { "src": "https://...", "caption": "..." } },
|
||||
{ "type": "table", "data": { "html": "<table>...</table>", "caption": "..." } },
|
||||
{ "type": "embed", "data": { "url": "https://...", "caption": "..." } },
|
||||
{ "type": "heading", "data": { "level": 1, "text": "Заголовок" } },
|
||||
{ "type": "divider" }
|
||||
]
|
||||
```
|
||||
|
||||
### Планирование
|
||||
|
||||
- Публикация со `status: "scheduled"` и `scheduledAt` автоматически переходит в `published` при наступлении времени.
|
||||
- Планировщик запускается каждую минуту.
|
||||
|
||||
### SearXNG
|
||||
|
||||
Эндпоинт `/search?q={query}&page={pageno}` отдаёт только опубликованные публикации в формате JSON engine SearXNG.
|
||||
|
||||
Конфиг в `searxng-engine-example.yml`.
|
||||
|
||||
## Алиасы
|
||||
|
||||
- `/api/posts` → тот же API (для обратной совместимости)
|
||||
11
apps/posts-mcs/drizzle.config.ts
Normal file
11
apps/posts-mcs/drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'sqlite',
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dbCredentials: {
|
||||
url: path.join(process.cwd(), 'data', 'posts.db'),
|
||||
},
|
||||
});
|
||||
5
apps/posts-mcs/entrypoint.sh
Normal file
5
apps/posts-mcs/entrypoint.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
mkdir -p /app/data
|
||||
npx tsx src/init-db.ts 2>/dev/null || true
|
||||
exec npx tsx src/index.ts
|
||||
3145
apps/posts-mcs/package-lock.json
generated
Normal file
3145
apps/posts-mcs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
apps/posts-mcs/package.json
Normal file
26
apps/posts-mcs/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "posts-microservice",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Сервис постинга для SearXNG - API для отображения своих постов вместо Bing",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "npx tsx watch src/index.ts",
|
||||
"start": "npx tsx src/index.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"drizzle-orm": "^0.40.1",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/express": "^4.17.21",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
18
apps/posts-mcs/searxng-engine-example.yml
Normal file
18
apps/posts-mcs/searxng-engine-example.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
# Добавьте этот engine в /etc/searxng/settings.yml в секцию engines:
|
||||
#
|
||||
# Ваши публикации будут отображаться в результатах поиска Perplexica
|
||||
# Замените http://localhost:4001 на ваш URL (например http://posts:4001 в Docker)
|
||||
|
||||
- name: my_publications
|
||||
engine: json_engine
|
||||
paging: true
|
||||
search_url: http://localhost:4001/search?q={query}&page={pageno}
|
||||
results_query: results
|
||||
url_query: url
|
||||
title_query: title
|
||||
content_query: content
|
||||
disabled: false
|
||||
|
||||
# Чтобы отключить Bing и использовать только свои посты:
|
||||
# - name: bing
|
||||
# disabled: true
|
||||
13
apps/posts-mcs/src/db/index.ts
Normal file
13
apps/posts-mcs/src/db/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const dataDir = path.join(process.cwd(), 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sqlite = new Database(path.join(dataDir, 'posts.db'));
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
41
apps/posts-mcs/src/db/schema.ts
Normal file
41
apps/posts-mcs/src/db/schema.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// Типы блоков контента (изображения, видео, аудио, таблицы и т.д.)
|
||||
export type ContentBlock =
|
||||
| { type: 'text'; data: { html: string } }
|
||||
| { type: 'image'; data: { src: string; alt?: string; caption?: string } }
|
||||
| { type: 'video'; data: { src: string; poster?: string; caption?: string } }
|
||||
| { type: 'audio'; data: { src: string; caption?: string } }
|
||||
| { type: 'table'; data: { html: string; caption?: string } }
|
||||
| { type: 'embed'; data: { url: string; caption?: string } }
|
||||
| { type: 'divider' }
|
||||
| { type: 'heading'; data: { level: 1 | 2 | 3; text: string } };
|
||||
|
||||
export const publications = sqliteTable('publications', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
title: text('title').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
// Простой текст для поиска; полный контент в contentBlocks
|
||||
excerpt: text('excerpt'),
|
||||
// JSON массив ContentBlock[]
|
||||
contentBlocks: text('contentBlocks', { mode: 'json' })
|
||||
.$type<ContentBlock[]>()
|
||||
.default(sql`'[]'`),
|
||||
// Превью изображение
|
||||
previewImage: text('previewImage'),
|
||||
url: text('url').notNull(),
|
||||
author: text('author'),
|
||||
status: text({ enum: ['draft', 'scheduled', 'published'] }).notNull().default('draft'),
|
||||
publishedAt: text('publishedAt'),
|
||||
scheduledAt: text('scheduledAt'),
|
||||
createdAt: text('createdAt')
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
updatedAt: text('updatedAt')
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export type Publication = typeof publications.$inferSelect;
|
||||
export type NewPublication = typeof publications.$inferInsert;
|
||||
41
apps/posts-mcs/src/index.ts
Normal file
41
apps/posts-mcs/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import express from 'express';
|
||||
import publicationsRouter from './routes/publications.js';
|
||||
import searchRouter from './routes/search.js';
|
||||
import { runScheduledPublish } from './lib/scheduler.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4001;
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// API публикаций
|
||||
app.use('/api/publications', publicationsRouter);
|
||||
|
||||
// Алиас /api/posts -> publications (обратная совместимость)
|
||||
app.use('/api/posts', (req, res, next) => {
|
||||
const origJson = res.json.bind(res);
|
||||
res.json = (body: unknown) => {
|
||||
if (body && typeof body === 'object' && 'publications' in body) {
|
||||
return origJson({ ...body, posts: (body as any).publications });
|
||||
}
|
||||
return origJson(body);
|
||||
};
|
||||
next();
|
||||
}, publicationsRouter);
|
||||
|
||||
// SearXNG JSON Engine — поиск по опубликованным
|
||||
app.use('/search', searchRouter);
|
||||
|
||||
// Health
|
||||
app.get('/health', (_, res) => res.json({ status: 'ok' }));
|
||||
|
||||
// Планировщик: каждую минуту проверяет и публикует запланированные
|
||||
setInterval(runScheduledPublish, 60_000);
|
||||
runScheduledPublish();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Publications Service: http://localhost:${PORT}`);
|
||||
console.log(` API: /api/publications`);
|
||||
console.log(` Calendar: /api/publications/calendar`);
|
||||
console.log(` SearXNG: /search?q=...`);
|
||||
});
|
||||
26
apps/posts-mcs/src/init-db.ts
Normal file
26
apps/posts-mcs/src/init-db.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const dataDir = path.join(process.cwd(), 'data');
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
const dbPath = path.join(dataDir, 'posts.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS publications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
excerpt TEXT,
|
||||
contentBlocks TEXT DEFAULT '[]',
|
||||
previewImage TEXT,
|
||||
url TEXT NOT NULL,
|
||||
author TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
publishedAt TEXT,
|
||||
scheduledAt TEXT,
|
||||
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.close();
|
||||
22
apps/posts-mcs/src/lib/content.ts
Normal file
22
apps/posts-mcs/src/lib/content.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ContentBlock } from '../db/schema.js';
|
||||
|
||||
/** Извлекает поисковый текст из блоков контента */
|
||||
export function blocksToSearchText(blocks: ContentBlock[] | null): string {
|
||||
if (!blocks || !Array.isArray(blocks)) return '';
|
||||
return blocks
|
||||
.map((b) => {
|
||||
if (b.type === 'text' && b.data?.html) return stripHtml(String(b.data.html));
|
||||
if (b.type === 'heading' && b.data?.text) return b.data.text;
|
||||
if (b.type === 'image' && b.data?.caption) return b.data.caption;
|
||||
if (b.type === 'video' && b.data?.caption) return b.data.caption;
|
||||
if (b.type === 'audio' && b.data?.caption) return b.data.caption;
|
||||
if (b.type === 'table' && b.data?.html) return stripHtml(String(b.data.html));
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
25
apps/posts-mcs/src/lib/scheduler.ts
Normal file
25
apps/posts-mcs/src/lib/scheduler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { publications } from '../db/schema.js';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
/** Переводит запланированные публикации в published, когда наступило время */
|
||||
export function runScheduledPublish() {
|
||||
const now = new Date().toISOString();
|
||||
const rows = db
|
||||
.select({ id: publications.id })
|
||||
.from(publications)
|
||||
.where(
|
||||
sql`${publications.status} = 'scheduled' AND ${publications.scheduledAt} IS NOT NULL AND ${publications.scheduledAt} <= ${now}`,
|
||||
)
|
||||
.all();
|
||||
|
||||
for (const row of rows) {
|
||||
db.update(publications)
|
||||
.set({ status: 'published', publishedAt: now, updatedAt: now })
|
||||
.where(eq(publications.id, row.id))
|
||||
.run();
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
console.log(`[scheduler] Опубликовано: ${rows.length}`);
|
||||
}
|
||||
}
|
||||
226
apps/posts-mcs/src/routes/publications.ts
Normal file
226
apps/posts-mcs/src/routes/publications.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Router } from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { publications } from '../db/schema.js';
|
||||
import { desc, eq, and } from 'drizzle-orm';
|
||||
import { blocksToSearchText } from '../lib/content.js';
|
||||
import type { ContentBlock } from '../db/schema.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function slugify(s: string): string {
|
||||
const slug = s
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
return slug || `post-${Date.now()}`;
|
||||
}
|
||||
|
||||
// Создать публикацию
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
slug,
|
||||
excerpt,
|
||||
content,
|
||||
contentBlocks,
|
||||
previewImage,
|
||||
url,
|
||||
author,
|
||||
status,
|
||||
scheduledAt,
|
||||
} = req.body;
|
||||
|
||||
if (!title || !url) {
|
||||
return res.status(400).json({ error: 'Необходимы поля: title, url' });
|
||||
}
|
||||
|
||||
const finalSlug = slug || slugify(title);
|
||||
const pubStatus = status || 'draft';
|
||||
const now = new Date().toISOString();
|
||||
const blocks: ContentBlock[] =
|
||||
contentBlocks && Array.isArray(contentBlocks)
|
||||
? contentBlocks
|
||||
: content
|
||||
? [{ type: 'text', data: { html: content } }]
|
||||
: [];
|
||||
|
||||
let publishedAt: string | null = null;
|
||||
if (pubStatus === 'published') publishedAt = now;
|
||||
if (pubStatus === 'scheduled' && scheduledAt) publishedAt = null;
|
||||
|
||||
const result = db
|
||||
.insert(publications)
|
||||
.values({
|
||||
title,
|
||||
slug: finalSlug,
|
||||
excerpt: excerpt || (typeof content === 'string' ? content.slice(0, 300) : null),
|
||||
contentBlocks: blocks,
|
||||
previewImage: previewImage || null,
|
||||
url,
|
||||
author: author || null,
|
||||
status: pubStatus,
|
||||
publishedAt,
|
||||
scheduledAt: scheduledAt || null,
|
||||
})
|
||||
.run();
|
||||
|
||||
res.status(201).json({ id: result.lastInsertRowid, message: 'Публикация создана' });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'Ошибка создания публикации' });
|
||||
}
|
||||
});
|
||||
|
||||
// Список публикаций (с фильтрами)
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const q = req.query.q as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
let baseQuery = db.select().from(publications).orderBy(desc(publications.createdAt));
|
||||
|
||||
const conditions = [];
|
||||
if (status) {
|
||||
conditions.push(eq(publications.status, status as 'draft' | 'scheduled' | 'published'));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length ? and(...conditions) : undefined;
|
||||
const items = (whereClause
|
||||
? baseQuery.where(whereClause)
|
||||
: baseQuery
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
let result = items;
|
||||
if (q) {
|
||||
const qLower = q.toLowerCase();
|
||||
result = items.filter(
|
||||
(p) =>
|
||||
p.title?.toLowerCase().includes(qLower) ||
|
||||
(p.excerpt && p.excerpt.toLowerCase().includes(qLower)) ||
|
||||
p.url?.toLowerCase().includes(qLower) ||
|
||||
blocksToSearchText(p.contentBlocks).toLowerCase().includes(qLower),
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ publications: result });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'Ошибка получения публикаций' });
|
||||
}
|
||||
});
|
||||
|
||||
// Календарь: публикации по датам (для редакторского календаря)
|
||||
router.get('/calendar', (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.query.year as string) || new Date().getFullYear();
|
||||
const month = parseInt(req.query.month as string);
|
||||
|
||||
const items = db
|
||||
.select({
|
||||
id: publications.id,
|
||||
title: publications.title,
|
||||
status: publications.status,
|
||||
scheduledAt: publications.scheduledAt,
|
||||
publishedAt: publications.publishedAt,
|
||||
createdAt: publications.createdAt,
|
||||
})
|
||||
.from(publications)
|
||||
.all();
|
||||
|
||||
const byDate: Record<string, typeof items> = {};
|
||||
for (const p of items) {
|
||||
const dateStr =
|
||||
p.scheduledAt?.slice(0, 10) || p.publishedAt?.slice(0, 10) || p.createdAt?.slice(0, 10);
|
||||
if (!dateStr) continue;
|
||||
if (month !== undefined) {
|
||||
const [, m] = dateStr.split('-').map(Number);
|
||||
if (m !== month) continue;
|
||||
}
|
||||
const [y] = dateStr.split('-').map(Number);
|
||||
if (y !== year) continue;
|
||||
if (!byDate[dateStr]) byDate[dateStr] = [];
|
||||
byDate[dateStr].push(p);
|
||||
}
|
||||
|
||||
res.json({ calendar: byDate });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'Ошибка календаря' });
|
||||
}
|
||||
});
|
||||
|
||||
// Одна публикация по ID
|
||||
router.get('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const row = db.select().from(publications).where(eq(publications.id, id)).get();
|
||||
if (!row) return res.status(404).json({ error: 'Публикация не найдена' });
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
// По slug
|
||||
router.get('/slug/:slug', (req, res) => {
|
||||
const row = db
|
||||
.select()
|
||||
.from(publications)
|
||||
.where(eq(publications.slug, req.params.slug))
|
||||
.get();
|
||||
if (!row) return res.status(404).json({ error: 'Публикация не найдена' });
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
// Обновить
|
||||
router.patch('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
|
||||
const {
|
||||
title,
|
||||
slug,
|
||||
excerpt,
|
||||
contentBlocks,
|
||||
previewImage,
|
||||
url,
|
||||
author,
|
||||
status,
|
||||
scheduledAt,
|
||||
publishedAt,
|
||||
} = req.body;
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (slug !== undefined) updates.slug = slug;
|
||||
if (excerpt !== undefined) updates.excerpt = excerpt;
|
||||
if (contentBlocks !== undefined) updates.contentBlocks = contentBlocks;
|
||||
if (previewImage !== undefined) updates.previewImage = previewImage;
|
||||
if (url !== undefined) updates.url = url;
|
||||
if (author !== undefined) updates.author = author;
|
||||
if (status !== undefined) updates.status = status;
|
||||
if (scheduledAt !== undefined) updates.scheduledAt = scheduledAt;
|
||||
if (publishedAt !== undefined) updates.publishedAt = publishedAt;
|
||||
|
||||
const result = db.update(publications).set(updates as any).where(eq(publications.id, id)).run();
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Публикация не найдена' });
|
||||
res.json({ message: 'Публикация обновлена' });
|
||||
});
|
||||
|
||||
// Удалить
|
||||
router.delete('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const result = db.delete(publications).where(eq(publications.id, id)).run();
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Публикация не найдена' });
|
||||
res.json({ message: 'Публикация удалена' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
63
apps/posts-mcs/src/routes/search.ts
Normal file
63
apps/posts-mcs/src/routes/search.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Router } from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { publications } from '../db/schema.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { blocksToSearchText } from '../lib/content.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Только опубликованные или запланированные (когда время наступило) */
|
||||
function getPublishedItems() {
|
||||
const now = new Date().toISOString();
|
||||
const items = db
|
||||
.select()
|
||||
.from(publications)
|
||||
.where(
|
||||
sql`(${publications.status} = 'published' OR (${publications.status} = 'scheduled' AND ${publications.scheduledAt} <= ${now}))`,
|
||||
)
|
||||
.all();
|
||||
return items.sort((a, b) => {
|
||||
const da = a.publishedAt || a.scheduledAt || a.createdAt || '';
|
||||
const dbVal = b.publishedAt || b.scheduledAt || b.createdAt || '';
|
||||
return dbVal.localeCompare(da);
|
||||
});
|
||||
}
|
||||
|
||||
// SearXNG JSON Engine API
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const query = (req.query.q as string)?.trim() || '';
|
||||
const pageno = Math.max(1, parseInt((req.query.page || req.query.pageno) as string) || 1);
|
||||
const pageSize = 10;
|
||||
const offset = (pageno - 1) * pageSize;
|
||||
|
||||
const allItems = getPublishedItems();
|
||||
let filtered = allItems;
|
||||
if (query) {
|
||||
const q = query.toLowerCase();
|
||||
filtered = allItems.filter(
|
||||
(p) =>
|
||||
p.title?.toLowerCase().includes(q) ||
|
||||
(p.excerpt && p.excerpt.toLowerCase().includes(q)) ||
|
||||
p.url?.toLowerCase().includes(q) ||
|
||||
blocksToSearchText(p.contentBlocks).toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
const items = filtered.slice(offset, offset + pageSize);
|
||||
|
||||
const results = filtered.map((p) => ({
|
||||
url: p.url,
|
||||
title: p.title,
|
||||
content: (p.excerpt || blocksToSearchText(p.contentBlocks) || '').slice(0, 500),
|
||||
...(p.author && { author: p.author }),
|
||||
...(p.previewImage && { thumbnail: p.previewImage }),
|
||||
}));
|
||||
|
||||
res.json({ results });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ results: [] });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
13
apps/posts-mcs/tsconfig.json
Normal file
13
apps/posts-mcs/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user