import { chromium } from 'playwright'; import log from 'electron-log'; import tabsPkg from './common/tabs.js'; const { preparePage, safeDisconnectBrowser } = tabsPkg; function reportProgress(step, percent) { console.log('__ZN_PROGRESS__' + JSON.stringify({ step, percent })); } const parseDateInput = (dateStr) => { if (!dateStr) return null; if (typeof dateStr === 'number' && Number.isFinite(dateStr)) return new Date(dateStr); if (dateStr instanceof Date && !Number.isNaN(dateStr.getTime())) return dateStr; const raw = String(dateStr).trim(); const ymdMatch = raw.match(/(\d{4}-\d{2}-\d{2})/); if (ymdMatch) return new Date(`${ymdMatch[1]}T00:00:00`); const mmddMatch = raw.match(/(\d{2}-\d{2})/); if (mmddMatch) { const year = new Date().getFullYear(); return new Date(`${year}-${mmddMatch[1]}T00:00:00`); } return null; }; const formatMMDD = (d) => { const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${mm}-${dd}`; }; const buildDateList = (startDate, endDate) => { const start = parseDateInput(startDate); const end = parseDateInput(endDate || startDate); if (!start || !end) return []; const from = start <= end ? start : end; const to = start <= end ? end : start; const dates = []; const cur = new Date(from.getTime()); while (cur <= to) { dates.push(formatMMDD(cur)); cur.setDate(cur.getDate() + 1); } return dates; }; const clickWithRetry = async (locator, { attempts = 6 } = {}) => { let lastError; for (let i = 0; i < attempts; i++) { try { await locator.scrollIntoViewIfNeeded().catch(() => {}); await locator.waitFor({ state: 'visible', timeout: 8000 }); await locator.click({ timeout: 8000, force: true }); return; } catch (e) { lastError = e; try { await locator.page().keyboard.press('Escape'); } catch (e2) {} await locator.page().waitForTimeout(400 + Math.random() * 600); } } throw lastError; }; const ensureRoomPriceCalendar = async (page) => { const container = page.locator('#price-reserve-table-container'); try { if (await container.isVisible({ timeout: 1500 })) return; } catch (e) {} const menuTitle = page.getByRole('menuitem', { name: /房价房量管理/ }).first(); await clickWithRetry(menuTitle); const calendarEntry = page.getByRole('menuitem', { name: /房价房量日历/ }).first(); await clickWithRetry(calendarEntry); await container.waitFor({ state: 'visible', timeout: 30000 }); }; const findHeaderDateIndex = async (container, mmdd) => { for (let attempt = 0; attempt < 10; attempt++) { const headerCells = container.locator( "xpath=.//div[contains(@class,'headerRow')]/div[contains(@class,'headerContent')]", ); const count = await headerCells.count(); for (let i = 0; i < count; i++) { const text = (await headerCells.nth(i).innerText()).replace(/\s+/g, ' ').trim(); if (text.includes(mmdd)) return i; } await container.evaluate((el) => { el.scrollLeft += 600; }); await container.page().waitForTimeout(120); } return -1; }; const toggleRoomByDateIndex = async (container, { roomType, dateIndex, operation }) => { const strictAnchor = container.getByText(roomType, { exact: true }).first(); const looseAnchor = container.getByText(roomType).first(); const roomAnchor = (await strictAnchor.count()) > 0 ? strictAnchor : looseAnchor; if ((await roomAnchor.count()) === 0) { throw new Error(`Room type not found in table: ${roomType}`); } await roomAnchor.scrollIntoViewIfNeeded(); const boardRow = roomAnchor.locator("xpath=ancestor::div[contains(@class,'boardRow')][1]"); const dayColumns = boardRow.locator( "xpath=.//div[contains(@class,'switchbar')]/ancestor::div[contains(@class,'flex-col')][1]", ); const dayColumnCount = await dayColumns.count(); if (dateIndex >= dayColumnCount) { throw new Error( `Date column out of range for room ${roomType}: index=${dateIndex}, available=${dayColumnCount}`, ); } const dayColumn = dayColumns.nth(dateIndex); const targetStateClass = operation === 'open' ? 'error' : 'success'; const expectedCurrentLabel = operation === 'open' ? '满' : '有'; const barByLabel = dayColumn .locator( `xpath=.//*[ (contains(@class,'success') or contains(@class,'error')) and .//*[contains(normalize-space(),'${expectedCurrentLabel}')] ]//*[contains(@class,'bar')]`, ) .first(); const barByClass = dayColumn .locator(`xpath=.//*[contains(@class,'${targetStateClass}')]//*[contains(@class,'bar')]`) .first(); const bar = (await barByLabel.count()) > 0 ? barByLabel : barByClass; await bar.waitFor({ state: 'visible' }); await bar.scrollIntoViewIfNeeded(); await bar.click(); }; (async () => { let browser; try { reportProgress('连接本地浏览器', 10); const targetUrl = 'https://hotel.fliggy.com/ebooking/hotelBaseInfoUv.htm#/ebk/homeV1'; const prepared = await preparePage(chromium, { targetUrl }); browser = prepared.browser; const page = prepared.page; await page.waitForTimeout(4000 + Math.random() * 300); reportProgress('定位目标页面', 30); await ensureRoomPriceCalendar(page); const roomType = process.env.ROOM_TYPE; const startDate = process.env.START_DATE; const endDate = process.env.END_DATE; const operation = process.env.OPERATION === 'close' ? 'close' : 'open'; if (!roomType || !startDate) { log.info('ROOM_TYPE/START_DATE not provided, skip room toggle.'); reportProgress('执行完成', 100); return; } const container = page.locator('#price-reserve-table-container'); await container.waitFor({ state: 'visible' }); reportProgress('操作房态数据', 60); const dateList = buildDateList(startDate, endDate); for (const mmdd of dateList) { const dateIndex = await findHeaderDateIndex(container, mmdd); if (dateIndex < 0) { throw new Error(`Date not found in header: ${mmdd}`); } await toggleRoomByDateIndex(container, { roomType, dateIndex, operation }); await page.waitForTimeout(600 + Math.random() * 600); } reportProgress('保存并校验', 90); } catch (error) { log.error(error); process.exitCode = 1; } finally { await safeDisconnectBrowser(browser); reportProgress('执行完成', 100); process.exit(process.exitCode || 0); } })();