This workflow follows the HTTP Request → Postgres recipe pattern — see all workflows that pair these two integrations.
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": "2026-UP-Ambassador-v2",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9,13,17 * * 1-5"
}
]
}
},
"id": "a472da46-dc43-4cd1-8001-59959db0de42",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
240,
640
]
},
{
"parameters": {
"method": "GET",
"url": "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
},
{
"name": "Accept",
"value": "text/html,application/xhtml+xml"
},
{
"name": "Accept-Language",
"value": "ko-KR,ko;q=0.9"
},
{
"name": "Referer",
"value": "https://apply.lh.or.kr/lhapply/main.do"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "text"
}
},
"timeout": 30000,
"redirect": {
"redirect": {
"followRedirects": true
}
}
}
},
"id": "51df7a01-c20a-4aa8-88f3-dae903be4c8b",
"name": "LH \uacf5\uace0 \ubaa9\ub85d \uc218\uc9d1",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
464,
544
],
"onError": "continueErrorOutput",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// LH wrtanc \ubaa9\ub85d \ud398\uc774\uc9c0(\ubbf8=1026) HTML \ud30c\uc2f1\nconst response = $input.first()?.json;\nconst html = typeof response === 'string' ? response : (response?.data || response?.body || '');\nconst announcements = [];\n\nif (!html || html.includes('\uc624\ub958\uc54c\ub9bc')) {\n return [{ json: { source: 'LH', post_id: 'NONE', title: '[LH \uc694\uccad \uc2e4\ud328]', url: '', crawled_at: new Date().toISOString(), _error: 'LH \uc624\ub958 \ud398\uc774\uc9c0 \ubc18\ud658' } }];\n}\n\n// \ubaa9\ub85d \ud589\uc758 \ub9c1\ud06c \ud328\ud134: <a ... data-id1=\"panId\" data-id2=\"ccr\" data-id3=\"uppAis\" data-id4=\"ais\" class=\"wrtancInfoBtn\"><span>\uc81c\ubaa9</span>\nconst linkRegex = /<a[^>]*data-id1=\"([^\"]+)\"[^>]*data-id2=\"([^\"]+)\"[^>]*data-id3=\"([^\"]+)\"[^>]*data-id4=\"([^\"]+)\"[^>]*class=\"[^\"]*wrtancInfoBtn[^\"]*\"[^>]*>[\\s\\S]*?<span>([\\s\\S]*?)<\\/span>/gi;\nlet m;\nwhile ((m = linkRegex.exec(html)) !== null) {\n const panId = (m[1] || '').trim();\n const ccr = (m[2] || '').trim();\n const uppAis = (m[3] || '').trim();\n const ais = (m[4] || '').trim();\n const rawTitle = (m[5] || '').replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim();\n if (!panId || !rawTitle) continue;\n\n const detailUrl = `https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?ccrCnntSysDsCd=${encodeURIComponent(ccr)}&panId=${encodeURIComponent(panId)}&aisTpCd=${encodeURIComponent(ais)}&uppAisTpCd=${encodeURIComponent(uppAis)}&mi=1026`;\n\n announcements.push({\n source: 'LH',\n post_id: `LH_${panId}`,\n title: rawTitle,\n url: detailUrl,\n posted_at: '',\n crawled_at: new Date().toISOString(),\n });\n}\n\nif (announcements.length === 0) {\n return [{ json: { source: 'LH', post_id: 'NONE', title: '[LH \ud30c\uc2f1 \uc2e4\ud328]', url: '', crawled_at: new Date().toISOString(), _error: 'ZERO_RESULTS' } }];\n}\n\nreturn announcements.map((a) => ({ json: a }));\n"
},
"id": "03e08c2c-5c6a-4f6c-9fef-615d623044ea",
"name": "LH \ud30c\uc2f1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
688,
544
]
},
{
"parameters": {
"url": "https://www.i-sh.co.kr/app/lay2/program/S48T1581C563/www/brd/m_247/list.do?multi_itm_seq=2",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
},
{
"name": "Accept",
"value": "text/html,application/xhtml+xml"
},
{
"name": "Accept-Language",
"value": "ko-KR,ko;q=0.9"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "text"
}
},
"timeout": 30000,
"redirect": {
"redirect": {
"followRedirects": true
}
}
}
},
"id": "c4753279-8345-40e4-bfba-0c033f7bd207",
"name": "SH \uacf5\uace0 \ubaa9\ub85d \uc218\uc9d1",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
464,
736
],
"onError": "continueErrorOutput",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// SH \uc8fc\ud0dd\uc784\ub300 \uac8c\uc2dc\ud310 HTML \ud30c\uc2f1\n// \uacf5\uac1c URL: list.do (view.do\ub294 POST \uc804\uc6a9 \u2014 \ube0c\ub77c\uc6b0\uc800 \uc9c1\uc811 \ub9c1\ud06c \ubd88\uac00)\n// \ud06c\ub864\ub9c1: POST view.do + seq(detail_seq)\n\nconst items = $input.all();\nconst response = items[0]?.json;\n\nif (!response || response.error) {\n return [{ json: { source: 'SH', post_id: 'NONE', title: '[SH \uc694\uccad \uc2e4\ud328]', url: '', crawled_at: new Date().toISOString(), _error: response?.error?.message || 'HTTP \uc694\uccad \uc2e4\ud328' } }];\n}\n\nconst html = typeof response === 'string' ? response : (response.data || '');\nconst announcements = [];\nconst SH_LIST_URL = 'https://www.i-sh.co.kr/app/lay2/program/S48T1581C563/www/brd/m_247/list.do?multi_itm_seq=2';\n\nconst tbodyMatch = html.match(/<tbody[^>]*>([\\s\\S]*?)<\\/tbody>/i);\nif (tbodyMatch) {\n const tbody = tbodyMatch[1];\n const rowRegex = /<tr[^>]*>([\\s\\S]*?)<\\/tr>/gi;\n let rowMatch;\n\n while ((rowMatch = rowRegex.exec(tbody)) !== null) {\n const row = rowMatch[1];\n const detailMatch = row.match(/getDetailView\\(['\"](\\d+)['\"]\\)/i);\n if (!detailMatch) continue;\n\n const detailSeq = detailMatch[1];\n const tdRegex = /<td[^>]*>([\\s\\S]*?)<\\/td>/gi;\n const cells = [];\n let tdMatch;\n while ((tdMatch = tdRegex.exec(row)) !== null) {\n cells.push(tdMatch[1]);\n }\n if (cells.length < 4) continue;\n\n const boardNo = cells[0].replace(/<[^>]*>/g, '').trim();\n const titleHtml = cells[1];\n const title = titleHtml\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<[^>]*>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n const dept = cells[2].replace(/<[^>]*>/g, '').trim();\n const date = cells[3].replace(/<[^>]*>/g, '').trim();\n\n if (!/^\\d+$/.test(boardNo) || !title) continue;\n\n announcements.push({\n source: 'SH',\n post_id: 'SH_' + detailSeq,\n board_no: boardNo,\n detail_seq: detailSeq,\n title,\n url: SH_LIST_URL,\n department: dept,\n posted_at: date,\n crawled_at: new Date().toISOString(),\n });\n }\n}\n\nif (announcements.length === 0) {\n return [{ json: { source: 'SH', post_id: 'NONE', title: '[SH \ud30c\uc2f1 \uc2e4\ud328]', url: '', crawled_at: new Date().toISOString(), _error: 'ZERO_RESULTS \u2014 HTML \uad6c\uc870 \ubcc0\uacbd \uc758\uc2ec' } }];\n}\n\nreturn announcements.map((a) => ({ json: a }));"
},
"id": "cebc655a-2f6f-4daa-aec1-d7f17e4f5321",
"name": "SH HTML \ud30c\uc2f1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
688,
736
]
},
{
"parameters": {
"mode": "append",
"options": {}
},
"id": "ab2352fe-5b7a-495f-ad7a-46ed9ea37a84",
"name": "LH\u00b7SH \uacb0\uacfc \ud1b5\ud569",
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
912,
640
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// \ud30c\uc2f1 \uc2e4\ud328 \ud56d\ubaa9(post_id='NONE') \uc81c\uac70 \ud6c4 \uc720\ud6a8\ud55c \uacf5\uace0\ub9cc \ud1b5\uacfc\nconst items = $input.all();\nconst valid = items.filter(item => \n item.json.post_id && \n item.json.post_id !== 'NONE' && \n item.json.url\n);\n\nconst errors = items.filter(item => item.json._error).map(item => ({\n source: item.json.source,\n error: item.json._error\n}));\n\nif (errors.length > 0) {\n console.log('\uc218\uc9d1 \uc5d0\ub7ec:', JSON.stringify(errors));\n}\n\nif (valid.length === 0) {\n return [{ json: { _no_results: true, errors } }];\n}\n\nreturn valid;"
},
"id": "filter-valid-01",
"name": "\uc720\ud6a8 \uacf5\uace0 \ud544\ud130",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1020,
640
]
},
{
"parameters": {
"conditions": {
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json._no_results }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "notEquals"
}
}
]
},
"options": {}
},
"id": "check-has-results",
"name": "\uc2e0\uaddc \uacf5\uace0 \uc788\uc74c?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
1136,
640
]
},
{
"parameters": {
"options": {
"batchSize": 1
}
},
"id": "13281a62-c6b1-4aa4-ae38-d408c71b599e",
"name": "\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
1360,
640
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT\n COUNT(*)::int AS duplicate_count,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.source || '').replace(/'/g, \"''\") }}' AS source,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.post_id || '').replace(/'/g, \"''\") }}' AS post_id,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.detail_seq || '').replace(/'/g, \"''\") }}' AS detail_seq,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.board_no || '').replace(/'/g, \"''\") }}' AS board_no,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.title || '').replace(/'/g, \"''\") }}' AS title,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.url || '').replace(/'/g, \"''\") }}' AS url,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.department || '').replace(/'/g, \"''\") }}' AS department,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.posted_at || '').replace(/'/g, \"''\") }}' AS posted_at,\n '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.crawled_at || '').replace(/'/g, \"''\") }}' AS crawled_at\nFROM announcements\nWHERE post_id = '{{ ($('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json.post_id || '').replace(/'/g, \"''\") }}'",
"options": {}
},
"id": "b8412f76-a386-4fe4-b661-ff2b3bfc7563",
"name": "DB \uc911\ubcf5 \ud655\uc778",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1584,
640
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ Number($json.duplicate_count || 0) < 1 }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "485e1915-6b6d-4740-899f-ffe461296669",
"name": "\uc2e0\uaddc \uacf5\uace0\uc778\uac00?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
1808,
640
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// LH: GET(latin1) / SH: POST view (form-urlencoded body)\nconst meta = $input.item.json;\nconst ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36';\nconst baseHeaders = {\n 'User-Agent': ua,\n 'Accept-Language': 'ko-KR,ko;q=0.9',\n};\n\nlet data = '';\n\ntry {\n if (meta.source === 'SH') {\n const listUrl = 'https://www.i-sh.co.kr/app/lay2/program/S48T1581C563/www/brd/m_247/list.do?multi_itm_seq=2';\n const viewUrl = 'https://www.i-sh.co.kr/app/lay2/program/S48T1581C563/www/brd/m_247/view.do'; // POST \uc804\uc6a9 (meta.url\uc740 list.do \uacf5\uac1c URL)\n const detailSeq = String(meta.detail_seq || '').trim()\n || String(meta.post_id || '').replace(/^SH_/i, '');\n\n const listRes = await this.helpers.httpRequest({\n method: 'GET',\n url: listUrl,\n headers: { ...baseHeaders, Referer: listUrl },\n returnFullResponse: true,\n });\n\n const rawCookies = listRes.headers?.['set-cookie'] || listRes.headers?.['Set-Cookie'] || [];\n const cookieHeader = (Array.isArray(rawCookies) ? rawCookies : [rawCookies])\n .filter(Boolean)\n .map((c) => String(c).split(';')[0])\n .join('; ');\n\n const postHeaders = {\n ...baseHeaders,\n Referer: listUrl,\n 'content-type': 'application/x-www-form-urlencoded',\n };\n if (cookieHeader) postHeaders.Cookie = cookieHeader;\n\n data = await this.helpers.httpRequest({\n method: 'POST',\n url: viewUrl,\n headers: postHeaders,\n body: {\n seq: detailSeq,\n multi_itm_seq: '2',\n page: '1',\n multi_itm_seqsStr: '',\n },\n });\n\n const text = String(data || '');\n if (!text.includes('detailTable') && !text.includes('initParam.downList')) {\n throw new Error(`SH view body missing (${text.length} bytes)`);\n }\n } else {\n data = await this.helpers.httpRequest({\n method: 'GET',\n url: meta.url,\n headers: baseHeaders,\n encoding: 'latin1',\n });\n }\n} catch (e) {\n return {\n json: {\n ...meta,\n data: '',\n _fetch_error: e.message || String(e),\n },\n };\n}\n\nreturn {\n json: {\n ...meta,\n data,\n },\n};"
},
"id": "ce67c378-afc7-47cc-b069-a331dd37c192",
"name": "\uacf5\uace0 \uc0c1\uc138 \uc218\uc9d1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2032,
544
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "\nfunction stripScriptsAndStyles(html) {\n return String(html || '')\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n .replace(/<noscript[\\s\\S]*?<\\/noscript>/gi, ' ')\n .replace(/<!--[\\s\\S]*?-->/g, ' ');\n}\n\nfunction pickHtmlBlock(html, patterns) {\n for (const re of patterns) {\n const m = html.match(re);\n if (m && m[1] && m[1].length > 300) return m[1];\n }\n return null;\n}\n\nfunction extractMainHtml(html, meta) {\n const source = String(meta?.source || '').toUpperCase();\n const url = String(meta?.url || meta?.detail_url || '');\n let chunk = html;\n\n if (source === 'SH' || /i-sh\\.co\\.kr/i.test(url)) {\n chunk = pickHtmlBlock(html, [\n /<div[^>]*class=[\"'][^\"']*detailTable[^\"']*firgs0401Table[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*(?:<div[^>]*class=[\"']detailTable|<div[^>]*id=[\"']hwpEditor)/i,\n /<div[^>]*class=[\"'][^\"']*detailTable[^\"']*gs0401Table[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>/i,\n /<div[^>]*class=[\"'][^\"']*board_view[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/div>/i,\n /<div[^>]*class=[\"'][^\"']*view_cont[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>/i,\n /<div[^>]*class=[\"']contents[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/div>\\s*<\\/div>/i,\n /<div[^>]*class=[\"'][^\"']*contents[\"'][^>]*>([\\s\\S]*?)<\\/div>/i,\n ]) || html;\n chunk = chunk\n .replace(/<div[^>]*id=[\"']hwpEditorBoardContent[\"'][^>]*>[\\s\\S]*?<\\/div>/gi, ' ')\n .replace(/<!--\\[data-hwpjson\\][\\s\\S]*?-->/gi, ' ');\n } else if (source === 'LH' || /apply\\.lh\\.or\\.kr/i.test(url)) {\n chunk = pickHtmlBlock(html, [\n /<div[^>]*id=[\"']sub_container[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/footer>/i,\n /<div[^>]*class=[\"'][^\"']*sub_container[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/footer>/i,\n /<div[^>]*class=[\"'][^\"']*wrtanc[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>/i,\n ]) || html;\n }\n\n return stripScriptsAndStyles(chunk);\n}\n\nfunction decodeHtmlEntities(text) {\n return String(text || '')\n .replace(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/</gi, '<')\n .replace(/>/gi, '>')\n .replace(/"/gi, '\"')\n .replace(/'/gi, \"'\")\n .replace(/&#(\\d+);/g, (_, n) => String.fromCharCode(Number(n)))\n .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));\n}\n\nfunction stripResidualHtmlTags(text) {\n let s = decodeHtmlEntities(String(text || ''));\n for (let i = 0; i < 3; i++) {\n s = s\n .replace(/<\\/?(?:table|thead|tbody|tfoot|tr|td|th|col|colgroup)[^>]*>/gi, '\\n')\n .replace(/<\\/?(?:div|span|p|a|img|ul|ol|li|h[1-6]|strong|em|b|i|u|sup|sub|font)[^>]*>/gi, ' ')\n .replace(/<br\\s*\\/?>/gi, '\\n')\n .replace(/<[^>]+>/g, ' ');\n }\n return s;\n}\n\nfunction normalizeWhitespace(text) {\n return String(text || '')\n .replace(/[ \\t]+\\n/g, '\\n')\n .replace(/\\n[ \\t]+/g, '\\n')\n .replace(/[ \\t]{2,}/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nfunction cellInnerText(html) {\n return normalizeWhitespace(stripResidualHtmlTags(convertTables(String(html || ''))));\n}\n\nfunction tableBodyToMarkdown(body) {\n const rows = [];\n const trRe = /<tr[^>]*>([\\s\\S]*?)<\\/tr>/gi;\n let trMatch;\n while ((trMatch = trRe.exec(body)) !== null) {\n const cells = [];\n const cellRe = /<t(?:d|h)[^>]*>([\\s\\S]*?)<\\/t(?:d|h)>/gi;\n let cMatch;\n while ((cMatch = cellRe.exec(trMatch[1])) !== null) {\n cells.push(cellInnerText(cMatch[1]));\n }\n if (cells.length) rows.push(cells);\n }\n if (!rows.length) return '\\n';\n const colCount = Math.max(...rows.map((r) => r.length));\n const norm = rows.map((r) => {\n const copy = r.slice();\n while (copy.length < colCount) copy.push('');\n return copy;\n });\n const esc = (v) => String(v).replace(/\\|/g, '\\\\|').replace(/\\s+/g, ' ').trim();\n const lines = norm.map((r) => '| ' + r.map(esc).join(' | ') + ' |');\n if (lines.length >= 1) {\n lines.splice(1, 0, '| ' + norm[0].map(() => '---').join(' | ') + ' |');\n }\n return '\\n' + lines.join('\\n') + '\\n';\n}\n\nfunction convertTables(html) {\n let s = String(html || '');\n let prev = '';\n let guard = 0;\n while (prev !== s && guard < 50) {\n guard += 1;\n prev = s;\n s = s.replace(/<table[^>]*>([\\s\\S]*?)<\\/table>/gi, (_, body) => tableBodyToMarkdown(body));\n }\n return s;\n}\n\nfunction htmlToText(html) {\n let s = String(html || '');\n s = s\n .replace(/<h1[^>]*>([\\s\\S]*?)<\\/h1>/gi, (_, t) => '\\n# ' + cellInnerText(t) + '\\n')\n .replace(/<h2[^>]*>([\\s\\S]*?)<\\/h2>/gi, (_, t) => '\\n## ' + cellInnerText(t) + '\\n')\n .replace(/<h3[^>]*>([\\s\\S]*?)<\\/h3>/gi, (_, t) => '\\n### ' + cellInnerText(t) + '\\n')\n .replace(/<h4[^>]*>([\\s\\S]*?)<\\/h4>/gi, (_, t) => '\\n#### ' + cellInnerText(t) + '\\n')\n .replace(/<h5[^>]*>([\\s\\S]*?)<\\/h5>/gi, (_, t) => '\\n##### ' + cellInnerText(t) + '\\n')\n .replace(/<h6[^>]*>([\\s\\S]*?)<\\/h6>/gi, (_, t) => '\\n###### ' + cellInnerText(t) + '\\n');\n s = convertTables(s);\n s = s\n .replace(/<br\\s*\\/?>/gi, '\\n')\n .replace(/<\\/p>/gi, '\\n')\n .replace(/<\\/tr>/gi, '\\n')\n .replace(/<\\/li>/gi, '\\n')\n .replace(/<li[^>]*>/gi, '- ');\n s = stripResidualHtmlTags(s);\n return normalizeWhitespace(s);\n}\n\nfunction sanitizeMarkdown(text) {\n return normalizeWhitespace(stripResidualHtmlTags(String(text || '')));\n}\n\nfunction isNoiseLine(line) {\n const t = line.trim();\n if (!t || t.length < 2) return true;\n if (t.length > 600) return true;\n if (/badWord\\s*=\\s*\\[/.test(t)) return true;\n if (/^\\$\\(|^function\\s+\\w+\\s*\\(|^\\s*var\\s+\\w+\\s*=\\s*\\[/.test(t)) return true;\n if (/\"(?:\\d+\uc0c8|asshole|bitch|\uc528\ubc1c|\uc2dc\ubc1c)\"/i.test(t)) return true;\n if (/^(\uba54\ub274|\ubc14\ub85c\uac00\uae30|Copyright|\uac1c\uc778\uc815\ubcf4\ucc98\ub9ac\ubc29\uce68|\uc774\uc6a9\uc57d\uad00|\ud328\ubc00\ub9ac\uc0ac\uc774\ud2b8)\\b/.test(t) && t.length < 100) return true;\n if (/\uc6f9\uc811\uadfc\uc131|\uc13c\uc2a4\ub9ac\ub354|\uac00\uc0c1\ucee4\uc11c|NetFunnel|document\\.cookie/.test(t)) return true;\n if (/^(\uccad\uc57d|\uc784\ub300\uc8fc\ud0dd|\ubd84\uc591\uc8fc\ud0dd|\ud1a0\uc9c0|\uc0c1\uac00)\\s+(\uacf5\uace0\ubb38|\uccad\uc57d\uc2e0\uccad)/.test(t) && t.length < 40) return true;\n return false;\n}\n\nfunction dropNoiseLines(text) {\n return text\n .split('\\n')\n .map((l) => l.trim())\n .filter((l) => !isNoiseLine(l))\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nfunction cleanAnnouncementHtml(html, meta) {\n const mainHtml = extractMainHtml(html, meta);\n let text = sanitizeMarkdown(dropNoiseLines(htmlToText(mainHtml)));\n const maxLen = 60000;\n if (text.length > maxLen) text = text.slice(0, maxLen);\n return { text, mainHtml };\n}\n\n// \uc0c1\uc138 \ud398\uc774\uc9c0: PDF \ub9c1\ud06c \ucd94\ucd9c + charset \uc815\uaddc\ud654 + RAG\uc6a9 \ubcf8\ubb38 \uc815\uc81c\nlet meta = {};\ntry { meta = $('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json || {}; } catch (_) {}\nconst response = $input.item.json;\nconst original = meta.post_id ? meta : (response._original || response);\nconst raw = typeof response === 'string'\n ? response\n : (response.data || response.body || response.html_content || '');\n\nfunction countHangul(s) {\n return (String(s).match(/[\\uAC00-\\uD7A3]/g) || []).length;\n}\n\nfunction countCtrl(s) {\n return (String(s).match(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g) || []).length;\n}\n\nfunction isReadableHtml(text) {\n const s = String(text || '');\n return countHangul(s) >= 20 && countCtrl(s) === 0 && /<html/i.test(s);\n}\n\nfunction decodeHtml(rawStr) {\n if (!rawStr) return '';\n const str = String(rawStr);\n\n // n8n HTTP(text) may already return decoded UTF-8 \u2014 do not latin1 round-trip\n if (isReadableHtml(str)) return str;\n\n const buf = Buffer.from(str, 'latin1');\n const head = buf.slice(0, 8192).toString('latin1');\n const m = head.match(/charset\\s*=\\s*[\"']?([^\"';\\s>]+)/i);\n let enc = (m && m[1]) ? String(m[1]).toLowerCase().replace(/_/g, '-') : 'utf-8';\n const tryUtf8 = () => {\n const text = buf.toString('utf8');\n return { text, hangul: countHangul(text), bad: (text.match(/\\uFFFD/g) || []).length };\n };\n const tryEucKr = () => {\n try {\n const text = new TextDecoder('euc-kr').decode(buf);\n return { text, hangul: countHangul(text), bad: (text.match(/\\uFFFD/g) || []).length };\n } catch (_) {\n return { text: '', hangul: 0, bad: 0 };\n }\n };\n\n if (enc.includes('euc') || enc.includes('949') || enc === 'ks-c-5601-1987') {\n const cp = tryEucKr();\n if (cp.hangul >= 3 && cp.bad === 0) return cp.text;\n return tryUtf8().text || cp.text;\n }\n const u = tryUtf8();\n if (u.hangul >= 3 && u.bad === 0) return u.text;\n const cp = tryEucKr();\n if (cp.hangul > u.hangul) return cp.text;\n return u.text || cp.text || str;\n}\n\nconst htmlRaw = decodeHtml(raw);\nconst cleaned = cleanAnnouncementHtml(htmlRaw, original);\nconst baseOrigin = (original.url || original.detail_url || 'https://apply.lh.or.kr')\n .split('/').slice(0, 3).join('/');\n\nfunction toAbsoluteUrl(path) {\n if (!path) return '';\n if (/^https?:\\/\\//i.test(path)) return path;\n if (/^\\d+$/.test(path)) return `${baseOrigin}/lhapply/lhFile.do?fileid=${path}`;\n const p = path.startsWith('/') ? path : `/${path}`;\n return `${baseOrigin}${p}`;\n}\n\nconst pdfUrls = [];\nconst lhAnchorRe = /<a[^>]*href=[\"']javascript:fileDownLoad\\(['\"](\\d+)['\"]\\)[^>]*>([^<]*)<\\/a>/gi;\nlet lhMatch;\nwhile ((lhMatch = lhAnchorRe.exec(htmlRaw)) !== null) {\n const url = toAbsoluteUrl(lhMatch[1]);\n const label = String(lhMatch[2] || '').toLowerCase();\n if (label.includes('.pdf') && !pdfUrls.includes(url)) pdfUrls.push(url);\n}\nif (pdfUrls.length === 0) {\n const lhIdRe = /fileDownLoad\\(['\"](\\d+)['\"]\\)/gi;\n while ((lhMatch = lhIdRe.exec(htmlRaw)) !== null) {\n const url = toAbsoluteUrl(lhMatch[1]);\n if (!pdfUrls.includes(url)) pdfUrls.push(url);\n }\n}\n\nconst pdfPatterns = [\n /<a[^>]*href=[\"']([^\"']*\\.pdf[^\"']*)[\"'][^>]*>/gi,\n /<a[^>]*href=[\"']([^\"']*download[^\"']*)[\"'][^>]*>[^<]*\\.pdf/gi,\n /<a[^>]*href=[\"'](\\/[^\"']*fileDown[^\"']*)[\"'][^>]*>/gi,\n /<a[^>]*href=[\"']([^\"']*atchFile[^\"']*)[\"'][^>]*>/gi,\n /lhFile\\.do\\?fileid=\\d+/gi,\n];\n\nfor (const pattern of pdfPatterns) {\n let match;\n while ((match = pattern.exec(htmlRaw)) !== null) {\n const url = toAbsoluteUrl(match[0].startsWith('lhFile') ? `/${match[0]}` : match[1]);\n if (url && !pdfUrls.includes(url)) pdfUrls.push(url);\n }\n}\n\n\n// SH \ucca8\ubd80 PDF \uba54\ud0c0 (has_pdf \uc81c\uc678 \u2014 \ubcf8\ubb38\uc740 detailTable HTML)\nconst shPdfFiles = [];\nconst shSource = String(meta?.source || original?.source || '').toUpperCase();\nif (shSource === 'SH' || /i-sh\\.co\\.kr/i.test(String(meta?.url || original?.url || ''))) {\n const dlMatch = htmlRaw.match(/initParam\\.downList\\s*=\\s*(\\[[\\s\\S]*?\\]);/);\n if (dlMatch) {\n try {\n const files = JSON.parse(dlMatch[1]);\n for (const f of files) {\n if (/\\.pdf$/i.test(String(f.oriFileNm || ''))) {\n shPdfFiles.push({ name: f.oriFileNm, brdId: f.brdId, seq: f.seq, fileSeq: f.fileSeq });\n }\n }\n } catch (_) {}\n }\n}\n\nreturn {\n ...original,\n _original: original,\n pdf_urls: pdfUrls,\n pdf_url: pdfUrls.length > 0 ? pdfUrls[0] : '',\n html_content: cleaned.text,\n html_body_html: cleaned.mainHtml.slice(0, 80000),\n html_raw_length: htmlRaw.length,\n sh_pdf_files: shPdfFiles,\n has_pdf: pdfUrls.length > 0,\n};\n"
},
"id": "b431c42e-0a03-49b2-bfb0-80f61bbaa85e",
"name": "PDF \ub9c1\ud06c \ucd94\ucd9c",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2256,
544
]
},
{
"parameters": {
"conditions": {
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.has_pdf }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "pdf-exists-check",
"name": "PDF \uc874\uc7ac?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
2480,
544
]
},
{
"parameters": {
"url": "={{ $json.pdf_url }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "file",
"outputPropertyName": "pdf_data"
}
},
"timeout": 60000
}
},
"id": "53cfd503-1c21-45df-b05c-3188078f4c52",
"name": "PDF \ub2e4\uc6b4\ub85c\ub4dc",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2704,
448
],
"onError": "continueErrorOutput",
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 3000
},
{
"parameters": {
"method": "POST",
"url": "https://api.upstage.ai/v1/document-ai/document-parse",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "document",
"inputDataFieldName": "pdf_data"
},
{
"name": "ocr",
"value": "auto"
},
{
"name": "output_formats",
"value": "html,markdown"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 120000
}
},
"id": "542d9f05-c486-410c-8b7f-7e7aa9c4b0e4",
"name": "Upstage Document Parse",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2928,
448
],
"onError": "continueErrorOutput",
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://api.upstage.ai/v1/solar/chat/completions",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"solar-pro\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"\ub2f9\uc2e0\uc740 \uc8fc\ud0dd \uacf5\uace0 \ubb38\uc11c\uc5d0\uc11c \uad6c\uc870\ud654\ub41c \uc815\ubcf4\ub97c \ucd94\ucd9c\ud558\ub294 \uc804\ubb38\uac00\uc785\ub2c8\ub2e4. \uc8fc\uc5b4\uc9c4 \ubb38\uc11c\uc5d0\uc11c \uc544\ub798 JSON \uc2a4\ud0a4\ub9c8\uc5d0 \ub9de\ub294 \uc815\ubcf4\ub97c \ucd94\ucd9c\ud558\uc138\uc694. \ucc3e\uc744 \uc218 \uc5c6\ub294 \ud544\ub4dc\ub294 null\ub85c \uc124\uc815\ud558\uc138\uc694. \ubc18\ub4dc\uc2dc JSON\ub9cc \ucd9c\ub825\ud558\uc138\uc694.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"\ub2e4\uc74c \ubb38\uc11c\uc5d0\uc11c \uc815\ubcf4\ub97c \ucd94\ucd9c\ud558\uc138\uc694:\\\n\\\n{{ $json.content?.markdown || $json.content?.html || $json.html_content || '' }}\\\n\\\n\ucd94\ucd9c\ud560 JSON \uc2a4\ud0a4\ub9c8:\\\n{\\\n \\\"\uacf5\uace0\uba85\\\": \\\"string\\\",\\\n \\\"\uc2e0\uccad\uc2dc\uc791\uc77c\\\": \\\"YYYY-MM-DD\\\",\\\n \\\"\uc2e0\uccad\uc885\ub8cc\uc77c\\\": \\\"YYYY-MM-DD\\\",\\\n \\\"\uc785\uc8fc\uc608\uc815\uc77c\\\": \\\"string or null\\\",\\\n \\\"\uc2dc\ub3c4\\\": \\\"string (\uc608: \uc11c\uc6b8\ud2b9\ubcc4\uc2dc)\\\",\\\n \\\"\uad6c\uad70\\\": \\\"string (\uc608: \uac15\ub0a8\uad6c)\\\",\\\n \\\"\uc8fc\ud0dd\uc720\ud615\\\": \\\"\ud589\ubcf5\uc8fc\ud0dd|\uacf5\uacf5\uc784\ub300|\uad6d\ubbfc\uc784\ub300|\uc7a5\uae30\uc804\uc138|\ub9e4\uc785\uc784\ub300|\uccad\ub144\uc548\uc2ec\uc8fc\ud0dd|\uae30\ud0c0 \uc911 \ud558\ub098\\\",\\\n \\\"\uacf5\uae09\uc720\ud615\ubaa9\ub85d\\\": [{\\\"\uc720\ud615\\\": \\\"string\\\", \\\"\uc138\ub300\uc218\\\": \\\"number\\\", \\\"\uc804\uc6a9\uba74\uc801\\\": \\\"string\\\", \\\"\uc6d4\uc784\ub300\ub8cc\\\": \\\"string\\\"}],\\\n \\\"\uc2e0\uccad\uc790\uaca9_\ub098\uc774\ucd5c\uc18c\\\": \\\"number or null\\\",\\\n \\\"\uc2e0\uccad\uc790\uaca9_\ub098\uc774\ucd5c\ub300\\\": \\\"number or null\\\",\\\n \\\"\uc18c\ub4dd\uae30\uc900\\\": \\\"string or null\\\",\\\n \\\"\uc790\uc0b0\uae30\uc900\\\": \\\"string or null\\\",\\\n \\\"\uc81c\ucd9c\uc11c\ub958\ubaa9\ub85d\\\": [\\\"string\\\"],\\\n \\\"\uc2e0\uccadURL\\\": \\\"string or null\\\",\\\n \\\"\ub2f4\ub2f9\uc804\ud654\\\": \\\"string or null\\\",\\\n \\\"\ub2f4\ub2f9\ubd80\uc11c\\\": \\\"string or null\\\"\\\n}\"\n }\n ],\n \"temperature\": 0.1,\n \"max_tokens\": 2000,\n \"response_format\": { \"type\": \"json_object\" }\n}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 60000
}
},
"id": "cf56128e-eebb-418c-aeba-72406951c6da",
"name": "Solar LLM \uc815\ubcf4 \ucd94\ucd9c",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3152,
448
],
"onError": "continueErrorOutput",
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Solar LLM \uc751\ub2f5 + \uac15\ud654 \ud734\ub9ac\uc2a4\ud2f1 \ubcf4\uc815\uc73c\ub85c \uad6c\uc870\ud654\nlet original = {};\ntry { original = $('PDF \ub9c1\ud06c \ucd94\ucd9c').item.json._original || $('PDF \ub9c1\ud06c \ucd94\ucd9c').item.json || {}; } catch (_) {}\nif (!original || !original.post_id) {\n try { original = $('HTML \ubcf8\ubb38 \uc815\ub9ac').item.json._original || {}; } catch (_) {}\n}\nif (!original || !original.post_id) {\n try { original = $('\uacf5\uace0\ubcc4 \ucc98\ub9ac \ub8e8\ud504').item.json || {}; } catch (_) {}\n}\n\nlet pdfMeta = {};\ntry { pdfMeta = $('PDF \ub9c1\ud06c \ucd94\ucd9c').item.json || {}; } catch (_) {}\nlet parseResult = {};\nif (pdfMeta.has_pdf) {\n try { parseResult = $('Upstage Document Parse').item.json || {}; } catch (_) { parseResult = {}; }\n}\nlet htmlOnly = {};\ntry { htmlOnly = $('HTML \ubcf8\ubb38 \uc815\ub9ac').item.json || {}; } catch (_) { htmlOnly = {}; }\nconst llmResponse = $input.item.json || {};\n\nlet extracted = {};\ntry {\n const content = llmResponse.choices?.[0]?.message?.content || '{}';\n extracted = JSON.parse(String(content).replace(/```json|```/g, '').trim());\n} catch (_) {\n extracted = {};\n}\n\nconst textBlocks = [\n extracted.\uacf5\uace0\uba85,\n extracted.\uc2dc\ub3c4,\n extracted.\uc2e0\uccad\uc2dc\uc791\uc77c,\n extracted.\uc2e0\uccad\uc885\ub8cc\uc77c,\n original.title,\n original.region,\n parseResult?.content?.markdown,\n parseResult?.content?.html,\n original.html_content,\n original.title\n].filter(Boolean).map(v => String(v));\nconst baseText = textBlocks.join(String.fromCharCode(10));\n\n\nfunction countHangul(s) {\n return (String(s).match(/[\\uAC00-\\uD7A3]/g) || []).length;\n}\n\nfunction countCtrl(s) {\n return (String(s).match(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g) || []).length;\n}\n\nfunction isReadableHtml(text) {\n const s = String(text || '');\n return countHangul(s) >= 20 && countCtrl(s) === 0 && /<html/i.test(s);\n}\n\nconst safeStr = (v, maxLen = 100000) => {\n if (v == null) return '';\n return String(v)\n .replace(/\\0/g, '')\n .replace(/[\\u2028\\u2029]/g, ' ')\n .slice(0, maxLen);\n};\n\nconst safeJsonArr = (v) => {\n try {\n if (Array.isArray(v)) return JSON.stringify(v);\n if (typeof v === 'string' && v.trim()) {\n const p = JSON.parse(v);\n return JSON.stringify(Array.isArray(p) ? p : []);\n }\n } catch (_) {}\n return '[]';\n};\n\nconst isValidPgDate = (s) => {\n if (!s || !/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return false;\n const [y, mo, d] = s.split('-').map(Number);\n if (y < 1990 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) return false;\n const dt = new Date(`${s}T12:00:00Z`);\n return !Number.isNaN(dt.getTime());\n};\n\nconst normalizeDate = (dateStr) => {\n if (!dateStr) return null;\n const s = String(dateStr).trim();\n if (!s) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return isValidPgDate(s) ? s : null;\n\n let m = s.match(/^(\\d{2})[.\\-/](\\d{1,2})[.\\-/](\\d{1,2})$/);\n if (m) {\n const y = `20${m[1]}`;\n const out = `${y}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;\n return isValidPgDate(out) ? out : null;\n }\n\n m = s.match(/(20\\d{2}|\\d{2})[.\\-/\ub144\\s]*(\\d{1,2})[.\\-/\uc6d4\\s]*(\\d{1,2})/);\n if (!m) return null;\n\n const yRaw = m[1];\n const y = yRaw.length === 2 ? `20${yRaw}` : yRaw;\n const mo = m[2].padStart(2, '0');\n const d = m[3].padStart(2, '0');\n const out = `${y}-${mo}-${d}`;\n return isValidPgDate(out) ? out : null;\n};\n\nconst toDateCandidates = (text) => {\n const t = String(text || '');\n const out = [];\n\n const patterns = [\n /(20\\d{2}[.\\-/\ub144\\s]*\\d{1,2}[.\\-/\uc6d4\\s]*\\d{1,2})/g,\n /(\\d{2}[.\\-/]\\d{1,2}[.\\-/]\\d{1,2})/g,\n ];\n\n for (const p of patterns) {\n for (const m of t.matchAll(p)) {\n const d = normalizeDate(m[1]);\n if (d) out.push(d);\n }\n }\n\n return [...new Set(out)].sort();\n};\n\nconst inferDateRangeFromText = (text) => {\n const t = String(text || '');\n\n const rangeMatch = t.match(/((?:20\\d{2}|\\d{2})[.\\-/\ub144\\s]*\\d{1,2}[.\\-/\uc6d4\\s]*\\d{1,2})\\s*(?:~|\\-|\ubd80\ud130)\\s*((?:20\\d{2}|\\d{2})[.\\-/\ub144\\s]*\\d{1,2}[.\\-/\uc6d4\\s]*\\d{1,2})/);\n if (rangeMatch) {\n const s = normalizeDate(rangeMatch[1]);\n const e = normalizeDate(rangeMatch[2]);\n if (s || e) return { start: s || e, end: e || s };\n }\n\n const candidates = toDateCandidates(t);\n if (candidates.length === 0) return { start: null, end: null };\n if (candidates.length === 1) return { start: candidates[0], end: candidates[0] };\n\n return { start: candidates[0], end: candidates[1] || candidates[0] };\n};\n\nconst provinceMap = {\n '\uc11c\uc6b8': '\uc11c\uc6b8\ud2b9\ubcc4\uc2dc', '\uc11c\uc6b8\uc2dc': '\uc11c\uc6b8\ud2b9\ubcc4\uc2dc',\n '\ubd80\uc0b0': '\ubd80\uc0b0\uad11\uc5ed\uc2dc', '\ub300\uad6c': '\ub300\uad6c\uad11\uc5ed\uc2dc', '\uc778\ucc9c': '\uc778\ucc9c\uad11\uc5ed\uc2dc', '\uad11\uc8fc': '\uad11\uc8fc\uad11\uc5ed\uc2dc',\n '\ub300\uc804': '\ub300\uc804\uad11\uc5ed\uc2dc', '\uc6b8\uc0b0': '\uc6b8\uc0b0\uad11\uc5ed\uc2dc', '\uc138\uc885': '\uc138\uc885\ud2b9\ubcc4\uc790\uce58\uc2dc',\n '\uacbd\uae30': '\uacbd\uae30\ub3c4', '\uac15\uc6d0': '\uac15\uc6d0\ud2b9\ubcc4\uc790\uce58\ub3c4', '\ucda9\ubd81': '\ucda9\uccad\ubd81\ub3c4', '\ucda9\ub0a8': '\ucda9\uccad\ub0a8\ub3c4',\n '\uc804\ubd81': '\uc804\ubd81\ud2b9\ubcc4\uc790\uce58\ub3c4', '\uc804\ub0a8': '\uc804\ub77c\ub0a8\ub3c4', '\uacbd\ubd81': '\uacbd\uc0c1\ubd81\ub3c4', '\uacbd\ub0a8': '\uacbd\uc0c1\ub0a8\ub3c4',\n '\uc81c\uc8fc': '\uc81c\uc8fc\ud2b9\ubcc4\uc790\uce58\ub3c4'\n};\n\nconst inferRegion = (text, source) => {\n const t = String(text || '');\n\n for (const [k, v] of Object.entries(provinceMap)) {\n if (t.includes(k)) return v;\n }\n\n const cityToProvince = [\n ['\uc218\uc6d0', '\uacbd\uae30\ub3c4'], ['\uc131\ub0a8', '\uacbd\uae30\ub3c4'], ['\uace0\uc591', '\uacbd\uae30\ub3c4'], ['\uc6a9\uc778', '\uacbd\uae30\ub3c4'], ['\ubd80\ucc9c', '\uacbd\uae30\ub3c4'], ['\uc548\uc591', '\uacbd\uae30\ub3c4'], ['\ud30c\uc8fc', '\uacbd\uae30\ub3c4'], ['\uae40\ud3ec', '\uacbd\uae30\ub3c4'],\n ['\ucd98\ucc9c', '\uac15\uc6d0\ud2b9\ubcc4\uc790\uce58\ub3c4'], ['\uc6d0\uc8fc', '\uac15\uc6d0\ud2b9\ubcc4\uc790\uce58\ub3c4'], ['\uac15\ub989', '\uac15\uc6d0\ud2b9\ubcc4\uc790\uce58\ub3c4'], ['\ud3c9\ucc3d', '\uac15\uc6d0\ud2b9\ubcc4\uc790\uce58\ub3c4'], ['\ucca0\uc6d0', '\uac15\uc6d0\ud2b9\ubcc4\uc790\uce58\ub3c4'],\n ['\uccad\uc8fc', '\ucda9\uccad\ubd81\ub3c4'], ['\ucda9\uc8fc', '\ucda9\uccad\ubd81\ub3c4'], ['\uc81c\ucc9c', '\ucda9\uccad\ubd81\ub3c4'],\n ['\ucc9c\uc548', '\ucda9\uccad\ub0a8\ub3c4'], ['\uc544\uc0b0', '\ucda9\uccad\ub0a8\ub3c4'], ['\ub17c\uc0b0', '\ucda9\uccad\ub0a8\ub3c4'],\n ['\uc804\uc8fc', '\uc804\ubd81\ud2b9\ubcc4\uc790\uce58\ub3c4'], ['\uc775\uc0b0', '\uc804\ubd81\ud2b9\ubcc4\uc790\uce58\ub3c4'], ['\uc815\uc74d', '\uc804\ubd81\ud2b9\ubcc4\uc790\uce58\ub3c4'], ['\uae40\uc81c', '\uc804\ubd81\ud2b9\ubcc4\uc790\uce58\ub3c4'],\n ['\ubaa9\ud3ec', '\uc804\ub77c\ub0a8\ub3c4'], ['\uc21c\ucc9c', '\uc804\ub77c\ub0a8\ub3c4'], ['\uc5ec\uc218', '\uc804\ub77c\ub0a8\ub3c4'], ['\ud568\ud3c9', '\uc804\ub77c\ub0a8\ub3c4'],\n ['\ud3ec\ud56d', '\uacbd\uc0c1\ubd81\ub3c4'], ['\uad6c\ubbf8', '\uacbd\uc0c1\ubd81\ub3c4'], ['\uae40\ucc9c', '\uacbd\uc0c1\ubd81\ub3c4'], ['\uc548\ub3d9', '\uacbd\uc0c1\ubd81\ub3c4'], ['\uc0c1\uc8fc', '\uacbd\uc0c1\ubd81\ub3c4'], ['\uc758\uc131', '\uacbd\uc0c1\ubd81\ub3c4'],\n ['\ucc3d\uc6d0', '\uacbd\uc0c1\ub0a8\ub3c4'], ['\uc591\uc0b0', '\uacbd\uc0c1\ub0a8\ub3c4'], ['\ud1b5\uc601', '\uacbd\uc0c1\ub0a8\ub3c4'], ['\uac70\ucc3d', '\uacbd\uc0c1\ub0a8\ub3c4'], ['\uc0b0\uccad', '\uacbd\uc0c1\ub0a8\ub3c4'],\n ['\uad70\uc0b0', '\uc804\ubd81\ud2b9\ubcc4\uc790\uce58\ub3c4'], ['\uc6b8\uc0b0\ub2e4\uc6b4', '\uc6b8\uc0b0\uad11\uc5ed\uc2dc'], ['\ud0dc\ud654\uac15', '\uc6b8\uc0b0\uad11\uc5ed\uc2dc']\n ];\n\n for (const [city, prov] of cityToProvince) {\n if (t.includes(city)) return prov;\n }\n\n if (source === 'SH') return '\uc11c\uc6b8\ud2b9\ubcc4\uc2dc';\n return null;\n};\n\nconst title = extracted.\uacf5\uace0\uba85 || original.title || null;\n\n\nconst inferPeriodSentenceRange = (text) => {\n const t = String(text || '');\n const lines = t.split(/\\r?\\n/).map(v => v.trim()).filter(Boolean);\n\n const periodKeywords = ['\uc2e0\uccad\uae30\uac04', '\uc811\uc218\uae30\uac04', '\ubaa8\uc9d1\uae30\uac04', '\uccad\uc57d\uae30\uac04', '\uc2e0\uccad\uc77c\uc815', '\uc811\uc218\uc77c\uc815'];\n\n const lineHits = [];\n for (const line of lines) {\n if (periodKeywords.some(k => line.includes(k))) {\n lineHits.push(line);\n }\n }\n\n // Also try paragraph-level regex when line split is poor\n const paraRegex = /(\uc2e0\uccad\uae30\uac04|\uc811\uc218\uae30\uac04|\ubaa8\uc9d1\uae30\uac04|\uccad\uc57d\uae30\uac04|\uc2e0\uccad\uc77c\uc815|\uc811\uc218\uc77c\uc815)[^\\n]{0,120}/g;\n for (const m of t.matchAll(paraRegex)) {\n if (m[0]) lineHits.push(m[0]);\n }\n\n const uniqueHits = [...new Set(lineHits)];\n\n for (const hit of uniqueHits) {\n const rangeMatch = hit.match(/((?:20\\d{2}|\\d{2})[.\\-/\ub144\\s]*\\d{1,2}[.\\-/\uc6d4\\s]*\\d{1,2})\\s*(?:~|\\-|\ubd80\ud130|\uae4c\uc9c0)\\s*((?:20\\d{2}|\\d{2})[.\\-/\ub144\\s]*\\d{1,2}[.\\-/\uc6d4\\s]*\\d{1,2})/);\n if (rangeMatch) {\n const s = normalizeDate(rangeMatch[1]);\n const e = normalizeDate(rangeMatch[2]);\n if (s || e) return { start: s || e, end: e || s };\n }\n\n const dates = toDateCandidates(hit);\n if (dates.length >= 2) return { start: dates[0], end: dates[1] };\n if (dates.length === 1) return { start: dates[0], end: dates[0] };\n }\n\n return { start: null, end: null };\n};\n\nconst periodRange = inferPeriodSentenceRange(baseText);\n\nconst inferredRange = inferDateRangeFromText(baseText);\nconst appStart = normalizeDate(extracted.\uc2e0\uccad\uc2dc\uc791\uc77c) || periodRange.start || inferredRange.start;\nconst appEnd = normalizeDate(extracted.\uc2e0\uccad\uc885\ub8cc\uc77c) || periodRange.end || inferredRange.end;\nconst region = extracted.\uc2dc\ub3c4 || inferRegion(baseText, original.source);\n\nconst requiredChecks = {\n \uacf5\uace0\uba85: title,\n \uc2e0\uccad\uc2dc\uc791\uc77c: appStart,\n \uc2e0\uccad\uc885\ub8cc\uc77c: appEnd,\n \uc2dc\ub3c4: region,\n};\nconst missing = Object.entries(requiredChecks).filter(([, v]) => !v).map(([k]) => k);\n\nfunction sanitizeMarkdownForDb(text) {\n let s = String(text || '');\n for (let i = 0; i < 3; i++) {\n s = s\n .replace(/<\\/?(?:table|thead|tbody|tfoot|tr|td|th|col|colgroup)[^>]*>/gi, '\\n')\n .replace(/<\\/?(?:div|span|p|a|img|ul|ol|li|h[1-6]|strong|em|b|i|u)[^>]*>/gi, ' ')\n .replace(/<br\\s*\\/?>/gi, '\\n')\n .replace(/<[^>]+>/g, ' ')\n .replace(/ /gi, ' ')\n .replace(/</gi, '<')\n .replace(/>/gi, '>')\n .replace(/&/gi, '&');\n }\n return s\n .replace(/[ \\t]+\\n/g, '\\n')\n .replace(/\\n[ \\t]+/g, '\\n')\n .replace(/[ \\t]{2,}/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nconst _parsedHtml = parseResult.content?.html || htmlOnly.content?.html || original.html_body_html || '';\nconst _parsedMd = sanitizeMarkdownForDb(\n parseResult.content?.markdown || htmlOnly.content?.markdown || original.html_content || ''\n);\n\nreturn {\n source: safeStr(original.source, 20),\n post_id: safeStr(original.post_id, 80),\n title: safeStr(title, 500),\n housing_type: safeStr(extracted.\uc8fc\ud0dd\uc720\ud615, 100) || null,\n region: safeStr(region, 50) || null,\n district: safeStr(extracted.\uad6c\uad70, 80) || null,\n application_start: appStart,\n application_end: appEnd,\n move_in_date: safeStr(extracted.\uc785\uc8fc\uc608\uc815\uc77c, 80) || null,\n eligibility_age_min: extracted.\uc2e0\uccad\uc790\uaca9_\ub098\uc774\ucd5c\uc18c || null,\n eligibility_age_max: extracted.\uc2e0\uccad\uc790\uaca9_\ub098\uc774\ucd5c\ub300 || null,\n eligibility_income: safeStr(extracted.\uc18c\ub4dd\uae30\uc900, 500) || null,\n eligibility_asset: safeStr(extracted.\uc790\uc0b0\uae30\uc900, 500) || null,\n required_documents: safeJsonArr(extracted.\uc81c\ucd9c\uc11c\ub958\ubaa9\ub85d),\n supply_types: safeJsonArr(extracted.\uacf5\uae09\uc720\ud615\ubaa9\ub85d),\n application_url: safeStr(extracted.\uc2e0\uccadURL || original.url, 2000),\n contact_phone: safeStr(extracted.\ub2f4\ub2f9\uc804\ud654, 80) || null,\n contact_department: safeStr(extracted.\ub2f4\ub2f9\ubd80\uc11c || original.department, 200) || null,\n parsed_html: safeStr(_parsedHtml) || null,\n parsed_markdown: safeStr(_parsedMd) || null,\n detail_url: safeStr(original.url, 2000),\n pdf_url: safeStr(original.pdf_url, 2000) || null,\n parse_status: missing.length > 0 ? 'needs_review' : 'ok',\n missing_fields: missing.length > 0 ? safeStr(missing.join(','), 500) : null,\n crawled_at: original.crawled_at,\n created_at: new Date().toISOString()\n};"
},
"id": "01eef96b-ffc7-44de-829d-d11a913e3d7f",
"name": "\uc815\uaddc\ud654 \ubc0f \uac80\uc99d",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3376,
448
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.upstage.ai/v1/solar/embeddings",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"solar-embedding-1-large-passage\",\n \"input\": \"{{ ($json.title || '') + ' ' + ($json.region || '') + ' ' + ($json.district || '') + ' ' + ($json.housing_type || '') + ' ' + ($json.eligibility_income || '') }}\"\n}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"id": "2c58c84f-cf5b-40df-86ef-755fd220a8c3",
"name": "Upstage Embeddings",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3600,
448
],
"onError": "continueErrorOutput",
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// \uc784\ubca0\ub529 \uacb0\uacfc \ubcd1\ud569\nconst prev = $('\uc815\uaddc\ud654 \ubc0f \uac80\uc99d').item.json;\nconst embeddingResponse = $input.item.json;\n\nlet embedding = null;\ntry {\n // Upstage Embeddings \uc751\ub2f5: { data: [{ embedding: [...] }] }\n embedding = embeddingResponse.data?.[0]?.embedding || null;\n} catch {\n embedding = null;\n}\n\nreturn {\n ...prev,\n embedding: embedding\n};"
},
"id": "43e523ec-a07d-427d-b682-cd4e0c6a0428",
"name": "\uc784\ubca0\ub529 \ubcd1\ud569",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3824,
448
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO announcements (\n source, post_id, title, housing_type, region, district,\n application_start, application_end, move_in_date,\n eligibility_age_min, eligibility_age_max, eligibility_income, eligibility_asset,\n required_documents, supply_types, application_url, contact_phone, contact_department,\n parsed_html, parsed_markdown, detail_url, pdf_url, parse_status, missing_fields,\n embedding, crawled_at, created_at\n) VALUES (\n '{{ ($json.source || '').replace(/'/g, \"''\") }}',\n '{{ ($json.post_id || '').replace(/'/g, \"''\") }}',\n '{{ ($json.title || '').replace(/'/g, \"''\") }}',\n '{{ ($json.housing_type || '').replace(/'/g, \"''\") }}',\n '{{ ($json.region || '').replace(/'/g, \"''\") }}',\n '{{ ($json.district || '').replace(/'/g, \"''\") }}',\n NULLIF('{{ ($json.application_start || '').replace(/'/g, \"''\") }}','')::date,\n NULLIF('{{ ($json.application_end || '').replace(/'/g, \"''\") }}','')::date,\n '{{ ($json.move_in_date || '').replace(/'/g, \"''\") }}',\n NULLIF('{{ $json.eligibility_age_min ?? '' }}','')::int,\n NULLIF('{{ $json.eligibility_age_max ?? '' }}','')::int,\n '{{ ($json.eligibility_income || '').replace(/'/g, \"''\") }}',\n '{{ ($json.eligibility_asset || '').replace(/'/g, \"''\") }}',\n '{{ ($json.required_documents || '[]').replace(/'/g, \"''\") }}'::jsonb,\n '{{ ($json.supply_types || '[]').replace(/'/g, \"''\") }}'::jsonb,\n '{{ ($json.application_url || '').replace(/'/g, \"''\") }}',\n '{{ ($json.contact_phone || '').replace(/'/g, \"''\") }}',\n '{{ ($json.contact_department || '').replace(/'/g, \"''\") }}',\n '{{ ($json.parsed_html || '').replace(/'/g, \"''\") }}',\n '{{ ($json.parsed_markdown || '').replace(/'/g, \"''\") }}',\n '{{ ($json.detail_url || '').replace(/'/g, \"''\") }}',\n '{{ ($json.pdf_url || '').replace(/'/g, \"''\") }}',\n '{{ ($json.parse_status || '').replace(/'/g, \"''\") }}',\n '{{ ($json.missing_fields || '').replace(/'/g, \"''\") }}',\n '{{ JSON.stringify($json.embedding || null).replace(/'/g, \"''\") }}'::jsonb,\n NULLIF('{{ ($json.crawled_at || '').replace(/'/g, \"''\") }}','')::timestamptz,\n NOW()\n)\nON CONFLICT (post_id)\nDO UPDATE SET\n title = EXCLUDED.title,\n housing_type = EXCLUDED.housing_type,\n region = EXCLUDED.region,\n district = EXCLUDED.district,\n application_start = EXCLUDED.application_start,\n application_end = EXCLUDED.application_end,\n move_in_date = EXCLUDED.move_in_date,\n eligibility_age_min = EXCLUDED.eligibility_age_min,\n eligibility_age_max = EXCLUDED.eligibility_age_max,\n eligibility_income = EXCLUDED.eligibility_income,\n eligibility_asset = EXCLUDED.eligibility_asset,\n required_documents = EXCLUDED.required_documents,\n supply_types = EXCLUDED.supply_types,\n application_url = EXCLUDED.application_url,\n contact_phone = EXCLUDED.contact_phone,\n contact_department = EXCLUDED.contact_department,\n parsed_html = EXCLUDED.parsed_html,\n parsed_markdown = EXCLUDED.parsed_markdown,\n detail_url = EXCLUDED.detail_url,\n pdf_url = EXCLUDED.pdf_url,\n parse_status = EXCLUDED.parse_status,\n missing_fields = EXCLUDED.missing_fields,\n embedding = EXCLUDED.embedding,\n updated_at = NOW()\nRETURNING id, post_id, parse_status, source, title, missing_fields",
"options": {}
},
"id": "fa7436f5-3dab-4992-a6e4-f5ff32a2393e",
"name": "DB \uacf5\uace0 \uc800\uc7a5",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
4048,
448
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.parse_status }}",
"rightValue": "needs_review",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "6a7910ad-0605-4cb7-bce0-272084cc81ae",
"name": "\uac80\ud1a0 \ud544\uc694 \ubd84\uae30",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
4272,
448
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO admin_review_queue (post_id, source, title, reason, missing_fields, created_at)\nVALUES (\n '{{ ($json.post_id || '').replace(/'/g, \"''\") }}',\n '{{ ($json.source || '').replace(/'/g, \"''\") }}',\n '{{ ($json.title || '').replace(/'/g, \"''\") }}',\n '\ud544\uc218 \ud544\ub4dc \ub204\ub77d',\n NULLIF('{{ ($json.missing_fields || '').replace(/'/g, \"''\") }}', ''),\n NOW()\n)",
"options": {}
},
"id": "4942a103-1bce-438d-a938-4a53c4c681f4",
"name": "\uac80\ud1a0 \ud050 \uc800\uc7a5",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
4496,
352
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"workflowId": {
"__rl": true,
"mode": "id",
"value": "<__PLACEHOLDER__\uc6cc\ud06c\ud50c\ub85c\uc6b02_ID__>"
},
"options": {
"waitForSubWorkflow": false
}
},
"id": "22c5ad72-f2ec-488f-a2d4-759943e19fed",
"name": "\uc6cc\ud06c\ud50c\ub85c\uc6b0 2 \ud638\ucd9c",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.3,
"position": [
4496,
544
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "\nfunction countHangul(s) {\n return (String(s).match(/[\\uAC00-\\uD7A3]/g) || []).length;\n}\n\nfunction countCtrl(s) {\n return (String(s).match(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g) || []).length;\n}\n\nfunction isReadableHtml(text) {\n const s = String(text || '');\n return countHangul(s) >= 20 && countCtrl(s) === 0 && /<html/i.test(s);\n}\n\nfunction stripScriptsAndStyles(html) {\n return String(html || '')\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n .replace(/<noscript[\\s\\S]*?<\\/noscript>/gi, ' ')\n .replace(/<!--[\\s\\S]*?-->/g, ' ');\n}\n\nfunction pickHtmlBlock(html, patterns) {\n for (const re of patterns) {\n const m = html.match(re);\n if (m && m[1] && m[1].length > 300) return m[1];\n }\n return null;\n}\n\nfunction extractMainHtml(html, meta) {\n const source = String(meta?.source || '').toUpperCase();\n const url = String(meta?.url || meta?.detail_url || '');\n let chunk = html;\n\n if (source === 'SH' || /i-sh\\.co\\.kr/i.test(url)) {\n chunk = pickHtmlBlock(html, [\n /<div[^>]*class=[\"'][^\"']*detailTable[^\"']*firgs0401Table[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*(?:<div[^>]*class=[\"']detailTable|<div[^>]*id=[\"']hwpEditor)/i,\n /<div[^>]*class=[\"'][^\"']*detailTable[^\"']*gs0401Table[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>/i,\n /<div[^>]*class=[\"'][^\"']*board_view[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/div>/i,\n /<div[^>]*class=[\"'][^\"']*view_cont[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>/i,\n /<div[^>]*class=[\"']contents[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/div>\\s*<\\/div>/i,\n /<div[^>]*class=[\"'][^\"']*contents[\"'][^>]*>([\\s\\S]*?)<\\/div>/i,\n ]) || html;\n chunk = chunk\n .replace(/<div[^>]*id=[\"']hwpEditorBoardContent[\"'][^>]*>[\\s\\S]*?<\\/div>/gi, ' ')\n .replace(/<!--\\[data-hwpjson\\][\\s\\S]*?-->/gi, ' ');\n } else if (source === 'LH' || /apply\\.lh\\.or\\.kr/i.test(url)) {\n chunk = pickHtmlBlock(html, [\n /<div[^>]*id=[\"']sub_container[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/footer>/i,\n /<div[^>]*class=[\"'][^\"']*sub_container[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/footer>/i,\n /<div[^>]*class=[\"'][^\"']*wrtanc[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>/i,\n ]) || html;\n }\n\n return stripScriptsAndStyles(chunk);\n}\n\nfunction decodeHtmlEntities(text) {\n return String(text || '')\n .replace(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/</gi, '<')\n .replace(/>/gi, '>')\n .replace(/"/gi, '\"')\n .replace(/'/gi, \"'\")\n .replace(/&#(\\d+);/g, (_, n) => String.fromCharCode(Number(n)))\n .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));\n}\n\nfunction stripResidualHtmlTags(text) {\n let s = decodeHtmlEntities(String(text || ''));\n for (let i = 0; i < 3; i++) {\n s = s\n .replace(/<\\/?(?:table|thead|tbody|tfoot|tr|td|th|col|colgroup)[^>]*>/gi, '\\n')\n .replace(/<\\/?(?:div|span|p|a|img|ul|ol|li|h[1-6]|strong|em|b|i|u|sup|sub|font)[^>]*>/gi, ' ')\n .replace(/<br\\s*\\/?>/gi, '\\n')\n .replace(/<[^>]+>/g, ' ');\n }\n return s;\n}\n\nfunction normalizeWhitespace(text) {\n return String(text || '')\n .replace(/[ \\t]+\\n/g, '\\n')\n .replace(/\\n[ \\t]+/g, '\\n')\n .replace(/[ \\t]{2,}/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nfunction cellInnerText(html) {\n return normalizeWhitespace(stripResidualHtmlTags(convertTables(String(html || ''))));\n}\n\nfunction tableBodyToMarkdown(body) {\n const rows = [];\n const trRe = /<tr[^>]*>([\\s\\S]*?)<\\/tr>/gi;\n let trMatch;\n while ((trMatch = trRe.exec(body)) !== null) {\n const cells = [];\n const cellRe = /<t(?:d|h)[^>]*>([\\s\\S]*?)<\\/t(?:d|h)>/gi;\n let cMatch;\n while ((cMatch = cellRe.exec(trMatch[1])) !== null) {\n cells.push(cellInnerText(cMatch[1]));\n }\n if (cells.length) rows.push(cells);\n }\n if (!rows.length) return '\\n';\n const colCount = Math.max(...rows.map((r) => r.length));\n const norm = rows.map((r) => {\n const copy = r.slice();\n while (copy.length < colCount) copy.push('');\n return copy;\n });\n const esc = (v) => String(v).replace(/\\|/g, '\\\\|').replace(/\\s+/g, ' ').trim();\n const lines = norm.map((r) => '| ' + r.map(esc).join(' | ') + ' |');\n if (lines.length >= 1) {\n lines.splice(1, 0, '| ' + norm[0].map(() => '---').join(' | ') + ' |');\n }\n return '\\n' + lines.join('\\n') + '\\n';\n}\n\nfunction convertTables(html) {\n let s = String(html || '');\n let prev = '';\n let guard = 0;
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
httpHeaderAuthpostgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
2026-UP-Ambassador-v2. Uses httpRequest, postgres. Scheduled trigger; 32 nodes.
Source: https://github.com/kyne0127/2026-Spring-UpstageAIAmbassador-final-proj/blob/0a695dc9ae4f5bd126fb919bb4c53721fd4fbc03/2026-UP-Ambassador-v2.importable.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.
Disparador 1.8. Uses itemLists, postgres, emailSend, httpRequest. Scheduled trigger; 85 nodes.
공유회_알림톡_크론. Uses postgres, httpRequest, n8n-nodes-solapi. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.