Догрузка фото: остановка при неработающем ключе ридера

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 01:08:26 +04:00
1 parent 0ddd632863
commit eede13d577
4 files changed
+94 -38

No files matched your search

+66 -35
View File
@@ -38,6 +38,8 @@ export interface PhotoRepairState {
/** Страницу прочитать не удалось. */
failed: number;
lastError: string | null;
/** Почему задача остановилась раньше конца: READER_KEY — ключ ридера не работает (кончился баланс). */
stopped?: 'READER_KEY' | null;
}
type GallerySource = {
@@ -75,7 +77,7 @@ export async function startPhotoRepair(db: Db, now = new Date()): Promise<PhotoR
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,
ok: 0, coverOnly: 0, broken: 0, loaded: 0, removed: 0, failed: 0, lastError: null, stopped: null,
};
await save(db, state);
return state;
@@ -100,45 +102,74 @@ export async function runPhotoRepair({ db, config, source, fetcher, batch = 5, n
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 rows = new Map((ids.length ? await db.select().from(listings).where(inArray(listings.id, ids)) : []).map((l) => [l.id, l]));
let processed = 0;
for (const id of ids) {
const listing = rows.get(id);
// Объявления, которых уже нет в базе, просто пропускаются.
if (listing) {
const result = await repairOne(db, config, source.gallery.bind(source), listing, state, fetcher, now);
if (result === 'stop') {
// Без ключа полную страницу ридер не отдаёт: остальные объявления упадут так же.
// Останавливаемся на этом объявлении; после пополнения ключа задачу запускают заново.
state.stopped = 'READER_KEY';
break;
}
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);
}
processed += 1;
}
// Объявления, которых уже нет в базе, просто пропускаются.
state.cursor += ids.length;
state.cursor += processed;
state.touchedAt = new Date().toISOString();
if (state.cursor >= state.listingIds.length) state.finishedAt = state.touchedAt;
if (state.stopped || state.cursor >= state.listingIds.length) state.finishedAt = state.touchedAt;
await save(db, state);
return ids.length;
return processed;
}
/** Одно объявление: проверить фото и при необходимости загрузить галерею заново. 'stop' — ключ ридера не работает. */
async function repairOne(
db: Db, config: Config, gallery: NonNullable<GallerySource['gallery']>, listing: typeof listings.$inferSelect,
state: PhotoRepairState, fetcher: ImageFetcher | undefined, now: Date,
): Promise<'stop' | void> {
const coverOnly = listing.photos.length <= 1;
const broken = !coverOnly && (await hasBroken(config, listing.photos, fetcher));
if (!coverOnly && !broken) {
state.ok += 1;
return;
}
let page: Awaited<ReturnType<typeof gallery>>;
try {
page = await gallery(listing.externalId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
state.lastError = `${listing.externalId}: ${message}`.slice(0, 300);
console.error(`[photo-repair] ${listing.externalId}: ${message}`);
// Ключ не работает — объявление не считаем: его проверят при следующем запуске.
if (/Ридер ответил 40[123] /.test(message)) {
await alertReaderKey(db, message);
return 'stop';
}
page = { photos: [] };
}
if (coverOnly) state.coverOnly += 1;
else state.broken += 1;
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)));
return;
}
const photos = page.photos.slice(0, MAX_PHOTOS);
if (!photos.length) {
// Страница не прочиталась (ошибка уже записана) или фото на ней не нашлось.
state.failed += 1;
if (!state.lastError?.startsWith(`${listing.externalId}:`)) state.lastError = `${listing.externalId}: на странице не нашлось фото`;
return;
}
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;
}
/** Прогресс для админки — без списка id. */
+23
View File
@@ -10,11 +10,13 @@ import { testApp, testImage } from './testing.ts';
let t: Awaited<ReturnType<typeof testApp>>;
const galleries = new Map<string, { photos: string[]; removed?: boolean }>();
const read: string[] = [];
let keyError = false;
const source = {
name: 'fake',
listPage: async () => [],
inspect: async (card: never) => card,
gallery: async (id: string) => {
if (keyError) throw new Error(`Ридер ответил 402 на https://www.list.am/ru/item/${id}`);
read.push(id);
const g = galleries.get(id);
if (!g) throw new Error('страница не прочиталась');
@@ -87,5 +89,26 @@ describe('догрузка фото', () => {
// Закончилась — можно запустить снова.
expect((await admin.post('/api/admin/parser/photo-repair')).status).toBe(200);
while ((await runPhotoRepair({ db: t.db, config: t.config, source, fetcher, batch: 2 })) > 0);
});
it('кончился баланс ключа ридера — задача сразу останавливается с причиной', async () => {
const [lead] = await t.db.select().from(leads).limit(1);
const add = async (externalId: string) => {
const [l] = await t.db.insert(listings).values({ externalId, url: `https://www.list.am/item/${externalId}`, title: 'Дом', photos: [photo(`k${externalId}`)] }).returning();
await t.db.insert(leads).values({ companyId: lead!.companyId, listingId: l!.id, source: 'listing', statusId: lead!.statusId, title: 'Дом', photos: l!.photos });
};
await add('201');
await add('202');
keyError = true;
const [sa] = await t.db.select().from(users).where(eq(users.email, 'sa-photo@test.am'));
const admin = t.client(await createSession(t.db, sa!.id));
await admin.post('/api/admin/parser/photo-repair');
while ((await runPhotoRepair({ db: t.db, config: t.config, source, fetcher, batch: 10 })) > 0);
const state = (await admin.get('/api/admin/parser/photo-repair')).body.state;
expect(state).toMatchObject({ running: false, stopped: 'READER_KEY', failed: 0 });
expect(state.checked).toBeLessThan(state.total);
expect(state.lastError).toContain('402');
keyError = false;
});
});