- 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.
236 lines
8.2 KiB
JavaScript
236 lines
8.2 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 formatDate = (d) => {
|
|
const yyyy = d.getFullYear();
|
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
const dd = String(d.getDate()).padStart(2, '0');
|
|
return `${yyyy}-${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(formatDate(cur));
|
|
cur.setDate(cur.getDate() + 1);
|
|
}
|
|
return dates;
|
|
};
|
|
|
|
const toggleRoomByDate = async (page, { roomType, dateStr, operation }) => {
|
|
// Find row by room type
|
|
const row = page.locator('.lifep-table-row').filter({ hasText: roomType }).first();
|
|
if (await row.count() === 0) {
|
|
throw new Error(`Room type not found: ${roomType}`);
|
|
}
|
|
|
|
// Find cell by date class (e.g., .date-2026-03-14)
|
|
const cell = row.locator(`.lifep-table-cell.date-${dateStr}`);
|
|
if (await cell.count() === 0) {
|
|
log.warn(`Date cell not found: ${dateStr}`);
|
|
return;
|
|
}
|
|
|
|
// Find switch components
|
|
// Structure: .byted-switch (label) > input[type=checkbox] + .byted-switch-wrapper (visual)
|
|
// The user provided structure shows: .lifep-table-cell... > .room-swtich-container-PxrDYy > .byted-switch > .byted-switch-wrapper
|
|
// We can just find .byted-switch inside the cell, it should be unique enough.
|
|
const switchLabel = cell.locator('.byted-switch').first();
|
|
const switchWrapper = cell.locator('.byted-switch-wrapper').first();
|
|
const switchInput = cell.locator('input[type="checkbox"]').first();
|
|
|
|
if (await switchLabel.count() === 0) {
|
|
// If not found, log details and return
|
|
log.warn(`Switch not found for ${roomType} on ${dateStr}`);
|
|
return;
|
|
}
|
|
|
|
await switchWrapper.scrollIntoViewIfNeeded();
|
|
|
|
// Check state
|
|
// Preferred: check input state or wrapper class
|
|
let isOpen = false;
|
|
if (await switchInput.count() > 0) {
|
|
// In dy.md, when checked, the input has a `checked=""` attribute.
|
|
// However, playwright's isChecked() might not always sync perfectly if the framework uses synthetic events.
|
|
// So we check both isChecked and the classes.
|
|
isOpen = await switchInput.isChecked();
|
|
}
|
|
|
|
if (!isOpen) {
|
|
const labelClass = await switchLabel.getAttribute('class') || '';
|
|
const wrapperClass = await switchWrapper.getAttribute('class') || '';
|
|
// Based on dy.md: `byted-switch-checked` is added to the label
|
|
// and `byted-switch-wrapper-checked` is added to the wrapper
|
|
isOpen = labelClass.includes('byted-switch-checked') || wrapperClass.includes('byted-switch-wrapper-checked');
|
|
}
|
|
|
|
if (operation === 'open' && !isOpen) {
|
|
// Click the wrapper as seen in recording
|
|
await switchWrapper.click();
|
|
log.info(`Opened room ${roomType} on ${dateStr}`);
|
|
await handleConfirmation(page);
|
|
} else if (operation === 'close' && isOpen) {
|
|
await switchWrapper.click();
|
|
log.info(`Closed room ${roomType} on ${dateStr}`);
|
|
await handleConfirmation(page);
|
|
} else {
|
|
log.info(`Room ${roomType} already ${operation === 'open' ? 'open' : 'closed'} on ${dateStr}`);
|
|
}
|
|
};
|
|
|
|
const handleConfirmation = async (page) => {
|
|
try {
|
|
const confirmBtn = page.getByRole('button', { name: '确定' });
|
|
// Wait slightly longer for dialog animation
|
|
if (await confirmBtn.isVisible({ timeout: 3000 })) {
|
|
await confirmBtn.click();
|
|
// Wait for dialog to disappear
|
|
await confirmBtn.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {});
|
|
}
|
|
} catch (e) {
|
|
// Ignore if no confirmation dialog appears
|
|
}
|
|
};
|
|
|
|
const navigateToRoomStatusManagement = async (page) => {
|
|
try {
|
|
const currentUrl = page.url();
|
|
if (currentUrl.includes('/p/travel-ari/hotel/price_amount_state')) return;
|
|
} catch (e) {}
|
|
|
|
try {
|
|
const tableRow = page.locator('.lifep-table-row').first();
|
|
if (await tableRow.isVisible({ timeout: 1500 })) return;
|
|
} catch (e) {}
|
|
|
|
const managementEntry = page.getByText('房价房态管理', { exact: true });
|
|
try {
|
|
if (await managementEntry.isVisible({ timeout: 1500 })) return;
|
|
} catch (e) {}
|
|
|
|
const candidates = [
|
|
page.locator('div').filter({ hasText: /^房价房态$/ }).nth(1),
|
|
page.locator('div').filter({ hasText: /^房价房态$/ }).first(),
|
|
page.getByText('房价房态', { exact: true }),
|
|
page.getByRole('menuitem', { name: '房价房态' }).first(),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
await candidate.waitFor({ state: 'visible', timeout: 6000 });
|
|
await candidate.click({ timeout: 6000, force: true });
|
|
if (await managementEntry.isVisible({ timeout: 3000 })) break;
|
|
} catch (e) {}
|
|
}
|
|
|
|
await managementEntry.waitFor({ state: 'visible', timeout: 10000 });
|
|
await managementEntry.click({ timeout: 10000, force: true });
|
|
};
|
|
|
|
(async () => {
|
|
let browser;
|
|
|
|
try {
|
|
reportProgress('连接本地浏览器', 10);
|
|
const groupId = '1816249020842116';
|
|
const homeUrl = `https://life.douyin.com/p/home?groupid=${groupId}`;
|
|
const priceAmountStateUrl = `https://life.douyin.com/p/travel-ari/hotel/price_amount_state?groupid=${groupId}`;
|
|
const prepared = await preparePage(chromium, { targetUrl: priceAmountStateUrl });
|
|
browser = prepared.browser;
|
|
const page = prepared.page;
|
|
|
|
try {
|
|
if (!page.url().includes('/p/travel-ari/hotel/price_amount_state')) {
|
|
await page.goto(priceAmountStateUrl, { waitUntil: 'domcontentloaded' });
|
|
}
|
|
} catch (e) {
|
|
try {
|
|
await page.goto(homeUrl, { waitUntil: 'domcontentloaded' });
|
|
} catch (e2) {}
|
|
}
|
|
|
|
reportProgress('定位目标页面', 30);
|
|
// Navigation logic (User provided)
|
|
try {
|
|
// Try to click the store/login button if visible
|
|
const storeBtn = page.getByText('息烽南山天沐温泉我创建的');
|
|
if (await storeBtn.isVisible({ timeout: 3000 })) {
|
|
await storeBtn.click();
|
|
await page.getByRole('button', { name: '确认登录' }).click();
|
|
await page.waitForTimeout(2000);
|
|
}
|
|
|
|
// Navigate to Price/Room Status
|
|
await navigateToRoomStatusManagement(page);
|
|
await page.waitForTimeout(2000);
|
|
} catch (e) {
|
|
log.warn('Navigation steps skipped or failed, assuming already on page or different flow:', 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 rows to appear
|
|
await page.locator('.lifep-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
|
|
|
|
reportProgress('操作房态数据', 60);
|
|
const dateList = buildDateList(startDate, endDate);
|
|
for (const dateStr of dateList) {
|
|
try {
|
|
await toggleRoomByDate(page, { roomType, dateStr, operation });
|
|
} catch (e) {
|
|
log.error(`Failed to toggle ${roomType} on ${dateStr}: ${e.message}`);
|
|
}
|
|
await page.waitForTimeout(500 + Math.random() * 500);
|
|
}
|
|
|
|
reportProgress('保存并校验', 90);
|
|
} catch (error) {
|
|
log.error(error);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await safeDisconnectBrowser(browser);
|
|
reportProgress('执行完成', 100);
|
|
process.exit(process.exitCode || 0);
|
|
}
|
|
})();
|