Add Profile/Channel entities with CRUD and remove legacy parser builder.

Introduce reusable ParserProfile and ParseChannel, pair jobs with enqueue flatten, Events admin CRUD, and drop inline/legacy parser-builder UI and aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-16 14:51:03 +03:00
co-authored by Cursor
parent e196de7320
commit 71f8cf169e
30 changed files with 2157 additions and 954 deletions
@@ -4,9 +4,11 @@ import type {
Consumer,
ConsumerCreate,
ConsumerUpdate,
EventCreate,
EventFilters,
EventListResponse,
EventRecord,
EventUpdate,
ParseJob,
ParseJobCreate,
ParseJobUpdate,
@@ -69,6 +71,26 @@ export function fetchEvents(filters: EventFilters = {}): Promise<EventListRespon
);
}
export function createEvent(payload: EventCreate): Promise<EventRecord> {
return request<EventRecord>(
"/events",
{ method: "POST", body: JSON.stringify(payload) },
ADMIN_BASE,
);
}
export function updateEvent(id: number, payload: EventUpdate): Promise<EventRecord> {
return request<EventRecord>(
`/events/${id}`,
{ method: "PATCH", body: JSON.stringify(payload) },
ADMIN_BASE,
);
}
export function deleteEvent(id: number): Promise<void> {
return request<void>(`/events/${id}`, { method: "DELETE" }, ADMIN_BASE);
}
export function fetchConsumers(): Promise<Consumer[]> {
return request<Consumer[]>("/consumers", undefined, ADMIN_BASE);
}
@@ -0,0 +1,84 @@
import { ADMIN_BASE, request } from "./client";
import type {
HeuristicProfile,
ParseChannel,
ParseChannelCreate,
ParseChannelUpdate,
ParserProfile,
ParserProfileCreate,
ParserProfileUpdate,
TargetField,
} from "../types/admin";
export type { HeuristicProfile, TargetField } from "../types/admin";
export function fetchChannels(): Promise<ParseChannel[]> {
return request<ParseChannel[]>("/parse-channels", undefined, ADMIN_BASE);
}
export function createChannel(payload: ParseChannelCreate): Promise<ParseChannel> {
return request<ParseChannel>(
"/parse-channels",
{ method: "POST", body: JSON.stringify(payload) },
ADMIN_BASE,
);
}
export function updateChannel(id: number, payload: ParseChannelUpdate): Promise<ParseChannel> {
return request<ParseChannel>(
`/parse-channels/${id}`,
{ method: "PATCH", body: JSON.stringify(payload) },
ADMIN_BASE,
);
}
export function deleteChannel(id: number): Promise<void> {
return request<void>(`/parse-channels/${id}`, { method: "DELETE" }, ADMIN_BASE);
}
export function fetchProfiles(): Promise<ParserProfile[]> {
return request<ParserProfile[]>("/parser-profiles", undefined, ADMIN_BASE);
}
export function createProfile(payload: ParserProfileCreate): Promise<ParserProfile> {
return request<ParserProfile>(
"/parser-profiles",
{ method: "POST", body: JSON.stringify(payload) },
ADMIN_BASE,
);
}
export function updateProfile(id: number, payload: ParserProfileUpdate): Promise<ParserProfile> {
return request<ParserProfile>(
`/parser-profiles/${id}`,
{ method: "PATCH", body: JSON.stringify(payload) },
ADMIN_BASE,
);
}
export function deleteProfile(id: number): Promise<void> {
return request<void>(`/parser-profiles/${id}`, { method: "DELETE" }, ADMIN_BASE);
}
export function fetchTargetFields(): Promise<{ fields: TargetField[] }> {
return request<{ fields: TargetField[] }>("/parser-profiles/target-fields", undefined, ADMIN_BASE);
}
export function generateParserProfile(sample_post: string): Promise<{ profile: HeuristicProfile }> {
return request<{ profile: HeuristicProfile }>(
"/parser-profiles/generate",
{ method: "POST", body: JSON.stringify({ sample_post }) },
ADMIN_BASE,
);
}
export function previewParserProfile(
sample_post: string,
heuristic_profile: HeuristicProfile | Record<string, unknown>,
): Promise<{ fields: Record<string, string> }> {
return request<{ fields: Record<string, string> }>(
"/parser-profiles/preview",
{ method: "POST", body: JSON.stringify({ sample_post, heuristic_profile }) },
ADMIN_BASE,
);
}
@@ -1,52 +0,0 @@
import { ADMIN_BASE, request } from "./client";
import type { ParseJob } from "../types/admin";
export type TargetField = {
name: string;
type: string;
description: string;
};
export type HeuristicProfile = {
version: number;
fields: Record<string, Record<string, unknown>>;
notes?: string;
};
export function fetchTargetFields(): Promise<{ fields: TargetField[] }> {
return request<{ fields: TargetField[] }>("/parser-builder/target-fields", undefined, ADMIN_BASE);
}
export function generateParserProfile(sample_post: string): Promise<{ profile: HeuristicProfile }> {
return request<{ profile: HeuristicProfile }>(
"/parser-builder/generate",
{ method: "POST", body: JSON.stringify({ sample_post }) },
ADMIN_BASE,
);
}
export function previewParserProfile(
sample_post: string,
profile: HeuristicProfile | Record<string, unknown>,
): Promise<{ fields: Record<string, string> }> {
return request<{ fields: Record<string, string> }>(
"/parser-builder/preview",
{ method: "POST", body: JSON.stringify({ sample_post, profile }) },
ADMIN_BASE,
);
}
export function createProfileJob(payload: {
channel: string;
profile: HeuristicProfile | Record<string, unknown>;
sample_post?: string;
limit?: number;
interval_seconds?: number;
is_active?: boolean;
}): Promise<ParseJob> {
return request<ParseJob>(
"/parser-builder/jobs",
{ method: "POST", body: JSON.stringify(payload) },
ADMIN_BASE,
);
}
@@ -6,8 +6,9 @@ const route = useRoute();
const navItems = [
{ to: "/", label: "Карта", exact: true },
{ to: "/channels", label: "Каналы" },
{ to: "/parser-profiles", label: "Профили" },
{ to: "/parsers", label: "Парсеры" },
{ to: "/parser-builder", label: "Конструктор" },
{ to: "/events", label: "События" },
{ to: "/analytics", label: "Аналитика" },
{ to: "/consumers", label: "ПИ" },
@@ -2,10 +2,11 @@ import { createRouter, createWebHistory } from "vue-router";
import AppLayout from "../layouts/AppLayout.vue";
import AnalyticsView from "../views/AnalyticsView.vue";
import ChannelsView from "../views/ChannelsView.vue";
import ConsumersView from "../views/ConsumersView.vue";
import EventsView from "../views/EventsView.vue";
import MapViewPage from "../views/MapViewPage.vue";
import ParserBuilderView from "../views/ParserBuilderView.vue";
import ParserProfilesView from "../views/ParserProfilesView.vue";
import ParsersView from "../views/ParsersView.vue";
const router = createRouter({
@@ -16,8 +17,9 @@ const router = createRouter({
component: AppLayout,
children: [
{ path: "", name: "map", component: MapViewPage },
{ path: "channels", name: "channels", component: ChannelsView },
{ path: "parser-profiles", name: "parser-profiles", component: ParserProfilesView },
{ path: "parsers", name: "parsers", component: ParsersView },
{ path: "parser-builder", name: "parser-builder", component: ParserBuilderView },
{ path: "events", name: "events", component: EventsView },
{ path: "analytics", name: "analytics", component: AnalyticsView },
{ path: "consumers", name: "consumers", component: ConsumersView },
+9
View File
@@ -125,6 +125,15 @@ textarea {
font-size: 0.8rem;
}
.btn-danger {
color: #b71c1c;
border-color: #ef9a9a;
}
.btn-danger:hover {
background: #ffebee;
}
.link-btn {
background: none;
border: none;
+103 -2
View File
@@ -2,6 +2,8 @@ export interface ParseJob {
id: number;
source_type: string;
source_config: Record<string, unknown>;
profile_id?: number | null;
channel_id?: number | null;
schedule: string | null;
interval_seconds: number;
is_active: boolean;
@@ -9,11 +11,17 @@ export interface ParseJob {
last_run_at: string | null;
last_error: string | null;
created_at: string;
profile_name?: string | null;
channel_name?: string | null;
channel_handle?: string | null;
}
export interface ParseJobCreate {
source_type: string;
source_config: Record<string, unknown>;
source_type?: string;
source_config?: Record<string, unknown>;
profile_id?: number;
channel_id?: number;
limit?: number;
schedule?: string | null;
interval_seconds?: number;
is_active?: boolean;
@@ -21,10 +29,71 @@ export interface ParseJobCreate {
export interface ParseJobUpdate {
source_config?: Record<string, unknown>;
profile_id?: number;
channel_id?: number;
limit?: number;
interval_seconds?: number;
is_active?: boolean;
}
export interface ParserProfile {
id: number;
name: string;
sample_post: string;
heuristic_profile: Record<string, unknown> | null;
status: string;
created_at: string;
}
export interface ParserProfileCreate {
name: string;
sample_post?: string;
heuristic_profile?: Record<string, unknown> | null;
status?: string;
}
export interface ParserProfileUpdate {
name?: string;
sample_post?: string;
heuristic_profile?: Record<string, unknown> | null;
status?: string;
}
export interface ParseChannel {
id: number;
name: string;
source_type: string;
channel: string;
is_active: boolean;
created_at: string;
}
export interface ParseChannelCreate {
name: string;
source_type?: string;
channel: string;
is_active?: boolean;
}
export interface ParseChannelUpdate {
name?: string;
source_type?: string;
channel?: string;
is_active?: boolean;
}
export type TargetField = {
name: string;
type: string;
description: string;
};
export type HeuristicProfile = {
version: number;
fields: Record<string, Record<string, unknown>>;
notes?: string;
};
export interface EventRecord {
id: number;
source_type: string;
@@ -43,6 +112,38 @@ export interface EventRecord {
metadata: Record<string, unknown> | null;
}
export interface EventCreate {
source_type?: string;
source_url: string;
raw_text?: string;
title?: string;
description?: string;
locality?: string;
latitude?: number | null;
longitude?: number | null;
event_date?: string | null;
region?: string | null;
topic?: string | null;
tags?: string[] | null;
metadata?: Record<string, unknown> | null;
}
export interface EventUpdate {
source_type?: string;
source_url?: string;
raw_text?: string;
title?: string;
description?: string;
locality?: string;
latitude?: number | null;
longitude?: number | null;
event_date?: string | null;
region?: string | null;
topic?: string | null;
tags?: string[] | null;
metadata?: Record<string, unknown> | null;
}
export interface EventListResponse {
items: EventRecord[];
total: number;
@@ -0,0 +1,273 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
createChannel,
deleteChannel,
fetchChannels,
updateChannel,
} from "../api/entities";
import type { ParseChannel } from "../types/admin";
const channels = ref<ParseChannel[]>([]);
const loading = ref(true);
const error = ref("");
const submitting = ref(false);
const form = ref({
name: "",
channel: "",
is_active: true,
});
const editModal = ref<{
open: boolean;
channel: ParseChannel | null;
name: string;
handle: string;
is_active: boolean;
}>({
open: false,
channel: null,
name: "",
handle: "",
is_active: true,
});
async function loadChannels() {
loading.value = true;
error.value = "";
try {
channels.value = await fetchChannels();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить каналы";
} finally {
loading.value = false;
}
}
async function handleCreate() {
if (!form.value.name.trim() || !form.value.channel.trim()) {
error.value = "Укажите название и handle канала";
return;
}
submitting.value = true;
error.value = "";
try {
await createChannel({
name: form.value.name.trim(),
channel: form.value.channel.trim(),
source_type: "telegram",
is_active: form.value.is_active,
});
form.value = { name: "", channel: "", is_active: true };
await loadChannels();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось создать канал";
} finally {
submitting.value = false;
}
}
function openEdit(ch: ParseChannel) {
editModal.value = {
open: true,
channel: ch,
name: ch.name,
handle: ch.channel,
is_active: ch.is_active,
};
}
function closeEdit() {
editModal.value.open = false;
editModal.value.channel = null;
}
async function handleSaveEdit() {
if (!editModal.value.channel) return;
if (!editModal.value.name.trim() || !editModal.value.handle.trim()) {
error.value = "Укажите название и handle";
return;
}
submitting.value = true;
error.value = "";
try {
await updateChannel(editModal.value.channel.id, {
name: editModal.value.name.trim(),
channel: editModal.value.handle.trim(),
is_active: editModal.value.is_active,
});
closeEdit();
await loadChannels();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось сохранить";
} finally {
submitting.value = false;
}
}
async function handleDelete(ch: ParseChannel) {
if (!window.confirm(`Удалить канал «${ch.name}» (@${ch.channel})?`)) return;
error.value = "";
try {
await deleteChannel(ch.id);
await loadChannels();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось удалить";
}
}
function formatDate(value: string): string {
return new Date(value).toLocaleString("ru-RU");
}
onMounted(loadChannels);
</script>
<template>
<div class="page">
<h2 class="page-heading">Каналы</h2>
<p class="intro">
Каналы для парсинга (Telegram). Связка канал + профиль создаётся на странице
<router-link to="/parsers">Парсеры</router-link>.
</p>
<section class="card">
<h3>Новый канал</h3>
<form class="admin-form" @submit.prevent="handleCreate">
<div class="form-row">
<label>
Название
<input v-model="form.name" type="text" placeholder="Сводки фронта" required />
</label>
<label>
Handle / id
<input v-model="form.channel" type="text" placeholder="example_channel" required />
</label>
<label class="checkbox-label">
<input v-model="form.is_active" type="checkbox" />
Активен
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Создание..." : "Создать канал" }}
</button>
</div>
</form>
<p v-if="error" class="form-error">{{ error }}</p>
</section>
<section class="card">
<h3>Список <span v-if="loading" class="muted">(загрузка...)</span></h3>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Канал</th>
<th>Тип</th>
<th>Активен</th>
<th>Создан</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="ch in channels" :key="ch.id">
<td>{{ ch.id }}</td>
<td>{{ ch.name }}</td>
<td><code>@{{ ch.channel }}</code></td>
<td>{{ ch.source_type }}</td>
<td>{{ ch.is_active ? "да" : "нет" }}</td>
<td>{{ formatDate(ch.created_at) }}</td>
<td class="actions">
<button type="button" class="btn btn-sm" @click="openEdit(ch)">Изменить</button>
<button type="button" class="btn btn-sm btn-danger" @click="handleDelete(ch)">
Удалить
</button>
</td>
</tr>
<tr v-if="!loading && !channels.length">
<td colspan="7" class="muted">Пока нет каналов</td>
</tr>
</tbody>
</table>
</div>
</section>
<div v-if="editModal.open" class="modal-backdrop" @click.self="closeEdit">
<div class="modal">
<h3>Редактировать канал #{{ editModal.channel?.id }}</h3>
<form class="admin-form" @submit.prevent="handleSaveEdit">
<label>
Название
<input v-model="editModal.name" type="text" required />
</label>
<label>
Handle / id
<input v-model="editModal.handle" type="text" required />
</label>
<label class="checkbox-label">
<input v-model="editModal.is_active" type="checkbox" />
Активен
</label>
<div class="form-actions">
<button type="button" class="btn" @click="closeEdit">Отмена</button>
<button type="submit" class="btn btn-primary" :disabled="submitting">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.intro {
margin: 0 0 1rem;
font-size: 0.9rem;
color: #555;
line-height: 1.5;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
.actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
background: #fff;
border-radius: 8px;
padding: 1.25rem 1.5rem;
width: min(420px, 92vw);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.modal h3 {
margin: 0 0 1rem;
font-size: 1.05rem;
}
.modal .admin-form label {
display: block;
margin-bottom: 0.75rem;
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { fetchEvents } from "../api/admin";
import { createEvent, deleteEvent, fetchEvents, updateEvent } from "../api/admin";
import type { EventFilters, EventRecord } from "../types/admin";
const router = useRouter();
@@ -10,6 +10,7 @@ const items = ref<EventRecord[]>([]);
const total = ref(0);
const loading = ref(true);
const error = ref("");
const submitting = ref(false);
const filters = ref({
search: "",
@@ -24,8 +25,44 @@ const filters = ref({
const page = ref(1);
const pageSize = 25;
const selectedEvent = ref<EventRecord | null>(null);
const modalOpen = ref(false);
type EventForm = {
source_type: string;
source_url: string;
title: string;
description: string;
locality: string;
region: string;
topic: string;
raw_text: string;
latitude: string;
longitude: string;
event_date: string;
tags: string;
};
function emptyForm(): EventForm {
return {
source_type: "manual",
source_url: "",
title: "",
description: "",
locality: "",
region: "",
topic: "",
raw_text: "",
latitude: "",
longitude: "",
event_date: "",
tags: "",
};
}
const createForm = ref(emptyForm());
const editModal = ref<{ open: boolean; event: EventRecord | null; form: EventForm }>({
open: false,
event: null,
form: emptyForm(),
});
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
@@ -76,14 +113,128 @@ function resetFilters() {
loadEvents();
}
function openModal(event: EventRecord) {
selectedEvent.value = event;
modalOpen.value = true;
function parseOptionalFloat(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const num = Number(trimmed);
if (Number.isNaN(num)) throw new Error("Координаты должны быть числами");
return num;
}
function closeModal() {
modalOpen.value = false;
selectedEvent.value = null;
function parseTags(value: string): string[] | null {
const tags = value
.split(",")
.map((t) => t.trim())
.filter(Boolean);
return tags.length ? tags : null;
}
function toDateTimeLocal(value: string | null): string {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formToPayload(form: EventForm) {
const latitude = parseOptionalFloat(form.latitude);
const longitude = parseOptionalFloat(form.longitude);
if ((latitude == null) !== (longitude == null)) {
throw new Error("Укажите обе координаты или ни одной");
}
const source_url = form.source_url.trim();
if (!source_url) throw new Error("Укажите source_url");
return {
source_type: form.source_type.trim() || "manual",
source_url,
title: form.title.trim(),
description: form.description.trim(),
locality: form.locality.trim(),
region: form.region.trim() || null,
topic: form.topic.trim() || null,
raw_text: form.raw_text,
latitude,
longitude,
event_date: form.event_date ? new Date(form.event_date).toISOString() : null,
tags: parseTags(form.tags),
};
}
function eventToForm(event: EventRecord): EventForm {
return {
source_type: event.source_type || "manual",
source_url: event.source_url,
title: event.title || "",
description: event.description || "",
locality: event.locality || "",
region: event.region || "",
topic: event.topic || "",
raw_text: event.raw_text || "",
latitude: event.latitude != null ? String(event.latitude) : "",
longitude: event.longitude != null ? String(event.longitude) : "",
event_date: toDateTimeLocal(event.event_date),
tags: event.tags?.join(", ") ?? "",
};
}
async function handleCreate() {
submitting.value = true;
error.value = "";
try {
await createEvent(formToPayload(createForm.value));
createForm.value = emptyForm();
createForm.value.source_url = `manual://${crypto.randomUUID()}`;
await loadEvents();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось создать событие";
} finally {
submitting.value = false;
}
}
function openEdit(event: EventRecord) {
editModal.value = {
open: true,
event,
form: eventToForm(event),
};
}
function closeEdit() {
editModal.value.open = false;
editModal.value.event = null;
}
async function handleSaveEdit() {
if (!editModal.value.event) return;
submitting.value = true;
error.value = "";
try {
await updateEvent(editModal.value.event.id, formToPayload(editModal.value.form));
closeEdit();
await loadEvents();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось сохранить событие";
} finally {
submitting.value = false;
}
}
async function handleDelete(event: EventRecord) {
const label = event.title || event.source_url || `#${event.id}`;
if (!window.confirm(`Удалить событие #${event.id} («${label}»)? Связанная точка на карте тоже будет удалена.`)) {
return;
}
error.value = "";
try {
await deleteEvent(event.id);
if (editModal.value.event?.id === event.id) closeEdit();
await loadEvents();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось удалить событие";
}
}
function goToMap(event: EventRecord) {
@@ -103,13 +254,77 @@ function truncate(text: string, max = 80): string {
watch(page, loadEvents);
onMounted(loadEvents);
onMounted(() => {
createForm.value.source_url = `manual://${crypto.randomUUID()}`;
loadEvents();
});
</script>
<template>
<div class="page">
<h2 class="page-heading">События</h2>
<section class="card">
<h3>Новое событие</h3>
<form class="admin-form" @submit.prevent="handleCreate">
<div class="form-row">
<label>
Заголовок
<input v-model="createForm.title" type="text" placeholder="Краткий заголовок" />
</label>
<label>
Источник (type)
<input v-model="createForm.source_type" type="text" placeholder="manual" />
</label>
<label class="span-2">
source_url
<input v-model="createForm.source_url" type="text" required />
</label>
<label class="span-2">
Описание
<textarea v-model="createForm.description" rows="2" />
</label>
<label>
Регион
<input v-model="createForm.region" type="text" />
</label>
<label>
Тема
<input v-model="createForm.topic" type="text" />
</label>
<label>
Населённый пункт
<input v-model="createForm.locality" type="text" />
</label>
<label>
Дата события
<input v-model="createForm.event_date" type="datetime-local" />
</label>
<label>
Широта
<input v-model="createForm.latitude" type="text" placeholder="48.1234" />
</label>
<label>
Долгота
<input v-model="createForm.longitude" type="text" placeholder="37.6543" />
</label>
<label>
Теги (через запятую)
<input v-model="createForm.tags" type="text" placeholder="обстрел, нп" />
</label>
<label class="span-2">
Сырой текст
<textarea v-model="createForm.raw_text" rows="3" />
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Создание..." : "Создать событие" }}
</button>
</div>
</form>
</section>
<section class="card">
<form class="admin-form filters-form" @submit.prevent="applyFilters">
<div class="form-row">
@@ -177,7 +392,7 @@ onMounted(loadEvents);
<tr v-for="event in items" :key="event.id">
<td>{{ event.id }}</td>
<td>
<button class="link-btn" @click="openModal(event)">
<button class="link-btn" type="button" @click="openEdit(event)">
{{ truncate(event.title || event.description || "—") }}
</button>
</td>
@@ -191,14 +406,19 @@ onMounted(loadEvents);
</template>
<template v-else>—</template>
</td>
<td>
<td class="actions">
<button class="btn btn-sm" type="button" @click="openEdit(event)">Изменить</button>
<button
v-if="event.latitude != null && event.longitude != null"
class="btn btn-sm"
type="button"
@click="goToMap(event)"
>
На карте
</button>
<button class="btn btn-sm btn-danger" type="button" @click="handleDelete(event)">
Удалить
</button>
</td>
</tr>
</tbody>
@@ -206,56 +426,129 @@ onMounted(loadEvents);
</div>
<div class="pagination">
<button class="btn btn-sm" :disabled="page <= 1" @click="page--">← Назад</button>
<button class="btn btn-sm" type="button" :disabled="page <= 1" @click="page--">← Назад</button>
<span>Стр. {{ page }} / {{ totalPages }}</span>
<button class="btn btn-sm" :disabled="page >= totalPages" @click="page++">Вперёд →</button>
<button class="btn btn-sm" type="button" :disabled="page >= totalPages" @click="page++">
Вперёд →
</button>
</div>
</section>
<div v-if="modalOpen && selectedEvent" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div v-if="editModal.open && editModal.event" class="modal-overlay" @click.self="closeEdit">
<div class="modal modal-wide">
<header class="modal-header">
<h3>Событие #{{ selectedEvent.id }}</h3>
<button class="btn btn-sm" @click="closeModal">✕</button>
<h3>Событие #{{ editModal.event.id }}</h3>
<button class="btn btn-sm" type="button" @click="closeEdit">✕</button>
</header>
<div class="modal-body">
<dl class="detail-list">
<dt>Заголовок</dt>
<dd>{{ selectedEvent.title || "—" }}</dd>
<dt>Описание</dt>
<dd>{{ selectedEvent.description || "—" }}</dd>
<dt>Источник</dt>
<dd>{{ selectedEvent.source_type }} — {{ selectedEvent.source_url }}</dd>
<dt>Регион / Тема</dt>
<dd>{{ selectedEvent.region ?? "—" }} / {{ selectedEvent.topic ?? "—" }}</dd>
<dt>Населённый пункт</dt>
<dd>{{ selectedEvent.locality || "—" }}</dd>
<dt>Дата события</dt>
<dd>{{ formatDate(selectedEvent.event_date) }}</dd>
<dt>Ingested</dt>
<dd>{{ formatDate(selectedEvent.ingested_at) }}</dd>
<dt>Координаты</dt>
<dd>
<template v-if="selectedEvent.latitude != null">
{{ selectedEvent.latitude }}, {{ selectedEvent.longitude }}
</template>
<template v-else>—</template>
</dd>
<dt>Текст</dt>
<dd class="raw-text">{{ selectedEvent.raw_text || "—" }}</dd>
</dl>
</div>
<footer class="modal-footer">
<button
v-if="selectedEvent.latitude != null"
class="btn btn-primary"
@click="goToMap(selectedEvent)"
>
Показать на карте
</button>
<button class="btn" @click="closeModal">Закрыть</button>
</footer>
<form class="modal-body admin-form" @submit.prevent="handleSaveEdit">
<div class="form-row">
<label>
Заголовок
<input v-model="editModal.form.title" type="text" />
</label>
<label>
Источник (type)
<input v-model="editModal.form.source_type" type="text" />
</label>
<label class="span-2">
source_url
<input v-model="editModal.form.source_url" type="text" required />
</label>
<label class="span-2">
Описание
<textarea v-model="editModal.form.description" rows="2" />
</label>
<label>
Регион
<input v-model="editModal.form.region" type="text" />
</label>
<label>
Тема
<input v-model="editModal.form.topic" type="text" />
</label>
<label>
Населённый пункт
<input v-model="editModal.form.locality" type="text" />
</label>
<label>
Дата события
<input v-model="editModal.form.event_date" type="datetime-local" />
</label>
<label>
Широта
<input v-model="editModal.form.latitude" type="text" />
</label>
<label>
Долгота
<input v-model="editModal.form.longitude" type="text" />
</label>
<label>
Теги
<input v-model="editModal.form.tags" type="text" />
</label>
<label class="span-2">
Сырой текст
<textarea v-model="editModal.form.raw_text" rows="4" />
</label>
<p class="hint span-2">
Ingested: {{ formatDate(editModal.event.ingested_at) }}. При координатах создаётся/обновляется
точка на карте; без координат связанная точка удаляется.
</p>
</div>
<div class="form-actions">
<button type="button" class="btn" @click="closeEdit">Отмена</button>
<button
v-if="editModal.event.latitude != null"
type="button"
class="btn"
@click="goToMap(editModal.event)"
>
На карте
</button>
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Сохранение..." : "Сохранить" }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.form-row .span-2 {
grid-column: span 2;
}
.form-row textarea {
width: 100%;
font: inherit;
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
resize: vertical;
}
.actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
white-space: nowrap;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: #666;
line-height: 1.4;
}
.modal-wide {
width: min(720px, 94vw);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem;
}
</style>
@@ -1,320 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import {
createProfileJob,
fetchTargetFields,
generateParserProfile,
previewParserProfile,
type HeuristicProfile,
type TargetField,
} from "../api/parserBuilder";
const INTERVAL_OPTIONS = [
{ value: 900, label: "15 мин" },
{ value: 1800, label: "30 мин" },
{ value: 3600, label: "1 ч" },
{ value: 10800, label: "3 ч" },
{ value: 21600, label: "6 ч" },
{ value: 86400, label: "24 ч" },
];
const router = useRouter();
const samplePost = ref("");
const targetFields = ref<TargetField[]>([]);
const profile = ref<HeuristicProfile | null>(null);
const profileJson = ref("");
const previewFields = ref<Record<string, string> | null>(null);
const channel = ref("");
const limit = ref(100);
const intervalSeconds = ref(3600);
const loadingFields = ref(true);
const generating = ref(false);
const previewing = ref(false);
const saving = ref(false);
const error = ref("");
const success = ref("");
async function loadTargetFields() {
loadingFields.value = true;
try {
const data = await fetchTargetFields();
targetFields.value = data.fields;
error.value = "";
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить целевые поля";
} finally {
loadingFields.value = false;
}
}
function syncProfileFromJson(): HeuristicProfile | null {
if (!profileJson.value.trim()) return null;
try {
const parsed = JSON.parse(profileJson.value) as HeuristicProfile;
profile.value = parsed;
error.value = "";
return parsed;
} catch {
error.value = "Некорректный JSON профиля";
return null;
}
}
async function handleGenerate() {
if (!samplePost.value.trim()) {
error.value = "Вставьте образец поста";
return;
}
generating.value = true;
error.value = "";
success.value = "";
previewFields.value = null;
try {
const data = await generateParserProfile(samplePost.value);
profile.value = data.profile;
profileJson.value = JSON.stringify(data.profile, null, 2);
success.value = "Профиль сгенерирован. Preview и runtime идут только по правилам — без LLM.";
} catch (err) {
error.value = err instanceof Error ? err.message : "Ошибка генерации";
} finally {
generating.value = false;
}
}
async function handlePreview() {
const current = syncProfileFromJson();
if (!current) {
if (!error.value) error.value = "Сначала сгенерируйте профиль";
return;
}
if (!samplePost.value.trim()) {
error.value = "Нужен образец поста для preview";
return;
}
previewing.value = true;
error.value = "";
try {
const data = await previewParserProfile(samplePost.value, current);
previewFields.value = data.fields;
} catch (err) {
error.value = err instanceof Error ? err.message : "Ошибка preview";
} finally {
previewing.value = false;
}
}
async function handleCreate() {
const current = syncProfileFromJson();
if (!current) {
if (!error.value) error.value = "Нужен профиль";
return;
}
if (!channel.value.trim()) {
error.value = "Укажите канал Telegram";
return;
}
saving.value = true;
error.value = "";
success.value = "";
try {
const job = await createProfileJob({
channel: channel.value.trim(),
profile: current,
sample_post: samplePost.value.trim() || undefined,
limit: limit.value,
interval_seconds: intervalSeconds.value,
is_active: true,
});
success.value = `Парсер #${job.id} создан (extract_mode=profile).`;
await router.push({ name: "parsers" });
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось создать парсер";
} finally {
saving.value = false;
}
}
onMounted(loadTargetFields);
</script>
<template>
<div class="page">
<h2 class="page-heading">Конструктор парсера</h2>
<p class="intro">
Вставьте образец структурированного поста → DeepSeek один раз построит статичный профиль правил.
Дальше preview и парсинг канала идут только по профилю, без LLM на каждый пост.
</p>
<section class="card">
<h3>Образец поста</h3>
<textarea
v-model="samplePost"
class="sample-area"
rows="12"
placeholder="15.03.2024 Населённый пункт&#10;&#10;Описание события…&#10;&#10;48.123456, 37.654321&#10;#тема"
/>
<div class="form-actions">
<button
type="button"
class="btn btn-primary"
:disabled="generating || !samplePost.trim()"
@click="handleGenerate"
>
{{ generating ? "Генерация..." : "Сгенерировать парсер" }}
</button>
<button
type="button"
class="btn"
:disabled="previewing || !profileJson.trim()"
@click="handlePreview"
>
{{ previewing ? "Preview..." : "Preview" }}
</button>
</div>
</section>
<div class="grid-2">
<section class="card">
<h3>Целевая таблица <span class="muted">(Event / ingest)</span></h3>
<p v-if="loadingFields" class="muted">Загрузка…</p>
<div v-else class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Поле</th>
<th>Тип</th>
<th>Описание</th>
</tr>
</thead>
<tbody>
<tr v-for="field in targetFields" :key="field.name">
<td><code>{{ field.name }}</code></td>
<td>{{ field.type }}</td>
<td>{{ field.description }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="card">
<h3>Профиль (JSON)</h3>
<p v-if="!profileJson" class="muted">Появится после генерации. Можно слегка поправить правила.</p>
<textarea
v-model="profileJson"
class="profile-area"
rows="16"
:disabled="!profileJson && !profile"
spellcheck="false"
/>
</section>
</div>
<section v-if="previewFields" class="card">
<h3>Preview строки</h3>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th v-for="key in Object.keys(previewFields)" :key="key">{{ key }}</th>
</tr>
</thead>
<tbody>
<tr>
<td v-for="(val, key) in previewFields" :key="key" class="preview-cell">
{{ val || "—" }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="card">
<h3>Создать Telegram-парсер</h3>
<form class="admin-form" @submit.prevent="handleCreate">
<div class="form-row">
<label>
Канал
<input v-model="channel" type="text" placeholder="example_channel" required />
</label>
<label>
Лимит batch
<input v-model.number="limit" type="number" min="1" max="1000" />
</label>
<label>
Интервал
<select v-model.number="intervalSeconds">
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</label>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
:disabled="saving || !profileJson.trim() || !channel.trim()"
>
{{ saving ? "Создание..." : "Создать парсер" }}
</button>
</div>
</form>
</section>
<p v-if="error" class="form-error">{{ error }}</p>
<p v-if="success" class="form-success">{{ success }}</p>
</div>
</template>
<style scoped>
.intro {
margin: 0 0 1rem;
font-size: 0.9rem;
color: #555;
line-height: 1.5;
max-width: 72ch;
}
.sample-area,
.profile-area {
width: 100%;
font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem;
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
resize: vertical;
line-height: 1.45;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 960px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
.preview-cell {
max-width: 180px;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.8rem;
}
.form-success {
color: #2e7d32;
font-size: 0.875rem;
margin-top: 0.5rem;
}
</style>
@@ -0,0 +1,422 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
createProfile,
deleteProfile,
fetchProfiles,
fetchTargetFields,
generateParserProfile,
previewParserProfile,
updateProfile,
type HeuristicProfile,
type TargetField,
} from "../api/entities";
import type { ParserProfile } from "../types/admin";
const profiles = ref<ParserProfile[]>([]);
const loading = ref(true);
const error = ref("");
const success = ref("");
const submitting = ref(false);
const samplePost = ref("");
const profileName = ref("");
const targetFields = ref<TargetField[]>([]);
const profileJson = ref("");
const previewFields = ref<Record<string, string> | null>(null);
const generating = ref(false);
const previewing = ref(false);
const loadingFields = ref(true);
const editingId = ref<number | null>(null);
const profileStatus = ref<"draft" | "ready">("draft");
async function loadProfiles() {
loading.value = true;
try {
profiles.value = await fetchProfiles();
error.value = "";
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить профили";
} finally {
loading.value = false;
}
}
async function loadTargetFields() {
loadingFields.value = true;
try {
const data = await fetchTargetFields();
targetFields.value = data.fields;
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить целевые поля";
} finally {
loadingFields.value = false;
}
}
function syncProfileFromJson(): HeuristicProfile | null {
if (!profileJson.value.trim()) return null;
try {
return JSON.parse(profileJson.value) as HeuristicProfile;
} catch {
error.value = "Некорректный JSON профиля";
return null;
}
}
async function handleGenerate() {
if (!samplePost.value.trim()) {
error.value = "Вставьте образец поста";
return;
}
generating.value = true;
error.value = "";
success.value = "";
previewFields.value = null;
try {
const data = await generateParserProfile(samplePost.value);
profileJson.value = JSON.stringify(data.profile, null, 2);
success.value = "Профиль сгенерирован. Preview и runtime — только по правилам, без LLM.";
} catch (err) {
error.value = err instanceof Error ? err.message : "Ошибка генерации";
} finally {
generating.value = false;
}
}
async function handlePreview() {
const current = syncProfileFromJson();
if (!current) {
if (!error.value) error.value = "Сначала сгенерируйте или вставьте профиль";
return;
}
if (!samplePost.value.trim()) {
error.value = "Нужен образец поста для preview";
return;
}
previewing.value = true;
error.value = "";
try {
const data = await previewParserProfile(samplePost.value, current);
previewFields.value = data.fields;
} catch (err) {
error.value = err instanceof Error ? err.message : "Ошибка preview";
} finally {
previewing.value = false;
}
}
function resetEditor() {
editingId.value = null;
profileName.value = "";
samplePost.value = "";
profileJson.value = "";
profileStatus.value = "draft";
previewFields.value = null;
success.value = "";
}
function openEdit(p: ParserProfile) {
editingId.value = p.id;
profileName.value = p.name;
samplePost.value = p.sample_post || "";
profileJson.value = p.heuristic_profile
? JSON.stringify(p.heuristic_profile, null, 2)
: "";
profileStatus.value = p.status === "ready" ? "ready" : "draft";
previewFields.value = null;
success.value = "";
error.value = "";
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function handleSave() {
if (!profileName.value.trim()) {
error.value = "Укажите название профиля";
return;
}
const current = syncProfileFromJson();
if (!current) {
if (!error.value) error.value = "Нужен JSON профиля (Generate или вручную)";
return;
}
submitting.value = true;
error.value = "";
success.value = "";
try {
if (editingId.value != null) {
await updateProfile(editingId.value, {
name: profileName.value.trim(),
sample_post: samplePost.value,
heuristic_profile: current,
status: profileStatus.value,
});
success.value = `Профиль #${editingId.value} обновлён`;
} else {
const created = await createProfile({
name: profileName.value.trim(),
sample_post: samplePost.value,
heuristic_profile: current,
status: profileStatus.value === "draft" ? "draft" : "ready",
});
success.value = `Профиль #${created.id} сохранён`;
editingId.value = created.id;
profileStatus.value = created.status === "ready" ? "ready" : "draft";
}
await loadProfiles();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось сохранить профиль";
} finally {
submitting.value = false;
}
}
async function handleDelete(p: ParserProfile) {
if (!window.confirm(`Удалить профиль «${p.name}»?`)) return;
error.value = "";
try {
await deleteProfile(p.id);
if (editingId.value === p.id) resetEditor();
await loadProfiles();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось удалить";
}
}
function formatDate(value: string): string {
return new Date(value).toLocaleString("ru-RU");
}
onMounted(() => {
loadProfiles();
loadTargetFields();
});
</script>
<template>
<div class="page">
<h2 class="page-heading">Профили парсера</h2>
<p class="intro">
Статичный эвристический профиль: Generate (DeepSeek один раз) → Preview → Сохранить.
Дальше профиль подключается к каналу на странице
<router-link to="/parsers">Парсеры</router-link>.
</p>
<section class="card">
<div class="card-head">
<h3>{{ editingId != null ? `Редактирование #${editingId}` : "Новый профиль" }}</h3>
<button v-if="editingId != null" type="button" class="btn btn-sm" @click="resetEditor">
Сбросить
</button>
</div>
<div class="form-row">
<label>
Название
<input v-model="profileName" type="text" placeholder="Сводка: дата + НП + coords" />
</label>
<label>
Статус
<select v-model="profileStatus">
<option value="draft">draft</option>
<option value="ready">ready</option>
</select>
</label>
</div>
<label>
Образец поста
<textarea
v-model="samplePost"
class="sample-area"
rows="10"
placeholder="15.03.2024 Населённый пункт&#10;&#10;Описание…&#10;&#10;48.123456, 37.654321"
/>
</label>
<div class="form-actions">
<button
type="button"
class="btn btn-primary"
:disabled="generating || !samplePost.trim()"
@click="handleGenerate"
>
{{ generating ? "Генерация..." : "Generate" }}
</button>
<button
type="button"
class="btn"
:disabled="previewing || !profileJson.trim()"
@click="handlePreview"
>
{{ previewing ? "Preview..." : "Preview" }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="submitting || !profileJson.trim() || !profileName.trim()"
@click="handleSave"
>
{{ submitting ? "Сохранение..." : editingId != null ? "Обновить" : "Сохранить профиль" }}
</button>
</div>
</section>
<div class="grid-2">
<section class="card">
<h3>Целевые поля <span class="muted">(Event)</span></h3>
<p v-if="loadingFields" class="muted">Загрузка…</p>
<div v-else class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Поле</th>
<th>Тип</th>
<th>Описание</th>
</tr>
</thead>
<tbody>
<tr v-for="field in targetFields" :key="field.name">
<td><code>{{ field.name }}</code></td>
<td>{{ field.type }}</td>
<td>{{ field.description }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="card">
<h3>Профиль (JSON)</h3>
<textarea
v-model="profileJson"
class="profile-area"
rows="16"
spellcheck="false"
placeholder="Появится после Generate"
/>
</section>
</div>
<section v-if="previewFields" class="card">
<h3>Preview строки</h3>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th v-for="key in Object.keys(previewFields)" :key="key">{{ key }}</th>
</tr>
</thead>
<tbody>
<tr>
<td v-for="(val, key) in previewFields" :key="key" class="preview-cell">
{{ val || "—" }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
<p v-if="error" class="form-error">{{ error }}</p>
<p v-if="success" class="form-success">{{ success }}</p>
<section class="card">
<h3>Сохранённые профили <span v-if="loading" class="muted">(загрузка...)</span></h3>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Статус</th>
<th>Создан</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in profiles" :key="p.id">
<td>{{ p.id }}</td>
<td>{{ p.name }}</td>
<td>{{ p.status }}</td>
<td>{{ formatDate(p.created_at) }}</td>
<td class="actions">
<button type="button" class="btn btn-sm" @click="openEdit(p)">Изменить</button>
<button type="button" class="btn btn-sm btn-danger" @click="handleDelete(p)">
Удалить
</button>
</td>
</tr>
<tr v-if="!loading && !profiles.length">
<td colspan="5" class="muted">Пока нет профилей</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<style scoped>
.intro {
margin: 0 0 1rem;
font-size: 0.9rem;
color: #555;
line-height: 1.5;
max-width: 72ch;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.card-head h3 {
margin: 0;
}
.sample-area,
.profile-area {
width: 100%;
font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem;
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
resize: vertical;
line-height: 1.45;
margin-top: 0.35rem;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 960px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
.preview-cell {
max-width: 180px;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.8rem;
}
.form-success {
color: #2e7d32;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { createJob, deleteJob, fetchJobs, retryJob, updateJob } from "../api/admin";
import type { ParseJob } from "../types/admin";
import { fetchChannels, fetchProfiles } from "../api/entities";
import type { ParseChannel, ParseJob, ParserProfile } from "../types/admin";
const INTERVAL_OPTIONS = [
{ value: 900, label: "15 мин" },
@@ -12,105 +13,65 @@ const INTERVAL_OPTIONS = [
{ value: 86400, label: "24 ч" },
];
const SOURCE_TYPES = [
{ value: "telegram", label: "Telegram" },
{ value: "crawl4ai", label: "Crawl4AI (web)" },
{ value: "viina", label: "VIINA (news NLP)" },
] as const;
const jobs = ref<ParseJob[]>([]);
const channels = ref<ParseChannel[]>([]);
const profiles = ref<ParserProfile[]>([]);
const loading = ref(true);
const error = ref("");
const submitting = ref(false);
const forcingJobId = ref<number | null>(null);
const editingJob = ref<ParseJob | null>(null);
const pairForm = ref({
channel_id: null as number | null,
profile_id: null as number | null,
limit: 100,
interval_seconds: 3600,
});
const editForm = ref({
channel: "",
limit: 50,
urlsText: "",
textsText: "",
domain_profile: "generic_news",
input_mode: "urls" as "urls" | "texts" | "mixed",
extract_mode: "heuristic" as "heuristic" | "llm",
channel_id: null as number | null,
profile_id: null as number | null,
limit: 100,
interval_seconds: 3600,
is_active: true,
});
const form = ref({
source_type: "telegram" as "telegram" | "crawl4ai" | "viina",
channel: "",
limit: 50,
urlsText: "",
textsText: "",
domain_profile: "generic_news",
input_mode: "urls" as "urls" | "texts" | "mixed",
extract_mode: "heuristic" as "heuristic" | "llm",
interval_seconds: 3600,
const readyProfiles = computed(() =>
profiles.value.filter((p) => !!p.heuristic_profile),
);
const activeChannels = computed(() => channels.value.filter((c) => c.is_active));
const editChannelOptions = computed(() => channels.value);
const editProfileOptions = computed(() => {
const list = [...readyProfiles.value];
const id = editForm.value.profile_id;
if (id != null && !list.some((p) => p.id === id)) {
const found = profiles.value.find((p) => p.id === id);
if (found) list.unshift(found);
}
return list;
});
let pollTimer: ReturnType<typeof setInterval> | null = null;
function channelFromConfig(config: Record<string, unknown>): string {
return typeof config.channel === "string" ? config.channel : "";
function isPairJob(job: ParseJob | null): boolean {
return !!job && job.profile_id != null && job.channel_id != null;
}
function limitFromConfig(config: Record<string, unknown>): number {
const limit = config.limit;
return typeof limit === "number" ? limit : 50;
}
function urlsFromConfig(config: Record<string, unknown>): string[] {
return Array.isArray(config.urls)
? config.urls.filter((u): u is string => typeof u === "string")
: [];
}
function textsFromConfig(config: Record<string, unknown>): string[] {
return Array.isArray(config.texts)
? config.texts.filter((t): t is string => typeof t === "string")
: [];
}
function parseLines(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function extractModeLabel(cfg: Record<string, unknown>): string {
if (cfg.extract_mode === "llm") return " [LLM]";
if (cfg.extract_mode === "profile") return " [profile]";
return "";
return typeof limit === "number" ? limit : 100;
}
function sourceSummary(job: ParseJob): string {
const cfg = job.source_config;
const mode = extractModeLabel(cfg);
if (job.source_type === "telegram") {
return (channelFromConfig(cfg) || "—") + mode;
}
const urls = urlsFromConfig(cfg);
if (urls.length) return (urls.length === 1 ? urls[0] : `${urls.length} URL`) + mode;
const texts = textsFromConfig(cfg);
if (texts.length) return `${texts.length} текст(ов)`;
return "—";
}
function isProfileJob(job: ParseJob | null): boolean {
return !!job && job.source_config.extract_mode === "profile";
}
function profileJson(job: ParseJob | null): string {
if (!job) return "";
const profile = job.source_config.heuristic_profile;
if (!profile || typeof profile !== "object") return "";
try {
return JSON.stringify(profile, null, 2);
} catch {
return "";
if (isPairJob(job)) {
const ch = job.channel_name || job.channel_handle || `#${job.channel_id}`;
const pr = job.profile_name || `#${job.profile_id}`;
return `${ch} × ${pr}`;
}
return `${job.source_type} (без связки)`;
}
function formatInterval(seconds: number): string {
@@ -121,64 +82,6 @@ function formatInterval(seconds: number): string {
return `${Math.round(seconds / 86400)} д`;
}
function buildSourceConfig(
sourceType: string,
data: {
channel: string;
limit: number;
urlsText: string;
textsText: string;
domain_profile: string;
input_mode: "urls" | "texts" | "mixed";
extract_mode: "heuristic" | "llm";
},
existing?: Record<string, unknown>,
): Record<string, unknown> {
if (sourceType === "telegram") {
// Preserve builder-created profile jobs (edit form has no profile editor)
if (existing?.extract_mode === "profile" && existing.heuristic_profile) {
return {
channel: data.channel.trim(),
limit: data.limit,
extract_mode: "profile",
heuristic_profile: existing.heuristic_profile,
sample_post: existing.sample_post ?? null,
};
}
return {
channel: data.channel.trim(),
limit: data.limit,
extract_mode: data.extract_mode,
};
}
if (sourceType === "crawl4ai") {
return {
urls: parseLines(data.urlsText),
domain_profile: data.domain_profile.trim() || "generic_news",
extract_mode: data.extract_mode,
};
}
return {
urls: parseLines(data.urlsText),
texts: parseLines(data.textsText),
input_mode: data.input_mode,
};
}
const formHint = computed(() => {
if (form.value.source_type === "telegram") {
return form.value.extract_mode === "llm"
? "LLM (DeepSeek): неструктурированные посты канала разбираются в locality/date/coords/description."
: "Активные Telegram-парсеры подхватываются real-time listener; batch забирает последние N постов (эвристики).";
}
if (form.value.source_type === "crawl4ai") {
return form.value.extract_mode === "llm"
? "Crawl4AI + LLM (DeepSeek): страница → structured event. Нужен DEEPSEEK_API_KEY и cp-workers-web."
: "Crawl4AI обходит URL и нормализует страницы эвристиками. Обрабатывает cp-workers-web.";
}
return "VIINA-адаптер извлекает инциденты из новостных URL/текстов. Обрабатывает cp-workers-nlp.";
});
async function loadJobs() {
try {
jobs.value = await fetchJobs();
@@ -190,68 +93,51 @@ async function loadJobs() {
}
}
async function handleSubmit() {
if (form.value.source_type === "telegram" && !form.value.channel.trim()) {
error.value = "Укажите канал Telegram";
return;
}
if (form.value.source_type === "crawl4ai" && !parseLines(form.value.urlsText).length) {
error.value = "Укажите хотя бы один URL";
return;
}
if (form.value.source_type === "viina") {
const urls = parseLines(form.value.urlsText);
const texts = parseLines(form.value.textsText);
if (form.value.input_mode === "urls" && !urls.length) {
error.value = "Укажите URL для VIINA";
return;
}
if (form.value.input_mode === "texts" && !texts.length) {
error.value = "Укажите тексты для VIINA";
return;
}
if (form.value.input_mode === "mixed" && !urls.length && !texts.length) {
error.value = "Укажите URL или тексты";
return;
}
async function loadEntities() {
try {
const [chs, prs] = await Promise.all([fetchChannels(), fetchProfiles()]);
channels.value = chs;
profiles.value = prs;
} catch {
/* selects stay empty */
}
}
async function handleCreatePair() {
if (!pairForm.value.channel_id || !pairForm.value.profile_id) {
error.value = "Выберите канал и профиль";
return;
}
submitting.value = true;
error.value = "";
try {
await createJob({
source_type: form.value.source_type,
source_config: buildSourceConfig(form.value.source_type, form.value),
interval_seconds: form.value.interval_seconds,
channel_id: pairForm.value.channel_id,
profile_id: pairForm.value.profile_id,
limit: pairForm.value.limit,
interval_seconds: pairForm.value.interval_seconds,
is_active: true,
});
form.value.channel = "";
form.value.urlsText = "";
form.value.textsText = "";
pairForm.value.channel_id = null;
pairForm.value.profile_id = null;
await loadJobs();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось создать задание";
error.value = err instanceof Error ? err.message : "Не удалось создать связку";
} finally {
submitting.value = false;
}
}
function openEdit(job: ParseJob) {
if (!isPairJob(job)) {
error.value = "Редактируются только связки канал+профиль. Остальные задания можно удалить.";
return;
}
editingJob.value = job;
editForm.value = {
channel: channelFromConfig(job.source_config),
channel_id: job.channel_id ?? null,
profile_id: job.profile_id ?? null,
limit: limitFromConfig(job.source_config),
urlsText: urlsFromConfig(job.source_config).join("\n"),
textsText: textsFromConfig(job.source_config).join("\n"),
domain_profile:
typeof job.source_config.domain_profile === "string"
? job.source_config.domain_profile
: "generic_news",
input_mode:
job.source_config.input_mode === "texts" || job.source_config.input_mode === "mixed"
? job.source_config.input_mode
: "urls",
extract_mode: job.source_config.extract_mode === "llm" ? "llm" : "heuristic",
interval_seconds: job.interval_seconds,
is_active: job.is_active,
};
@@ -262,19 +148,18 @@ function closeEdit() {
}
async function handleSaveEdit() {
if (!editingJob.value) return;
const sourceType = editingJob.value.source_type;
if (sourceType === "telegram" && !editForm.value.channel.trim()) {
error.value = "Укажите канал Telegram";
if (!editingJob.value || !isPairJob(editingJob.value)) return;
if (!editForm.value.channel_id || !editForm.value.profile_id) {
error.value = "Выберите канал и профиль";
return;
}
submitting.value = true;
error.value = "";
try {
await updateJob(editingJob.value.id, {
source_config: buildSourceConfig(sourceType, editForm.value, editingJob.value.source_config),
channel_id: editForm.value.channel_id,
profile_id: editForm.value.profile_id,
limit: editForm.value.limit,
interval_seconds: editForm.value.interval_seconds,
is_active: editForm.value.is_active,
});
@@ -333,6 +218,7 @@ function formatDate(value: string | null): string {
onMounted(() => {
loadJobs();
loadEntities();
pollTimer = setInterval(loadJobs, 5000);
});
@@ -346,82 +232,42 @@ onUnmounted(() => {
<h2 class="page-heading">Парсеры</h2>
<section class="card">
<h3>Новый парсер</h3>
<h3>Новая связка (канал + профиль)</h3>
<p class="hint">
{{ formHint }}
Дубликаты по <code>source_url</code> не записываются.
Статичный парсер по образцу поста —
<router-link to="/parser-builder">Конструктор</router-link>.
Создайте
<router-link to="/channels">канал</router-link>
и
<router-link to="/parser-profiles">профиль</router-link>,
затем свяжите их здесь. В Redis уходит плоский
<code>extract_mode=profile</code> + <code>heuristic_profile</code>.
</p>
<form class="admin-form" @submit.prevent="handleSubmit">
<form class="admin-form" @submit.prevent="handleCreatePair">
<div class="form-row">
<label>
Тип источника
<select v-model="form.source_type">
<option v-for="opt in SOURCE_TYPES" :key="opt.value" :value="opt.value">
{{ opt.label }}
Канал
<select v-model.number="pairForm.channel_id" required>
<option :value="null" disabled>Выберите…</option>
<option v-for="ch in activeChannels" :key="ch.id" :value="ch.id">
{{ ch.name }} (@{{ ch.channel }})
</option>
</select>
</label>
<template v-if="form.source_type === 'telegram'">
<label>
Канал
<input v-model="form.channel" type="text" placeholder="creamy_caprice" required />
</label>
<label>
Лимит
<input v-model.number="form.limit" type="number" min="1" max="1000" />
</label>
<label>
Извлечение
<select v-model="form.extract_mode">
<option value="heuristic">Эвристики (структурированные посты)</option>
<option value="llm">LLM DeepSeek (неструктурированные)</option>
</select>
</label>
</template>
<template v-else-if="form.source_type === 'crawl4ai'">
<label class="span-2">
URL (по одному в строке)
<textarea v-model="form.urlsText" rows="3" placeholder="https://example.com/news/…" required />
</label>
<label>
Domain profile
<input v-model="form.domain_profile" type="text" placeholder="generic_news" />
</label>
<label>
Извлечение
<select v-model="form.extract_mode">
<option value="heuristic">Эвристики (regex)</option>
<option value="llm">LLM DeepSeek (Crawl4AI)</option>
</select>
</label>
</template>
<template v-else>
<label>
Режим ввода
<select v-model="form.input_mode">
<option value="urls">URLs</option>
<option value="texts">Texts</option>
<option value="mixed">Mixed</option>
</select>
</label>
<label v-if="form.input_mode !== 'texts'" class="span-2">
URL (по одному в строке)
<textarea v-model="form.urlsText" rows="3" placeholder="https://example.com/article…" />
</label>
<label v-if="form.input_mode !== 'urls'" class="span-2">
Тексты (по одному блоку в строке)
<textarea v-model="form.textsText" rows="3" placeholder="Текст статьи…" />
</label>
</template>
<label>
Профиль
<select v-model.number="pairForm.profile_id" required>
<option :value="null" disabled>Выберите…</option>
<option v-for="p in readyProfiles" :key="p.id" :value="p.id">
{{ p.name }} (#{{ p.id }})
</option>
</select>
</label>
<label>
Лимит batch
<input v-model.number="pairForm.limit" type="number" min="1" max="1000" />
</label>
<label>
Интервал
<select v-model.number="form.interval_seconds">
<select v-model.number="pairForm.interval_seconds">
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
@@ -429,16 +275,24 @@ onUnmounted(() => {
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Создание..." : "Создать парсер" }}
<button
type="submit"
class="btn btn-primary"
:disabled="submitting || !pairForm.channel_id || !pairForm.profile_id"
>
{{ submitting ? "Создание..." : "Создать связку" }}
</button>
</div>
</form>
<p v-if="!activeChannels.length || !readyProfiles.length" class="hint">
Нет активных каналов или готовых профилей —
сначала добавьте их на страницах Каналы / Профили.
</p>
<p v-if="error" class="form-error">{{ error }}</p>
</section>
<section class="card">
<h3>Парсеры <span v-if="loading" class="muted">(загрузка...)</span></h3>
<h3>Связки <span v-if="loading" class="muted">(загрузка...)</span></h3>
<div class="table-wrap">
<table class="admin-table">
<thead>
@@ -470,16 +324,18 @@ onUnmounted(() => {
<td>{{ formatDate(job.last_run_at) }}</td>
<td class="error-cell">{{ job.last_error ?? "—" }}</td>
<td class="actions-cell">
<button class="btn btn-sm" type="button" @click="openEdit(job)">Изменить</button>
<button
v-if="isPairJob(job)"
class="btn btn-sm"
type="button"
@click="openEdit(job)"
>
Изменить
</button>
<button
class="btn btn-sm"
type="button"
:disabled="!canForceRun(job) || forcingJobId === job.id"
:title="
canForceRun(job)
? 'Принудительно выполнить запрос постов'
: 'Парсер уже в очереди или выполняется'
"
@click="handleForceRun(job.id)"
>
{{ forcingJobId === job.id ? "Запуск..." : "Запустить" }}
@@ -501,69 +357,29 @@ onUnmounted(() => {
<div v-if="editingJob" class="modal-backdrop" @click.self="closeEdit">
<div class="modal card">
<h3>Редактировать парсер #{{ editingJob.id }} ({{ editingJob.source_type }})</h3>
<h3>Редактировать связку #{{ editingJob.id }}</h3>
<form class="admin-form" @submit.prevent="handleSaveEdit">
<div class="form-row">
<template v-if="editingJob.source_type === 'telegram'">
<label>
Канал
<input v-model="editForm.channel" type="text" required />
</label>
<label>
Лимит
<input v-model.number="editForm.limit" type="number" min="1" max="1000" />
</label>
<label v-if="!isProfileJob(editingJob)">
Извлечение
<select v-model="editForm.extract_mode">
<option value="heuristic">Эвристики</option>
<option value="llm">LLM DeepSeek</option>
</select>
</label>
<p v-else class="hint profile-hint">
Режим: <code>profile</code> (статичный профиль из конструктора).
Сменить правила — через повторную генерацию в «Конструктор».
</p>
<label v-if="isProfileJob(editingJob)" class="span-2">
Профиль (только просмотр)
<textarea class="profile-ro" :value="profileJson(editingJob)" rows="8" readonly />
</label>
</template>
<template v-else-if="editingJob.source_type === 'crawl4ai'">
<label class="span-2">
URL (по одному в строке)
<textarea v-model="editForm.urlsText" rows="3" required />
</label>
<label>
Domain profile
<input v-model="editForm.domain_profile" type="text" />
</label>
<label>
Извлечение
<select v-model="editForm.extract_mode">
<option value="heuristic">Эвристики</option>
<option value="llm">LLM DeepSeek</option>
</select>
</label>
</template>
<template v-else>
<label>
Режим ввода
<select v-model="editForm.input_mode">
<option value="urls">URLs</option>
<option value="texts">Texts</option>
<option value="mixed">Mixed</option>
</select>
</label>
<label v-if="editForm.input_mode !== 'texts'" class="span-2">
URL
<textarea v-model="editForm.urlsText" rows="3" />
</label>
<label v-if="editForm.input_mode !== 'urls'" class="span-2">
Тексты
<textarea v-model="editForm.textsText" rows="3" />
</label>
</template>
<label>
Канал
<select v-model.number="editForm.channel_id" required>
<option v-for="ch in editChannelOptions" :key="ch.id" :value="ch.id">
{{ ch.name }} (@{{ ch.channel }}){{ ch.is_active ? "" : " — выкл." }}
</option>
</select>
</label>
<label>
Профиль
<select v-model.number="editForm.profile_id" required>
<option v-for="p in editProfileOptions" :key="p.id" :value="p.id">
{{ p.name }} (#{{ p.id }})
</option>
</select>
</label>
<label>
Лимит batch
<input v-model.number="editForm.limit" type="number" min="1" max="1000" />
</label>
<label>
Интервал
<select v-model.number="editForm.interval_seconds">
@@ -597,19 +413,6 @@ onUnmounted(() => {
line-height: 1.5;
}
.form-row .span-2 {
grid-column: span 2;
}
.form-row textarea {
width: 100%;
font: inherit;
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
resize: vertical;
}
.source-cell {
max-width: 280px;
overflow: hidden;
@@ -625,11 +428,6 @@ onUnmounted(() => {
margin-right: 0.25rem;
}
.btn-danger {
color: #b91c1c;
border-color: #fecaca;
}
.modal-backdrop {
position: fixed;
inset: 0;
@@ -643,8 +441,6 @@ onUnmounted(() => {
.modal {
width: min(560px, 92vw);
margin: 0;
max-height: 90vh;
overflow: auto;
}
.checkbox-row {
@@ -654,16 +450,12 @@ onUnmounted(() => {
margin-top: 1.5rem;
}
.profile-hint {
flex: 1 1 100%;
margin: 0;
}
.profile-ro {
width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.75rem;
background: #f8f8f8;
color: #444;
.error-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
color: #b91c1c;
}
</style>