PDF резюме: Puppeteer-скрипт, кнопка скачивания, имя файла Mamadaev_Nik__CV__Product_Designer_UX-UI
This commit is contained in:
1 parent
322938b5ec
commit
97e56b48bf
45 files changed
+5132
-4197
No files matched your search
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Инкрементальный деплой public/ на VPS в Docker volume nginx.
|
||||
* Загружает только изменённые файлы через tar-пайп над SSH (одно соединение).
|
||||
*
|
||||
* Использование:
|
||||
* node scripts/deploy-vps.mjs [sourceDir]
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const SSH_KEY = path.join(os.homedir(), ".ssh", "beget");
|
||||
const SSH_RAW_OPTS = [
|
||||
"-i", SSH_KEY,
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=15",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
];
|
||||
const SSH_HOST = "root@155.212.186.39";
|
||||
const TARGET_DIR = "/var/lib/docker/volumes/s9mrcps8xw02r1dak0fsznix-www-data/_data";
|
||||
const MANIFEST_PATH = path.join(projectRoot, ".deploy-manifest.json");
|
||||
|
||||
const SOURCE_DIR = (() => {
|
||||
const raw = process.argv[2] ?? "public";
|
||||
return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw);
|
||||
})();
|
||||
|
||||
function ssh(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("ssh", [...SSH_RAW_OPTS, SSH_HOST, ...args], { maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
||||
if (err) reject(new Error((err.stderr || "").trim() || err.message));
|
||||
else resolve((stdout || "").trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scanFiles(dir) {
|
||||
const entries = [];
|
||||
const walk = (current, relative) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const rel = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full, rel);
|
||||
} else if (entry.isFile()) {
|
||||
const stat = fs.statSync(full);
|
||||
entries.push({
|
||||
relPath: rel.replace(/\\/g, "/"),
|
||||
mtime: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dir, "");
|
||||
return entries;
|
||||
}
|
||||
|
||||
function loadManifest() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveManifest(manifest) {
|
||||
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
/** Загрузить изменённые файлы одним tar-пайпом через SSH */
|
||||
function uploadViaTar(filePaths, sourceDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const listPath = path.join(os.tmpdir(), `deploy-files-${Date.now()}.txt`);
|
||||
fs.writeFileSync(listPath, filePaths.join("\n"), "utf-8");
|
||||
|
||||
// cwd вместо -C — обходит баг bsdtar с кириллицей в пути
|
||||
const tar = spawn("tar", [
|
||||
"-c",
|
||||
`--files-from=${listPath}`,
|
||||
"--format=ustar",
|
||||
], { cwd: sourceDir });
|
||||
|
||||
const sshProc = spawn("ssh", [
|
||||
...SSH_RAW_OPTS,
|
||||
SSH_HOST,
|
||||
`tar xf - -C ${TARGET_DIR}`,
|
||||
]);
|
||||
|
||||
tar.stdout.pipe(sshProc.stdin);
|
||||
|
||||
let stderrChunks = [];
|
||||
sshProc.stderr.on("data", (d) => { stderrChunks.push(d); });
|
||||
tar.stderr.on("data", (d) => { stderrChunks.push(d); });
|
||||
|
||||
function cleanup() {
|
||||
try { fs.unlinkSync(listPath); } catch { /* ok */ }
|
||||
}
|
||||
|
||||
sshProc.on("close", (code) => {
|
||||
cleanup();
|
||||
if (code !== 0) {
|
||||
const msg = Buffer.concat(stderrChunks).toString().trim();
|
||||
reject(new Error(msg || `ssh exited with code ${code}`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
sshProc.on("error", (err) => { cleanup(); reject(err); });
|
||||
tar.on("error", (err) => { cleanup(); sshProc.kill(); reject(err); });
|
||||
});
|
||||
}
|
||||
|
||||
async function deploy() {
|
||||
// 1. Убедиться, что целевая директория существует
|
||||
console.log("Проверка соединения...");
|
||||
await ssh([`mkdir -p ${TARGET_DIR}`]);
|
||||
|
||||
// 2. Сравнить локальные файлы с манифестом
|
||||
console.log("Сканирование...");
|
||||
const localFiles = scanFiles(SOURCE_DIR);
|
||||
const manifest = loadManifest();
|
||||
|
||||
const changedPaths = [];
|
||||
for (const f of localFiles) {
|
||||
const prev = manifest[f.relPath];
|
||||
if (!prev || prev.mtime !== f.mtime || prev.size !== f.size) {
|
||||
changedPaths.push(f.relPath);
|
||||
}
|
||||
}
|
||||
|
||||
const localPaths = new Set(localFiles.map((f) => f.relPath));
|
||||
const removedPaths = Object.keys(manifest).filter((p) => !localPaths.has(p));
|
||||
|
||||
if (changedPaths.length === 0 && removedPaths.length === 0) {
|
||||
console.log("Изменений нет — деплой не требуется.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Изменено/новых: ${changedPaths.length}, удалено: ${removedPaths.length}`);
|
||||
|
||||
// 3. Загрузить изменённые файлы одним tar-пайпом
|
||||
if (changedPaths.length > 0) {
|
||||
console.log("Загрузка файлов...");
|
||||
await uploadViaTar(changedPaths, SOURCE_DIR);
|
||||
console.log("Загрузка завершена.");
|
||||
}
|
||||
|
||||
// 4. Удалить файлы, которых больше нет локально
|
||||
if (removedPaths.length > 0) {
|
||||
console.log("Удаление лишних файлов...");
|
||||
const rmList = removedPaths.map((p) => `${TARGET_DIR}/${p}`).join(" ");
|
||||
await ssh([`rm -f ${rmList}`]);
|
||||
await ssh([`find ${TARGET_DIR} -type d -empty -delete 2>/dev/null || true`]);
|
||||
}
|
||||
|
||||
// 5. Обновить манифест
|
||||
saveManifest(
|
||||
Object.fromEntries(localFiles.map((f) => [f.relPath, { mtime: f.mtime, size: f.size }])),
|
||||
);
|
||||
}
|
||||
|
||||
const relDir = path.relative(projectRoot, SOURCE_DIR);
|
||||
|
||||
deploy()
|
||||
.then(() => {
|
||||
console.log(`Готово: ${relDir}/ → ${SSH_HOST}:${TARGET_DIR}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Ошибка деплоя:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = resolve(__dirname, '../public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf');
|
||||
const PAGE = process.argv[2] || 'http://localhost:4175/resume-5.html';
|
||||
const FONT = process.argv[3] || 'geologica';
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(PAGE, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
await page.waitForSelector('.resume-doc', { timeout: 5000 });
|
||||
|
||||
await page.evaluate((font) => {
|
||||
localStorage.setItem('siteThemeV1', JSON.stringify({ font }));
|
||||
document.documentElement.dataset.font = font;
|
||||
const href = `assets/fonts/${font}.css`;
|
||||
if (!document.querySelector(`link[data-theme-font="${font}"]`)) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
link.dataset.themeFont = font;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}, FONT);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
const fontInfo = await page.evaluate(() => ({
|
||||
dataFont: document.documentElement.dataset.font,
|
||||
computedFont: getComputedStyle(document.body).fontFamily,
|
||||
}));
|
||||
console.log('Font:', fontInfo);
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('header, footer, nav, .fab-stack, .cookie-notice, .skip-link').forEach(el => el.remove());
|
||||
const doc = document.querySelector('.resume-doc');
|
||||
if (doc) {
|
||||
doc.style.removeProperty('max-width');
|
||||
doc.style.width = '100%';
|
||||
doc.style.margin = '0';
|
||||
doc.style.setProperty('background', 'transparent', 'important');
|
||||
doc.style.border = 'none';
|
||||
document.documentElement.style.setProperty('background', 'transparent', 'important');
|
||||
document.body.style.background = 'transparent';
|
||||
document.body.innerHTML = '';
|
||||
document.body.appendChild(doc);
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.padding = '0';
|
||||
}
|
||||
});
|
||||
|
||||
await page.pdf({
|
||||
path: OUT,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' }
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
console.log('PDF saved:', OUT);
|
||||
+53
-14
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Локальный сервер: public/ + BrowserSync + API правок контента (только localhost).
|
||||
* Локальный сервер: public/ + API правок контента (только localhost).
|
||||
* Фолбэк: нативный http без browser-sync (portscanner глючит на Node 23+).
|
||||
*/
|
||||
import browserSync from "browser-sync";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -199,17 +200,55 @@ if (!fs.existsSync(PENDING_FILE)) {
|
||||
}
|
||||
migrateLegacyFiles();
|
||||
|
||||
browserSync.create().init({
|
||||
server: {
|
||||
baseDir: publicDir,
|
||||
middleware: [contentEditsApi],
|
||||
},
|
||||
files: [path.join(publicDir, "**", "*")],
|
||||
port: 4175,
|
||||
ui: { port: 4176 },
|
||||
notify: false,
|
||||
open: false,
|
||||
ghostMode: false,
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
};
|
||||
|
||||
function serveStatic(req, res) {
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
let filePath = path.join(publicDir, decodeURIComponent(url.pathname));
|
||||
if (filePath.endsWith(path.sep)) filePath = path.join(filePath, "index.html");
|
||||
|
||||
fs.stat(filePath, (err, stat) => {
|
||||
if (err || !stat.isFile()) {
|
||||
const fallback = path.join(publicDir, "index.html");
|
||||
fs.readFile(fallback, (e2, data) => {
|
||||
if (e2) { res.writeHead(404); res.end("Not found"); return; }
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(data);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = MIME[ext] || "application/octet-stream";
|
||||
fs.readFile(filePath, (e2, data) => {
|
||||
if (e2) { res.writeHead(500); res.end("Error"); return; }
|
||||
res.writeHead(200, { "Content-Type": mime });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
contentEditsApi(req, res, () => serveStatic(req, res));
|
||||
});
|
||||
|
||||
console.log("serve-dev: http://localhost:4175 (public/, reload only public/**)");
|
||||
server.listen(4175, () => {
|
||||
console.log("serve-dev: http://localhost:4175 (public/)");
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Копирует содержимое папки public в папку gitverse.
|
||||
* Файлы `.png` не копируются (на GitVerse достаточно WebP и прочих ассетов).
|
||||
*
|
||||
* По умолчанию:
|
||||
* node scripts/sync-gitverse.mjs
|
||||
* (копирует projectRoot/public -> projectRoot/gitverse)
|
||||
*
|
||||
* Можно передать свои пути:
|
||||
* node scripts/sync-gitverse.mjs <sourceDir> <targetDir>
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fsp from "node:fs/promises";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const PRESERVE_IN_TARGET = new Set([".git", ".gitverse"]);
|
||||
|
||||
function shouldSkipGitverseFile(fileName) {
|
||||
return path.extname(fileName).toLowerCase() === ".png";
|
||||
}
|
||||
const DEFAULT_SSH_KEY_PATH = path.join(os.homedir(), ".ssh", "id_ed25519_work");
|
||||
const GITVERSE_SITE_URL = "https://mamadaevv.gitverse.site/me/";
|
||||
|
||||
/** Системный браузер по умолчанию (Windows / macOS / Linux). */
|
||||
function openUrlInDefaultBrowser(url) {
|
||||
if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return;
|
||||
const opts = { detached: true, stdio: "ignore" };
|
||||
let child;
|
||||
if (process.platform === "win32") {
|
||||
child = spawn("cmd", ["/c", "start", "", url], opts);
|
||||
} else if (process.platform === "darwin") {
|
||||
child = spawn("open", [url], opts);
|
||||
} else {
|
||||
child = spawn("xdg-open", [url], opts);
|
||||
}
|
||||
child?.unref();
|
||||
}
|
||||
|
||||
function resolveDir(input, fallback) {
|
||||
const raw = input ?? fallback;
|
||||
return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw);
|
||||
}
|
||||
|
||||
async function ensureDirExists(dirPath, label) {
|
||||
try {
|
||||
const st = await fsp.stat(dirPath);
|
||||
if (!st.isDirectory()) {
|
||||
throw new Error(`${label} не является папкой: ${dirPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
throw new Error(`${label} не найдена: ${dirPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanTargetDir(targetDir) {
|
||||
await fsp.mkdir(targetDir, { recursive: true });
|
||||
const entries = await fsp.readdir(targetDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (PRESERVE_IN_TARGET.has(entry.name)) continue;
|
||||
const fullPath = path.join(targetDir, entry.name);
|
||||
await fsp.rm(fullPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDirFiltered(sourceDir, targetDir) {
|
||||
await fsp.mkdir(targetDir, { recursive: true });
|
||||
const entries = await fsp.readdir(sourceDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(sourceDir, entry.name);
|
||||
const dstPath = path.join(targetDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirFiltered(srcPath, dstPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || shouldSkipGitverseFile(entry.name)) continue;
|
||||
await fsp.copyFile(srcPath, dstPath);
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(args, cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_SSH_COMMAND: `ssh -i "${DEFAULT_SSH_KEY_PATH}" -o IdentitiesOnly=yes`,
|
||||
};
|
||||
execFile("git", args, { cwd, encoding: "utf8", env: gitEnv }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error((stderr || error.message || "").trim()));
|
||||
return;
|
||||
}
|
||||
resolve((stdout || "").trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function makeCommitMessage() {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
const hh = String(now.getHours()).padStart(2, "0");
|
||||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||||
return `sync public ${y}-${m}-${d} ${hh}:${mm}`;
|
||||
}
|
||||
|
||||
async function commitAndPush(targetDir) {
|
||||
const gitDir = path.join(targetDir, ".git");
|
||||
await ensureDirExists(gitDir, "Git репозиторий (.git)");
|
||||
try {
|
||||
await fsp.access(DEFAULT_SSH_KEY_PATH);
|
||||
} catch {
|
||||
throw new Error(`SSH-ключ не найден: ${DEFAULT_SSH_KEY_PATH}`);
|
||||
}
|
||||
|
||||
await runGit(["add", "."], targetDir);
|
||||
const changed = await runGit(["status", "--porcelain"], targetDir);
|
||||
|
||||
if (!changed) {
|
||||
console.log("Изменений для коммита нет.");
|
||||
return;
|
||||
}
|
||||
|
||||
const message = makeCommitMessage();
|
||||
await runGit(["commit", "-m", message], targetDir);
|
||||
await runGit(["push"], targetDir);
|
||||
console.log(`Коммит и пуш выполнены: ${message}`);
|
||||
openUrlInDefaultBrowser(GITVERSE_SITE_URL);
|
||||
console.log(`Открыт в браузере: ${GITVERSE_SITE_URL}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sourceDir = resolveDir(process.argv[2], "public");
|
||||
const targetDir = resolveDir(process.argv[3], "gitverse");
|
||||
|
||||
await ensureDirExists(sourceDir, "Исходная папка");
|
||||
|
||||
if (sourceDir === targetDir) {
|
||||
throw new Error("Исходная и целевая папки совпадают.");
|
||||
}
|
||||
|
||||
await cleanTargetDir(targetDir);
|
||||
await copyDirFiltered(sourceDir, targetDir);
|
||||
|
||||
console.log(
|
||||
`Синхронизировано: ${path.relative(projectRoot, sourceDir)} -> ${path.relative(projectRoot, targetDir)} (сохранены .git и .gitverse)`,
|
||||
);
|
||||
await commitAndPush(targetDir);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Ошибка копирования:", error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user