commit 5e2a1514c5102c2563b3bb0ac130ae18c8cf75c8 Author: stackops Date: Mon Aug 24 20:34:25 2026 +0300 hh resume booster: free auto-bump for hh.ru (Playwright, Linux/Win/Mac) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..475724b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# НИКОГДА не коммитьте и не пересылайте сессию — это полный доступ к аккаунту hh! +session/ +config.json +*.log +.venv/ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0eb8979 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# hh resume booster + +Бесплатное автоматическое «поднятие» резюме в поиске hh.ru — по расписанию, на Linux, Windows и macOS. + +## Как это работает + +На hh **любое сохранение резюме заново поднимает его в выдаче поиска** — причём +**без 4-часового кулдауна**, который есть у ручной кнопки «Поднять в поиске». +Скрипт вносит невидимую правку в блок **«О себе»** (тумблерит один двойной пробел +в середине текста — в вёрстке страницы он схлопывается, так что глазами он не виден +и содержимое резюме не меняется) и сохраняет. Это бесплатный аналог платного +сервиса «Автоподнятие». + +Резюме на вашем аккаунте определяются **автоматически**. Планировщик ОС гоняет +скрипт каждые 2 часа. + +> ⚠️ **Требование:** у резюме должен быть непустой блок «О себе» (иначе прятать +> правку негде — такое резюме скрипт пропустит и напишет об этом в лог). + +## 🔐 Безопасность — прочитайте + +Файл **`session/storage.json`** — это ваши cookie-сессии hh, то есть **полный доступ +к аккаунту**. Никогда не выкладывайте его в git и не пересылайте архив ПОСЛЕ логина. +В поставке этого файла нет — вы создаёте его локально сам через `login.py`. +`.gitignore` уже исключает `session/`, `config.json` и логи. + +Автоматизация браузером — «серая зона» правил hh (ToS). Риск низкий (вы правите +своё же резюме), но частить не стоит: 2 часа — норм, каждые 5 минут — палево. + +--- + +## 1. Требования + +- **Python 3.9+** +- Интернет и аккаунт соискателя на hh.ru +- ~400 МБ на браузер Playwright (chromium) + +Проверить питон: `python3 --version` (на Windows — `python --version`). + +--- + +## 2. Установка (суть одна, команды под каждую ОС ниже) + +Распакуйте архив, зайдите в папку `hh-resume-booster`, создайте виртуальное +окружение, поставьте зависимости и браузер. + +### 🐧 Linux + +```bash +cd hh-resume-booster +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +playwright install --with-deps chromium # --with-deps ставит системные библиотеки (нужен sudo) +``` +Если `--with-deps` ругается на права — выполните `sudo $(which playwright) install-deps` +один раз, затем `playwright install chromium`. + +### 🪟 Windows (PowerShell) + +```powershell +cd hh-resume-booster +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +playwright install chromium +``` +Если PowerShell не даёт активировать окружение — запустите один раз: +`Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`. + +### 🍎 macOS + +```bash +cd hh-resume-booster +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +playwright install chromium +``` +(Если нет питона: `brew install python`.) + +--- + +## 3. Логин (создаём сессию) + +```bash +python login.py # Windows: python login.py | Linux/mac: python login.py +``` +Откроется настоящий браузер. **Войдите в hh.ru** любым способом (почта/пароль, код +из письма/смс, госуслуги). Скрипт сам поймает вход и сохранит `session/storage.json`, +после чего окно можно закрыть. Появится `✅ Готово`. + +> **Сервер без экрана (headless Linux)?** `login.py` требует графику. Запустите его +> на своём компьютере, затем скопируйте получившийся `session/storage.json` на сервер +> в такую же папку `session/`. + +--- + +## 4. Пробный прогон вручную + +```bash +python bump_resumes.py +``` +Ожидаемый вывод — по строке на резюме, например: +``` +[2026-08-24 20:08] 30ee2ebd…: about-added-ok | поднято✓ (next-manual=00:08) +``` +`next-manual=HH:MM` = метка «Можно сегодня в …» на странице резюме; она равна +времени_правки + 4ч и **двигается при каждом прогоне** — это и есть подтверждение, +что резюме подняли. Всё пишется в `bump.log`. + +--- + +## 5. Автозапуск каждые 2 часа + +### 🍎 macOS — launchd + +```bash +bash scheduler/install-mac.sh +``` +- Статус: `launchctl list | grep hhbump` +- Остановить: `launchctl bootout gui/$(id -u)/com.stackops.hhbump` + +### 🐧 Linux — systemd (user timer) + +```bash +bash scheduler/install-linux.sh +``` +- Статус: `systemctl --user list-timers | grep hhbump` +- Разовый прогон: `systemctl --user start hhbump.service` +- Остановить: `systemctl --user disable --now hhbump.timer` + +
Нет systemd? Вариант через cron + +```bash +crontab -e +# добавить строку (замените /ПОЛНЫЙ/ПУТЬ): +0 */2 * * * /ПОЛНЫЙ/ПУТЬ/hh-resume-booster/scheduler/run.sh >> /ПОЛНЫЙ/ПУТЬ/hh-resume-booster/bump.cron.log 2>&1 +``` +(сделайте `chmod +x scheduler/run.sh`) +
+ +### 🪟 Windows — Планировщик заданий + +В PowerShell **из папки пакета**: +```powershell +powershell -ExecutionPolicy Bypass -File .\scheduler\install-windows.ps1 +``` +- Статус: `schtasks /Query /TN hhbump` +- Разовый прогон: `schtasks /Run /TN hhbump` +- Удалить: `schtasks /Delete /TN hhbump /F` + +--- + +## 6. Настройка (необязательно) + +Скопируйте `config.example.json` → `config.json`: + +```json +{ + "resume_ids": ["30ee2ebd...", "fb175217..."], + "headless": true +} +``` +- `resume_ids` — если хотите поднимать только конкретные резюме (иначе — все на аккаунте). + RID виден в URL резюме: `hh.ru/resume/ЭТОТ_ID`. +- `headless: false` — показывать браузер во время прогона (для отладки). + +**Сменить частоту:** поменяйте `StartInterval` (секунды) в plist / `OnUnitActiveSec` +в `hhbump.timer` / интервал в задаче Windows и переустановите соответствующим +`install-*` скриптом. + +--- + +## 7. Диагностика + +| Симптом в `bump.log` | Что делать | +|---|---| +| `STOP: нет session/storage.json` | запустите `python login.py` | +| `STOP: сессия мертва (hhrole=...)` | сессия протухла (живёт ~30 дней) → `python login.py` заново | +| `empty-about-skip` | добавьте текст в блок «О себе» этого резюме | +| `no-about-field` / `ERR` | hh поменяли вёрстку или таймаут — проверьте вручную | + +**Важно:** прогон случается, только когда компьютер **не спит**. launchd/systemd +схлопывают пропуски в один прогон при пробуждении (свежак к утру — это ок). +Нужен режим 24/7 без пропусков — держите машину бодрой или поставьте на VPS. + +--- + +## Состав пакета + +``` +hh-resume-booster/ +├── bump_resumes.py # сам бустер (автоопределение резюме) +├── login.py # логин в hh -> session/storage.json +├── requirements.txt +├── config.example.json +├── README.md +├── session/ # сюда ляжет storage.json (в архиве пусто) +└── scheduler/ + ├── run.sh / run.bat # обёртки для планировщиков + ├── install-mac.sh # launchd + ├── install-linux.sh # systemd user-timer + ├── install-windows.ps1 # Task Scheduler + ├── com.stackops.hhbump.plist # шаблон launchd + ├── hhbump.service / .timer # шаблоны systemd +``` diff --git a/bump_resumes.py b/bump_resumes.py new file mode 100644 index 0000000..659428d --- /dev/null +++ b/bump_resumes.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +hh resume booster — бесплатное «поднятие» резюме в выдаче hh.ru. + +Как это работает: на hh ЛЮБОЕ сохранение резюме заново поднимает его в поиске, +причём БЕЗ 4-часового кулдауна ручной кнопки «Поднять в поиске». Скрипт вносит +невидимую правку в блок «О себе» (тумблерит один двойной пробел в середине текста — +в вёрстке он схлопывается, глазами не виден) и сохраняет. Это бесплатный аналог +платного «Автоподнятия». + +Резюме определяются автоматически из вашей сессии (можно переопределить в config.json). +Запускать по расписанию (launchd / systemd / Task Scheduler) — см. README.md. + +Требуется: session/storage.json (создаётся через `python login.py`). +""" +import asyncio, json, re, datetime, sys +from pathlib import Path +from playwright.async_api import async_playwright + +BASE = Path(__file__).resolve().parent +SESSION = BASE / "session" / "storage.json" +CONFIG = BASE / "config.json" +LOG = BASE / "bump.log" +UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") + +def cfg(): + if CONFIG.exists(): + try: return json.loads(CONFIG.read_text(encoding="utf-8")) + except Exception: pass + return {} + +def toggle_space(text): + """Невидимый mid-text тумблер: убрать первый двойной пробел ИЛИ добавить его + после первого пробела за 15-м символом (не старт и не конец текста).""" + if " " in text: + return text.replace(" ", " ", 1), "removed" + m = re.search(r'\S \S', text[15:]) + if m: + pos = 15 + m.start() + 1 + return text[:pos] + " " + text[pos:], "added" + return None, "empty-skip" # пустой/слишком короткий «О себе» — не трогаем + +async def discover_rids(pg): + await pg.goto("https://hh.ru/applicant/resumes", wait_until="domcontentloaded", timeout=60000) + await pg.wait_for_timeout(2500) + html = await pg.content() + # RID = 38 hex; /resume/edit/... не матчится (после /resume/ идёт 'edit') + return list(dict.fromkeys(re.findall(r'/resume/([0-9a-f]{30,})', html))) + +async def boost_one(pg, rid): + # 1) правка «О себе» = поднятие + await pg.goto(f"https://hh.ru/resume/edit/{rid}/about", wait_until="domcontentloaded", timeout=60000) + await pg.wait_for_timeout(2200) + ta = pg.locator('[data-qa="resume-editor-about"], textarea[data-qa*="about"], textarea').first + if await ta.count() == 0: + return "no-about-field" + cur = await ta.input_value() + new, how = toggle_space(cur) + if new is None: + return "empty-about-skip (добавьте текст в «О себе»)" + await ta.click(); await ta.fill(new); await pg.wait_for_timeout(500) + saved = False + for sel in ['[data-qa="resume-partial-edit-save"]', 'button:has-text("Сохранить")']: + l = pg.locator(sel).first + if await l.count() > 0 and await l.is_visible(): + await l.click(); saved = True; break + await pg.wait_for_timeout(3000) + ok = saved and f"/resume/{rid}" in pg.url and "edit" not in pg.url + # 2) подтверждение: метка «Можно сегодня в HH:MM» = время правки + 4ч (двигается => подняли) + await pg.goto(f"https://hh.ru/resume/{rid}", wait_until="domcontentloaded", timeout=60000) + await pg.wait_for_timeout(2000) + body = await pg.inner_text("body") + m = re.search(r'[Мм]ожно[^0-9]{0,25}(\d\d:\d\d)', body) + tag = f"next-manual={m.group(1)}" if m else "no-timer-label" + return f"about-{how}-{'ok' if ok else '?'} | поднято✓ ({tag})" + +async def main(): + if not SESSION.exists(): + print("STOP: нет session/storage.json — сначала запустите `python login.py`") + sys.exit(2) + conf = cfg() + ss = json.loads(SESSION.read_text(encoding="utf-8")) + out = [] + async with async_playwright() as p: + b = await p.chromium.launch(headless=conf.get("headless", True), args=["--no-sandbox"]) + ctx = await b.new_context(storage_state=ss, user_agent=UA, locale="ru-RU", + timezone_id="Europe/Moscow", viewport={"width": 1440, "height": 1200}) + pg = await ctx.new_page() + role = {c["name"]: c["value"] for c in await ctx.cookies("https://hh.ru")}.get("hhrole") + if role != "applicant": + out.append(f"STOP: сессия мертва (hhrole={role}) — перелогиньтесь: python login.py") + else: + rids = conf.get("resume_ids") or await discover_rids(pg) + if not rids: + out.append("STOP: резюме не найдены на аккаунте") + for rid in rids: + try: + r = await boost_one(pg, rid) + out.append(f"{rid[:8]}…: {r}") + except Exception as e: + out.append(f"{rid[:8]}…: ERR {str(e)[:70]}") + await b.close() + ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") + line = f"[{ts}] " + " || ".join(out) + print(line) + try: + with LOG.open("a", encoding="utf-8") as fh: + fh.write(line + "\n") + except Exception: + pass + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..8fb3808 --- /dev/null +++ b/config.example.json @@ -0,0 +1,5 @@ +{ + "_comment": "Скопируйте в config.json, если нужно переопределить. Всё опционально.", + "resume_ids": [], + "headless": true +} diff --git a/hh-resume-booster.zip b/hh-resume-booster.zip new file mode 100644 index 0000000..da2ea41 Binary files /dev/null and b/hh-resume-booster.zip differ diff --git a/login.py b/login.py new file mode 100644 index 0000000..a19b1ce --- /dev/null +++ b/login.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Логин в hh.ru → сохраняет сессию в session/storage.json. + +Открывает НАСТОЯЩИЙ браузер (не headless). Войдите в свой аккаунт hh любым способом +(почта/пароль, код из смс/письма, госуслуги — как обычно). Скрипт сам поймает момент +входа по cookie `hhrole=applicant` и сохранит сессию. После этого можно закрывать. + +На сервере без экрана — запустите login.py на своём компьютере, затем скопируйте +файл session/storage.json на сервер (см. README). +""" +import asyncio, json, sys +from pathlib import Path +from playwright.async_api import async_playwright + +BASE = Path(__file__).resolve().parent +SESSION = BASE / "session" / "storage.json" +SESSION.parent.mkdir(parents=True, exist_ok=True) + +async def main(): + print("Открываю браузер… войдите в hh.ru в открывшемся окне.") + print("Как только вход выполнен — жду до 5 минут и сохраняю сессию автоматически.") + async with async_playwright() as p: + b = await p.chromium.launch(headless=False) + ctx = await b.new_context(locale="ru-RU", timezone_id="Europe/Moscow") + pg = await ctx.new_page() + await pg.goto("https://hh.ru/account/login", wait_until="domcontentloaded", timeout=60000) + ok = False + for _ in range(150): # 150 * 2s = 5 минут + await pg.wait_for_timeout(2000) + try: + role = {c["name"]: c["value"] for c in await ctx.cookies("https://hh.ru")}.get("hhrole") + except Exception: + role = None + if role == "applicant": + ok = True + break + if ok: + await ctx.storage_state(path=str(SESSION)) + print(f"\n✅ Готово. Сессия сохранена: {SESSION}") + print(" Теперь можно ставить на расписание (см. README.md).") + else: + print("\n❌ Не увидел вход (cookie hhrole=applicant) за 5 минут. Запустите ещё раз.") + await b.close() + sys.exit(0 if ok else 1) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..13cbe37 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +playwright>=1.40 diff --git a/scheduler/com.stackops.hhbump.plist b/scheduler/com.stackops.hhbump.plist new file mode 100644 index 0000000..a2ec110 --- /dev/null +++ b/scheduler/com.stackops.hhbump.plist @@ -0,0 +1,17 @@ + + + + + Label com.stackops.hhbump + ProgramArguments + + __DIR__/.venv/bin/python + __DIR__/bump_resumes.py + + StartInterval 7200 + RunAtLoad + StandardOutPath __DIR__/bump.out.log + StandardErrorPath__DIR__/bump.err.log + ProcessType Background + + diff --git a/scheduler/hhbump.service b/scheduler/hhbump.service new file mode 100644 index 0000000..54023b0 --- /dev/null +++ b/scheduler/hhbump.service @@ -0,0 +1,7 @@ +[Unit] +Description=hh resume booster (одноразовый прогон) + +[Service] +Type=oneshot +WorkingDirectory=__DIR__ +ExecStart=__DIR__/.venv/bin/python __DIR__/bump_resumes.py diff --git a/scheduler/hhbump.timer b/scheduler/hhbump.timer new file mode 100644 index 0000000..dbeeb75 --- /dev/null +++ b/scheduler/hhbump.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Запуск hh resume booster каждые 2 часа + +[Timer] +OnBootSec=5min +OnUnitActiveSec=2h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scheduler/install-linux.sh b/scheduler/install-linux.sh new file mode 100644 index 0000000..ce3bb50 --- /dev/null +++ b/scheduler/install-linux.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Linux: ставит systemd user-timer (каждые 2ч) с автоподстановкой путей. +set -e +DIR="$(cd "$(dirname "$0")/.." && pwd)" +mkdir -p "$HOME/.config/systemd/user" +sed "s#__DIR__#$DIR#g" "$DIR/scheduler/hhbump.service" > "$HOME/.config/systemd/user/hhbump.service" +cp "$DIR/scheduler/hhbump.timer" "$HOME/.config/systemd/user/hhbump.timer" +systemctl --user daemon-reload +systemctl --user enable --now hhbump.timer +# чтобы таймер работал и когда вы не залогинены в графическую сессию: +loginctl enable-linger "$USER" 2>/dev/null || true +echo "✅ Установлено. Проверка: systemctl --user list-timers | grep hhbump" +echo " Разовый прогон сейчас: systemctl --user start hhbump.service" +echo " Остановить: systemctl --user disable --now hhbump.timer" +echo " Логи: $DIR/bump.log (и: journalctl --user -u hhbump.service)" diff --git a/scheduler/install-mac.sh b/scheduler/install-mac.sh new file mode 100644 index 0000000..5716648 --- /dev/null +++ b/scheduler/install-mac.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# macOS: ставит launchd-агент (каждые 2ч) с автоподстановкой путей. +set -e +DIR="$(cd "$(dirname "$0")/.." && pwd)" +PLIST="$HOME/Library/LaunchAgents/com.stackops.hhbump.plist" +mkdir -p "$HOME/Library/LaunchAgents" +sed "s#__DIR__#$DIR#g" "$DIR/scheduler/com.stackops.hhbump.plist" > "$PLIST" +launchctl bootout "gui/$(id -u)/com.stackops.hhbump" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$PLIST" +echo "✅ Установлено. Проверка: launchctl list | grep hhbump" +echo " Остановить: launchctl bootout gui/\$(id -u)/com.stackops.hhbump" +echo " Логи: $DIR/bump.log" diff --git a/scheduler/install-windows.ps1 b/scheduler/install-windows.ps1 new file mode 100644 index 0000000..38fb9c0 --- /dev/null +++ b/scheduler/install-windows.ps1 @@ -0,0 +1,16 @@ +# Windows: регистрирует задачу в Планировщике (каждые 2 часа). +# Запуск: правой кнопкой -> "Выполнить с помощью PowerShell", ИЛИ в PowerShell: +# powershell -ExecutionPolicy Bypass -File .\scheduler\install-windows.ps1 +$Root = (Resolve-Path "$PSScriptRoot\..").Path +$Bat = Join-Path $Root "scheduler\run.bat" +$Action = New-ScheduledTaskAction -Execute $Bat +$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) ` + -RepetitionInterval (New-TimeSpan -Hours 2) -RepetitionDuration ([TimeSpan]::MaxValue) +$Settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries +Register-ScheduledTask -TaskName "hhbump" -Action $Action -Trigger $Trigger ` + -Settings $Settings -Description "hh resume booster" -Force +Write-Host "OK. Проверка: schtasks /Query /TN hhbump" +Write-Host "Разовый прогон: schtasks /Run /TN hhbump" +Write-Host "Удалить: schtasks /Delete /TN hhbump /F" +Write-Host "Логи: $Root\bump.log" diff --git a/scheduler/run.bat b/scheduler/run.bat new file mode 100644 index 0000000..a7b0b3b --- /dev/null +++ b/scheduler/run.bat @@ -0,0 +1,4 @@ +@echo off +REM Обёртка для Планировщика заданий Windows. +cd /d "%~dp0.." +".venv\Scripts\python.exe" bump_resumes.py diff --git a/scheduler/run.sh b/scheduler/run.sh new file mode 100755 index 0000000..fcfaaec --- /dev/null +++ b/scheduler/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Обёртка для планировщиков (launchd/cron/systemd) — идёт в корень пакета и запускает бустер. +cd "$(dirname "$0")/.." || exit 1 +exec ./.venv/bin/python bump_resumes.py