- Implemented task and subtask structures with progress tracking. - Added reporting functionality to log progress at various stages in hotel room status management scripts. - Created a task store to manage tasks and their states, including persistence to local storage. - Updated UI components to display task lists and handle task actions (retry, remove). - Removed deprecated TaskCard and TaskList components, replacing them with a new structure for better maintainability. - Enhanced script execution service to emit progress events for UI updates.
248 lines
8.6 KiB
JavaScript
248 lines
8.6 KiB
JavaScript
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}`; // Meituan uses MM.DD format
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
// Find the column class (e.g. "col_55") for a specific date
|
|
const findDateColumnClass = async (page, mmdd) => {
|
|
// Try visible headers first
|
|
const headerCells = page.locator('.vxe-header--column');
|
|
|
|
const checkHeaders = async () => {
|
|
const c = await headerCells.count();
|
|
for (let i = 0; i < c; i++) {
|
|
const cell = headerCells.nth(i);
|
|
const text = (await cell.innerText()).replace(/\s+/g, ' ').trim();
|
|
if (text.includes(mmdd)) {
|
|
// Extract the col_X class
|
|
const classList = await cell.getAttribute('class');
|
|
const match = classList.match(/col_\d+/);
|
|
if (match) return match[0];
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
let colClass = await checkHeaders();
|
|
if (colClass) return colClass;
|
|
|
|
// Try scrolling if not found
|
|
const scrollContainer = page.locator('.vxe-table--body-wrapper').first();
|
|
if (await scrollContainer.isVisible()) {
|
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
await scrollContainer.evaluate((el) => {
|
|
el.scrollLeft += 600;
|
|
});
|
|
await page.waitForTimeout(300);
|
|
|
|
colClass = await checkHeaders();
|
|
if (colClass) return colClass;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Find the row index for a specific room type
|
|
const findRoomRowIndex = async (page, roomType) => {
|
|
// We look for the row in ANY part of the table (fixed left or main)
|
|
// and assume row indexes align across split tables.
|
|
const rows = page.locator('.vxe-body--row');
|
|
const count = await rows.count();
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const text = await rows.nth(i).innerText();
|
|
if (text.includes(roomType)) {
|
|
// Found the row. Now we need to determine its "logical" index.
|
|
// If the table is split into left/main, 'rows' contains ALL rows from both tables mixed or sequential.
|
|
// A safer way is to find the specific TR and get its rowIndex or check its parent.
|
|
|
|
// Optimization: Usually room names are in the fixed-left wrapper.
|
|
// Let's try to find the row specifically in the left wrapper first if it exists.
|
|
const leftRows = page.locator('.vxe-table--fixed-left-wrapper .vxe-body--row');
|
|
if (await leftRows.count() > 0) {
|
|
const leftCount = await leftRows.count();
|
|
for(let j=0; j<leftCount; j++) {
|
|
if ((await leftRows.nth(j).innerText()).includes(roomType)) {
|
|
return j;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: Just return this index if it's a simple table
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
const toggleRoom = async (page, { roomType, mmdd, operation }) => {
|
|
// 1. Find the column class for the date (e.g. "col_6")
|
|
const colClass = await findDateColumnClass(page, mmdd);
|
|
if (!colClass) {
|
|
throw new Error(`Date column not found for ${mmdd}`);
|
|
}
|
|
// log.info(`Date ${mmdd} maps to column class: ${colClass}`);
|
|
|
|
// 2. Find the row index for the room
|
|
const rowIndex = await findRoomRowIndex(page, roomType);
|
|
if (rowIndex === -1) {
|
|
throw new Error(`Room row not found for ${roomType}`);
|
|
}
|
|
// log.info(`Room ${roomType} maps to row index: ${rowIndex}`);
|
|
|
|
// 3. Locate the target cell in the MAIN body wrapper (where switches usually are)
|
|
// using the row index and column class.
|
|
const mainBody = page.locator('.vxe-table--body-wrapper');
|
|
const targetRow = mainBody.locator('.vxe-body--row').nth(rowIndex);
|
|
const targetCell = targetRow.locator(`.${colClass}`);
|
|
|
|
// 4. Find and toggle switch
|
|
const switchEl = targetCell.locator('.lz-switch').first();
|
|
|
|
if (await switchEl.count() === 0) {
|
|
log.warn(`No switch found at ${roomType} / ${mmdd} (Row ${rowIndex}, Col ${colClass})`);
|
|
return;
|
|
}
|
|
|
|
await switchEl.scrollIntoViewIfNeeded();
|
|
|
|
// Check state
|
|
// Inspecting common switch classes: 'lz-switch-active' (Meituan), 'is-checked', etc.
|
|
const classList = await switchEl.getAttribute('class');
|
|
const isChecked = classList && (
|
|
classList.includes('is-checked') ||
|
|
classList.includes('checked') ||
|
|
classList.includes('lz-switch-checked') ||
|
|
classList.includes('lz-switch-active')
|
|
);
|
|
|
|
const isOpen = isChecked;
|
|
|
|
if (operation === 'open' && !isOpen) {
|
|
await switchEl.click();
|
|
log.info(`Opened room ${roomType} on ${mmdd}`);
|
|
} else if (operation === 'close' && isOpen) {
|
|
await switchEl.click();
|
|
log.info(`Closed room ${roomType} on ${mmdd}`);
|
|
} else {
|
|
log.info(`Room ${roomType} already ${operation === 'open' ? 'open' : 'closed'} on ${mmdd}`);
|
|
}
|
|
};
|
|
|
|
(async () => {
|
|
let browser;
|
|
|
|
try {
|
|
reportProgress('连接本地浏览器', 10);
|
|
const targetUrl = 'https://me.meituan.com/ebooking/merchant/product#/index';
|
|
const prepared = await preparePage(chromium, { targetUrl });
|
|
browser = prepared.browser;
|
|
const page = prepared.page;
|
|
|
|
await page.waitForTimeout(4000 + Math.random() * 300);
|
|
|
|
reportProgress('定位目标页面', 30);
|
|
// Navigation logic from user snippet
|
|
try {
|
|
const menu = page.locator('#vina-menu-item-100114001 > .lz-submenu-title');
|
|
if (await menu.isVisible()) {
|
|
await menu.click();
|
|
await page.waitForTimeout(1000);
|
|
} else {
|
|
// Fallback if the submenu title isn't there
|
|
const menuFallback = page.locator('#vina-menu-item-100114001');
|
|
if (await menuFallback.isVisible()) {
|
|
await menuFallback.click();
|
|
await page.waitForTimeout(1000);
|
|
}
|
|
}
|
|
|
|
const calendarLink = page.locator('a').filter({ hasText: '房价房量日历' });
|
|
if (await calendarLink.isVisible()) {
|
|
await calendarLink.click();
|
|
await page.waitForTimeout(3000);
|
|
}
|
|
} catch (e) {
|
|
log.warn('Navigation attempt failed, assuming already on page:', e.message);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Wait for table
|
|
await page.locator('.vxe-table--body-wrapper').waitFor({ state: 'visible', timeout: 15000 });
|
|
|
|
reportProgress('操作房态数据', 60);
|
|
const dateList = buildDateList(startDate, endDate);
|
|
for (const mmdd of dateList) {
|
|
try {
|
|
await toggleRoom(page, { roomType, mmdd, operation });
|
|
} catch (e) {
|
|
log.error(`Failed to toggle ${roomType} on ${mmdd}: ${e.message}`);
|
|
}
|
|
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);
|
|
}
|
|
})();
|