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