Админка: догрузка фото объявлений за 24 часа

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-26 00:39:09 +04:00
1 parent 5470036a69
commit 0ddd632863
8 files changed
+352 -5

No files matched your search

+13
View File
@@ -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');
+3
View File
@@ -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));
+149
View File
@@ -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<PhotoRepairState | null> {
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<string, unknown>;
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<PhotoRepairState> {
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<number> {
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) };
}
+91
View File
@@ -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<ReturnType<typeof testApp>>;
const galleries = new Map<string, { photos: string[]; removed?: boolean }>();
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<string>();
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);
});
});
+1 -1
View File
@@ -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'));
+77 -1
View File
@@ -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() {
</div>
</>
)}
{d?.enabled && <PhotoRepair />}
<ParserSettings />
</>
);
}
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 (
<section className="card mt-8 space-y-4 p-5 sm:p-6">
<div>
<h2 className="text-lg font-medium tracking-tight">{p.photoTitle}</h2>
<p className="mt-1 max-w-3xl text-sm text-muted">{p.photoHint}</p>
</div>
<button className="btn btn-primary h-auto min-h-10 whitespace-normal py-2 text-left" disabled={busy} onClick={() => start.mutate()}>
<ImageDown size={16} aria-hidden="true" className="shrink-0" />
{s?.running ? p.photoRunning : p.photoRun}
</button>
<ErrorNote error={info.error ?? start.error} />
{s && (
<div className="space-y-3 border-t border-line pt-4 text-sm">
<p className="text-muted">{p.photoStarted}: {dateTime(s.startedAt)}</p>
{s.total === 0 ? (
<p>{p.photoNone}</p>
) : (
<>
<div>
<div className="mb-1.5 flex justify-between gap-3">
<span role="status">{p.photoProgress(s.checked, s.total)}</span>
<span className="tabular-nums text-muted">{percent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-line" aria-hidden="true">
<div className="h-full rounded-full bg-capture transition-[width]" style={{ width: `${percent}%` }} />
</div>
</div>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3">
{([
[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]) => (
<div key={k} className="flex justify-between gap-3">
<dt className="text-muted">{k}</dt>
<dd className="tabular-nums font-medium">{v}</dd>
</div>
))}
</dl>
</>
)}
{s.lastError && <p className="break-words text-xs text-muted">{p.photoError}: {s.lastError}</p>}
</div>
)}
</section>
);
}
+15 -3
View File
@@ -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<string, string>,
};
@@ -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',
},
};