Написал Парсер сайтов на Electron + Vue3 + Vite

Я программист в отставке. Когда услышал про Node.js фрэймворк Electron, я посмотрел документацию и написал платформу для парсинга сайтов.

Суть платформы: пользоваль может выбирать из дерева сайты, настроить нужные поля и каталоги, одновременно может парсить от 10 до 20 сайтов одновременно, может открыть результат выгрузки в табличном редакторе. В этой статье я акцентируюсь на работу с кодом, поэтому если интересно подробнее о платформе тут.

Cтруктура проекта в общем: есть главный процесс Main, который с помощью Node.js API может взаимодействовать с операционной системой; есть Renderer - это приложение Vue3; и есть между ними мост IPC, который связывает Main и Renderer.

Для одновременного парсинга нескольких сайтов необходимо работать с многопоточностью worker threads, где каждый поток это работающий node worker, который запускает скрипт для парсинга определенного сайта.

Создать приложение можно с помощью команды:

npm create @quick-start/electron@latest

Будет создано приложение с структурным разделением на Main, Renderer и Preload (мост IPC).

Main.

Структура:

Написал Парсер сайтов на Electron + Vue3 + Vite

Для блока main главный файл это index.ts

import { electronApp, optimizer } from '@electron-toolkit/utils'; import { app, BrowserWindow, session } from 'electron'; import { registerIPCHandlers } from "./ipcHandlers"; import { createWindow } from "./window"; const os = require('os') let cpuCores = 0 app.whenReady().then(() => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" ] } }); }); electronApp.setAppUserModelId('com.electron') app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) const mainWindow = createWindow() mainWindow.webContents.on('did-finish-load', () => { cpuCores = os.cpus().length; registerIPCHandlers(mainWindow, cpuCores) mainWindow.webContents.send('cpu-cores', cpuCores); }); app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } })

Я вывел в отдельные файлы работу с BrowserWindow в файл window.ts:

import { is } from '@electron-toolkit/utils'; import { BrowserWindow, shell } from 'electron'; import { join } from 'path'; import icon from '../../resources/icon2.jpg?asset'; export function createWindow(): any { const mainWindow = new BrowserWindow({ width: 1500, height: 800, show: false, autoHideMenuBar: true, ...(process.platform === 'linux' ? { icon } : {}), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: true, nodeIntegration: true, contextIsolation: true, }, }) let resizeTimeout mainWindow.on('resize', () => { if (resizeTimeout) { clearTimeout(resizeTimeout); } resizeTimeout = setTimeout(() => { const [width, height] = mainWindow.getSize(); mainWindow.webContents.send('window-resized', { width, height }); }, 200); // Задержка в 200 мс }); mainWindow.on('ready-to-show', () => { mainWindow.show() }) mainWindow.webContents.on('did-finish-load', () => { mainWindow?.maximize(); }); mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } }) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } return mainWindow } module.exports = { createWindow };

...и с IPC в файл ipcHandlers.ts:

import { CompletedTypeEnum, StatusParseEnum } from '@common/ParseCommon'; import { intervalToDuration } from 'date-fns'; import { app, dialog, ipcMain, shell } from 'electron'; import fs from 'fs'; import puppeteer from 'puppeteer'; const path = require("path"); const { Worker } = require("worker_threads"); const activeProcesses = {} let processIdCounter = 0 export function registerIPCHandlers(mainWindow, cpuCores) { function sendProcessListUpdate() { //отправка актуального списка в рендерер mainWindow.webContents.send('process-list-update', activeProcessesCut()); } ipcMain.handle('start-parser', (_event, { config }) => { if (Object.keys(activeProcesses).length >= cpuCores) { return } processIdCounter++; const processId = processIdCounter; let urlq = '' let isPackaged = true if (app.isPackaged) { //режим продакшн isPackaged = true urlq = path.join(app.getAppPath(), 'out', 'main') } else { //режим разработки isPackaged = false urlq = path.join(app.getAppPath(), 'src', 'main') } const workerPath = app.isPackaged ? path.join(process.resourcesPath, 'app.asar', 'out', 'main', 'workers', 'parserWorker.js') : path.join(app.getAppPath(),'src', 'main', 'workers', 'parserWorker.js'); let worker = new Worker(workerPath, { workerData: { config, urlq , processId, isPackaged }, type: 'module' }) activeProcesses[processId] = { id: processId, name: config.nameProcess, worker, config, status: StatusParseEnum.Active, typeCompleted: null, startTime: Date.now(), finishTime: 0, folder: '', elapsedTime: 0, progress: 0, selected: false }; worker.on('message', (message) => { if (message.type == StatusParseEnum.Active) { activeProcesses[processId].folder = message.folder; activeProcesses[processId].progress = +message.percent; const elapsedTime = new Date().getTime() - activeProcesses[processId].startTime; const estimatedTotalTime = elapsedTime / (message.percent / 100); const remainingTime = estimatedTotalTime - elapsedTime; const duration = intervalToDuration({ start: 0, end: remainingTime }); // Ручное форматирование продолжительности const hours = duration.hours || 0; // Обрабатываем undefined или null const minutes = duration.minutes || 0; const seconds = duration.seconds || 0; const formattedTime = `${hours < 10 ? '0' : ''}${hours}:${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; activeProcesses[processId].finishTime = formattedTime; } else if (message.type == StatusParseEnum.Completed) { activeProcesses[processId].status = StatusParseEnum.Completed; activeProcesses[processId].finishTime = Date.now() activeProcesses[processId].typeCompleted = CompletedTypeEnum.Programm activeProcesses[processId].progress = +message.percent; activeProcesses[processId].folder = message.folder; } else if (message.type == StatusParseEnum.Error) { activeProcesses[processId].status = StatusParseEnum.Error; activeProcesses[processId].finishTime = Date.now() activeProcesses[processId].typeCompleted = CompletedTypeEnum.Programm activeProcesses[processId].folder = message.folder; } sendProcessListUpdate(); // Обновляем список процессов в Renderer }) }); ipcMain.handle('restart-process', async (_event, processId ) => { if (Object.keys(activeProcesses).length >= cpuCores) { return } const config = activeProcesses[processId].config let urlq = '' let isPackaged = true if (app.isPackaged) { //режим продакшн isPackaged = true urlq = path.join(app.getAppPath(), 'out', 'main') } else { //режим разработки isPackaged = false urlq = path.join(app.getAppPath(), 'src', 'main') } const workerPath = app.isPackaged ? path.join(process.resourcesPath, 'app.asar', 'out', 'main', 'workers', 'parserWorker.js') : path.join(app.getAppPath(),'src', 'main', 'workers', 'parserWorker.js'); let worker = new Worker(workerPath, { workerData: { config, urlq , processId, isPackaged }, type: 'module' }) activeProcesses[processId].worker = worker activeProcesses[processId].status = StatusParseEnum.Active activeProcesses[processId].startTime = Date.now() activeProcesses[processId].finishTime = 0 activeProcesses[processId].progress = 0 worker.on('message', (message) => { if (message.type == StatusParseEnum.Active) { activeProcesses[processId].progress = +message.percent; const elapsedTime = new Date().getTime() - activeProcesses[processId].startTime; const estimatedTotalTime = elapsedTime / (message.percent / 100); const remainingTime = estimatedTotalTime - elapsedTime; const duration = intervalToDuration({ start: 0, end: remainingTime }); // Ручное форматирование продолжительности const hours = duration.hours || 0; // Обрабатываем undefined или null const minutes = duration.minutes || 0; const seconds = duration.seconds || 0; const formattedTime = `${hours < 10 ? '0' : ''}${hours}:${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; activeProcesses[processId].finishTime = formattedTime; } else if (message.type == StatusParseEnum.Completed) { activeProcesses[processId].status = StatusParseEnum.Completed; activeProcesses[processId].finishTime = Date.now() activeProcesses[processId].typeCompleted = CompletedTypeEnum.Programm activeProcesses[processId].progress = +message.percent; } sendProcessListUpdate(); // Обновляем список процессов в Renderer }) return activeProcessesCut() }); ipcMain.handle('get-processes', async () => { // Используем handle для ответа на запрос return activeProcessesCut() }); function activeProcessesCut() { return Object.values(activeProcesses).map((processInfo: any) => ({ id: processInfo.id, name: processInfo.name, status: processInfo.status, startTime: processInfo.startTime, finishTime: processInfo.finishTime, typeCompleted: processInfo.typeCompleted, elapsedTime: processInfo.elapsedTime, progress: processInfo.progress, selected: processInfo.selected, folder: processInfo.folder })) } ipcMain.handle('delete-process', async (_event, processId ) => { const worker = activeProcesses[processId].worker worker.terminate(); // Завершаем worker после завершения задачи delete activeProcesses[processId]; // Удаляем процесс из активных return activeProcessesCut(); // Обновляем список процессов в Renderer }) ipcMain.handle('stop-process', async (_event, processId ) => { const worker = activeProcesses[processId].worker worker.terminate(); // Завершаем worker после завершения задачи activeProcesses[processId].status = StatusParseEnum.Completed activeProcesses[processId].finishTime = Date.now() activeProcesses[processId].typeCompleted = CompletedTypeEnum.Manually return activeProcessesCut(); // Обновляем список процессов в Renderer }) ipcMain.handle('select-process', async (_event, processId ) => { activeProcesses[processId].selected = !activeProcesses[processId].selected return activeProcessesCut(); // Обновляем список процессов в Renderer }) ipcMain.handle('show-open-dialog', async () => { const { filePaths } = await dialog.showOpenDialog({ title: 'Выберите папку', properties: ['openDirectory'], // Разрешаем выбор только папок }); return filePaths[0]; // Возвращаем путь к выбранной папке }) ipcMain.handle('open-folder', async (_event, folderPath) => { shell.openPath(folderPath) }) // Обработчик для записи файла ipcMain.handle('write-file', async (_event, filePath, content) => { try { fs.writeFileSync(filePath, content); return true; } catch (error) { console.error('Ошибка при записи файла:', error); return false; } }) ipcMain.handle('get-screenshot', async (_event, url) => { if (!(await isSiteAvailable(url))) { throw new Error('Site is not available or blocked'); } const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); await page.setViewport({ width: 1600, height: 1080 }) await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 }); // Делаем скриншот const screenshot = await page.screenshot(); await browser.close(); return screenshot; // Возвращаем скриншот в виде Buffer }) ipcMain.on('close-app', () => app.quit()) } module.exports = { registerIPCHandlers }; async function isSiteAvailable(url) { try { const response = await fetch(url, { method: 'HEAD'}); return response.ok; } catch (error) { console.log('error , isSiteAvailable') return false; } }

когда пользователь запускаете процесс парсинга срабатывает ipcMain.handle('start-parser'), который создает worker и ссылается на файл parserWorker.js:

const { workerData, parentPort } = require('worker_threads'); const path = require('path'); async function startParsing(workerData) { const { config, urlq, processId, isPackaged } = workerData; const siteName = config.siteName let parsingLogic; if (isPackaged) { if (siteName == "KolesaDarom") { let urls = path.join(process.resourcesPath, 'app.asar', 'out', 'main', 'parsers', 'kolesaDarom.js'); parsingLogic = require(urls) } else if (siteName == "Koleso") { let urls = path.join(process.resourcesPath, 'app.asar', 'out', 'main', 'parsers', 'kolesoRu.js'); parsingLogic = require(urls) } else { parentPort.postMessage({ type: 'error'}); return; } } else { if (siteName == "KolesaDarom") { let urls = path.join(urlq, 'parsers', 'kolesaDarom.js'); parsingLogic = require(urls) } else if (siteName == "Koleso") { let urls = path.join(urlq, 'parsers', 'kolesoRu.js'); parsingLogic = require(urls) } else { parentPort.postMessage({ type: 'error'}); return; } } try { await parsingLogic(config, processId, parentPort); // Вызов функции парсинга } catch (error) { console.error(`Worker error for process ${processId}:`, error); } } startParsing(workerData);

В файле parserWorker.js определяется какой скрипт необходимо выбрать и передать в него пользовательские настройки. После того как скрпт отработает собранные данные выгружаются например в json:

const fs = require('fs/promises'); const path = require('path'); async function saveArrayToJSON(data, folderPath, fileName) { try { if (!fileName.endsWith(".json")) { fileName += ".json"; } const jsonData = JSON.stringify(data, null, 2); const filePath = path.join(folderPath, fileName); await fs.writeFile(filePath, jsonData, "utf8"); } catch (error) { console.error("Ошибка при сохранении JSON-файла:", error); } } module.exports = { saveArrayToJSON };
Начать дискуссию