Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ dependencies {
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'

// JSONL 파일 파싱을 위해 사용한 ObjectMapper용 Jackson 라이브러리
implementation 'org.springframework.boot:spring-boot-starter-web'
}

tasks.named('test') {
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
56 changes: 56 additions & 0 deletions database/scripts/build_pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""개발 세부직군 태그를 offset 페이징으로 훑어 후보 id 풀을 구성하고 N개 샤드로 분할.

출력:
<out>/pool.json : 전체 후보 id (순서보존 dedup)
<out>/shard_{k}.json : k번째 샤드 id 목록 (멀티에이전트용)

사용:
python build_pool.py --shards 10 --per-tag-max 400 --out ../out/shards
"""
from __future__ import annotations
import argparse, json, os, sys, time
from wanted import list_job_ids, DEV_TAG_IDS


def build_pool(per_tag_max, page=100, delay=0.4):
seen, pool = set(), []
for tid in DEV_TAG_IDS:
got = 0
for off in range(0, per_tag_max, page):
try:
ids = list_job_ids(limit=page, offset=off, tag_type_id=tid)
except Exception as e:
print(f"[tag {tid}] off {off} 실패: {e}", file=sys.stderr)
break
if not ids:
break
new = 0
for j in ids:
if j not in seen:
seen.add(j); pool.append(j); new += 1
got += len(ids)
time.sleep(delay)
if len(ids) < page:
break
print(f"[tag {tid}] 누적 풀 {len(pool)}", file=sys.stderr)
return pool


if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--shards", type=int, default=10)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ap.add_argument("--per-tag-max", type=int, default=400)
ap.add_argument("--out", default="../out/shards")
a = ap.parse_args()
if a.shards <= 0:
+ ap.error("--shards must be greater than zero")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
os.makedirs(a.out, exist_ok=True)
pool = build_pool(a.per_tag_max)
json.dump(pool, open(os.path.join(a.out, "pool.json"), "w"))
# 라운드로빈 분할(인접 id 가 같은 샤드에 몰리지 않게)
shards = [[] for _ in range(a.shards)]
for i, jid in enumerate(pool):
shards[i % a.shards].append(jid)
for k, sh in enumerate(shards):
json.dump(sh, open(os.path.join(a.out, f"shard_{k}.json"), "w"))
print(f"\n풀 {len(pool)}건 → {a.shards}개 샤드 (각 ~{len(pool)//a.shards}건) → {a.out}/")
61 changes: 61 additions & 0 deletions database/scripts/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""공통 HTTP/파싱 헬퍼 (표준 라이브러리만 사용 — 외부 의존성 없음)."""
from __future__ import annotations
import gzip
import json
import re
import urllib.parse
import urllib.request

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36")


def http_get(url, referer=None, timeout=30):
"""브라우저 UA로 GET. gzip 응답도 처리. 본문 문자열 반환."""
req = urllib.request.Request(url, headers={
"User-Agent": UA,
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
})
if referer:
req.add_header("Referer", referer)
with urllib.request.urlopen(req, timeout=timeout) as r:
data = r.read()
if r.headers.get("Content-Encoding") == "gzip":
data = gzip.decompress(data)
return data.decode("utf-8", "replace")


def http_json(url, referer=None, timeout=30):
return json.loads(http_get(url, referer, timeout))


def quote(s):
return urllib.parse.quote(str(s))


def rsc_blob(html):
"""잡코리아 Next.js(App Router) RSC: self.__next_f.push 조각들을 하나의 문자열로 결합."""
out = []
for p in re.findall(r"self\.__next_f\.push\((\[.*?\])\)", html, re.S):
try:
for el in json.loads(p):
if isinstance(el, str):
out.append(el)
except Exception:
out.append(p)
return "".join(out)


_PAREN = re.compile(r"\([^)]*\)") # 영문 별칭 등 괄호군: 넵튠(Neptune)→넵튠, 크몽(kmong)→크몽
_DROP = re.compile(r"㈜|주식회사|유한회사|inc\.?|co\.,?\s*ltd\.?|corp\.?|ltd\.?", re.I)


def norm_company(name):
"""기업명 매칭/조인 키: 괄호별칭·법인 표기·공백 제거 + 소문자.
원티드 '크몽(kmong)' ↔ 잡코리아 '㈜크몽' 같은 표기 차를 흡수한다."""
if not name:
return ""
n = _PAREN.sub("", name) # (Neptune)/(kmong)/(주) 등 괄호군 제거
n = _DROP.sub("", n) # ㈜/주식회사/Inc/Co.,Ltd 등 법인 표기 제거
n = re.sub(r"\s+", "", n)
return n.lower()
126 changes: 126 additions & 0 deletions database/scripts/jobkorea_company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""잡코리아(enrichment) 기업 메타 추출기.

원티드는 기업 메타가 약하다(업종 정도). 잡코리아 공고 RSC의 CORP_INFO 객체에는
사원수·기업규모·업종·상장여부·주소가 정형으로 들어있다(실측 검증).

경로(기업명만 알면 됨):
1) 잡코리아 통합검색 /Search/?stext={기업명} → 해당 기업 공고 Gno 추출
2) /Recruit/GI_Read/{Gno} 의 RSC → CORP_INFO 회사객체 파싱

한계:
- 해당 기업이 잡코리아에 공고가 있어야 함(없으면 not_found → 원티드 industry_name으로 fallback).
- 검색 첫 결과를 쓰므로 동명이인 가능 → 정규화 이름 일치(name_match)로 검증.
"""
from __future__ import annotations
import json
import re
import sys

from common import http_get, rsc_blob, norm_company, quote


def search_gnos(company_name, limit=4):
"""기업명으로 잡코리아 검색 → 후보 공고 Gno 들(순서 보존 dedup, 상위 limit개).
첫 결과가 무관한 회사일 수 있어, enrich 에서 이름 일치하는 후보를 고른다."""
html = http_get(f"https://www.jobkorea.co.kr/Search/?stext={quote(company_name)}",
referer="https://www.jobkorea.co.kr/")
raw = re.findall(r"/Recruit/GI_Read/([0-9]+)", html)
if not raw:
raw = re.findall(r"GI_Read/([0-9]+)", rsc_blob(html))
out = []
for g in raw: # 순서 보존 dedup
if g not in out:
out.append(g)
if len(out) >= limit:
break
return out


def _posting_company_name(html, blob):
"""공고의 회사명(신뢰): JSON-LD hiringOrganization.name. HTML LD 우선, RSC fallback."""
for m in re.finditer(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>', html, re.S):
try:
j = json.loads(m.group(1))
except Exception:
continue
for it in (j if isinstance(j, list) else [j]):
if isinstance(it, dict) and it.get("@type") == "JobPosting":
org = it.get("hiringOrganization") or {}
if org.get("name"):
return org["name"]
# fallback: RSC(이스케이프된 JSON) 안의 hiringOrganization name
m = re.search(r'hiringOrganization\\?"\s*:\s*\{[^}]*?name\\?"\s*:\s*\\?"([^"\\]{1,60})', blob)
return m.group(1) if m else None


def fetch_corp_info(gno):
"""공고 RSC의 CORP_INFO 회사객체에서 메타 추출. employeeCount 를 앵커로 사용."""
html = http_get(f"https://www.jobkorea.co.kr/Recruit/GI_Read/{gno}?sc=729&sn=103",
referer="https://www.jobkorea.co.kr/")
blob = rsc_blob(html)
i = blob.find('"employeeCount"')
if i < 0:
return None
seg = blob[max(0, i - 500):i + 700]

def s(key):
m = re.search(r'"' + key + r'"\s*:\s*"([^"]{0,80})"', seg)
return m.group(1) if m else None

def n(key):
m = re.search(r'"' + key + r'"\s*:\s*([0-9]+)', seg)
return int(m.group(1)) if m else None

addr = None
am = re.search(r'"address"\s*:\s*\{[^}]*?"address"\s*:\s*"([^"]{0,80})"'
r'(?:[^}]*?"addressDetail"\s*:\s*"([^"]{0,80})")?', seg)
if am:
addr = " ".join(p for p in [am.group(1), am.group(2)] if p).strip()

return {
"jobkorea_gno_ref": int(gno),
"company_name_jk": _posting_company_name(html, blob), # JSON-LD 기준 (신뢰)
"employee_count": n("employeeCount"),
"company_type": s("companyTypeName"), # 대기업/중견기업/중소기업
"industry": s("industryName"),
"stock_status": s("stockStatusName"), # 코스피/코스닥/-
"hq_address": addr,
}


def enrich(company_name, max_candidates=4):
"""기업명 → 잡코리아 메타 dict. enrichment_status 포함.

상위 후보 Gno 들을 훑어 **이름이 일치하는 회사만** 채택한다. 일치가 없으면
틀린 회사의 사원수·규모를 저장하지 않는다(무결성). status:
enriched — 이름 일치, 메타 채움
name_mismatch — 후보는 있었으나 이름 일치 없음(타사 메타 폐기, 감사용 rejected_name 만 남김)
not_found — 검색 결과 없음
"""
target = norm_company(company_name)
gnos = search_gnos(company_name, limit=max_candidates)
if not gnos:
return {"enrichment_status": "not_found"}

rejected = None
for gno in gnos:
meta = fetch_corp_info(gno)
if not meta:
continue
cand = meta.get("company_name_jk") or ""
if norm_company(cand) == target and target:
meta["name_match"] = True
meta["enrichment_status"] = "enriched"
meta["raw_jobkorea"] = {k: meta[k] for k in
("employee_count", "company_type", "industry",
"stock_status", "hq_address", "company_name_jk")}
return meta
rejected = rejected or cand # 첫 후보명만 감사용으로 기록
# 어떤 후보도 이름이 안 맞음 → 타사 메타는 버린다(저장 금지)
return {"enrichment_status": "name_mismatch", "name_match": False,
"rejected_name": rejected}


if __name__ == "__main__":
name = sys.argv[1] if len(sys.argv) > 1 else "메리츠화재"
print(json.dumps(enrich(name), ensure_ascii=False, indent=2))
62 changes: 62 additions & 0 deletions database/scripts/merge_shards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""샤드 결과 병합 + dedup → 최종 out/job_postings.jsonl, out/companies.jsonl + 통계.

공고 : external_id 기준 dedup
기업 : normalized_name 기준 dedup (enriched 를 우선 채택)

사용:
python merge_shards.py --shards-dir ../out/shards --out ../out
"""
from __future__ import annotations
import argparse, glob, json, os
from collections import Counter

RANK = {"enriched": 3, "name_mismatch": 2, "not_found": 1, None: 0}


def main(shards_dir, out_dir):
posts, comps = {}, {}
for fp in sorted(glob.glob(os.path.join(shards_dir, "postings_*.jsonl"))):
for line in open(fp, encoding="utf-8"):
p = json.loads(line)
posts.setdefault(p["external_id"], p) # 첫 등장 유지
for fp in sorted(glob.glob(os.path.join(shards_dir, "companies_*.jsonl"))):
for line in open(fp, encoding="utf-8"):
c = json.loads(line)
k = c["normalized_name"]
cur = comps.get(k)
# 더 좋은 보강상태를 우선
if cur is None or RANK.get(c.get("enrichment_status"), 0) > RANK.get(cur.get("enrichment_status"), 0):
comps[k] = c

os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "job_postings.jsonl"), "w", encoding="utf-8") as f:
for p in posts.values():
f.write(json.dumps(p, ensure_ascii=False) + "\n")
with open(os.path.join(out_dir, "companies.jsonl"), "w", encoding="utf-8") as f:
for c in comps.values():
f.write(json.dumps(c, ensure_ascii=False) + "\n")

np = len(posts); nc = len(comps)
st = Counter(c.get("enrichment_status") for c in comps.values())
sk = sum(1 for p in posts.values() if p.get("skill_tags"))
th = sum(1 for p in posts.values() if p.get("thumbnail_url"))

print(f"=== 병합 완료 → {out_dir}/ ===")

#np가 0일 때를 대비
sk_pct = 100 * sk // np if np > 0 else 0
th_pct = 100 * th // np if np > 0 else 0
print(f"공고 {np}건 (skill {sk}={sk_pct}% / thumbnail {th}={th_pct}%)")
print(f"기업 {nc}곳 보강: " + " ".join(f"{k}={v}" for k, v in st.most_common()))

#nc가 0일 때를 대비
enr_pct = 100 * st.get('enriched', 0) // nc if nc > 0 else 0
print(f" enriched 비율: {enr_pct}%")


if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--shards-dir", default="../out/shards")
ap.add_argument("--out", default="../out")
a = ap.parse_args()
main(a.shards_dir, a.out)
Loading