#!/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())