From 0ddd63286346a48810b2474a2a1466df10a70af7 Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Mon, 26 Oct 2026 00:39:09 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0:=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D1=84=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=B7=D0=B0=2024=20=D1=87=D0=B0=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 3 + apps/api/src/app.ts | 13 +++ apps/api/src/main.ts | 3 + apps/api/src/parser/photo-repair.ts | 149 ++++++++++++++++++++++++++ apps/api/src/photo-repair.int.test.ts | 91 ++++++++++++++++ apps/api/src/showcase/service.ts | 2 +- apps/web/src/crm/ParserPage.tsx | 78 +++++++++++++- apps/web/src/crm/i18n.ts | 18 +++- 8 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/parser/photo-repair.ts create mode 100644 apps/api/src/photo-repair.int.test.ts diff --git a/README.md b/README.md index d8e3a9d..ff4c1b6 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,9 @@ pnpm dev:web # http://localhost:5174 «кому ушло лидом». **Настройки парсера** в админке: включение, свежесть в днях, пауза, число страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса. **Ночная пауза** (в тех же настройках, по умолчанию 22:00–8:00 по Еревану): парсер и проверка снятых не работают (экономия токенов Jina), сторож парсера ночью молчит. + **Догрузка фото** (кнопка на странице парсера): объявления за последние 24 часа, ставшие лидами, + у которых в базе только обложка или фото не открываются, — заново читается галерея со страницы + List.am (без переписывания текста моделью); идёт в фоне пачками, прогресс виден в админке. - **Биллинг**: счета за установку, фикс и «%» с расшифровкой по сделкам; смена тарифа со следующего месяца; напоминания за 7, 3 и 1 день и при просрочке; ручная отметка оплаты суперадмином; контракт платёжного провайдера для онлайн-оплаты — см. [docs/BILLING.md](docs/BILLING.md). diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dc4b63c..7083796 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -65,6 +65,7 @@ import { registerDepartmentRoutes } from './departments/routes.ts'; import type { GroupSender } from './group/service.ts'; import { registerLeadRoutes } from './leads/routes.ts'; import { LISTAM_CATEGORIES } from './parser/listam-source.ts'; +import { loadPhotoRepair, photoRepairView, startPhotoRepair } from './parser/photo-repair.ts'; import { loadParserSettings, ParserSettings, saveParserSettings } from './parser/settings.ts'; import { registerPipelineRoutes } from './pipelines/routes.ts'; import { runIngest, type PagedSource } from './ingest/service.ts'; @@ -846,6 +847,18 @@ export function buildApp({ db, config, mailer = consoleMailer, source = null, pa return parserSettingsView(); }); + // Догрузка фото: объявления за 24 часа с лидами, где только обложка или фото не открываются. + app.get('/api/admin/parser/photo-repair', async (req) => { + superadmin(req); + return { state: photoRepairView(await loadPhotoRepair(db)) }; + }); + + app.post('/api/admin/parser/photo-repair', async (req) => { + superadmin(req); + if (!source?.gallery) throw badRequest('PARSER_DISABLED'); + return { state: photoRepairView(await startPhotoRepair(db)) }; + }); + app.post('/api/admin/parser/run', async (req) => { superadmin(req); if (!source) throw badRequest('PARSER_DISABLED'); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 80e4a53..47b9ab4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -11,6 +11,7 @@ import { runLeadJobs } from './leads/service.ts'; import { loadConfig } from './lib/config.ts'; import { runGroupPosts } from './group/service.ts'; import { ListAmListingSource } from './parser/listam-source.ts'; +import { runPhotoRepair } from './parser/photo-repair.ts'; import { isQuietHours } from './parser/quiet-hours.ts'; import { loadParserSettings } from './parser/settings.ts'; import { startPolling } from './telegram/bot.ts'; @@ -101,6 +102,8 @@ if (source) { loop('removal-check', 600_000, async () => { if (!isQuietHours(await loadParserSettings(db))) await runRemovalCheck({ db, source }); }); + // Догрузка фото по кнопке в админке: пачка объявлений раз в 10 секунд, пока задача не закончится. + loop('photo-repair', 10_000, () => runPhotoRepair({ db, config, source })); // Сторож: парсер выключен или стоит дольше 20 минут — сообщение суперадминам в Telegram. const watchdog: WatchdogState = { alertedAt: null }; loop('parser-watchdog', 300_000, () => runParserWatchdog(db, watchdog)); diff --git a/apps/api/src/parser/photo-repair.ts b/apps/api/src/parser/photo-repair.ts new file mode 100644 index 0000000..7c48ab2 --- /dev/null +++ b/apps/api/src/parser/photo-repair.ts @@ -0,0 +1,149 @@ +import { and, eq, gt, inArray, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.ts'; +import { leads, listings, settings } from '../db/schema.ts'; +import { conflict } from '../lib/errors.ts'; +import type { Config } from '../lib/config.ts'; +import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts'; +import { alertReaderKey } from '../showcase/service.ts'; +import { loadParserSettings } from './settings.ts'; + +// Догрузка фото по кнопке в админке: объявления за последние 24 часа, по которым есть лиды, +// у которых в базе только обложка или фото не открываются. Для них заново читается страница +// объявления (полная галерея) — без переписывания текста моделью, только фото. Идёт в фоне +// пачками; прогресс хранится в настройках (ключ photoRepair) и виден в админке. + +const KEY = 'photoRepair'; +const MAX_PHOTOS = 20; +/** Задача считается зависшей, если не двигалась столько времени: её можно запустить заново. */ +const STALE_MS = 30 * 60_000; + +export interface PhotoRepairState { + startedAt: string; + finishedAt: string | null; + /** Когда пачка обработана последний раз. */ + touchedAt: string; + listingIds: number[]; + /** Сколько объявлений из listingIds уже проверено. */ + cursor: number; + /** В порядке: фото больше одного и все открываются. */ + ok: number; + /** Была только обложка. */ + coverOnly: number; + /** Были фото, которые не открываются. */ + broken: number; + /** Галерея загружена заново. */ + loaded: number; + /** Объявление сняли с List.am. */ + removed: number; + /** Страницу прочитать не удалось. */ + failed: number; + lastError: string | null; +} + +type GallerySource = { + gallery?(externalId: string): Promise<{ photos: string[]; removed?: boolean }>; + configure?(apiKey: string | null | undefined): void; +}; + +export async function loadPhotoRepair(db: Db): Promise { + const [row] = await db.select().from(settings).where(eq(settings.key, KEY)); + return (row?.value as PhotoRepairState | undefined) ?? null; +} + +async function save(db: Db, state: PhotoRepairState) { + const value = state as unknown as Record; + await db.insert(settings).values({ key: KEY, value }).onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } }); +} + +const running = (s: PhotoRepairState | null, now: Date) => + Boolean(s && !s.finishedAt && now.getTime() - new Date(s.touchedAt).getTime() < STALE_MS); + +/** Собрать объявления за 24 часа с лидами и поставить задачу. Вторая, пока идёт первая, — 409. */ +export async function startPhotoRepair(db: Db, now = new Date()): Promise { + if (running(await loadPhotoRepair(db), now)) throw conflict('PHOTO_REPAIR_RUNNING'); + const rows = await db + .select({ id: listings.id }) + .from(listings) + .where( + and( + gt(listings.createdAt, new Date(now.getTime() - 86_400_000)), + eq(listings.active, true), + sql`exists (select 1 from leads l where l.listing_id = "listings"."id")`, + ), + ) + .orderBy(listings.id); + const at = now.toISOString(); + const state: PhotoRepairState = { + startedAt: at, finishedAt: rows.length ? null : at, touchedAt: at, listingIds: rows.map((r) => r.id), cursor: 0, + ok: 0, coverOnly: 0, broken: 0, loaded: 0, removed: 0, failed: 0, lastError: null, + }; + await save(db, state); + return state; +} + +/** Не открывается ли хоть одно фото. Скачивание заодно кладёт обрезанное фото в кэш на диске. */ +async function hasBroken(config: Config, photos: string[], fetcher?: ImageFetcher) { + for (const url of photos.slice(0, MAX_PHOTOS)) { + if (!/^https?:\/\//.test(url)) continue; + const ok = await croppedPhoto(config, url, fetcher).then(() => true, () => false); + if (!ok) return true; + } + return false; +} + +/** Одна пачка задачи. Возвращает, сколько объявлений обработано. */ +export async function runPhotoRepair({ db, config, source, fetcher, batch = 5, now = new Date() }: { + db: Db; config: Config; source: GallerySource | null; fetcher?: ImageFetcher; batch?: number; now?: Date; +}): Promise { + const state = await loadPhotoRepair(db); + if (!state || state.finishedAt || !source?.gallery) return 0; + const ids = state.listingIds.slice(state.cursor, state.cursor + batch); + const parser = await loadParserSettings(db); + source.configure?.(parser.useKey ? parser.jinaApiKey || undefined : null); + const rows = ids.length ? await db.select().from(listings).where(inArray(listings.id, ids)) : []; + for (const listing of rows) { + const coverOnly = listing.photos.length <= 1; + const broken = !coverOnly && (await hasBroken(config, listing.photos, fetcher)); + if (!coverOnly && !broken) { + state.ok += 1; + continue; + } + if (coverOnly) state.coverOnly += 1; + else state.broken += 1; + try { + const page = await source.gallery(listing.externalId); + if (page.removed) { + state.removed += 1; + await db.update(listings).set({ active: false, removedAt: now, lastCheckedAt: now }).where(and(eq(listings.id, listing.id), eq(listings.active, true))); + continue; + } + const photos = page.photos.slice(0, MAX_PHOTOS); + if (!photos.length) throw new Error('на странице не нашлось фото'); + await db.update(listings).set({ photos }).where(eq(listings.id, listing.id)); + // Фото лидов — копия фото объявления: агенты их не правят. + await db.update(leads).set({ photos }).where(eq(leads.listingId, listing.id)); + // Сразу кладём новые фото в кэш, чтобы карточки открывались быстро. + await hasBroken(config, photos, fetcher); + state.loaded += 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + state.failed += 1; + state.lastError = `${listing.externalId}: ${message}`.slice(0, 300); + console.error(`[photo-repair] ${listing.externalId}: ${message}`); + if (/Ридер ответил 40[123] /.test(message)) await alertReaderKey(db, message); + } + } + // Объявления, которых уже нет в базе, просто пропускаются. + state.cursor += ids.length; + state.touchedAt = new Date().toISOString(); + if (state.cursor >= state.listingIds.length) state.finishedAt = state.touchedAt; + await save(db, state); + return ids.length; +} + +/** Прогресс для админки — без списка id. */ +export function photoRepairView(s: PhotoRepairState | null, now = new Date()) { + if (!s) return null; + const { listingIds, cursor, ...rest } = s; + return { ...rest, total: listingIds.length, checked: cursor, running: running(s, now) }; +} diff --git a/apps/api/src/photo-repair.int.test.ts b/apps/api/src/photo-repair.int.test.ts new file mode 100644 index 0000000..48b9fa6 --- /dev/null +++ b/apps/api/src/photo-repair.int.test.ts @@ -0,0 +1,91 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createSession } from './auth/sessions.ts'; +import { leads, listings, pipelineStatuses, users } from './db/schema.ts'; +import { runPhotoRepair } from './parser/photo-repair.ts'; +import { testApp, testImage } from './testing.ts'; + +// Кнопка в админке «догрузить фото»: объявления за 24 часа с лидами, где только обложка или битые фото. + +let t: Awaited>; +const galleries = new Map(); +const read: string[] = []; +const source = { + name: 'fake', + listPage: async () => [], + inspect: async (card: never) => card, + gallery: async (id: string) => { + read.push(id); + const g = galleries.get(id); + if (!g) throw new Error('страница не прочиталась'); + return g; + }, +}; +/** Фото, которые «не открываются». */ +const dead = new Set(); +const fetcher = async (url: string) => { + if (dead.has(url)) throw new Error('Фото не скачалось: 404'); + return testImage; +}; +const photo = (n: string) => `https://img.list.am/f/1/${n}.webp`; + +beforeAll(async () => { t = await testApp('photorepair', source as never); }); +afterAll(() => t.close()); + +describe('догрузка фото', () => { + it('догружает обложки и битые фото только у объявлений с лидами за сутки', async () => { + const [sa] = await t.db.insert(users).values({ name: 'Админ', role: 'superadmin', email: 'sa-photo@test.am', passwordHash: 'x' }).returning(); + const admin = t.client(await createSession(t.db, sa!.id)); + const created = await admin.post('/api/admin/companies', { + name: 'Агентство', plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + const companyId = created.body.id as number; + expect(companyId).toBeGreaterThan(0); + const [status] = await t.db.select().from(pipelineStatuses).where(eq(pipelineStatuses.companyId, companyId)).limit(1); + const owner = await t.register('Пользователь без прав'); + + const old = new Date(Date.now() - 30 * 3_600_000); + const add = async (externalId: string, photos: string[], opts: { lead?: boolean; createdAt?: Date } = {}) => { + const [l] = await t.db.insert(listings).values({ externalId, url: `https://www.list.am/item/${externalId}`, title: 'Квартира', photos, createdAt: opts.createdAt ?? new Date() }).returning(); + if (opts.lead !== false) { + await t.db.insert(leads).values({ companyId, listingId: l!.id, source: 'listing', statusId: status!.id, title: 'Квартира', photos }); + } + return l!; + }; + const cover = await add('101', [photo('c1')]); // только обложка + const broken = await add('102', [photo('b1'), photo('b2')]); // одно фото не открывается + dead.add(photo('b2')); + await add('103', [photo('o1'), photo('o2')]); // всё в порядке + await add('104', [photo('n1')], { lead: false }); // без лида — не трогаем + await add('105', [photo('x1')], { createdAt: old }); // старше суток — не трогаем + await add('106', [photo('f1')]); // страницу прочитать не удастся + galleries.set('101', { photos: [photo('c1'), photo('c2'), photo('c3')] }); + galleries.set('102', { photos: [photo('b1'), photo('b3')] }); + + expect((await admin.get('/api/admin/parser/photo-repair')).body.state).toBeNull(); + const started = await admin.post('/api/admin/parser/photo-repair'); + expect(started.status).toBe(200); + expect(started.body.state).toMatchObject({ total: 4, checked: 0, running: true }); + // Пока идёт — второй запуск нельзя. + expect((await admin.post('/api/admin/parser/photo-repair')).status).toBe(409); + expect((await owner.post('/api/admin/parser/photo-repair')).status).toBe(403); + + await runPhotoRepair({ db: t.db, config: t.config, source, fetcher, batch: 2 }); + expect((await admin.get('/api/admin/parser/photo-repair')).body.state).toMatchObject({ checked: 2, running: true }); + while ((await runPhotoRepair({ db: t.db, config: t.config, source, fetcher, batch: 2 })) > 0); + + const state = (await admin.get('/api/admin/parser/photo-repair')).body.state; + expect(state).toMatchObject({ total: 4, checked: 4, running: false, ok: 1, coverOnly: 2, broken: 1, loaded: 2, failed: 1 }); + expect(state.finishedAt).not.toBeNull(); + expect(state.lastError).toContain('106'); + expect(read.sort()).toEqual(['101', '102', '106']); + + const photosOf = async (id: number) => (await t.db.select().from(leads).where(eq(leads.listingId, id)))[0]!.photos; + expect(await photosOf(cover.id)).toEqual([photo('c1'), photo('c2'), photo('c3')]); + expect(await photosOf(broken.id)).toEqual([photo('b1'), photo('b3')]); + expect((await t.db.select().from(listings).where(eq(listings.id, cover.id)))[0]!.photos).toHaveLength(3); + + // Закончилась — можно запустить снова. + expect((await admin.post('/api/admin/parser/photo-repair')).status).toBe(200); + }); +}); diff --git a/apps/api/src/showcase/service.ts b/apps/api/src/showcase/service.ts index 3a7f817..69c9e4f 100644 --- a/apps/api/src/showcase/service.ts +++ b/apps/api/src/showcase/service.ts @@ -301,7 +301,7 @@ export async function refreshStaleGallery(tx: Tx, leadIds: number[], now = new D let readerKeyAlertAt = 0; /** Ключ ридера отклонён (кончился баланс или ключ отозван): фото для клиентов не загружаются. Алерт не чаще раза в 6 часов. */ -async function alertReaderKey(db: Db, error: string) { +export async function alertReaderKey(db: Db, error: string) { if (Date.now() - readerKeyAlertAt < 6 * 3_600_000) return; readerKeyAlertAt = Date.now(); const admins = await db.select({ id: users.id }).from(users).where(eq(users.role, 'superadmin')); diff --git a/apps/web/src/crm/ParserPage.tsx b/apps/web/src/crm/ParserPage.tsx index e12a338..442cae7 100644 --- a/apps/web/src/crm/ParserPage.tsx +++ b/apps/web/src/crm/ParserPage.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { Play } from 'lucide-react'; +import { ImageDown, Play } from 'lucide-react'; import { api } from '../api.ts'; import { ErrorNote, PageTitle } from '../components/ui.tsx'; import { useAction } from '../hooks.ts'; @@ -79,7 +79,83 @@ export function ParserPage() { )} + {d?.enabled && } ); } + +interface PhotoRepairState { + startedAt: string; + finishedAt: string | null; + running: boolean; + total: number; + checked: number; + ok: number; + coverOnly: number; + broken: number; + loaded: number; + removed: number; + failed: number; + lastError: string | null; +} + +/** Кнопка «догрузить фото» и прогресс фоновой задачи. */ +function PhotoRepair() { + const { c, dateTime } = useCrm(); + const p = c.parser; + const info = useQuery({ + queryKey: ['admin', 'parser', 'photo-repair'], + queryFn: () => api<{ state: PhotoRepairState | null }>('/admin/parser/photo-repair'), + refetchInterval: (q) => (q.state.data?.state?.running ? 3_000 : false), + }); + const start = useAction(() => api('/admin/parser/photo-repair', { method: 'POST' }), ['admin', 'parser', 'photo-repair']); + const s = info.data?.state; + const busy = start.isPending || Boolean(s?.running); + const percent = s?.total ? Math.round((s.checked / s.total) * 100) : 100; + return ( +
+
+

{p.photoTitle}

+

{p.photoHint}

+
+ + + {s && ( +
+

{p.photoStarted}: {dateTime(s.startedAt)}

+ {s.total === 0 ? ( +

{p.photoNone}

+ ) : ( + <> +
+
+ {p.photoProgress(s.checked, s.total)} + {percent}% +
+ +
+ {([ + [p.photoOk, s.ok], [p.photoCover, s.coverOnly], [p.photoBroken, s.broken], + [p.photoLoaded, s.loaded], [p.photoRemoved, s.removed], [p.photoFailed, s.failed], + ] as const).map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ + )} + {s.lastError &&

{p.photoError}: {s.lastError}

} +
+ )} +
+ ); +} diff --git a/apps/web/src/crm/i18n.ts b/apps/web/src/crm/i18n.ts index 900e27d..db010ca 100644 --- a/apps/web/src/crm/i18n.ts +++ b/apps/web/src/crm/i18n.ts @@ -201,6 +201,10 @@ const ru = { title: 'Парсер', off: 'Источник объявлений не настроен.', lastOk: 'Последний успешный проход', day: 'Добавлено в базу за сутки', total: 'Всего в базе', cycle: 'Полный круг парсера', delay: 'Задержка «размещено → лид», медиана за сутки', delayN: (n: number) => `по ${n} лидам`, day3: 'В базе: свежие, не старше 3 дней', run: 'Запустить сейчас', runs: 'Последние запуски', started: 'Начало', took: 'Длительность', found: 'Новых', status: 'Статус', errors: 'Ошибки', ok: 'успешно', failed: 'сбой', running: 'идёт', sec: 'с', + photoTitle: 'Догрузка фото', photoHint: 'Объявления за последние 24 часа, которые стали лидами: у кого в базе только обложка или фото не открываются, загружается полная галерея со страницы List.am. Каждое такое объявление — одно платное чтение страницы ридером.', + photoRun: 'Найти все объявления за последние 24 часа с недогруженными фото и загрузить', photoRunning: 'Идёт загрузка…', + photoProgress: (done: number, total: number) => `Проверено ${done} из ${total}`, photoStarted: 'Запуск', photoNone: 'За сутки нет объявлений с лидами — догружать нечего', + photoOk: 'В порядке', photoCover: 'Была только обложка', photoBroken: 'Фото не открывались', photoLoaded: 'Загружено заново', photoRemoved: 'Сняты с List.am', photoFailed: 'Не удалось прочитать', photoError: 'Последняя ошибка', }, dp: { favorite: 'Избранное', unseen: 'Не просмотренные', addFav: 'В избранное', removeFav: 'Убрать из избранного', unseenMark: 'Не просмотрен', @@ -228,7 +232,7 @@ const ru = { PAIR_NOT_LINKED: 'Сначала прикрепите объект к клиенту', DEAL_HAS_PAIR: 'По этой сделке уже указана другая пара. Сначала отмените ту сделку', BAD_STATUS: 'Такого статуса нет в воронке', SYSTEM_STATUS: 'Системный статус удалить нельзя', STATUS_IN_USE: 'В статусе есть карточки: выберите, куда их перевести', SYSTEM_REASON: 'Системную причину удалить нельзя', LAST_REASON: 'Нужна хотя бы одна причина отказа', - DEAL_CLOSED: 'Сделка закрыта: статус меняет руководитель', TELEGRAM_DISABLED: 'Бот не настроен', PARSER_DISABLED: 'Парсер не настроен', BAD_LISTAM_URL: 'Это не ссылка на объявление List.am', LISTAM_NOT_FOUND: 'Такого объявления на List.am нет — возможно, его сняли', LISTAM_UNAVAILABLE: 'Не удалось прочитать объявление с List.am, попробуйте ещё раз', HAS_DEAL: 'У карточки есть сделка: удалить её может только суперадмин', GROUP_BOT_NOT_SET: 'Бот не настроен', GROUP_SEND_FAILED: 'Не удалось отправить в Telegram, попробуйте ещё раз', TELEGRAM_NOT_LINKED: 'Telegram не привязан к боту', BOT_CHAT_UNAVAILABLE: 'Бот не может вам написать: откройте бота и нажмите «Start»', BOT_SEND_FAILED: 'Не удалось отправить в бот, попробуйте ещё раз', DEPARTMENT_NOT_EMPTY: 'В отделе есть сотрудники, клиенты или объекты в работе — сначала переведите их', BAD_DEPARTMENT: 'Такого отдела нет', DEPARTMENT_HAS_LEAD: 'В отделе этого сотрудника уже есть карточка по этому объявлению', + DEAL_CLOSED: 'Сделка закрыта: статус меняет руководитель', TELEGRAM_DISABLED: 'Бот не настроен', PARSER_DISABLED: 'Парсер не настроен', BAD_LISTAM_URL: 'Это не ссылка на объявление List.am', LISTAM_NOT_FOUND: 'Такого объявления на List.am нет — возможно, его сняли', LISTAM_UNAVAILABLE: 'Не удалось прочитать объявление с List.am, попробуйте ещё раз', HAS_DEAL: 'У карточки есть сделка: удалить её может только суперадмин', GROUP_BOT_NOT_SET: 'Бот не настроен', GROUP_SEND_FAILED: 'Не удалось отправить в Telegram, попробуйте ещё раз', TELEGRAM_NOT_LINKED: 'Telegram не привязан к боту', BOT_CHAT_UNAVAILABLE: 'Бот не может вам написать: откройте бота и нажмите «Start»', BOT_SEND_FAILED: 'Не удалось отправить в бот, попробуйте ещё раз', DEPARTMENT_NOT_EMPTY: 'В отделе есть сотрудники, клиенты или объекты в работе — сначала переведите их', BAD_DEPARTMENT: 'Такого отдела нет', DEPARTMENT_HAS_LEAD: 'В отделе этого сотрудника уже есть карточка по этому объявлению', PHOTO_REPAIR_RUNNING: 'Догрузка фото уже идёт', MONTH_PAID: 'Месяц уже закрыт выплатой. Чтобы изменить его, отмените отметку', PAYOUT_NOT_LAST: 'Отменить можно только последнюю выплату сотрудника', } as Record, }; @@ -436,6 +440,10 @@ const hy: Dict = { title: 'Պարսեր', off: 'Հայտարարությունների աղբյուրը կարգավորված չէ։', lastOk: 'Վերջին հաջող անցումը', day: 'Հայտարարություններ՝ վերջին օրվա ընթացքում', total: 'Ընդամենը հայտարարություններ', cycle: 'Պարսերի լրիվ շրջանը', delay: '«Տեղադրվել է → լիդ» ուշացում, օրվա մեդիան', delayN: (n) => `${n} լիդի հիման վրա`, day3: 'Բազայում՝ թարմ, 3 օրից ոչ հին', run: 'Գործարկել հիմա', runs: 'Վերջին գործարկումները', started: 'Սկիզբ', took: 'Տևողություն', found: 'Նոր', status: 'Կարգավիճակ', errors: 'Սխալներ', ok: 'հաջող', failed: 'ձախողում', running: 'ընթացքում', sec: 'վ', + photoTitle: 'Լուսանկարների լրացում', photoHint: 'Վերջին 24 ժամվա հայտարարությունները, որոնք դարձել են լիդ․ եթե բազայում միայն շապիկն է կամ լուսանկարները չեն բացվում, List.am-ի էջից բեռնվում է ամբողջ պատկերասրահը։ Յուրաքանչյուր այդպիսի հայտարարություն՝ ընթերցիչի մեկ վճարովի կարդացում։', + photoRun: 'Գտնել վերջին 24 ժամվա թերի լուսանկարներով բոլոր հայտարարությունները և բեռնել', photoRunning: 'Բեռնվում է…', + photoProgress: (done, total) => `Ստուգված է ${done}-ը ${total}-ից`, photoStarted: 'Գործարկում', photoNone: 'Վերջին օրում լիդով հայտարարություններ չկան՝ լրացնելու բան չկա', + photoOk: 'Կարգին են', photoCover: 'Միայն շապիկ էր', photoBroken: 'Լուսանկարները չէին բացվում', photoLoaded: 'Բեռնված է նորից', photoRemoved: 'Հանված են List.am-ից', photoFailed: 'Չհաջողվեց կարդալ', photoError: 'Վերջին սխալը', }, dp: { favorite: 'Ընտրված', unseen: 'Չդիտված', addFav: 'Ավելացնել ընտրվածներին', removeFav: 'Հանել ընտրվածներից', unseenMark: 'Չդիտված', @@ -464,7 +472,7 @@ const hy: Dict = { PAIR_NOT_LINKED: 'Նախ կցեք օբյեկտը հաճախորդին', DEAL_HAS_PAIR: 'Այս գործարքում արդեն նշված է այլ զույգ։ Նախ չեղարկեք այն', BAD_STATUS: 'Ձագարում նման կարգավիճակ չկա', SYSTEM_STATUS: 'Համակարգային կարգավիճակը հնարավոր չէ ջնջել', STATUS_IN_USE: 'Կարգավիճակում քարտեր կան. ընտրեք, թե ուր տեղափոխել', SYSTEM_REASON: 'Համակարգային պատճառը հնարավոր չէ ջնջել', LAST_REASON: 'Անհրաժեշտ է առնվազն մեկ մերժման պատճառ', - DEAL_CLOSED: 'Գործարքը փակված է. կարգավիճակը փոխում է ղեկավարը', TELEGRAM_DISABLED: 'Բոտը կարգավորված չէ', PARSER_DISABLED: 'Պարսերը կարգավորված չէ', BAD_LISTAM_URL: 'Սա List.am-ի հայտարարության հղում չէ', LISTAM_NOT_FOUND: 'Նման հայտարարություն List.am-ում չկա — հնարավոր է՝ հանվել է', LISTAM_UNAVAILABLE: 'Չհաջողվեց կարդալ հայտարարությունը List.am-ից, փորձեք կրկին', HAS_DEAL: 'Քարտն ունի գործարք․ այն կարող է ջնջել միայն սուպերադմինը', GROUP_BOT_NOT_SET: 'Բոտը կարգավորված չէ', GROUP_SEND_FAILED: 'Չհաջողվեց ուղարկել Telegram, փորձեք կրկին', TELEGRAM_NOT_LINKED: 'Telegram-ը կապված չէ բոտին', BOT_CHAT_UNAVAILABLE: 'Բոտը չի կարող գրել ձեզ․ բացեք բոտը և սեղմեք «Start»', BOT_SEND_FAILED: 'Չհաջողվեց ուղարկել բոտին, փորձեք կրկին', DEPARTMENT_NOT_EMPTY: 'Բաժնում կան աշխատակիցներ, հաճախորդներ կամ աշխատանքի մեջ գտնվող օբյեկտներ․ նախ տեղափոխեք դրանք', BAD_DEPARTMENT: 'Նման բաժին չկա', DEPARTMENT_HAS_LEAD: 'Այս աշխատակցի բաժնում արդեն կա այս հայտարարության քարտը', + DEAL_CLOSED: 'Գործարքը փակված է. կարգավիճակը փոխում է ղեկավարը', TELEGRAM_DISABLED: 'Բոտը կարգավորված չէ', PARSER_DISABLED: 'Պարսերը կարգավորված չէ', BAD_LISTAM_URL: 'Սա List.am-ի հայտարարության հղում չէ', LISTAM_NOT_FOUND: 'Նման հայտարարություն List.am-ում չկա — հնարավոր է՝ հանվել է', LISTAM_UNAVAILABLE: 'Չհաջողվեց կարդալ հայտարարությունը List.am-ից, փորձեք կրկին', HAS_DEAL: 'Քարտն ունի գործարք․ այն կարող է ջնջել միայն սուպերադմինը', GROUP_BOT_NOT_SET: 'Բոտը կարգավորված չէ', GROUP_SEND_FAILED: 'Չհաջողվեց ուղարկել Telegram, փորձեք կրկին', TELEGRAM_NOT_LINKED: 'Telegram-ը կապված չէ բոտին', BOT_CHAT_UNAVAILABLE: 'Բոտը չի կարող գրել ձեզ․ բացեք բոտը և սեղմեք «Start»', BOT_SEND_FAILED: 'Չհաջողվեց ուղարկել բոտին, փորձեք կրկին', DEPARTMENT_NOT_EMPTY: 'Բաժնում կան աշխատակիցներ, հաճախորդներ կամ աշխատանքի մեջ գտնվող օբյեկտներ․ նախ տեղափոխեք դրանք', BAD_DEPARTMENT: 'Նման բաժին չկա', DEPARTMENT_HAS_LEAD: 'Այս աշխատակցի բաժնում արդեն կա այս հայտարարության քարտը', PHOTO_REPAIR_RUNNING: 'Լուսանկարների լրացումն արդեն ընթացքում է', }, }; @@ -669,6 +677,10 @@ const en: Dict = { title: 'Parser', off: 'The listing source is not configured.', lastOk: 'Last successful run', day: 'Listings in 24 hours', total: 'Listings total', cycle: 'Full parser cycle', delay: '“Posted → lead” delay, 24h median', delayN: (n) => `over ${n} leads`, day3: 'In the base: fresh, up to 3 days old', run: 'Run now', runs: 'Recent runs', started: 'Started', took: 'Duration', found: 'New', status: 'Status', errors: 'Errors', ok: 'ok', failed: 'failed', running: 'running', sec: 's', + photoTitle: 'Photo top-up', photoHint: 'Listings from the last 24 hours that became leads: if only the cover is stored or photos do not open, the full gallery is loaded from the List.am page. Each such listing costs one paid reader page read.', + photoRun: 'Find all listings from the last 24 hours with incomplete photos and load them', photoRunning: 'Loading…', + photoProgress: (done, total) => `Checked ${done} of ${total}`, photoStarted: 'Started', photoNone: 'No listings with leads in the last 24 hours — nothing to load', + photoOk: 'Fine', photoCover: 'Cover only', photoBroken: 'Photos did not open', photoLoaded: 'Reloaded', photoRemoved: 'Removed from List.am', photoFailed: 'Could not read', photoError: 'Last error', }, dp: { favorite: 'Favourites', unseen: 'Not viewed', addFav: 'Add to favourites', removeFav: 'Remove from favourites', unseenMark: 'Not viewed', @@ -697,7 +709,7 @@ const en: Dict = { PAIR_NOT_LINKED: 'Attach the property to the client first', DEAL_HAS_PAIR: 'This deal already has another pair. Cancel that deal first', BAD_STATUS: 'No such status in the pipeline', SYSTEM_STATUS: 'A system status cannot be deleted', STATUS_IN_USE: 'The status has cards: choose where to move them', SYSTEM_REASON: 'A system reason cannot be deleted', LAST_REASON: 'At least one reject reason is required', - DEAL_CLOSED: 'The deal is closed: only the manager changes the status', TELEGRAM_DISABLED: 'The bot is not configured', PARSER_DISABLED: 'The parser is not configured', BAD_LISTAM_URL: 'This is not a List.am listing link', LISTAM_NOT_FOUND: 'There is no such listing on List.am — it may have been removed', LISTAM_UNAVAILABLE: 'Could not read the listing from List.am, please try again', HAS_DEAL: 'The card has a deal: only the superadmin can delete it', GROUP_BOT_NOT_SET: 'Bot is not set up', GROUP_SEND_FAILED: 'Could not send to Telegram, please try again', TELEGRAM_NOT_LINKED: 'Telegram is not linked to the bot', BOT_CHAT_UNAVAILABLE: 'The bot cannot message you: open the bot and press “Start”', BOT_SEND_FAILED: 'Could not send to the bot, please try again', DEPARTMENT_NOT_EMPTY: 'The department has employees, clients or properties in work — move them first', BAD_DEPARTMENT: 'No such department', DEPARTMENT_HAS_LEAD: 'This employee’s department already has a card for this listing', + DEAL_CLOSED: 'The deal is closed: only the manager changes the status', TELEGRAM_DISABLED: 'The bot is not configured', PARSER_DISABLED: 'The parser is not configured', BAD_LISTAM_URL: 'This is not a List.am listing link', LISTAM_NOT_FOUND: 'There is no such listing on List.am — it may have been removed', LISTAM_UNAVAILABLE: 'Could not read the listing from List.am, please try again', HAS_DEAL: 'The card has a deal: only the superadmin can delete it', GROUP_BOT_NOT_SET: 'Bot is not set up', GROUP_SEND_FAILED: 'Could not send to Telegram, please try again', TELEGRAM_NOT_LINKED: 'Telegram is not linked to the bot', BOT_CHAT_UNAVAILABLE: 'The bot cannot message you: open the bot and press “Start”', BOT_SEND_FAILED: 'Could not send to the bot, please try again', DEPARTMENT_NOT_EMPTY: 'The department has employees, clients or properties in work — move them first', BAD_DEPARTMENT: 'No such department', DEPARTMENT_HAS_LEAD: 'This employee’s department already has a card for this listing', PHOTO_REPAIR_RUNNING: 'Photo top-up is already running', }, };