hh resume booster: free auto-bump for hh.ru (Playwright, Linux/Win/Mac)
This commit is contained in:
+115
@@ -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())
|
||||
Reference in New Issue
Block a user