The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "youth-center-seoul",
"name": "YouthFit - \uc628\ud1b5\uccad\ub144 \uc11c\uc6b8 \uccad\ub144 \uc815\ucc45 \uc218\uc9d1",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 4 * * *"
}
]
}
},
"id": "schedule-trigger",
"name": "\ub9e4\uc77c \uc0c8\ubcbd 4\uc2dc \uc2e4\ud589",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "youth-center-manual",
"responseMode": "lastNode",
"options": {}
},
"id": "manual-webhook-trigger",
"name": "\uc218\ub3d9 \uc2e4\ud589 \ud2b8\ub9ac\uac70",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
200
]
},
{
"parameters": {
"jsCode": "return [{ json: { pageNum: 1 } }];"
},
"id": "init-page",
"name": "\ud398\uc774\uc9c0 \ucd08\uae30\ud654",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
]
},
{
"parameters": {
"url": "https://www.youthcenter.go.kr/go/ythip/getPlcy",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "apiKeyNm",
"value": "={{ $env.YOUTH_CENTER_API_KEY }}"
},
{
"name": "rtnType",
"value": "json"
},
{
"name": "pageNum",
"value": "={{ $json.pageNum }}"
},
{
"name": "pageSize",
"value": "1000"
}
]
},
"options": {
"response": {
"response": {
"fullResponse": false,
"responseFormat": "json"
}
},
"timeout": 30000
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "YouthFit-Bot/1.0 (+https://youthfit.kr/bot)"
},
{
"name": "Accept",
"value": "application/json"
}
]
}
},
"id": "fetch-list",
"name": "getPlcy \ud638\ucd9c",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
0
]
},
{
"parameters": {
"jsCode": "const SEOUL_CODES = new Set(['11000','11110','11140','11170','11200','11215','11230','11260','11290','11305','11320','11350','11380','11410','11440','11470','11500','11530','11545','11560','11590','11620','11650','11680','11710','11740']);\n\nconst raw = $input.first().json;\nif (!raw || raw.resultCode !== 200) {\n throw new Error(`\uc628\ud1b5\uccad\ub144 API error: ${raw && raw.resultCode} ${raw && raw.resultMessage}`);\n}\nconst result = raw.result || {};\nconst pagging = result.pagging || {};\nconst totCount = pagging.totCount || 0;\nconst pageNum = pagging.pageNum || $('\ud398\uc774\uc9c0 \ucd08\uae30\ud654').first()?.json?.pageNum || $('\ub2e4\uc74c \ud398\uc774\uc9c0 \uc774\ub3d9').first()?.json?.pageNum || 1;\nconst pageSize = pagging.pageSize || 100;\nconst lastPage = Math.max(1, Math.ceil(totCount / pageSize));\n\nconst list = result.youthPolicyList || [];\nconst seoulItems = [];\nfor (const it of list) {\n const codes = (it.zipCd || '').split(',').map(s => s.trim()).filter(Boolean);\n if (codes.some(c => SEOUL_CODES.has(c))) {\n seoulItems.push({ json: { ...it, _pageNum: pageNum, _lastPage: lastPage, _totCount: totCount, _seoulCodes: codes.filter(c => SEOUL_CODES.has(c)) } });\n }\n}\n\nif (seoulItems.length === 0) {\n return [{ json: { _empty: true, _pageNum: pageNum, _lastPage: lastPage, _totCount: totCount } }];\n}\nreturn seoulItems;"
},
"id": "parse-and-filter",
"name": "JSON \ud30c\uc2f1 + \uc11c\uc6b8 \ud544\ud130",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
0
]
},
{
"parameters": {
"batchSize": 1,
"options": {}
},
"id": "loop-policies",
"name": "\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
880,
0
]
},
{
"parameters": {
"amount": 1,
"unit": "seconds"
},
"id": "rate-limit-wait",
"name": "1\ucd08 \ub300\uae30",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
1100,
100
]
},
{
"parameters": {
"jsCode": "// === \ucf54\ub4dc \uc0ac\uc804 (data.go.kr \uacf5\uc2dd \uc5d1\uc140 \ucd9c\ucc98) \u2014 body \ud569\uc131 \uc804\uc6a9 ===\nconst CODE = {\n mrgSttsCd: { '0055001':'\uae30\ud63c', '0055002':'\ubbf8\ud63c', '0055003':'\uc81c\ud55c\uc5c6\uc74c' },\n earnCndSeCd: { '0043001':'\ubb34\uad00', '0043002':'\uc5f0\uc18c\ub4dd', '0043003':'\uae30\ud0c0' },\n jobCd: { '0013001':'\uc7ac\uc9c1\uc790','0013002':'\uc790\uc601\uc5c5\uc790','0013003':'\ubbf8\ucde8\uc5c5\uc790','0013004':'\ud504\ub9ac\ub79c\uc11c','0013005':'\uc77c\uc6a9\uadfc\ub85c\uc790','0013006':'(\uc608\ube44)\ucc3d\uc5c5\uc790','0013007':'\ub2e8\uae30\uadfc\ub85c\uc790','0013008':'\uc601\ub18d\uc885\uc0ac\uc790','0013009':'\uae30\ud0c0','0013010':'\uc81c\ud55c\uc5c6\uc74c' },\n schoolCd: { '0049001':'\uace0\uc878 \ubbf8\ub9cc','0049002':'\uace0\uad50 \uc7ac\ud559','0049003':'\uace0\uc878 \uc608\uc815','0049004':'\uace0\uad50 \uc878\uc5c5','0049005':'\ub300\ud559 \uc7ac\ud559','0049006':'\ub300\uc878 \uc608\uc815','0049007':'\ub300\ud559 \uc878\uc5c5','0049008':'\uc11d\u00b7\ubc15\uc0ac','0049009':'\uae30\ud0c0','0049010':'\uc81c\ud55c\uc5c6\uc74c' },\n plcyMajorCd: { '0011001':'\uc778\ubb38\uacc4\uc5f4','0011002':'\uc0ac\ud68c\uacc4\uc5f4','0011003':'\uc0c1\uacbd\uacc4\uc5f4','0011004':'\uc774\ud559\uacc4\uc5f4','0011005':'\uacf5\ud559\uacc4\uc5f4','0011006':'\uc608\uccb4\ub2a5\uacc4\uc5f4','0011007':'\ub18d\uc0b0\uc5c5\uacc4\uc5f4','0011008':'\uae30\ud0c0','0011009':'\uc81c\ud55c\uc5c6\uc74c' },\n sbizCd: { '0014001':'\uc911\uc18c\uae30\uc5c5','0014002':'\uc5ec\uc131','0014003':'\uae30\ucd08\uc0dd\ud65c\uc218\uae09\uc790','0014004':'\ud55c\ubd80\ubaa8\uac00\uc815','0014005':'\uc7a5\uc560\uc778','0014006':'\ub18d\uc5c5\uc778','0014007':'\uad70\uc778','0014008':'\uc9c0\uc5ed\uc778\uc7ac','0014009':'\uae30\ud0c0','0014010':'\uc81c\ud55c\uc5c6\uc74c' },\n plcyPvsnMthdCd: { '0042001':'\uc778\ud504\ub77c \uad6c\ucd95','0042002':'\ud504\ub85c\uadf8\ub7a8','0042003':'\uc9c1\uc811\ub300\ucd9c','0042004':'\uacf5\uacf5\uae30\uad00','0042005':'\uacc4\uc57d(\uc704\ud0c1\uc6b4\uc601)','0042006':'\ubcf4\uc870\uae08','0042007':'\ub300\ucd9c\ubcf4\uc99d','0042008':'\uacf5\uc801\ubcf4\ud5d8','0042009':'\uc870\uc138\uc9c0\ucd9c','0042010':'\ubc14\uc6b0\ucc98','0042011':'\uc815\ubcf4\uc81c\uacf5','0042012':'\uacbd\uc81c\uc801 \uaddc\uc81c','0042013':'\uae30\ud0c0' },\n bizPrdSeCd: { '0056001':'\ud2b9\uc815\uae30\uac04','0056002':'\uae30\ud0c0' },\n aplyPrdSeCd: { '0057001':'\ud2b9\uc815\uae30\uac04','0057002':'\uc0c1\uc2dc','0057003':'\ub9c8\uac10' }\n};\n\nconst GU = { '11110':'\uc885\ub85c\uad6c','11140':'\uc911\uad6c','11170':'\uc6a9\uc0b0\uad6c','11200':'\uc131\ub3d9\uad6c','11215':'\uad11\uc9c4\uad6c','11230':'\ub3d9\ub300\ubb38\uad6c','11260':'\uc911\ub791\uad6c','11290':'\uc131\ubd81\uad6c','11305':'\uac15\ubd81\uad6c','11320':'\ub3c4\ubd09\uad6c','11350':'\ub178\uc6d0\uad6c','11380':'\uc740\ud3c9\uad6c','11410':'\uc11c\ub300\ubb38\uad6c','11440':'\ub9c8\ud3ec\uad6c','11470':'\uc591\ucc9c\uad6c','11500':'\uac15\uc11c\uad6c','11530':'\uad6c\ub85c\uad6c','11545':'\uae08\ucc9c\uad6c','11560':'\uc601\ub4f1\ud3ec\uad6c','11590':'\ub3d9\uc791\uad6c','11620':'\uad00\uc545\uad6c','11650':'\uc11c\ucd08\uad6c','11680':'\uac15\ub0a8\uad6c','11710':'\uc1a1\ud30c\uad6c','11740':'\uac15\ub3d9\uad6c' };\nconst SEOUL_CODES = new Set(Object.keys(GU).concat(['11000']));\n\nfunction clean(s) {\n if (s == null) return '';\n return String(s)\n .replace(/\u1b3c/g, '\u00b7')\n .replace(/\\n[ \\t]+/g, ' ')\n .replace(/[ \\t\\u00A0]+/g, ' ')\n .replace(/\\n\\s*\\n+/g, '\\n')\n .trim();\n}\nfunction inline(s) {\n return clean(s).replace(/\\s+/g, ' ').trim();\n}\nfunction splitTokens(s, sep) {\n if (!s) return [];\n return inline(s).split(sep || /[,\u30fb\uff65\u00b7]/).map(x => x.trim()).filter(Boolean);\n}\nfunction parseIntOrNull(s) {\n if (s == null || String(s).trim() === '') return null;\n const n = parseInt(String(s).trim(), 10);\n if (isNaN(n) || n === 0) return null;\n return n;\n}\nfunction parseYmd(s) {\n if (!s) return null;\n const c = String(s).trim();\n if (c.length !== 8) return null;\n return `${c.slice(0,4)}-${c.slice(4,6)}-${c.slice(6,8)}`;\n}\n\nfunction mapCategory(lclsf) {\n const tokens = splitTokens(lclsf);\n const order = [\n [['\uc77c\uc790\ub9ac'], '\uc77c\uc790\ub9ac'],\n [['\uc8fc\uac70'], '\uc8fc\uac70'],\n [['\uad50\uc721','\uc9c1\uc5c5\ud6c8\ub828'], '\uad50\uc721'],\n [['\uae08\uc735'], '\uae08\uc735'],\n [['\ubb38\ud654','\uc5ec\uac00'], '\ubb38\ud654'],\n [['\ubcf5\uc9c0','\ubcf5\uc9c0\ubb38\ud654'], '\ubcf5\uc9c0'],\n [['\ucc38\uc5ec','\uad8c\ub9ac','\ucc38\uc5ec\uad8c\ub9ac'], '\ucc38\uc5ec']\n ];\n for (const [keys, label] of order) {\n for (const t of tokens) {\n if (keys.some(k => t.includes(k))) return label;\n }\n }\n return '\ubcf5\uc9c0';\n}\n\nfunction parseApplyPeriod(aplyYmd) {\n if (!aplyYmd) return { start: null, end: null };\n const m = String(aplyYmd).match(/(\\d{8})\\s*~\\s*(\\d{8})/);\n if (!m) return { start: null, end: null };\n function fmt(s) { return `${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}`; }\n return { start: fmt(m[1]), end: fmt(m[2]) };\n}\n\nfunction regionLabel(zipCd) {\n const codes = splitTokens(zipCd, ',');\n const distinctSido = new Set(codes.map(c => c.slice(0, 2)).filter(Boolean)).size;\n if (distinctSido >= 10 || codes.length >= 100) return '\uc804\uad6d';\n const seoul = codes.filter(c => SEOUL_CODES.has(c));\n const guCount = seoul.filter(c => c !== '11000').length;\n if (guCount >= 25) return '\uc11c\uc6b8\ud2b9\ubcc4\uc2dc';\n if (guCount === 1) {\n const guCode = seoul.find(c => c !== '11000');\n return `\uc11c\uc6b8\ud2b9\ubcc4\uc2dc ${GU[guCode] || ''}`.trim();\n }\n return '\uc11c\uc6b8\ud2b9\ubcc4\uc2dc';\n}\n\nfunction buildBody(p) {\n const lines = [];\n if (clean(p.plcyExplnCn)) lines.push('[\uac1c\uc694]', clean(p.plcyExplnCn), '');\n\n const tgt = [];\n const minA = parseInt(p.sprtTrgtMinAge || '0', 10);\n const maxA = parseInt(p.sprtTrgtMaxAge || '0', 10);\n if (p.sprtTrgtAgeLmtYn === 'N') tgt.push('- \uc5f0\ub839: \uc81c\ud55c\uc5c6\uc74c');\n else if (minA || maxA) tgt.push(`- \uc5f0\ub839: ${minA}~${maxA}\uc138`);\n if (CODE.mrgSttsCd[p.mrgSttsCd]) tgt.push(`- \uacb0\ud63c\uc0c1\ud0dc: ${CODE.mrgSttsCd[p.mrgSttsCd]}`);\n if (CODE.earnCndSeCd[p.earnCndSeCd]) {\n let earn = `- \uc18c\ub4dd\uc870\uac74: ${CODE.earnCndSeCd[p.earnCndSeCd]}`;\n const eMin = parseInt(p.earnMinAmt || '0', 10);\n const eMax = parseInt(p.earnMaxAmt || '0', 10);\n if (eMin || eMax) earn += ` (${eMin.toLocaleString()}~${eMax.toLocaleString()}\uc6d0)`;\n if (clean(p.earnEtcCn) && clean(p.earnEtcCn) !== '-') earn += ` ${clean(p.earnEtcCn)}`;\n tgt.push(earn);\n }\n if (CODE.jobCd[p.jobCd]) tgt.push(`- \ucde8\uc5c5\uc0c1\ud0dc: ${CODE.jobCd[p.jobCd]}`);\n if (CODE.schoolCd[p.schoolCd]) tgt.push(`- \ud559\ub825: ${CODE.schoolCd[p.schoolCd]}`);\n if (CODE.plcyMajorCd[p.plcyMajorCd]) tgt.push(`- \uc804\uacf5: ${CODE.plcyMajorCd[p.plcyMajorCd]}`);\n if (CODE.sbizCd[p.sbizCd]) tgt.push(`- \ud2b9\ud654\uc694\uac74: ${CODE.sbizCd[p.sbizCd]}`);\n if (clean(p.ptcpPrpTrgtCn)) tgt.push(`- \ucc38\uc5ec \uc81c\ud55c \ub300\uc0c1: ${clean(p.ptcpPrpTrgtCn)}`);\n if (tgt.length) { lines.push('[\uc9c0\uc6d0\ub300\uc0c1]'); lines.push(...tgt); lines.push(''); }\n\n const sel = [];\n if (clean(p.srngMthdCn)) sel.push(clean(p.srngMthdCn));\n if (clean(p.addAplyQlfcCndCn) && clean(p.addAplyQlfcCndCn) !== '\ud574\ub2f9\uc0ac\ud56d \uc5c6\uc74c') sel.push(`\ucd94\uac00 \uc790\uaca9: ${clean(p.addAplyQlfcCndCn)}`);\n if (sel.length) { lines.push('[\uc120\uc815\uae30\uc900]'); lines.push(...sel); lines.push(''); }\n else { lines.push('[\uc120\uc815\uae30\uc900]', '\ubcc4\ub3c4 \ubb38\uc758', ''); }\n\n const sup = [];\n if (clean(p.plcySprtCn)) sup.push(clean(p.plcySprtCn));\n if (CODE.plcyPvsnMthdCd[p.plcyPvsnMthdCd]) sup.push(`\uc81c\uacf5\ubc29\uc2dd: ${CODE.plcyPvsnMthdCd[p.plcyPvsnMthdCd]}`);\n const sclCnt = parseInt(p.sprtSclCnt || '0', 10);\n if (sclCnt) {\n const arvl = p.sprtArvlSeqYn === 'Y' ? ' (\uc120\ucc29\uc21c)' : '';\n sup.push(`\uc9c0\uc6d0\uaddc\ubaa8: ${sclCnt.toLocaleString()}\uba85${arvl}`);\n }\n if (sup.length) { lines.push('[\uc9c0\uc6d0\ub0b4\uc6a9]'); lines.push(...sup); lines.push(''); }\n\n if (clean(p.sbmsnDcmntCn)) lines.push('[\uc81c\ucd9c\uc11c\ub958]', clean(p.sbmsnDcmntCn), '');\n\n const bizStart = clean(p.bizPrdBgngYmd);\n const bizEnd = clean(p.bizPrdEndYmd);\n if (bizStart && bizEnd) {\n function fmt(s) { return s.length === 8 ? `${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}` : s; }\n let line = `${fmt(bizStart)} ~ ${fmt(bizEnd)}`;\n if (CODE.bizPrdSeCd[p.bizPrdSeCd]) line += ` (${CODE.bizPrdSeCd[p.bizPrdSeCd]})`;\n lines.push('[\uc0ac\uc5c5\uae30\uac04]', line);\n if (clean(p.bizPrdEtcCn)) lines.push(clean(p.bizPrdEtcCn));\n lines.push('');\n }\n\n if (clean(p.etcMttrCn)) lines.push('[\uae30\ud0c0]', clean(p.etcMttrCn));\n\n return lines.join('\\n').trim();\n}\n\nconst p = $input.first().json;\nif (p._empty) return [{ json: { _empty: true } }];\n\nconst { start, end } = parseApplyPeriod(p.aplyYmd);\nconst category = mapCategory(p.lclsfNm);\nconst region = regionLabel(p.zipCd);\n\nconst themeTags = Array.from(new Set([\n ...splitTokens(p.lclsfNm),\n ...splitTokens(p.mclsfNm),\n ...splitTokens(p.plcyKywdNm)\n].map(clean).filter(Boolean)));\n\nconst targetTags = [];\nconst sbiz = CODE.sbizCd[p.sbizCd];\nif (sbiz && sbiz !== '\uc81c\ud55c\uc5c6\uc74c' && sbiz !== '\uae30\ud0c0') targetTags.push(sbiz);\n\nconst orgParts = [];\nif (clean(p.sprvsnInstCdNm)) orgParts.push(clean(p.sprvsnInstCdNm));\nif (clean(p.operInstCdNm) && clean(p.operInstCdNm) !== clean(p.sprvsnInstCdNm)) orgParts.push(clean(p.operInstCdNm));\nlet organization = orgParts.join(' / ');\nif (organization.length > 200) organization = organization.slice(0, 200);\n\nconst picName = clean(p.sprvsnInstPicNm);\nconst enrPhone = ($json._enrichment && $json._enrichment.sections && $json._enrichment.sections.contactPhone) || '';\nlet contact;\nif (picName && enrPhone) contact = `\ub2f4\ub2f9: ${picName}\\n\uc804\ud654: ${enrPhone}`;\nelse if (picName) contact = `\ub2f4\ub2f9: ${picName}`;\nelse if (enrPhone) contact = `\uc804\ud654: ${enrPhone}`;\nelse contact = '';\n\nconst referenceSites = [];\nif (clean(p.refUrlAddr1)) referenceSites.push({ name: '\ucc38\uace0 \uc0ac\uc774\ud2b8', url: clean(p.refUrlAddr1) });\nif (clean(p.refUrlAddr2)) referenceSites.push({ name: '\ucc38\uace0 \uc0ac\uc774\ud2b8', url: clean(p.refUrlAddr2) });\n\nconst applyMethods = [];\nif (clean(p.plcyAplyMthdCn)) applyMethods.push({ stageName: '\uc2e0\uccad \uc808\ucc28', description: clean(p.plcyAplyMthdCn) });\n\nconst referenceYear = (p.frstRegDt && p.frstRegDt.length >= 4) ? parseInt(p.frstRegDt.slice(0, 4), 10) : null;\n\nconst result = {\n source: {\n url: `https://www.youthcenter.go.kr/youngPlcyUnif/youngPlcyUnifDtl.do?plcyNo=${p.plcyNo}`,\n type: 'YOUTH_CENTER',\n fetchedAt: new Date().toISOString().replace('Z', '')\n },\n rawData: {\n externalId: p.plcyNo,\n title: clean(p.plcyNm) || `(\uc815\ucc45 ${p.plcyNo})`,\n summary: clean(p.plcyExplnCn) || clean(p.plcyNm),\n body: buildBody(p),\n category,\n region,\n applyStart: start,\n applyEnd: end,\n referenceYear,\n supportCycle: null,\n provideType: CODE.plcyPvsnMthdCd[p.plcyPvsnMthdCd] || null,\n organization,\n contact,\n lifeTags: ['\uccad\ub144'],\n themeTags,\n targetTags,\n attachments: [],\n referenceSites,\n applyMethods,\n screeningMethod: clean(p.srngMthdCn) || null,\n submissionDocuments: clean(p.sbmsnDcmntCn) || null,\n additionalQualification: clean(p.addAplyQlfcCndCn) || null,\n participationRestriction: clean(p.ptcpPrpTrgtCn) || null,\n additionalNotes: clean(p.etcMttrCn) || null,\n businessPeriodStart: parseYmd(p.bizPrdBgngYmd),\n businessPeriodEnd: parseYmd(p.bizPrdEndYmd),\n businessPeriodNote: clean(p.bizPrdEtcCn) || null,\n supportScale: parseIntOrNull(p.sprtSclCnt),\n firstComeFirstServed: p.sprtArvlSeqYn === 'Y',\n applyUrl: clean(p.aplyUrlAddr) || null,\n rawCodes: {\n ageMin: parseIntOrNull(p.sprtTrgtMinAge),\n ageMax: parseIntOrNull(p.sprtTrgtMaxAge),\n ageLimitYn: p.sprtTrgtAgeLmtYn || null,\n maritalStatusCd: p.mrgSttsCd || null,\n earnConditionCd: p.earnCndSeCd || null,\n earnMin: parseIntOrNull(p.earnMinAmt),\n earnMax: parseIntOrNull(p.earnMaxAmt),\n earnEtcCn: clean(p.earnEtcCn) || null,\n employmentKindCd: p.jobCd || null,\n educationCd: p.schoolCd || null,\n majorFieldCd: p.plcyMajorCd || null,\n specializationCd: p.sbizCd || null,\n zipCodes: splitTokens(p.zipCd, ',')\n },\n sourceHash: $json._sourceHash,\n enrichment: $json._enrichment || null\n }\n};\nreturn [{ json: result }];\n"
},
"id": "transform",
"name": "\uc815\ucc45 \u2192 IngestPolicyRequest \ubcc0\ud658",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
100
]
},
{
"parameters": {
"jsCode": "// \ub3d9\uae30\ud654 \ucc45\uc784: n8n/workflows/__fixtures__/promote-attachments/promote.mjs \uc640\n// \ub3d9\uc77c \uc54c\uace0\ub9ac\uc998\uc774\uc5b4\uc57c \ud55c\ub2e4. \ud55c \uacf3\uc744 \uc218\uc815\ud558\uba74 \ub2e4\ub978 \uacf3\ub3c4 \uac19\uc740 \ubcc0\uacbd\uc744 \ubc18\uc601\ud574\uc57c \ud55c\ub2e4.\nconst EXT_TO_MEDIA_TYPE = {\n pdf: 'application/pdf',\n hwp: 'application/x-hwp',\n hwpx: 'application/x-hwp',\n doc: 'application/msword',\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n xls: 'application/vnd.ms-excel',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n};\n\nconst TEXT_EXT_PATTERN = /\\.(pdf|hwpx?|docx?|xlsx?)\\b/i;\nconst PAREN_EXT_PATTERN = /[\\(\\[]\\s*(pdf|hwpx?|docx?|xlsx?)\\s*[\\)\\]]/i;\nconst PATH_EXT_PATTERN = /(?:^|[^a-zA-Z])(pdf|hwpx?|docx?|xlsx?)(?:$|[^a-zA-Z])/i;\n// path-pattern fallback \uc740 \ub2e4\uc6b4\ub85c\ub4dc \uc758\ub3c4 \ud0a4\uc6cc\ub4dc\uac00 \ub3d9\ubc18\ub420 \ub54c\ub9cc \ud65c\uc131\ud654\ud55c\ub2e4.\nconst PATH_DOWNLOAD_KEYWORD_PATTERN = /(?:download|filedown|attach|getfile|board)/i;\n\nfunction extractExt(url) {\n if (typeof url !== 'string') return null;\n const cleaned = url.split('#')[0].split('?')[0].toLowerCase();\n const dotIdx = cleaned.lastIndexOf('.');\n if (dotIdx === -1) return null;\n return cleaned.slice(dotIdx + 1);\n}\n\nfunction mapExt(ext) {\n if (!ext) return null;\n return EXT_TO_MEDIA_TYPE[ext.toLowerCase()] || null;\n}\n\nfunction extractExtFromText(text) {\n if (typeof text !== 'string' || text.length === 0) return null;\n const m1 = text.match(TEXT_EXT_PATTERN);\n if (m1) return m1[1].toLowerCase();\n const m2 = text.match(PAREN_EXT_PATTERN);\n if (m2) return m2[1].toLowerCase();\n return null;\n}\n\nfunction extractExtFromPath(url) {\n if (typeof url !== 'string') return null;\n const path = url.split('?')[0].split('#')[0]\n .replace(/([a-z])([A-Z])/g, '$1_$2')\n .toLowerCase();\n if (!PATH_DOWNLOAD_KEYWORD_PATTERN.test(path)) return null;\n const m = path.match(PATH_EXT_PATTERN);\n return m ? m[1].toLowerCase() : null;\n}\n\nfunction inferMediaType(item) {\n const fromUrl = mapExt(extractExt(item.url));\n if (fromUrl) return fromUrl;\n const fromName = mapExt(extractExtFromText(item.name));\n if (fromName) return fromName;\n const fromPath = mapExt(extractExtFromPath(item.url));\n if (fromPath) return fromPath;\n return null;\n}\n\nconst NAME_WHITELIST_PATTERN = /(\uacf5\uace0|\uc548\ub0b4|\ubaa8\uc9d1|\uc694\uac15|\uc2e0\uccad\uc11c|\uacc4\ud68d\uc11c|FAQ|Q&A|\uac00\uc774\ub4dc|\uc124\uba85|\uc77c\uc815|\ucc38\uac00|\uc6b4\uc601|\ud3c9\uac00|\uc120\uc815|\ucc44\uc6a9|\uc9c0\uc6d0|\ubcf4\uace0\uc11c|\uc591\uc2dd|\uc11c\uc2dd|\uc790\ub8cc|\ub2e4\uc6b4\ub85c\ub4dc|\ubd99\uc784|\ubcc4\ucca8|\uacb0\uacfc|\uba85\ub2e8|\ubaa9\ub85d)/i;\nconst NAME_BLACKLIST_PATTERN = /(\ub85c\uace0|\ubc30\ub108|\uc544\uc774\ucf58|\uc378\ub124\uc77c|\ud3ec\uc2a4\ud130|\uad11\uace0|favicon)/i;\nfunction isInformationalName(name) {\n if (typeof name !== 'string' || !name) return true;\n if (NAME_BLACKLIST_PATTERN.test(name)) return false;\n if (NAME_WHITELIST_PATTERN.test(name)) return true;\n return name.length >= 5;\n}\n\nconst input = $input.first().json;\nconst enrichment = input?.rawData?.enrichment;\nconst extras = enrichment?.extraAttachments;\nif (!Array.isArray(extras) || extras.length === 0) {\n return [{ json: input }];\n}\nconst attachments = Array.isArray(input.rawData.attachments) ? input.rawData.attachments : [];\nconst existingUrls = new Set(\n attachments\n .map(a => (typeof a.url === 'string' ? a.url.toLowerCase() : null))\n .filter(Boolean)\n);\nconst merged = [...attachments];\nfor (const ex of extras) {\n if (!ex || typeof ex.url !== 'string') continue;\n const mediaType = inferMediaType(ex);\n if (!mediaType) continue;\n if (!isInformationalName(ex.name)) continue;\n const key = ex.url.toLowerCase();\n if (existingUrls.has(key)) continue;\n merged.push({ name: ex.name, url: ex.url, mediaType });\n existingUrls.add(key);\n}\nreturn [{ json: { ...input, rawData: { ...input.rawData, attachments: merged } } }];"
},
"id": "promote-attachments",
"name": "attachments \uc2b9\uaca9",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
100
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.BACKEND_URL || 'http://backend:8080' }}/api/internal/ingestion/policies",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "X-Internal-Api-Key",
"value": "={{ $env.INTERNAL_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json) }}",
"options": {
"timeout": 30000
}
},
"id": "send-to-backend",
"name": "\ubc31\uc5d4\ub4dc API \uc804\uc1a1",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"onError": "continueRegularOutput",
"position": [
1760,
100
]
},
{
"parameters": {
"jsCode": "const items = $('JSON \ud30c\uc2f1 + \uc11c\uc6b8 \ud544\ud130').all();\nconst first = items[0]?.json || {};\nconst current = first._pageNum || 1;\nconst last = first._lastPage || 1;\nif (current < last) return [{ json: { pageNum: current + 1, hasNext: true } }];\nreturn [{ json: { pageNum: current, hasNext: false } }];"
},
"id": "check-next-page",
"name": "\ub2e4\uc74c \ud398\uc774\uc9c0 \ud655\uc778",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
-200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "has-next",
"leftValue": "={{ $json.hasNext }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-has-next",
"name": "\ub2e4\uc74c \ud398\uc774\uc9c0 \uc874\uc7ac?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1320,
-200
]
},
{
"parameters": {
"jsCode": "return [{ json: { pageNum: $input.first().json.pageNum } }];"
},
"id": "next-page",
"name": "\ub2e4\uc74c \ud398\uc774\uc9c0 \uc774\ub3d9",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
-300
]
},
{
"parameters": {
"jsCode": "const items = $('JSON \ud30c\uc2f1 + \uc11c\uc6b8 \ud544\ud130').all();\nconst first = items[0]?.json || {};\nreturn [{ json: { message: '\uc628\ud1b5\uccad\ub144 \uc218\uc9d1 \uc644\ub8cc', totalPolicies: first._totCount || 0, totalPages: first._lastPage || 0, completedAt: new Date().toISOString() } }];"
},
"id": "complete",
"name": "\uc218\uc9d1 \uc644\ub8cc",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
-100
]
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.BACKEND_URL || 'http://backend:8080' }}/api/internal/ingestion/policies/external-hashes",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "source",
"value": "YOUTH_CENTER"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Internal-Api-Key",
"value": "={{ $env.INTERNAL_API_KEY }}"
}
]
},
"options": {
"response": {
"response": {
"fullResponse": false,
"responseFormat": "json"
}
},
"timeout": 15000
}
},
"id": "fetch-external-hashes",
"name": "\uc678\ubd80 hash \uc870\ud68c",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"position": [
660,
-250
]
},
{
"parameters": {
"jsCode": "const crypto = require('crypto');\n\nconst hashMapItem = $('\uc678\ubd80 hash \uc870\ud68c').first().json;\nconst hashMap = hashMapItem || {};\n\nconst HASH_FIELDS = [\n 'plcyNm','plcyExplnCn','aplyYmd','sprvsnInstCdNm','operInstCdNm',\n 'aplyUrlAddr','refUrlAddr1','refUrlAddr2','zipCd',\n 'mrgSttsCd','jobCd','schoolCd','plcyMajorCd','sbizCd','plcyPvsnMthdCd','bizPrdSeCd',\n 'sprtTrgtMinAge','sprtTrgtMaxAge','earnMinAmt','earnMaxAmt',\n 'sbmsnDcmntCn','etcMttrCn'\n];\n\nfunction computeSourceHash(p) {\n const subset = {};\n for (const k of [...HASH_FIELDS].sort()) {\n subset[k] = p[k] == null ? null : String(p[k]);\n }\n const json = JSON.stringify(subset);\n return crypto.createHash('sha256').update(json, 'utf8').digest('hex');\n}\n\nconst items = $('JSON \ud30c\uc2f1 + \uc11c\uc6b8 \ud544\ud130').all();\nconst out = [];\nfor (const item of items) {\n const p = item.json;\n if (p._empty) {\n out.push({ json: p });\n continue;\n }\n const sourceHash = computeSourceHash(p);\n const existing = hashMap[p.plcyNo];\n let diffStatus;\n if (existing == null) diffStatus = 'NEW';\n else if (existing !== sourceHash) diffStatus = 'CHANGED';\n else diffStatus = 'UNCHANGED';\n out.push({\n json: {\n ...p,\n _sourceHash: sourceHash,\n _diffStatus: diffStatus,\n _enrich: diffStatus === 'NEW' || diffStatus === 'CHANGED'\n }\n });\n}\nreturn out;"
},
"id": "diff-status",
"name": "\ubcc0\ub3d9 \ud310\uc815",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
770,
-250
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "enrich-flag",
"leftValue": "={{ $json._enrich }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-enrich",
"name": "enrichment \uc5ec\ubd80",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1320,
100
]
},
{
"parameters": {
"jsCode": "// \u26a0 \ub2e8\uc77c \uc6d0\ubcf8: \uc774 \ud30c\uc77c\uc774 4\uac1c \uc6cc\ud06c\ud50c\ub85c\uc6b0 \ub178\ub4dc jsCode \uc758 \uc6d0\ubcf8\uc774\ub2e4.\n// - youth-seoul-city/district/external.json :: \"\ucc38\uace0\uc0ac\uc774\ud2b8 fetch + \uba38\uc9c0\"\n// - youth-center-seoul.json :: \"\ub9c1\ud06c fetch + \uba38\uc9c0\"\n// \uc6cc\ud06c\ud50c\ub85c\uc6b0 JSON \uc744 \uc190\uc73c\ub85c \uc218\uc815\ud558\uc9c0 \ub9d0\uace0 sync-link-fetch-merge.mjs \ub97c \uc2e4\ud589\ud55c\ub2e4.\n// \u26a0 \uc54c\uace0\ub9ac\uc998 \ubbf8\ub7ec: __fixtures__/enrichment-merge/enrich.mjs (verify.mjs \ub85c \uac80\uc99d).\n// \uc21c\uc218 \ud568\uc218(selectUrls/prepareUrls/mergeFetchResults/cookie jar/\ucd94\ucd9c)\ub294\n// \ubbf8\ub7ec\uc640 \ub3d9\uc77c \ubcf8\ubb38\uc774\uc5b4\uc57c \ud55c\ub2e4.\nconst cheerio = require('cheerio');\nconst https = require('https');\nconst http = require('http');\nconst tls = require('tls');\n\n// TLS \uc911\uac04 \uc778\uc99d\uc11c \ubcf4\uac15 (#160). n8n 2.16 task runner \ub294 NODE_EXTRA_CA_CERTS \ub97c\n// \uc0c1\uc18d\ud558\uc9c0 \uc54a\uc544 \ucee8\ud14c\uc774\ub108 env \ub85c \uc8fc\uc785\ud55c \ubc88\ub4e4\uc774 Code \ub178\ub4dc fetch \uc5d0 \ub2ff\uc9c0 \uc54a\ub294\ub2e4.\n// \uadf8\ub798\uc11c \ub204\ub77d\ub41c \uc911\uac04 \uc778\uc99d\uc11c(GlobalSign RSA OV SSL CA 2018 \u2014 kinfa.or.kr \ub4f1)\ub97c\n// \ub178\ub4dc \ucf54\ub4dc\uc5d0 \uc778\ub77c\uc778\ud574 \uae30\ubcf8 root \ubaa9\ub85d\uacfc \ud569\uccd0 request \uc758 ca \ub85c \uc9c1\uc811 \ub118\uae34\ub2e4.\n// \uc6d0\ubcf8\uc740 n8n/certs/extra-ca.pem. \ub9cc\ub8cc 2028-11-21 (OPS.md \uac31\uc2e0 \uc808\ucc28 \ucc38\uace0).\nconst EXTRA_CA_PEM = `-----BEGIN CERTIFICATE-----\nMIIETjCCAzagAwIBAgINAe5fIh38YjvUMzqFVzANBgkqhkiG9w0BAQsFADBMMSAw\nHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFs\nU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjAeFw0xODExMjEwMDAwMDBaFw0yODEx\nMjEwMDAwMDBaMFAxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52\nLXNhMSYwJAYDVQQDEx1HbG9iYWxTaWduIFJTQSBPViBTU0wgQ0EgMjAxODCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKdaydUMGCEAI9WXD+uu3Vxoa2uP\nUGATeoHLl+6OimGUSyZ59gSnKvuk2la77qCk8HuKf1UfR5NhDW5xUTolJAgvjOH3\nidaSz6+zpz8w7bXfIa7+9UQX/dhj2S/TgVprX9NHsKzyqzskeU8fxy7quRU6fBhM\nabO1IFkJXinDY+YuRluqlJBJDrnw9UqhCS98NE3QvADFBlV5Bs6i0BDxSEPouVq1\nlVW9MdIbPYa+oewNEtssmSStR8JvA+Z6cLVwzM0nLKWMjsIYPJLJLnNvBhBWk0Cq\no8VS++XFBdZpaFwGue5RieGKDkFNm5KQConpFmvv73W+eka440eKHRwup08CAwEA\nAaOCASkwggElMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMB0G\nA1UdDgQWBBT473/yzXhnqN5vjySNiPGHAwKz6zAfBgNVHSMEGDAWgBSP8Et/qC5F\nJK5NUPpjmove4t0bvDA+BggrBgEFBQcBAQQyMDAwLgYIKwYBBQUHMAGGImh0dHA6\nLy9vY3NwMi5nbG9iYWxzaWduLmNvbS9yb290cjMwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5nbG9iYWxzaWduLmNvbS9yb290LXIzLmNybDBHBgNVHSAEQDA+\nMDwGBFUdIAAwNDAyBggrBgEFBQcCARYmaHR0cHM6Ly93d3cuZ2xvYmFsc2lnbi5j\nb20vcmVwb3NpdG9yeS8wDQYJKoZIhvcNAQELBQADggEBAJmQyC1fQorUC2bbmANz\nEdSIhlIoU4r7rd/9c446ZwTbw1MUcBQJfMPg+NccmBqixD7b6QDjynCy8SIwIVbb\n0615XoFYC20UgDX1b10d65pHBf9ZjQCxQNqQmJYaumxtf4z1s4DfjGRzNpZ5eWl0\n6r/4ngGPoJVpjemEuunl1Ig423g7mNA2eymw0lIYkN5SQwCuaifIFJ6GlazhgDEw\nfpolu4usBCOmmQDo8dIm7A9+O4orkjgTHY+GzYZSR+Y0fFukAj6KYXwidlNalFMz\nhriSqHKvoflShx8xpfywgVcvzfTO3PYkz6fiNJBonf6q8amaEsybwMbDqKWwIX7e\nSPY=\n-----END CERTIFICATE-----`;\n// ca \ub97c \uc9c0\uc815\ud558\uba74 \uae30\ubcf8 \uc2e0\ub8b0 \ubaa9\ub85d\uc774 \ub300\uccb4\ub418\ubbc0\ub85c, Node \uae30\ubcf8 root \uc5d0 \uc911\uac04 \uc778\uc99d\uc11c\ub97c \ub354\ud55c\ub2e4.\nconst CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA_PEM];\n\nconst MAX_URLS = 3;\nconst MAX_CLEANED_LEN = 16000;\nconst TEXT_SEPARATOR = '\\n\\n---\\n\\n';\nconst FETCH_TIMEOUT_MS = 10000;\nconst MAX_RESPONSE_BYTES = 2000000;\nconst PER_PAGE_CAP = 8000;\n\nfunction normalizeUrlKey(u) {\n return u.toLowerCase().replace(/\\/+$/, '');\n}\n\nfunction selectUrls(policy) {\n // 1) \uba85\uc2dc refUrls[] \uac00 \uc788\uc73c\uba74 \uc6b0\uc120 \uc0ac\uc6a9 (youth-seoul-crawl)\n if (policy && Array.isArray(policy.refUrls)) {\n const seen = new Set();\n const out = [];\n for (const u of policy.refUrls) {\n if (typeof u !== 'string') continue;\n const trimmed = u.trim();\n if (!trimmed) continue;\n const key = normalizeUrlKey(trimmed);\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(trimmed);\n if (out.length >= MAX_URLS) break;\n }\n return out;\n }\n // 2) fallback: \uc628\ud1b5\uccad\ub144 \ud0a4 \uae30\ubc18\n const candidates = [policy?.aplyUrlAddr, policy?.refUrlAddr1, policy?.refUrlAddr2]\n .map(s => (typeof s === 'string' ? s.trim() : ''))\n .filter(Boolean);\n const seen = new Set();\n const out = [];\n for (const u of candidates) {\n const key = normalizeUrlKey(u);\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(u);\n if (out.length >= MAX_URLS) break;\n }\n return out;\n}\n\n// URL \uc815\uaddc\ud654: \uc2a4\ud0b4 \uc5c6\ub294 \ub3c4\uba54\uc778(`www.kofpi.or.kr`)\uc5d0 https \ub97c \ubd80\uc5ec\ud55c\ub2e4 (#157).\n// URL \ub85c \ubcfc \uc218 \uc5c6\ub294 \ubb38\uc790\uc5f4\uc740 null \u2014 \ud638\ucd9c\ubd80\uac00 INVALID_URL \ub85c \uae30\ub85d\ud55c\ub2e4.\nfunction normalizeCandidateUrl(raw) {\n if (typeof raw !== 'string') return null;\n const u = raw.trim();\n if (!u) return null;\n if (/^https?:\\/\\//i.test(u)) return u;\n if (u.startsWith('//')) return 'https:' + u;\n if (/^[a-z0-9-]+(\\.[a-z0-9-]+)+([/:?#]|$)/i.test(u)) return 'https://' + u;\n return null;\n}\n\n// \uc790\uae30 \ud3ec\ud138(youth.seoul.go.kr)\uc740 fetch \ud558\uc9c0 \uc54a\ub294\ub2e4.\n// \uba54\uc778\uc740 \uc778\ub371\uc2a4 shell, content.do \ub294 WebGate JS \ucc4c\ub9b0\uc9c0, view.do \ub294 \ud0c0 \uc815\ucc45 \uad50\ucc28 \uc624\uc5fc\uc6d0.\nfunction isSelfPortalUrl(url) {\n const m = String(url).match(/^https?:\\/\\/([^/:?#]+)/i);\n if (!m) return false;\n return /(^|\\.)youth\\.seoul\\.go\\.kr$/i.test(m[1]);\n}\n\n// SSRF \uac00\ub4dc: \ub0b4\ubd80 \ub300\uc5ed(\uc0ac\uc124/\ub8e8\ud504\ubc31/\ub9c1\ud06c\ub85c\uceec/\uba54\ud0c0\ub370\uc774\ud130/docker \uc11c\ube44\uc2a4\uba85)\uc73c\ub85c\uc758 \uc694\uccad\uc744 \ucc28\ub2e8\ud55c\ub2e4.\n// n8n \uc740 docker \ub124\ud2b8\uc6cc\ud06c \uc548\uc5d0\uc11c \ub3cc\uace0 prod \ub294 EC2(IMDS)\ub77c, \ud06c\ub864 URL\u00b7\ub9ac\ub2e4\uc774\ub809\ud2b8\uac00\n// \ub0b4\ubd80 \ub9ac\uc18c\uc2a4\ub85c \ud5a5\ud558\uba74 \uc548 \ub41c\ub2e4. \ub9ac\ud130\ub7f4 IP\u00b7\uc54c\ub824\uc9c4 \ub0b4\ubd80 \ud638\uc2a4\ud2b8\uba85\u00b7\ub2e8\uc77c \ub77c\ubca8 \ud638\uc2a4\ud2b8\ub97c \ub9c9\ub294\ub2e4.\n// (\uacf5\uac1c \ud638\uc2a4\ud2b8\uba85\uc774 \ub0b4\ubd80 IP \ub85c resolve \ub418\ub294 DNS rebinding \uc740 \uc774 \uc21c\uc218 \uac00\ub4dc \ubc94\uc704 \ubc16 \u2014\n// \uc644\uc804 \ubc29\uc5b4\ub294 dns.lookup \ud6c4 \uc5f0\uacb0 IP \uace0\uc815\uc774 \ud544\uc694\ud558\uba70 \ubcc4\ub3c4 \uacfc\uc81c\ub2e4.)\nfunction isInternalHost(url) {\n const m = String(url).match(/^https?:\\/\\/([^/:?#]+)/i);\n if (!m) return false;\n let host = m[1].toLowerCase();\n if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);\n if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;\n if (host === 'metadata.google.internal') return true;\n if (host === '::1' || host === '::') return true;\n if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;\n if (/^fe[89ab][0-9a-f]:/.test(host)) return true;\n const mapped = host.match(/^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/);\n const v4 = mapped ? mapped[1] : host;\n const oct = v4.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/);\n if (oct) {\n const a = +oct[1], b = +oct[2];\n if (a === 0 || a === 10 || a === 127) return true;\n if (a === 169 && b === 254) return true;\n if (a === 192 && b === 168) return true;\n if (a === 172 && b >= 16 && b <= 31) return true;\n return false;\n }\n if (/^(0x[0-9a-f]+|\\d+)$/.test(host)) return true;\n if (!host.includes('.')) return true;\n return false;\n}\n\n// selectUrls \uac00 \ubaa8\uc740 \ud6c4\ubcf4\ub97c \uc815\uaddc\ud654\u00b7\ud544\ud130\ub9c1\ud574 fetch \ub300\uc0c1\uacfc \uc9c4\ub2e8\uc744 \ubd84\ub9ac\ud55c\ub2e4.\nfunction prepareUrls(candidates) {\n const urls = [];\n const diagnostics = [];\n const seen = new Set();\n for (const raw of Array.isArray(candidates) ? candidates : []) {\n const normalized = normalizeCandidateUrl(raw);\n if (!normalized) {\n diagnostics.push({ url: String(raw).slice(0, 500), outcome: 'INVALID_URL' });\n continue;\n }\n const key = normalizeUrlKey(normalized);\n if (seen.has(key)) continue;\n seen.add(key);\n if (isSelfPortalUrl(normalized)) {\n diagnostics.push({ url: normalized, outcome: 'SELF_PORTAL' });\n continue;\n }\n if (isInternalHost(normalized)) {\n diagnostics.push({ url: normalized, outcome: 'INVALID_URL' });\n continue;\n }\n urls.push(normalized);\n }\n return { urls, diagnostics };\n}\n\n// \ub9ac\ub2e4\uc774\ub809\ud2b8 \uccb4\uc778 \ud55c\uc815 cookie jar (#158).\n// Set-Cookie \uc758 name=value \ub9cc \ucde8\ud558\uace0 \uc18d\uc131(Path/Domain/Expires)\uc740 \ubb34\uc2dc\ud55c\ub2e4 \u2014\n// \uccb4\uc778 \ubc16\uc73c\ub85c \ucfe0\ud0a4\ub97c \uc720\uc9c0\ud558\uc9c0 \uc54a\uc73c\ubbc0\ub85c \ub9cc\ub8cc\u00b7\uc2a4\ucf54\ud504 \uad00\ub9ac\uac00 \ubd88\ud544\uc694\ud558\ub2e4.\nfunction applySetCookies(jar, host, setCookieHeaders) {\n if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return jar;\n const next = { ...jar, [host]: { ...(jar[host] || {}) } };\n for (const line of setCookieHeaders) {\n if (typeof line !== 'string') continue;\n const pair = line.split(';', 1)[0];\n const eq = pair.indexOf('=');\n if (eq <= 0) continue;\n const name = pair.slice(0, eq).trim();\n if (!name) continue;\n next[host][name] = pair.slice(eq + 1).trim();\n }\n return next;\n}\n\nfunction cookieHeaderFor(jar, host) {\n const cookies = jar && jar[host];\n if (!cookies) return null;\n const entries = Object.entries(cookies);\n if (entries.length === 0) return null;\n return entries.map(([k, v]) => `${k}=${v}`).join('; ');\n}\n\nfunction mergeFetchResults(results) {\n if (!Array.isArray(results) || results.length === 0) {\n return { cleanedText: '', extraAttachments: [], status: 'FETCH_FAILED' };\n }\n const ok = results.filter(r => r && r.status == null);\n if (ok.length === 0) {\n const allTooShort = results.length > 0 && results.every(r => r && r.status === 'TOO_SHORT');\n return {\n cleanedText: '',\n extraAttachments: [],\n status: allTooShort ? 'TOO_SHORT' : 'FETCH_FAILED'\n };\n }\n let cleanedText = ok.map(r => r.cleanedText || '').join(TEXT_SEPARATOR);\n if (cleanedText.length > MAX_CLEANED_LEN) cleanedText = cleanedText.slice(0, MAX_CLEANED_LEN);\n const seenAttachments = new Set();\n const extraAttachments = [];\n for (const r of ok) {\n const items = Array.isArray(r.extraAttachments) ? r.extraAttachments : [];\n for (const a of items) {\n if (!a || typeof a.url !== 'string') continue;\n const key = a.url.toLowerCase();\n if (seenAttachments.has(key)) continue;\n seenAttachments.add(key);\n extraAttachments.push(a);\n }\n }\n return { cleanedText, extraAttachments, status: null };\n}\n\nfunction absUrl(href, pageUrl) {\n if (!href) return href;\n if (/^https?:\\/\\//i.test(href)) return href;\n const m = pageUrl.match(/^(https?:\\/\\/[^/]+)/);\n const origin = m ? m[1] : '';\n if (href.startsWith('//')) {\n const proto = (pageUrl.match(/^(https?:)/) || ['', 'https:'])[1];\n return proto + href;\n }\n if (href.startsWith('/')) return origin + href;\n return origin + '/' + href.replace(/^\\.?\\//, '');\n}\n\nconst ONCLICK_DOWNLOAD_RULES = [\n {\n host: /(^|\\.)kofpi\\.or\\.kr$/i,\n fnName: 'fnNotiDownload',\n buildUrl: (origin, seq) => `${origin}/noti/download.do?fileSeq=${encodeURIComponent(seq)}`,\n },\n];\n\nfunction isDummyHref(href) {\n if (!href) return true;\n const trimmed = href.trim();\n return trimmed === '' || trimmed === '#' || /^javascript:/i.test(trimmed);\n}\n\nfunction escapeRegExp(s) {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction resolveOnclickUrl(onclick, pageUrl) {\n if (!onclick) return null;\n const m = pageUrl.match(/^(https?:\\/\\/([^/]+))/);\n if (!m) return null;\n const origin = m[1];\n const host = m[2];\n for (const rule of ONCLICK_DOWNLOAD_RULES) {\n if (!rule.host.test(host)) continue;\n const fnRe = new RegExp('\\\\b' + escapeRegExp(rule.fnName) + \"\\\\s*\\\\(\\\\s*['\\\"]([^'\\\"]+)['\\\"]\\\\s*\\\\)\");\n const am = onclick.match(fnRe);\n if (!am) continue;\n return rule.buildUrl(origin, am[1]);\n }\n return null;\n}\n\nfunction extractCleanedAndAttachments(rawHtml, pageUrl) {\n const $ = cheerio.load(rawHtml);\n $('script, style, nav, footer, aside, header, noscript').remove();\n const root = $('main').first().length ? $('main').first()\n : $('article').first().length ? $('article').first()\n : $('[role=\"main\"]').first().length ? $('[role=\"main\"]').first()\n : $('#content').first().length ? $('#content').first()\n : $('body').first();\n let cleaned = root.text().replace(/\\s+/g, ' ').trim();\n if (cleaned.length > PER_PAGE_CAP) cleaned = cleaned.slice(0, PER_PAGE_CAP);\n\n const seen = new Set();\n const extras = [];\n $('a[href]').each((_, el) => {\n const $a = $(el);\n const href = $a.attr('href') || '';\n const onclick = $a.attr('onclick') || '';\n const text = $a.text().trim();\n const imgAlt = $a.find('img').first().attr('alt') || '';\n const lowerHref = href.toLowerCase();\n const extPattern = /\\.(pdf|hwp|hwpx|docx|xlsx|zip)(\\?|$|#)/i;\n const hasExt = extPattern.test(href);\n const textHasExt = /\\.(pdf|hwp|hwpx|docx|xlsx|zip)$/i.test(text);\n const imgIsFile = /^(pdf|hwp|hwpx|docx|xlsx|zip)$/i.test(imgAlt);\n const hrefHasDownloadKw = /(download|filedown|attach)/i.test(lowerHref);\n const looksLikeFile = hasExt || textHasExt || imgIsFile || (hrefHasDownloadKw && text.length > 0 && text.length < 200);\n if (!looksLikeFile) return;\n let url = absUrl(href, pageUrl);\n if (isDummyHref(href)) {\n const reconstructed = resolveOnclickUrl(onclick, pageUrl);\n if (reconstructed) url = reconstructed;\n }\n if (seen.has(url)) return;\n seen.add(url);\n let name = text;\n if (!name || name.length < 2) {\n name = imgAlt ? `attachment.${imgAlt}` : url.split('/').pop().slice(0, 200);\n }\n name = name.replace(/\\s*\ubbf8\ub9ac\ubcf4\uae30\\s*$/, '').trim().slice(0, 200);\n extras.push({ name, url });\n });\n return { cleaned, extras };\n}\n\n// TLS \uacc4\uc5f4 \uc5d0\ub7ec\ucf54\ub4dc \u2192 TLS_ERROR \ub85c \ubd84\ub958 (\uadf8 \uc678 \uc18c\ucf13 \uc5d0\ub7ec\ub294 NETWORK)\nconst TLS_ERROR_CODES = /^(UNABLE_TO_VERIFY_LEAF_SIGNATURE|SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|CERT_|ERR_TLS_)/;\n\nfunction hostOf(url) {\n const m = String(url).match(/^https?:\\/\\/([^/:?#]+)/i);\n return m ? m[1].toLowerCase() : '';\n}\n\n// #157: lib.request \ub3d9\uae30 throw(ERR_INVALID_URL \ub4f1)\uae4c\uc9c0 \uc804\ubd80 resolve \ub85c \ud761\uc218\ud55c\ub2e4.\n// \ubc18\ud658: { ok:true, body } | { ok:false, outcome }\nfunction httpGetText(url, state) {\n state = state || { hops: 0, jar: {}, visited: [] };\n if (state.hops >= 5) return Promise.resolve({ ok: false, outcome: 'REDIRECT_LOOP' });\n return new Promise((resolve) => {\n let req;\n try {\n const lib = /^https:\\/\\//i.test(url) ? https : http;\n const host = hostOf(url);\n const cookie = cookieHeaderFor(state.jar, host);\n // \uac19\uc740 URL \uc744 \uac19\uc740 \ucfe0\ud0a4\ub85c \uc7ac\ubc29\ubb38 = \ucfe0\ud0a4\ub97c \uc918\ub3c4 \uc548 \ud480\ub9ac\ub294 \ub8e8\ud504 \u2192 \uc911\ub2e8\n const visitKey = url + '|' + (cookie || '');\n if (state.visited.includes(visitKey)) {\n return resolve({ ok: false, outcome: 'REDIRECT_LOOP' });\n }\n state.visited.push(visitKey);\n const headers = {\n 'User-Agent': 'YouthFit-Bot/1.0 (+https://youthfit.kr/bot)',\n 'Accept': 'text/html,application/xhtml+xml',\n 'Accept-Encoding': 'identity'\n };\n if (cookie) headers['Cookie'] = cookie;\n req = lib.request(url, { method: 'GET', headers, timeout: FETCH_TIMEOUT_MS, ca: CA_BUNDLE }, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n const nextUrl = absUrl(res.headers.location, url);\n if (isInternalHost(nextUrl)) {\n res.resume();\n return resolve({ ok: false, outcome: 'INVALID_URL' });\n }\n const jar = applySetCookies(state.jar, host, res.headers['set-cookie'] || []);\n res.resume();\n return httpGetText(nextUrl, { hops: state.hops + 1, jar, visited: state.visited }).then(resolve);\n }\n if (res.statusCode < 200 || res.statusCode >= 300) {\n res.resume();\n return resolve({ ok: false, outcome: 'HTTP_' + res.statusCode });\n }\n res.on('error', () => resolve({ ok: false, outcome: 'NETWORK' }));\n const chunks = [];\n let total = 0;\n res.on('data', c => {\n total += c.length;\n if (total > MAX_RESPONSE_BYTES) {\n req.destroy();\n resolve({ ok: false, outcome: 'OVERSIZE' });\n return;\n }\n chunks.push(c);\n });\n res.on('end', () => resolve({ ok: true, body: Buffer.concat(chunks).toString('utf8') }));\n });\n req.on('error', (e) => {\n const code = (e && e.code) || '';\n resolve({ ok: false, outcome: TLS_ERROR_CODES.test(code) ? 'TLS_ERROR' : 'NETWORK' });\n });\n req.on('timeout', () => { req.destroy(); resolve({ ok: false, outcome: 'TIMEOUT' }); });\n req.end();\n } catch (e) {\n resolve({ ok: false, outcome: 'INVALID_URL' });\n }\n });\n}\n\n// #157: URL \ud558\ub098\uc758 \uc2e4\ud328\uac00 \ub2e4\ub978 URL\u00b7\ub2e4\ub978 \uc815\ucc45\uc73c\ub85c \ubc88\uc9c0\uc9c0 \uc54a\uac8c await \ub3c4 \uaca9\ub9ac\ud55c\ub2e4.\nasync function fetchAndExtract(url) {\n let res;\n try {\n res = await httpGetText(url);\n } catch (e) {\n res = { ok: false, outcome: 'NETWORK' };\n }\n if (!res.ok || !res.body) {\n return { url, status: 'FETCH_FAILED', outcome: res.outcome || 'NETWORK', cleanedText: '', extraAttachments: [] };\n }\n try {\n const { cleaned, extras } = extractCleanedAndAttachments(res.body, url);\n const tooShort = cleaned.length < 200;\n return {\n url,\n status: tooShort ? 'TOO_SHORT' : null,\n outcome: tooShort ? 'TOO_SHORT' : 'OK',\n cleanedText: cleaned,\n extraAttachments: extras\n };\n } catch (e) {\n return { url, status: 'FETCH_FAILED', outcome: 'NETWORK', cleanedText: '', extraAttachments: [] };\n }\n}\n\n// \ud638\ucd9c\ubd80 \u2014 \ubabd\ub545(rawData._refUrls)\uacfc \uc628\ud1b5\uccad\ub144(aplyUrlAddr \uacc4\uc5f4) \ubaa8\ub450 \ucc98\ub9ac\ud558\ub294 \ud1b5\ud569 tail.\nconst p = $input.first().json;\nconst refUrls = p && p.rawData && Array.isArray(p.rawData._refUrls) ? p.rawData._refUrls : null;\nconst candidates = refUrls ? selectUrls({ refUrls }) : selectUrls(p);\nconst prep = prepareUrls(candidates);\n\nif (prep.urls.length === 0) {\n return [{\n json: {\n ...p,\n _enrichUrl: null,\n _enrichUrls: [],\n _cleanedText: '',\n _extraAttachments: [],\n _enrichmentStatus: 'NO_LINK',\n _fetchDiagnostics: prep.diagnostics\n }\n }];\n}\n\nconst results = await Promise.all(prep.urls.map(u => fetchAndExtract(u)));\nconst merged = mergeFetchResults(results);\nconst diagnostics = prep.diagnostics.concat(results.map(r => ({ url: r.url, outcome: r.outcome })));\n\nreturn [{\n json: {\n ...p,\n _enrichUrl: prep.urls[0],\n _enrichUrls: prep.urls,\n _cleanedText: merged.cleanedText,\n _extraAttachments: merged.extraAttachments,\n _enrichmentStatus: merged.status,\n _fetchDiagnostics: diagnostics\n }\n}];\n"
},
"id": "pick-link",
"name": "\ub9c1\ud06c fetch + \uba38\uc9c0",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
0
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "cleaned-ok",
"leftValue": "={{ $json._enrichmentStatus }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "empty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-cleaned-ok",
"name": "cleaned \ud1b5\uacfc \uc5ec\ubd80",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
2420,
-100
]
},
{
"parameters": {
"jsCode": "const p = $input.first().json;\nconst e = {\n sourceUrl: p._enrichUrl || null,\n fetchedAt: new Date().toISOString(),\n extractor: 'openai:gpt-4o-mini',\n confidence: null,\n status: p._enrichmentStatus || 'FETCH_FAILED',\n sections: null,\n extraAttachments: p._extraAttachments || [],\n fetchDiagnostics: p._fetchDiagnostics || []\n};\nreturn [{ json: { ...p, _enrichment: e } }];"
},
"id": "skip-cleaned",
"name": "enrichment skip: cleaned",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2640,
50
]
},
{
"id": "llm-extract",
"name": "LLM \uad6c\uc870\ud654 \ucd94\ucd9c",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2640,
-200
],
"parameters": {
"jsCode": "// OpenAI Chat Completions \ud638\ucd9c (Node built-in https \uc0ac\uc6a9 \u2014 sandbox \uc548\uc5d0\uc11c fetch \ubbf8\uc9c0\uc6d0)\nconst https = require('https');\nconst upstream = $input.first().json;\nconst cleanedText = upstream._cleanedText || '';\nconst apiTitle = upstream.plcyNm || '';\n\nconst schema = {\n type: \"object\",\n properties: {\n supportTarget: { type: [\"string\",\"null\"] },\n supportContent: { type: [\"string\",\"null\"] },\n applyMethod: { type: [\"string\",\"null\"] },\n requiredDocuments: { type: [\"string\",\"null\"] },\n deadlineNote: { type: [\"string\",\"null\"] },\n policyOverview: { type: [\"string\",\"null\"] },\n eligibilityCriteria: { type: [\"string\",\"null\"] },\n operatingOrganization: { type: [\"string\",\"null\"] },\n contactPhone: { type: [\"string\",\"null\"] },\n confidence: { type: \"number\", minimum: 0, maximum: 1 }\n },\n required: [\n \"supportTarget\",\"supportContent\",\"applyMethod\",\"requiredDocuments\",\"deadlineNote\",\n \"policyOverview\",\"eligibilityCriteria\",\"operatingOrganization\",\"contactPhone\",\n \"confidence\"\n ],\n additionalProperties: false\n};\n\nconst systemMsg = `\ub108\ub294 \ud55c\uad6d \uccad\ub144 \uc815\ucc45 \uc548\ub0b4 \ud398\uc774\uc9c0\uc758 \ubcf8\ubb38\uc744 \uc77d\uace0 \ud575\uc2ec \uad6c\uc870\ud654 \uc815\ubcf4\ub97c \ubf51\ub294\ub2e4.\n- \ud14d\uc2a4\ud2b8\uc5d0 \uba85\uc2dc\ub418\uc9c0 \uc54a\uc740 \ub0b4\uc6a9\uc744 \uc784\uc758\ub85c \ub9cc\ub4e4\uc9c0 \ub9c8\ub77c. \ubaa8\ub974\uba74 null.\n- policyOverview: \uc815\ucc45\uc774 \uc5b4\ub5a4 \uac83\uc778\uc9c0 1~2\ubb38\uc7a5 \uc694\uc57d(\ubaa9\uc801\u00b7\ub300\uc0c1\u00b7\ub0b4\uc6a9).\n- supportTarget: \uc9c0\uc6d0 \ub300\uc0c1.\n- eligibilityCriteria: \uc9c0\uc6d0\uc790\uaca9 (\uc5f0\ub839/\uc870\uac74/\uc81c\uc678 \ub4f1).\n- supportContent: \uc9c0\uc6d0 \ub0b4\uc6a9.\n- applyMethod: \uc2e0\uccad \ubc29\ubc95.\n- requiredDocuments: \uc81c\ucd9c \uc11c\ub958.\n- deadlineNote: \uc2e0\uccad \uae30\uac04/\ub9c8\uac10 \uc548\ub0b4.\n- operatingOrganization: \uc6b4\uc601\uae30\uad00 \ub610\ub294 \uc8fc\uad00\uae30\uad00. \"\uc6b4\uc601\uae30\uad00\"\u00b7\"\uc8fc\uad00\uae30\uad00\"\u00b7\"\ub2f4\ub2f9\ubd80\uc11c\" \ub77c\ubca8 \uc8fc\ubcc0\uc5d0\uc11c.\n- contactPhone: \ubb38\uc758 \uc804\ud654\ubc88\ud638.\n- confidence: \uc785\ub825 \uc815\ubcf4\uac00 \uc815\ucc45 \uc548\ub0b4 \ubb38\uc11c\ub85c\uc11c \ucda9\ubd84\ud55c \uc815\ub3c4\ub97c 0~1 \ub85c \uc790\uccb4 \ud3c9\uac00.\n\ucd9c\ub825\uc740 JSON \ub9cc. \uc790\uc5f0\uc5b4 \uc124\uba85 \uae08\uc9c0.`;\n\nconst userMsg = `\uc6d0\ubcf8 API title: ${apiTitle}\\n\\n\uc678\ubd80 \ud398\uc774\uc9c0 \ubcf8\ubb38:\\n${cleanedText}`;\n\nconst reqBody = JSON.stringify({\n model: \"gpt-4o-mini\",\n response_format: {\n type: \"json_schema\",\n json_schema: { name: \"policy_enrichment\", strict: true, schema: schema }\n },\n messages: [\n { role: \"system\", content: systemMsg },\n { role: \"user\", content: userMsg }\n ]\n});\n\nfunction callOpenAI() {\n return new Promise((resolve) => {\n const req = https.request({\n method: 'POST',\n hostname: 'api.openai.com',\n path: '/v1/chat/completions',\n headers: {\n 'Authorization': `Bearer ${$env.OPENAI_API_KEY}`,\n 'Content-Type': 'application/json',\n 'Content-Length': Buffer.byteLength(reqBody)\n },\n timeout: 30000\n }, (res) => {\n let chunks = [];\n res.on('data', c => chunks.push(c));\n res.on('end', () => {\n const body = Buffer.concat(chunks).toString('utf8');\n if (res.statusCode >= 200 && res.statusCode < 300) {\n try { resolve({ ok: true, data: JSON.parse(body) }); }\n catch (e) { resolve({ ok: false, error: 'parse: ' + e.message }); }\n } else {\n resolve({ ok: false, error: `HTTP ${res.statusCode}: ${body.slice(0,200)}` });\n }\n });\n });\n req.on('error', e => resolve({ ok: false, error: 'req error: ' + e.message }));\n req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });\n req.write(reqBody);\n req.end();\n });\n}\n\nconst result = await callOpenAI();\nreturn [{\n json: {\n ...upstream,\n _llmResponse: result.ok ? result.data : null,\n _llmError: result.ok ? null : result.error\n }\n}];"
}
},
{
"parameters": {
"jsCode": "// LLM \ub178\ub4dc\uac00 ...upstream \uc73c\ub85c fetch \uacb0\uacfc \uba54\ud0c0(_cleanedText \ub4f1)\ub97c \uadf8\ub300\ub85c \uc804\ub2ec\ud558\ubbc0\ub85c\n// \uac19\uc740 item \uc5d0 _llmResponse + _enrichUrl/_cleanedText/_extraAttachments \uac00 \ud568\uaed8 \ub4e4\uc5b4\uc788\ub2e4.\nconst item = $input.first().json;\n\nlet parsed = null;\nlet status = null;\nlet confidence = null;\nlet sections = null;\n\nconst llmError = item._llmError;\nconst llmResponse = item._llmResponse;\n\nif (llmError) {\n status = 'LLM_FAILED';\n} else if (!llmResponse) {\n status = 'PARSE_FAILED';\n} else {\n const content = llmResponse?.choices?.[0]?.message?.content;\n if (!content) {\n status = 'PARSE_FAILED';\n } else {\n try {\n parsed = typeof content === 'string' ? JSON.parse(content) : content;\n } catch (e) {\n parsed = null;\n }\n if (!parsed || typeof parsed.confidence !== 'number') {\n status = 'PARSE_FAILED';\n } else {\n confidence = parsed.confidence;\n sections = {\n supportTarget: parsed.supportTarget || null,\n supportContent: parsed.supportContent || null,\n applyMethod: parsed.applyMethod || null,\n requiredDocuments: parsed.requiredDocuments || null,\n deadlineNote: parsed.deadlineNote || null,\n policyOverview: parsed.policyOverview || null,\n eligibilityCriteria: parsed.eligibilityCriteria || null,\n operatingOrganization: parsed.operatingOrganization || null,\n contactPhone: parsed.contactPhone || null\n };\n status = confidence >= 0.6 ? 'OK' : 'LOW_CONFIDENCE';\n }\n }\n}\n\nconst enrichment = {\n sourceUrl: item._enrichUrl,\n fetchedAt: new Date().toISOString(),\n extractor: 'openai:gpt-4o-mini',\n confidence,\n status,\n sections,\n extraAttachments: item._extraAttachments || [],\n cleanedText: item._cleanedText || null,\n fetchDiagnostics: item._fetchDiagnostics || []\n};\n\nreturn [{ json: { ...item, _enrichment: enrichment } }];\n"
},
"id": "assemble-enrichment",
"name": "enrichment \uac1d\uccb4 \uc870\ub9bd",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2860,
-200
]
}
],
"connections": {
"\ub9e4\uc77c \uc0c8\ubcbd 4\uc2dc \uc2e4\ud589": {
"main": [
[
{
"node": "\ud398\uc774\uc9c0 \ucd08\uae30\ud654",
"type": "main",
"index": 0
}
]
]
},
"\uc218\ub3d9 \uc2e4\ud589 \ud2b8\ub9ac\uac70": {
"main": [
[
{
"node": "\ud398\uc774\uc9c0 \ucd08\uae30\ud654",
"type": "main",
"index": 0
}
]
]
},
"\ud398\uc774\uc9c0 \ucd08\uae30\ud654": {
"main": [
[
{
"node": "getPlcy \ud638\ucd9c",
"type": "main",
"index": 0
}
]
]
},
"getPlcy \ud638\ucd9c": {
"main": [
[
{
"node": "JSON \ud30c\uc2f1 + \uc11c\uc6b8 \ud544\ud130",
"type": "main",
"index": 0
}
]
]
},
"JSON \ud30c\uc2f1 + \uc11c\uc6b8 \ud544\ud130": {
"main": [
[
{
"node": "\uc678\ubd80 hash \uc870\ud68c",
"type": "main",
"index": 0
}
]
]
},
"\uc678\ubd80 hash \uc870\ud68c": {
"main": [
[
{
"node": "\ubcc0\ub3d9 \ud310\uc815",
"type": "main",
"index": 0
}
]
]
},
"\ubcc0\ub3d9 \ud310\uc815": {
"main": [
[
{
"node": "\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac",
"type": "main",
"index": 0
}
]
]
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
YouthFit - 온통청년 서울 청년 정책 수집. Uses httpRequest. Scheduled trigger; 22 nodes.
Source: https://github.com/TaetaetaE01/youthfit/blob/39a44a4b1cd69ed076fa61cb3b4f3a062af2edbe/n8n/workflows/youth-center-seoul.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Birthday Automation - Production (Fixed). Uses stopAndError, httpRequest, emailSend, bannerbear. Scheduled trigger; 86 nodes.
This template runs two scheduled workflows to govern Microsoft Entra ID (Azure AD) guest accounts by detecting stale users via Microsoft Graph, staging deletions in SharePoint with a 72-hour window, n
Jira-Allure-Auto-Qa. Uses httpRequest, jira. Scheduled trigger; 68 nodes.
Spotify-Sync-Surrealdb-V1. Uses httpRequest, n8n-nodes-surrealdb, spotify. Scheduled trigger; 62 nodes.
As n8n instances scale, teams often lose track of sub-workflows—who uses them, where they are referenced, and whether they can be safely updated. This leads to inefficiencies like unnecessary copies o