| name | job-scraping |
| description | Web scraping workflow for collecting job postings from Korean job sites using agent-browser with custom User-Agent |
| allowed-tools | ["Bash(agent-browser:*)","Bash(sleep)","Bash(curl)"] |
Job Scraping Skill v6.4 (EXP-167: Korean word boundary fix โ ๋ทฐ/๋คํธ lookbehind prevents ๋ฆฌ๋ทฐโvue, ์คํ๋คํธโdart false positives)
ํต์ฌ: agent-browser์ --user-agent ํ๋๊ทธ๊ฐ ํ์. ์์ผ๋ฉด Wanted์์ 403 ์๋ฌ ๋ฐ์.
Work Type + Location Detection (EXP-025)
Wanted listing text์ ์ฌํ/ํ์ด๋ธ๋ฆฌ๋/์ง์ญ ์ ๋ณด๊ฐ ํฌํจ๋ ๊ฒฝ์ฐ ํ์ฑ:
Work Type ํค์๋
- remote: ์ ๋ฉด์ฌํ, ์ฌํ๊ทผ๋ฌด, ํ๋ฆฌ๋ชจํธ, full remote, ์๊ฒฉ๊ทผ๋ฌด, fully remote, 100% remote
- hybrid: ํ์ด๋ธ๋ฆฌ๋, ์ฃผN์ผ์ถ๊ทผ, hybrid, ์ฃผN์ผ ์ถ๊ทผ
- onsite: ์ ํค์๋ ์์ผ๋ฉด ๊ธฐ๋ณธ๊ฐ
๊ฐ์ง ํ ํค์๋๋ฅผ working text์์ ์ ๊ฑฐ (title ์ค์ผ ๋ฐฉ์ง).
Location ์ถ์ถ
- ๋ธ๋ํท์์ ์ถ์ถ:
[์์ธ ์๋ฑํฌ๊ตฌ], [ํ๊ต], [๋ถ์ฐ/...]
- city + district ํจํด:
์์ธ ์๋ฑํฌ๊ตฌ, ๊ฒฝ๊ธฐ ๋ถ๋น
- mixed bracket๋ ์ฒ๋ฆฌ:
[๋ถ์ฐ/๊ฒฝ๋ ฅ 5๋
] โ location: ๋ถ์ฐ
- ๋ธ๋ํท ์์ผ๋ฉด bare text์์ city/district keyword ๊ฒ์ ํ ์ ๊ฑฐ
User-Agent (๋ฐ๋์ ์ฌ์ฉ)
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
agent-browser --user-agent "$UA" open "..."
Fallback Chain
1์ฐจ: Wanted API (scrape-wanted-api.js) โ PRIMARY for Wanted (bypasses 403)
2์ฐจ: agent-browser + custom User-Agent โ PRIMARY for JobKorea/LinkedIn
3์ฐจ: web_fetch (์ ์ ํ์ด์ง ๋งํฌ๋ค์ด ๋ณํ)
4์ฐจ: web_search (๊ณต๊ณ ๊ฒ์์ผ๋ก URL ๋ฐ๊ฒฌ)
5์ฐจ: ์๋ (์ฌ์ฉ์์๊ฒ ์๋ฆผ)
Note (2026-04-05): Wanted.co.kr ๊ฒ์ ํ์ด์ง๋ ๋ธ๋ผ์ฐ์ ์๋ํ ์ 403์ ๋ฐํํ ์ ์์.
๋์ Wanted์ ๋น๊ณต๊ฐ(ไฝ ๊ณต๊ฐ ์ ๊ทผ ๊ฐ๋ฅ) JSON API๋ฅผ ์ฌ์ฉํ์ฌ 403์ ์ฐํํจ.
scripts/scrape-wanted-api.js๊ฐ 1์ฐจ ๋ฐฉ์.
Source 1: Wanted (wanted.co.kr) โ
๊ฒ์ฆ์๋ฃ
โก API-based Scraping (PRIMARY โ bypasses 403)
Wanted's search API endpoint is publicly accessible and returns structured JSON, completely bypassing browser 403 issues.
node scripts/scrape-wanted-api.js --keyword "ํ๋ก ํธ์๋" --limit 20
node scripts/scrape-wanted-api.js --keyword "์๊ฒฉ" --limit 20 --details
node scripts/scrape-wanted-api.js --keyword "๋ฐฑ์๋" --limit 50 --offset 50
API Endpoints:
- Search:
GET /api/chaos/search/v1/results?keyword={}&limit=20&offset=0&tab=position&query={}
- Detail:
GET /api/v1/jobs/{wdId}?lang=ko
Search Response Structure (positions.data[]):
{
"id": 351790,
"position": "ํ๋ก ํธ์๋ ๊ฐ๋ฐ์",
"company": { "name": "๊ฒ์ฐจ" },
"address": { "country": "ํ๊ตญ", "location": "์์ธ", "full_location": "์์ธ ๊ฐ๋จ๊ตฌ ..." },
"employment_type": "regular",
"due_time": "2026-04-20T00:00:00",
"reward": { "formatted_total": "100๋ง์" },
"is_newbie": false
}
Detail Response Structure:
{
"jd": "<full HTML description>",
"position": "ํ๋ก ํธ์๋ ๊ฐ๋ฐ์",
"company_name": "๊ฒ์ฐจ",
"address": { "full_location": "์์ธ ๊ฐ๋จ๊ตฌ ...", "geo_location": { "location": { "lat": ..., "lng": ... } } }
}
Advantages over browser scraping:
- No 403 bot detection issues
- Structured JSON โ no CSS selector parsing needed
- Company name, location, employment_type already separated
- Faster (no browser overhead, no sleep needed)
- Includes geo coordinates for commute calculation
Limitations:
- No salary data in API response (enrich via post-processing or title parsing)
- No explicit work_type field (infer from JD text or detail page)
- No explicit experience years (only
is_newbie boolean)
Browser-based Scraping (FALLBACK)
์
๋ ํฐ
a[href*="/wd/"] โ CSS class ์
๋ ํฐ(.JobCard_container)๋ ์๋ํ์ง ์์
๋ฐ์ดํฐ ๊ตฌ์กฐ
- title, company, experience, reward๊ฐ
el.textContent์ ํฉ์ณ์ ธ ์์
- EXP-023 pre-segmentation, EXP-025 work_type/location, EXP-031 eval sync ์ ์ฉ๋จ
์ํฌํ๋ก์ฐ
agent-browser --user-agent "$UA" open "https://www.wanted.co.kr/search?query={keyword}&tab=position"
sleep 5
agent-browser wait --load networkidle
agent-browser eval "[...document.querySelectorAll('a[href*=\"/wd/\"]')].slice(0,20).map(el => {
function escapeRegExp(s) { return s.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&'); }
const allText = (el.textContent || '').trim();
const link = el.href;
const wdId = link?.split('/wd/')[1] || '';
let r = { id: wdId, title: '', company: '', experience: '', reward: '', work_type: 'onsite', location: '', link: link };
let t = allText;
// Location from brackets (before removal)
const cities = '(์์ธ|๊ฒฝ๊ธฐ|๋ถ์ฐ|๋์ |์ธ์ฒ|๊ด์ฃผ|๋๊ตฌ|์ธ์ฐ|ํ๊ต|๊ฐ๋จ|์๋ฑํฌ|์กํ|์ฑ์|์ญ์ผ|์ ์ค|๋งํฌ|์ฉ์ฐ|๊ตฌ๋ก|๋ถ๋น|์ผ์ฐ|ํ์ด|์์|์ด์ฒ)';
const bm = t.match(new RegExp('\\\\[.*?' + cities + '.*?\\\\]'));
if (bm) { const lm = bm[0].replace(/[\\[\\]]/g,'').match(new RegExp(cities+'(?:\\\\s+[๊ฐ-ํฃ]{2,3}(?:๊ตฌ|์|๊ตฐ|๋))?)')); if (lm) r.location = lm[0].trim(); }
// Work type detection (EXP-025)
if (/์ ๋ฉด์ฌํ|์ฌํ๊ทผ๋ฌด|ํ๋ฆฌ๋ชจํธ|full\\s*remote|์๊ฒฉ๊ทผ๋ฌด|fully\\s*remote/i.test(t)) r.work_type = 'remote';
else if (/ํ์ด๋ธ๋ฆฌ๋|์ฃผ\\\\d์ผ\\\\s*์ถ๊ทผ|hybrid/i.test(t)) r.work_type = 'hybrid';
t = t.replace(/์ ๋ฉด์ฌํ|์ฌํ๊ทผ๋ฌด|ํ๋ฆฌ๋ชจํธ|์๊ฒฉ๊ทผ๋ฌด|fully?\\s*remote|ํ์ด๋ธ๋ฆฌ๋|์ฃผ\\\\d์ผ\\\\s*์ถ๊ทผ|hybrid/gi, ' ');
// EXP-037: Extract company from raw text before pre-segmentation
let rawCompany = null;
const rcm = allText.match(/([๊ฐ-ํฃ]+(?:\\\\s*\\\\([^)]+\\\\))?)๊ฒฝ๋ ฅ/);
if (rcm) { rawCompany = rcm[1].replace(/^(๊ฐ๋ฐ์|์์ง๋์ด|๋งค๋์ |๋์์ด๋|๊ธฐํ์|๋ถ์๊ฐ|๋ฆฌ๋|์ปจ์คํดํธ|์ ๋ฌธ๊ฐ|๋๋ ํฐ|๊ณผ์ฅ|์ฐจ์ฅ|๋ถ์ฅ|๋๋ฆฌ|์ฌ์|์ธํด|PD|PM|CTO|CEO|COO)/, ''); if (rawCompany.length < 2) rawCompany = null; }
// Pre-segmentation for concatenated text (EXP-023)
t = t.replace(/(๊ฒฝ๋ ฅ)/g, ' \$1').replace(/(ํฉ๊ฒฉ|๋ณด์๊ธ|์ฑ๊ณผ๊ธ)/g, ' \$1').trim();
// Remove brackets + slashes
t = t.replace(/\\\\[.*?\\\\]/g, '').replace(/\\\\//g, ' ').trim();
// Bare location (if not from brackets)
if (!r.location) { const lp = t.match(new RegExp(cities+'(?:\\\\s+[๊ฐ-ํฃ]{2,3}(?:๊ตฌ|์|๊ตฐ|๋))?)')); if (lp) r.location = lp[0]; }
if (r.location) t = t.replace(new RegExp(escapeRegExp(r.location), 'g'), ' ').trim();
// Experience (supports ~ and - ranges)
const em = t.match(/๊ฒฝ๋ ฅ[\\\\s]*(\\\\d+[~-]\\\\d+๋
|\\\\d+๋
\\\\s*์ด์|\\\\d+๋
โ|๋ฌด๊ด)/);
if (em) { r.experience = '๊ฒฝ๋ ฅ ' + em[1]; t = t.replace(em[0], ' ').trim(); }
// Reward
const rm = t.match(/(๋ณด์๊ธ|ํฉ๊ฒฉ๊ธ)[\\\\s]*(\\\\d+๋ง์)/);
if (rm) { r.reward = rm[0]; t = t.replace(rm[0], ' ').trim(); }
// Noise cleanup: standalone ํฉ๊ฒฉ
t = t.replace(/ํฉ๊ฒฉ/g, ' ').trim();
// Company extraction
let cm = null;
const kInd = ['ใ','์ฃผ์ํ์ฌ','์ ํํ์ฌ','(์ฃผ)'];
for (const ind of kInd) { const m = t.match(new RegExp(escapeRegExp(ind)+'\\\\s*([^\\\\s,]+(?:\\\\s[^\\\\s,]+)?)')); if (m) { cm = m[0]; break; } }
if (!cm) {
const known = ['์นด์นด์ค','๋ค์ด๋ฒ','์ผ์ฑ','๋ผ์ธ','์ฐ์ํํ์ ๋ค','๋ฐฐ๋ฌ์๋ฏผ์กฑ','ํ ์ค','๋น๊ทผ๋ง์ผ','ํฌ๋ชฝ','์ผ๋์','๋ง์ดํ๋ ์ด์ค','ํ์ปด','๋ค์ค์์ฆ','๋ฅ์จ','์์จ์ํํธ','ํค์','๋ฏธ๋์','์จ์ด๋ธ๋ฆฟ์ง','ํธ๋ฆฌ๋
ธ๋','ํ์นญ','๋น๋์ค','์ฝ์ด์
','ํคํธ์์ค','๋์กด','์ฟ ํก','111ํผ์ผํธ','์คํจ์ด๋','์ธํฐ์์ค','์์','์์ด์์ค'];
for (const c of known) { if (new RegExp(escapeRegExp(c)).test(t)) { cm = c; t = t.replace(c, ' '); break; } }
}
// EXP-037: Fallback โ number+Korean company names (e.g., 111ํผ์ผํธ)
if (!cm) { const nk = t.match(/(\d+[๊ฐ-ํฃ]{2,}(?:\([A-Za-z0-9]+\))?)$/); if (nk) cm = nk[1]; }
// EXP-038: Fallback โ camelCase English company name (e.g., DeveloperVingle โ Vingle)
if (!cm) { const cc = t.match(/([a-z])([A-Z][a-z]+)\s*$/); if (cc) cm = t.substring(cc.index + 1).trim(); }
if (cm) { r.company = cm.replace(/^[\\sใ]+/,'').replace(/^\\(์ฃผ\\)\\s*/,''); if (!cm.includes('ใ') && !cm.includes('์ฃผ์ํ์ฌ') && !cm.includes('(์ฃผ)')) t = t.replace(new RegExp(escapeRegExp(cm),'g'),' '); }
r.title = t.replace(/[,ยท\\\\s]+/g,' ').trim() || '์ง๋ฌด ๋ฏธ์';
if (!r.company || r.company.length < 2) r.company = 'ํ์ฌ๋ช
๋ฏธ์';
return r;
})" --json > wanted_jobs.json
agent-browser click @{ref}
sleep 3
agent-browser eval "document.querySelector('.job-description, [class*=description]')?.textContent"
agent-browser back
agent-browser close
์ํ ๊ฒฐ๊ณผ
{"id":"350866","title":"๋์งํธ ํ์ต ํ๋ซํผ ๋ฐฑ์๋ ๊ฐ๋ฐ์ (JAVA)","company":"๋ฏธ๋์","experience":"๊ฒฝ๋ ฅ 5๋
์ด์","reward":"ํฉ๊ฒฉ๋ณด์๊ธ 100๋ง์","link":"https://www.wanted.co.kr/wd/350866"}
Post-Processing (EXP-053)
If the eval output contains raw concatenated text (company/experience/reward all in one string), run the post-processor:
cat wanted_jobs.json | node scripts/post-process-wanted.js > wanted_jobs_parsed.json
The post-processor (scripts/post-process-wanted.js) applies the validated parsing logic to raw scrape output. It handles:
- Company extraction from Korean/English names before
๊ฒฝ๋ ฅ
- Work type detection (remote/hybrid/onsite)
- Location from brackets and bare city names
- Experience ranges (N-M๋
, N๋
์ด์, ๋ฌด๊ด)
- Reward extraction
- Salary extraction:
์ฐ๋ด/์๊ธ/์ฐ์์
+ range, single value, or ๋ฉด์ ํ๊ฒฐ์ (EXP-057)
- Standalone
๋ฉด์ ํ๊ฒฐ์ captured as salary (not leaked to title) (EXP-057)
- Title suffix stripping
- Already-parsed pass-through (idempotent)
Source 2: JobKorea (jobkorea.co.kr) โ
๊ฒ์ฆ์๋ฃ
์
๋ ํฐ (Fallback Chain)
CSS module hash ์
๋ ํฐ๋ ์ฌ์ดํธ ์
๋ฐ์ดํธ ์ ๋ณ๊ฒฝ๋จ. Fallback chain์ผ๋ก ๋ณต์๋ ฅ ํ๋ณด.
| Priority | Selector | Strategy |
|---|
| 1์ฐจ | [class*=dlua7o0] | CSS module hash (ํ์ฌ ๋์) |
| 2์ฐจ | div.list-item, div[class*=recruit-item] | ์๋ฏธ์ ํด๋์ค๋ช
|
| 3์ฐจ | a[href*="Recruit/Detail"]์ ์กฐ์ div (3๋จ๊ณ) | ์์ ์ ์ธ ๋งํฌ ๊ธฐ๋ฐ ์ญ์ถ์ |
| 4์ฐจ | #smScrapList li ๋๋ ๊ฒ์ ๊ฒฐ๊ณผ ์ปจํ
์ด๋ ๋ด li | ๊ตฌ์กฐ์ ํด๋ฐฑ |
์ํฌํ๋ก์ฐ
agent-browser --user-agent "$UA" open "https://www.jobkorea.co.kr/Search/?stext={keyword}&tabType=recruit"
sleep 5
agent-browser wait --load networkidle
agent-browser eval "(() => {
// Fallback selector chain
const selectors = [
'[class*=dlua7o0]',
'div.list-item, div[class*=recruit-item]',
'a[href*=\"Recruit/Detail\"]'
];
let cards = [];
for (const sel of selectors) {
if (sel.includes('Recruit')) {
// Link-based fallback: group by parent
const links = [...document.querySelectorAll(sel)];
const parentSet = new Map();
links.forEach(a => {
const parent = a.closest('li') || a.closest('div[class]') || a.parentElement;
if (parent && !parentSet.has(parent)) parentSet.set(parent, a);
});
cards = [...parentSet.keys()];
} else {
cards = [...document.querySelectorAll(sel)];
}
if (cards.length > 0) break;
}
// JobKorea positional parsing (EXP-035): classify โ extract in order
return cards.slice(0,20).map(card => {
const text = (card.textContent || '').trim();
const lines = text.split(/\\n/).map(s => s.trim()).filter(Boolean);
const cityP = /(์์ธ|๊ฒฝ๊ธฐ|๋ถ์ฐ|๋์ |์ธ์ฒ|๊ด์ฃผ|๋๊ตฌ|์ธ์ฐ|ํ๊ต|๊ฐ๋จ|์๋ฑํฌ|์กํ|์ฑ์|์ญ์ผ|์ ์ค|๋งํฌ|์ฉ์ฐ|๊ตฌ๋ก|๋ถ๋น|์ผ์ฐ|ํ์ด|์์|์ด์ฒ|์ฑ๋จ|์ค๊ตฌ)/;
const prefixP = /^(ใ|\\(์ฃผ\\)|์ฃผ์ํ์ฌ)/;
const uiNoise = /์คํฌ๋ฉ\\d*|์ง์\\d*๋ช
|๋ฑ๋ก/;
let title='',company='',experience='',location='',deadline='',salary='';
// Classify
const cls = lines.map((l,i)=> {
if (/๋ง๊ฐ/.test(l)) return {t:'dl',l,i};
if (/^์ ์
$/.test(l)) return {t:'exp',l,i};
if (/^๊ฒฝ๋ ฅ/.test(l)) { const r=l.replace(/^๊ฒฝ๋ ฅ\\s*/,''); if(!r||/^๋ฌด๊ด/.test(r)||/^\\d/.test(r)) return {t:'exp',l,i}; }
if (/^(์ฐ๋ด|์๊ธ)\\s*\\d/.test(l) || /^๋ฉด์ ํ๊ฒฐ์ /.test(l)) return {t:'sal',l,i};
if (uiNoise.test(l)) return {t:'noise',l,i};
return {t:'unk',l,i};
});
const dl=cls.find(c=>c.t==='dl'); if(dl) deadline=dl.l;
const ex=cls.find(c=>c.t==='exp'); if(ex) experience=ex.l;
const sa=cls.find(c=>c.t==='sal'); if(sa) salary=sa.l;
const unks=cls.filter(c=>c.t==='unk');
// Company by prefix
let ci=-1;
for(const u of unks) { if(prefixP.test(u.l)){company=u.l.replace(prefixP,'').trim();ci=u.i;break;} }
// Location: last city-matching unknown (handles company-name-is-city edge)
const cm=unks.filter(u=>u.i!==ci&&cityP.test(u.l));
let li=-1;
if(cm.length){const e=cm[cm.length-1];location=e.l;li=e.i;if(!company&&cm.length>=2){company=cm[0].l;ci=cm[0].i;}}
// Company fallback: positional (first unknown after title)
if(!company){for(const u of unks){if(u.i!==li){if(!title)title=u.l;else{company=u.l;ci=u.i;break;}}}}
// Title: first unknown not company/location
if(!title){for(const u of unks){if(u.i!==ci&&u.i!==li){title=u.l;break;}}}
const linkEl = card.querySelector('a[href*=\"Recruit\"]') || card.closest('a[href*=\"Recruit\"]');
return { title, company, experience, location, deadline, salary, link: linkEl?.href || '' };
});
})()" --json > jobkorea_jobs.json
node -e "
const {parseJobKoreaCard} = require('./scripts/post-process-jobkorea');
const fs = require('fs');
const raw = JSON.parse(fs.readFileSync('jobkorea_jobs.json','utf8'));
const processed = raw.map(r => typeof r === 'string' || r.text ? parseJobKoreaCard(r) : ({...r, salary_min: null, salary_max: null}));
fs.writeFileSync('jobkorea_jobs.json', JSON.stringify(processed, null, 2));
"
agent-browser close
Source 3: LinkedIn (linkedin.com/jobs) โ
๊ฒ์ฆ์๋ฃ
์
๋ ํฐ
.base-card ๋๋ .jobs-search__results-list li
์ํฌํ๋ก์ฐ
agent-browser --user-agent "$UA" open "https://www.linkedin.com/jobs/search/?keywords={keyword}&location=South+Korea"
sleep 5
agent-browser wait --load networkidle
agent-browser eval "window.location.href" --json
agent-browser eval "[...document.querySelectorAll('.jobs-search__results-list li, .base-card')].slice(0,20).map(el => {
const titleEl = el.querySelector('.base-search-card__title, h3');
const companyEl = el.querySelector('.base-search-card__subtitle, h4');
const locEl = el.querySelector('.job-search-card__location, [class*=location]');
const linkEl = el.querySelector('a[href*=\"/jobs/\"]');
return {
title: titleEl?.textContent?.trim() || '',
company: companyEl?.textContent?.trim() || '',
location: locEl?.textContent?.trim() || '',
link: linkEl?.href || ''
};
})" --json > linkedin_jobs.json
agent-browser close
LinkedIn ์นด๋ ํ์ฒ๋ฆฌ (v3.7)
LinkedIn ์นด๋์์ ์ถ์ถ ํ ์ถ๊ฐ ํ์ฑ ํ์:
const normalizeLocation = (loc) => {
if (!loc) return '';
let l = loc.replace(/,?\s*South Korea\s*$/i, '').replace(/,?\s*๋ํ๋ฏผ๊ตญ\s*$/, '');
const cities = [['Seoul','์์ธ'],['Busan','๋ถ์ฐ'],['Suwon','์์'],['Pangyo','ํ๊ต'],
['Incheon','์ธ์ฒ'],['Daegu','๋๊ตฌ'],['Daejeon','๋์ '],['Gwangju','๊ด์ฃผ'],['Ulsan','์ธ์ฐ'],['Jeju','์ ์ฃผ']];
for (const [en, kr] of cities) { if (new RegExp('\\b'+en+'\\b','i').test(l)) { l = l.replace(new RegExp('\\b'+en+'\\b','i'), kr); break; } }
return l.replace(/,?\s*Gyeonggi-do/i,' ๊ฒฝ๊ธฐ๋').replace(/,?\s*Gyeonggi/i,' ๊ฒฝ๊ธฐ๋').replace(/,\s*/g,' ').replace(/\s+/g,' ').trim();
};
const parseKoreanDate = (text) => {
if (!text) return null;
const monthDay = text.match(/(\d+)์\s*(\d+)์ผ/);
if (monthDay) {
const now = new Date();
return new Date(now.getFullYear(), parseInt(monthDay[1]) - 1, parseInt(monthDay[2]));
}
const dDay = text.match(/D-(\d+)/);
if (dDay) {
const deadline = new Date();
deadline.setDate(deadline.getDate() + parseInt(dDay[1]));
return deadline;
}
const ago = text.match(/(\d+)(์ผ|์ฃผ)\s*์ /);
if (ago) {
const date = new Date();
const unit = ago[2] === '์ฃผ' ? 7 : 1;
date.setDate(date.getDate() - parseInt(ago[1]) * unit);
return date;
}
const mmdd = text.match(/(\d{2})\/(\d{2})/);
if (mmdd) {
const now = new Date();
return new Date(now.getFullYear(), parseInt(mmdd[1]) - 1, parseInt(mmdd[2]));
}
return null;
};
Culture Keyword Extraction (EXP-043, EXP-048)
Job listings contain cultural signals that feed into the matching algorithm's culture component (15% weight). Extract from full job description text (์์ธ ํ์ด์ง) or listing snippet:
Culture Keywords (Korean + English)
| Category | Keywords |
|---|
| innovative | ํ์ , ๋์ , ์ฐฝ์, ํฌ๋ฆฌ์์ดํฐ๋ธ, creative, innovation, ์คํ, experiment |
| collaborative | ํ์
, ํ์ํฌ, ์ํต, ํ๋ ฅ, collaborat*, teamwork, ํจ๊ป, ๊ณต๋, ์ํ์ , ๊ฐ๋กํ |
| fast_paced | ๋น ๋ฅธ, agile, ์ค์๊ฐ, ์คํํธ์
, fast-paced, ๋ฆด๋ฆฌ์ฆ, ์คํ๋ฆฐํธ, sprint |
| structured | ์ฒด๊ณ, ํ๋ก์ธ์ค, systematic, ํ์คํ, QA, ํ์ง๊ด๋ฆฌ, ์ฝ๋๋ฆฌ๋ทฐ, code review, ๊ฐ์ด๋๋ผ์ธ |
| learning_focused | ์ฑ์ฅ, ํ์ต, learning, ๊ต์ก, ์คํฐ๋, ๋ฉํ ๋ง, ์ธ๋ฏธ๋, ์ฌ๋ด๊ฐ์, ๋์์ง์ |
| autonomous | ์์จ, ๋
๋ฆฝ, autonomous, ์๊ธฐ์ฃผ๋, ์ค๋์ญ, ์์ ๋, ์ฃผ๋์ |
| work_life_balance | ์๋ผ๋ฐธ, ์ํฌ๋ผ์ดํ๋ฐธ๋ฐ์ค, WLB, ์ ์ฐ๊ทผ๋ฌด, ์์ฐจ์ถ๊ทผ, ์์ ์ถํด๊ทผ, ์ฐ์ฐจ, ๋ฆฌํ๋ ์, ๊ฐ์กฑ์นํ |
Extraction (JavaScript)
const CULTURE_PATTERNS = {
innovative: /(ํ์ |๋์ |์ฐฝ์|ํฌ๋ฆฌ์์ดํฐ๋ธ|creative|innovation|challenge|์๋ก์ด|์คํ|experiment)/i,
collaborative: /(ํ์
|ํ์ํฌ|์ํต|ํ๋ ฅ|collaborat|teamwork|communication|partnership|ํจ๊ป|๊ณต๋|์ํ์ |๊ฐ๋กํ|ํฌ๋ก์ค\s*ํ์
๋|cross[\s-]?functional)/i,
fast_paced: /(๋น ๋ฅธ|agile|์ค์๊ฐ|์คํํธ์
|fast[\s-]?paced|rapid|๋น ๋ฅด๊ฒ|๋ฏผ์ฒฉ|๋ฆด๋ฆฌ์ฆ|release|์คํ๋ฆฐํธ|sprint|iterations?)/i,
structured: /(์ฒด๊ณ|ํ๋ก์ธ์ค|systematic|process|์ฒด๊ณ์ |์กฐ์ง์ |ํ์คํ|qa|ํ์ง๊ด๋ฆฌ|code\s*review|์ฝ๋๋ฆฌ๋ทฐ|๊ฐ์ด๋๋ผ์ธ|guideline)/i,
learning_focused: /(์ฑ์ฅ|ํ์ต|learning|growth|๊ต์ก|์ํฌ์ต|์ปจํผ๋ฐ์ค|๊ฐ๋ฐ์\s*์ปค๋ฎค๋ํฐ|์คํฐ๋|๋ฉํ |๋ฉํ ๋ง|mentoring|์ธ๋ฏธ๋|์ฌ๋ด๊ฐ์|๋์์ง์|์ํ๋น์ง์)/i,
autonomous: /(์์จ|๋
๋ฆฝ|autonomous|independent|์๊ธฐ์ฃผ๋|์ค๋์ญ|ownership|์ฃผ๋์ |์์ ๋ก์ด|์์ ๋|discretion)/i,
work_life_balance: /(์๋ผ๋ฐธ|์ํฌ๋ผ์ดํ๋ฐธ๋ฐ์ค|work[\s_-]?life[\s_-]?balance|wlb|์ ์ฐ๊ทผ๋ฌด|flexible\s*(working|hours|time)|์์ฐจ์ถ๊ทผ|์์ ์ถํด๊ทผ|์์จ์ถ๊ทผ|์ฐ์ฐจ|ํด๊ฐ|sabbatical|๋ฆฌํ๋ ์|refresh|ํด์|healing|๊ฐ์กฑ์นํ|family[\s-]?friendly)/i,
};
function extractCultureKeywords(text) {
if (!text) return [];
const kw = [];
for (const [key, re] of Object.entries(CULTURE_PATTERNS)) {
if (re.test(text)) kw.push(key);
}
return kw;
}
Extract from: (1) ์์ธ ํ์ด์ง .job-description text (best), (2) listing card textContent (partial), (3) company about page. Store as culture_keywords field (JSON array) in the jobs table.
ํต๊ทผ ๊ฑฐ๋ฆฌ ๊ณ์ฐ (Kakao Map)
export KAKAO_REST_API_KEY="your_api_key_here"
curl -s "https://dapi.kakao.com/v2/local/search/address.json?query={address}" \
-H "Authorization: KakaoAK $KAKAO_REST_API_KEY"
curl -s "https://apis-navi.kakaomobility.com/v1/directions?origin={lon},{lat}&destination={lon},{lat}" \
-H "Authorization: KakaoAK $KAKAO_REST_API_KEY"
Rate Limiting
- ์ต์ ๋๊ธฐ: 3์ด (๋์ผ ๋๋ฉ์ธ)
- ์ต๋ ํ์ด์ง: 50ํ์ด์ง/์ธ์
- robots.txt ์ค์
- 429 ๋๋ 403 ์ ์ฆ์ ์ค๋จ
- Exponential backoff: 3์ด โ 6์ด โ 12์ด
์๋ฌ ํธ๋ค๋ง
agent-browser close
agent-browser eval "document.body.innerHTML.length"
agent-browser screenshot --annotate error.png
Cross-Source Deduplication (EXP-045, EXP-067)
Same job posted on Wanted, JobKorea, LinkedIn has different URLs. Fuzzy matching detects duplicates:
Algorithm
- Company match: Normalize both company names (strip
(์ฃผ), ใ, ์ฃผ์ํ์ฌ, case-insensitive). Match exactly, substring, or via KoreanโEnglish company equivalents (EXP-067).
- Title similarity: Normalize titles, compute token-based Jaccard with KoreanโEnglish equivalents:
- ํ๋ก ํธ์๋โfrontend, ๋ฐฑ์๋โbackend, ํ์คํโfullstack, ๊ฐ๋ฐ์โdeveloper, ์์ง๋์ดโengineer, ๋ฐ์ดํฐโdata, ๋ฐ๋ธ์ต์คโdevops, ์๋ฒ ๋๋โembedded, ๋ณด์โsecurity, ํด๋ผ์ฐ๋โcloud, etc.
- Threshold: Same company + title similarity โฅ 0.6 โ duplicate
KoreanโEnglish Company Equivalents (EXP-067)
LinkedIn uses English company names (Kakao, Naver, LINE) while Wanted/JobKorea use Korean (์นด์นด์ค, ๋ค์ด๋ฒ, ๋ผ์ธ). Map bridges this gap:
const companyKoEnMap = {
'์นด์นด์ค': 'kakao', '๋ค์ด๋ฒ': 'naver', '๋ผ์ธ': 'line', 'ํ ์ค': 'toss',
'๋น๊ทผ๋ง์ผ': 'danggeun', '๋ฐฐ๋ฌ์๋ฏผ์กฑ': 'baemin', '์ฐ์ํํ์ ๋ค': 'woowa',
'์ผ์ฑ': 'samsung', '์ฟ ํก': 'coupang', 'ํ๋': 'hyundai', '์์ง': 'lg',
'์นด์นด์ค๋ฑ
ํฌ': 'kakaobank', 'ํ ์ค๋ฑ
ํฌ': 'tossbank', '๋ง์ผ์ปฌ๋ฆฌ': 'kurly',
};
When merging duplicates, keep the entry with:
- Most complete fields (prefer the one with salary, deadline, culture_keywords)
- If tied: prefer Wanted (usually richer data)
const koEnMap = {
'ํ๋ก ํธ์๋': 'frontend', '๋ฐฑ์๋': 'backend', 'ํ์คํ': 'fullstack',
'๊ฐ๋ฐ์': 'developer', '์์ง๋์ด': 'engineer', '๋ฐ์ดํฐ': 'data',
'๋ถ์๊ฐ': 'analyst', '๋์์ด๋': 'designer', '๋งค๋์ ': 'manager',
'๋ฐ๋ธ์ต์ค': 'devops', '๋ชจ๋ฐ์ผ': 'mobile', '์ธํ๋ผ': 'infrastructure',
'์๋ฒ ๋๋': 'embedded', '์๋์ด': 'senior', '์ฃผ๋์ด': 'junior',
'ํ๋ซํผ': 'platform', '์๋ฃจ์
': 'solution', '์๋ฒ': 'server',
'์ํ๋ฆฌํฐ': 'security', '๋ณด์': 'security', 'ํด๋ผ์ฐ๋': 'cloud',
};
SQL: Mark duplicates after scraping
SELECT a.id, a.source, a.title, a.company, b.id as dup_id, b.source as dup_source
FROM jobs a JOIN jobs b ON a.id < b.id
WHERE replace(replace(replace(lower(a.company),'(์ฃผ)',''),'ใ',''),'์ฃผ์ํ์ฌ','')
= replace(replace(replace(lower(b.company),'(์ฃผ)',''),'ใ',''),'์ฃผ์ํ์ฌ','');
CLI: Run dedup script (EXP-054, EXP-111: skill/culture merge)
node scripts/dedup-jobs.js --dry-run
node scripts/dedup-jobs.js
node scripts/dedup-jobs.js --json
Detail-Page Skill Extraction (EXP-059)
์์ธ ํ์ด์ง ๋ณธ๋ฌธ์์ ๊ธฐ์ ์คํ์ ์๋ ์ถ์ถ. Listing์ title-based inference (EXP-052)์ไบ่กฅ์ ์ผ๋ก ๋์.
When to use
- ์์ธ ํ์ด์ง ์ด์์ ๋ (
.job-description, ๋ณธ๋ฌธ ์ ์ฒด)
- Listing์์ skills ํ๋๊ฐ ๋น์ด์์ ๋
Extraction
const detailSkills = extractSkillsFromDetail(pageText);
Integration with Matching
Detail-extracted skills supplement job.skills field. Priority:
- Explicit skills from API/scraped skill tags
- Detail-page extracted skills (EXP-059)
- Title-inferred skills (EXP-052)
Salary Normalization (EXP-060)
Salary fields are stored as raw strings. Normalize to annual ๋ง์ for comparison and NLP filtering.
Normalization Logic (JavaScript)
function normalizeSalary(raw) {
if (!raw || typeof raw !== 'string') return null;
const text = raw.trim();
if (/๋ฉด์ ํ๊ฒฐ์ |ํ์ฌ๋ด๊ท|ํ์/.test(text)) return null;
let min = null, max = null, isMonthly = /์๊ธ|์\s*๊ธ|๊ฐ์/.test(text);
const rangeMatch = text.match(/(\d[\d,]*)\s*[~\-]\s*(\d[\d,]*)\s*๋ง?\s*์/);
if (rangeMatch) {
min = parseInt(rangeMatch[1].replace(/,/g, ''));
max = parseInt(rangeMatch[2].replace(/,/g, ''));
} else {
const singleMatch = text.match(/(\d[\d,]*)\s*๋ง?\s*์/);
if (singleMatch) {
const val = parseInt(singleMatch[1].replace(/,/g, ''));
min = val;
max = /์ด์|โ/.test(text) ? val : val;
}
}
if (min === null) {
const eokRange = text.match(/(\d+(?:\.\d+)?)\s*[~\-]\s*(\d+(?:\.\d+)?)\s*์ต/);
if (eokRange) {
min = Math.round(parseFloat(eokRange[1]) * 10000);
max = Math.round(parseFloat(eokRange[2]) * 10000);
} else {
const eok = text.match(/(\d+(?:\.\d+)?)\s*์ต/);
if (eok) { min = max = Math.round(parseFloat(eok[1]) * 10000); }
}
}
if (min === null) return null;
if (isMonthly) { min *= 12; max *= 12; }
return { min, max: max || min, annual: true };
}
NLP Salary Filter Usage
- "์ฐ๋ด 6000 ์ด์" โ
normalizeSalary(salary).min >= 6000
- "์๊ธ 400 ์ด์" โ
normalizeSalary(salary).min >= 4800 (monthlyโannual auto-convert)
- Negotiable salaries (๋ฉด์ ํ๊ฒฐ์ ) return
null โ excluded from threshold checks, pass range filters
Salary Pipeline Integration (EXP-068)
post-process-wanted.js now auto-populates salary_min/salary_max from parsed salary text:
normalizeSalary() is called on r.salary after extraction
salary_min/salary_max are numeric (๋ง์, annual) โ ready for DB INSERT and NLP queries
- ์ต patterns captured by expanded salary regex:
(์ฐ๋ด|์๊ธ|์ฐ์์
)[\s]*(...|์ต patterns)
- Exported:
const { normalizeSalary } = require('./scripts/post-process-wanted')
Skills Persistence (EXP-077)
All post-processors output a skills field (comma-separated). This must be persisted to the DB skills column:
- Wanted:
inferSkillsFromTitle() from title + detail-page extraction
- JobKorea: Detail-page extraction only
- LinkedIn:
inferSkillsFromText() from title+description + detail-page extraction
- INSERT: Include
skills in column list and VALUES
- UPDATE after detail extraction:
UPDATE jobs SET skills = '...' WHERE id = '...'
๋๋ฒ๊น
agent-browser console
agent-browser errors
agent-browser screenshot
LinkedIn Post-Processor (EXP-070)
scripts/post-process-linkedin.js enriches raw LinkedIn card data with:
Features
- Experience level extraction: senior/lead/principal โ senior (5+yr), mid-senior/์ค๊ธ โ mid (3yr), junior/์ ์
/entry-level โ junior, intern โ intern. Korean N๋
์ฐจ also detected.
- Skill inference: 50+ tech patterns (React, Python, Spring Boot, Kubernetes, etc.) from title + description. Normalizes k8sโkubernetes, golangโgo.
- Salary extraction: ์ฐ๋ด/์๊ธ/์ต patterns via shared
normalizeSalary(). ๋ฉด์ ํ๊ฒฐ์ detected.
- Work type detection: remote/hybrid/onsite from Korean and English keywords.
- Location normalization: KoreanโEnglish city mapping, ๋ํ๋ฏผ๊ตญ stripping.
Usage
const { parseLinkedInCard } = require('./scripts/post-process-linkedin');
const job = parseLinkedInCard({ title: 'Senior Backend Engineer (Python)', company: 'Naver', location: 'Pangyo, Gyeonggi-do', link: '...', description: 'Django, Kubernetes. ์ฐ๋ด 6000~9000๋ง์' });
Employment Type Extraction (EXP-085)
All three post-processors (Wanted, JobKorea, LinkedIn) now extract employment_type:
regular โ ์ ๊ท์ง (default when no keyword found)
contract โ ๊ณ์ฝ์ง, ํ๊ฒฌ, ์์ด, contract position
intern โ ์ธํด, ์ธํด์ญ, intern
freelance โ ํ๋ฆฌ๋์, freelance
The field is used in matching (10% location/work component) to penalize contract/intern jobs when the candidate prefers regular employment.