gethired 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gethired-0.1.0/.gitignore +21 -0
- gethired-0.1.0/PKG-INFO +13 -0
- gethired-0.1.0/app/__init__.py +0 -0
- gethired-0.1.0/app/__main__.py +87 -0
- gethired-0.1.0/app/api.py +170 -0
- gethired-0.1.0/app/auth.py +61 -0
- gethired-0.1.0/app/config.py +21 -0
- gethired-0.1.0/app/demo.py +201 -0
- gethired-0.1.0/app/jobs.py +127 -0
- gethired-0.1.0/app/llm.py +280 -0
- gethired-0.1.0/app/main.py +18 -0
- gethired-0.1.0/app/matcher.py +100 -0
- gethired-0.1.0/app/profile.py +76 -0
- gethired-0.1.0/app/repository.py +275 -0
- gethired-0.1.0/app/resume.py +83 -0
- gethired-0.1.0/app/scrapers/__init__.py +7 -0
- gethired-0.1.0/app/scrapers/base.py +80 -0
- gethired-0.1.0/app/scrapers/coupang.py +25 -0
- gethired-0.1.0/app/scrapers/daangn.py +22 -0
- gethired-0.1.0/app/scrapers/dunamu.py +89 -0
- gethired-0.1.0/app/scrapers/fortytwodot.py +41 -0
- gethired-0.1.0/app/scrapers/greenhouse.py +54 -0
- gethired-0.1.0/app/scrapers/greetinghr.py +74 -0
- gethired-0.1.0/app/scrapers/hyperconnect.py +50 -0
- gethired-0.1.0/app/scrapers/hyundai.py +63 -0
- gethired-0.1.0/app/scrapers/kakao.py +45 -0
- gethired-0.1.0/app/scrapers/kakaobank.py +56 -0
- gethired-0.1.0/app/scrapers/kakaopay.py +10 -0
- gethired-0.1.0/app/scrapers/line.py +56 -0
- gethired-0.1.0/app/scrapers/naver.py +68 -0
- gethired-0.1.0/app/scrapers/samsung.py +87 -0
- gethired-0.1.0/app/scrapers/toss.py +44 -0
- gethired-0.1.0/app/scrapers/upstage.py +10 -0
- gethired-0.1.0/app/scrapers/woowahan.py +57 -0
- gethired-0.1.0/app/static/assets/index-BDk24PzM.css +1 -0
- gethired-0.1.0/app/static/assets/index-DP2qaOvg.js +49 -0
- gethired-0.1.0/app/static/index.html +13 -0
- gethired-0.1.0/pyproject.toml +42 -0
- gethired-0.1.0/uv.lock +1011 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
.uv-cache/
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.pyc
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
backend/out/
|
|
7
|
+
backend/.claude/
|
|
8
|
+
*.db
|
|
9
|
+
.DS_Store
|
|
10
|
+
node_modules/
|
|
11
|
+
.npm-cache/
|
|
12
|
+
backend/app/static/
|
|
13
|
+
backend/dist/
|
|
14
|
+
backend/data/
|
|
15
|
+
frontend/tsconfig.tsbuildinfo
|
|
16
|
+
handoff.md
|
|
17
|
+
resume/
|
|
18
|
+
.claude/worktrees/
|
|
19
|
+
frontend/.playwright/
|
|
20
|
+
frontend/test-results/
|
|
21
|
+
frontend/playwright-report/
|
gethired-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gethired
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 내 이력서에 맞는 채용 공고만 모아 보여주는 로컬 앱
|
|
5
|
+
Project-URL: Homepage, https://github.com/chaewonkong/job-posting
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Requires-Dist: claude-agent-sdk>=0.2.165
|
|
8
|
+
Requires-Dist: fastapi>=0.115
|
|
9
|
+
Requires-Dist: httpx>=0.28.1
|
|
10
|
+
Requires-Dist: platformdirs>=4.3
|
|
11
|
+
Requires-Dist: pydantic>=2.14.0
|
|
12
|
+
Requires-Dist: pypdf>=6.21.1
|
|
13
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""`gethired [serve|auth|reset-db|paths]` — 빌드된 프론트(app/static)를 정적 서빙하며 API 실행.
|
|
2
|
+
`python -m app [--port N] [--demo]`도 같다 (서브커맨드 생략 = serve)."""
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from . import auth, config
|
|
12
|
+
|
|
13
|
+
COMMANDS = ("serve", "auth", "reset-db", "paths")
|
|
14
|
+
LOGIN_COMMAND = {"claude": "claude login", "codex": f"{config.CODEX_BIN} login"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_app():
|
|
18
|
+
from .main import app
|
|
19
|
+
return app
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _serve(args: argparse.Namespace) -> None:
|
|
23
|
+
if not config.FRONTEND_DIST.exists():
|
|
24
|
+
print(f"warning: {config.FRONTEND_DIST} not found — frontend not built (API only)", file=sys.stderr)
|
|
25
|
+
if args.demo:
|
|
26
|
+
from .demo import make_app
|
|
27
|
+
app = make_app()
|
|
28
|
+
else:
|
|
29
|
+
app = _load_app()
|
|
30
|
+
url = f"http://{args.host}:{args.port}"
|
|
31
|
+
if not args.no_open:
|
|
32
|
+
threading.Timer(1.0, webbrowser.open, [url]).start()
|
|
33
|
+
print(f"gethired: {url}", file=sys.stderr)
|
|
34
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _auth(args: argparse.Namespace) -> None:
|
|
38
|
+
ok = asyncio.run(auth.is_logged_in(args.provider))
|
|
39
|
+
print(f"{args.provider}: {'logged in' if ok else 'not logged in — run `' + LOGIN_COMMAND[args.provider] + '`'}")
|
|
40
|
+
sys.exit(0 if ok else 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _reset_db(args: argparse.Namespace) -> None:
|
|
44
|
+
if config.DB_PATH.exists():
|
|
45
|
+
config.DB_PATH.unlink()
|
|
46
|
+
print(f"removed {config.DB_PATH}")
|
|
47
|
+
else:
|
|
48
|
+
print(f"no db at {config.DB_PATH}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _paths(args: argparse.Namespace) -> None:
|
|
52
|
+
print(f"home: {config.HOME}")
|
|
53
|
+
print(f"db: {config.DB_PATH}")
|
|
54
|
+
print(f"resume: {config.RESUME_DIR}")
|
|
55
|
+
print(f"frontend: {config.FRONTEND_DIST}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
59
|
+
ap = argparse.ArgumentParser(prog="gethired")
|
|
60
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
61
|
+
|
|
62
|
+
serve = sub.add_parser("serve", help="서버 실행 후 브라우저 열기 (기본)")
|
|
63
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
64
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
65
|
+
serve.add_argument("--no-open", action="store_true", help="브라우저를 열지 않음")
|
|
66
|
+
serve.add_argument("--demo", action="store_true", help="고정 데이터 데모 모드 (네트워크·LLM·실제 경로 없음)")
|
|
67
|
+
serve.set_defaults(func=_serve)
|
|
68
|
+
|
|
69
|
+
au = sub.add_parser("auth", help="provider CLI 로그인 상태 확인")
|
|
70
|
+
au.add_argument("provider", nargs="?", default="claude", choices=("claude", "codex"))
|
|
71
|
+
au.set_defaults(func=_auth)
|
|
72
|
+
|
|
73
|
+
sub.add_parser("reset-db", help="공고·판정 이력 삭제").set_defaults(func=_reset_db)
|
|
74
|
+
sub.add_parser("paths", help="데이터 경로 출력").set_defaults(func=_paths)
|
|
75
|
+
return ap
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv: list[str] | None = None) -> None:
|
|
79
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
80
|
+
if not argv or argv[0] not in COMMANDS and argv[0] not in ("-h", "--help"):
|
|
81
|
+
argv.insert(0, "serve")
|
|
82
|
+
args = build_parser().parse_args(argv)
|
|
83
|
+
args.func(args)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from . import auth, resume
|
|
10
|
+
from .config import RESUME_DIR
|
|
11
|
+
from .jobs import JobManager
|
|
12
|
+
from .llm import LOGIN_COMMAND, PROVIDERS, auth_message, resolve_provider
|
|
13
|
+
from .repository import Repository, now_iso
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InterestsBody(BaseModel):
|
|
17
|
+
companies: list[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProviderBody(BaseModel):
|
|
21
|
+
provider: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_app(repo: Repository, jobs: JobManager,
|
|
25
|
+
logged_in: Callable[[str], Awaitable[bool]] = auth.is_logged_in,
|
|
26
|
+
resume_dir: Path = RESUME_DIR) -> FastAPI:
|
|
27
|
+
app = FastAPI(title="gethired")
|
|
28
|
+
api = APIRouter(prefix="/api")
|
|
29
|
+
|
|
30
|
+
def provider_setting() -> dict:
|
|
31
|
+
stored = repo.get_setting("provider")
|
|
32
|
+
return {"provider": resolve_provider(stored), "chosen": stored is not None}
|
|
33
|
+
|
|
34
|
+
async def auth_status_body() -> dict:
|
|
35
|
+
provider = provider_setting()["provider"]
|
|
36
|
+
return {"provider": provider, "logged_in": await logged_in(provider), "login_command": LOGIN_COMMAND[provider]}
|
|
37
|
+
|
|
38
|
+
def job_running() -> bool:
|
|
39
|
+
return bool(jobs.current and jobs.current.status == "running")
|
|
40
|
+
|
|
41
|
+
@api.get("/settings/provider")
|
|
42
|
+
def get_provider():
|
|
43
|
+
return provider_setting()
|
|
44
|
+
|
|
45
|
+
@api.put("/settings/provider")
|
|
46
|
+
async def put_provider(body: ProviderBody):
|
|
47
|
+
if body.provider not in PROVIDERS:
|
|
48
|
+
raise HTTPException(status_code=400, detail=f"unknown provider: {body.provider}")
|
|
49
|
+
if job_running():
|
|
50
|
+
return JSONResponse(status_code=409, content={"code": "job_running",
|
|
51
|
+
"message": "스크래핑 중에는 AI를 바꿀 수 없습니다."})
|
|
52
|
+
repo.set_setting("provider", body.provider, now_iso())
|
|
53
|
+
return await auth_status_body()
|
|
54
|
+
|
|
55
|
+
@api.get("/auth/status")
|
|
56
|
+
async def auth_status():
|
|
57
|
+
return await auth_status_body()
|
|
58
|
+
|
|
59
|
+
@api.get("/companies")
|
|
60
|
+
def companies():
|
|
61
|
+
last = repo.last_runs()
|
|
62
|
+
interests = set(repo.list_interests())
|
|
63
|
+
return [
|
|
64
|
+
{"company": name, "display_name": cls.display_name, "careers_url": cls.careers_url,
|
|
65
|
+
"last_run": last.get(name), "interested": name in interests}
|
|
66
|
+
for name, cls in jobs.scrapers.items()
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def check_known(companies: list[str]) -> None:
|
|
70
|
+
unknown = [c for c in companies if c not in jobs.scrapers]
|
|
71
|
+
if unknown:
|
|
72
|
+
raise HTTPException(status_code=400, detail=f"unknown companies: {unknown}")
|
|
73
|
+
|
|
74
|
+
@api.put("/interests")
|
|
75
|
+
def put_interests(body: InterestsBody):
|
|
76
|
+
if not body.companies:
|
|
77
|
+
raise HTTPException(status_code=400, detail="at least one company is required")
|
|
78
|
+
check_known(body.companies)
|
|
79
|
+
repo.set_interests(body.companies, now_iso())
|
|
80
|
+
return {"companies": repo.list_interests()}
|
|
81
|
+
|
|
82
|
+
def interest(company: str, on: bool):
|
|
83
|
+
check_known([company])
|
|
84
|
+
repo.set_interest(company, on, now_iso())
|
|
85
|
+
return {"company": company, "interested": on}
|
|
86
|
+
|
|
87
|
+
@api.put("/companies/{company}/interest")
|
|
88
|
+
def add_interest(company: str):
|
|
89
|
+
return interest(company, True)
|
|
90
|
+
|
|
91
|
+
@api.delete("/companies/{company}/interest")
|
|
92
|
+
def remove_interest(company: str):
|
|
93
|
+
return interest(company, False)
|
|
94
|
+
|
|
95
|
+
@api.post("/scrape", status_code=202)
|
|
96
|
+
async def scrape(companies: list[str] | None = Query(default=None)):
|
|
97
|
+
if job_running():
|
|
98
|
+
return {"job_id": jobs.current.id, "status": "running"}
|
|
99
|
+
provider = provider_setting()["provider"]
|
|
100
|
+
if not await logged_in(provider):
|
|
101
|
+
return JSONResponse(status_code=401, content={"code": "login_required", "provider": provider,
|
|
102
|
+
"message": auth_message(provider)})
|
|
103
|
+
check_known(companies or [])
|
|
104
|
+
targets = companies or [c for c in repo.list_interests() if c in jobs.scrapers]
|
|
105
|
+
if not targets:
|
|
106
|
+
raise HTTPException(status_code=400, detail="no interest companies")
|
|
107
|
+
job = jobs.start(targets)
|
|
108
|
+
return {"job_id": job.id, "status": job.status}
|
|
109
|
+
|
|
110
|
+
@api.get("/scrape/{job_id}")
|
|
111
|
+
def scrape_status(job_id: str):
|
|
112
|
+
job = jobs.get(job_id)
|
|
113
|
+
if not job:
|
|
114
|
+
raise HTTPException(status_code=404, detail="job not found")
|
|
115
|
+
return job.to_dict()
|
|
116
|
+
|
|
117
|
+
@api.get("/postings")
|
|
118
|
+
def postings(company: str | None = None, min_level: Literal["stretch", "good", "strong"] | None = None):
|
|
119
|
+
return repo.list_postings(company=company, min_level=min_level)
|
|
120
|
+
|
|
121
|
+
def bookmark(posting_id: int, on: bool):
|
|
122
|
+
if not repo.set_bookmark(posting_id, on, now_iso()):
|
|
123
|
+
raise HTTPException(status_code=404, detail="posting not found")
|
|
124
|
+
return {"posting_id": posting_id, "bookmarked": on}
|
|
125
|
+
|
|
126
|
+
@api.put("/postings/{posting_id}/bookmark")
|
|
127
|
+
def add_bookmark(posting_id: int):
|
|
128
|
+
return bookmark(posting_id, True)
|
|
129
|
+
|
|
130
|
+
@api.delete("/postings/{posting_id}/bookmark")
|
|
131
|
+
def remove_bookmark(posting_id: int):
|
|
132
|
+
return bookmark(posting_id, False)
|
|
133
|
+
|
|
134
|
+
def hide(posting_id: int, on: bool):
|
|
135
|
+
if not repo.set_hidden(posting_id, on, now_iso()):
|
|
136
|
+
raise HTTPException(status_code=404, detail="posting not found")
|
|
137
|
+
return {"posting_id": posting_id, "hidden": on}
|
|
138
|
+
|
|
139
|
+
@api.put("/postings/{posting_id}/hide")
|
|
140
|
+
def add_hide(posting_id: int):
|
|
141
|
+
return hide(posting_id, True)
|
|
142
|
+
|
|
143
|
+
@api.delete("/postings/{posting_id}/hide")
|
|
144
|
+
def remove_hide(posting_id: int):
|
|
145
|
+
return hide(posting_id, False)
|
|
146
|
+
|
|
147
|
+
@api.get("/resume")
|
|
148
|
+
def resume_files():
|
|
149
|
+
return resume.list_files(resume_dir)
|
|
150
|
+
|
|
151
|
+
@api.put("/resume/{name}")
|
|
152
|
+
async def put_resume(name: str, request: Request):
|
|
153
|
+
try:
|
|
154
|
+
await resume.save(resume_dir, name, request.stream())
|
|
155
|
+
except ValueError as e:
|
|
156
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
@api.delete("/resume/{name}")
|
|
160
|
+
def delete_resume(name: str):
|
|
161
|
+
try:
|
|
162
|
+
found = resume.delete(resume_dir, name)
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
165
|
+
if not found:
|
|
166
|
+
raise HTTPException(status_code=404, detail="resume file not found")
|
|
167
|
+
return {"name": name}
|
|
168
|
+
|
|
169
|
+
app.include_router(api)
|
|
170
|
+
return app
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from . import config
|
|
5
|
+
|
|
6
|
+
STATUS_TIMEOUT_S = 15
|
|
7
|
+
|
|
8
|
+
_inflight: dict[str, asyncio.Future] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def is_logged_in(provider: str) -> bool:
|
|
12
|
+
"""provider CLI의 로그인 상태. CLI가 없거나 실패·타임아웃이면 False. 같은 provider의 동시 호출은 결과를 공유한다."""
|
|
13
|
+
fut = _inflight.get(provider)
|
|
14
|
+
if fut is None or fut.done():
|
|
15
|
+
fut = asyncio.ensure_future(_check(provider))
|
|
16
|
+
_inflight[provider] = fut
|
|
17
|
+
fut.add_done_callback(lambda _: _inflight.pop(provider, None))
|
|
18
|
+
return await asyncio.shield(fut)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _check(provider: str) -> bool:
|
|
22
|
+
return await (_codex_status() if provider == "codex" else _claude_status())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _claude_status() -> bool:
|
|
26
|
+
"""`claude auth status`의 loggedIn"""
|
|
27
|
+
try:
|
|
28
|
+
proc = await asyncio.create_subprocess_exec(
|
|
29
|
+
"claude", "auth", "status", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
|
30
|
+
except OSError:
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=STATUS_TIMEOUT_S)
|
|
34
|
+
return bool(json.loads(out.decode() or "{}").get("loggedIn"))
|
|
35
|
+
except asyncio.TimeoutError:
|
|
36
|
+
_kill(proc)
|
|
37
|
+
return False
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _codex_status() -> bool:
|
|
43
|
+
"""`codex login status`의 exit code 0"""
|
|
44
|
+
try:
|
|
45
|
+
proc = await asyncio.create_subprocess_exec(
|
|
46
|
+
config.CODEX_BIN, "login", "status",
|
|
47
|
+
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
|
48
|
+
except OSError:
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
51
|
+
return await asyncio.wait_for(proc.wait(), timeout=STATUS_TIMEOUT_S) == 0
|
|
52
|
+
except asyncio.TimeoutError:
|
|
53
|
+
_kill(proc)
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _kill(proc: asyncio.subprocess.Process) -> None:
|
|
58
|
+
try:
|
|
59
|
+
proc.kill()
|
|
60
|
+
except ProcessLookupError:
|
|
61
|
+
pass
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from platformdirs import user_data_dir
|
|
5
|
+
|
|
6
|
+
APP_DIR = Path(__file__).resolve().parent
|
|
7
|
+
FRONTEND_DIST = APP_DIR / "static"
|
|
8
|
+
|
|
9
|
+
# 사용자 데이터. 개발 시에는 justfile이 DB_PATH·RESUME_DIR로 저장소 안 경로를 넘긴다
|
|
10
|
+
HOME = Path(os.environ.get("GETHIRED_HOME") or user_data_dir("gethired"))
|
|
11
|
+
DB_PATH = Path(os.environ.get("DB_PATH") or HOME / "app.db")
|
|
12
|
+
RESUME_DIR = Path(os.environ.get("RESUME_DIR") or HOME / "resume")
|
|
13
|
+
|
|
14
|
+
MATCH_BATCH_SIZE = 20
|
|
15
|
+
MATCH_CONCURRENCY = 4
|
|
16
|
+
|
|
17
|
+
LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "claude")
|
|
18
|
+
LLM_TIMEOUT_S = float(os.environ.get("LLM_TIMEOUT_S", "600"))
|
|
19
|
+
CLAUDE_MODEL = os.environ.get("CLAUDE_MODEL", "claude-opus-5-5")
|
|
20
|
+
CODEX_BIN = os.environ.get("CODEX_BIN", "codex")
|
|
21
|
+
CODEX_MODEL = os.environ.get("CODEX_MODEL") or None
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""데모 모드: 스크래퍼·프로필·판정을 고정 데이터로 바꾼 백엔드. 네트워크·LLM·CLI·실제 경로를 쓰지 않는다.
|
|
2
|
+
모든 텍스트는 가상이다."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
|
|
15
|
+
from .api import create_app
|
|
16
|
+
from .config import FRONTEND_DIST
|
|
17
|
+
from .jobs import JobManager
|
|
18
|
+
from .matcher import Match
|
|
19
|
+
from .profile import Preferences, Profile
|
|
20
|
+
from .repository import Repository, set_fixed_now
|
|
21
|
+
from .scrapers import RawPosting, Scraper
|
|
22
|
+
|
|
23
|
+
FIXED_NOW = "2026-10-18T09:00:00+09:00"
|
|
24
|
+
DEMO_JUDGE_DELAY_S = 0.7
|
|
25
|
+
|
|
26
|
+
COMPANIES = ("toss", "daangn", "kakao", "naver")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _url(company: str, n: int) -> str:
|
|
30
|
+
return f"https://example.com/demo/{company}/{n}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
POSTINGS: dict[str, list[RawPosting]] = {
|
|
34
|
+
"toss": [
|
|
35
|
+
RawPosting(company="toss", external_id="demo-toss-1", title="서버 개발자 (Go)", apply_url=_url("toss", 1),
|
|
36
|
+
category="Engineering",
|
|
37
|
+
description="Go로 대규모 트래픽을 처리하는 코어 서버를 개발합니다. Redis·Kubernetes 기반 운영 경험과 "
|
|
38
|
+
"분산 시스템 설계 경험을 우대합니다. 5년 이상의 백엔드 경력을 기대합니다."),
|
|
39
|
+
RawPosting(company="toss", external_id="demo-toss-2", title="결제 플랫폼 백엔드", apply_url=_url("toss", 2),
|
|
40
|
+
category="Engineering",
|
|
41
|
+
description="결제 승인·정산 파이프라인을 설계하고 운영합니다. Kotlin 또는 Go, PostgreSQL, "
|
|
42
|
+
"메시지 큐 경험을 요구하며 금융 도메인 경험은 우대입니다."),
|
|
43
|
+
],
|
|
44
|
+
"daangn": [
|
|
45
|
+
RawPosting(company="daangn", external_id="demo-daangn-1", title="매칭 플랫폼 백엔드 엔지니어",
|
|
46
|
+
apply_url=_url("daangn", 1), category="Software Engineer", deadline=date(2026, 10, 22),
|
|
47
|
+
description="이웃 간 거래·모임을 연결하는 매칭 플랫폼의 백엔드를 만듭니다. Go, Redis, Kubernetes 환경에서 "
|
|
48
|
+
"지연과 공정성을 함께 다루는 매칭 로직을 설계·운영한 경험을 찾습니다."),
|
|
49
|
+
RawPosting(company="daangn", external_id="demo-daangn-2", title="데이터 플랫폼 엔지니어",
|
|
50
|
+
apply_url=_url("daangn", 2), category="Software Engineer",
|
|
51
|
+
description="이벤트 수집 파이프라인과 분석용 저장소를 운영합니다. Spark·Airflow·ClickHouse 등 "
|
|
52
|
+
"데이터 인프라 경험과 Python 기반 배치 개발 경험을 요구합니다."),
|
|
53
|
+
],
|
|
54
|
+
"kakao": [
|
|
55
|
+
RawPosting(company="kakao", external_id="demo-kakao-1", title="게임 서버 백엔드 (Kotlin)",
|
|
56
|
+
apply_url=_url("kakao", 1), category="서버",
|
|
57
|
+
description="모바일 게임의 매치메이킹·랭킹·보상 서버를 개발합니다. Kotlin/Spring 필수, "
|
|
58
|
+
"실시간 트래픽 처리와 Redis 기반 세션 관리 경험을 우대합니다."),
|
|
59
|
+
RawPosting(company="kakao", external_id="demo-kakao-2", title="인프라 엔지니어", apply_url=_url("kakao", 2),
|
|
60
|
+
category="인프라",
|
|
61
|
+
description="사내 Kubernetes 플랫폼과 관측 스택을 운영합니다. Terraform·Ansible 기반 프로비저닝, "
|
|
62
|
+
"네트워크·스토리지 운영 경험을 요구합니다."),
|
|
63
|
+
],
|
|
64
|
+
"naver": [
|
|
65
|
+
RawPosting(company="naver", external_id="demo-naver-1", title="검색 플랫폼 백엔드 (Java)",
|
|
66
|
+
apply_url=_url("naver", 1), category="Tech",
|
|
67
|
+
description="검색 색인·질의 서빙 플랫폼을 개발합니다. Java/Spring 필수, 대용량 분산 저장소 운영 경험과 "
|
|
68
|
+
"검색 품질 개선 경험을 우대합니다."),
|
|
69
|
+
RawPosting(company="naver", external_id="demo-naver-2", title="실시간 메시징 서버", apply_url=_url("naver", 2),
|
|
70
|
+
category="Tech",
|
|
71
|
+
description="수천만 사용자가 쓰는 실시간 메시징 서버를 개발합니다. Go 또는 Java, 분산 시스템과 "
|
|
72
|
+
"WebSocket·pub/sub 설계 경험을 요구합니다."),
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DemoScraper(Scraper, register=False):
|
|
78
|
+
expected_count: ClassVar[int] = 2
|
|
79
|
+
|
|
80
|
+
async def fetch(self, client: httpx.AsyncClient) -> list[RawPosting]:
|
|
81
|
+
return list(POSTINGS[self.company])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DemoToss(DemoScraper, register=False):
|
|
85
|
+
company = "toss"
|
|
86
|
+
display_name = "토스"
|
|
87
|
+
careers_url = "https://toss.im/career/jobs"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DemoDaangn(DemoScraper, register=False):
|
|
91
|
+
company = "daangn"
|
|
92
|
+
display_name = "당근"
|
|
93
|
+
careers_url = "https://about.daangn.com/jobs/"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DemoKakao(DemoScraper, register=False):
|
|
97
|
+
company = "kakao"
|
|
98
|
+
display_name = "카카오"
|
|
99
|
+
careers_url = "https://careers.kakao.com/jobs"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DemoNaver(DemoScraper, register=False):
|
|
103
|
+
company = "naver"
|
|
104
|
+
display_name = "네이버"
|
|
105
|
+
careers_url = "https://recruit.navercorp.com/rcrt/list.do"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
SCRAPERS: dict[str, type[Scraper]] = {"toss": DemoToss, "daangn": DemoDaangn, "kakao": DemoKakao, "naver": DemoNaver}
|
|
109
|
+
|
|
110
|
+
PROFILE = Profile(
|
|
111
|
+
name="홍길동", seniority="senior", years_experience=5,
|
|
112
|
+
core_skills=["Go", "Redis", "Kubernetes", "PostgreSQL"],
|
|
113
|
+
familiar_skills=["TypeScript", "gRPC", "ClickHouse"],
|
|
114
|
+
weak_or_learning=["Kotlin", "Java"],
|
|
115
|
+
domains=["매치메이킹", "실시간 게임 서버"],
|
|
116
|
+
notable_achievements=["매칭 대기 시간을 40% 줄인 큐 재설계", "피크 동시접속 50만 매칭 서버 무중단 운영"],
|
|
117
|
+
preferences=Preferences(locations=["서울"], visa_or_work_authorization=None,
|
|
118
|
+
target_domains=["매치메이킹", "실시간 플랫폼"], target_roles=["백엔드 엔지니어"]),
|
|
119
|
+
dealbreakers=["프론트엔드 전담", "주니어 포지션"],
|
|
120
|
+
summary="Go 기반 매치메이킹 서버를 5년간 설계·운영한 백엔드 엔지니어. Redis·Kubernetes·PostgreSQL이 주력이다.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _match(eid: str, level: str, score: int, summary: str, tech: list[str], gaps: list[str], reason: str) -> Match:
|
|
125
|
+
return Match(external_id=eid, fit_level=level, score=score, gaps=gaps, summary=summary, tech_stack=tech,
|
|
126
|
+
fit_reason=reason)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
MATCHES: dict[str, Match] = {m.external_id: m for m in [
|
|
130
|
+
_match("demo-toss-1", "strong", 90, "Go로 코어 서버를 개발하는 백엔드 포지션.", ["Go", "Redis", "Kubernetes"], [],
|
|
131
|
+
"주력 스택인 Go·Redis·Kubernetes가 공고 요구와 그대로 겹치고, 대규모 매칭 서버 무중단 운영 경험이 "
|
|
132
|
+
"대규모 트래픽 처리 요구에 직접 대응합니다. 연차도 5년 이상 조건에 맞습니다."),
|
|
133
|
+
_match("demo-toss-2", "good", 74, "결제 승인·정산 파이프라인을 만드는 백엔드 포지션.", ["Kotlin", "Go", "PostgreSQL"],
|
|
134
|
+
["domain: 금융·결제 도메인 경험 없음"],
|
|
135
|
+
"Go와 PostgreSQL 경험이 핵심 요구와 맞고 분산 시스템 운영 경험도 유효하지만, 결제·정산 도메인 경험이 없어 "
|
|
136
|
+
"우대 요건에서 점수를 잃습니다."),
|
|
137
|
+
_match("demo-daangn-1", "strong", 92, "이웃 매칭 플랫폼의 백엔드를 설계·운영하는 포지션.", ["Go", "Redis", "Kubernetes"], [],
|
|
138
|
+
"매칭 로직의 지연과 공정성을 다루는 포지션으로, 매칭 대기 시간을 40% 줄인 큐 재설계 경험이 그대로 들어맞습니다. "
|
|
139
|
+
"Go·Redis·Kubernetes 스택도 동일합니다."),
|
|
140
|
+
_match("demo-daangn-2", "stretch", 56, "이벤트 파이프라인과 분석 저장소를 운영하는 데이터 플랫폼 포지션.",
|
|
141
|
+
["Spark", "Airflow", "ClickHouse", "Python"],
|
|
142
|
+
["framework: Spark·Airflow 경험 없음", "language: Python 배치 개발이 주력이 아님"],
|
|
143
|
+
"ClickHouse 운영 경험이 있어 저장소 쪽은 접점이 있지만, Spark·Airflow 기반 파이프라인과 Python 배치 개발이 "
|
|
144
|
+
"주력이 아니라 서류 리스크가 있습니다."),
|
|
145
|
+
_match("demo-kakao-1", "good", 70, "모바일 게임의 매치메이킹·랭킹 서버를 만드는 포지션.", ["Kotlin", "Spring", "Redis"],
|
|
146
|
+
["language: Kotlin/Spring 필수, 지원자는 Go 주력"],
|
|
147
|
+
"게임 매치메이킹 도메인과 Redis 세션 관리 경험이 공고와 정확히 겹칩니다. 다만 Kotlin/Spring이 필수라 "
|
|
148
|
+
"언어 전환이 필요해 한 단계 낮췄습니다."),
|
|
149
|
+
_match("demo-kakao-2", "stretch", 52, "사내 Kubernetes 플랫폼과 관측 스택을 운영하는 인프라 포지션.",
|
|
150
|
+
["Kubernetes", "Terraform", "Ansible"],
|
|
151
|
+
["other: 애플리케이션 개발이 아닌 인프라 운영 중심", "framework: Terraform·Ansible 경험 없음"],
|
|
152
|
+
"Kubernetes 운영 경험은 맞지만 역할이 인프라 프로비저닝과 네트워크·스토리지 운영에 치우쳐 있어 "
|
|
153
|
+
"백엔드 개발 경력과는 거리가 있습니다."),
|
|
154
|
+
_match("demo-naver-1", "stretch", 58, "검색 색인·질의 서빙 플랫폼을 개발하는 백엔드 포지션.", ["Java", "Spring"],
|
|
155
|
+
["language: Java/Spring 필수, 지원자는 Go 주력", "domain: 검색 도메인 경험 없음"],
|
|
156
|
+
"대용량 분산 저장소 운영 경험은 유효하지만 Java/Spring이 필수이고 검색 도메인 경험이 없어 "
|
|
157
|
+
"역할은 맞되 서류 통과 리스크가 큽니다."),
|
|
158
|
+
_match("demo-naver-2", "good", 72, "대규모 실시간 메시징 서버를 개발하는 백엔드 포지션.", ["Go", "Java", "WebSocket"],
|
|
159
|
+
["domain: 메시징 서비스 경험 없음"],
|
|
160
|
+
"Go와 분산 시스템 설계 경험이 요구와 맞고, 실시간 게임 서버 운영 경험이 실시간 메시징 트래픽 처리에 "
|
|
161
|
+
"전이됩니다. 메시징 도메인 자체는 처음이라 good에 둡니다."),
|
|
162
|
+
]}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def load_profile(repo: Repository) -> Profile:
|
|
166
|
+
return PROFILE
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def judge(profile: Profile, postings: list[RawPosting]) -> tuple[list[Match], list[Any]]:
|
|
170
|
+
await asyncio.sleep(float(os.environ.get("DEMO_JUDGE_DELAY_S", DEMO_JUDGE_DELAY_S)))
|
|
171
|
+
return [MATCHES[p.external_id].model_copy() for p in postings], []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def logged_in(provider: str) -> bool:
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def make_app() -> FastAPI:
|
|
179
|
+
set_fixed_now(FIXED_NOW)
|
|
180
|
+
tmp: Path | None = None
|
|
181
|
+
|
|
182
|
+
def temp_path(name: str) -> Path:
|
|
183
|
+
nonlocal tmp
|
|
184
|
+
tmp = tmp or Path(tempfile.mkdtemp(prefix="gethired-demo-"))
|
|
185
|
+
return tmp / name
|
|
186
|
+
|
|
187
|
+
db_path = Path(os.environ.get("DB_PATH") or temp_path("app.db"))
|
|
188
|
+
resume_dir = Path(os.environ.get("RESUME_DIR") or temp_path("resume"))
|
|
189
|
+
|
|
190
|
+
repo = Repository(db_path)
|
|
191
|
+
jobs = JobManager(repo, scrapers=dict(SCRAPERS), profile_loader=load_profile, judge_fn=judge)
|
|
192
|
+
app = create_app(repo, jobs, logged_in=logged_in, resume_dir=resume_dir)
|
|
193
|
+
app.state.db_path, app.state.resume_dir = db_path, resume_dir
|
|
194
|
+
print(f"demo mode: DB_PATH={db_path} RESUME_DIR={resume_dir}", file=sys.stderr)
|
|
195
|
+
if FRONTEND_DIST.exists():
|
|
196
|
+
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
|
197
|
+
return app
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if os.environ.get("GETHIRED_DEMO") == "1":
|
|
201
|
+
app = make_app()
|