AutomationFlowsData & Sheets › 2026 Up Ambassador V2

2026 Up Ambassador V2

2026-UP-Ambassador-v2. Uses httpRequest, postgres. Scheduled trigger; 32 nodes.

Cron / scheduled trigger★★★★★ complexity32 nodesHTTP RequestPostgres
Data & Sheets Trigger: Cron / scheduled Nodes: 32 Complexity: ★★★★★ Added:
2026 Up Ambassador V2 — n8n workflow card showing HTTP Request, Postgres integration

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 →

Download .json
{
  "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(/&nbsp;/gi, ' ')\n    .replace(/&amp;/gi, '&')\n    .replace(/&lt;/gi, '<')\n    .replace(/&gt;/gi, '>')\n    .replace(/&quot;/gi, '\"')\n    .replace(/&#39;/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(/&nbsp;/gi, ' ')\n      .replace(/&lt;/gi, '<')\n      .replace(/&gt;/gi, '>')\n      .replace(/&amp;/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(/&nbsp;/gi, ' ')\n    .replace(/&amp;/gi, '&')\n    .replace(/&lt;/gi, '<')\n    .replace(/&gt;/gi, '>')\n    .replace(/&quot;/gi, '\"')\n    .replace(/&#39;/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.

Pro

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 →

More Data & Sheets workflows → · Browse all categories →

Related workflows

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

Data & Sheets

Disparador 1.8. Uses itemLists, postgres, emailSend, httpRequest. Scheduled trigger; 85 nodes.

Item Lists, Postgres, Email Send +1
Data & Sheets

공유회_알림톡_크론. Uses postgres, httpRequest, n8n-nodes-solapi. Scheduled trigger; 39 nodes.

Postgres, HTTP Request, N8N Nodes Solapi
Data & Sheets

QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.

Postgres, Postgres Trigger, HTTP Request
Data & Sheets

QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.

Postgres, Postgres Trigger, HTTP Request
Data & Sheets

QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.

Postgres, Postgres Trigger, HTTP Request