@clawhub-naxofon-12cff4a187
Search, evaluate, and manage job opportunities for a candidate across the Russian market with hh.ru, Habr Career, Telegram vacancy channels/chats, and Linked...
---
name: job-search
description: Search, evaluate, and manage job opportunities for a candidate across the Russian market with hh.ru, Habr Career, Telegram vacancy channels/chats, and LinkedIn as a secondary source. Use when building a candidate profile, filtering vacancies by salary/stack/seniority/remote format, scoring job relevance, tailoring resumes or cover letters, tracking applications, or running controlled job-hunt workflows with explicit safety rules for outreach and auto-apply.
---
# Job Search
Use this skill to run a structured job-hunt workflow without turning the agent into an uncontrolled spam bot.
## Core workflow
1. Build or update the candidate profile.
2. Define target roles, salary floor, geography, work format, and exclusions.
3. Before collecting vacancies from any browser-based source (especially hh.ru), force a page refresh/reload so newly published postings appear.
4. For hh.ru in Browser Relay / logged-in browser flows, do not trust the currently rendered list until after an explicit refresh of the active search results tab.
5. Before checking whether an hh resume can be raised in search, refresh the page that shows the resume cards; stale hh UI can falsely show a cooldown when the raise is already available.
6. When the user asks to raise resumes on hh without narrowing it down, raise all relevant active resumes that are currently available after refresh, not just one.
7. Collect vacancies from the supported sources.
8. Normalize each vacancy into a common structure.
9. Score each vacancy: strong match / possible match / skip.
10. Save the shortlist and application state in durable workspace files.
11. Tailor resume / cover letter only for shortlisted roles.
12. Apply or message recruiters only under the allowed mode.
## Supported sources
Primary:
- hh.ru
- Habr Career
- Telegram vacancy channels and chats
Secondary:
- LinkedIn
Read `references/source-strategy.md` when choosing source priority and source-specific handling.
## Operating modes
### 1. Research-only
Use when the user wants market scanning, salary mapping, or a shortlist.
- Search and score vacancies.
- Do not apply.
- Do not send messages.
### 2. Assisted apply
Use when the user wants help preparing applications but still approves submission.
- Search and score vacancies.
- Tailor resume / cover letter.
- Prepare answers and a pipeline row.
- Do not submit without clear approval.
### 3. Controlled auto-apply
Use only when the user explicitly allows automatic submission.
- Apply only to strong-match vacancies.
- Respect per-source limits.
- Log every submission.
- Never send recruiter DMs unless separately allowed.
### 4. Outreach-enabled
Use only when the user explicitly allows recruiter outreach.
- Follow the same rules as controlled auto-apply.
- Use short, factual messages.
- Log every outbound message.
## Hard safety rules
- Never contact recruiters or hiring managers unless the user explicitly allows outreach.
- Never auto-apply unless the user explicitly enables auto-apply.
- Never invent experience, stack, salary history, notice period, education, or citizenship details.
- Never overwrite a candidate master profile silently; append or propose diffs.
- Prefer fewer high-fit applications over mass low-fit submissions.
- Log every external action in the project workspace.
- Never mark an hh application as sent just because the "Откликнуться" button was clicked.
- On hh, treat an application as successful only after explicit UI confirmation that the response/application was sent or the vacancy appears in the sent responses state.
- If success cannot be verified, record the state as `apply-unconfirmed` or `apply-failed`, not `applied`.
- Before deciding that hh resume raising is unavailable or still on cooldown, refresh the resume page first; stale hh UI is not a reliable signal.
- Never answer screening questions or other free-text prompts without first showing them to the user and getting approval.
- After a verified hh apply, immediately refresh the resumes page and raise all relevant active resumes that are available, unless the user explicitly disables that post-apply raise step.
- When a bundled hh apply script/workflow already encodes required post-apply behavior, prefer that script/workflow over ad-hoc manual browser clicking for real applications.
Read `references/safety-rules.md` before enabling outreach or auto-apply.
## Workspace layout
Prefer a durable project folder such as:
```text
projects/job-search/
PROFILE.md
TARGET_ROLES.md
SEARCH_RULES.md
SOURCES.md
PIPELINE.md
OUTREACH_RULES.md
BLACKLIST.md
applications/
exports/
logs/
```
If the project does not exist yet, create it before doing serious work.
## Vacancy normalization
Normalize each vacancy into these fields when possible:
- source
- source_url
- company
- title
- stack
- seniority
- salary_min
- salary_max
- salary_currency
- gross_or_net
- location
- remote_mode
- employment_type
- visa_or_relocation
- english_level
- contact_name
- contact_url
- summary
- fit_score
- fit_label
- fit_reasons
- red_flags
- status
## Source-specific notes
### hh.ru
- Treat as the primary source for Russian-market roles.
- Normalize salary carefully; gross/net may differ by posting.
- Capture employer type, location, and work format.
### Habr Career
- Prefer for engineering/product/design roles with better tech signal.
- Preserve stack and level details from the description.
### Telegram
- Expect noisy, duplicated, and weakly structured posts.
- Deduplicate aggressively.
- Parse salary, stack, and contact info from free text.
- Treat direct recruiter handles as contact data, not permission to message.
### LinkedIn
- Use as a secondary source.
- Focus on roles that are materially better than local-market baseline or clearly international/remote.
## Tailoring outputs
For a strong-match vacancy, prepare:
- 1 tailored resume variant
- 1 short cover letter or intro note
- 3-6 bullet reasons why the role fits
- optional answers to standard screening questions
Keep tailoring factual and specific to the vacancy.
## Scripts
Prefer the bundled Python tools over ad-hoc manual parsing.
Install the Python dependencies first when you need the scoring/deduplication libraries:
```bash
python -m pip install -r skills/job-search/scripts/requirements.txt
```
Available tools:
- `scripts/requirements.txt` — Python deps for the scoring/normalization toolkit (`pydantic`, `rapidfuzz`)
- `references/onboarding.md` — resume-first onboarding and HH Browser Relay setup guidance for first-run users
- `scripts/init_job_search_project.py` — create a durable project folder from templates
- `scripts/profile_to_json.py` — convert project markdown files into a compact scoring profile JSON
- `scripts/job_match_score.py` — score vacancy JSON against a profile JSON
- `scripts/score_vacancy_from_project.py` — score one vacancy directly against a project folder
- `scripts/score_vacancies_jsonl.py` — score a JSONL batch against a project folder
- `scripts/normalize_salary.py` — normalize salary text into structured fields
- `scripts/parse_telegram_job.py` — extract a Telegram vacancy post into structured JSON
- `scripts/batch_parse_telegram_jobs.py` — process a directory of Telegram posts into JSONL
- `scripts/normalize_vacancy.py` — normalize a raw vacancy text/JSON payload into the common vacancy schema
- `scripts/batch_normalize_vacancies.py` — normalize a directory of raw vacancy payloads into JSONL
- `scripts/dedupe_vacancies.py` — cluster likely duplicate vacancies from JSONL input
- `scripts/canonicalize_deduped.py` — convert dedupe output into canonical-only JSONL
- `scripts/pipeline_add.py` — append a structured vacancy into `PIPELINE.md`
- `scripts/export_shortlist.py` — export strong or high-score vacancies into CSV
## Assets
Use `assets/templates/` when creating a new durable job-search project. These templates seed:
- `README-START.md`
- `PROFILE.md`
- `TARGET_ROLES.md`
- `SEARCH_RULES.md`
- `SOURCES.md`
- `PIPELINE.md`
- `OUTREACH_RULES.md`
- `BLACKLIST.md`
## References
Read only what you need:
- `references/onboarding.md` — first-run flow for resume/CV intake and when/how to instruct Browser Relay setup for HH automation
- `references/source-strategy.md` — source priorities and source-specific handling
- `references/safety-rules.md` — boundaries for outreach and auto-apply
- `references/project-layout.md` — recommended durable workspace structure
- `references/data-flow.md` — practical end-to-end batch flow for this skill
- `references/file-formats.md` — expected JSON/JSONL/project markdown formats
FILE:scripts/render_job_report.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Iterable
def load_rows(path: str) -> list[dict]:
text = Path(path).read_text(encoding='utf-8').strip()
if not text:
return []
if text.startswith('['):
data = json.loads(text)
return data if isinstance(data, list) else [data]
rows: list[dict] = []
for line in text.splitlines():
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def salary_text(v: dict) -> str:
if v.get('salary') and v.get('salary') != 'not listed':
return str(v['salary'])
low = v.get('salary_min')
high = v.get('salary_max')
cur = v.get('salary_currency') or ''
if low is None and high is None:
return 'не указана'
if low and high and low != high:
return f"{low:,}-{high:,} {cur}".replace(',', ' ')
val = high or low
return f"{val:,} {cur}".replace(',', ' ')
def match_label(v: dict) -> str:
mapping = {
'strong-match': 'strong',
'possible-match': 'possible',
'skip': 'skip',
'strong': 'strong',
'possible': 'possible',
None: 'unknown',
}
return mapping.get(v.get('fit_label'), str(v.get('fit_label')))
def verdict(v: dict) -> str:
reasons = v.get('fit_reasons') or ([] if not v.get('notes') else [v.get('notes')])
base = ', '.join(reasons[:2]) if reasons else 'короткая ручная проверка нужна'
risks = '; '.join((v.get('red_flags') or [])[:2])
return f"{base}." + (f" Риск: {risks}." if risks else '')
def render_card(v: dict, idx: int) -> str:
fit_reasons = v.get('fit_reasons') or ([] if not v.get('notes') else [v.get('notes')])
red_flags = v.get('red_flags') or []
parts = [
f"{idx}. {v.get('title') or 'Без названия'} — {v.get('company') or 'Без компании'}",
f" Источник: {v.get('source') or 'unknown'}",
f" Ссылка: {v.get('source_url') or v.get('url') or 'нет'}",
f" Формат: {v.get('remote_mode') or v.get('location') or 'unknown'}",
f" Зарплата: {salary_text(v)}",
f" Роль/уровень: {v.get('seniority') or 'не указан'}",
f" Match: {(v.get('fit_score') if v.get('fit_score') is not None else '?')}/100 · {match_label(v)}",
]
if fit_reasons:
parts.append(' Почему подходит: ' + '; '.join(fit_reasons[:3]))
if red_flags:
parts.append(' Что может мешать: ' + '; '.join(red_flags[:2]))
parts.append(' Вердикт: ' + verdict(v))
return '\n'.join(parts)
def render(vacancies: Iterable[dict], title: str) -> str:
rows = list(vacancies)
if not rows:
return f'{title}\n\nНовых релевантных вакансий нет.'
body = '\n\n'.join(render_card(v, i) for i, v in enumerate(rows, 1))
return f'{title}\n\n{body}'
def main() -> int:
parser = argparse.ArgumentParser(description='Render compact Telegram-friendly job report from vacancies JSON/JSONL')
parser.add_argument('input', help='vacancies json/jsonl path')
parser.add_argument('--out', help='output text file path')
parser.add_argument('--min-score', type=int, default=70)
parser.add_argument('--title', default='Свежий shortlist по job-search')
args = parser.parse_args()
vacancies = [
v for v in load_rows(args.input)
if (v.get('fit_score') or 0) >= args.min_score or v.get('fit_label') in {'strong-match', 'strong'}
]
text = render(vacancies, args.title)
if args.out:
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text, encoding='utf-8')
print(text)
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/profile_to_json.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
BULLET_RE = re.compile(r"^[-*]\s+(.*)$")
KV_RE = re.compile(r"^-?\s*([^:#]+):\s*(.*)$")
def slugify(text: str) -> str:
return text.strip().lower().replace("/", "_").replace(" ", "_")
def parse_md(path: str) -> dict:
lines = Path(path).read_text(encoding="utf-8").splitlines()
data: dict[str, object] = {}
current_key: str | None = None
current_list: list[str] = []
def flush_list():
nonlocal current_key, current_list
if current_key and current_list:
data[current_key] = current_list[:]
current_list = []
for raw in lines:
line = raw.strip()
if not line:
continue
if line.startswith("##"):
flush_list()
current_key = slugify(line.lstrip("# "))
continue
if line.startswith("#"):
continue
bullet = BULLET_RE.match(line)
if bullet and current_key:
item = bullet.group(1).strip()
if ":" in item and current_key not in {"core_stack", "priority_titles", "nice-to-have_titles", "companies", "agencies_/_patterns", "keywords"}:
k, v = item.split(":", 1)
data[slugify(k)] = v.strip()
else:
current_list.append(item)
continue
kv = KV_RE.match(line)
if kv:
flush_list()
key = slugify(kv.group(1))
value = kv.group(2).strip()
current_key = key
if value:
data[key] = value
else:
current_list = []
continue
flush_list()
return data
def to_profile(project_dir: str) -> dict:
project = Path(project_dir)
profile = parse_md(str(project / "PROFILE.md")) if (project / "PROFILE.md").exists() else {}
roles = parse_md(str(project / "TARGET_ROLES.md")) if (project / "TARGET_ROLES.md").exists() else {}
rules = parse_md(str(project / "SEARCH_RULES.md")) if (project / "SEARCH_RULES.md").exists() else {}
stack = profile.get("core_stack") or profile.get("stack") or []
if isinstance(stack, str):
stack = [stack]
target_roles = roles.get("priority_titles") or roles.get("target_roles") or []
if isinstance(target_roles, str):
target_roles = [target_roles]
exclude_keywords = rules.get("excluded_keywords") or rules.get("exclusions") or []
if isinstance(exclude_keywords, str):
exclude_keywords = [exclude_keywords]
salary_floor_raw = rules.get("salary_floor") or roles.get("floor")
salary_floor = None
if isinstance(salary_floor_raw, str):
digits = re.sub(r"\D", "", salary_floor_raw)
salary_floor = int(digits) if digits else None
remote_mode = str(roles.get("remote_/_hybrid_/_office") or profile.get("timezone_preference") or rules.get("remote_mode") or "").lower()
if "remote" not in remote_mode and "удал" in remote_mode:
remote_mode = "remote"
elif "hybrid" in remote_mode or "гибрид" in remote_mode:
remote_mode = "hybrid"
elif any(x in remote_mode for x in ["office", "офис"]):
remote_mode = "office"
else:
remote_mode = ""
return {
"target_roles": target_roles,
"stack": stack,
"remote_mode": remote_mode,
"salary_floor": salary_floor,
"exclude_keywords": exclude_keywords,
}
def main():
if len(sys.argv) != 2:
print("Usage: profile_to_json.py <project-dir>", file=sys.stderr)
sys.exit(2)
print(json.dumps(to_profile(sys.argv[1]), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/hh_raise_resumes.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from typing import Any
from hh_browser_cli import BrowserCli, BrowserCliError
HH_RESUMES_URL = "https://hh.ru/applicant/resumes"
def read_resume_cards_js(limit_titles: list[str] | None = None) -> str:
titles_json = json.dumps(limit_titles or [], ensure_ascii=False)
return rf"""
() => {{
const onlyWanted = new Set({titles_json});
const norm = (s) => (s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
const lines = (document.body.innerText || '').split('\n').map(norm).filter(Boolean);
const cards = [];
for (let i = 0; i < lines.length - 2; i++) {{
const title = lines[i];
const next = lines[i + 1] || '';
if (!title || title.length > 120) continue;
if (!next.includes('Уровень дохода')) continue;
if (onlyWanted.size && !onlyWanted.has(title)) continue;
const chunk = lines.slice(i, i + 12);
const statusLine = chunk.find(line => line.startsWith('Поднять')) || null;
if (!statusLine) continue;
const already = cards.find(c => c.title === title);
if (already) continue;
cards.push({{
title,
order: cards.length,
chunk,
statusLine,
cooldown: !!(statusLine && statusLine.startsWith('Поднять в ') && !statusLine.includes('поиске')),
available: !!(statusLine && statusLine.includes('Поднять в поиске')),
}});
}}
return cards;
}}
""".strip()
def click_first_available_raise_js() -> str:
return r'''
() => {
const norm = (s) => (s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
const controls = [...document.querySelectorAll('button,a,[role="button"]')]
.filter(el => norm(el.innerText || el.textContent || '').includes('Поднять в поиске'));
const el = controls[0];
if (!el) return {ok:false, status:'raise-button-not-found', count:controls.length};
const before = norm(el.innerText || el.textContent || '');
el.click();
return {ok:true, status:'clicked', before, count:controls.length};
}
'''.strip()
def modal_status_js() -> str:
return """
() => {
const text = document.body.innerText || '';
const visible = (el) => !!el && el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0;
const closeBtn = [...document.querySelectorAll('button,[role=\"button\"]')].find(el =>
visible(el) && (((el.getAttribute('aria-label') || '').includes('Закрыть')) || ['Закрыть', 'Отмена'].includes((el.innerText || '').trim()))
);
const outcome = {
success: text.includes('Готово, вы подняли резюме'),
cooldown: text.includes('Поднять снова можно через 4 часа'),
};
if (closeBtn && (outcome.success || outcome.cooldown)) closeBtn.click();
return outcome;
}
""".strip()
def main() -> int:
parser = argparse.ArgumentParser(description='Raise HH resumes via OpenClaw browser CLI')
parser.add_argument('--profile', default='chrome-relay')
parser.add_argument('--title', action='append', dest='titles', help='Resume title to raise (repeatable)')
parser.add_argument('--all-default', action='store_true', help='Compatibility flag; ignored when titles are not passed')
parser.add_argument('--wait-ms', type=int, default=1200)
args = parser.parse_args()
limit_titles = args.titles or None
browser = BrowserCli(profile=args.profile)
try:
browser.ensure_ready()
target_id = browser.current_target()
browser.navigate_js(HH_RESUMES_URL, target_id)
browser.wait_time(2200, target_id)
cards = browser.evaluate(read_resume_cards_js(limit_titles), target_id, retries=2).result or []
results: list[dict[str, Any]] = []
for card in cards:
item: dict[str, Any] = {'title': card.get('title'), 'card': card}
if card.get('cooldown'):
item['status'] = 'cooldown'
results.append(item)
continue
if not card.get('available'):
item['status'] = 'raise-button-not-found'
results.append(item)
continue
click = browser.evaluate(click_first_available_raise_js(), target_id, retries=1).result or {}
browser.wait_time(args.wait_ms, target_id)
modal = browser.evaluate(modal_status_js(), target_id, retries=2).result or {}
item['click'] = click
item['modal'] = modal
item['status'] = 'raised' if modal.get('success') else ('cooldown' if modal.get('cooldown') else click.get('status') or 'clicked')
results.append(item)
print(json.dumps({'ok': True, 'profile': args.profile, 'results': results}, ensure_ascii=False, indent=2))
return 0
except BrowserCliError as e:
print(json.dumps({'ok': False, 'error': str(e)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/hh_browser_cli.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import shutil
import subprocess
import time
from dataclasses import dataclass
from typing import Any
OPENCLAW_BIN = shutil.which("openclaw") or "openclaw"
DEFAULT_PROFILE = "chrome-relay"
class BrowserCliError(RuntimeError):
pass
@dataclass
class BrowserResult:
raw: dict[str, Any]
@property
def ok(self) -> bool:
return bool(self.raw.get("ok", True))
@property
def result(self) -> Any:
return self.raw.get("result")
@property
def url(self) -> str | None:
return self.raw.get("url")
@property
def target_id(self) -> str | None:
return self.raw.get("targetId")
class BrowserCli:
def __init__(self, profile: str = DEFAULT_PROFILE, timeout_ms: int = 30000):
self.profile = profile
self.timeout_ms = timeout_ms
def _run(self, args: list[str]) -> dict[str, Any]:
cmd = [OPENCLAW_BIN, "browser", "--browser-profile", self.profile, "--timeout", str(self.timeout_ms), "--json", *args]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise BrowserCliError(proc.stderr.strip() or proc.stdout.strip() or f"browser command failed: {' '.join(cmd)}")
out = proc.stdout.strip()
if not out:
return {}
try:
return json.loads(out)
except json.JSONDecodeError as e:
raise BrowserCliError(f"failed to decode browser JSON: {e}\nSTDOUT:\n{out}") from e
def status(self) -> dict[str, Any]:
return self._run(["status"])
def tabs(self) -> dict[str, Any]:
return self._run(["tabs"])
def navigate(self, url: str, target_id: str | None = None) -> BrowserResult:
args = ["navigate", url]
if target_id:
args += ["--target-id", target_id]
return BrowserResult(self._run(args))
def evaluate(self, fn: str, target_id: str | None = None, retries: int = 0, retry_delay_ms: int = 1200) -> BrowserResult:
args = ["evaluate", "--fn", fn]
if target_id:
args += ["--target-id", target_id]
attempt = 0
while True:
try:
return BrowserResult(self._run(args))
except BrowserCliError as e:
msg = str(e)
retryable = "Execution context was destroyed" in msg or "ERR_ABORTED" in msg
if not retryable or attempt >= retries:
raise
time.sleep(retry_delay_ms / 1000)
attempt += 1
def current_page(self, target_id: str | None = None) -> BrowserResult:
return self.evaluate("() => ({title: document.title, url: location.href, origin: location.origin})", target_id, retries=1)
def navigate_js(self, url: str, target_id: str | None = None) -> BrowserResult:
payload = json.dumps(url, ensure_ascii=False)
try:
return self.evaluate(f"() => {{ window.location.href = {payload}; return {{navigatingTo: {payload}}}; }}", target_id)
except BrowserCliError as e:
msg = str(e)
if "Execution context was destroyed" in msg or "ERR_ABORTED" in msg:
return BrowserResult({"ok": True, "result": {"navigatingTo": url}})
raise
def wait_time(self, ms: int, target_id: str | None = None) -> BrowserResult:
args = ["wait", "--time", str(ms)]
if target_id:
args += ["--target-id", target_id]
return BrowserResult(self._run(args))
def ensure_ready(self) -> None:
data = self.status()
if not data.get("running") or not data.get("cdpReady"):
raise BrowserCliError(f"browser profile {self.profile!r} is not attached/ready: {json.dumps(data, ensure_ascii=False)}")
def current_target(self) -> str:
tabs = self.tabs().get("tabs") or []
if not tabs:
raise BrowserCliError(f"browser profile {self.profile!r} has no attached tabs")
return tabs[0]["targetId"]
FILE:scripts/score_vacancy_from_project.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from profile_to_json import to_profile
def main():
if len(sys.argv) != 3:
print("Usage: score_vacancy_from_project.py <project-dir> <vacancy.json>", file=sys.stderr)
sys.exit(2)
project_dir, vacancy_json = sys.argv[1], sys.argv[2]
profile = to_profile(project_dir)
with tempfile.NamedTemporaryFile("w", suffix=".json", encoding="utf-8", delete=False) as tmp:
json.dump(profile, tmp, ensure_ascii=False, indent=2)
tmp_path = tmp.name
try:
script = Path(__file__).with_name("job_match_score.py")
subprocess.run([sys.executable, str(script), tmp_path, vacancy_json], check=True)
finally:
Path(tmp_path).unlink(missing_ok=True)
if __name__ == "__main__":
main()
FILE:scripts/batch_normalize_vacancies.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def main():
if len(sys.argv) != 4:
print("Usage: batch_normalize_vacancies.py <source> <input-dir> <out.jsonl>", file=sys.stderr)
sys.exit(2)
source, input_dir, out_path = sys.argv[1], Path(sys.argv[2]), Path(sys.argv[3])
normalizer = Path(__file__).with_name("normalize_vacancy.py")
rows = []
for path in sorted([p for p in input_dir.iterdir() if p.is_file()]):
proc = subprocess.run([sys.executable, str(normalizer), source, str(path)], capture_output=True, text=True, check=True)
rows.append(json.loads(proc.stdout))
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"Normalized {len(rows)} vacancies from {input_dir} -> {out_path}")
if __name__ == "__main__":
main()
FILE:scripts/parse_telegram_job.py
#!/usr/bin/env python3
import re
import sys
from pathlib import Path
from job_models import Vacancy, dump_json, extract_stack, normalize_remote_mode, normalize_salary_text, normalize_seniority
TITLE_RE = re.compile(r"^(?:вакансия|ищем|position|role)?\s*[:\-— ]*([^\n]{5,120})", re.I)
COMPANY_RE = re.compile(r"(?:компания|company)\s*[:\-—]\s*([^\n]+)", re.I)
CONTACT_RE = re.compile(r"(@[A-Za-z0-9_]{4,}|https?://t\.me/[^\s]+)")
URL_RE = re.compile(r"https?://[^\s]+")
def first_match(pattern, text):
m = pattern.search(text)
return m.group(1).strip() if m else None
def main():
if len(sys.argv) != 2:
print("Usage: parse_telegram_job.py <post.txt>", file=sys.stderr)
sys.exit(2)
text = Path(sys.argv[1]).read_text(encoding="utf-8")
lines = [line.strip("•- ") for line in text.splitlines() if line.strip()]
title = first_match(TITLE_RE, text) or (lines[0] if lines else None)
company = first_match(COMPANY_RE, text)
salary_line = next((line for line in lines if any(x in line.lower() for x in ["₽", "руб", "$", "usd", "зарплат"])), "")
salary = normalize_salary_text(salary_line) if salary_line else {}
urls = URL_RE.findall(text)
contacts = CONTACT_RE.findall(text)
vacancy = Vacancy(
source="telegram",
source_url=urls[0] if urls else None,
company=company,
title=title,
stack=extract_stack(text),
seniority=normalize_seniority(text),
location=None,
remote_mode=normalize_remote_mode(text),
contact_url=contacts[0] if contacts else None,
summary=" ".join(lines[:6])[:500] if lines else None,
raw_text=text[:4000],
**salary,
)
print(dump_json(vacancy.model_dump()))
if __name__ == "__main__":
main()
FILE:scripts/pipeline_add.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from datetime import date
from pathlib import Path
from job_models import Vacancy
def fmt_salary(v: Vacancy) -> str:
if v.salary_min and v.salary_max:
return f"{v.salary_min}-{v.salary_max} {v.salary_currency or ''}".strip()
if v.salary_min:
return f"{v.salary_min} {v.salary_currency or ''}".strip()
return ""
def md_escape(text: str) -> str:
return text.replace("|", "\\|").replace("\n", " ")
def main():
if len(sys.argv) not in (3, 4):
print("Usage: pipeline_add.py <project-dir> <vacancy.json> [status]", file=sys.stderr)
sys.exit(2)
project_dir = Path(sys.argv[1])
vacancy = Vacancy.model_validate(json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")))
status = sys.argv[3] if len(sys.argv) == 4 else (vacancy.status or "found")
pipeline = project_dir / "PIPELINE.md"
if not pipeline.exists():
raise SystemExit(f"PIPELINE.md not found in {project_dir}")
row = "| {date_found} | {source} | {company} | {title} | {url} | {fit_label} | {fit_score} | {salary} | {location} | {status} | {last_action} | {next_follow_up} | {notes} |\n".format(
date_found=date.today().isoformat(),
source=md_escape(vacancy.source or ""),
company=md_escape(vacancy.company or ""),
title=md_escape(vacancy.title or ""),
url=md_escape(vacancy.source_url or ""),
fit_label=md_escape(vacancy.fit_label or ""),
fit_score=vacancy.fit_score or "",
salary=md_escape(fmt_salary(vacancy)),
location=md_escape(vacancy.location or vacancy.remote_mode or ""),
status=md_escape(status),
last_action="added",
next_follow_up="",
notes=md_escape("; ".join(vacancy.fit_reasons[:3])),
)
with pipeline.open("a", encoding="utf-8") as f:
f.write(row)
print(f"Added pipeline row for: {vacancy.company or '-'} / {vacancy.title or '-'}")
if __name__ == "__main__":
main()
FILE:scripts/dedupe_vacancies.py
#!/usr/bin/env python3
import json
import sys
from collections import defaultdict
from rapidfuzz import fuzz
from job_models import Vacancy, dump_json, vacancy_key
def load_jsonl(path: str):
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
yield Vacancy.model_validate(json.loads(line))
def similar(a: Vacancy, b: Vacancy) -> bool:
if a.source_url and b.source_url and a.source_url == b.source_url:
return True
if vacancy_key(a) == vacancy_key(b) and vacancy_key(a) != "::":
return True
title_score = fuzz.token_set_ratio(a.title or "", b.title or "")
company_score = fuzz.token_set_ratio(a.company or "", b.company or "")
return title_score >= 92 and company_score >= 85
def main():
if len(sys.argv) != 2:
print("Usage: dedupe_vacancies.py <vacancies.jsonl>", file=sys.stderr)
sys.exit(2)
groups = []
for vacancy in load_jsonl(sys.argv[1]):
placed = False
for group in groups:
if similar(vacancy, group[0]):
group.append(vacancy)
placed = True
break
if not placed:
groups.append([vacancy])
result = []
for group in groups:
canonical = max(group, key=lambda v: len((v.summary or "")) + len((v.raw_text or "")))
duplicates = [v.model_dump() for v in group if v is not canonical]
result.append({
"canonical": canonical.model_dump(),
"duplicates": duplicates,
"count": len(group),
})
print(dump_json(result))
if __name__ == "__main__":
main()
FILE:scripts/score_vacancies_jsonl.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from profile_to_json import to_profile
def main():
if len(sys.argv) != 4:
print("Usage: score_vacancies_jsonl.py <project-dir> <in.jsonl> <out.jsonl>", file=sys.stderr)
sys.exit(2)
project_dir, in_path, out_path = sys.argv[1], Path(sys.argv[2]), Path(sys.argv[3])
profile = to_profile(project_dir)
score_script = Path(__file__).with_name("job_match_score.py")
with tempfile.NamedTemporaryFile("w", suffix=".json", encoding="utf-8", delete=False) as pf:
json.dump(profile, pf, ensure_ascii=False, indent=2)
profile_path = pf.name
out_path.parent.mkdir(parents=True, exist_ok=True)
try:
with in_path.open(encoding="utf-8") as src, out_path.open("w", encoding="utf-8") as dst:
for line in src:
line = line.strip()
if not line:
continue
vacancy = json.loads(line)
with tempfile.NamedTemporaryFile("w", suffix=".json", encoding="utf-8", delete=False) as vf:
json.dump(vacancy, vf, ensure_ascii=False, indent=2)
vacancy_path = vf.name
proc = subprocess.run([sys.executable, str(score_script), profile_path, vacancy_path], capture_output=True, text=True, check=True)
score = json.loads(proc.stdout)
vacancy.update(score)
dst.write(json.dumps(vacancy, ensure_ascii=False) + "\n")
Path(vacancy_path).unlink(missing_ok=True)
finally:
Path(profile_path).unlink(missing_ok=True)
print(f"Scored vacancies -> {out_path}")
if __name__ == "__main__":
main()
FILE:scripts/job_models.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field
RemoteMode = Literal["remote", "hybrid", "office", "unknown"]
FitLabel = Literal["strong-match", "possible-match", "skip"]
class Vacancy(BaseModel):
source: str
source_url: str | None = None
company: str | None = None
title: str | None = None
stack: list[str] = Field(default_factory=list)
seniority: str | None = None
salary_min: int | None = None
salary_max: int | None = None
salary_currency: str | None = None
gross_or_net: str | None = None
location: str | None = None
remote_mode: RemoteMode = "unknown"
employment_type: str | None = None
visa_or_relocation: str | None = None
english_level: str | None = None
contact_name: str | None = None
contact_url: str | None = None
summary: str | None = None
fit_score: int | None = None
fit_label: FitLabel | None = None
fit_reasons: list[str] = Field(default_factory=list)
red_flags: list[str] = Field(default_factory=list)
status: str | None = None
raw_text: str | None = None
RU_MONTH = re.compile(r"(руб|₽|р\b|руб\.)", re.I)
USD_MONTH = re.compile(r"(usd|\$|долл)", re.I)
K_SUFFIX = re.compile(r"(\d+(?:[.,]\d+)?)\s*k\b", re.I)
RANGE_RE = re.compile(r"(?P<a>\d[\d\s.,]*)\s*(?:[-–—]|to|до)\s*(?P<b>\d[\d\s.,]*)", re.I)
SINGLE_RE = re.compile(r"(?P<a>\d[\d\s.,]*)")
KNOWN_STACK = [
"python", "java", "kotlin", "go", "golang", "javascript", "typescript", "react", "vue",
"angular", "node", "node.js", "django", "flask", "fastapi", "spring", "postgresql",
"mysql", "redis", "kafka", "docker", "kubernetes", "aws", "gcp", "linux", "c#", ".net",
"php", "laravel", "1c", "rust", "scala", "clickhouse", "airflow", "gitlab", "ci/cd",
]
SENIORITY_MAP = {
"junior": "junior",
"middle": "middle",
"mid": "middle",
"senior": "senior",
"lead": "lead",
"staff": "staff",
"principal": "principal",
}
def read_text(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
def read_json(path: str) -> Any:
return json.loads(read_text(path))
def dump_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)
def clean_num(s: str) -> int:
s = s.replace(" ", "").replace(",", ".")
return int(float(s))
def normalize_currency(text: str) -> str | None:
if RU_MONTH.search(text):
return "RUB"
if USD_MONTH.search(text):
return "USD"
return None
def normalize_remote_mode(text: str) -> RemoteMode:
t = text.lower()
if any(x in t for x in ["удален", "remote", "дистанц"]):
return "remote"
if any(x in t for x in ["гибрид", "hybrid"]):
return "hybrid"
if any(x in t for x in ["офис", "office", "on-site", "onsite"]):
return "office"
return "unknown"
def normalize_seniority(text: str) -> str | None:
t = text.lower()
for key, value in SENIORITY_MAP.items():
if key in t:
return value
return None
def extract_stack(text: str) -> list[str]:
t = text.lower()
found = []
for item in KNOWN_STACK:
if item in t:
canonical = "Node.js" if item == "node.js" else item
found.append(canonical)
return sorted(dict.fromkeys(found), key=str.lower)
def normalize_salary_text(text: str) -> dict[str, Any]:
t = text.strip()
t = K_SUFFIX.sub(lambda m: str(float(m.group(1).replace(',', '.')) * 1000), t)
currency = normalize_currency(t)
gross_or_net = None
low = high = None
if "gross" in t.lower() or "до вычета" in t.lower() or "гряз" in t.lower():
gross_or_net = "gross"
elif "net" in t.lower() or "на руки" in t.lower() or "чист" in t.lower():
gross_or_net = "net"
m = RANGE_RE.search(t)
if m:
low = clean_num(m.group("a"))
high = clean_num(m.group("b"))
else:
m = SINGLE_RE.search(t)
if m:
low = clean_num(m.group("a"))
high = low
return {
"salary_min": low,
"salary_max": high,
"salary_currency": currency,
"gross_or_net": gross_or_net,
}
def vacancy_key(v: Vacancy) -> str:
company = (v.company or "").strip().lower()
title = (v.title or "").strip().lower()
return f"{company}::{title}"
FILE:scripts/batch_parse_telegram_jobs.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def main():
if len(sys.argv) != 3:
print("Usage: batch_parse_telegram_jobs.py <posts-dir> <out.jsonl>", file=sys.stderr)
sys.exit(2)
posts_dir = Path(sys.argv[1])
out_path = Path(sys.argv[2])
parser = Path(__file__).with_name("parse_telegram_job.py")
rows = []
for path in sorted(posts_dir.glob("*.txt")):
proc = subprocess.run([sys.executable, str(parser), str(path)], capture_output=True, text=True, check=True)
rows.append(json.loads(proc.stdout))
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"Parsed {len(rows)} Telegram posts -> {out_path}")
if __name__ == "__main__":
main()
FILE:scripts/render_delivery_delta.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def load(path: str):
p = Path(path)
text = p.read_text(encoding='utf-8').strip()
if not text:
return []
return json.loads(text)
def delivered_keys(delivery_path: str | None) -> set[str]:
if not delivery_path:
return set()
p = Path(delivery_path)
if not p.exists():
return set()
data = json.loads(p.read_text(encoding='utf-8'))
items = data.get('delivered_vacancies') or []
keys = set()
for item in items:
company = (item.get('company') or '').strip().lower()
title = (item.get('title') or '').strip().lower()
url = (item.get('url') or '').strip().lower()
if url:
keys.add('url:' + url)
if company or title:
keys.add('ct:' + company + '::' + title)
return keys
def item_key(v: dict) -> tuple[str, str]:
url = (v.get('source_url') or v.get('url') or '').strip().lower()
company = (v.get('company') or '').strip().lower()
title = (v.get('title') or '').strip().lower()
return ('url:' + url if url else '', 'ct:' + company + '::' + title)
def salary_text(v: dict) -> str:
salary = v.get('salary')
if salary and salary != 'not listed':
return str(salary)
low = v.get('salary_min')
high = v.get('salary_max')
cur = v.get('salary_currency') or ''
if low is None and high is None:
return 'не указана'
if low and high and low != high:
return f"{low:,}-{high:,} {cur}".replace(',', ' ')
return f"{(high or low):,} {cur}".replace(',', ' ')
def render_card(v: dict, idx: int) -> str:
notes = v.get('notes') or 'нужна ручная проверка'
return '\n'.join([
f"{idx}. {v.get('title') or 'Без названия'} — {v.get('company') or 'Без компании'}",
f" Источник: {v.get('source') or 'unknown'}",
f" Ссылка: {v.get('source_url') or v.get('url') or 'нет'}",
f" Формат: {v.get('remote_mode') or v.get('location') or 'unknown'}",
f" Зарплата: {salary_text(v)}",
f" Match: {(v.get('fit_score') if v.get('fit_score') is not None else '?')}/100 · {v.get('fit_label') or 'unknown'}",
f" Почему ок: {notes[:220]}",
])
def main() -> int:
ap = argparse.ArgumentParser(description='Render delivery delta from raw export + previous delivery memory')
ap.add_argument('raw_json')
ap.add_argument('--delivery-json')
ap.add_argument('--min-score', type=int, default=70)
ap.add_argument('--title', default='Свежий shortlist по job-search')
args = ap.parse_args()
rows = load(args.raw_json)
seen = delivered_keys(args.delivery_json)
keep = []
for v in rows:
score = v.get('fit_score') or 0
label = v.get('fit_label')
status = v.get('status')
if label == 'skip' or status == 'skip':
continue
if score < args.min_score and label not in {'strong', 'strong-match'}:
continue
k1, k2 = item_key(v)
if (k1 and k1 in seen) or k2 in seen:
continue
keep.append(v)
if not keep:
print(args.title + '\n\nНовых релевантных вакансий для отправки нет.')
return 0
print(args.title + '\n')
for i, v in enumerate(keep, 1):
print(render_card(v, i))
if i != len(keep):
print('\n')
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/requirements.txt
pydantic>=2,<3
rapidfuzz>=3,<4
FILE:scripts/hh_next_raise_at.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from hh_browser_cli import BrowserCli, BrowserCliError
from job_search_probe import read_resume_cards_js
HH_RESUMES_URL = "https://hh.ru/applicant/resumes"
TIME_RE = re.compile(r"Поднять в (\d{1,2}):(\d{2})")
def compute_next_iso(status_lines: list[str], tz_name: str, now: datetime | None = None) -> str | None:
tz = ZoneInfo(tz_name)
now = now or datetime.now(tz)
candidates: list[datetime] = []
for line in status_lines:
m = TIME_RE.search(line or "")
if not m:
continue
hour = int(m.group(1))
minute = int(m.group(2))
dt = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if dt <= now:
dt += timedelta(days=1)
dt += timedelta(minutes=5)
candidates.append(dt)
if not candidates:
return None
return min(candidates).isoformat()
def main() -> int:
ap = argparse.ArgumentParser(description='Compute next HH resume raise time from current cooldowns')
ap.add_argument('--profile', default='chrome-relay')
ap.add_argument('--tz', default='Asia/Krasnoyarsk')
args = ap.parse_args()
browser = BrowserCli(profile=args.profile)
result = {"ok": True}
try:
browser.ensure_ready()
target_id = browser.current_target()
browser.navigate_js(HH_RESUMES_URL, target_id)
browser.wait_time(2200, target_id)
cards = browser.evaluate(read_resume_cards_js(None), target_id, retries=2).result or []
lines = [c.get('statusLine') or '' for c in cards]
next_at = compute_next_iso(lines, args.tz)
result.update({"resumes": cards, "nextAt": next_at})
except BrowserCliError as e:
result = {"ok": False, "error": str(e), "nextAt": None}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/hh_apply_batch.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import sys
from typing import Any
from hh_browser_cli import BrowserCli, BrowserCliError
APPLIED_PATTERNS = [r"Вы\s*откликнулись", r"Резюме доставлено"]
QUESTION_PATTERNS = [r"вопрос", r"сопроводительное", r"textarea", r"ответ"]
def js(s: str) -> str:
return json.dumps(s, ensure_ascii=False)
def choose_resume_title(vacancy_title: str) -> str:
t = vacancy_title.lower()
if any(x in t for x in ["ml", "mlo", "machine learning", "инженер"]):
return "ML-инженер"
return "Data Scientist"
def page_probe_js() -> str:
applied_union = "|".join(APPLIED_PATTERNS)
return rf'''
() => {{
const text = document.body.innerText || '';
const title = (document.querySelector('h1') || {{innerText: ''}}).innerText || document.title;
const visible = (el) => !!el && el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0;
const buttons = [...document.querySelectorAll('button,a,[role="button"]')].filter(visible).map(el => (el.innerText || '').trim()).filter(Boolean);
return {{
title,
alreadyApplied: /{applied_union}/i.test(text),
hasRespondButton: buttons.some(t => /Откликнуться/i.test(t)),
text: text.slice(0, 10000)
}};
}}
'''.strip()
def click_respond_js() -> str:
return r'''
() => {
const visible = (el) => !!el && el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0;
const buttons = [...document.querySelectorAll('button,a,[role="button"]')]
.filter(el => visible(el) && /Откликнуться/i.test(el.innerText || ''))
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
if (!buttons.length) return {ok:false, status:'respond-button-not-found'};
buttons[0].click();
return {ok:true, status:'clicked-respond'};
}
'''.strip()
def modal_probe_js(resume_title: str) -> str:
question_union = "|".join(QUESTION_PATTERNS)
return f'''
() => {{
const wanted = {js(resume_title)};
const text = document.body.innerText || '';
const visible = (el) => !!el && el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0;
const modalTexts = [...document.querySelectorAll('dialog, [role="dialog"], [aria-modal="true"]')].filter(visible).map(el => (el.innerText || '').trim());
const modalText = modalTexts.join("\n");
const resumeButton = [...document.querySelectorAll('button,[role="button"]')].find(el => visible(el) && (el.innerText || '').includes(wanted));
const applyButton = [...document.querySelectorAll('button,[role="button"]')].find(el => visible(el) && /^Откликнуться$/i.test((el.innerText || '').trim()));
const textarea = [...document.querySelectorAll('textarea,input')].find(el => visible(el));
return {{
wanted,
hasWantedResume: !!resumeButton,
hasApplyButton: !!applyButton,
hasTextarea: !!textarea,
modalText,
needsReview: /{question_union}/i.test(modalText) || !!textarea,
}};
}}
'''.strip()
def click_apply_js(resume_title: str) -> str:
return f'''
() => {{
const wanted = {js(resume_title)};
const visible = (el) => !!el && el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0;
const resumeButton = [...document.querySelectorAll('button,[role="button"]')].find(el => visible(el) && (el.innerText || '').includes(wanted));
if (resumeButton) resumeButton.click();
const applyButton = [...document.querySelectorAll('button,[role="button"]')].find(el => visible(el) && /^Откликнуться$/i.test((el.innerText || '').trim()));
if (!applyButton) return {{ok:false, status:'apply-button-not-found', wanted}};
applyButton.click();
return {{ok:true, status:'clicked-apply', wanted}};
}}
'''.strip()
def verify_applied_js() -> str:
applied_union = "|".join(APPLIED_PATTERNS)
return rf'''
() => {{
const text = document.body.innerText || '';
return {{
applied: /{applied_union}/i.test(text),
snippets: (text.match(/.{{0,40}}(Вы\s*откликнулись|Резюме доставлено|Произошла ошибка).{{0,80}}/gi) || []).slice(0, 8)
}};
}}
'''.strip()
def read_resume_cards_js() -> str:
return r'''
() => {
const norm = (s) => (s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
const lines = (document.body.innerText || '').split('\n').map(norm).filter(Boolean);
const cards = [];
for (let i = 0; i < lines.length - 2; i++) {
const title = lines[i];
const next = lines[i + 1] || '';
if (!title || title.length > 120) continue;
if (!next.includes('Уровень дохода')) continue;
const chunk = lines.slice(i, i + 12);
const statusLine = chunk.find(line => line.startsWith('Поднять')) || null;
if (!statusLine) continue;
if (cards.find(c => c.title === title)) continue;
cards.push({
title,
order: cards.length,
statusLine,
cooldown: !!(statusLine && statusLine.startsWith('Поднять в ') && !statusLine.includes('поиске')),
available: !!(statusLine && statusLine.includes('Поднять в поиске')),
});
}
return cards;
}
'''.strip()
def click_first_available_raise_js() -> str:
return r'''
() => {
const norm = (s) => (s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
const controls = [...document.querySelectorAll('button,a,[role="button"]')]
.filter(el => norm(el.innerText || el.textContent || '').includes('Поднять в поиске'));
const el = controls[0];
if (!el) return {ok:false, status:'raise-button-not-found', count:controls.length};
controls[0].click();
return {ok:true, status:'clicked', count:controls.length};
}
'''.strip()
def raise_available_resumes(browser: BrowserCli, target_id: str, origin: str, wait_ms: int) -> list[dict[str, Any]]:
browser.navigate_js(f'{origin}/applicant/resumes', target_id)
browser.wait_time(2200, target_id)
cards = browser.evaluate(read_resume_cards_js(), target_id, retries=2).result or []
results: list[dict[str, Any]] = []
for card in cards:
item: dict[str, Any] = {'title': card.get('title'), 'status_before': card.get('statusLine')}
if card.get('cooldown'):
item['status'] = 'cooldown'
results.append(item)
continue
if not card.get('available'):
item['status'] = 'raise-button-not-found'
results.append(item)
continue
click = browser.evaluate(click_first_available_raise_js(), target_id, retries=1).result or {}
browser.wait_time(wait_ms, target_id)
refreshed = browser.evaluate(read_resume_cards_js(), target_id, retries=2).result or []
current = next((c for c in refreshed if c.get('title') == card.get('title')), None)
item['click'] = click
item['status_after'] = (current or {}).get('statusLine')
item['status'] = 'raised' if (current or {}).get('cooldown') else (click.get('status') or 'clicked')
results.append(item)
return results
def main() -> int:
parser = argparse.ArgumentParser(description="Apply to HH vacancies via OpenClaw browser CLI")
parser.add_argument("urls", nargs="+", help="HH vacancy URLs")
parser.add_argument("--profile", default="chrome-relay")
parser.add_argument("--wait-ms", type=int, default=1200)
parser.add_argument("--skip-raise-after-apply", action="store_true", help="Do not refresh applicant resumes and raise available resumes after a successful apply")
args = parser.parse_args()
browser = BrowserCli(profile=args.profile)
try:
browser.ensure_ready()
target_id = browser.current_target()
current = browser.current_page(target_id).result or {}
origin = str(current.get("origin") or "https://hh.ru")
results: list[dict[str, Any]] = []
for url in args.urls:
normalized = re.sub(r"^https?://[^/]+", origin, url)
browser.navigate_js(normalized, target_id)
browser.wait_time(2200, target_id)
probe = browser.evaluate(page_probe_js(), target_id).result or {}
vacancy_title = str(probe.get("title") or "")
chosen_resume = choose_resume_title(vacancy_title)
item: dict[str, Any] = {"url": normalized, "title": vacancy_title, "chosen_resume": chosen_resume}
if probe.get("alreadyApplied"):
item["status"] = "already-applied"
results.append(item)
continue
if not probe.get("hasRespondButton"):
item["status"] = "no-respond-button"
results.append(item)
continue
item["respond_click"] = browser.evaluate(click_respond_js(), target_id).result or {}
browser.wait_time(args.wait_ms, target_id)
modal = browser.evaluate(modal_probe_js(chosen_resume), target_id).result or {}
item["modal"] = modal
if modal.get("needsReview"):
item["status"] = "requires-review"
results.append(item)
continue
item["apply_click"] = browser.evaluate(click_apply_js(chosen_resume), target_id).result or {}
browser.wait_time(args.wait_ms, target_id)
verify = browser.evaluate(verify_applied_js(), target_id).result or {}
item["verify"] = verify
item["status"] = "applied" if verify.get("applied") else "apply-unconfirmed"
if item["status"] == "applied" and not args.skip_raise_after_apply:
item["post_apply_raise"] = raise_available_resumes(browser, target_id, origin, args.wait_ms)
browser.navigate_js(normalized, target_id)
browser.wait_time(args.wait_ms, target_id)
results.append(item)
print(json.dumps({"ok": True, "profile": args.profile, "results": results}, ensure_ascii=False, indent=2))
return 0
except BrowserCliError as e:
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/normalize_salary.py
#!/usr/bin/env python3
import json
import sys
from job_models import dump_json, normalize_salary_text
def main():
if len(sys.argv) < 2:
print("Usage: normalize_salary.py '<salary text>'", file=sys.stderr)
sys.exit(2)
text = " ".join(sys.argv[1:])
print(dump_json(normalize_salary_text(text)))
if __name__ == "__main__":
main()
FILE:scripts/canonicalize_deduped.py
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
def main():
if len(sys.argv) != 3:
print("Usage: canonicalize_deduped.py <deduped.json> <out.jsonl>", file=sys.stderr)
sys.exit(2)
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
out = Path(sys.argv[2])
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w", encoding="utf-8") as f:
for group in data:
f.write(json.dumps(group["canonical"], ensure_ascii=False) + "\n")
print(f"Wrote canonical JSONL -> {out}")
if __name__ == "__main__":
main()
FILE:scripts/normalize_vacancy.py
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
from job_models import Vacancy, dump_json, extract_stack, normalize_remote_mode, normalize_salary_text, normalize_seniority
def main():
if len(sys.argv) != 3:
print("Usage: normalize_vacancy.py <source> <input-file>", file=sys.stderr)
sys.exit(2)
source, path = sys.argv[1], sys.argv[2]
text = Path(path).read_text(encoding="utf-8")
try:
raw = json.loads(text)
except json.JSONDecodeError:
raw = {"title": None, "company": None, "description": text, "url": None, "salary": None}
title = raw.get("title") or raw.get("name") or raw.get("vacancy")
company = raw.get("company") or raw.get("employer") or raw.get("organization")
description = raw.get("description") or raw.get("summary") or text
salary_text = raw.get("salary") or raw.get("compensation") or ""
vacancy = Vacancy(
source=source,
source_url=raw.get("url") or raw.get("link"),
title=title,
company=company,
stack=extract_stack(" ".join([str(title or ""), str(description or "") ])),
seniority=normalize_seniority(" ".join([str(title or ""), str(description or "") ])),
remote_mode=normalize_remote_mode(" ".join([str(raw.get('remote_mode') or ''), str(description or '')])),
summary=str(description)[:800],
raw_text=str(description)[:4000],
**normalize_salary_text(str(salary_text)),
)
print(dump_json(vacancy.model_dump()))
if __name__ == "__main__":
main()
FILE:scripts/export_shortlist.py
#!/usr/bin/env python3
from __future__ import annotations
import csv
import json
import sys
from pathlib import Path
from job_models import Vacancy
FIELDS = ["source", "company", "title", "source_url", "fit_label", "fit_score", "salary_min", "salary_max", "salary_currency", "remote_mode", "seniority"]
def load_jsonl(path: str):
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
yield Vacancy.model_validate(json.loads(line))
def main():
if len(sys.argv) not in (3, 4):
print("Usage: export_shortlist.py <vacancies.jsonl> <out.csv> [min_score]", file=sys.stderr)
sys.exit(2)
min_score = int(sys.argv[3]) if len(sys.argv) == 4 else 60
rows = [v for v in load_jsonl(sys.argv[1]) if (v.fit_score or 0) >= min_score or v.fit_label == "strong-match"]
out = Path(sys.argv[2])
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for v in rows:
writer.writerow({k: getattr(v, k) for k in FIELDS})
print(f"Exported {len(rows)} shortlisted vacancies -> {out}")
if __name__ == "__main__":
main()
FILE:scripts/init_job_search_project.py
#!/usr/bin/env python3
import shutil
import sys
from pathlib import Path
TEMPLATES = [
"README-START.md",
"PROFILE.md",
"TARGET_ROLES.md",
"SEARCH_RULES.md",
"SOURCES.md",
"PIPELINE.md",
"OUTREACH_RULES.md",
"BLACKLIST.md",
]
def main():
if len(sys.argv) != 2:
print("Usage: init_job_search_project.py <target-dir>", file=sys.stderr)
sys.exit(2)
target = Path(sys.argv[1])
root = Path(__file__).resolve().parent.parent
tpl_dir = root / "assets" / "templates"
target.mkdir(parents=True, exist_ok=True)
(target / "applications").mkdir(exist_ok=True)
(target / "exports").mkdir(exist_ok=True)
(target / "logs").mkdir(exist_ok=True)
for name in TEMPLATES:
dst = target / name
if not dst.exists():
shutil.copy2(tpl_dir / name, dst)
print(f"Initialized job-search project at {target}")
if __name__ == "__main__":
main()
FILE:scripts/job_match_score.py
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
def load_json(path: str):
return json.loads(Path(path).read_text(encoding="utf-8"))
def overlap(a, b):
sa = {x.strip().lower() for x in a if str(x).strip()}
sb = {x.strip().lower() for x in b if str(x).strip()}
return sorted(sa & sb)
def main():
if len(sys.argv) != 3:
print("Usage: job_match_score.py <profile.json> <vacancy.json>", file=sys.stderr)
sys.exit(2)
profile = load_json(sys.argv[1])
vacancy = load_json(sys.argv[2])
score = 0
reasons = []
penalties = []
profile_stack = profile.get("stack", [])
vacancy_stack = vacancy.get("stack", [])
shared_stack = overlap(profile_stack, vacancy_stack)
score += min(len(shared_stack) * 12, 48)
if shared_stack:
reasons.append(f"shared stack: {', '.join(shared_stack)}")
target_roles = [x.lower() for x in profile.get("target_roles", [])]
title = str(vacancy.get("title", "")).lower()
if any(role in title for role in target_roles):
score += 20
reasons.append("title matches target roles")
profile_remote = str(profile.get("remote_mode", "")).lower()
vacancy_remote = str(vacancy.get("remote_mode", "")).lower()
if profile_remote and vacancy_remote and profile_remote == vacancy_remote:
score += 10
reasons.append("remote mode matches")
salary_floor = profile.get("salary_floor")
salary_max = vacancy.get("salary_max") or vacancy.get("salary_min")
if salary_floor and salary_max:
if salary_max >= salary_floor:
score += 15
reasons.append("salary meets floor")
else:
score -= 15
penalties.append("salary below floor")
exclude = [x.lower() for x in profile.get("exclude_keywords", [])]
if any(word in title for word in exclude):
score -= 30
penalties.append("excluded keyword in title")
fit = "skip"
if score >= 60:
fit = "strong-match"
elif score >= 35:
fit = "possible-match"
print(json.dumps({
"fit_label": fit,
"fit_score": score,
"fit_reasons": reasons,
"reasons": reasons,
"penalties": penalties,
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/job_search_probe.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from hh_browser_cli import BrowserCli, BrowserCliError
HH_RESUMES_URL = "https://hh.ru/applicant/resumes"
def read_resume_cards_js(limit_titles: list[str] | None = None) -> str:
titles_json = json.dumps(limit_titles or [], ensure_ascii=False)
return rf"""
() => {{
const onlyWanted = new Set({titles_json});
const norm = (s) => (s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
const lines = (document.body.innerText || '').split('\n').map(norm).filter(Boolean);
const cards = [];
for (let i = 0; i < lines.length - 2; i++) {{
const title = lines[i];
const next = lines[i + 1] || '';
if (!title || title.length > 120) continue;
if (!next.includes('Уровень дохода')) continue;
if (onlyWanted.size && !onlyWanted.has(title)) continue;
const chunk = lines.slice(i, i + 12);
const statusLine = chunk.find(line => line.startsWith('Поднять')) || null;
if (!statusLine) continue;
const already = cards.find(c => c.title === title);
if (already) continue;
cards.push({{
title,
order: cards.length,
statusLine,
cooldown: !!(statusLine && statusLine.startsWith('Поднять в ') && !statusLine.includes('поиске')),
available: !!(statusLine && statusLine.includes('Поднять в поиске')),
chunk,
}});
}}
return cards;
}}
""".strip()
def load_json(path: str):
text = Path(path).read_text(encoding='utf-8').strip()
if not text:
return []
return json.loads(text)
def delivered_keys(delivery_path: str | None) -> set[str]:
if not delivery_path:
return set()
p = Path(delivery_path)
if not p.exists():
return set()
data = load_json(str(p))
items = data.get('delivered_vacancies') if isinstance(data, dict) else []
keys = set()
for item in items or []:
company = (item.get('company') or '').strip().lower()
title = (item.get('title') or '').strip().lower()
url = (item.get('url') or '').strip().lower()
if url:
keys.add('url:' + url)
if company or title:
keys.add('ct:' + company + '::' + title)
return keys
def vacancy_key(v: dict) -> tuple[str, str]:
url = (v.get('source_url') or v.get('url') or '').strip().lower()
company = (v.get('company') or '').strip().lower()
title = (v.get('title') or '').strip().lower()
return ('url:' + url if url else '', 'ct:' + company + '::' + title)
def shortlist_delta(raw_path: str, delivery_path: str | None, min_score: int = 70) -> list[dict]:
rows = load_json(raw_path)
seen = delivered_keys(delivery_path)
keep = []
for v in rows:
score = v.get('fit_score') or 0
label = v.get('fit_label')
if score < min_score and label not in {'strong', 'strong-match'}:
continue
if label in {'possible', 'possible-match'} and score < 80:
continue
k1, k2 = vacancy_key(v)
if (k1 and k1 in seen) or k2 in seen:
continue
keep.append({
'title': v.get('title'),
'company': v.get('company'),
'url': v.get('source_url') or v.get('url'),
'fit_score': score,
'fit_label': label,
})
return keep
def main() -> int:
ap = argparse.ArgumentParser(description='Quick status/probe for HH resumes and fresh vacancy delta')
ap.add_argument('--profile', default='chrome-relay')
ap.add_argument('--raw-json')
ap.add_argument('--delivery-json')
ap.add_argument('--min-score', type=int, default=70)
ap.add_argument('--titles', nargs='*')
args = ap.parse_args()
result = {'ok': True, 'resumes': None, 'delta': None}
try:
browser = BrowserCli(profile=args.profile)
browser.ensure_ready()
target_id = browser.current_target()
browser.navigate_js(HH_RESUMES_URL, target_id)
browser.wait_time(2200, target_id)
result['resumes'] = browser.evaluate(read_resume_cards_js(args.titles), target_id, retries=2).result or []
except BrowserCliError as e:
result['ok'] = False
result['resume_error'] = str(e)
if args.raw_json:
result['delta'] = shortlist_delta(args.raw_json, args.delivery_json, args.min_score)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/render_probe_summary.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def load(path: str) -> dict:
return json.loads(Path(path).read_text(encoding='utf-8'))
def resume_line(card: dict) -> str:
title = card.get('title') or 'Без названия'
status = card.get('status') or card.get('statusLine') or 'неизвестно'
if card.get('available'):
verdict = 'можно поднять сейчас'
elif card.get('cooldown'):
verdict = status
else:
verdict = status
return f"- {title}: {verdict}"
def vacancy_line(idx: int, item: dict) -> str:
title = item.get('title') or 'Без названия'
company = item.get('company') or 'Без компании'
score = item.get('fit_score')
label = item.get('fit_label') or 'unknown'
url = item.get('url') or item.get('source_url') or 'нет ссылки'
return f"{idx}. {title} — {company} · {score}/100 · {label}\n {url}"
def main() -> int:
ap = argparse.ArgumentParser(description='Render Telegram-friendly HH resume + vacancy probe summary')
ap.add_argument('probe_json')
ap.add_argument('--title', default='Job-search status update')
args = ap.parse_args()
data = load(args.probe_json)
parts = [args.title]
resumes = data.get('resumes') or []
parts.append('\nHH резюме:')
if resumes:
parts.extend(resume_line(card) for card in resumes)
elif data.get('resume_error'):
parts.append(f"- ошибка: {data['resume_error']}")
else:
parts.append('- карточки резюме не найдены')
delta = data.get('delta') or []
parts.append('\nНовые релевантные вакансии:')
if delta:
parts.extend(vacancy_line(i, item) for i, item in enumerate(delta[:5], 1))
if len(delta) > 5:
parts.append(f"… и ещё {len(delta) - 5}")
else:
parts.append('- новых релевантных вакансий нет')
print('\n'.join(parts).strip())
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:references/file-formats.md
# File formats
## Vacancy JSON
Preferred fields:
- source
- source_url
- company
- title
- stack
- seniority
- salary_min
- salary_max
- salary_currency
- gross_or_net
- location
- remote_mode
- employment_type
- summary
- fit_score
- fit_label
- fit_reasons
- red_flags
- status
- raw_text
## Vacancy JSONL
Use JSONL for batch workflows:
- one vacancy per line
- UTF-8
- no trailing commas
## Project markdown files
The current project parsers are intentionally lightweight.
They work best when the templates are kept simple:
- scalar values as `Key: value`
- list values as bullet items under a heading-like key
- avoid complex nested markdown
FILE:references/source-strategy.md
# Source strategy
## Priority order for the current target market
1. hh.ru
2. Habr Career
3. Telegram vacancy channels and chats
4. LinkedIn
## Why this order
- `hh.ru` gives the broadest coverage for the Russian market.
- `Habr Career` often has better signal for engineering vacancies.
- `Telegram` provides fresh leads but is noisy and duplicated.
- `LinkedIn` is useful mainly for stronger-than-local opportunities or international remote roles.
## Source-specific extraction hints
### hh.ru
Extract:
- title
- company
- salary range and currency
- gross/net if stated
- location
- remote/office/hybrid
- experience level
- employment type
- key stack
### Habr Career
Extract:
- title
- company
- salary range
- stack
- seniority
- product/domain
- remote/office/hybrid
- hiring process details if present
### Telegram
Extract from free text:
- title
- company
- stack
- salary or compensation hints
- remote/office/hybrid
- city or timezone
- contact handle or bot
- channel/chat source
For Telegram, also store raw text excerpt because structured extraction will be lossy.
### LinkedIn
Extract:
- title
- company
- location
- remote mode
- seniority
- stack hints
- application path
## Deduplication rules
Treat vacancies as possible duplicates when at least two of these match:
- same company + same/similar title
- same company + same stack + close salary
- same external URL
- Telegram post appears to mirror hh/Habr/LinkedIn posting
Prefer the richest source record as the canonical one and keep backlinks to duplicates.
FILE:references/data-flow.md
# Data flow
## Recommended practical flow
1. Initialize a job-search project.
2. Fill `PROFILE.md`, `TARGET_ROLES.md`, and `SEARCH_RULES.md`.
3. Convert project state into profile JSON with `profile_to_json.py` when needed.
4. Parse source material into structured vacancy JSON/JSONL.
5. Deduplicate vacancies.
6. Score vacancies against the project profile.
7. Export shortlist or append rows into `PIPELINE.md`.
8. Tailor resume and cover letter only for shortlisted roles.
## Typical command chain
```bash
python -m pip install -r skills/job-search/scripts/requirements.txt
python skills/job-search/scripts/init_job_search_project.py projects/job-search
python skills/job-search/scripts/batch_parse_telegram_jobs.py incoming/tg-posts projects/job-search/exports/tg.jsonl
python skills/job-search/scripts/dedupe_vacancies.py projects/job-search/exports/tg.jsonl > projects/job-search/exports/tg-deduped.json
python skills/job-search/scripts/score_vacancies_jsonl.py projects/job-search projects/job-search/exports/tg.jsonl projects/job-search/exports/tg-scored.jsonl
python skills/job-search/scripts/export_shortlist.py projects/job-search/exports/tg-scored.jsonl projects/job-search/exports/shortlist.csv
```
## Current limitation
`dedupe_vacancies.py` currently emits grouped JSON, not JSONL. If you want a canonical-only JSONL flow, either add a converter step or extend the script later.
FILE:references/project-layout.md
# Project layout
Use a durable workspace project for serious job-search work.
```text
projects/job-search/
PROFILE.md # candidate facts only
TARGET_ROLES.md # target titles, salary floors, locations, exclusions
SEARCH_RULES.md # source order, limits, filters, daily caps
SOURCES.md # channel lists, saved searches, URLs
PIPELINE.md # master application tracker
OUTREACH_RULES.md # whether messaging is allowed and under what conditions
BLACKLIST.md # companies, agencies, or patterns to skip
applications/ # tailored resumes, cover letters, notes per role
exports/ # csv/json exports
logs/ # action logs
```
## Minimum required files
For a lightweight setup, create at least:
- `PROFILE.md`
- `TARGET_ROLES.md`
- `PIPELINE.md`
## Pipeline columns
Suggested columns:
- date_found
- source
- company
- title
- url
- fit_label
- fit_score
- salary
- location
- status
- last_action
- next_follow_up
- notes
FILE:references/onboarding.md
# Onboarding
Use this flow when a user is starting from scratch or sends a resume/CV.
## Goal
Turn a raw resume into a usable job-search project and, when relevant, prepare the user for HH Browser Relay automation.
## Resume-first onboarding
When the user sends a resume, CV, or pasted experience summary:
1. Extract factual candidate data only.
2. Initialize a job-search project if it does not exist yet.
3. Fill or update:
- `PROFILE.md`
- `TARGET_ROLES.md`
- `SEARCH_RULES.md`
4. Keep uncertainty explicit:
- unknown salary targets stay blank or marked for confirmation
- inferred target roles should be marked as draft assumptions until the user confirms
5. After parsing the resume, give the user a short summary:
- what was extracted confidently
- what still needs confirmation
- what sources can already be searched safely
## Minimum questions after resume parsing
Ask only the missing high-value questions, for example:
- desired salary floor / target
- remote vs hybrid vs office
- preferred cities / countries
- whether recruiter outreach is allowed
- whether automatic applications are allowed
Do not ask for details already present in the resume.
## HH / Browser Relay onboarding
If the user wants hh.ru automation (resume raising, logged-in vacancy review, or assisted HH apply), explain that a logged-in browser session is required.
Give concise instructions:
1. Open hh.ru in Chrome.
2. Log into the desired hh account.
3. Open the target tab you want the agent to use.
4. Click the OpenClaw Browser Relay extension icon on that tab so the badge turns ON.
5. Tell the agent the relay is attached, then continue.
Explain why only briefly: Browser Relay lets the agent work with the user’s already logged-in tab without asking for passwords.
## What to do after relay setup
Once the relay is attached:
- refresh the active hh search or resume page before trusting visible state
- verify whether resumes are available to raise or still in cooldown
- only then run hh-specific automation
## When not to push Browser Relay
Do not ask the user to set up Browser Relay unless the task actually needs logged-in browser automation.
Examples that usually do NOT need relay:
- parsing a resume
- building a candidate profile
- scoring already exported vacancies
- editing search rules
- preparing a shortlist from local files
## First-run response shape
For a brand-new user who sent a resume, prefer this response shape:
1. short acknowledgement
2. extracted role/profile summary
3. missing confirmations
4. next step recommendation
5. Browser Relay instruction only if hh automation is part of the next step
FILE:references/safety-rules.md
# Safety rules
## Default posture
Default to research-only unless the user explicitly asks for application or outreach actions.
## Allowed without extra approval
- read vacancies
- score vacancies
- build shortlists
- tailor resume drafts
- draft cover letters
- update local pipeline files
## Require explicit approval
- submit an application
- send a recruiter or hiring-manager message
- create accounts on external services
- modify public profiles in a meaningful way
- upload resumes or attachments to third-party services
## Outreach rules
Never message recruiters or hiring managers unless the user explicitly enables outreach.
When outreach is enabled:
- keep messages short and factual
- avoid manipulative urgency
- avoid repeated follow-ups without a rule
- log the exact destination and message summary
## Auto-apply rules
When auto-apply is enabled:
- apply only to strong-match roles
- respect daily caps
- skip unclear or suspicious vacancies
- log every application with timestamp, source, title, company, and status
## Red flags
Down-rank or skip vacancies with:
- missing company name
- vague responsibilities and no stack
- unrealistic requirements spread across many roles
- suspicious compensation language
- obvious recruiting spam
- requests for irrelevant personal data too early
FILE:assets/templates/SEARCH_RULES.md
# SEARCH RULES
## Sources
- hh.ru
- Habr Career
- Telegram
- LinkedIn
## Browser refresh rule
- Before checking browser-based sources, especially hh.ru, always refresh the active search results page first so fresh vacancies appear.
## Daily limits
- Max applications/day:
- Max outreach/day:
## Fit policy
- Strong match:
- Possible match:
- Skip when:
## Filters
- Required stack:
- Excluded keywords:
- Salary floor:
FILE:assets/templates/TARGET_ROLES.md
# TARGET ROLES
## Priority titles
-
## Nice-to-have titles
-
## Salary targets
- Floor:
- Target:
- Currency:
- Gross/net preference:
## Work format
- Remote / hybrid / office:
- Cities / regions:
- Relocation:
## Exclusions
-
FILE:assets/templates/PIPELINE.md
# PIPELINE
| date_found | source | company | title | url | fit_label | fit_score | salary | location | status | last_action | next_follow_up | notes |
|---|---|---|---|---|---:|---:|---|---|---|---|---|---|
FILE:assets/templates/PROFILE.md
# PROFILE
## Candidate facts
- Full name:
- Headline:
- Base location:
- Languages:
- Years of experience:
## Core stack
-
## Domain experience
-
## Constraints
- Employment type:
- Citizenship / work authorization:
- Timezone preference:
- Notice period:
## Truth rules
- Only factual claims.
- Do not invent missing experience.
FILE:assets/templates/SOURCES.md
# SOURCES
## hh.ru
- Saved searches:
- Target employers:
## Habr Career
- Saved searches:
- Target employers:
## Telegram
- Channels:
- Chats:
- Bots:
## LinkedIn
- Saved searches:
- Target employers:
FILE:assets/templates/README-START.md
# JOB SEARCH START
1. Fill `PROFILE.md` with factual candidate data.
2. Fill `TARGET_ROLES.md` with role priorities and salary targets.
3. Fill `SEARCH_RULES.md` with filters and limits.
4. Fill `SOURCES.md` with hh/Habr/Telegram/LinkedIn sources.
5. Keep `OUTREACH_RULES.md` conservative unless explicit outreach is allowed.
6. Add shortlisted opportunities into `PIPELINE.md`.
FILE:assets/templates/OUTREACH_RULES.md
# OUTREACH RULES
- Outreach allowed: no
- Auto-apply allowed: no
- Default mode: research-only
- Allowed message styles:
- Forbidden actions:
- unsolicited DMs without explicit approval
- misleading claims
- repeated follow-ups without a rule
FILE:assets/templates/BLACKLIST.md
# BLACKLIST
## Companies
-
## Agencies / patterns
-
## Keywords
-