Догрузка фото: остановка при неработающем ключе ридера
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0ddd632863
commit
eede13d577
4 files changed
+94
-38
No files matched your search
@@ -38,6 +38,8 @@ export interface PhotoRepairState {
|
|||||||
/** Страницу прочитать не удалось. */
|
/** Страницу прочитать не удалось. */
|
||||||
failed: number;
|
failed: number;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
|
/** Почему задача остановилась раньше конца: READER_KEY — ключ ридера не работает (кончился баланс). */
|
||||||
|
stopped?: 'READER_KEY' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GallerySource = {
|
type GallerySource = {
|
||||||
@@ -75,7 +77,7 @@ export async function startPhotoRepair(db: Db, now = new Date()): Promise<PhotoR
|
|||||||
const at = now.toISOString();
|
const at = now.toISOString();
|
||||||
const state: PhotoRepairState = {
|
const state: PhotoRepairState = {
|
||||||
startedAt: at, finishedAt: rows.length ? null : at, touchedAt: at, listingIds: rows.map((r) => r.id), cursor: 0,
|
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);
|
await save(db, state);
|
||||||
return 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 ids = state.listingIds.slice(state.cursor, state.cursor + batch);
|
||||||
const parser = await loadParserSettings(db);
|
const parser = await loadParserSettings(db);
|
||||||
source.configure?.(parser.useKey ? parser.jinaApiKey || undefined : null);
|
source.configure?.(parser.useKey ? parser.jinaApiKey || undefined : null);
|
||||||
const rows = ids.length ? await db.select().from(listings).where(inArray(listings.id, ids)) : [];
|
const rows = new Map((ids.length ? await db.select().from(listings).where(inArray(listings.id, ids)) : []).map((l) => [l.id, l]));
|
||||||
for (const listing of rows) {
|
let processed = 0;
|
||||||
const coverOnly = listing.photos.length <= 1;
|
for (const id of ids) {
|
||||||
const broken = !coverOnly && (await hasBroken(config, listing.photos, fetcher));
|
const listing = rows.get(id);
|
||||||
if (!coverOnly && !broken) {
|
// Объявления, которых уже нет в базе, просто пропускаются.
|
||||||
state.ok += 1;
|
if (listing) {
|
||||||
continue;
|
const result = await repairOne(db, config, source.gallery.bind(source), listing, state, fetcher, now);
|
||||||
}
|
if (result === 'stop') {
|
||||||
if (coverOnly) state.coverOnly += 1;
|
// Без ключа полную страницу ридер не отдаёт: остальные объявления упадут так же.
|
||||||
else state.broken += 1;
|
// Останавливаемся на этом объявлении; после пополнения ключа задачу запускают заново.
|
||||||
try {
|
state.stopped = 'READER_KEY';
|
||||||
const page = await source.gallery(listing.externalId);
|
break;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
processed += 1;
|
||||||
}
|
}
|
||||||
// Объявления, которых уже нет в базе, просто пропускаются.
|
state.cursor += processed;
|
||||||
state.cursor += ids.length;
|
|
||||||
state.touchedAt = new Date().toISOString();
|
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);
|
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. */
|
/** Прогресс для админки — без списка id. */
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { testApp, testImage } from './testing.ts';
|
|||||||
let t: Awaited<ReturnType<typeof testApp>>;
|
let t: Awaited<ReturnType<typeof testApp>>;
|
||||||
const galleries = new Map<string, { photos: string[]; removed?: boolean }>();
|
const galleries = new Map<string, { photos: string[]; removed?: boolean }>();
|
||||||
const read: string[] = [];
|
const read: string[] = [];
|
||||||
|
let keyError = false;
|
||||||
const source = {
|
const source = {
|
||||||
name: 'fake',
|
name: 'fake',
|
||||||
listPage: async () => [],
|
listPage: async () => [],
|
||||||
inspect: async (card: never) => card,
|
inspect: async (card: never) => card,
|
||||||
gallery: async (id: string) => {
|
gallery: async (id: string) => {
|
||||||
|
if (keyError) throw new Error(`Ридер ответил 402 на https://www.list.am/ru/item/${id}`);
|
||||||
read.push(id);
|
read.push(id);
|
||||||
const g = galleries.get(id);
|
const g = galleries.get(id);
|
||||||
if (!g) throw new Error('страница не прочиталась');
|
if (!g) throw new Error('страница не прочиталась');
|
||||||
@@ -87,5 +89,26 @@ describe('догрузка фото', () => {
|
|||||||
|
|
||||||
// Закончилась — можно запустить снова.
|
// Закончилась — можно запустить снова.
|
||||||
expect((await admin.post('/api/admin/parser/photo-repair')).status).toBe(200);
|
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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -98,6 +98,7 @@ interface PhotoRepairState {
|
|||||||
removed: number;
|
removed: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
|
stopped?: 'READER_KEY' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Кнопка «догрузить фото» и прогресс фоновой задачи. */
|
/** Кнопка «догрузить фото» и прогресс фоновой задачи. */
|
||||||
@@ -153,6 +154,7 @@ function PhotoRepair() {
|
|||||||
</dl>
|
</dl>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{s.stopped === 'READER_KEY' && <p className="rounded-lg bg-danger/10 px-3 py-2 text-danger" role="alert">{p.photoStoppedKey}</p>}
|
||||||
{s.lastError && <p className="break-words text-xs text-muted">{p.photoError}: {s.lastError}</p>}
|
{s.lastError && <p className="break-words text-xs text-muted">{p.photoError}: {s.lastError}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ const ru = {
|
|||||||
photoTitle: 'Догрузка фото', photoHint: 'Объявления за последние 24 часа, которые стали лидами: у кого в базе только обложка или фото не открываются, загружается полная галерея со страницы List.am. Каждое такое объявление — одно платное чтение страницы ридером.',
|
photoTitle: 'Догрузка фото', photoHint: 'Объявления за последние 24 часа, которые стали лидами: у кого в базе только обложка или фото не открываются, загружается полная галерея со страницы List.am. Каждое такое объявление — одно платное чтение страницы ридером.',
|
||||||
photoRun: 'Найти все объявления за последние 24 часа с недогруженными фото и загрузить', photoRunning: 'Идёт загрузка…',
|
photoRun: 'Найти все объявления за последние 24 часа с недогруженными фото и загрузить', photoRunning: 'Идёт загрузка…',
|
||||||
photoProgress: (done: number, total: number) => `Проверено ${done} из ${total}`, photoStarted: 'Запуск', photoNone: 'За сутки нет объявлений с лидами — догружать нечего',
|
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: {
|
dp: {
|
||||||
favorite: 'Избранное', unseen: 'Не просмотренные', addFav: 'В избранное', removeFav: 'Убрать из избранного', unseenMark: 'Не просмотрен',
|
favorite: 'Избранное', unseen: 'Не просмотренные', addFav: 'В избранное', removeFav: 'Убрать из избранного', unseenMark: 'Не просмотрен',
|
||||||
@@ -443,7 +443,7 @@ const hy: Dict = {
|
|||||||
photoTitle: 'Լուսանկարների լրացում', photoHint: 'Վերջին 24 ժամվա հայտարարությունները, որոնք դարձել են լիդ․ եթե բազայում միայն շապիկն է կամ լուսանկարները չեն բացվում, List.am-ի էջից բեռնվում է ամբողջ պատկերասրահը։ Յուրաքանչյուր այդպիսի հայտարարություն՝ ընթերցիչի մեկ վճարովի կարդացում։',
|
photoTitle: 'Լուսանկարների լրացում', photoHint: 'Վերջին 24 ժամվա հայտարարությունները, որոնք դարձել են լիդ․ եթե բազայում միայն շապիկն է կամ լուսանկարները չեն բացվում, List.am-ի էջից բեռնվում է ամբողջ պատկերասրահը։ Յուրաքանչյուր այդպիսի հայտարարություն՝ ընթերցիչի մեկ վճարովի կարդացում։',
|
||||||
photoRun: 'Գտնել վերջին 24 ժամվա թերի լուսանկարներով բոլոր հայտարարությունները և բեռնել', photoRunning: 'Բեռնվում է…',
|
photoRun: 'Գտնել վերջին 24 ժամվա թերի լուսանկարներով բոլոր հայտարարությունները և բեռնել', photoRunning: 'Բեռնվում է…',
|
||||||
photoProgress: (done, total) => `Ստուգված է ${done}-ը ${total}-ից`, photoStarted: 'Գործարկում', photoNone: 'Վերջին օրում լիդով հայտարարություններ չկան՝ լրացնելու բան չկա',
|
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: {
|
dp: {
|
||||||
favorite: 'Ընտրված', unseen: 'Չդիտված', addFav: 'Ավելացնել ընտրվածներին', removeFav: 'Հանել ընտրվածներից', unseenMark: 'Չդիտված',
|
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.',
|
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…',
|
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',
|
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: {
|
dp: {
|
||||||
favorite: 'Favourites', unseen: 'Not viewed', addFav: 'Add to favourites', removeFav: 'Remove from favourites', unseenMark: 'Not viewed',
|
favorite: 'Favourites', unseen: 'Not viewed', addFav: 'Add to favourites', removeFav: 'Remove from favourites', unseenMark: 'Not viewed',
|
||||||
|
|||||||
Reference in new issue
Block a user