Админка: догрузка фото объявлений за 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

+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',
},
};