PDF резюме: Puppeteer-скрипт, кнопка скачивания, имя файла Mamadaev_Nik__CV__Product_Designer_UX-UI

This commit is contained in:
Local Build committed 2026-08-14 10:05:17 +02:00
1 parent 322938b5ec
commit 97e56b48bf
45 files changed
+5132 -4197

No files matched your search

-179
View File
@@ -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);
});
+63
View File
@@ -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
View File
@@ -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/)");
});
-162
View File
@@ -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);
});