Админка: догрузка фото объявлений за 24 часа
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5470036a69
commit
0ddd632863
8 files changed
+352
-5
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
Reference in new issue
Block a user