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 →
{
"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(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/�?39;/g, \"'\")\n .replace(/'/gi, \"'\")\n .replace(/'/g, \"'\")\n .replace(/ /g, ' ')\n .replace(/&/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"
}
]
}
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Birthday Automation - Production (Fixed). Uses stopAndError, httpRequest, emailSend, bannerbear. Scheduled trigger; 86 nodes.
This template runs two scheduled workflows to govern Microsoft Entra ID (Azure AD) guest accounts by detecting stale users via Microsoft Graph, staging deletions in SharePoint with a 72-hour window, n
Jira-Allure-Auto-Qa. Uses httpRequest, jira. Scheduled trigger; 68 nodes.
Spotify-Sync-Surrealdb-V1. Uses httpRequest, n8n-nodes-surrealdb, spotify. Scheduled trigger; 62 nodes.
As n8n instances scale, teams often lose track of sub-workflows—who uses them, where they are referenced, and whether they can be safely updated. This leads to inefficiencies like unnecessary copies o