{
  "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
          }
        ]
      ]
    },
    "\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac": {
      "main": [
        [
          {
            "node": "\ub2e4\uc74c \ud398\uc774\uc9c0 \ud655\uc778",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "1\ucd08 \ub300\uae30",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1\ucd08 \ub300\uae30": {
      "main": [
        [
          {
            "node": "enrichment \uc5ec\ubd80",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "enrichment \uc5ec\ubd80": {
      "main": [
        [
          {
            "node": "\ub9c1\ud06c fetch + \uba38\uc9c0",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\uc815\ucc45 \u2192 IngestPolicyRequest \ubcc0\ud658",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "cleaned \ud1b5\uacfc \uc5ec\ubd80": {
      "main": [
        [
          {
            "node": "LLM \uad6c\uc870\ud654 \ucd94\ucd9c",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "enrichment skip: cleaned",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "enrichment skip: cleaned": {
      "main": [
        [
          {
            "node": "\uc815\ucc45 \u2192 IngestPolicyRequest \ubcc0\ud658",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM \uad6c\uc870\ud654 \ucd94\ucd9c": {
      "main": [
        [
          {
            "node": "enrichment \uac1d\uccb4 \uc870\ub9bd",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "enrichment \uac1d\uccb4 \uc870\ub9bd": {
      "main": [
        [
          {
            "node": "\uc815\ucc45 \u2192 IngestPolicyRequest \ubcc0\ud658",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\uc815\ucc45 \u2192 IngestPolicyRequest \ubcc0\ud658": {
      "main": [
        [
          {
            "node": "attachments \uc2b9\uaca9",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "attachments \uc2b9\uaca9": {
      "main": [
        [
          {
            "node": "\ubc31\uc5d4\ub4dc API \uc804\uc1a1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ubc31\uc5d4\ub4dc API \uc804\uc1a1": {
      "main": [
        [
          {
            "node": "\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ub2e4\uc74c \ud398\uc774\uc9c0 \ud655\uc778": {
      "main": [
        [
          {
            "node": "\ub2e4\uc74c \ud398\uc774\uc9c0 \uc874\uc7ac?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ub2e4\uc74c \ud398\uc774\uc9c0 \uc874\uc7ac?": {
      "main": [
        [
          {
            "node": "\ub2e4\uc74c \ud398\uc774\uc9c0 \uc774\ub3d9",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\uc218\uc9d1 \uc644\ub8cc",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ub2e4\uc74c \ud398\uc774\uc9c0 \uc774\ub3d9": {
      "main": [
        [
          {
            "node": "getPlcy \ud638\ucd9c",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ub9c1\ud06c fetch + \uba38\uc9c0": {
      "main": [
        [
          {
            "node": "cleaned \ud1b5\uacfc \uc5ec\ubd80",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "tags": [
    {
      "name": "api-ingest"
    },
    {
      "name": "youth-center"
    },
    {
      "name": "youth-policy"
    }
  ]
}