Compare commits

..
9 changed files with 42 additions and 453 deletions

No files matched your search

-6
View File
@@ -15,9 +15,3 @@ HH_HEADLESS=0
# На сервере без sandbox (root/контейнер): 1 # На сервере без sandbox (root/контейнер): 1
HH_NO_SANDBOX=0 HH_NO_SANDBOX=0
# Регистрация: 1 = открытая (любой может создать аккаунт), 0 = только через manage_users.py
ALLOW_REGISTRATION=1
# Дневной лимит генераций для самостоятельно зарегистрированных пользователей
REGISTER_LLM_LIMIT=50
+5 -20
View File
@@ -9,7 +9,7 @@
│ nginx │ Let's Encrypt │ nginx │ Let's Encrypt
│ reverse proxy│ │ reverse proxy│
└───────┬───────┘ └───────┬───────┘
│ :18091 │ :5000
▼ ▼
gunicorn (Flask: авторизация, изоляция данных, лимиты генераций) gunicorn (Flask: авторизация, изоляция данных, лимиты генераций)
│ │
@@ -69,9 +69,7 @@
- ОС: **Debian 12 / Ubuntu 22.04+**, есть **root** - ОС: **Debian 12 / Ubuntu 22.04+**, есть **root**
- Домен или статический IP; порты **22, 80, 443** открыты - Домен или статический IP; порты **22, 80, 443** открыты
- Круг пользователей небольшой (2–10 человек). Два варианта: - Круг пользователей небольшой (2–10 человек), пользователей создаёт владелец (без открытой регистрации)
- **Открытая регистрация** (по умолчанию): любой может создать аккаунт на странице `/register`
- **Закрытая** (`ALLOW_REGISTRATION=0` в `.env`): пользователей создаёт владелец через `manage_users.py`
--- ---
@@ -156,19 +154,6 @@ systemctl enable --now xvfb
## Этап 4 — пользователи ## Этап 4 — пользователи
**Вариант А — открытая регистрация** (по умолчанию, `ALLOW_REGISTRATION=1`):
любой посетитель создаёт аккаунт сам на странице `/register` (логин + пароль,
сразу входит). Лимит генераций для таких пользователей — `REGISTER_LLM_LIMIT`
(по умолчанию 50 в день). Владельца всё равно стоит создать через CLI:
```bash
# Владелец (лимит 50 генераций в день)
/opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py add admin --password СЛОЖНЫЙ_ПАРОЛЬ --limit 50
```
**Вариант Б — закрытая регистрация** (`ALLOW_REGISTRATION=0` в `.env`):
пользователей создаёт только владелец:
```bash ```bash
# Владелец (лимит 50 генераций в день) # Владелец (лимит 50 генераций в день)
/opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py add admin --password СЛОЖНЫЙ_ПАРОЛЬ --limit 50 /opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py add admin --password СЛОЖНЫЙ_ПАРОЛЬ --limit 50
@@ -201,7 +186,7 @@ WorkingDirectory=/opt/hh_auto
Environment=DISPLAY=:99 Environment=DISPLAY=:99
EnvironmentFile=/opt/hh_auto/.env EnvironmentFile=/opt/hh_auto/.env
ExecStart=/opt/hh_auto/.venv/bin/gunicorn \ ExecStart=/opt/hh_auto/.venv/bin/gunicorn \
-w 1 --threads 8 --timeout 300 -b 127.0.0.1:18091 webui:app -w 1 --threads 8 --timeout 300 -b 127.0.0.1:5000 webui:app
Restart=always Restart=always
RestartSec=5 RestartSec=5
LimitNOFILE=65536 LimitNOFILE=65536
@@ -245,7 +230,7 @@ server {
client_max_body_size 20M; client_max_body_size 20M;
location / { location / {
proxy_pass http://127.0.0.1:18091; proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -296,7 +281,7 @@ chmod 644 /etc/cron.d/hh-backup
journalctl -u hh-web -f journalctl -u hh-web -f
# Здоровье # Здоровье
curl -sf http://127.0.0.1:18091/api/accounts > /dev/null || systemctl restart hh-web curl -sf http://127.0.0.1:5000/api/accounts > /dev/null || systemctl restart hh-web
``` ```
--- ---
+18 -89
View File
@@ -15,10 +15,6 @@ import time
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
from dotenv import load_dotenv
load_dotenv()
from hh_search import parse_cookies from hh_search import parse_cookies
# Селекторы hh.ru (с запасными вариантами) # Селекторы hh.ru (с запасными вариантами)
@@ -42,7 +38,6 @@ SUBMIT_BUTTONS = [
# Доступны ТОЛЬКО из браузерного потока. # Доступны ТОЛЬКО из браузерного потока.
_browsers = {} _browsers = {}
_pages = {} _pages = {}
_playwrights = {}
# ---------- Браузерный поток ---------- # ---------- Браузерный поток ----------
@@ -112,8 +107,9 @@ def get_proxy_config():
# ---------- Внутренние функции (только из браузерного потока) ---------- # ---------- Внутренние функции (только из браузерного потока) ----------
def _launch_context_with(p, account_dir): def _launch_context(account_dir):
"""Запускает браузер с профилем аккаунта и подставляет cookies сессии.""" """Запускает браузер с профилем аккаунта и подставляет cookies сессии."""
p = sync_playwright().start()
kwargs = dict( kwargs = dict(
user_data_dir=os.path.join(account_dir, "profile"), user_data_dir=os.path.join(account_dir, "profile"),
headless=is_headless(), headless=is_headless(),
@@ -127,15 +123,6 @@ def _launch_context_with(p, account_dir):
return context return context
def _context_alive(ctx):
"""Проверяет, жив ли контекст (лёгкая операция, не создаёт страниц)."""
try:
ctx.pages
return True
except Exception:
return False
def _inject_session_cookies(context, account_dir): def _inject_session_cookies(context, account_dir):
"""Подставляет cookies из session.txt — вход на hh.ru без ручного логина.""" """Подставляет cookies из session.txt — вход на hh.ru без ручного логина."""
path = os.path.join(account_dir, "session.txt") path = os.path.join(account_dir, "session.txt")
@@ -158,33 +145,10 @@ def _inject_session_cookies(context, account_dir):
def _get_context(account_dir, user, account): def _get_context(account_dir, user, account):
"""Возвращает живой контекст; если браузер умер — перезапускает."""
key = (user, account) key = (user, account)
ctx = _browsers.get(key) if key not in _browsers:
if ctx is None or not _context_alive(ctx): _browsers[key] = _launch_context(account_dir)
if ctx is not None: return _browsers[key]
print(f" ⚠ Браузер {key} закрыт — перезапускаю")
_close_context(key)
p = sync_playwright().start()
try:
ctx = _launch_context_with(p, account_dir)
except Exception:
p.stop()
raise
_browsers[key] = ctx
_playwrights[key] = p
return ctx
def _new_page(account_dir, user, account):
"""Создаёт страницу; если контекст умер между проверкой и созданием —
перезапускает браузер и пробует ещё раз."""
try:
return _get_context(account_dir, user, account).new_page()
except Exception:
print(f" ⚠ Не удалось создать страницу для {(user, account)} — перезапускаю браузер")
_close_context((user, account))
return _get_context(account_dir, user, account).new_page()
def _save_screenshot(page, screenshot_dir, vid): def _save_screenshot(page, screenshot_dir, vid):
@@ -200,10 +164,9 @@ def _save_screenshot(page, screenshot_dir, vid):
def open_response_page(account_dir, user, account, vacancy, message, def open_response_page(account_dir, user, account, vacancy, message,
resume_id=None, screenshot_dir=None, submit=False): resume_id=None, screenshot_dir=None):
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст. """Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст,
При submit=True дополнительно жмёт «Отправить» и снимает скриншот результата. делает скриншот. Возвращает (ok, note, screenshot_path)."""
Возвращает (ok, note, screenshot_path)."""
key = (user, account) key = (user, account)
# Закрываем предыдущую страницу этого аккаунта # Закрываем предыдущую страницу этого аккаунта
prev = _pages.pop(key, None) prev = _pages.pop(key, None)
@@ -213,7 +176,8 @@ def open_response_page(account_dir, user, account, vacancy, message,
except Exception: except Exception:
pass pass
page = _new_page(account_dir, user, account) context = _get_context(account_dir, user, account)
page = context.new_page()
_pages[key] = page _pages[key] = page
page.goto(vacancy["url"], timeout=45000) page.goto(vacancy["url"], timeout=45000)
page.wait_for_timeout(2500) page.wait_for_timeout(2500)
@@ -258,34 +222,10 @@ def open_response_page(account_dir, user, account, vacancy, message,
except Exception: except Exception:
continue continue
if not filled:
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
return False, "Не нашёл поле сообщения — проверьте скриншот", shot
# Одно-кликовый режим: сразу жмём «Отправить»
if submit:
submitted = False
for sel in SUBMIT_BUTTONS:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=3000):
btn.click()
submitted = True
break
except Exception:
continue
if not submitted:
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
return False, "Не нашёл кнопку «Отправить» — проверьте скриншот", shot
# Ждём подтверждение и снимаем скриншот результата
page.wait_for_timeout(2500)
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
# Отклик отправлен — закрываем браузер автоматически
_close_context(key)
return True, "Отклик отправлен", shot
shot = _save_screenshot(page, screenshot_dir, vacancy["id"]) shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
if filled:
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
return False, "Не нашёл поле сообщения — проверьте скриншот", shot
def confirm_send(user, account, vid): def confirm_send(user, account, vid):
@@ -300,8 +240,6 @@ def confirm_send(user, account, vid):
if btn.is_visible(timeout=3000): if btn.is_visible(timeout=3000):
btn.click() btn.click()
page.wait_for_timeout(1500) page.wait_for_timeout(1500)
# Отклик отправлен — закрываем браузер автоматически
_close_context(key)
return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru." return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru."
except Exception: except Exception:
continue continue
@@ -311,14 +249,15 @@ def confirm_send(user, account, vid):
def open_login_page(account_dir, user, account): def open_login_page(account_dir, user, account):
"""Открывает hh.ru в браузере (для ручного входа, локальный режим с экраном).""" """Открывает hh.ru в браузере (для ручного входа, локальный режим с экраном)."""
key = (user, account) key = (user, account)
page = _new_page(account_dir, user, account) context = _get_context(account_dir, user, account)
page = context.new_page()
_pages[key] = page _pages[key] = page
page.goto("https://hh.ru", timeout=45000) page.goto("https://hh.ru", timeout=45000)
return True, "Страница hh.ru открыта" return True, "Страница hh.ru открыта"
def _close_context(key): def close_context(user, account):
"""Закрывает контекст и останавливает playwright-инстанс (из браузерного потока).""" key = (user, account)
if key in _browsers: if key in _browsers:
try: try:
_browsers[key].close() _browsers[key].close()
@@ -326,25 +265,15 @@ def _close_context(key):
pass pass
_browsers.pop(key, None) _browsers.pop(key, None)
_pages.pop(key, None) _pages.pop(key, None)
p = _playwrights.pop(key, None)
if p:
try:
p.stop()
except Exception:
pass
def close_context(user, account):
_close_context((user, account))
# ---------- Публичные обёртки (можно вызывать из любого потока) ---------- # ---------- Публичные обёртки (можно вызывать из любого потока) ----------
def browser_open(account_dir, user, account, vacancy, message, def browser_open(account_dir, user, account, vacancy, message,
resume_id=None, screenshot_dir=None, submit=False): resume_id=None, screenshot_dir=None):
return run_in_browser(lambda: open_response_page( return run_in_browser(lambda: open_response_page(
account_dir, user, account, vacancy, message, account_dir, user, account, vacancy, message,
resume_id=resume_id, screenshot_dir=screenshot_dir, submit=submit)) resume_id=resume_id, screenshot_dir=screenshot_dir))
def browser_send(user, account, vid): def browser_send(user, account, vid):
-4
View File
@@ -8,10 +8,6 @@ import urllib.parse
import requests import requests
from dotenv import load_dotenv
load_dotenv()
HEADERS = { HEADERS = {
"User-Agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "User-Agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"), "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"),
-1
View File
@@ -3,4 +3,3 @@ playwright>=1.40.0
flask>=3.0.0 flask>=3.0.0
pypdf>=4.0.0 pypdf>=4.0.0
python-docx>=1.1.0 python-docx>=1.1.0
python-dotenv>=1.0.0
+10 -161
View File
@@ -67,7 +67,7 @@
border-radius: 8px; margin-bottom: 8px; background: var(--card); } border-radius: 8px; margin-bottom: 8px; background: var(--card); }
.account-item .name { font-weight: 600; } .account-item .name { font-weight: 600; }
.account-item .actions { display: flex; gap: 8px; } .account-item .actions { display: flex; gap: 8px; }
.hidden { display: none !important; } .hidden { display: none; }
.texts-panel textarea { min-height: 90px; margin-bottom: 8px; } .texts-panel textarea { min-height: 90px; margin-bottom: 8px; }
.texts-panel .item { margin-bottom: 14px; } .texts-panel .item { margin-bottom: 14px; }
.texts-panel .item .title { font-weight: 600; margin-bottom: 4px; } .texts-panel .item .title { font-weight: 600; margin-bottom: 4px; }
@@ -86,12 +86,6 @@
max-height: 92vh; overflow: auto; } max-height: 92vh; overflow: auto; }
.modal-box img { max-width: 100%; border: 1px solid var(--border); .modal-box img { max-width: 100%; border: 1px solid var(--border);
border-radius: 8px; display: block; } border-radius: 8px; display: block; }
.spinner { width: 18px; height: 18px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin .8s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading { display: flex; align-items: center; gap: 10px;
color: var(--muted); margin-bottom: 8px; }
::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg); } ::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
@@ -246,28 +240,6 @@
</section> </section>
</main> </main>
<!-- ============ МОДАЛКА: ОТПРАВКА ОТКЛИКА ============ -->
<div id="send-modal" class="modal hidden" onclick="if(event.target===this) closeSendModal()">
<div class="modal-box">
<div class="toolbar">
<h2 style="margin:0">Отправить отклик</h2>
<div class="spacer"></div>
<button class="secondary" onclick="closeSendModal()">Закрыть</button>
</div>
<div id="send-vacancy-title" class="muted" style="margin-bottom:8px"></div>
<div id="send-loading" class="loading">
<div class="spinner"></div>
<span>Текст генерируется...</span>
</div>
<textarea id="send-letter" style="min-height:140px" disabled
placeholder="Сопроводительное письмо появится здесь..."></textarea>
<div class="toolbar" style="margin-top:12px">
<button id="btn-send-letter" onclick="sendFromModal()" disabled>Отправить отклик</button>
<span id="send-modal-note" class="muted"></span>
</div>
</div>
</div>
<!-- ============ МОДАЛКА: ПРОВЕРКА ОТКЛИКА ============ --> <!-- ============ МОДАЛКА: ПРОВЕРКА ОТКЛИКА ============ -->
<div id="shot-modal" class="modal hidden" onclick="if(event.target===this) closeShot()"> <div id="shot-modal" class="modal hidden" onclick="if(event.target===this) closeShot()">
<div class="modal-box"> <div class="modal-box">
@@ -280,7 +252,7 @@
<img id="shot-img" alt="Скриншот вакансии"> <img id="shot-img" alt="Скриншот вакансии">
<div class="toolbar" style="margin-top:12px"> <div class="toolbar" style="margin-top:12px">
<button id="btn-send" onclick="confirmSend()">Отправить отклик</button> <button id="btn-send" onclick="confirmSend()">Отправить отклик</button>
<button id="btn-done" class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button> <button class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button>
<span id="send-note" class="muted"></span> <span id="send-note" class="muted"></span>
</div> </div>
</div> </div>
@@ -291,8 +263,6 @@ let currentAccount = null;
let vacancies = []; let vacancies = [];
let generatedTexts = {}; let generatedTexts = {};
let currentShotId = null; let currentShotId = null;
let sentTexts = new Set();
let sendModalId = null;
// ---------- Вкладки ---------- // ---------- Вкладки ----------
function showTab(name) { function showTab(name) {
@@ -411,9 +381,6 @@ async function loadAccountSettings() {
if (data.has_session) { if (data.has_session) {
sm.className = 'ok'; sm.className = 'ok';
sm.textContent = 'Ключ сохранён на сервере. Меняйте только если истёк.'; sm.textContent = 'Ключ сохранён на сервере. Меняйте только если истёк.';
await loadResumes(); // авто-загрузка списка резюме
// Применяем сохранённый resume_id после того, как опции появились
document.getElementById('cfg-resume-id').value = cfg.resume_id || '';
} else { } else {
sm.className = 'error'; sm.className = 'error';
sm.textContent = 'Ключ не задан — вставьте cookies и нажмите «Сохранить ключ»'; sm.textContent = 'Ключ не задан — вставьте cookies и нажмите «Сохранить ключ»';
@@ -540,7 +507,6 @@ function renderVacancies() {
<td>${exp}</td> <td>${exp}</td>
<td>${status}</td> <td>${status}</td>
<td class="actions-cell"> <td class="actions-cell">
<button class="secondary" onclick="openSendModal('${v.id}')">Отправить отклик</button>
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть</button> <button class="secondary" onclick="openVacancy('${v.id}')">Открыть</button>
<button class="secondary" onclick="markDone('${v.id}')">Отправил</button> <button class="secondary" onclick="markDone('${v.id}')">Отправил</button>
</td> </td>
@@ -591,15 +557,12 @@ function renderTexts(errors = {}) {
vacancies.filter(v => generatedTexts[v.id]).forEach(v => { vacancies.filter(v => generatedTexts[v.id]).forEach(v => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'item'; div.className = 'item';
const sent = sentTexts.has(v.id);
div.innerHTML = ` div.innerHTML = `
<div class="title">${v.title} — ${v.company || ''}</div> <div class="title">${v.title} — ${v.company || ''}</div>
<textarea data-id="${v.id}">${escapeHtml(generatedTexts[v.id])}</textarea> <textarea data-id="${v.id}">${escapeHtml(generatedTexts[v.id])}</textarea>
<div class="toolbar"> <div class="toolbar">
${sent <button class="secondary" onclick="openVacancy('${v.id}')">Открыть в браузере</button>
? '<button class="secondary" disabled>Отправлено ✓</button>' <button class="secondary" onclick="markDone('${v.id}')">Отправил</button>
: `<button class="secondary" onclick="sendVacancy('${v.id}')">Отправить отклик</button>
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть в браузере</button>`}
<span class="muted" id="note-${v.id}"></span> <span class="muted" id="note-${v.id}"></span>
</div>`; </div>`;
list.appendChild(div); list.appendChild(div);
@@ -619,92 +582,6 @@ function escapeHtml(s) {
} }
// ---------- Открытие, проверка и отправка ---------- // ---------- Открытие, проверка и отправка ----------
async function openSendModal(id) {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
const v = vacancies.find(x => x.id === id);
if (!v) return;
sendModalId = id;
document.getElementById('send-vacancy-title').textContent = `${v.title} — ${v.company || ''}`;
document.getElementById('send-letter').value = '';
document.getElementById('send-letter').disabled = true;
document.getElementById('btn-send-letter').disabled = true;
document.getElementById('send-modal-note').textContent = '';
document.getElementById('send-modal').classList.remove('hidden');
const cached = generatedTexts[id];
if (cached) {
// Текст уже сгенерирован ранее — показываем сразу
document.getElementById('send-letter').value = cached;
document.getElementById('send-letter').disabled = false;
document.getElementById('btn-send-letter').disabled = false;
document.getElementById('send-loading').classList.add('hidden');
return;
}
// Генерируем текст (прелоадер виден)
document.getElementById('send-loading').classList.remove('hidden');
try {
const data = await api(`/api/accounts/${currentAccount}/generate`, {
method: 'POST', body: JSON.stringify({ ids: [id] }),
});
document.getElementById('send-loading').classList.add('hidden');
if (data.texts && data.texts[id]) {
generatedTexts[id] = data.texts[id];
document.getElementById('send-letter').value = data.texts[id];
document.getElementById('send-letter').disabled = false;
document.getElementById('btn-send-letter').disabled = false;
renderTexts();
} else if (data.errors && data.errors[id]) {
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + data.errors[id];
}
} catch (e) {
document.getElementById('send-loading').classList.add('hidden');
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + e.message;
}
}
function closeSendModal() {
document.getElementById('send-modal').classList.add('hidden');
}
async function sendFromModal() {
if (!currentAccount || !sendModalId) return;
const message = document.getElementById('send-letter').value;
const btn = document.getElementById('btn-send-letter');
const note = document.getElementById('send-modal-note');
btn.disabled = true; note.textContent = 'Отправляю...';
try {
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
method: 'POST', body: JSON.stringify({ id: sendModalId, message }),
});
if (data.screenshot) {
showShotModal(sendModalId, data.screenshot, data.note || 'Отклик отправлен', true);
sentTexts.add(sendModalId);
const v = vacancies.find(x => x.id === sendModalId);
if (v) v.responded = true;
renderVacancies();
renderTexts();
closeSendModal();
} else {
note.textContent = data.note || 'Не удалось отправить отклик';
btn.disabled = false;
}
} catch (e) {
note.textContent = 'Ошибка: ' + e.message;
btn.disabled = false;
}
}
function showShotModal(id, screenshot, note, sent) {
currentShotId = id;
document.getElementById('shot-img').src = screenshot + '?t=' + Date.now();
document.getElementById('shot-note').textContent = note;
document.getElementById('send-note').textContent = '';
document.getElementById('btn-send').style.display = sent ? 'none' : '';
document.getElementById('btn-done').style.display = sent ? 'none' : '';
document.getElementById('shot-modal').classList.remove('hidden');
}
async function openVacancy(id) { async function openVacancy(id) {
const ta = document.querySelector(`textarea[data-id="${id}"]`); const ta = document.querySelector(`textarea[data-id="${id}"]`);
const message = ta ? ta.value : (generatedTexts[id] || ''); const message = ta ? ta.value : (generatedTexts[id] || '');
@@ -716,7 +593,12 @@ async function openVacancy(id) {
}); });
if (note) note.textContent = data.note || ''; if (note) note.textContent = data.note || '';
if (data.screenshot) { if (data.screenshot) {
showShotModal(id, data.screenshot, data.note || '', false); currentShotId = id;
document.getElementById('shot-img').src = data.screenshot + '?t=' + Date.now();
document.getElementById('shot-note').textContent = data.note || '';
document.getElementById('send-note').textContent = '';
document.getElementById('btn-send').disabled = false;
document.getElementById('shot-modal').classList.remove('hidden');
} else { } else {
alert(data.note || 'Не удалось открыть вакансию'); alert(data.note || 'Не удалось открыть вакансию');
} }
@@ -726,39 +608,11 @@ async function openVacancy(id) {
} }
} }
async function sendVacancy(id) {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
const ta = document.querySelector(`textarea[data-id="${id}"]`);
const message = ta ? ta.value : (generatedTexts[id] || '');
const note = document.getElementById(`note-${id}`);
if (note) { note.textContent = 'Отправляю отклик...'; }
try {
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
method: 'POST', body: JSON.stringify({ id, message }),
});
if (note) note.textContent = data.note || '';
if (data.screenshot) {
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
sentTexts.add(id);
const v = vacancies.find(x => x.id === id);
if (v) v.responded = true;
renderVacancies();
renderTexts();
} else {
alert(data.note || 'Не удалось отправить отклик');
}
} catch (e) {
if (note) note.textContent = 'Ошибка: ' + e.message;
alert('Ошибка: ' + e.message);
}
}
function closeShot() { function closeShot() {
document.getElementById('shot-modal').classList.add('hidden'); document.getElementById('shot-modal').classList.add('hidden');
} }
async function confirmSend() { async function confirmSend() {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
const btn = document.getElementById('btn-send'); const btn = document.getElementById('btn-send');
const note = document.getElementById('send-note'); const note = document.getElementById('send-note');
btn.disabled = true; note.textContent = 'Отправляю...'; btn.disabled = true; note.textContent = 'Отправляю...';
@@ -770,9 +624,7 @@ async function confirmSend() {
if (data.ok) { if (data.ok) {
const v = vacancies.find(x => x.id === currentShotId); const v = vacancies.find(x => x.id === currentShotId);
if (v) v.responded = true; if (v) v.responded = true;
sentTexts.add(currentShotId);
renderVacancies(); renderVacancies();
renderTexts();
} }
} catch (e) { } catch (e) {
note.textContent = 'Ошибка: ' + e.message; note.textContent = 'Ошибка: ' + e.message;
@@ -781,15 +633,12 @@ async function confirmSend() {
} }
async function markDone(id) { async function markDone(id) {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
await api(`/api/accounts/${currentAccount}/done`, { await api(`/api/accounts/${currentAccount}/done`, {
method: 'POST', body: JSON.stringify({ id }), method: 'POST', body: JSON.stringify({ id }),
}); });
const v = vacancies.find(x => x.id === id); const v = vacancies.find(x => x.id === id);
if (v) v.responded = true; if (v) v.responded = true;
sentTexts.add(id);
renderVacancies(); renderVacancies();
renderTexts();
const note = document.getElementById(`note-${id}`); const note = document.getElementById(`note-${id}`);
if (note) note.textContent = 'Отмечено как отправленное'; if (note) note.textContent = 'Отмечено как отправленное';
} }
-4
View File
@@ -29,9 +29,6 @@
button:hover { background: var(--accent-hover); } button:hover { background: var(--accent-hover); }
.error { color: var(--red); font-size: 13px; text-align: center; margin-top: 10px; .error { color: var(--red); font-size: 13px; text-align: center; margin-top: 10px;
min-height: 20px; } min-height: 20px; }
.link { text-align: center; margin-top: 14px; font-size: 13px; }
.link a { color: var(--accent); text-decoration: none; }
.link a:hover { color: var(--accent-hover); }
</style> </style>
</head> </head>
<body> <body>
@@ -42,7 +39,6 @@
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password"> <input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
<button onclick="doLogin()">Войти</button> <button onclick="doLogin()">Войти</button>
<div id="msg" class="error"></div> <div id="msg" class="error"></div>
<div class="link">Нет аккаунта? <a href="/register">Зарегистрироваться</a></div>
</div> </div>
<script> <script>
async function doLogin() { async function doLogin() {
-72
View File
@@ -1,72 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Регистрация — hh.ru автоотклик</title>
<style>
:root {
--bg: #0f1117; --card: #1a1d27; --border: #2a2f3a; --text: #e5e7eb;
--muted: #9ca3af; --accent: #3b82f6; --accent-hover: #60a5fa;
--input-bg: #12141c; --red: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); font-size: 14px;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; }
.login-box { background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 32px; width: 100%; max-width: 360px; }
.login-box h1 { font-size: 18px; text-align: center; margin-bottom: 4px; }
.login-box .muted { text-align: center; font-size: 13px; margin-bottom: 20px; }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: 8px; font-size: 14px; font-family: inherit;
background: var(--input-bg); color: var(--text); margin-bottom: 12px; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; padding: 10px; border: none; border-radius: 8px;
cursor: pointer; font-size: 14px; background: var(--accent); color: #fff;
font-weight: 500; }
button:hover { background: var(--accent-hover); }
.error { color: var(--red); font-size: 13px; text-align: center; margin-top: 10px;
min-height: 20px; }
.link { text-align: center; margin-top: 14px; font-size: 13px; }
.link a { color: var(--accent); text-decoration: none; }
.link a:hover { color: var(--accent-hover); }
</style>
</head>
<body>
<div class="login-box">
<h1>hh.ru автоотклик</h1>
<p class="muted">Регистрация</p>
<input type="text" id="username" placeholder="Логин (3-30 символов)" autocomplete="username" autofocus>
<input type="password" id="password" placeholder="Пароль (мин. 6 символов)" autocomplete="new-password">
<input type="password" id="confirm" placeholder="Повторите пароль" autocomplete="new-password">
<button onclick="doRegister()">Зарегистрироваться</button>
<div id="msg" class="error"></div>
<div class="link">Уже есть аккаунт? <a href="/login">Войти</a></div>
</div>
<script>
async function doRegister() {
const msg = document.getElementById('msg');
msg.textContent = '';
try {
const resp = await fetch('/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
confirm: document.getElementById('confirm').value,
}),
});
const data = await resp.json();
if (!resp.ok) { msg.textContent = data.error || 'Ошибка регистрации'; return; }
window.location = '/';
} catch (e) { msg.textContent = 'Ошибка сети'; }
}
document.getElementById('confirm').addEventListener('keydown', e => {
if (e.key === 'Enter') doRegister();
});
</script>
</body>
</html>
+9 -96
View File
@@ -9,7 +9,7 @@
показывается скриншотом, «Отправить» нажимается по подтверждению человека. показывается скриншотом, «Отправить» нажимается по подтверждению человека.
Запуск: .venv/bin/python webui.py Запуск: .venv/bin/python webui.py
Открыть: http://localhost:18091 Открыть: http://localhost:5000
""" """
import io import io
import json import json
@@ -20,14 +20,9 @@ import time
from datetime import date from datetime import date
from functools import wraps from functools import wraps
from dotenv import load_dotenv
# Загружаем .env (LLM_API_KEY, SESSION_SECRET, PORT, HH_HEADLESS и т.д.)
load_dotenv()
from flask import (Flask, jsonify, render_template, request, session, from flask import (Flask, jsonify, render_template, request, session,
redirect, url_for, send_file) redirect, url_for, send_file)
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash
from hh_search import (search_vacancies, fetch_vacancy_details, fetch_my_resumes, from hh_search import (search_vacancies, fetch_vacancy_details, fetch_my_resumes,
parse_cookies, get_proxies) parse_cookies, get_proxies)
@@ -173,15 +168,6 @@ def save_progress(acc_dir, progress):
write_json(os.path.join(acc_dir, "progress.json"), progress) write_json(os.path.join(acc_dir, "progress.json"), progress)
def mark_responded(acc_dir, vid):
"""Добавляет вакансию в список откликнутых."""
progress = get_progress(acc_dir)
responded = set(progress.get("responded", []))
responded.add(vid)
progress["responded"] = sorted(responded)
save_progress(acc_dir, progress)
def get_cache(user, account): def get_cache(user, account):
with _cache_lock: with _cache_lock:
return _cache.setdefault((user, account), {}) return _cache.setdefault((user, account), {})
@@ -222,47 +208,6 @@ def login_page():
return render_template("login.html") return render_template("login.html")
@app.route("/register", methods=["GET", "POST"])
def register_page():
"""Простая регистрация: логин + пароль. Отключается через ALLOW_REGISTRATION=0."""
if os.environ.get("ALLOW_REGISTRATION", "1") != "1":
if request.method == "POST":
return jsonify({"error": "Регистрация отключена"}), 403
return redirect(url_for("login_page"))
if request.method == "POST":
data = request.get_json(force=True)
username = (data.get("username") or "").strip()
password = data.get("password") or ""
confirm = data.get("confirm") or ""
# Проверки
if not re.match(r"^[a-zA-Z0-9_.-]{3,30}$", username):
return jsonify({"error": "Логин: 3-30 символов, буквы/цифры/точка/дефис/подчёркивание"}), 400
if len(password) < 6:
return jsonify({"error": "Пароль: минимум 6 символов"}), 400
if password != confirm:
return jsonify({"error": "Пароли не совпадают"}), 400
users = load_users()
if username in users:
return jsonify({"error": "Логин уже занят"}), 409
limit = int(os.environ.get("REGISTER_LLM_LIMIT", "50") or 0)
users[username] = {
"password_hash": generate_password_hash(password),
"llm_limit": limit,
"llm_usage": {},
"created": date.today().isoformat(),
"registered": True,
}
save_users(users)
os.makedirs(os.path.join(USERS_DIR, username, "accounts"), exist_ok=True)
session["user"] = username # сразу входим
return jsonify({"ok": True, "limit": limit})
return render_template("register.html")
@app.route("/logout") @app.route("/logout")
def logout(): def logout():
session.clear() session.clear()
@@ -548,40 +493,6 @@ def api_open(name):
return jsonify({"error": f"Ошибка браузера: {e}"}), 500 return jsonify({"error": f"Ошибка браузера: {e}"}), 500
@app.route("/api/accounts/<name>/send-now", methods=["POST"])
@login_required
def api_send_now(name):
"""Одно-кликовый режим: открывает вакансию, вставляет текст, жмёт «Отправить»,
делает скриншот результата и помечает вакансию как откликнутую."""
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
data = request.get_json(force=True)
vid = data.get("id")
message = data.get("message") or ""
cfg = read_json(os.path.join(acc_dir, "config.json"), {})
resume_id = cfg.get("resume_id")
cache = get_cache(current_user(), name)
vacancy = cache.get(vid)
if not vacancy:
return jsonify({"error": "Вакансия не найдена в кэше — обновите поиск"}), 404
try:
shot_dir = os.path.join(acc_dir, "screenshots")
ok, note, shot = browser_open(acc_dir, current_user(), name, vacancy, message,
resume_id=resume_id, screenshot_dir=shot_dir,
submit=True)
if ok:
mark_responded(acc_dir, vid)
return jsonify({
"ok": ok,
"note": note,
"screenshot": f"/api/accounts/{name}/screenshot/{vid}" if shot else None,
})
except Exception as e:
return jsonify({"error": f"Ошибка браузера: {e}"}), 500
@app.route("/api/accounts/<name>/send", methods=["POST"]) @app.route("/api/accounts/<name>/send", methods=["POST"])
@login_required @login_required
def api_send(name): def api_send(name):
@@ -619,7 +530,11 @@ def api_done(name):
return jsonify({"error": "Аккаунт не найден"}), 404 return jsonify({"error": "Аккаунт не найден"}), 404
data = request.get_json(force=True) data = request.get_json(force=True)
vid = data.get("id") vid = data.get("id")
mark_responded(acc_dir, vid) progress = get_progress(acc_dir)
responded = set(progress.get("responded", []))
responded.add(vid)
progress["responded"] = sorted(responded)
save_progress(acc_dir, progress)
return jsonify({"ok": True}) return jsonify({"ok": True})
@@ -634,7 +549,5 @@ def api_reset(name):
if __name__ == "__main__": if __name__ == "__main__":
port = int(os.environ.get("PORT", "18091")) print("Откройте http://localhost:5000")
host = os.environ.get("HOST", "127.0.0.1") app.run(host="127.0.0.1", port=5000, debug=False)
print(f"Откройте http://localhost:{port}")
app.run(host=host, port=port, debug=False)