AutomationFlowsWeb Scraping › Youth Seoul District

Youth Seoul District

youth-seoul-district. Uses httpRequest. Scheduled trigger; 21 nodes.

Cron / scheduled trigger★★★★☆ complexity21 nodesHTTP Request
Web Scraping Trigger: Cron / scheduled Nodes: 21 Complexity: ★★★★☆ Added:

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 →

Download .json
{
  "name": "youth-seoul-district",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 3 * * *"
            }
          ]
        }
      },
      "id": "schedule-trigger",
      "name": "\ub9e4\uc77c \uc0c8\ubcbd 3\uc2dc \uc2e4\ud589",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "youth-seoul-district-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": "// \uc790\uce58\uad6c \ub2e8\uc77c \uce74\ud14c\uace0\ub9ac \uc0c1\uc218 \uc778\ub77c\uc778 (\uce74\ud14c\uace0\ub9ac \ub8e8\ud504 \uc81c\uac70).\n// \ud638\uc2a4\ud2b8\ub294 \ubc18\ub4dc\uc2dc youth.seoul.go.kr (www \uc5c6\uc74c \u2014 www \ub294 NXDOMAIN, N8N-1 \uc2e4\uc99d).\n// region \uc740 \uc5ec\uae30\uc11c \uace0\uc815\ud558\uc9c0 \uc54a\uace0 \uc0c1\uc138 \ud30c\uc2f1 \ub178\ub4dc\uc5d0\uc11c \uc815\ucc45 \uc81c\ubaa9 \ub05d (\u25cb\u25cb\uad6c) \ub85c \uacb0\uc815\ud55c\ub2e4(regionFromDistrictTitle).\nreturn [{ json: {\n  pageIndex: 1,\n  listBase: 'https://youth.seoul.go.kr/infoData/plcyInfo/guList.do?key=2309150002&tabKind=003',\n  detailBase: 'https://youth.seoul.go.kr/infoData/plcyInfo/view.do',\n  detailSuffix: '&tab=001&key=2309150002&tabKind=003',\n  sourceType: 'YOUTH_SEOUL_CRAWL',\n} }];"
      },
      "id": "init-page",
      "name": "\ud398\uc774\uc9c0 \ucd08\uae30\ud654",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "url": "={{ $('\ud398\uc774\uc9c0 \ucd08\uae30\ud654').first().json.listBase }}&pageIndex={{ $json.pageIndex }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": false,
              "responseFormat": "text"
            }
          },
          "timeout": 30000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "YouthFit-Bot/1.0 (+https://youthfit.kr/bot)"
            }
          ]
        }
      },
      "id": "fetch-list",
      "name": "\ubaa9\ub85d \ud398\uc774\uc9c0 \uc694\uccad",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        0
      ],
      "retryOnFail": true,
      "maxTries": 5,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// \ubaa9\ub85d HTML \uc5d0\uc11c plcyBizId \uc218\uc9d1 + 2026 \ucef7\uc624\ud504 + lastPage \uacc4\uc0b0.\n// \ubaa9\ub85d\uc740 \ub4f1\ub85d\uc77c desc \uc815\ub82c\uc774\ubbc0\ub85c 2026 \uc544\ub2cc ID \ucd9c\ud604 \uc2dc \ub9c8\uc9c0\ub9c9 \ud398\uc774\uc9c0\ub85c \uac04\uc8fc.\nconst cat = $('\ud398\uc774\uc9c0 \ucd08\uae30\ud654').first().json;\nconst html = $input.first().json.data || $input.first().json.body || '';\n\n// \ud604\uc7ac \ud398\uc774\uc9c0 \ubc88\ud638: \ubaa9\ub85d hidden input \uc6b0\uc120, \uc5c6\uc73c\uba74 \uc9c1\uc804 \uc785\ub825\uc758 pageIndex\nconst pageIdxMatch = html.match(/name=\"pageIndex\"[^>]*value=\"(\\d+)\"/);\nconst currentPage = pageIdxMatch ? parseInt(pageIdxMatch[1], 10) : ($json.pageIndex || 1);\n\n// goView('V202600006') \ud615\uc2dd ID \uc218\uc9d1\nconst ids = [...html.matchAll(/goView\\('([A-Za-z0-9]+)'\\)/g)].map(m => m[1]);\n\n// 2026 \ud310\uc815: \uc11c\uc6b8\uc2dc 'V2026\u2026' (V + YYYY + \uc77c\ub828\ubc88\ud638)\nconst is2026 = (id) => /^V?2026/.test(id);\nconst ids2026 = ids.filter(is2026);\nconst anyOlder = ids.some(id => !is2026(id));   // 2025\u2193 \ucd9c\ud604 \u2192 \ucef7\n\n// \ub9c8\uc9c0\ub9c9 \ud398\uc774\uc9c0: fn_egov_link_page(N) \ucd5c\ub313\uac12\nconst pages = [...html.matchAll(/fn_egov_link_page\\((\\d+)\\)/g)].map(m => parseInt(m[1], 10));\nconst lastPage = pages.length ? Math.max(...pages) : currentPage;\n\n// plcyBizId \ub97c static data \uc5d0 \ub204\uc801 (item splitInBatches \uc5c6\uc774)\nconst sd = $getWorkflowStaticData('global');\nif (!Array.isArray(sd.collected)) sd.collected = [];\nfor (const id of ids2026) sd.collected.push({ plcyBizId: id });\n\nconst cut = anyOlder || ids2026.length === 0;\nconst hasNext = !cut && currentPage < lastPage;\n\n// \ud398\uc774\uc9c0 \ub8e8\ud504 \uc81c\uc5b4\uc6a9 \uc694\uc57d 1\uac74\ub9cc emit\nreturn [{ json: {\n  currentPage,\n  lastPage,\n  hasNext,\n  collectedCount: sd.collected.length\n} }];\n"
      },
      "id": "extract-ids",
      "name": "plcyBizId \ucd94\ucd9c",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// plcyBizId \ucd94\ucd9c emit \uc758 \ud398\uc774\uc9c0 \ub8e8\ud504 \uc2e0\ud638\ub97c \uadf8\ub300\ub85c \ud1b5\uacfc.\nconst f = $input.first().json;\nreturn [{ json: { pageIndex: f.currentPage, currentPage: f.currentPage, lastPage: f.lastPage, hasNext: f.hasNext === true } }];"
      },
      "id": "check-next-page",
      "name": "\ub2e4\uc74c \ud398\uc774\uc9c0 \ud655\uc778",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        -200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "has-next",
              "leftValue": "={{ $json.hasNext === true }}",
              "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": [
        1100,
        -200
      ]
    },
    {
      "parameters": {
        "jsCode": "// \ub2e4\uc74c \ud398\uc774\uc9c0\ub85c \uc774\ub3d9 (pageIndex+1).\nconst currentPage = $input.first().json.currentPage || $input.first().json.pageIndex || 1;\nreturn [{ json: { pageIndex: currentPage + 1 } }];"
      },
      "id": "next-page",
      "name": "\ub2e4\uc74c \ud398\uc774\uc9c0 \uc774\ub3d9",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        -300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $env.BACKEND_URL || 'http://backend:8080' }}/api/internal/ingestion/policies/external-hashes",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "source",
              "value": "YOUTH_SEOUL_CRAWL"
            }
          ]
        },
        "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,
      "executeOnce": true,
      "position": [
        1320,
        -100
      ]
    },
    {
      "parameters": {
        "jsCode": "// external-hash dedup: DB \uc5d0 \uc544\uc9c1 \uc5c6\ub294 \uc2e0\uaddc plcyBizId \ub9cc \uc0c1\uc138 \uc218\uc9d1 \ub300\uc0c1\uc73c\ub85c.\nconst hashMap = $('\uc678\ubd80 hash \uc870\ud68c').first().json || {};\nconst sd = $getWorkflowStaticData('global');\nconst items = Array.isArray(sd.collected) ? sd.collected : [];\nconst out = [];\nfor (const p of items) {\n  if (hashMap[p.plcyBizId] == null) out.push({ json: p }); // DB \ubbf8\uc874\uc7ac \uc2e0\uaddc\ub9cc\n}\nsd.collected = []; // \ub2e4\uc74c \uc2e4\ud589 \uc704\ud574 \ub9ac\uc14b\nif (out.length === 0) return [{ json: { empty: true } }]; // \ube48 \ud398\uc774\uc9c0 \uce90\ub9ac\uc5b4\nreturn out;\n"
      },
      "id": "filter-new-plcybizid",
      "name": "\uc2e0\uaddc plcyBizId \ud544\ud130",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        -100
      ]
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "id": "loop-policies",
      "name": "\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        1760,
        -100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "has-plcybizid",
              "leftValue": "={{ $json.plcyBizId ? true : false }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-has-plcybizid",
      "name": "\uc0c1\uc138 \ub300\uc0c1 \uc5ec\ubd80",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1980,
        -100
      ]
    },
    {
      "parameters": {
        "amount": 3,
        "unit": "seconds"
      },
      "id": "rate-limit-wait",
      "name": "3\ucd08 \ub300\uae30 (Rate Limit)",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        2200,
        0
      ]
    },
    {
      "parameters": {
        "url": "={{ $('\ud398\uc774\uc9c0 \ucd08\uae30\ud654').first().json.detailBase }}?plcyBizId={{ $json.plcyBizId }}{{ $('\ud398\uc774\uc9c0 \ucd08\uae30\ud654').first().json.detailSuffix }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": false,
              "responseFormat": "text"
            }
          },
          "timeout": 30000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "YouthFit-Bot/1.0 (+https://youthfit.kr/bot)"
            }
          ]
        }
      },
      "id": "fetch-detail",
      "name": "\uc0c1\uc138 \ud398\uc774\uc9c0 \uc694\uccad",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2420,
        0
      ],
      "retryOnFail": true,
      "maxTries": 5,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// \u26a0 \ub3d9\uae30\ud654 \ubbf8\ub7ec: n8n/workflows/__fixtures__/youth-seoul-detail/parse-plcyinfo.mjs \uc758\n//   parsePlcyInfoDetail \ubcf8\ubb38\uacfc \ub3d9\uc77c \uc54c\uace0\ub9ac\uc998\uc774\uc5b4\uc57c \ud55c\ub2e4(README \ub3d9\uae30\ud654 \ucc45\uc784).\n//   n8n code \ub178\ub4dc\ub294 import \ubd88\uac00 \u2192 \ud568\uc218 \ubcf8\ubb38\uc744 \uc778\ub77c\uc778\ud55c\ub2e4.\n//   \ub2e8 \uc790\uce58\uad6c region \uc740 parsePlcyInfoDetail \uc758 ctx.region passthrough \ub300\uc2e0\n//   regionFromDistrictTitle(title) \ub85c \ub36e\uc5b4\uc4f4\ub2e4(\ubbf8\ub7ec\uc758 \ub3d9\uba85 \ud5ec\ud37c\uc640 \ub3d9\uae30\ud654).\nconst plcyBizId = $('\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac').first().json.plcyBizId;\nconst init = $('\ud398\uc774\uc9c0 \ucd08\uae30\ud654').first().json;\nconst sourceUrl = init.detailBase + '?plcyBizId=' + plcyBizId + init.detailSuffix;\nconst rawHtml = $input.first().json.data || $input.first().json.body || '';\n// region: \uc790\uce58\uad6c\ub294 \ud30c\uc2f1\ub41c title \ub05d \uad04\ud638 (\u25cb\u25cb\uad6c) \uc5d0\uc11c \ucd94\ucd9c(footer \uc8fc\uc18c \uc624\ub9e4\uce6d \uae08\uc9c0). \ud3f4\ubc31 = \uc11c\uc6b8\ud2b9\ubcc4\uc2dc.\n//   title \uc740 \ud30c\uc11c\uac00 \ucd94\ucd9c\ud558\ubbc0\ub85c region \uc740 \ud30c\uc2f1 \ub4a4\uc5d0 \uacb0\uc815\ud55c\ub2e4(\uc544\ub798 \ubcf8\ubb38 \ub05d \ucc38\uc870).\nconst SEOUL_GU = ['\uc885\ub85c\uad6c','\uc911\uad6c','\uc6a9\uc0b0\uad6c','\uc131\ub3d9\uad6c','\uad11\uc9c4\uad6c','\ub3d9\ub300\ubb38\uad6c','\uc911\ub791\uad6c','\uc131\ubd81\uad6c','\uac15\ubd81\uad6c','\ub3c4\ubd09\uad6c','\ub178\uc6d0\uad6c','\uc740\ud3c9\uad6c','\uc11c\ub300\ubb38\uad6c','\ub9c8\ud3ec\uad6c','\uc591\ucc9c\uad6c','\uac15\uc11c\uad6c','\uad6c\ub85c\uad6c','\uae08\ucc9c\uad6c','\uc601\ub4f1\ud3ec\uad6c','\ub3d9\uc791\uad6c','\uad00\uc545\uad6c','\uc11c\ucd08\uad6c','\uac15\ub0a8\uad6c','\uc1a1\ud30c\uad6c','\uac15\ub3d9\uad6c'];\nfunction regionFromDistrictTitle(t){ const s=String(t||''); const paren=s.match(/\\(([\uac00-\ud7a3]+\uad6c)\\)/); if(paren && SEOUL_GU.includes(paren[1])) return paren[1]; if(/\\(\uc6b0\\)|\\d{5}/.test(s)) return '\uc11c\uc6b8\ud2b9\ubcc4\uc2dc'; for(const tok of s.split(/\\s+/)){ if(SEOUL_GU.includes(tok)) return tok; } return '\uc11c\uc6b8\ud2b9\ubcc4\uc2dc'; }\n\nconst REF_LABELS = ['\uad00\ub828 \uc0ac\uc774\ud2b8', '\uc2e0\uccad \uc0ac\uc774\ud2b8', '\ucc38\uace0 \uc0ac\uc774\ud2b8 \u2160', '\ucc38\uace0 \uc0ac\uc774\ud2b8 \u2161'];\nconst QUAL_LABELS = ['\uc5f0\ub839', '\ucc38\uc5ec\uc694\uac74', '\ud559\ub825', '\uc804\uacf5\uc694\uac74', '\ucde8\uc5c5\uc0c1\ud0dc', '\ud2b9\ud654\ubd84\uc57c \uc694\uac74', '\ucd94\uac00\ub2e8\uc11c \uc0ac\ud56d', '\ucc38\uc5ec\uc81c\ud55c \ub300\uc0c1'];\n\nconst BR_SENTINEL = ' BR ';\n\nfunction escapeRegExp(s) {\n  return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\nfunction decodeEntities(text) {\n  return String(text)\n    .replace(/&lt;/g, '<')\n    .replace(/&gt;/g, '>')\n    .replace(/&quot;/g, '\"')\n    .replace(/&#0?39;/g, \"'\")\n    .replace(/&#x27;/gi, \"'\")\n    .replace(/&apos;/g, \"'\")\n    .replace(/&nbsp;/g, ' ')\n    .replace(/&amp;/g, '&');\n}\nfunction decodeAndStrip(htmlFragment) {\n  let s = String(htmlFragment);\n  s = s.replace(/<br\\s*\\/?>/gi, BR_SENTINEL);\n  s = s.replace(/<[^>]+>/g, '');\n  s = decodeEntities(s);\n  return s;\n}\nfunction normalizeWhitespace(text) {\n  return String(text)\n    .replace(/\\s+/g, ' ')\n    .split(BR_SENTINEL)\n    .map((line) => line.trim())\n    .filter((line) => line.length > 0)\n    .join('\\n')\n    .trim();\n}\nfunction stripComments(html) {\n  return String(html).replace(/<!--[\\s\\S]*?-->/g, '');\n}\nfunction thValueRawHtml(html, label) {\n  const re = new RegExp(\n    '<th\\\\s+scope=\"row\">\\\\s*' + escapeRegExp(label) + '\\\\s*</th>\\\\s*<td[^>]*>([\\\\s\\\\S]*?)</td>'\n  );\n  const m = html.match(re);\n  return m ? m[1] : '';\n}\nfunction thValueRaw(html, label) {\n  return decodeAndStrip(thValueRawHtml(html, label));\n}\nfunction thValue(html, label) {\n  return normalizeWhitespace(thValueRaw(html, label));\n}\nfunction thLink(html, label) {\n  const inner = thValueRawHtml(html, label);\n  const m = inner.match(/href=\"([^\"]*)\"/);\n  if (!m) return '';\n  const href = decodeEntities(m[1]).trim();\n  if (!href || href.startsWith('javascript:') || href === '#') return '';\n  return href;\n}\nfunction extractTitle(html) {\n  const m = html.match(/<strong\\s+class=\"title\">([\\s\\S]*?)<\\/strong>/);\n  return m ? normalizeWhitespace(decodeAndStrip(m[1])) : '';\n}\nfunction absolutize(href, baseUrl) {\n  if (!href) return '';\n  try { return new URL(href, baseUrl).href; } catch (e) { return href; }\n}\nfunction extractAttachments(html, srcUrl) {\n  const headIdx = html.indexOf('<th scope=\"col\">\ud30c\uc77c \uba85</th>');\n  if (headIdx < 0) return [];\n  const tableEnd = html.indexOf('</table>', headIdx);\n  const region = tableEnd < 0 ? html.slice(headIdx) : html.slice(headIdx, tableEnd);\n  const out = [];\n  const re = /<a[^>]*href=\"([^\"]*)\"[^>]*>([\\s\\S]*?)<\\/a>/g;\n  let m;\n  while ((m = re.exec(region)) !== null) {\n    const href = decodeEntities(m[1]).trim();\n    if (!href || href.startsWith('javascript:') || href === '#') continue;\n    const name = normalizeWhitespace(decodeAndStrip(m[2]));\n    out.push({ name, url: absolutize(href, srcUrl) });\n  }\n  return out;\n}\nfunction pad2(n) { return String(n).padStart(2, '0'); }\nfunction extractDate(s) {\n  if (!s) return null;\n  const str = String(s);\n  let m = str.match(/(\\d{4})[.\\-/](\\d{1,2})[.\\-/](\\d{1,2})/);\n  if (m) return m[1] + '-' + pad2(m[2]) + '-' + pad2(m[3]);\n  m = str.match(/(\\d{4})(\\d{2})(\\d{2})/);\n  if (m) return m[1] + '-' + m[2] + '-' + m[3];\n  return null;\n}\nfunction parsePeriod(raw) {\n  const text = String(raw || '').trim();\n  if (!text) return { start: null, end: null };\n  const parts = text.split('~');\n  const start = extractDate(parts[0]);\n  const end = parts.length > 1 ? extractDate(parts[1]) : null;\n  return { start, end };\n}\nfunction joinNonEmpty(values, separator) {\n  return values.filter(Boolean).join(separator);\n}\n\n// --- \ubcf8\ubb38 ---\nconst doc = stripComments(rawHtml);\nconst title = extractTitle(doc);\nconst body = joinNonEmpty([thValue(doc, '\uc815\ucc45 \uc18c\uac1c'), thValue(doc, '\uc9c0\uc6d0 \ub0b4\uc6a9')], '\\n\\n');\nconst additionalQualification = QUAL_LABELS.map((label) => {\n  const value = thValue(doc, label);\n  return value ? (label + ': ' + value) : '';\n}).filter(Boolean).join('\\n');\nconst applyPeriod = thValueRaw(doc, '\uc0ac\uc5c5\uc2e0\uccad\uae30\uac04'); // \"\uc0ac\uc5c5\uc6b4\uc601\uae30\uac04\"\uc740 \uc2e0\uccad\uae30\uac04 \uc544\ub2d8 \u2192 \uc0ac\uc6a9 \uc548 \ud568\nconst period = parsePeriod(applyPeriod);\nconst applyUrl = thLink(doc, '\uc2e0\uccad \uc0ac\uc774\ud2b8');\nconst refUrls = [];\nfor (const label of REF_LABELS) {\n  const url = thLink(doc, label);\n  if (url && !refUrls.includes(url)) refUrls.push(url);\n  if (refUrls.length >= 3) break;\n}\nconst selfAttachments = extractAttachments(doc, sourceUrl);\n\nconst rawData = {\n  externalId: plcyBizId,\n  title,\n  body,\n  category: '\ubcf5\uc9c0', // \ubc31\uc5d4\ub4dc mapCategory \uac00 \ubcf8\ubb38/\ud0dc\uadf8\ub85c \uc7ac\ubd84\ub958\n  region: regionFromDistrictTitle(title), // \uc81c\ubaa9 \ub05d (\u25cb\u25cb\uad6c) \ucd94\ucd9c, \uc5c6\uc73c\uba74 \uc11c\uc6b8\ud2b9\ubcc4\uc2dc\n  additionalQualification, // support_target \u2192 \uc801\ud569\ub3c4 \ub8f0 \uc0dd\uc131\n  applyStart: period.start,\n  applyEnd: period.end,\n  applyUrl,\n  _refUrls: refUrls,\n  _selfAttachments: selfAttachments\n};\n\nreturn [{ json: { rawData, _sourceUrl: sourceUrl } }];"
      },
      "id": "parse-detail",
      "name": "\uc0c1\uc138 \ub370\uc774\ud130 \ud30c\uc2f1",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2640,
        0
      ]
    },
    {
      "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": "\ucc38\uace0\uc0ac\uc774\ud2b8 fetch + \uba38\uc9c0",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        0
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const p = $input.first().json;\nconst e = {\n  sourceUrl: (p.rawData._refUrls && p.rawData._refUrls[0]) || null,\n  fetchedAt: new Date().toISOString().replace('Z', ''),\n  extractor: 'regex',\n  confidence: null,\n  status: p._enrichmentStatus === undefined ? 'FETCH_FAILED' : (p._enrichmentStatus ?? 'OK'),\n  sections: null,\n  extraAttachments: p._extraAttachments || [],\n  cleanedText: p._cleanedText || null,\n  fetchDiagnostics: p._fetchDiagnostics || [],\n};\nreturn [{ json: { ...p, rawData: { ...p.rawData, enrichment: e } } }];"
      },
      "id": "enrichment-meta",
      "name": "enrichment \uba54\ud0c0 \ud569\uc131",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3080,
        0
      ]
    },
    {
      "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 enrichmentExtras = Array.isArray(enrichment?.extraAttachments) ? enrichment.extraAttachments : [];\nconst selfExtras = Array.isArray(input?.rawData?._selfAttachments) ? input.rawData._selfAttachments : [];\nconst extras = [...enrichmentExtras, ...selfExtras];\nif (extras.length === 0) {\n  const { _selfAttachments, _refUrls, ...cleanRaw } = input.rawData || {};\n  return [{ json: { ...input, rawData: cleanRaw } }];\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}\nconst { _selfAttachments, _refUrls, ...cleanRaw } = input.rawData;\nreturn [{ json: { ...input, rawData: { ...cleanRaw, attachments: merged } } }];"
      },
      "id": "promote-attachments",
      "name": "attachments \uc2b9\uaca9",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3300,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// source type \uc815\ub82c: \ucd5c\uc885 payload \uc758 source \uba54\ud0c0\ub97c YOUTH_SEOUL_CRAWL \ub85c \ud655\uc815.\nconst p = $input.first().json;\nconst sourceUrl = p._sourceUrl || (p.rawData && p.rawData.applyUrl) || null;\nconst { _sourceUrl, ...rest } = p;\nreturn [{ json: {\n  ...rest,\n  source: {\n    type: 'YOUTH_SEOUL_CRAWL',\n    url: sourceUrl,\n    fetchedAt: new Date().toISOString().replace('Z', '')\n  }\n} }];"
      },
      "id": "build-source",
      "name": "source \uba54\ud0c0 \uc815\ub82c",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3520,
        0
      ]
    },
    {
      "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,
      "position": [
        3740,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "return [{ json: { message: '\ud06c\ub864\ub9c1 \uc644\ub8cc', completedAt: new Date().toISOString().replace('Z', '') } }];"
      },
      "id": "complete",
      "name": "\ud06c\ub864\ub9c1 \uc644\ub8cc",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2200,
        -200
      ]
    }
  ],
  "connections": {
    "\ub9e4\uc77c \uc0c8\ubcbd 3\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": "\ubaa9\ub85d \ud398\uc774\uc9c0 \uc694\uccad",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ubaa9\ub85d \ud398\uc774\uc9c0 \uc694\uccad": {
      "main": [
        [
          {
            "node": "plcyBizId \ucd94\ucd9c",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "plcyBizId \ucd94\ucd9c": {
      "main": [
        [
          {
            "node": "\ub2e4\uc74c \ud398\uc774\uc9c0 \ud655\uc778",
            "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": "\uc678\ubd80 hash \uc870\ud68c",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ub2e4\uc74c \ud398\uc774\uc9c0 \uc774\ub3d9": {
      "main": [
        [
          {
            "node": "\ubaa9\ub85d \ud398\uc774\uc9c0 \uc694\uccad",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\uc678\ubd80 hash \uc870\ud68c": {
      "main": [
        [
          {
            "node": "\uc2e0\uaddc plcyBizId \ud544\ud130",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\uc2e0\uaddc plcyBizId \ud544\ud130": {
      "main": [
        [
          {
            "node": "\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac": {
      "main": [
        [
          {
            "node": "\ud06c\ub864\ub9c1 \uc644\ub8cc",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\uc0c1\uc138 \ub300\uc0c1 \uc5ec\ubd80",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\uc0c1\uc138 \ub300\uc0c1 \uc5ec\ubd80": {
      "main": [
        [
          {
            "node": "3\ucd08 \ub300\uae30 (Rate Limit)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\uc815\ucc45\ubcc4 \uc21c\ucc28 \ucc98\ub9ac",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3\ucd08 \ub300\uae30 (Rate Limit)": {
      "main": [
        [
          {
            "node": "\uc0c1\uc138 \ud398\uc774\uc9c0 \uc694\uccad",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\uc0c1\uc138 \ud398\uc774\uc9c0 \uc694\uccad": {
      "main": [
        [
          {
            "node": "\uc0c1\uc138 \ub370\uc774\ud130 \ud30c\uc2f1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\uc0c1\uc138 \ub370\uc774\ud130 \ud30c\uc2f1": {
      "main": [
        [
          {
            "node": "\ucc38\uace0\uc0ac\uc774\ud2b8 fetch + \uba38\uc9c0",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ucc38\uace0\uc0ac\uc774\ud2b8 fetch + \uba38\uc9c0": {
      "main": [
        [
          {
            "node": "enrichment \uba54\ud0c0 \ud569\uc131",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "enrichment \uba54\ud0c0 \ud569\uc131": {
      "main": [
        [
          {
            "node": "attachments \uc2b9\uaca9",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "attachments \uc2b9\uaca9": {
      "main": [
        [
          {
            "node": "source \uba54\ud0c0 \uc815\ub82c",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "source \uba54\ud0c0 \uc815\ub82c": {
      "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
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "tags": [
    {
      "name": "crawling"
    },
    {
      "name": "youth-policy"
    }
  ]
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

youth-seoul-district. Uses httpRequest. Scheduled trigger; 21 nodes.

Source: https://github.com/TaetaetaE01/youthfit/blob/39a44a4b1cd69ed076fa61cb3b4f3a062af2edbe/n8n/workflows/youth-seoul-district.json — original creator credit. Request a take-down →

More Web Scraping workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Web Scraping

Birthday Automation - Production (Fixed). Uses stopAndError, httpRequest, emailSend, bannerbear. Scheduled trigger; 86 nodes.

Stop And Error, HTTP Request, Email Send +1
Web Scraping

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

Microsoft SharePoint, Microsoft Teams, Microsoft Entra +1
Web Scraping

Jira-Allure-Auto-Qa. Uses httpRequest, jira. Scheduled trigger; 68 nodes.

HTTP Request, Jira
Web Scraping

Spotify-Sync-Surrealdb-V1. Uses httpRequest, n8n-nodes-surrealdb, spotify. Scheduled trigger; 62 nodes.

HTTP Request, N8N Nodes Surrealdb, Spotify
Web Scraping

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

HTTP Request, n8n, N8N Trigger +1