// ==UserScript== // @name HH Apply Assistant // @namespace https://github.com/tgeruzov/hh-apply-assistant // @version 0.2.32 // @author Timur Geruzov // @description Автоматические отклики на вакансии hh.ru из поиска. Вакансии с тестами и анкетами откладывает в очередь для ручного отклика // @license GPL-3.0-only // @homepageURL https://github.com/tgeruzov/hh-apply-assistant // @supportURL https://github.com/tgeruzov/hh-apply-assistant/issues // @updateURL https://raw.githubusercontent.com/tgeruzov/hh-apply-assistant/main/hh-apply-assistant.user.js // @downloadURL https://raw.githubusercontent.com/tgeruzov/hh-apply-assistant/main/hh-apply-assistant.user.js // @match https://*.hh.ru/search/vacancy* // @match https://*.hh.ru/vacancy/* // @match https://*.hh.ru/applicant/vacancy_response* // @match https://*.hh.ru/article/* // @match https://*.hh.ru/vrsurvey/* // @noframes // @grant none // @run-at document-idle // ==/UserScript== // Contents. Every section header carries its number: search for "E14." to get to the main // loop. G: shared top of the file, E: part 1, the engine, H: part 2, the HUD (H3.x are // sections of its stylesheet). A new section gets a number here and in its header. // G1 Global Shared Configuration Constants // G2 Global Shared Utilities // Part 1: Automation Engine // E1 Constants, Selectors & Defaults // E2 Event Bus // E3 Storage Layer // E4 Utilities // E5 Error Reporting, Logging & Telemetry // E5.1 Watchdog Progress Tracking // E5.2 Daily Counters Domain // E6 Configuration // E6.1 Pace // E7 Manual Queue Domain // E8 State Accessors // E8.1 Attempts & Blacklist Tracking (Circuit Breaker) // E9 Concurrency & Instance Locks // E10 Run Lifecycle & Guards // E11 DOM Queries & Form Automation // E11.1 Form Input Dispatch // E11.2 Direct element click // E12 Page Classification & Security Anomalies // E13 Application Flow & Scenarios // E14 Main Execution Loop // E15 Watchdog & Recovery // E16 Engine API // E17 Bootstrap & Global Binding // Part 2: Floating HUD // H1 Utilities & Pure Functions // H2 SVG Icons // H3 Shadow DOM Stylesheet // H3.1 Design Tokens // H3.2 Root Positioning // H3.3 Pill // H3.4 Quick Action Button // H3.5 Flyout Panel // H3.5.1 Island Header // H3.6 Bottom Navigation & Action Dock // H3.7 Tab Panels // H3.8 Queue & Log Containers // H3.9 Error Card // H3.10 Tooltip // H3.11 Settings Controls // H3.12 Switch & Cover Letter // H4 Web Component Implementation (Closed Shadow DOM) // H4.1 Public API // H4.2 DOM Assembly // H4.3 Drag & Drop with Pointer Capture API // H4.4 State Synchronizers // H5 Custom Element Registration & Mounting Helper // --- G1. Global Shared Configuration Constants --- const MAX_DAILY_LIMIT = 200; const MAX_COVER_LENGTH = 5000; // Set by the engine (part 1) and read by the HUD (part 2). The userscript manager wraps // the file in a function, so it is not visible to the page. let hhaEngine = null; // --- G2. Global Shared Utilities --- function clamp(val, min, max) { const num = Number(val); if (isNaN(num)) return min; return Math.max(min, Math.min(max, num)); } const collapseSpaces = (s) => String(s || '').replace(/\s+/g, ' ').trim(); // At most n UTF-16 units, without cutting a surrogate pair in half: half an emoji turns // into a replacement sign once the text is sent. function sliceSafe(s, n) { const str = String(s ?? ''); if (str.length <= n) return str; const last = str.charCodeAt(n - 1); return str.slice(0, last >= 0xD800 && last <= 0xDBFF ? n - 1 : n); } // A stored value as text: strings and numbers only. Anything else in storage (an object // written by another script or by hand) would show up as "[object Object]". const asText = (v) => (typeof v === 'string' || typeof v === 'number' ? String(v) : ''); const formatCleanSalary = (raw) => { if (!raw) return ''; return collapseSpaces( String(raw) // [а-яё], not \w: without the u flag \w is Latin only, and "за 1 смену" left a stray "у". .replace(/(?:до\s+вычета\s+(?:налогов|ндфл)|на\s+руки|за\s+месяц|за\s+\d+\s+смен[а-яё]*|после\s+вычета|gross|net)/gi, '') .replace(/[,.]\s*$/g, '') ); }; function cleanVid(vid) { return vid ? String(vid).trim().replace(/^v_/i, '') : ''; } // Russian plural: pluralRu(5, 'отклик', 'отклика', 'откликов') gives 'откликов'. function pluralRu(n, one, few, many) { const mod10 = n % 10; const mod100 = n % 100; if (mod10 === 1 && mod100 !== 11) return one; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few; return many; } // "4 с" with a narrow no-break space: the unit stays next to the number and never // wraps to the next line on its own. A minute or more is "1:45": "105 с" is hard to read // and does not fit the pill. const formatSeconds = (n) => (n >= 60 ? `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}` : `${n}\u202Fс`); function formatTime(dOrTs = new Date()) { const d = dOrTs instanceof Date ? dOrTs : (dOrTs ? new Date(dOrTs) : new Date()); const pad = (n) => String(n).padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } // An address for the log and the error report: the page, and the number of a results // page, never the query. Links from the search carry the user's search text // (/vacancy/1?query=...), and a log goes into public issues. function toLogUrl(rawUrl) { try { const u = new URL(rawUrl); const page = u.pathname.startsWith('/search/vacancy') ? u.searchParams.get('page') : null; return `${u.origin}${u.pathname}${page ? `?page=${page}` : ''}`; } catch (_) { return ''; } } /** * ============================================================================ * Part 1: Automation Engine (Headless Core) * ============================================================================ */ (function () { 'use strict'; // --- E1. Constants, Selectors & Defaults --- // GM_info is available to every userscript, including @grant none; 'dev' is for runs outside a manager. const VERSION = (typeof GM_info !== 'undefined' && GM_info.script?.version) || 'dev'; // This file's address as stack traces show it (Tampermonkey's userscript.html?name=..., // the mock's /hha.user.js), to tell this script's errors from the page's. Empty when the // stack has no address of its own or it is the page itself (a script injected inline). const OWN_SCRIPT_URL = (() => { try { const m = /((?:https?|chrome-extension|moz-extension|safari-web-extension|blob):[^\s()@]+?):\d+:\d+/.exec(new Error().stack || ''); const url = m ? m[1] : ''; const page = String(globalThis.location?.href || '').split('#')[0]; return url && url !== page ? url : ''; } catch (_) { return ''; } })(); const SELECTORS = { modal: '[data-qa="bottom-sheet-content"], [data-qa="vacancy-response-popup-form"], [data-qa*="modal" i], [class*="modal" i], [data-qa*="popup" i], [class*="popup" i], [role="dialog"]', modalClose: '[data-qa="vacancy-response-popup-close"], [data-qa*="close" i], button[aria-label*="закрыть" i]', applyBtn: '[data-qa="vacancy-serp__vacancy_response"]', vacancyApply: '[data-qa="vacancy-response-link-bottom"], [data-qa="vacancy-response-link-top"], a[data-qa*="vacancy-response-link"]', attachCoverBtn: '[data-qa="responded-success-attach-cover-letter"]', attachCoverInModal: '[data-qa="responded-success-attach-cover-letter"], [data-qa="add-cover-letter"], [data-qa="vacancy-response-letter-toggle"]', letterTextarea: 'textarea[data-qa="vacancy-response-popup-form-letter-input"], textarea[name="text"], textarea[name="coverLetter"]', letterSubmit: '[data-qa="vacancy-response-letter-submit"], [data-qa*="response-submit" i]', responseChat: '[data-qa="vacancy-response-link-view-topic"]', nativeWrapper: '[data-qa="textarea-native-wrapper"]', relocationBtn: '[data-qa="relocation-warning-confirm"]', rejectWarning: '[data-qa="response-reject-warning"]', vacancyLink: 'a[data-qa="serp-item__title"], a[data-qa="vacancy-serp__vacancy-title"]', // No tag: live hh.ru made the search card an
(snapshot of 2026-10-20), and the // old div[...] found no card at all there. vacancyCard: '[data-qa="vacancy-serp__vacancy"], .vacancy-serp-item', pagerNext: '[data-qa="pager-next"]' }; const SELECTOR_METADATA = { applyBtn: { name: 'Кнопка "Откликнуться" в поисковой выдаче' }, vacancyApply: { name: 'Кнопка "Откликнуться" на странице вакансии' }, attachCoverBtn: { name: 'Кнопка "Прикрепить сопроводительное" после отклика' }, attachCoverInModal: { name: 'Переключатель письма в модальном окне' }, letterTextarea: { name: 'Поле ввода текста письма' }, letterSubmit: { name: 'Кнопка отправки формы отклика' }, relocationBtn: { name: 'Подтверждение предупреждения о релокации' }, vacancyCard: { name: 'Карточка вакансии в выдаче' }, pagerNext: { name: 'Кнопка "Дальше" (пагинация поиска)' }, rejectWarning: { name: 'Предупреждение о возможном отказе' }, responseChat: { name: 'Ссылка на чат после отклика' }, vacancyLink: { name: 'Ссылка на вакансию в выдаче' } }; const STORAGE_PREFIX = 'hh_apply_assistant_s1_'; const KEYS = { settings: STORAGE_PREFIX + 'settings', isRunning: STORAGE_PREFIX + 'is_active', returnUrl: STORAGE_PREFIX + 'return_url', history: STORAGE_PREFIX + 'processed_ids', trapLock: STORAGE_PREFIX + 'trap_lock', instanceLock: STORAGE_PREFIX + 'instance_lock', lastAttempt: STORAGE_PREFIX + 'last_attempt_id', manualList: STORAGE_PREFIX + 'manual_queue', tabId: STORAGE_PREFIX + 'tab_id', pendingVacancyMeta: STORAGE_PREFIX + 'pending_vacancy_meta', blacklist: STORAGE_PREFIX + 'blacklist_v1', appliedHistory: STORAGE_PREFIX + 'applied_v1', queuedHistory: STORAGE_PREFIX + 'queued_v1', skippedHistory: STORAGE_PREFIX + 'skipped_v1', attempts: STORAGE_PREFIX + 'attempts_v1', dailyCounters: STORAGE_PREFIX + 'daily_counters', lastCommittedVid: STORAGE_PREFIX + 'last_committed_vid', captchas: STORAGE_PREFIX + 'captchas_v1', schema: STORAGE_PREFIX + 'schema', // The flag of the first migration in 0.2.26-0.2.29. Kept and set: those versions read it. skipsReset0226: STORAGE_PREFIX + 'skips_reset_0226', logBuffer: 'hha:log_buffer', watchdogStallCount: 'hha:watchdog_stall_count', watchdogStallVid: 'hha:watchdog_stall_vid', hiddenSince: 'hha:hidden_since', skipAlertShown: 'hha:skip_alert_shown', sessionProcessedTotal: 'hha:session_processed_total', sessionAnomalousSkips: 'hha:session_anomalous_skips', runStats: 'hha:run_stats', runVisits: 'hha:run_visits', runPages: 'hha:run_pages', noButtonStreak: 'hha:no_button_streak', visibilityStreak: 'hha:visibility_streak', inflight: 'hha:inflight', lateApplied: 'hha:late_applied', paceState: 'hha:pace_state', responseRest: 'hha:response_rest', paceTest: 'hha:pace_test', lastRun: 'hha:last_run' }; const MAX_VACANCY_ATTEMPTS = 3; const BLACKLIST_TTL = 24 * 60 * 60 * 1000; // 24 hours const PAGE_WATCHDOG_TIMEOUT = 15000; // 15 seconds const WATCHDOG_STALL_TIMEOUT = 60000; // 60 seconds stall timeout const SKIP_ALERT_RATIO = 0.7; // 70% threshold for skip rate alert const MAX_LOG_ENTRIES = 1000; // A watchdog tick normally comes every second. A longer gap means the page did not run // at all: the computer slept, or the browser paused the background tab. const TIMER_GAP_MS = 30000; // A tick this late means the page's timers were held back, not that the run hung. const TICK_LATE_MS = 3000; // Text rules of the detectors, in one place so they can be narrowed by the decision log. // Several of them look alike on purpose: each one is tuned to where it is applied. const TEXT_RULES = Object.freeze({ // Access and page state. inaccessible: /(?:вам\s+недоступна\s+эта\s+вакансия|войдите\s+как\s+пользователь[,\s]+у\s+которого\s+есть\s+доступ|вакансия\s+(?:закрыта|в\s+архиве|удалена|не\s+найдена)|эта\s+вакансия\s+была\s+удалена|похоже[,\s]+этой\s+вакансии\s+больше\s+нет)/i, captcha: /(?:подтвердите,?\s*что\s*вы\s*не\s*робот|введите\s*символы\s*с\s*картинки|вы\s+не\s+робот|not\s+a\s+robot|необычн[а-яё]*\s+активн|unusual\s+(?:activity|traffic))/i, dailyLimit: /(?:исчерпали\s+лимит\s+откликов|не\s+более\s+200\s+откликов|в\s+течение\s+24\s+часов\s+можно\s+совершить\s+не\s+более|лимит\s+откликов[,\s]+попробуйте\s+отправить\s+отклик\s+позднее|лимит\s+откликов.*попробуйте|24\s+часов?\s+можно\s+совершить\s+не\s+более|вы\s+исчерпали\s+лимит|daily\s+application\s+limit|reached\s+(?:the\s+)?limit\s+of\s+(?:200\s+)?applications)/i, // Document title; a bare 429/503 would also match a salary like "смена 4290". rateLimitTitle: /(?:^\s*(?:429|503)\b|(?:error|ошибка)\s*(?:429|503)\b|доступ\s+ограничен|too\s+many\s+requests|service\s+(?:temporarily\s+)?unavailable)/i, rateLimitPage: /(?:слишком\s*много\s*запросов|429\s*Too\s*Many\s*Requests|503\s*Service\s*Unavailable|доступ\s*(?:временно\s*)?ограничен|access\s*(?:temporarily\s*)?denied|error\s+429|error\s+503)/i, // Reasons to skip or queue a vacancy. reject: /(?:не\s*соответствует(?:\s*требованиям)?|не\s*подходит|(?:^|[\s.,!?:;\u00ab\u00bb'"()\u2014\u2013-])отказ(?:а|у|ом|ы)?(?=[\s.,!?:;\u00ab\u00bb'"()\u2014\u2013-]|$)|reject|warning)/i, testPage: /(?:необходимо\s+пройти\s+тест|ответьте\s+на\s+(?:следующие\s+)?вопрос|тестовое\s+задание\s*работодателя|анкета\s+работодателя|пройти\s+опрос)/i, resumeHidden: /резюме\s*скрыто|resume\s*is\s*hidden/i, // "Чтобы откликнуться на эту вакансию, поменяйте видимость резюме на 'Видно всем // работодателям, зарегистрированным на hh.ru'" (2026-10-18). It is about the user's // resume, not the vacancy: in a live run it was on every response dialog. 0.2.23 took it // for a vacancy to skip and skipped a whole run. resumeVisibility: /(?:поменяйте|измените)\s+видимость\s+резюме/i, // A request to answer, not a job description like "Ответы на вопросы клиентов". // Real question fields are checked separately, so their heading need not match. modalTest: /анкета|questionnaire|(?:ответьте|ответить)\s+на\s+(?:(?:следующие|несколько)\s+)?вопрос[а-яё]*|(?:пройти|пройдите)\s+тест|take\s+(?:a|the)\s+test/i, modalCaptcha: /капч[аеы]|captcha|recaptcha|smartcaptcha/i, modalRateLimit: /слишком\s*много\s*запросов|доступ\s*ограничен|rate\s*limit|blocked/i, resumeChoice: /(?:выберите|выбор)\s+(?:подходящее\s+)?резюме|резюме\s+для\s+отклика/i, // Relocation warning: its text and the confirm buttons. relocationText: /откликаетесь\s+на\s+вакансию\s+в\s+другой\s+стране|в\s+другой\s+стране/i, relocationConfirm: /^вс[её]\s*равно(?:\s*откликнуться)?$/i, relocationConfirmPlain: /^(?:откликнуться|подтвердить)$/i, // A response exists. The sets differ by scope: the page text, a modal, the vacancy // page, a search card, and the visible text logged for an unconfirmed submit. alreadyApplied: /(?:вы уже откликались|отклик уже отправлен|already applied)/i, sentInModal: /(?:отклик отправлен|вы откликнулись|резюме доставлено|резюме отправлено|response sent|applied successfully)/i, sentOnPage: /(?:отклик отправлен|вы уже откликались|вы откликнулись|резюме доставлено)/i, serpCardApplied: /(?:вы откликнулись|резюме доставлено|отклик отправлен)/i, sentAnyVisible: /(?:отклик отправлен|вы откликнулись|резюме доставлено|резюме отправлено|вы уже откликались)/i }); const ACTION_TIMINGS = { delay: [3500, 6500], action: [250, 500] }; const DEFAULT_COVER_TEXT = 'Здравствуйте! Меня заинтересовала ваша вакансия. Ознакомьтесь, пожалуйста, с моим резюме.'; const DEFAULTS = { coverText: DEFAULT_COVER_TEXT, useCover: false, coverNeedsReview: false, skipHidden: true, }; // --- E2. Event Bus --- class EventEmitter { constructor() { this._e = Object.create(null); } on(event, fn) { if (typeof fn !== 'function') return () => {}; (this._e[event] = this._e[event] || []).push(fn); return () => this.off(event, fn); } off(event, fn) { if (!this._e[event]) return; if (!fn) { delete this._e[event]; return; } this._e[event] = this._e[event].filter(h => h !== fn); if (!this._e[event].length) delete this._e[event]; } emit(event, ...args) { const handlers = this._e[event]; if (!handlers) return; for (const h of handlers.slice()) { try { h(...args); } catch (err) { console.error(`[HH] Listener error for "${event}":`, err); } } } removeAllListeners(event) { if (event) delete this._e[event]; else this._e = Object.create(null); } } const events = new EventEmitter(); // --- E3. Storage Layer --- function getNativeStore(type) { try { return globalThis[type === 'local' ? 'localStorage' : 'sessionStorage'] || null; } catch (_) { return null; } } function storeGet(type, key) { try { const s = getNativeStore(type); return s ? s.getItem(key) : null; } catch (_) { return null; } } // The log is the biggest key in localStorage and the least needed one: when the storage // is full, it gives way to the queue, the counters and the lists, down to its last entries. const LOG_KEEP_ON_FULL = 100; function freeLogSpace(s) { try { const list = JSON.parse(s.getItem(KEYS.logBuffer) || '[]'); if (!Array.isArray(list) || list.length <= LOG_KEEP_ON_FULL) return false; s.setItem(KEYS.logBuffer, JSON.stringify(list.slice(-LOG_KEEP_ON_FULL))); return true; } catch (_) { return false; } } let storageFailureLogged = false; function storeSet(type, key, val) { let s = null; try { s = getNativeStore(type); if (!s) return false; s.setItem(key, String(val)); return true; } catch (e) { if (s && type === 'local' && key !== KEYS.logBuffer && freeLogSpace(s)) { try { s.setItem(key, String(val)); return true; } catch (_) {} } console.warn(`[HH] Storage write failed on ${type}Storage (key: ${key}):`, e); // Once per page: a write that did not go through is lost data, and the log says which. if (key !== KEYS.logBuffer && !storageFailureLogged) { storageFailureLogged = true; hhaLog('warn', 'storage_write_failed', { storage: type, key, error: String(e && e.name || e) }); } return false; } } function storeRemove(type, key) { try { const s = getNativeStore(type); if (s) s.removeItem(key); return true; } catch (_) { return false; } } function isLocalBlocked() { try { const s = getNativeStore('local'); if (!s) return true; s.setItem('__hha_test__', '1'); s.removeItem('__hha_test__'); return false; } catch (_) { return true; } } const storage = { localGet: (k) => storeGet('local', k), localSet: (k, v) => storeSet('local', k, v), localRemove: (k) => storeRemove('local', k), sessionGet: (k) => storeGet('session', k), sessionSet: (k, v) => storeSet('session', k, v), sessionRemove: (k) => storeRemove('session', k), isLocalBlocked }; // --- E4. Utilities --- const toNum = (v, fallback) => { const n = Number(v); return Number.isNaN(n) ? fallback : n; }; const randBetween = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; const parseJson = (raw, fallback) => { if (!raw || typeof raw !== 'string') return fallback; try { return JSON.parse(raw); } catch (_) { return fallback; } }; // A local address counts only on a local page, that is in a mock of hh.ru. On hh.ru a // stored link to localhost (the queue is in localStorage, open to the page's scripts) // would open a local service, and an apply button with one would be followed. const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1)$/i; function toSafeHhUrl(rawUrl) { if (!rawUrl) return ''; try { const base = globalThis.location?.href || 'https://hh.ru'; const u = new URL(String(rawUrl), base); const isHh = /(^|\.)hh\.ru$/i.test(u.hostname); const isLocal = LOCAL_HOST_RE.test(u.hostname) && LOCAL_HOST_RE.test(globalThis.location?.hostname || ''); return (['http:', 'https:'].includes(u.protocol) && (isHh || isLocal)) ? u.href : ''; } catch (_) { return ''; } } function toSearchListUrl(rawUrl) { const safe = toSafeHhUrl(rawUrl); return safe && new URL(safe).pathname.startsWith('/search/vacancy') ? safe : ''; } // The results page as hh.ru shows it, from 1 (the address counts from 0: page=1 is the // second page). Null away from the search. The log gets this number, never the address // with the search filters. function searchPageNumber(rawUrl) { try { const u = new URL(rawUrl); if (!u.pathname.startsWith('/search/vacancy')) return null; const n = Number(u.searchParams.get('page') || 0); return Number.isInteger(n) && n >= 0 ? n + 1 : null; } catch (_) { return null; } } // --- E5. Error Reporting, Logging & Telemetry --- const SENSITIVE_KEYS = new Set(['covertext', 'letter', 'text', 'cover', 'name', 'phone', 'email', 'fio', 'resume', 'applicant', 'applicantname', 'covermessage', 'chatmessage', 'usermessage']); function sanitizeLogData(data) { if (!data || typeof data !== 'object') return {}; const clean = {}; for (const [k, v] of Object.entries(data)) { if (v === undefined || v === null) continue; if (SENSITIVE_KEYS.has(k.toLowerCase())) continue; // Never log cover letters or personal data if (typeof v === 'object' && !Array.isArray(v)) { clean[k] = sanitizeLogData(v); } else { clean[k] = v; } } return clean; } function formatLogData(data) { if (!data || typeof data !== 'object') return ''; const parts = []; for (const [k, v] of Object.entries(data)) { if (v === undefined || v === null) continue; if (typeof v === 'object') { parts.push(`${k}=${JSON.stringify(v)}`); } else { parts.push(`${k}=${v}`); } } return parts.join(' '); } // Entries of this tab that are not in storage yet. Several hh.ru tabs share one log // (a run in one, vacancies from the queue in others), so a flush appends to what is // stored instead of replacing it with this tab's copy. let pendingLog = []; let logFlushTimer = null; function readStoredLog() { const arr = parseJson(storage.localGet(KEYS.logBuffer), []); return Array.isArray(arr) ? arr.filter(entry => entry && typeof entry === 'object' && !Array.isArray(entry) && Number.isFinite(entry.ts) && typeof entry.event === 'string') : []; } function flushLogBuffer() { if (logFlushTimer) { clearTimeout(logFlushTimer); logFlushTimer = null; } if (!pendingLog.length) return; try { const list = readStoredLog().concat(pendingLog).slice(-MAX_LOG_ENTRIES); if (storage.localSet(KEYS.logBuffer, JSON.stringify(list))) pendingLog = []; } catch (e) { console.warn('[HHA] Failed to write log buffer:', e); } } function scheduleLogFlush() { if (!logFlushTimer && typeof setTimeout !== 'undefined') { logFlushTimer = setTimeout(() => { logFlushTimer = null; flushLogBuffer(); }, 3000); } } function appendLogEntry(entry) { pendingLog.push(entry); if (pendingLog.length > MAX_LOG_ENTRIES) pendingLog = pendingLog.slice(-MAX_LOG_ENTRIES); scheduleLogFlush(); } function hhaDumpLog() { flushLogBuffer(); // Entries of different tabs come in flush order, so they are put back in time order. const entries = readStoredLog().concat(pendingLog).sort((a, b) => (a.ts || 0) - (b.ts || 0)); return entries.map(e => JSON.stringify(e)).join('\n'); } function hhaClearLog() { pendingLog = []; if (logFlushTimer) { clearTimeout(logFlushTimer); logFlushTimer = null; } storage.localSet(KEYS.logBuffer, JSON.stringify([])); console.info('[HHA] Log buffer cleared'); return true; } function hhaLog(level, event, data = null) { const ts = Date.now(); const cleanData = sanitizeLogData(data); // Ids come with a v_ prefix from some pages and without it from others; one form in // the log lets a vacancy be followed from navigate_vacancy to its outcome. if (typeof cleanData.vid === 'string') cleanData.vid = cleanVid(cleanData.vid); const entry = { ts, level: level || 'info', event: String(event || 'unknown'), ...cleanData }; appendLogEntry(entry); const timeStr = formatTime(ts); const dataStr = formatLogData(cleanData); const line = `[HHA] ${timeStr} ${entry.event}${dataStr ? ' ' + dataStr : ''}`; if (level === 'error') { console.error(line); } else if (level === 'warn') { console.warn(line); } else { console.info(line); } } function reportError(msg, code = 'ERROR', details = null) { const entryCode = code || 'ERROR'; const message = String(msg || ''); try { console.error(`%c[HHA ERROR]%c [${entryCode}] ${message}`, 'background: #ef4444; color: #ffffff; font-weight: 700; border-radius: 3px; padding: 1px 5px; font-size: 11px;', 'color: #ef4444; font-weight: 600;', details || ''); } catch (_) {} hhaLog('error', 'error', { code: entryCode, message, ...(details && typeof details === 'object' ? details : {}) }); events.emit('error', { code: entryCode, message, fatal: false, details: details || {} }); } // For problems the run gets past by itself (a retry, a vacancy sent to the queue). // They go to the log only, so the HUD does not show a routine retry as a failure. function reportWarning(msg, code, details = null) { hhaLog('warn', 'warning', { code, message: String(msg || ''), ...(details && typeof details === 'object' ? details : {}) }); } // --- E5.1. Watchdog Progress Tracking --- let lastProgressTs = Date.now(); let lastCommittedVid = null; // The vacancy whose letter field the script filled last, so a response sent from a // form with no letter field can still get the letter through the attach button. let letterFilledVid = null; function markProgress() { lastProgressTs = Date.now(); } // --- E5.2. Daily Counters Domain --- function getLocalDateKey(d = new Date()) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function makeFreshCounters(today = getLocalDateKey()) { return { date: today, applied: 0, queued: 0, skipped: 0, error: 0 }; } // Today's counters. Reading never writes: the next outcome saves a new day. The stored // day is kept when it is today or tomorrow: tomorrow means the time zone or the clock // went back (a trip, an OS zone fixed), and those counts are the user's real day. // A read during a short visit to another date used to wipe them. function getDailyCounters() { const now = new Date(); const today = getLocalDateKey(now); const tomorrow = getLocalDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)); const data = parseJson(storage.localGet(KEYS.dailyCounters), null); if (data && typeof data === 'object' && (data.date === today || data.date === tomorrow)) { const n = (v) => Math.max(0, Math.floor(Number(v)) || 0); return { date: data.date, applied: n(data.applied), queued: n(data.queued), skipped: n(data.skipped), error: n(data.error) }; } return makeFreshCounters(today); } function emitProgress() { const cur = getSentCount(); events.emit('progress', { sent: cur, percentage: Math.min(100, Math.round((cur / MAX_DAILY_LIMIT) * 100)) }); } function saveDailyCounters(counters) { storage.localSet(KEYS.dailyCounters, JSON.stringify(counters)); } function normalizeReasonCode(rawReason, defaultPrefix = 'skip') { if (!rawReason) return defaultPrefix + '_unknown'; let s = String(rawReason).trim().toLowerCase().replace(/[-\s]+/g, '_'); if (s.startsWith('skip_') || s.startsWith('queued_') || s.startsWith('error_') || s.startsWith('applied_') || s === 'hh_limit_reached') { return s; } if (s === 'reject_regex' || s.includes('reject_regex') || s.includes('regex')) return 'skip_reject_regex'; if (s.includes('reject') || s.includes('warning')) return 'skip_reject_warning'; if (s.includes('hidden')) return s.includes('resume') ? 'skip_resume_hidden' : 'skip_hidden_employer'; if (s.includes('already')) return 'skip_already_applied'; if (s.includes('access_denied') || s.includes('inaccessible')) return 'skip_inaccessible'; if (s.includes('unknown_page')) return 'skip_unknown_page'; if (s.includes('promo') || s.includes('article') || s.includes('lead_gen')) return 'queued_promo'; if (s.includes('test') || s.includes('questionnaire')) return 'queued_questionnaire'; if (s.includes('relocation')) return 'queued_relocation'; if (s.includes('no_button') || s.includes('no_apply') || s.includes('no_submit')) return 'queued_no_button'; if (s.includes('external')) return 'queued_external_site'; if (s.includes('max_attempts')) return 'queued_max_attempts'; if (s.includes('timeout')) return 'error_timeout'; if (s.includes('selector')) return 'error_selector_missing'; if (s.includes('captcha')) return 'error_captcha'; if (s.includes('rate_limit')) return 'error_rate_limit'; if (s.includes('daily_limit') || s.includes('limit_reached')) return 'hh_limit_reached'; if (s.includes('submit')) return 'error_submit_failed'; if (s.includes('unconfirmed')) return 'error_unconfirmed'; if (s.includes('page_error') || s.includes('error')) return 'error_page_failed'; return `${defaultPrefix}_${s}`; } function checkSkipRateAnomaly(reason) { let total = toNum(storage.sessionGet(KEYS.sessionProcessedTotal), 0) + 1; storage.sessionSet(KEYS.sessionProcessedTotal, String(total)); let anomalousSkips = toNum(storage.sessionGet(KEYS.sessionAnomalousSkips), 0); const r = String(reason || ''); const isAnomalous = r === 'skip_reject_regex' || r === 'skip_unknown_page' || r.startsWith('error_'); if (isAnomalous) { anomalousSkips++; storage.sessionSet(KEYS.sessionAnomalousSkips, String(anomalousSkips)); } if (total >= 20 && (anomalousSkips / total) > SKIP_ALERT_RATIO) { const alertKey = KEYS.skipAlertShown; if (storage.sessionGet(alertKey) !== '1') { storage.sessionSet(alertKey, '1'); hhaLog('warn', 'skip_rate_alert', { anomalousSkips, total, ratio: ((anomalousSkips / total) * 100).toFixed(1) + '%' }); reportError( `Пропущено много вакансий (${anomalousSkips} из ${total}). Проверь лог`, 'SKIP_RATE_ALERT', { skipped: anomalousSkips, total, ratio: anomalousSkips / total } ); } } } // Outcomes of the current run, for the summary shown after it stops. The daily // counters span all runs of the day; these start from zero on every start. They live // in sessionStorage, since a run goes through many page loads of the same tab. function readRunStats() { return parseJson(storage.sessionGet(KEYS.runStats), null); } function startRunStats() { storage.sessionSet(KEYS.runStats, JSON.stringify({ startedAt: Date.now(), applied: 0, queued: 0, skipped: 0, error: 0 })); storage.sessionRemove(KEYS.lastRun); events.emit('runSummary', null); } function bumpRunStats(outcome) { const stats = readRunStats(); if (!stats || !(outcome in stats)) return; stats[outcome] = (Number(stats[outcome]) || 0) + 1; storage.sessionSet(KEYS.runStats, JSON.stringify(stats)); } function closeRunStats(code) { const stats = readRunStats(); if (!stats) return; storage.sessionRemove(KEYS.runStats); const summary = { ...stats, endedAt: Date.now(), code }; storage.sessionSet(KEYS.lastRun, JSON.stringify(summary)); events.emit('runSummary', summary); } function recordOutcome(vid, outcome, reason, details = null) { const clean = cleanVid(vid); const validOutcomes = ['applied', 'queued', 'skipped', 'error']; const finalOutcome = validOutcomes.includes(outcome) ? outcome : 'error'; const finalReason = normalizeReasonCode(reason, finalOutcome === 'applied' ? 'applied' : (finalOutcome === 'queued' ? 'queued' : (finalOutcome === 'skipped' ? 'skip' : 'error'))); const counters = getDailyCounters(); if (finalOutcome in counters && finalOutcome !== 'date') { counters[finalOutcome]++; saveDailyCounters(counters); } storage.sessionRemove(KEYS.watchdogStallCount); storage.sessionRemove(KEYS.watchdogStallVid); markProgress(); if (clean) { clearInflight(clean); // Any other outcome of a vacancy ends a series of vacancies without the apply button. if (finalReason !== 'queued_no_button') storage.sessionRemove(KEYS.noButtonStreak); if (finalReason !== 'skip_resume_visibility') storage.sessionRemove(KEYS.visibilityStreak); } if (finalOutcome === 'applied') noteResponse(); const logLevel = finalOutcome === 'error' ? 'error' : (finalOutcome === 'skipped' ? 'warn' : 'info'); const logData = { vid: clean || undefined, outcome: finalOutcome, reason: finalReason }; if (details && typeof details === 'object') { Object.assign(logData, details); } hhaLog(logLevel, 'outcome', logData); checkSkipRateAnomaly(finalReason); if (isRunning()) bumpRunStats(finalOutcome); events.emit('outcome', { vid: clean, outcome: finalOutcome, reason: finalReason, counters }); } // --- E6. Configuration --- function normalizeConfig(raw) { const m = { ...DEFAULTS, ...(raw && typeof raw === 'object' ? raw : {}) }; // Booleans written as strings ("false") by hand or by another script mean what they say. return { coverText: sliceSafe(typeof m.coverText === 'string' ? m.coverText : (asText(m.coverText) || DEFAULT_COVER_TEXT), MAX_COVER_LENGTH), useCover: m.coverNeedsReview !== true && (m.useCover === true || m.useCover === 'true'), coverNeedsReview: m.coverNeedsReview === true, skipHidden: !(m.skipHidden === false || m.skipHidden === 'false'), }; } let config = normalizeConfig(parseJson(storage.localGet(KEYS.settings), null)); function persistSettings(nextPartial) { const prev = { ...config }; // Turning sending back on is the user's acknowledgement of the old template notice. const reviewed = nextPartial?.useCover === true ? { coverNeedsReview: false } : {}; const stored = parseJson(storage.localGet(KEYS.settings), null); const nextConfig = normalizeConfig({ ...config, ...(stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {}), ...(nextPartial || {}), ...reviewed }); const success = storage.localSet(KEYS.settings, JSON.stringify(nextConfig)); config = nextConfig; events.emit('config', { current: config, previous: prev }); return success; } const actionPause = () => wait(randBetween(ACTION_TIMINGS.action[0], ACTION_TIMINGS.action[1])); // --- E6.1. Pace --- // A person does not keep one rhythm for an hour. On top of the pauses above: now and then a // longer pause before a vacancy, a break every few dozen vacancies and a longer rest every // 10-12 responses. The letter is pasted and read before the submit; pauses grow for 12 // hours after a captcha. The time depends on these rests and the slowdown after captchas. // On local pages (the dev mocks) this is off unless hha:pace_test supplies test timings; // on hh.ru that key is not read. const PACE_DEFAULTS = Object.freeze({ tailChance: 0.06, tailMs: [10000, 30000], breakEvery: [25, 40], breakMs: [40000, 100000], responseBreakEvery: [10, 12], responseBreakMs: [180000, 300000], responseWindowMs: 30 * 60 * 1000, resumeMs: [1000, 2200], letterBeforeMs: [400, 1000], letterAfterMs: [1000, 2500], cooldownMs: 12 * 60 * 60 * 1000 }); const PACE = (() => { if (!LOCAL_HOST_RE.test(globalThis.location?.hostname || '')) return { on: true, ...PACE_DEFAULTS }; const test = parseJson(storage.localGet(KEYS.paceTest), null); return test && typeof test === 'object' ? { ...PACE_DEFAULTS, ...test, on: true } : { ...PACE_DEFAULTS, on: false }; })(); const randIn = (range) => randBetween(range[0], range[1]); // Captchas of the last 12 hours, shared by all tabs. One makes the pauses 1.5 times longer, // two or more twice as long. function recentCaptchas(now = Date.now()) { const list = parseJson(storage.localGet(KEYS.captchas), []); return (Array.isArray(list) ? list : []).map(Number).filter((ts) => ts <= now && now - ts < PACE.cooldownMs); } function noteCaptcha() { storage.localSet(KEYS.captchas, JSON.stringify(recentCaptchas().concat(Date.now()).slice(-10))); // Solving it was a rest: the responses count from here. startRestCount(); } const paceFactor = () => (PACE.on ? 1 + 0.5 * Math.min(2, recentCaptchas().length) : 1); function vacancyPauseMs() { const f = paceFactor(); let ms = randIn(ACTION_TIMINGS.delay) * f; if (PACE.on && Math.random() < PACE.tailChance) ms += randIn(PACE.tailMs) * f; return Math.max(2000, Math.round(ms)); } // A pause longer than the watchdog allows for a step (a minute, then it reloads the page): // while it lasts, the watchdog takes the page as resting, not hung. let restingUntil = 0; async function rest(ms) { restingUntil = Date.now() + ms; try { await wait(ms); } finally { restingUntil = 0; markProgress(); } } // A rest every 10-12 responses. On live hh.ru (2026-10-20) a captcha came at about the 15th // response after each start, at x1 and at x1.5 alike, and the break by opened vacancies // below never came: each start began that count again. These responses are counted in // local storage, shared by the tabs, so a stop and a start keep them. One older than half an // hour does not count (a pause that long was a rest), and a captcha starts the count over. // Only new responses: a vacancy found "already applied" is not one. function readRest() { const st = parseJson(storage.localGet(KEYS.responseRest), null) || {}; return { since: Number(st.since) || 0, every: Number(st.every) || 0, times: Array.isArray(st.times) ? st.times.map(Number).filter(Number.isFinite) : [] }; } const writeRest = (st) => storage.localSet(KEYS.responseRest, JSON.stringify(st)); function noteResponse(now = Date.now()) { const st = readRest(); st.times = st.times.filter((ts) => ts <= now && now - ts < PACE.responseWindowMs).concat(now).slice(-60); writeRest(st); } function responsesSinceRest(now = Date.now()) { const st = readRest(); const from = Math.max(st.since, now - PACE.responseWindowMs); return st.times.filter((ts) => ts > from && ts <= now).length; } function startRestCount(now = Date.now()) { const st = readRest(); st.since = now; st.every = Math.max(1, Math.round(randIn(PACE.responseBreakEvery))); writeRest(st); } // The vacancies opened in this run and the count at which the next break comes. function readPaceState() { const st = parseJson(storage.sessionGet(KEYS.paceState), null); return st && typeof st === 'object' ? { opened: Math.max(0, Number(st.opened) || 0), nextBreak: Number(st.nextBreak) || 0 } : { opened: 0, nextBreak: 0 }; } const nextBreakAfter = (opened) => opened + Math.max(1, Math.round(randIn(PACE.breakEvery) / paceFactor())); async function breakIfDue() { if (!PACE.on) return; const st = readPaceState(); if (!st.nextBreak) st.nextBreak = nextBreakAfter(st.opened); // The number is used as chosen, not read back: if the storage refused it, every response // would read as due, and the run would rest before each vacancy. const restState = readRest(); if (!restState.every) { restState.every = Math.max(1, Math.round(randIn(PACE.responseBreakEvery))); writeRest(restState); } const responses = responsesSinceRest(); const byResponses = responses >= restState.every; if (byResponses || st.opened >= st.nextBreak) { // Both are due at times: the longer rest by responses covers the other. const ms = Math.round(randIn(byResponses ? PACE.responseBreakMs : PACE.breakMs) * paceFactor()); hhaLog('info', 'pace_break', { kind: byResponses ? 'responses' : 'opened', opened: st.opened, responses, ms }); setActivity('break', { durationMs: ms }); await rest(ms); // A rest cut short by Стоп is no rest: the next start takes it again. if (!stopSignal) { startRestCount(); st.nextBreak = nextBreakAfter(st.opened); } } storage.sessionSet(KEYS.paceState, JSON.stringify(st)); } function notePaceOpened() { if (!PACE.on) return; const st = readPaceState(); st.opened++; storage.sessionSet(KEYS.paceState, JSON.stringify(st)); } const wait = (ms) => new Promise((resolve) => { const sig = activeAbortController?.signal; if (stopSignal || sig?.aborted || ms <= 0) return resolve(); let timer = null; const cleanup = () => { if (timer) clearTimeout(timer); if (sig) sig.removeEventListener('abort', onAbort); }; const onAbort = () => { cleanup(); resolve(); }; if (sig) sig.addEventListener('abort', onAbort, { once: true }); timer = setTimeout(() => { cleanup(); markProgress(); resolve(); }, ms); }); // --- E7. Manual Queue Domain --- function normalizeManualEntry(entry) { if (!entry || typeof entry !== 'object') return null; const rawVid = (asText(entry.vid) || asText(entry.id)).trim(); const vid = cleanVid(rawVid); if (!vid) return null; return { vid, url: toSafeHhUrl(asText(entry.url) || asText(entry.href)), title: collapseSpaces(asText(entry.title)), employer: collapseSpaces(asText(entry.employer)), salary: collapseSpaces(asText(entry.salary)), reason: collapseSpaces(asText(entry.reason) || asText(entry.note)), addedAt: Number(entry.addedAt || entry.ts) || Date.now(), returnUrl: toSafeHhUrl(asText(entry.returnUrl)), viewed: entry.viewed === true || entry.viewed === 'true', viewedAt: entry.viewedAt ? (Number(entry.viewedAt) || null) : null, questions: normalizeQuestions(entry.questions) }; } // How many questions the employer's form asks and the first of them, shown on the // queue card so the queue can be sorted out without opening every vacancy. function normalizeQuestions(q) { const count = Math.round(Number(q && q.count) || 0); if (count < 1) return null; return { count: Math.min(count, 99), first: sliceSafe(collapseSpaces(asText(q.first)), 200) }; } // The questions on the page right now: hh.ru renders each one as // div[data-qa="task-question"] (seen on the response form page, 2026-10-13). function readTaskQuestions() { const texts = qa('[data-qa="task-question"]').map((el) => collapseSpaces(el.innerText || el.textContent)).filter(Boolean); return texts.length ? { count: texts.length, first: texts[0] } : null; } const ManualQueue = { get() { const raw = parseJson(storage.localGet(KEYS.manualList), []); return Array.isArray(raw) ? raw.map(normalizeManualEntry).filter(Boolean) : []; }, has(vid) { const targetVid = cleanVid(vid); if (!targetVid) return false; return this.get().some(it => cleanVid(it.vid) === targetVid); }, markViewed(vid, viewed = true) { const queue = this.get(); const targetVid = cleanVid(vid); const idx = queue.findIndex(it => cleanVid(it.vid) === targetVid); if (idx >= 0) { const isChange = queue[idx].viewed !== Boolean(viewed); if (isChange) { queue[idx].viewed = Boolean(viewed); queue[idx].viewedAt = viewed ? Date.now() : null; if (!storage.localSet(KEYS.manualList, JSON.stringify(queue))) return false; events.emit('manualQueue', { action: 'update', item: queue[idx], queue }); } return true; } return false; }, add(entry) { const item = normalizeManualEntry(entry); if (!item) return { success: false, isNew: false }; const queue = this.get(); const idx = queue.findIndex(it => it.vid === item.vid); const isNew = idx < 0; if (idx >= 0) queue[idx] = { ...queue[idx], ...item }; else queue.unshift(item); const ok = storage.localSet(KEYS.manualList, JSON.stringify(queue)); // A queue that was not saved is not shown either: the HUD would list a vacancy that // is gone after the next page load. if (ok) events.emit('manualQueue', { action: isNew ? 'add' : 'update', item, queue }); return { success: Boolean(ok), isNew: Boolean(isNew) }; }, remove(vid) { const queue = this.get(); const targetVid = cleanVid(vid); const filtered = queue.filter(it => cleanVid(it.vid) !== targetVid); if (filtered.length !== queue.length) { if (!storage.localSet(KEYS.manualList, JSON.stringify(filtered))) return false; events.emit('manualQueue', { action: 'remove', item: { vid }, queue: filtered }); } return true; }, clear() { if (!storage.localRemove(KEYS.manualList)) return false; events.emit('manualQueue', { action: 'clear', queue: [] }); return true; } }; // The vacancy of this page when it was in the queue as the page loaded. It stays after a // response by hand takes it off the queue: that is when the next one is wanted. `closed` // is the entry taken off because hh.ru says the vacancy is closed, for the HUD to offer back. let queuePage = null; // The next queued vacancy for "Следующая": one not opened yet, newest first, the order // of the queue tab. function nextManualItem(exceptVid) { const except = cleanVid(exceptVid); return ManualQueue.get() .filter((it) => !it.viewed && cleanVid(it.vid) !== except) .sort((a, b) => (Number(b.addedAt) || 0) - (Number(a.addedAt) || 0))[0] || null; } function manualItemUrl(item) { const vid = cleanVid(item.vid); const origin = globalThis.location?.origin || 'https://hh.ru'; return toSafeHhUrl(/^\d+$/.test(vid) ? `${origin}/vacancy/${vid}` : item.url); } // In this tab, as a link would: the vacancy page marks itself viewed when it loads. function openNextManualItem() { if (isRunning() || !queuePage) return false; const item = nextManualItem(queuePage.vid); const url = item && manualItemUrl(item); if (!url) return false; hhaLog('info', 'queue_next', { from: queuePage.vid, to: cleanVid(item.vid) }); leavePage(url); return true; } // hh.ru says the queued vacancy is closed or gone: there is nothing to answer, so it // leaves the queue by itself. It stays in the queued history, so a run does not bring // it back; the HUD offers it back in case the page was misread. function closeQueuedVacancy(vid) { const clean = cleanVid(vid); const item = ManualQueue.get().find((it) => cleanVid(it.vid) === clean); if (!item) return false; if (!ManualQueue.remove(clean)) return false; if (queuePage && queuePage.vid === clean) queuePage.closed = item; hhaLog('info', 'queue_closed', { vid: clean }); events.emit('queueClosed', item); return true; } // --- E8. State Accessors --- const TAB_ID = (() => { const win = globalThis.window; const sessionTabId = storage.sessionGet(KEYS.tabId); const winName = (win && typeof win.name === 'string') ? win.name : ''; // Check if this window was navigated or refreshed in the SAME tab // In browsers, window.name persists across navigations in the same tab, // but may be empty or unlinked if not explicitly set. const isSameTab = Boolean(sessionTabId && sessionTabId.startsWith('hha_') && (!winName || winName === sessionTabId)); if (isSameTab) { try { if (win && win.name !== sessionTabId) win.name = sessionTabId; } catch (_) {} return sessionTabId; } // New or cloned tab: generate a fresh unique Tab ID const newTabId = 'hha_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 9); try { if (win) win.name = newTabId; } catch (_) {} storage.sessionSet(KEYS.tabId, newTabId); // If this tab inherited cloned sessionStorage from a parent tab, disarm the cloned state if (sessionTabId && sessionTabId !== newTabId) { storage.sessionRemove(KEYS.isRunning); storage.sessionRemove(KEYS.trapLock); storage.sessionRemove(KEYS.lastAttempt); } return newTabId; })(); const isRunning = () => storage.sessionGet(KEYS.isRunning) === '1'; const setRunning = (val) => (val ? storage.sessionSet(KEYS.isRunning, '1') : storage.sessionRemove(KEYS.isRunning)); const getSentCount = () => getDailyCounters().applied; // The lists below are written to storage at once. They used to be written on pagehide // only, and in a live run that write was lost: a skipped vacancy was not in the list on // the search page and was opened again and again. let memProcessedIds = null; // Ids are kept without the v_ prefix: some pages give it and others do not. function getProcessedIDs() { if (!memProcessedIds) { const arr = parseJson(storage.sessionGet(KEYS.history), []); memProcessedIds = new Set((Array.isArray(arr) ? arr : []).map(cleanVid).filter(Boolean)); } return memProcessedIds; } function isProcessedID(id) { return Boolean(id) && getProcessedIDs().has(cleanVid(id)); } function addProcessedID(id) { const clean = cleanVid(id); if (!clean) return true; const ids = getProcessedIDs(); if (!ids.has(clean)) { ids.add(clean); storage.sessionSet(KEYS.history, JSON.stringify(Array.from(ids))); } return true; } // How many times each vacancy was opened from the search in this run. A vacancy is // opened again only after a failure, at most MAX_VACANCY_ATTEMPTS times; more visits // mean it was not marked as processed, and the run would go round in a loop. function readRunVisits() { const map = parseJson(storage.sessionGet(KEYS.runVisits), {}); return map && typeof map === 'object' && !Array.isArray(map) ? map : {}; } function countRunVisit(vid) { const clean = cleanVid(vid); if (!clean) return; const map = readRunVisits(); map[clean] = (Number(map[clean]) || 0) + 1; storage.sessionSet(KEYS.runVisits, JSON.stringify(map)); } // --- E8.1. Attempts & Blacklist Tracking (Circuit Breaker) --- let memVacancyAttempts = null; let memVacancyAttemptsDirty = false; // A stored map, or an empty one when the key holds anything else: a string or a number // there made every later write throw in strict mode and stopped each run at once. const asMap = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {}); function getVacancyAttemptsMap() { if (!memVacancyAttempts) { memVacancyAttempts = asMap(parseJson(storage.sessionGet(KEYS.attempts), {})); } return memVacancyAttempts; } function flushVacancyAttempts() { if (memVacancyAttemptsDirty && memVacancyAttempts) { storage.sessionSet(KEYS.attempts, JSON.stringify(memVacancyAttempts)); memVacancyAttemptsDirty = false; } } function recordVacancyAttempt(vid) { if (!vid) return 1; const clean = cleanVid(vid); const map = getVacancyAttemptsMap(); const count = (Number(map[clean]) || 0) + 1; map[clean] = count; memVacancyAttemptsDirty = true; flushVacancyAttempts(); return count; } let memBlacklist = null; let memBlacklistDirty = false; function getBlacklistMap() { if (!memBlacklist) { const raw = storage.localGet(KEYS.blacklist); const parsed = parseJson(raw, {}); const map = asMap(parsed); const now = Date.now(); let changed = Boolean(raw) && map !== parsed; for (const k of Object.keys(map)) { const entry = map[k]; const ts = typeof entry === 'object' && entry !== null ? Number(entry.ts) : Number(entry); if (!ts || (now - ts) > BLACKLIST_TTL) { delete map[k]; changed = true; } } memBlacklist = map; if (changed) { memBlacklistDirty = true; } } return memBlacklist; } function flushBlacklist() { if (memBlacklistDirty && memBlacklist) { storage.localSet(KEYS.blacklist, JSON.stringify(memBlacklist)); memBlacklistDirty = false; } } function isBlacklisted(vid) { if (!vid) return false; const clean = cleanVid(vid); const map = getBlacklistMap(); return Boolean(map[clean]); } function addToBlacklist(vid, reason = 'apply_failed') { if (!vid) return; const clean = cleanVid(vid); const map = getBlacklistMap(); map[clean] = { ts: Date.now(), reason: String(reason || '') }; memBlacklistDirty = true; flushBlacklist(); } // Vacancies with a response, kept across tabs and sessions. Some search cards keep the // apply button after a response (a pinned card at the top of the list), and the session // list of processed ids starts empty in every tab, so without this such a vacancy is // opened on every run only to find the response already there. Written at once, since // a queued vacancy can be answered in another tab. const VID_HISTORY_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days at hh.ru's limit of 200 responses a day, with room to spare: with 2000 the // oldest ids went after 10 days, while README promises 30. const VID_HISTORY_MAX = 6500; // A map of vacancy id to the time it was stored, kept for ttl. function createVidHistory(key, ttl = VID_HISTORY_TTL) { const read = () => { const map = parseJson(storage.localGet(key), {}); return map && typeof map === 'object' && !Array.isArray(map) ? map : {}; }; const write = (map) => { const now = Date.now(); const kept = Object.entries(map) .filter(([, ts]) => now - Number(ts) < ttl) .sort((a, b) => Number(b[1]) - Number(a[1])) .slice(0, VID_HISTORY_MAX); storage.localSet(key, JSON.stringify(Object.fromEntries(kept))); }; return { remember(vid) { const clean = cleanVid(vid); if (!clean) return; const map = read(); map[clean] = Date.now(); write(map); }, forget(vid) { const clean = cleanVid(vid); const map = read(); if (!clean || !(clean in map)) return; delete map[clean]; write(map); }, ids() { const now = Date.now(); return new Set(Object.entries(read()) .filter(([, ts]) => now - Number(ts) < ttl) .map(([vid]) => vid)); } }; } const appliedHistory = createVidHistory(KEYS.appliedHistory); function rememberApplied(vid) { appliedHistory.remember(vid); } function forgetApplied(vid) { appliedHistory.forget(vid); } function getAppliedHistorySet() { return appliedHistory.ids(); } // Vacancies queued for a reason a new attempt cannot change: a questionnaire, a response // on another site, a promo page, no apply button. "Очистить всё" empties the queue, and // without this the next run would open all of them again only to queue them again. // Passing failures (a submit or a relocation not confirmed) are not kept: for them a // new attempt is the point of removing them from the queue. const queuedHistory = createVidHistory(KEYS.queuedHistory); const LASTING_QUEUE_REASONS = new Set(['test_required', 'test-questionnaire', 'queued_external_site', 'queued_promo', 'no-apply-button']); // Vacancies skipped for a reason the next session would find again: hh.ru says the // resume does not fit, the resume is hidden, the vacancy is closed. The processed list // lives in sessionStorage, so without this every new tab opened them and pressed the // apply button again. A week, not 30 days: a reworked resume may fit next time. const SKIPPED_HISTORY_TTL = 7 * 24 * 60 * 60 * 1000; const skippedHistory = createVidHistory(KEYS.skippedHistory, SKIPPED_HISTORY_TTL); // Stored data of older versions is put right here, once per browser and in order. The // store keeps how many steps are done, so a new step goes at the end of the list, never // in between. (The pill place moved from window coordinates to edge anchors in the HUD, // _restorePosition: it needs the window size.) const MIGRATIONS = [ // 0.2.26: 0.2.23-0.2.25 skipped every vacancy whose dialog asked to change the resume // visibility and remembered it for a week. The skips written since the 0.2.23 release // are forgotten, so those vacancies are opened again; a real rejection is found again. function forgetVisibilitySkips() { const since = 1792325543000; const map = parseJson(storage.localGet(KEYS.skippedHistory), null); if (map && typeof map === 'object' && !Array.isArray(map)) { storage.localSet(KEYS.skippedHistory, JSON.stringify(Object.fromEntries(Object.entries(map).filter(([, ts]) => Number(ts) < since)))); } }, // 0.2.30: variable substitution is gone. Keep the old text, but do not send its // placeholders literally until the user has reviewed it and turns sending back on. function reviewOldCoverText() { if (!/\{\s*(?:должность|компания)\s*(?:\|[^{}]*)?\}/i.test(config.coverText)) return; config = { ...config, useCover: false, coverNeedsReview: true }; if (!storage.localSet(KEYS.settings, JSON.stringify(config))) { throw new Error('Could not save the cover letter review state'); } }, ]; (function migrateStorage() { const stored = Number(storage.localGet(KEYS.schema)); // 0.2.26-0.2.29 marked the first step with a flag of its own. A number above the list // is data of a newer version (the script was rolled back): it is left as it is. let done = Number.isInteger(stored) && stored > 0 ? stored : (storage.localGet(KEYS.skipsReset0226) ? 1 : 0); while (done < MIGRATIONS.length) { try { MIGRATIONS[done](); } catch (e) { // Tried again on the next page; the later steps wait for it. console.warn('[HHA] Storage migration failed:', MIGRATIONS[done].name, e); break; } done++; } if (done !== stored) storage.localSet(KEYS.schema, String(done)); // 0.2.26-0.2.29 know the first step only by this flag. Without it an older version (a // rollback, or the release script beside a dev copy, which share this storage) would // run it again and forget the skips of the days since 0.2.23 came out. if (done >= 1 && !storage.localGet(KEYS.skipsReset0226)) storage.localSet(KEYS.skipsReset0226, '1'); })(); const LASTING_SKIP_REASONS = new Set(['skip_reject_warning', 'skip_reject_regex', 'resume_hidden', 'access_denied', 'skip_inaccessible']); // A response the script started: the apply button or a submit was pressed and hh.ru has // not confirmed it yet. It lives in sessionStorage, so a Stop or a reload in between // still counts the response once the page shows it, instead of losing it or taking it // for a skip "already applied". const INFLIGHT_TTL = 2 * 60 * 1000; // hh.ru may answer a submit with a captcha and send the response once the user solves it, // which takes minutes when the tab is in the background. const CAPTCHA_INFLIGHT_TTL = 30 * 60 * 1000; function markInflight(vid) { const clean = cleanVid(vid); if (clean) storage.sessionSet(KEYS.inflight, JSON.stringify({ vid: clean, ts: Date.now() })); } function readInflightEntry() { const p = parseJson(storage.sessionGet(KEYS.inflight), null); if (!p || typeof p.vid !== 'string') return null; return Date.now() - Number(p.ts) < (p.captcha ? CAPTCHA_INFLIGHT_TTL : INFLIGHT_TTL) ? p : null; } function readInflight() { return readInflightEntry()?.vid || null; } // A response counted after the run stopped. The next run on that page does not count it // again as a skip "already applied", and attaches the letter if the submit went without it. function takeLateApplied(vid) { const p = parseJson(storage.sessionGet(KEYS.lateApplied), null); if (!p || p.vid !== cleanVid(vid)) return null; storage.sessionRemove(KEYS.lateApplied); return Date.now() - Number(p.ts) < CAPTCHA_INFLIGHT_TTL ? p : null; } function clearInflight(vid) { const raw = storage.sessionGet(KEYS.inflight); if (!raw) return; const p = parseJson(raw, null); if (!vid || !p || p.vid === cleanVid(vid)) storage.sessionRemove(KEYS.inflight); } function flushStorageCaches() { flushVacancyAttempts(); flushBlacklist(); } const onPageHide = () => { flushLogBuffer(); flushStorageCaches(); }; window.addEventListener('pagehide', onPageHide, { capture: true }); window.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { onPageHide(); } }, { capture: true }); // Every page change of a run goes through here. The log is written before leaving: // in a live run the write on pagehide did not reach storage, and the entries of the // last seconds on a vacancy page (the decision and the outcome) were lost. function leavePage(url) { onPageHide(); const loc = globalThis.location; if (!loc) return; if (!url) { loc.reload(); return; } try { loc.assign(url); } catch (_) { loc.href = url; } } function handleVacancyFailure(vid, reason = 'apply_failed', runId = currentRunId, meta = null) { // After Стоп or a captcha a wait that ended early is not a failed attempt: three of them // used to block the vacancy for 24 hours. if (!isRunCurrent(runId)) return false; if (!vid) return returnToList(null, { markProcessed: false, runId }); const clean = cleanVid(vid); const attempts = recordVacancyAttempt(clean); if (attempts >= MAX_VACANCY_ATTEMPTS) { addToBlacklist(clean, reason); markVacancyProcessed(clean, runId); saveCurrentForManual( clean, reason || 'apply_failed', runId, meta?.title || '', meta?.employer || '', meta?.salary || '', meta?.url || '' ); reportWarning(`Вакансия #${clean} превысила лимит попыток (${attempts}/${MAX_VACANCY_ATTEMPTS}). Отправлена в ручную очередь и заблокирована на 24ч.`, 'MAX_ATTEMPTS_EXCEEDED', { vid: clean, attempts, reason }); return returnToList(vid, { markProcessed: true, runId }); } else { reportWarning(`Сбой при отклике на вакансию #${clean} (попытка ${attempts}/${MAX_VACANCY_ATTEMPTS}). Возврат к списку.`, 'VACANCY_ATTEMPT_FAILED', { vid: clean, attempts, reason }); return returnToList(vid, { markProcessed: false, runId }); } } function checkAndResetStallVid(newVid) { const cNew = cleanVid(newVid); const stored = cleanVid(storage.sessionGet(KEYS.watchdogStallVid) || ''); if (stored && cNew && stored !== cNew) { storage.sessionRemove(KEYS.watchdogStallCount); storage.sessionRemove(KEYS.watchdogStallVid); } } const getReturnUrl = () => storage.sessionGet(KEYS.returnUrl) || ''; const setReturnUrl = (url) => storage.sessionSet(KEYS.returnUrl, url); const getLastAttemptID = () => storage.sessionGet(KEYS.lastAttempt) || null; const setLastAttemptID = (id) => { if (id) { checkAndResetStallVid(id); return storage.sessionSet(KEYS.lastAttempt, id); } return storage.sessionRemove(KEYS.lastAttempt); }; const clearLastAttemptID = () => storage.sessionRemove(KEYS.lastAttempt); const getPendingVacancyMeta = (vid) => { const raw = storage.sessionGet(KEYS.pendingVacancyMeta); if (!raw) return null; const meta = parseJson(raw, null); if (!meta) return null; const cleanTarget = cleanVid(vid); if (cleanTarget && meta.vid && cleanVid(meta.vid) !== cleanTarget) return null; return meta; }; const setPendingVacancyMeta = (meta) => (meta ? storage.sessionSet(KEYS.pendingVacancyMeta, JSON.stringify(meta)) : storage.sessionRemove(KEYS.pendingVacancyMeta)); const clearPendingVacancyMeta = () => storage.sessionRemove(KEYS.pendingVacancyMeta); function getActiveTrapLock() { const val = storage.sessionGet(KEYS.trapLock); if (!val) return null; const p = parseJson(val, null); if (!p || typeof p.token !== 'string' || typeof p.expiresAt !== 'number') return null; if (Date.now() >= p.expiresAt) { storage.sessionRemove(KEYS.trapLock); return null; } return p; } function setTrapLock(ttlMs = 45000, runId = currentRunId) { const token = Math.random().toString(36).slice(2, 10); return storage.sessionSet(KEYS.trapLock, JSON.stringify({ token, expiresAt: Date.now() + ttlMs, runId })) ? token : null; } function clearTrapLock() { return storage.sessionRemove(KEYS.trapLock); } let currentStatus = { statusKey: 'idle', code: 'IDLE', details: null }; function setStatus(statusKey, code = null, details = null) { const key = ['idle', 'running', 'stopped', 'error', 'done'].includes(statusKey) ? statusKey : 'idle'; currentStatus = { statusKey: key, code: code || key.toUpperCase(), details }; events.emit('status', { status: key, code: currentStatus.code, details: details || {} }); } // The current step of a run, for the HUD status line. `until` is set for timed // pauses so the HUD can count them down: a pause should not look like a hang. let currentActivity = null; function setActivity(code, { vid = null, durationMs = 0 } = {}) { currentActivity = code ? { code, vid: cleanVid(vid) || null, durationMs: durationMs > 0 ? durationMs : 0, until: durationMs > 0 ? Date.now() + durationMs : null } : null; events.emit('activity', currentActivity); } // --- E9. Concurrency & Instance Locks --- const INSTANCE_LOCK_TTL = 30000; // After a navigation the previous page can hold the lock for another 1-1.6 s on live // hh.ru. A timeout means "another tab runs the script" and stops the run, so it has to // leave a wide margin; a real second tab is still caught, just a few seconds later. const WEBLOCK_ACQUIRE_TIMEOUT = 5000; // A wait longer than this is logged as a warning, to see how close it gets to the timeout. const WEBLOCK_SLOW_WAIT = 1000; let currentLeaseId = null; let instanceLeaseVerified = false; let hasActiveWebLock = false; let webLockAbortController = null; let webLockReleaseResolver = null; let webLockPendingPromise = null; async function releaseWebLock() { if (!hasActiveWebLock && !webLockPendingPromise) return; if (webLockReleaseResolver) { try { webLockReleaseResolver(); } catch (_) {} webLockReleaseResolver = null; } else if (webLockAbortController) { try { webLockAbortController.abort(); } catch (_) {} } webLockAbortController = null; hasActiveWebLock = false; if (webLockPendingPromise) { try { await webLockPendingPromise; } catch (e) { hhaLog('debug', 'weblock_pending_error', { error: String(e && e.message || e) }); } webLockPendingPromise = null; } await new Promise(r => setTimeout(r, 10)); } function readInstanceLock() { return parseJson(storage.localGet(KEYS.instanceLock), null); } function isLiveLock(lock, now = Date.now()) { return Boolean(lock && Number.isFinite(Number(lock.ts)) && (now - Number(lock.ts)) < INSTANCE_LOCK_TTL); } async function acquireInstanceLock(tabId) { await releaseWebLock(); const now = Date.now(); // On search pages the acquisition sometimes takes 1-1.6 s. waitMs is the time // until the browser granted the lock, totalMs until this code ran again: a long wait // means another document held the lock, a short wait with a long total means the // page's own scripts kept the main thread busy. let grantedAt = null; // Web Locks acquisition (native browser mutual exclusion across tabs/workers) const nav = globalThis.navigator; if (nav?.locks?.request) { let webLockAcquired = false; const lockController = new AbortController(); // Set before the grant, so a stop while the request still waits aborts it instead // of waiting for the lock and taking it after the stop. webLockAbortController = lockController; try { let lockResolver; const lockPromise = new Promise(res => { lockResolver = res; }); webLockPendingPromise = nav.locks.request( KEYS.instanceLock, { mode: 'exclusive', signal: lockController.signal }, (lock) => { if (!lock) { lockResolver(false); return; } webLockAcquired = true; grantedAt = Date.now(); hasActiveWebLock = true; webLockAbortController = lockController; lockResolver(true); return new Promise(resRelease => { webLockReleaseResolver = resRelease; }); } ).catch(() => { hasActiveWebLock = false; if (!webLockAcquired) lockResolver(false); }); let timeoutId; const timeoutPromise = new Promise(resolve => { timeoutId = setTimeout(() => resolve('TIMEOUT'), WEBLOCK_ACQUIRE_TIMEOUT); }); const acquired = await Promise.race([lockPromise, timeoutPromise]); clearTimeout(timeoutId); if (acquired === 'TIMEOUT') { try { lockController.abort(); } catch (_) {} } if (acquired !== true) { instanceLeaseVerified = false; return false; } } catch (_) { hasActiveWebLock = false; instanceLeaseVerified = false; return false; } } else { // Fallback for environments without Web Locks API const existing = readInstanceLock(); if (existing && isLiveLock(existing, now) && existing.tabId !== tabId) { instanceLeaseVerified = false; return false; } } const leaseId = `${tabId}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; const written = storage.localSet(KEYS.instanceLock, JSON.stringify({ tabId, leaseId, ts: now })); if (!written) { await releaseWebLock(); instanceLeaseVerified = false; return false; } currentLeaseId = leaseId; instanceLeaseVerified = true; const waitMs = grantedAt !== null ? grantedAt - now : undefined; hhaLog(waitMs > WEBLOCK_SLOW_WAIT ? 'warn' : 'info', 'lock_acquire', { tabId, leaseId, waitMs, totalMs: Date.now() - now }); return true; } async function releaseInstanceLock(tabId) { const cur = readInstanceLock(); const ownsStoredLock = Boolean(cur && cur.tabId === tabId); const held = ownsStoredLock || hasActiveWebLock || Boolean(webLockPendingPromise); if (ownsStoredLock) { storage.localRemove(KEYS.instanceLock); } currentLeaseId = null; instanceLeaseVerified = false; await releaseWebLock(); if (held) hhaLog('info', 'lock_release', { tabId }); return true; } function touchInstanceLock(tabId) { const now = Date.now(); const cur = readInstanceLock(); // Only consider lock lost if another tab holds an active (live) lock if (cur && cur.tabId !== tabId && isLiveLock(cur, now)) { instanceLeaseVerified = false; return 'LOST'; } const leaseId = currentLeaseId || (cur && cur.tabId === tabId ? cur.leaseId : null) || `${tabId}_${now.toString(36)}`; currentLeaseId = leaseId; if (!storage.localSet(KEYS.instanceLock, JSON.stringify({ tabId, leaseId, ts: now }))) { instanceLeaseVerified = false; return 'LOST'; } instanceLeaseVerified = true; return 'OWNED'; } // --- E10. Run Lifecycle & Guards --- let isLoopActive = false; let stopSignal = false; let currentRunId = 0; let resumeTimer = null; let activeAbortController = null; let handlingResponsePage = false; let isNavigating = false; const isRunCurrent = (runId) => !stopSignal && (runId === undefined || runId === null || runId === currentRunId) && isRunning(); function guardOwnedCommit(runId = currentRunId) { if (!isRunCurrent(runId)) return false; if (touchInstanceLock(TAB_ID) !== 'OWNED') { haltForLostInstanceLock(); return false; } return true; } function terminateRun(code, logMsg = '', details = {}, isError = false) { currentRunId++; stopSignal = true; isNavigating = false; if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; } handlingResponsePage = false; clearTrapLock(); if (activeAbortController) { try { activeAbortController.abort(); } catch (_) {} activeAbortController = null; } isLoopActive = false; setRunning(false); setActivity(null); releaseInstanceLock(TAB_ID); const statusKey = (code === 'DAILY_LIMIT_REACHED' || code === 'DONE') ? 'done' : (isError ? 'error' : (code === 'STOPPED_BY_USER' ? 'stopped' : code.toLowerCase())); closeRunStats(code); setStatus(statusKey, code, details); hhaLog(isError ? 'error' : 'info', 'stop', { code, logMsg: logMsg || undefined, isError }); if (logMsg && isError) reportError(logMsg, code, details); const pending = readInflight(); if (pending) watchLateResponse(pending); flushLogBuffer(); flushStorageCaches(); } // A response that hh.ru confirms after the run stopped (Stop pressed while the script // waited for hh.ru) or after a reload. It went through and counts toward hh.ru's limit, // so it counts here too. Only the page is read, nothing is pressed. const LATE_RESPONSE_WATCH_MS = 20000; let lateWatchId = null; function recordLateResponse(vid, source) { const pending = readInflightEntry(); storage.sessionSet(KEYS.lateApplied, JSON.stringify({ vid, ts: Date.now(), letterOwed: Boolean(pending && pending.vid === vid && pending.letterOwed) })); rememberApplied(vid); recordOutcome(vid, 'applied', `applied_${source}`); // The run already has its summary; the response goes into it as well. const last = parseJson(storage.sessionGet(KEYS.lastRun), null); if (last && typeof last === 'object') { last.applied = (Number(last.applied) || 0) + 1; storage.sessionSet(KEYS.lastRun, JSON.stringify(last)); events.emit('runSummary', last); } emitProgress(); } function watchLateResponse(vid) { if (lateWatchId !== null) clearInterval(lateWatchId); lateWatchId = null; // Only on the page of that vacancy: its own page or its response form. if (cleanVid(getVacancyIDFromHref(globalThis.location?.href) || '') !== vid) return; const startedAt = Date.now(); const watchMs = readInflightEntry()?.captcha ? CAPTCHA_INFLIGHT_TTL : LATE_RESPONSE_WATCH_MS; const stop = () => { if (lateWatchId !== null) clearInterval(lateWatchId); lateWatchId = null; }; const check = () => { if (isRunning() || readInflight() !== vid || Date.now() - startedAt > watchMs) return stop(); if (detectAlreadyApplied() || isResponseConfirmed({ allowDocumentStrongText: true })) { stop(); hhaLog('info', 'late_response', { vid, afterMs: Date.now() - startedAt }); recordLateResponse(vid, 'after_stop'); } }; // The page text is read on every check: a captcha watch lasts up to 30 minutes. lateWatchId = setInterval(check, watchMs > LATE_RESPONSE_WATCH_MS ? 1000 : 500); } function finalizeRun(runId, statusKey, msg = '') { if (runId !== currentRunId) return; terminateRun(statusKey.toUpperCase(), msg, { message: msg }, statusKey === 'error'); } function haltEngine(code, logMsg, details = {}) { terminateRun(code, logMsg, details, true); } // A captcha on submit can leave a response awaiting confirmation. Keep its mark longer // so a response confirmed after the stop is counted once. The letter may survive the // captcha or need attaching; its presence is not established by the response alone. const haltForCaptcha = () => { const vid = resolveCurrentVid(); const pending = readInflightEntry(); noteCaptcha(); recordOutcome(vid, 'error', 'error_captcha'); if (pending && pending.vid === cleanVid(vid)) { // Try the attach path after Продолжить if sending is on. A field filled before the // captcha does not tell whether hh.ru kept its letter; no attach button is no proof // that the letter was lost. storage.sessionSet(KEYS.inflight, JSON.stringify({ ...pending, captcha: true, letterOwed: Boolean(config.useCover) })); if (letterFilledVid === pending.vid) letterFilledVid = null; } haltEngine('CAPTCHA_DETECTED', 'hh.ru показал капчу, автоматизация остановлена.'); }; const haltForRateLimit = () => { const vid = resolveCurrentVid(); recordOutcome(vid, 'error', 'error_rate_limit'); haltEngine('RATE_LIMITED', 'hh.ru ограничил частоту запросов, автоматизация остановлена.'); }; const haltForDailyLimit = (msg = `Достигнут суточный лимит HeadHunter: не более ${MAX_DAILY_LIMIT} откликов за 24 часа. Автоматизация остановлена.`) => { hhaLog('warn', 'hh_limit_reached', { source: 'hh_ui' }); terminateRun('DAILY_LIMIT_REACHED', msg, { limit: MAX_DAILY_LIMIT, period: '24h' }, false); }; const haltForLostInstanceLock = () => { const isBlocked = storage.isLocalBlocked(); haltEngine(isBlocked ? 'STORAGE_BLOCKED' : 'TAB_LOCK_LOST', isBlocked ? 'Браузер запретил сайту хранить данные, скрипт потерял блокировку вкладки.' : 'Блокировку вкладки перехватила другая вкладка.'); }; // --- E11. DOM Queries & Form Automation --- function q(sel, root) { try { return (root || globalThis.document)?.querySelector(sel) || null; } catch (_) { return null; } } function qa(sel, root) { try { const s = root || globalThis.document; return s ? Array.from(s.querySelectorAll(sel)) : []; } catch (_) { return []; } } // The detectors read textContent of the same nodes many times per check and on every // watchdog tick, often of the whole body. The text only changes with the DOM, so it // is cached until the next mutation. The HUD lives in a shadow root, so its own // updates do not reset the cache. const textCache = new Map(); let textCacheObserver = null; function textOf(node) { if (!node) return ''; if (!textCacheObserver) { const root = globalThis.document?.documentElement; if (typeof MutationObserver === 'undefined' || !root) return readPageText(node); textCacheObserver = new MutationObserver(() => textCache.clear()); textCacheObserver.observe(root, { childList: true, subtree: true, characterData: true }); } let text = textCache.get(node); if (text === undefined) { text = readPageText(node); textCache.set(node, text); } return text; } // The text of a node as the page shows it in its markup, without the code inside //