diff --git a/apps/api/src/parser/photo-repair.ts b/apps/api/src/parser/photo-repair.ts index 7c48ab2..9bf87e1 100644 --- a/apps/api/src/parser/photo-repair.ts +++ b/apps/api/src/parser/photo-repair.ts @@ -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 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, 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>; + 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. */ diff --git a/apps/api/src/photo-repair.int.test.ts b/apps/api/src/photo-repair.int.test.ts index 48b9fa6..249598e 100644 --- a/apps/api/src/photo-repair.int.test.ts +++ b/apps/api/src/photo-repair.int.test.ts @@ -10,11 +10,13 @@ import { testApp, testImage } from './testing.ts'; let t: Awaited>; const galleries = new Map(); 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; }); }); diff --git a/apps/web/src/crm/ParserPage.tsx b/apps/web/src/crm/ParserPage.tsx index 442cae7..100f972 100644 --- a/apps/web/src/crm/ParserPage.tsx +++ b/apps/web/src/crm/ParserPage.tsx @@ -98,6 +98,7 @@ interface PhotoRepairState { removed: number; failed: number; lastError: string | null; + stopped?: 'READER_KEY' | null; } /** Кнопка «догрузить фото» и прогресс фоновой задачи. */ @@ -153,6 +154,7 @@ function PhotoRepair() { )} + {s.stopped === 'READER_KEY' &&

{p.photoStoppedKey}

} {s.lastError &&

{p.photoError}: {s.lastError}

} )} diff --git a/apps/web/src/crm/i18n.ts b/apps/web/src/crm/i18n.ts index db010ca..13c470c 100644 --- a/apps/web/src/crm/i18n.ts +++ b/apps/web/src/crm/i18n.ts @@ -204,7 +204,7 @@ const ru = { 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: 'Последняя ошибка', + photoOk: 'В порядке', photoCover: 'Была только обложка', photoBroken: 'Фото не открывались', photoLoaded: 'Загружено заново', photoRemoved: 'Сняты с List.am', photoFailed: 'Не удалось прочитать', photoError: 'Последняя ошибка', photoStoppedKey: 'Остановлено: ключ ридера Jina не работает — скорее всего, кончился баланс. Пополните баланс или замените ключ в настройках ниже и нажмите кнопку снова. Без ключа полные галереи не загрузить.', }, dp: { favorite: 'Избранное', unseen: 'Не просмотренные', addFav: 'В избранное', removeFav: 'Убрать из избранного', unseenMark: 'Не просмотрен', @@ -443,7 +443,7 @@ const hy: Dict = { photoTitle: 'Լուսանկարների լրացում', photoHint: 'Վերջին 24 ժամվա հայտարարությունները, որոնք դարձել են լիդ․ եթե բազայում միայն շապիկն է կամ լուսանկարները չեն բացվում, List.am-ի էջից բեռնվում է ամբողջ պատկերասրահը։ Յուրաքանչյուր այդպիսի հայտարարություն՝ ընթերցիչի մեկ վճարովի կարդացում։', photoRun: 'Գտնել վերջին 24 ժամվա թերի լուսանկարներով բոլոր հայտարարությունները և բեռնել', photoRunning: 'Բեռնվում է…', photoProgress: (done, total) => `Ստուգված է ${done}-ը ${total}-ից`, photoStarted: 'Գործարկում', photoNone: 'Վերջին օրում լիդով հայտարարություններ չկան՝ լրացնելու բան չկա', - photoOk: 'Կարգին են', photoCover: 'Միայն շապիկ էր', photoBroken: 'Լուսանկարները չէին բացվում', photoLoaded: 'Բեռնված է նորից', photoRemoved: 'Հանված են List.am-ից', photoFailed: 'Չհաջողվեց կարդալ', photoError: 'Վերջին սխալը', + photoOk: 'Կարգին են', photoCover: 'Միայն շապիկ էր', photoBroken: 'Լուսանկարները չէին բացվում', photoLoaded: 'Բեռնված է նորից', photoRemoved: 'Հանված են List.am-ից', photoFailed: 'Չհաջողվեց կարդալ', photoError: 'Վերջին սխալը', photoStoppedKey: 'Կանգնեցված է․ Jina ընթերցիչի բանալին չի աշխատում — հավանաբար մնացորդը սպառվել է։ Համալրեք մնացորդը կամ փոխեք բանալին ստորև կարգավորումներում և սեղմեք կոճակը կրկին։ Առանց բանալու ամբողջ պատկերասրահները չեն բեռնվի։', }, dp: { favorite: 'Ընտրված', unseen: 'Չդիտված', addFav: 'Ավելացնել ընտրվածներին', removeFav: 'Հանել ընտրվածներից', unseenMark: 'Չդիտված', @@ -680,7 +680,7 @@ const en: Dict = { 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', + photoOk: 'Fine', photoCover: 'Cover only', photoBroken: 'Photos did not open', photoLoaded: 'Reloaded', photoRemoved: 'Removed from List.am', photoFailed: 'Could not read', photoError: 'Last error', photoStoppedKey: 'Stopped: the Jina reader key does not work — most likely the balance ran out. Top up or replace the key in the settings below and press the button again. Full galleries cannot be loaded without a key.', }, dp: { favorite: 'Favourites', unseen: 'Not viewed', addFav: 'Add to favourites', removeFav: 'Remove from favourites', unseenMark: 'Not viewed',