├── .gitignore ├── src ├── runner.ts ├── mailer.ts ├── selectors.ts ├── fsHelper.ts ├── types.ts ├── apiHelper.ts └── backuper.ts ├── tsconfig.json ├── package.json ├── config.json.example └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | config.json.* 3 | !config.json.example 4 | /node_modules/ 5 | /build/ 6 | chromedriver* 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /src/runner.ts: -------------------------------------------------------------------------------- 1 | const Backuper = require('./backuper'); 2 | 3 | const flags = process.argv.slice(2); 4 | 5 | const backuper = new Backuper({ 6 | verbose: flags.indexOf('--verbose') !== -1, 7 | debug: flags.indexOf('--debug') !== -1, 8 | all: flags.indexOf('--all') !== -1, 9 | autoIncremental: flags.indexOf('--auto-incremental') !== -1, 10 | }); 11 | 12 | backuper.doBackup(); 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build/", 4 | "sourceMap": false, 5 | "strictNullChecks": true, 6 | "module": "es6", 7 | "jsx": "react", 8 | "target": "es6", 9 | "allowJs": true, 10 | "types": ["node"] 11 | }, 12 | "include": [ 13 | "./src/**/*.ts" 14 | ], 15 | "types": [ 16 | "selenium-webdriver" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "axios": "^0.19.2", 4 | "chromedriver": "^99.0.0", 5 | "glob": "^7.1.6", 6 | "nodemailer": "^6.4.3", 7 | "selenium-webdriver": "^4.1.1", 8 | "ts-node": "^8.6.2" 9 | }, 10 | "devDependencies": { 11 | "@types/selenium-webdriver": "^4.0.8", 12 | "ts-loader": "^6.2.1", 13 | "typescript": "^3.7.5" 14 | }, 15 | "scripts": { 16 | "tsc": "tsc" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/mailer.ts: -------------------------------------------------------------------------------- 1 | const nodemailer = require("nodemailer"); 2 | const configMail = require('../config.json'); 3 | 4 | const sendEmail = async (html: string) => { 5 | if (configMail.transporter && configMail.receivers && configMail.receivers.length > 0) { 6 | let transporter = nodemailer.createTransport(configMail.transporter); 7 | 8 | let info = await transporter.sendMail({ 9 | from: configMail.transporter.auth.user, 10 | to: configMail.receivers, 11 | subject: "Отчет бекапера Фигмы", 12 | html: html 13 | }); 14 | 15 | console.log("Message sent: %s", info.messageId); 16 | } else { 17 | console.log("No email report configured"); 18 | console.log(html); 19 | } 20 | 21 | }; 22 | 23 | module.exports = { 24 | sendEmail 25 | }; 26 | -------------------------------------------------------------------------------- /src/selectors.ts: -------------------------------------------------------------------------------- 1 | const authBlock = '[class^="auth"]'; // форма аутентификации 2 | const authFieldLogin = 'input[type=email]'; // форма аутентификации, поле логина 3 | const authFieldPassword = 'input[type=password]'; // форма аутентификации, поле пароля 4 | const loginLink = '#auth-view-page button[type=submit]'; // кнопка "Login" 5 | 6 | const menuLinkDrafts = '[class^="folder_link--draftsName"]'; // пункт меню "Drafts" 7 | const menuLinkProjects = '[class^="folder_link--folderName"]'; // проект в левой колонке 8 | const folderNameInFile = '[class^="toolbar_view--buttonGroup"]'; // кнопки, 9 | const quickActionsInput = '[class^="quick_actions--searchInput"]'; // Окно быстрых действий 10 | const recentFilesSelector = '[class^=tiles_view--tiles] a:not([draggable])'; // Макеты в списке Recent' файлов 11 | 12 | module.exports = { 13 | menuLinkDrafts, 14 | menuLinkProjects, 15 | authBlock, 16 | authFieldLogin, 17 | authFieldPassword, 18 | loginLink, 19 | folderNameInFile, 20 | quickActionsInput, 21 | recentFilesSelector, 22 | }; 23 | -------------------------------------------------------------------------------- /src/fsHelper.ts: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const glob = require('glob'); 3 | const path = require('path'); 4 | 5 | /** 6 | * 7 | */ 8 | const createFolder = (pathToCreateFolder: string) => { 9 | if (!fs.existsSync(pathToCreateFolder)) { 10 | fs.mkdirSync(pathToCreateFolder, {recursive: true}); 11 | } 12 | }; 13 | 14 | /** 15 | * 16 | */ 17 | const prepareFolderName = (...parts) => { 18 | return path.join(...parts); 19 | }; 20 | 21 | /** 22 | * находится ли данный файл в данной директории 23 | */ 24 | const isFileInDirectory = (folderName: string, fileName) => { 25 | const files = glob.sync(path.join(folderName, `${fileName}.fig`), {nodir: true}); 26 | 27 | return files.length > 0; 28 | }; 29 | 30 | /** 31 | * перенести файл из временной папки на его реальное место 32 | */ 33 | const moveFile = (tmpFolderName: string, folderName: string, fileName) => { 34 | const oldPath = path.join(tmpFolderName, `${fileName}.fig`); 35 | const newPath = path.join(folderName, `${fileName}.fig`); 36 | fs.renameSync(oldPath, newPath); 37 | return newPath; 38 | }; 39 | 40 | module.exports = { 41 | createFolder, 42 | prepareFolderName, 43 | isFileInDirectory, 44 | moveFile, 45 | }; 46 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "user": [ 3 | { 4 | "login": "<логин пользователя в фигме>", 5 | "password": "<пароль пользователя>", 6 | "token": "<токен пользователя>", 7 | "downloadRecent": false, 8 | "teams": [ 9 | { 10 | "id": "", 11 | "name": "<название команды 1, в которой находятся проекты>" 12 | }, 13 | { 14 | "id": "", 15 | "name": "<название команды 2, в которой находятся проекты>" 16 | } 17 | ] 18 | } 19 | ], 20 | "hoursForPartialBackup": 48, 21 | "daysForAutoIncrementalBackup": 8, 22 | "delayFileDownloadSeconds": 300, 23 | "baseFolder": "<путь до папки, в которую будут сохраняться файлы>", 24 | "transporter": { 25 | "host": "smtp.gmail.com", 26 | "port": 465, 27 | "secure": true, 28 | "auth": { 29 | "user": "<почта, с которой будет отправляться отчет>", 30 | "pass": "<пароль от почты>" 31 | } 32 | }, 33 | "receivers": ["<адрес получателя отчета 1>", "<адрес получателя отчета 2>"] 34 | } 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export class WebElement { 2 | getText: () => string 3 | } 4 | 5 | // класс отчета пользователя 6 | export class Report { 7 | login: string; 8 | filesShouldBe: number; 9 | filesSaved: number; 10 | errors: string[]; 11 | statistics: string[]; 12 | } 13 | 14 | // класс файла, перезупущенного после неудачного скачивания 15 | export class Restarted { 16 | title:string; 17 | timesRestarted: number; 18 | success: boolean; 19 | userLogin: string; 20 | sycleCount: number; 21 | link: string; 22 | } 23 | 24 | export class Team { 25 | id: string; 26 | name: string; 27 | } 28 | 29 | export class User { 30 | login: string; 31 | password: string; 32 | token: string; 33 | downloadRecent: boolean; 34 | teams: Team[]; 35 | } 36 | 37 | export class LinkToFolder { 38 | link: string; 39 | folder: string; 40 | tries: number; 41 | } 42 | 43 | export class ResultToUser { 44 | res: boolean; 45 | user: User; 46 | } 47 | 48 | // apiHelper 49 | export class FigmaTeam { 50 | id: string; 51 | name: string; 52 | } 53 | 54 | export class FigmaFile { 55 | key: string; 56 | name: string; 57 | thumbnail_url: string; 58 | last_modified: string; 59 | } 60 | export class FigmaProject { 61 | id: Number; 62 | name: string; 63 | } 64 | 65 | export class ProjectToTeams { 66 | project: FigmaProject; 67 | team: FigmaTeam; 68 | } 69 | 70 | export class FilesToProjects { 71 | file: FigmaFile; 72 | projectId: string; 73 | } 74 | 75 | export class FilesToProjectsAndTeams { 76 | file: FigmaFile; 77 | project: FigmaProject; 78 | team: FigmaTeam; 79 | } 80 | 81 | export class LinksToProjectsAndTeams { 82 | link: string; 83 | project: FigmaProject; 84 | team: FigmaTeam; 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Figma backuper 2 | Скрипт для выполнения бекапа проектов, в формате .fig. Для работы использует Chrome, chrome-webdriver и Node.JS. 3 | 4 | - Может сохранять либо все файлы, либо только измененные за заданное время 5 | - Отправляет отчет на почту 6 | 7 | ## Установка 8 | 9 | - Клонируйте код скрипта 10 | - Создайте папку в которую будут скачиваться бекапы 11 | - В папке проекта, вы увидите файл config.json.example 12 | 13 | Уберите расширение ".example" и заполните файл config.json своими данными. 14 | 15 | Если вы используете **Windows**, следует вписать путь до папки сохранения в таком виде: ```"С:\\figma\\backup\\"``` (должно быть по 2 слеша подряд!). Если *nix - то "/home/user/figma-backup/". 16 | 17 | **"hoursForPartialBackup"**: 1 означает, что будут скачиваться файлы за последний час. При этом, значение 0 равносильно установке флага **--all** в true и означает что будут скачаны все файлы, находящиеся в проектах. 18 | 19 | ### токен 20 | Чтобы получить токен фигмы, зайдите в настройки профиля фигмы (клик по своему аватару) и нажмите "Create presonal access token". После этого введите название токена и дождитесь его генерации. Скопируйте токен из желтого поля в файл config.json. 21 | 22 | ### id команды 23 | Чтобы получить id и название своей команды, кликните на нее. В адресной строке вы получите ссылку вида: https://www.figma.com/files/team/812231708750477310/team2, где: 24 | 25 | /files/team//<название команды> 26 | 27 | ### почта 28 | Отправка через любой почтовый сервер, поддерживающий SMTP. 29 | 30 | Чтобы разрешить отправку почты с почтового ящика gmail, нужно включить "Небезопасные приложения" в настройках Google аккаута https://myaccount.google.com/lesssecureapps?pli=1 31 | 32 | # Запуск 33 | - У вас должен быть установлен Node.JS и Google Chrome. 34 | - В файле package.json измените версию chromedriver'а на версию хрома, установленную у вас в системе. Например, если установлена версия 99.0.4844.51, возьмите от неё только первое число (99), и укажите его в версию в формате ```"chromedriver": "^99.0.0",``` 35 | - Откройте командную строку или терминал в папке с клонированным кодом скрипта 36 | - Установите зависимости (только для первого запуска) - ```npm install``` 37 | - Скомпилируйте TypeScript файлы в JavaScript (для первого запуска или в случае изменений исходного кода) - ```npm run tsc``` 38 | - Запустите основной файл программы, из папки скомпилированных файлов - ```node build/runner.js``` 39 | 40 | Допустимые ключи при запуске бекапера: 41 | - ```--verbose``` - выводить в консоль информационные сообщения 42 | - ```--debug``` - выводить в консоль отладочные сообщения 43 | - ```--all``` - выполнить скачивание всех файлов. При отсутствии этого флага будут загружены только файлы, изменившиеся за последние hoursForPartialBackup часов 44 | - ```--auto-incremental``` - автоматический выбор полного/частичного скачивания. Полное скачивание будет выполнено, если скрипт запущен в субботу. Этот флаг позволяет в течение недели выполнять только частичный бекап, и 1 раз в неделю в субботу выполнять полный бекап. При наличии этого флага будет игнорироваться флаг ```--all```. 45 | 46 | Пример запуска: 47 | ```shell 48 | node build/runner.js --verbose --debug --auto-incremental 49 | ``` 50 | 51 | ## Отчет 52 | Отчет состоит из сообщения успешности ("Все Ок" или "Не ОК") и мини-отчетов по каждому пользователю. 53 | Если рядом с логином пользователя написано "есть ошибки", значит во время работы программы были перехвачены исключения: страница не была загружена во время, или произошла ошибка в очереди запуска окон. 54 | 55 | Также в отчете присутствуют числа обозначающие количество сохраненных файлов по сравнению с количеством файлов, которые должны быть сохранены (например: 2/4). Если указаны числа "0/0", значит пользователь не изменял файлы в проектах за последне указанное количество часов. Последними в строке указаны числа сохраненных драфтов по отношению к общему числу драфтов пользователя. Если указаны числа "0/0", значит в конфиге не указана необходимость сохранять драфты, либо у пользователя нет драфтов. 56 | -------------------------------------------------------------------------------- /src/apiHelper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FigmaFile, 3 | FigmaProject, 4 | FilesToProjects, 5 | FilesToProjectsAndTeams, 6 | LinksToProjectsAndTeams, 7 | ProjectToTeams 8 | } from './types'; 9 | 10 | const axios = require('axios'); 11 | 12 | const urlApi = 'https://api.figma.com/v1/'; 13 | const urlFile = 'https://www.figma.com/file/'; 14 | const configApi = require('../config.json'); 15 | const MAX_TRIES = 10; 16 | 17 | /** 18 | * получить проекты одной команды 19 | */ 20 | 21 | // {{API_URL}}teams/{{TEAM_ID}}/projects 22 | // https://api.figma.com/v1/teams/971593941299334989/projects 23 | 24 | const getProjectsByTeam = async (team: {id: string, name: string}, token: string) => { 25 | 26 | // АПИ фигмы, сука, падает по таймауту. Дрочим его 10 попыток, в надежде, что эта сцуко ответит 27 | for (let i = 0; i < MAX_TRIES; i++) { 28 | try { 29 | const response = await axios.get( 30 | `${urlApi}teams/${team.id}/projects`, 31 | { 32 | headers: {'X-FIGMA-TOKEN': token} 33 | } 34 | ); 35 | 36 | return response.data.projects; 37 | } catch (e) { 38 | // упало... 39 | console.log(`!!! ${urlApi}teams/${team.id}/projects axios error`); 40 | 41 | if (i >= MAX_TRIES - 1) { 42 | // Если упало много раз - пробрасываем ошибку выше 43 | throw e; 44 | } 45 | } 46 | } 47 | 48 | return false; 49 | }; 50 | 51 | /** 52 | * получить проекты для всех команд 53 | */ 54 | const getAllProjects = async (teams: {id: string, name: string}[], token: string) => { 55 | let projects: FigmaProject[] = []; 56 | let projectToTeams: ProjectToTeams[] = []; 57 | 58 | for (let i = 0; i < teams.length; i++) { 59 | try { 60 | const newProj = await getProjectsByTeam(teams[i], token); 61 | projects = [...projects,...newProj]; 62 | newProj.forEach((f) => { 63 | projectToTeams.push({ project: f, team: teams[i] }); 64 | }); 65 | } catch (error) { 66 | console.log(error); 67 | } 68 | } 69 | 70 | return projectToTeams; 71 | }; 72 | 73 | /** 74 | * получить список файлов для одного проекта 75 | */ 76 | const getProjectFiles = async (projectId: string, token: string) => { 77 | // АПИ фигмы, сука, падает по таймауту. Дрочим его 10 попыток, в надежде, что эта сцуко ответит 78 | for (let i = 0; i < MAX_TRIES; i++) { 79 | try { 80 | const response = await axios.get( 81 | `${urlApi}projects/${projectId}/files`, 82 | { 83 | headers: { 'X-FIGMA-TOKEN': token } 84 | } 85 | ); 86 | 87 | return response.data.files; 88 | 89 | } catch (e) { 90 | // упало... 91 | console.log(`!!! ${urlApi}projects/${projectId}/files axios error`); 92 | 93 | if (i >= MAX_TRIES - 1) { 94 | // Если упало много раз - пробрасываем ошибку выше 95 | throw e; 96 | } 97 | } 98 | } 99 | 100 | return false; 101 | }; 102 | 103 | /** 104 | * массив файлов в проекте, которые стоит сохранить 105 | */ 106 | const filesInProjectWorthVisiting = async (projectId: string, token: string, getAllFiles = false, hoursToGetOld = 0) => { 107 | const worthFiles: FigmaFile[] = []; 108 | 109 | const files: FigmaFile[] = await getProjectFiles(projectId, token); 110 | // если указано что нужно сохранять все файлы вне зависимости от даты модификации 111 | if (getAllFiles) { 112 | return files; 113 | } 114 | 115 | for (let i = 0; i < files.length; i++) { 116 | if (checkDateModified(files[i].last_modified, hoursToGetOld)) { 117 | worthFiles.push(files[i]); 118 | } 119 | } 120 | 121 | return worthFiles; 122 | }; 123 | 124 | /** 125 | * возвращает true если файл был модифицирован менее X часов назад 126 | */ 127 | const checkDateModified = (date: string, hoursToGetOld = 0) => { 128 | let dateModified = Date.parse(date); 129 | 130 | if (isNaN(dateModified)) { 131 | console.log('Дата не распознана ' + date); 132 | return false; 133 | } 134 | 135 | let lastDateToModify = new Date(); 136 | lastDateToModify.setHours(lastDateToModify.getHours() - hoursToGetOld); 137 | const lastTimeToModify = lastDateToModify.getTime(); 138 | 139 | return dateModified > lastTimeToModify; 140 | }; 141 | 142 | /** 143 | * получить все файлы, которые стоит сохранить во всех проектах 144 | */ 145 | const getFilesToVisit = async (projectIds: string[], token: string, getAllFiles = false, hoursToGetOld = 0) => { 146 | let worthFiles: FigmaFile[] = []; 147 | let filesToProjects: FilesToProjects[] = []; 148 | 149 | for (let i = 0; i < projectIds.length; i++) { 150 | const newFiles = await filesInProjectWorthVisiting(projectIds[i], token, getAllFiles, hoursToGetOld); 151 | worthFiles = [...worthFiles, ...newFiles]; 152 | newFiles.forEach((f) => { 153 | filesToProjects.push({ file: f, projectId: projectIds[i] }); 154 | }); 155 | } 156 | 157 | return filesToProjects; 158 | }; 159 | 160 | /** 161 | * получить все файлы, которые нужно сохранить, во всех командах 162 | */ 163 | const getFilesByTeams = async (teams: {id: string, name: string}[], token: string, getAllFiles = false, hoursToGetOld = 0) => { 164 | const projectsLinkedToTeams = await getAllProjects(teams, token); 165 | const projectIds = projectsLinkedToTeams.map((f) => f.project.id.toString()); 166 | const filesLinkedToProjects = await getFilesToVisit(projectIds, token, getAllFiles, hoursToGetOld); 167 | 168 | return linkFilesAndTeams(projectsLinkedToTeams, filesLinkedToProjects); 169 | }; 170 | 171 | /** 172 | * соединить файлы с соответствующими командами 173 | */ 174 | const linkFilesAndTeams = (projectsTeams: ProjectToTeams[], filesToProjects: FilesToProjects[]) => { 175 | let filesToProjectsAndTeams: FilesToProjectsAndTeams[] = []; 176 | 177 | for (let i = 0; i < filesToProjects.length; i++) { 178 | for (let j = 0; j < projectsTeams.length; j++) { 179 | if (projectsTeams[j].project.id.toString() === filesToProjects[i].projectId) { 180 | filesToProjectsAndTeams.push({ 181 | file: filesToProjects[i].file, 182 | project: projectsTeams[j].project, 183 | team: projectsTeams[j].team 184 | }); 185 | break; 186 | } 187 | } 188 | } 189 | 190 | return filesToProjectsAndTeams; 191 | }; 192 | 193 | /** 194 | * получить массив ссылок, по которым нужно пройтись, чтобы сохранить все недавно (23 часа) модифицированые файлы 195 | */ 196 | async function createLinksToFiles(teams: {id: string, name: string}[], token: string, getAllFiles = false, hoursToGetOld = 0): Promise { 197 | const filesToProjectsAndTeams = await getFilesByTeams(teams, token, getAllFiles, hoursToGetOld); 198 | 199 | return filesToProjectsAndTeams.map((f) => { 200 | let splitedLink = f.file.name.split('/'); 201 | let name = splitedLink.join('%2F'); 202 | 203 | return { 204 | link: `${urlFile}${f.file.key}/${name}`, 205 | project: f.project, 206 | team: f.team 207 | } 208 | }); 209 | } 210 | 211 | module.exports = { 212 | createLinksToFiles 213 | }; 214 | -------------------------------------------------------------------------------- /src/backuper.ts: -------------------------------------------------------------------------------- 1 | import { LinksToProjectsAndTeams, LinkToFolder, Report, User } from './types'; 2 | 3 | const WebDriver = require('selenium-webdriver'); 4 | const chrome = require('selenium-webdriver/chrome'); 5 | const path = require('chromedriver').path; 6 | 7 | const selector = require('./selectors'); 8 | const fsHelper = require('./fsHelper'); 9 | const mailer = require('./mailer'); 10 | const api = require('./apiHelper'); 11 | const config = require('../config.json'); 12 | 13 | // Указываем путь до chromedriver'а внутри node_modules 14 | const service = new chrome.ServiceBuilder(path).build(); 15 | chrome.setDefaultService(service); 16 | 17 | /** 18 | * 19 | */ 20 | class Backuper { 21 | 22 | private user: User[]; 23 | 24 | // использованые имена файлов 25 | private titles: Set; 26 | 27 | // максимальное время ожидания появления элемента, 1 минута 28 | private delayElement: number = 60 * 1000; 29 | // максимальное время ожидания скачивания файла, 5 минут 30 | private delayFileDownload: number = 5 * 60 * 1000; 31 | 32 | // За какое время скачивать файлы при частичном бекапе и при использовании --auto-incremental в день частичного бекапа 33 | private hoursForPartialBackup: number = 48; // количество часов 34 | // За какое время скачивать файлы при использовании --auto-incremental в день полного бекапа 35 | private daysForAutoIncrementalBackup: number = 8; // количество дней 36 | private MAX_TRIES = 10; 37 | 38 | // период проверки появления файла в каталоге 39 | private period: number = 500; 40 | 41 | private urlFigma: string = 'https://www.figma.com/login'; 42 | private urlRecent: string = 'https://www.figma.com/files/recent'; 43 | private baseFolder: string; 44 | 45 | private currentReportData: Report; 46 | private reportsData: Report[] = []; 47 | private totalTime: string = ''; 48 | private options: any; 49 | 50 | private webdriver: any; 51 | 52 | constructor(options) { 53 | this.options = options; 54 | this.titles = new Set(); 55 | this.baseFolder = config.baseFolder; 56 | this.user = config.user; 57 | this.hoursForPartialBackup = parseInt(config.hoursForPartialBackup, 10); 58 | this.daysForAutoIncrementalBackup = parseInt(config.daysForAutoIncrementalBackup, 10); 59 | 60 | if (config.delayFileDownloadSeconds) { 61 | const delay = parseInt(config.delayFileDownloadSeconds, 10); 62 | 63 | if (!isNaN(delay) && delay >= 60) { 64 | this.delayFileDownload = delay * 1000; 65 | } 66 | } 67 | 68 | if (this.options.autoIncremental) { 69 | const weekDayNumber = (new Date()).getDay(); 70 | 71 | if (weekDayNumber === 6) { 72 | this.hoursForPartialBackup = this.daysForAutoIncrementalBackup * 24; 73 | } 74 | } 75 | } 76 | 77 | /** 78 | * 79 | */ 80 | async doBackup() { 81 | if (this.options.verbose) console.log('Backup started'); 82 | 83 | // await this.debugWebGl(); return; // отладка, смотрим есть ли в используемом хроме поддержка WebGL 84 | 85 | const timeStart = Date.now(); 86 | 87 | for (let i = 0; i < this.user.length; i++) { 88 | const timeStartOne = Date.now(); 89 | await this.backupOneUser(this.user[i]); 90 | const timeForOne = this.formatTime((Date.now() - timeStartOne) / 1000); 91 | this.currentReportData.statistics.push('Total time used: ' + timeForOne); 92 | 93 | this.reportsData.push(this.currentReportData); 94 | } 95 | 96 | const timeForAll = this.formatTime((Date.now() - timeStart) / 1000); 97 | this.totalTime = 'Total time used: ' + timeForAll; 98 | 99 | await this.sendReport(this.reportsData); 100 | } 101 | 102 | async backupOneUser(user: User) { 103 | if (this.options.verbose) console.log(`Backup for user ${user.login} started`); 104 | 105 | this.currentReportData = { 106 | login: user.login, 107 | filesShouldBe: 0, 108 | filesSaved: 0, 109 | errors: [], 110 | statistics: [], 111 | }; 112 | 113 | await this.getWebdriver(user, true); 114 | 115 | if (user.teams.length) { 116 | await this.backupProjectsAndTeams(user); 117 | } 118 | 119 | if (user.downloadRecent) { 120 | await this.backupRecent(user); 121 | } 122 | 123 | const webdriver = await this.getWebdriver(user); 124 | await webdriver.close(); 125 | 126 | if (this.options.verbose) console.log(`Backup for user ${user.login} done`); 127 | } 128 | 129 | async backupProjectsAndTeams(user: User) { 130 | if (this.options.verbose) console.log('Backup all projects and teams, start'); 131 | 132 | let linksToFolders: LinkToFolder[] = []; 133 | 134 | // получаем ссылки на файлы, измененные менее X часов назад 135 | let figmaApiLinks = [] as LinksToProjectsAndTeams[]; 136 | try { 137 | figmaApiLinks = await this.getUserLinks(user); 138 | } catch (e) { 139 | if (this.options.debug || this.options.verbose) console.log(`${user.login} Ошибка получения списка файлов!`); 140 | if (this.options.debug) console.log(e); 141 | 142 | this.currentReportData.errors.push(`${user.login} Ошибка получения списка файлов!`); 143 | 144 | return; 145 | } 146 | 147 | if (this.options.debug) console.log({ 148 | linkCount: figmaApiLinks.length, 149 | links: figmaApiLinks.map((link) => link.link) 150 | }); 151 | 152 | // Папка для бекапа 153 | const userFolder = fsHelper.prepareFolderName(this.baseFolder, user.login); 154 | 155 | for (let i = 0; i < figmaApiLinks.length; i++) { 156 | let projectFolder = fsHelper.prepareFolderName(userFolder, figmaApiLinks[i].team.name, figmaApiLinks[i].project.name); 157 | fsHelper.createFolder(projectFolder); 158 | 159 | linksToFolders.push({ 160 | link: figmaApiLinks[i].link, 161 | folder: projectFolder, 162 | tries: 0, 163 | }); 164 | } 165 | 166 | this.currentReportData.filesShouldBe += figmaApiLinks.length; 167 | 168 | if (linksToFolders.length) { 169 | // сохранение всех файлов 170 | await this.startFilesDownload(linksToFolders); 171 | } 172 | 173 | if (this.options.verbose) console.log('Backup all projects and teams, done'); 174 | } 175 | 176 | async backupRecent(user: User) { 177 | if (this.options.verbose) console.log('Backup recent, start'); 178 | 179 | const webdriver = await this.getWebdriver(user); 180 | await webdriver.get(this.urlRecent); 181 | 182 | // дождаться загрузки страницы 183 | await webdriver.sleep(2000); 184 | 185 | if (this.options.debug) console.log(' waitForElementAndGet', selector.recentFilesSelector); 186 | const recentRows = await this.waitForElementAndGet(selector.recentFilesSelector, true); 187 | 188 | const regexpTitle = /]+class[^>]+generic_tile--title[^>]+>([^<]+)<\/div>/; 189 | const regexpTime = /Edited[^<]*]*>([^<]+)<\/span>/; 190 | const regexpTimeParts = /(?\d+|last)\s(?minute|hour|day|month|year)s?(\sago)?/; 191 | // Временны екоэффициенты, относительно 1 минуты 192 | const timeKoeffs = { 193 | 'minute': 1, 194 | 'hour': 60, 195 | 'day': 60 * 24, 196 | 'month': 60 * 24 * 30, 197 | 'year': 60 * 24 * 365, 198 | }; 199 | 200 | const userFolder = fsHelper.prepareFolderName(this.baseFolder, user.login); 201 | let recentFolder = fsHelper.prepareFolderName(userFolder, "Recent"); 202 | fsHelper.createFolder(recentFolder); 203 | 204 | let linksToFolders: LinkToFolder[] = []; 205 | 206 | for (let i = 0; i < recentRows.length; i++) { 207 | recentRows[i].click(); 208 | await webdriver.sleep(5); 209 | const href = await recentRows[i].getAttribute('href'); 210 | const html = await recentRows[i].getAttribute('innerHTML'); 211 | const linkObj = { 212 | link: href, 213 | folder: recentFolder, 214 | tries: 0, 215 | }; 216 | 217 | const matchTitle = html.match(regexpTitle)[1]; 218 | const matchTime = html.match(regexpTime)[1]; 219 | 220 | if (this.options.all) { 221 | linksToFolders.push(linkObj); 222 | } else if (matchTime) { 223 | // Пробуем распарсить текстовую дату и понять, надо ли скачивать файл 224 | // matchTime: "yesterday", "2 years ago", "last year", "16 hours ago", "20 days ago", "1 hour ago", "4 months ago" 225 | let minutesSinceUpdate = 0; 226 | if (matchTime === 'yesterday') { 227 | if (this.options.debug) console.log("Time: " + matchTime); 228 | minutesSinceUpdate = 60 * 24; 229 | } else { 230 | const timeParts = matchTime.match(regexpTimeParts); 231 | if (this.options.debug) console.log("Time: " + matchTime, "AS: ", timeParts); 232 | minutesSinceUpdate = (timeKoeffs[timeParts.groups.type] ? timeKoeffs[timeParts.groups.type] : 1) 233 | * parseInt(timeParts.groups.num === 'last' ? 1 : timeParts.groups.num); 234 | } 235 | 236 | if (minutesSinceUpdate <= this.hoursForPartialBackup * 60) { 237 | linksToFolders.push(linkObj); 238 | } 239 | } 240 | } 241 | 242 | if (linksToFolders.length) { 243 | // сохранение всех файлов 244 | this.currentReportData.filesShouldBe += linksToFolders.length; 245 | await this.startFilesDownload(linksToFolders); 246 | } 247 | 248 | if (this.options.verbose) console.log('Backup recent, done'); 249 | } 250 | 251 | /** 252 | * 253 | */ 254 | async startFilesDownload(linksToFolders: LinkToFolder[]) { 255 | let currentArray = linksToFolders; 256 | 257 | for (let currentTry = 0; currentTry < this.MAX_TRIES; currentTry++) { 258 | let newArray: LinkToFolder[] = []; 259 | 260 | for (let i = 0; i < currentArray.length; i++) { 261 | const timeStart = Date.now(); 262 | const result = await this.downloadOneFile(currentArray[i]); 263 | 264 | if (this.options.verbose) { 265 | const timeForOne = this.formatTime((Date.now() - timeStart) / 1000); 266 | const title = this.getTitle(currentArray[i]); 267 | this.currentReportData.statistics.push((result ? 'Скачал ' : 'НЕ СКАЧАЛ ') + title + ' за ' + timeForOne); 268 | } 269 | 270 | if (!result) { 271 | newArray.push({ 272 | ...currentArray[i], 273 | tries: currentArray[i].tries + 1 274 | }); 275 | } 276 | } 277 | 278 | currentArray = newArray; 279 | if (newArray.length === 0) { 280 | break; 281 | } else { 282 | if (this.options.verbose) { 283 | this.currentReportData.statistics.push('===== Следующая итерация попыток скачивания ====='); 284 | } 285 | } 286 | } 287 | 288 | this.currentReportData.filesSaved = linksToFolders.length - currentArray.length; 289 | if (currentArray.length > 0) { 290 | for (let i = 0; i < currentArray.length; i++) { 291 | this.currentReportData.errors.push('Can\'t download ' + currentArray[i].link); 292 | } 293 | } 294 | } 295 | 296 | /** 297 | * составить html для отправки и отправить 298 | */ 299 | async sendReport(reports: Report[]) { 300 | let goodHTML = '

#USER#: Все ОК

'; 301 | let partlyHTML = '

#USER#: ОК, но с предупреждениями

'; 302 | let badHTML = '

#USER#: Не ОК

'; 303 | 304 | const fullReport: string[] = []; 305 | for (let i = 0; i < reports.length; i++) { 306 | const report = reports[i]; 307 | const isDownloadedAll = (report.filesSaved == report.filesShouldBe); 308 | const hasErrors = (report.errors.length > 0); 309 | 310 | const errorsCount = report.errors.length; 311 | const errors = report.errors.join("
\n"); 312 | 313 | const statistics = this.options.verbose ? report.statistics.join('
') : ''; 314 | 315 | if (!report.filesShouldBe) { 316 | const hours = this.hoursForPartialBackup; 317 | const text = this.options.all ? 'Списов файлов пуст!' : `За последние ${hours}ч нет изменённых файлов.`; 318 | fullReport.push(`

${report.login}: ${text}

`); 319 | 320 | } else { 321 | const header = (isDownloadedAll ? (hasErrors ? partlyHTML : goodHTML) : badHTML) 322 | .replace('#USER#', report.login); 323 | 324 | fullReport.push(header + ` 325 |

сохранено файлов в проектах: ${report.filesSaved}/${report.filesShouldBe}

326 |
327 |

${statistics}

328 |
329 |

Ошибки: ${errorsCount}

330 |

${errors}

331 | `); 332 | } 333 | } 334 | 335 | await mailer.sendEmail(fullReport.join("


\n
\n") + "
\n
\n" + this.totalTime); 336 | } 337 | 338 | /** 339 | * получить ссылки на нужные файлы для одного пользователя 340 | */ 341 | async getUserLinks(user: User): Promise { 342 | return await api.createLinksToFiles(user.teams, user.token, this.options.all, this.hoursForPartialBackup); 343 | } 344 | 345 | /** 346 | * сохранить один файл 347 | */ 348 | async downloadOneFile(linkToFolder: LinkToFolder): Promise { 349 | if (this.options.verbose) console.log('downloadOneFile start', linkToFolder.link); 350 | 351 | const driver = await this.getWebdriver(); 352 | 353 | // делаем название файла из ссылки 354 | const title = this.getTitle(linkToFolder); 355 | 356 | // открываем ссылку на файл, который нужно скачать 357 | if (this.options.debug) console.log(' driver.get(link)', linkToFolder.link); 358 | await driver.get(linkToFolder.link); 359 | if (this.options.debug) console.log(' driver.get(link) wait'); 360 | await driver.sleep(200); 361 | if (this.options.debug) console.log(' driver.get(link) done'); 362 | 363 | const timeStart = Date.now(); 364 | try { 365 | // ждем загрузки страницы проверкой наличия элемента 366 | await driver.sleep(2000); // Ждём 2 секунды ибо фигма глючит часто 367 | await this.waitForElementAndGet(selector.folderNameInFile); 368 | const timeForOne = this.formatTime((Date.now() - timeStart) / 1000); 369 | if (this.options.debug) console.log(' waitForElementAndGet done ' + timeForOne, selector.folderNameInFile); 370 | 371 | } catch (exception) { 372 | const timeForOne = this.formatTime((Date.now() - timeStart) / 1000); 373 | 374 | if (this.options.debug) { 375 | console.log(' waitForElementAndGet error' + ' ' + linkToFolder.link + ' ' + timeForOne, selector.folderNameInFile); 376 | console.log(exception); 377 | 378 | const element = await this.webdriver.wait( 379 | WebDriver.until.elementLocated(WebDriver.By.css('html')), 380 | 500 381 | ); 382 | try { 383 | const html = await element.getAttribute('innerHTML'); 384 | console.log(html); 385 | } catch (e) { 386 | console.log('CANT GET HTML'); 387 | } 388 | } 389 | 390 | this.currentReportData.errors.push(`exception ${selector.folderNameInFile}, ${linkToFolder.link}, ${timeForOne}`); 391 | // неуспешный результат, в отчете будет показан как "есть ошибки" 392 | return false; 393 | } 394 | 395 | return await this.saveToDisk(title, linkToFolder.folder); 396 | } 397 | 398 | getTitle(linkToFolder: LinkToFolder) { 399 | const splitedLink = linkToFolder.link.split('/'); 400 | const splitLinkSecond = splitedLink[splitedLink.length - 1].split('?'); 401 | const splitLinkThird = splitLinkSecond[splitLinkSecond.length - 1].split('%2F'); 402 | 403 | return splitLinkThird.join('_').replace('/\|/g', '_'); 404 | } 405 | 406 | /** 407 | * 408 | */ 409 | async saveToDisk(title: string, folder: string): Promise { 410 | try { 411 | // сохранение файла 412 | const html = await this.webdriver.findElement(WebDriver.By.css("html")); 413 | 414 | await html.sendKeys(WebDriver.Key.CONTROL + "/"); 415 | await this.webdriver.sleep(200); 416 | 417 | const input = await this.webdriver.findElement(WebDriver.By.css(selector.quickActionsInput)); 418 | if (this.options.debug) console.log('Opened "Quick actions" input'); 419 | 420 | await input.sendKeys("Save local"); 421 | 422 | await this.webdriver.sleep(200); 423 | await input.sendKeys(WebDriver.Key.ENTER); 424 | 425 | if (this.options.debug) console.log('Send "Save local" command, waiting for file'); 426 | 427 | // чтобы файл успел скачаться 428 | let count = Math.round(this.delayFileDownload / this.period); 429 | const success = await this.waitExistenceOfFile(count, title, folder); 430 | 431 | // надеемся, что файл с дефолтным, вероятно, повторяющимся названием, тоже успеет скачаться 432 | if (title === 'Untitled') this.webdriver.sleep(this.period * 2); 433 | 434 | if (this.options.debug && !success) { 435 | const element = await this.webdriver.wait( 436 | WebDriver.until.elementLocated(WebDriver.By.css('html')), 437 | 500 438 | ); 439 | try { 440 | const html = await element.getAttribute('innerHTML'); 441 | console.log(html); 442 | } catch (e) { 443 | console.log('CANT GET HTML'); 444 | } 445 | } 446 | 447 | return success; 448 | 449 | } catch (StaleElementReferenceException) { 450 | this.currentReportData.errors.push(`StaleElementReferenceException "html"`); 451 | // неуспешный результат, в отчете будет показан как "есть ошибки" 452 | return false; 453 | } 454 | } 455 | 456 | /** 457 | * 458 | */ 459 | wasTitleUsed(title: string) { 460 | for (let item of this.titles) if (item === title) return true; 461 | return false; 462 | } 463 | 464 | /** 465 | * ожидание, пока файл с нужным именем появится в каталоге 466 | */ 467 | async waitExistenceOfFile(count: number, title: string, folder: string) { 468 | let titleWithFolder = `${title} ${folder}`; 469 | const tmpFolder = fsHelper.prepareFolderName(this.baseFolder, 'temp'); 470 | 471 | if (this.wasTitleUsed(titleWithFolder)) { 472 | let i = 1; 473 | let newTitle: string = ''; 474 | let newTitleWithFolder: string = ''; 475 | let postfix: string = ''; 476 | 477 | do { 478 | postfix = `(${i++})`; 479 | newTitle = `${title} ${postfix}`; 480 | newTitleWithFolder = `${title} ${postfix} ${folder}`; 481 | } while (this.wasTitleUsed(newTitleWithFolder)); 482 | 483 | title = newTitle; 484 | titleWithFolder = newTitleWithFolder; 485 | } 486 | 487 | do { 488 | count--; 489 | if (fsHelper.isFileInDirectory(tmpFolder, title)) { 490 | fsHelper.moveFile(tmpFolder, folder, title); 491 | return true; 492 | } 493 | await this.webdriver.sleep(this.period); 494 | } while (count > 0); 495 | 496 | if (this.options.verbose) console.log(title + ' не скачался'); 497 | return false; 498 | } 499 | 500 | /** 501 | * войти под пользователем 502 | */ 503 | async login(user: User, driver) { 504 | await driver.get(this.urlFigma); 505 | // дождаться загрузки страницы 506 | if (this.options.debug) console.log(' waitForElementAndGet', selector.authBlock); 507 | await this.waitForElementAndGet(selector.authBlock); 508 | if (this.options.debug) console.log(' waitForElementAndGet done', selector.authBlock); 509 | 510 | const emailInput = await this.waitForElementAndGet(selector.authFieldLogin); 511 | const passwordInput = await this.waitForElementAndGet(selector.authFieldPassword); 512 | const loginButton = await this.waitForElementAndGet(selector.loginLink); 513 | 514 | if (this.options.debug) console.log(' waitForElementAndGet (email, pass, btn) done'); 515 | 516 | // напечатать логин 517 | await emailInput.sendKeys(user.login); 518 | 519 | // напечатать пароль 520 | await passwordInput.sendKeys(user.password); 521 | 522 | await loginButton.click(); 523 | if (this.options.debug) console.log(' loginButton clicked'); 524 | 525 | // ждем, что откроется страница с проектами 526 | try { 527 | await this.waitForElementAndGet(selector.menuLinkDrafts); 528 | } catch (exception) { 529 | if (this.options.debug) { 530 | console.log(' waitForElementAndGet error ' + selector.menuLinkDrafts); 531 | console.log(exception); 532 | 533 | const element = await this.webdriver.wait( 534 | WebDriver.until.elementLocated(WebDriver.By.css('html')), 535 | 500 536 | ); 537 | try { 538 | const html = await element.getAttribute('innerHTML'); 539 | console.log(html); 540 | } catch (e) { 541 | console.log('CANT GET HTML'); 542 | } 543 | } 544 | 545 | this.currentReportData.errors.push(`Cant load projects page!`); 546 | throw exception; 547 | } 548 | 549 | if (this.options.debug) console.log(' projects page opened'); 550 | } 551 | 552 | /** 553 | * дождаться пока появится элемент и вернуть его 554 | */ 555 | async waitForElementAndGet(selector: string, multiple: boolean = false) { 556 | const element = await this.webdriver.wait( 557 | WebDriver.until.elementLocated(WebDriver.By.css(selector)), 558 | this.delayElement 559 | ); 560 | 561 | await this.webdriver.wait( 562 | WebDriver.until.elementIsVisible(element), 563 | this.delayElement 564 | ); 565 | 566 | if (multiple) { 567 | return await this.webdriver.findElements(WebDriver.By.css(selector)); 568 | } else { 569 | return await this.webdriver.findElement(WebDriver.By.css(selector)); 570 | } 571 | } 572 | 573 | /** 574 | * создать новую сессию и новый драйвер с новым каталогом для сохранения 575 | */ 576 | createSessionNewDriver(folderName: String = this.baseFolder) { 577 | const chromeCapabilities = WebDriver.Capabilities.chrome(); 578 | chromeCapabilities.set('goog:chromeOptions', { 579 | 'args': [ 580 | '--test-type', 581 | '--start-maximized', 582 | '--headless', 583 | '--no-sandbox', 584 | '--log-level=' + (this.options.debug ? '1' : (this.options.verbose ? '2' : '3')), 585 | // '--disable-gpu', 586 | // '--disable-dev-shm-usage', 587 | '--ignore-gpu-blacklist', 588 | '--use-gl', 589 | // '--disable-web-security', 590 | // 'user-data-dir=' + folderName, 591 | ], 592 | 'prefs': { 593 | 'download': { 594 | 'default_directory': folderName, 595 | 'prompt_for_download': 'false' 596 | } 597 | } 598 | }); 599 | 600 | return new WebDriver.Builder() 601 | .withCapabilities(chromeCapabilities) 602 | .build(); 603 | } 604 | 605 | /** 606 | * создать новую сессию и новый драйвер с новым каталогом для сохранения 607 | */ 608 | async getWebdriver(user: null|User = null, force = false) { 609 | if (this.webdriver && !force) return this.webdriver; 610 | 611 | this.webdriver = this.createSessionNewDriver(fsHelper.prepareFolderName(this.baseFolder, 'temp')); 612 | 613 | if (this.options.debug) console.log(' WebDriver autosave folder: ' + fsHelper.prepareFolderName(this.baseFolder, 'temp')); 614 | 615 | if (user) { 616 | if (this.options.debug) console.log(' login'); 617 | await this.login(user, this.webdriver); 618 | if (this.options.debug) console.log(' login done'); 619 | } 620 | 621 | return this.webdriver; 622 | } 623 | 624 | async debugWebGl() { 625 | const driver = await this.createSessionNewDriver(''); 626 | await driver.get('https://webglreport.com/'); 627 | await driver.sleep(1000); 628 | const element = await driver.wait( 629 | WebDriver.until.elementLocated(WebDriver.By.css('html')), 630 | 500 631 | ); 632 | const html = await element.getAttribute('innerHTML'); 633 | console.log(html); 634 | await driver.close(); 635 | return; 636 | } 637 | 638 | formatTime (seconds) { 639 | let sec = Math.round(seconds); 640 | let minutes = Math.floor(sec / 60); 641 | sec = (sec % 60); 642 | let hours = Math.floor(minutes / 60); 643 | minutes = (minutes % 60); 644 | 645 | return (hours ? hours + 'ч ' : '') 646 | + (minutes ? minutes + 'мин ' : '') 647 | + (sec + 'сек'); 648 | } 649 | } 650 | 651 | module.exports = Backuper; 652 | --------------------------------------------------------------------------------