여행 블로그 자동 작성 시스템 만들기

Hyeon·2025년 5월 7일
post-thumbnail

만들게된 계기

다낭 여행을 준비하며 숙소 정보를 찾다가, 블로그 포스트들을 여럿 확인했다. 그중 상당수가 아래와 같은 구조였다.

  • 숙소 이미지 한 장
  • 설명 몇 문장
  • 어필리에이트 링크

내용은 생성형 AI로 만든 듯했고, 깊이나 비교 정보는 거의 없었다. 그럼에도 이런 글들이 상위에 노출되고 있었다. 이 정도 퀄리티로도 전환이 일어난다면, 좀 더 고퀄리티로 자동화하면 유입도 많고, 전환도 높지 않을까? 라는 생각에서 콘텐츠 수집 → 생성 → 블로그 업로드까지 자동화 시스템을 만들게 됐다.

어떤 도시명이 있을까?

정보 수집 및 어필리에이트는 아고다로 하기로 결정했다. 서비스 규모가 크고, 어필리에이트 시스템도 잘되어있는 것으로 보였다. 블로그 자동화를 위해선 도시 이름을 입력받는 구조가 필요했다. 아고다는 공식적으로 도시 리스트나 ID를 공개하진 않고, 어필리에이트 프로그램에 가입하면 호텔 전체 데이터 파일을 받을 수 있다. 문의해서 받아봤는데, 용량이 약 2GB였다. row 하나당 호텔 하나였고, 위치, 이미지, 호텔명, 도시명 등 여러 정보가 담겨 있었다. 내가 필요한 건 그중에서도 city name과 city id였다. 그래서 이 CSV를 파싱해서, 도시 이름 → ID 형태의 JSON 파일을 만들어뒀다.

import fs from "fs";
import path from "path";
import csvParser from "csv-parser";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 입력 파일(hotels.csv)과 출력 파일(cities.json)의 경로를 설정합니다
const inputFile = path.resolve(__dirname, "../../data/hotels.csv");
const outputFile = path.resolve(__dirname, "../../data/cities.json");

// 도시 이름과 ID를 매핑할 객체를 생성합니다
const cityNameToId = {};

// CSV 파일을 스트림으로 읽어서 처리합니다
fs.createReadStream(inputFile)
  .pipe(csvParser())
  .on("data", (row) => {
    // 도시 이름에서 앞뒤 공백을 제거합니다
    const rawCityName = row.city?.trim();

    // 도시 ID를 가져옵니다
    const cityId = row.city_id;

    // 도시 이름에서 괄호와 그 안의 내용을 제거합니다 (예: "도쿄 (Tokyo)" -> "도쿄")
    const cityName = rawCityName?.replace(/\s*\([^)]*\)/g, "").trim();

    // 도시 이름과 ID가 있고, 아직 매핑되지 않은 경우에만 추가합니다
    if (cityName && cityId && !cityNameToId[cityName]) {
      cityNameToId[cityName] = parseInt(cityId, 10);
    }
  })
  .on("end", () => {
    // 모든 처리가 끝나면 결과를 JSON 파일로 저장합니다
    fs.writeFileSync(outputFile, JSON.stringify(cityNameToId, null, 2), "utf-8");
    console.log(`✅ JSON 저장 완료: ${outputFile}`);
  })
  .on("error", (err) => {
    // 에러가 발생하면 콘솔에 출력합니다
    console.error("❌ 에러 발생:", err);
  });

약 6만 7천개 정도의 도시가 있다. (많은데?)

{
  "호이안": 16552,
  "오사카": 9590,
  "나리타": 106352,
  "바르샤바": 16587,
  "토론토": 4495,
  "다낭": 16440,
  "하노이": 2758,
  ...
}

도시명으로 호텔정보 가져오기

아고다에서는 cityId를 통해 호텔 리스트를 조회할 수 있는 공식 API를 제공한다. 어필리에이트 프로그램에 가입하고 키를 발급받으면 사용할 수 있다.

API는 다양한 필터 조건을 지원한다. 이 필터 구조를 보면서 이 조건 자체가 콘텐츠의 제목이 될 수 있겠다는 생각이 들었다. 그래서 추천, 가족, 나홀로, 커플, 럭셔리, 가성비 등의 타입을 정의하고, 각 타입마다 적절한 필터를 매핑했다. 아래는 사용한 옵션 구조다.

{
  추천: {
    sortBy: "Recommended",
  },
  가족: {
    sortBy: "GroupsReviewScore",
    numberOfAdult: 2,
    numberOfChildren: 2,
  },
  나홀로: {
    sortBy: "SoloTravllersReviewScore",
    numberOfAdult: 1,
  },
  커플: {
    sortBy: "CouplesReviewScore",
    numberOfAdult: 2,
  },
  럭셔리: {
    minimumStarRating: 5,
    minimumReviewScore: 7,
  },
  가성비: {
    sortBy: "PriceAsc",
    minimumReviewScore: 8,
    minimumStarRating: 3,
  },
};

호출 시 옵션을 선택하면 해당 필터로 API를 호출하도록 구성했다. 다만, 이 API가 제공하는 정보는 매우 제한적이다. 호텔 이름, 평점, 별점, 조식 여부, 와이파이 여부, 이미지 한 장 정도만 제공되기 때문에 상세한 설명이나 체크포인트를 만들기에는 부족하다고 판단했다.

크롤링으로 추가정보 가져오기

추가적인 정보를 얻기 위해 호텔 상세페이지를 직접 열어서 필요한 데이터를 추출하려했다. 그런데 아쉽게도 Agoda는 hotelId만으로는 상세페이지에 바로 접근할 수 없도록 되어 있었다. 그래서 메인 페이지에서 호텔 이름을 검색하고, 목록 페이지로 이동한 뒤, 해당 호텔을 클릭해서 상세페이지로 이동하는 흐름을 따라야 했다.

이 과정에서 두 가지 문제가 있었다. 하나는 목록 페이지로 이동할 때 간헐적으로 광고 페이지가 끼어드는 경우였고, 다른 하나는 호텔 상세페이지가 새 탭으로 열릴 때도 있고, 같은 탭에서 열릴 때도 있다는 점이었다. 전자의 경우는 광고를 회피할 수 있는 검색 흐름을 정리하면서 해결했고, 후자의 경우는 Playwright의 page.waitForEvent("popup")과 내부 API 인터셉트를 통해 상세 URL을 추적해서 대응했다. 결과적으로 항상 동일한 방식으로 상세페이지를 접근하기 어려운 구조였지만, 흐름을 유연하게 잡아서 처리할 수 있었다.

export async function crawlHotelByName(hotelName) {
  let browser = null;
  let page = null;
  let hotelPage = null;
  let hotelUrl = "";

  try {
    // 0.브라우저 세팅
    browser = await chromium.launch({
      headless: true,
      timeout: 60000,
    });

    const context = await browser.newContext();
    page = await context.newPage();

    // 1. Agoda 메인 진입
    await page.goto(process.env.AGODA_URL_KR, {
      waitUntil: "domcontentloaded",
      timeout: 60000,
    });

    // 2. api 인터셉트 미리 걸어두기
    await page.route("**/api/cronos/layout/GetHotCities", async (route) => {
      const response = await route.fetch();
      const json = await response.json();

      const recentSearches = json.searchHistoryViewModel?.recentSearchList_CityAndHotel || [];

      const match = recentSearches.find((item) => item.resultText.toLowerCase().includes(hotelName.toLowerCase()));

      hotelUrl = match?.fullSearchUrl ? process.env.AGODA_URL + match.fullSearchUrl : null;

      await route.continue();
    });

    // 3. 호텔이름 입력
    await page.waitForSelector(SELECTORS.searchInput, { timeout: 10000 });
    await page.fill(SELECTORS.searchInput, hotelName);

    // 4. 호텔 검색(호텔 클릭 두 번해야 다른 모달이 닫힘)
    await page.waitForTimeout(500);
    await page.waitForSelector(SELECTORS.searchUl, { timeout: 10000 });
    await page.waitForSelector(SELECTORS.searchButton, { timeout: 10000 });
    await page.waitForSelector(SELECTORS.searchLi, { timeout: 10000 });
    await page.click(SELECTORS.searchLi);
    await page.waitForTimeout(500);
    await page.click(SELECTORS.searchButton);
    await page.waitForTimeout(500);
    await page.click(SELECTORS.searchButton);
    await page.waitForTimeout(3000);

    if (!hotelUrl) {
      throw new Error("호텔 URL을 찾을 수 없습니다.");
    }

    // 5. 호텔 상세페이지로 이동
    [hotelPage] = await Promise.all([
      page.waitForEvent("popup", { timeout: 10000 }),
      page.evaluate((url) => window.open(url, "_blank"), hotelUrl),
    ]);

    // 6. 호텔 상세페이지 로드 기다리기
    await hotelPage.waitForLoadState("domcontentloaded", { timeout: 10000 });

    await hotelPage.waitForSelector("p.fHvoAu", { timeout: 10000 });
    await hotelPage.waitForSelector('button[data-element-name="hotel-mosaic-tile"]', { timeout: 10000 });
    await hotelPage.waitForTimeout(2000);

    //6. 필요한 정보 추출
    const result = await hotelPage.evaluate(() => {
      const getText = (selector) => document.querySelector(selector)?.innerText.trim() || "";

      const getAllTexts = (selector) =>
        Array.from(document.querySelectorAll(selector))
          .map((el) => el.innerText.trim())
          .filter(Boolean);

      const getImageSrcs = (buttonSelector, imgSelector) =>
        Array.from(document.querySelectorAll(buttonSelector))
          .map((button) => button.querySelector(imgSelector)?.src)
          .filter(Boolean);

      return {
        // ✅ description: <p class="fHvoAu"> 중 가장 첫 번째 텍스트
        description: getText("p.fHvoAu"),

        // ✅ tiles: button[data-element-name="hotel-mosaic-tile"] 내부 img의 src
        tiles: getImageSrcs('button[data-element-name="hotel-mosaic-tile"]', "img").slice(1).slice(0, -1),

        // ✅ highlights: 각 div[data-element-name="atf-property-highlight"] > p.keaLUr
        highlights: getAllTexts('div[data-element-name="atf-property-highlight"] p.keaLUr'),

        // ✅ amenities: 각 div[data-element-name="atf-top-amenities-item"] > p.keaLUr
        amenities: getAllTexts('div[data-element-name="atf-top-amenities-item"] p.keaLUr'),
      };
    });

    return result;
  } catch (err) {
    console.error("❌ 크롤링 실패: ", hotelName, err.message);
    return null;
  } finally {
    // 모든 페이지와 브라우저를 안전하게 닫기
    if (hotelPage) {
      try {
        await hotelPage.close();
      } catch (e) {
        console.error("호텔 페이지 닫기 실패:", e.message);
      }
    }
    if (page) {
      try {
        await page.close();
      } catch (e) {
        console.error("메인 페이지 닫기 실패:", e.message);
      }
    }
    if (browser) {
      try {
        await browser.close();
      } catch (e) {
        console.error("브라우저 닫기 실패:", e.message);
      }
    }
  }
}

추가 추출한 정보는 다음과 같다

  • 호텔 설명 (description), 이미지 타일 목록 (tiles), 하이라이트 정보 (highlights), 편의시설 리스트 (amenities)

GPT를 통해 도시설명, 숙소 설명 생성

호텔 상세페이지에서 수집한 정보를 바탕으로, 도시 소개, 숙소별 설명, 블로그 글 제목을 GPT로 생성했다. 생각보다 코드를 짜는 것보다 프롬프트를 잘 작성하는 게 더 어려웠다. 특히 숙소 설명을 만들 때는 반복적으로 어색한 표현이나 번역체가 나와서 애를 많이 먹었다. "선사합니다", "되찾을 수 있습니다", "풍성하게 만들어보세요" 같은 표현이 자주 등장했는데, 실제 블로그에 올릴 글이라면 이런 표현은 피해야 했다. 그래서 프롬프트에 아예 "이런 표현은 절대 쓰지 말 것"이라는 조건을 명시적으로 넣었다.

GPT 모델은 gpt-4o와 gpt-3.5-turbo을 사용했고, temperature를 조절하면서 테스트했다. 아직도 완전히 만족스럽지는 않지만, 조건에 맞춘 글을 뽑아내는 구조는 어느 정도 자리를 잡았다.

// 숙소 설명 생성
export async function generateHotelDescription(hotel) {
  const prompt = `
  당신은 여행 블로그 에디터입니다.

  [목적]
  아래 숙소 정보를 바탕으로, 실제 블로그에 게재할 수 있을 만큼 자연스럽고 신뢰감 있는 숙소 소개 문장을 작성해주세요.
  광고 같지 않고, 실제 경험에 기반한 후기처럼 읽히는 글이 좋습니다.

  [입력 정보]
  - 숙소 이름: ${hotel.hotelName}
  - 도시: ${hotel.cityName}
  - 숙소 기본 설명: ${hotel.description}
  - 편의시설: ${hotel.amenities.join(", ")}

  [작성 조건 및 문체 가이드]
  - 문장은 5~7문장 이내로 구성해주세요.
  - 문체는 따뜻하고 정중한 설명체로, “~입니다”, “~합니다”로 끝나는 문장을 사용해주세요.
  - "선사합니다", "되찾을 수 있습니다", "풍성하게 만들어보세요" 같은 번역체 표현은 사용하지 마세요.
  - 실제 여행자가 참고할 수 있도록 정보 전달 중심의 글로 작성해주세요.
  - 블로거가 직접 숙박해보고 쓴 것처럼 객관적인 정보에 자연스러운 감상을 덧붙여 주세요.
  - 광고처럼 과장된 표현은 절대 사용하지 마세요.
  - 가격이나 숫자 정보보다는 숙소의 분위기, 위치, 주변 환경, 서비스의 특징 등을 중심으로 설명해주세요.
  - 무료 WIFI에 대한 내용은 포함하지 마세요.
  - “두 명의 여행자에게 적합” 같은 표현은 사용하지 마세요. 대상 지정 없이 일반적인 설명으로 작성해주세요.

  [자연스럽게 반영하면 좋은 요소]
  - 숙소의 위치와 주변 환경
  - 객실 분위기나 인테리어 특징
  - 제공되는 주요 서비스 (예: 조식, 수영장 등)
  - 이용 시 느껴지는 인상, 객관적인 장점
  - GPT가 알고 있는 브랜드나 지역 특성 정보도 포함 가능
  `;

  try {
    const res = await openai.chat.completions.create({
      model: "gpt-4o",
      // model: "gpt-3.5-turbo",
      temperature: 0.7,
      messages: [{ role: "user", content: prompt }],
    });

    return res.choices[0].message.content.trim();
  } catch (error) {
    console.error("GPT 응답 실패:", error);
    return "❌숙소 설명을 생성하는 데 실패했습니다.";
  }
}

도시 이미지 얻기

썸네일에 사용할 도시 이미지는 별도로 수집해야 했다. Agoda API는 호텔 이미지 정보는 제공하지만, 도시 자체에 대한 이미지는 포함되어 있지 않다. 그래서 Pixabay API를 사용해서 도시 이미지를 가져왔다. Pexels나 Unsplash도 검토해봤지만, 한글 검색어로는 결과가 없거나 부정확하게 나오는 경우가 많았다. 반면 Pixabay는 한글로도 안정적인 검색 결과를 제공했기 때문에, 도시 이미지는 Pixabay로 통일했다.

어필리에이트 링크 생성

호텔별 어필리에이트 링크는 Agoda에서 제공하는 구조를 따라, 내 어필리에이트 ID(cid)와 호텔 ID(hotelId)를 조합해서 만들었다. 이 링크는 블로그 HTML을 생성할 때 각 호텔 카드에 삽입되는 형태로 사용된다.

https://www.agoda.com/partners/partnersearch.aspx?pcs=1&cid=${process.env.AFFILIATE_CID}&hl=ko-kr&hid=${hotelId}

블로그 글 HTML 만들기

도시 이름, 도시 이미지, 글의 컨셉(예: 가성비, 추천, 럭셔리 등), 호텔 리스트, 호텔별 상세정보, 어필리에이트 링크, GPT로 생성한 설명들까지 전부 모았다. 이제 남은 건 블로그 글 형태로 이 데이터를 정리해 출력하는 일이다. 글은 HTML 형태로 만들어야 했고, 이 작업에는 handlebars 템플릿 엔진을 사용했다. 템플릿 파일(.hbs) 안에 구조를 정의하고, 거기에 수집된 데이터를 주입해서 최종 HTML을 렌더링하는 방식이다. 예를 들어 아래 함수는 템플릿을 불러와 컴파일한 뒤, 블로그 글 데이터를 넣어 HTML 문자열로 반환한다.

export const compileTemplate = ({ title, cityIntro, ResultHotelsData, cityImageUrl }) => {
  const templatePath = path.resolve(__dirname, "template.hbs");
  const templateSource = fs.readFileSync(templatePath, "utf-8");
  const template = Handlebars.compile(templateSource);

  return template({ title, cityIntro, ResultHotelsData, cityImageUrl });
};

블로그에 예약 발행하기

최종적으로 생성된 HTML 콘텐츠는 워드프레스 블로그에 업로드되도록 구성했다. 처음부터 블로그를 새로 만드는 것은 이 프로젝트의 목표와 거리가 멀었고, 워드프레스는 커스터마이징이 자유롭고, 추후 애드신스를 붙이기도 간편해 선택하게 됐다. 워드프레스 세팅 자체는 별도로 설명하지 않겠지만, 실제 포스팅 단계에서 가장 중요했던 건 예약 발행 기능이었다.

한국 사람들이 자주 찾는 여행 도시들을 우선순위로 정리해 jobQueue.json 파일을 만들고, 각 도시마다 원하는 글 타입(예: 추천, 가성비, 가족 등)을 지정해두었다. 도시x타입 개수 만큼 작업을 만들었다.

  {
    "city": "구마모토",
    "type": "나홀로"
  },
  {
    "city": "타이베이",
    "type": "가성비"
  },
  {
    "city": "괌",
    "type": "커플"
  },
  {
    "city": "오클랜드",
    "type": "가족"
  }
  ...

실행 스크립트는 하루 4개씩, 오전 9시, 12시, 15시, 18시에 예약되도록 설정했다. 이 예약 작업은 --start 옵션으로 시작 날짜를 지정하면 그 날짜 기준으로 순차적으로 예약이 들어가는 방식이다.

const jobQueuePath = path.resolve(__dirname, "../data/jobQueue.json");
const logPath = path.resolve(__dirname, "../data/post-log.json");

const SCHEDULE_HOURS = [9, 12, 15, 18];

const loadJobs = async () => {
  const data = await fs.readFile(jobQueuePath, "utf-8");
  return JSON.parse(data);
};

const loadLog = async () => {
  try {
    const data = await fs.readFile(logPath, "utf-8");
    return JSON.parse(data);
  } catch {
    return [];
  }
};

const saveLog = async (log) => {
  await fs.writeFile(logPath, JSON.stringify(log, null, 2));
};

const runJob = ({ city, type, scheduledAt }) => {
  return new Promise((resolve, reject) => {
    const args = ["src/main/index.js", "-c", city, "-t", type, "--scheduledAt", scheduledAt];
    const child = spawn("node", args, { stdio: "inherit" });

    child.on("exit", (code) => {
      code === 0 ? resolve() : reject(new Error(`작업 실패 (exit code ${code})`));
    });
  });
};

const main = async () => {
  const argv = minimist(process.argv.slice(2));
  const startDate = dayjs(argv.start);
  if (!startDate.isValid()) {
    console.error("❌ 시작 날짜를 YYYY-MM-DD 형식으로 입력해주세요. 예: --start 2025-04-10");
    process.exit(1);
  }

  const jobs = await loadJobs();
  const log = await loadLog();

  // ✅ city-type 조합 문자열로 중복 판단
  const completedKeys = new Set(log.map((j) => `${j.city}-${j.type}`));

  let currentDate = startDate;
  let hourIndex = 0;
  let jobCount = 0;

  for (let i = 0; i < jobs.length; i++) {
    const { city, type } = jobs[i];
    const key = `${city}-${type}`;

    if (completedKeys.has(key)) continue;

    const hour = SCHEDULE_HOURS[hourIndex];
    const scheduled = currentDate.hour(hour).minute(0).second(0).millisecond(0);
    const scheduledAt = scheduled.toISOString();

    console.log(`\n📌 예약: ${city} (${type}) → ${scheduled.format("YYYY-MM-DD HH:mm")}`);

    try {
      await runJob({ city, type, scheduledAt });
      log.push({ city, type, scheduledAt, status: "success" });
    } catch (err) {
      console.error(`❌ 실패: ${city} (${type})`);
      log.push({ city, type, scheduledAt, status: "failed", error: err.message });
    }

    await saveLog(log);

    // 다음 시간 계산
    hourIndex++;
    if (hourIndex >= SCHEDULE_HOURS.length) {
      hourIndex = 0;
      currentDate = currentDate.add(1, "day");
    }

    jobCount++;
  }

  if (jobCount === 0) {
    console.log("✅ 새로운 작업이 없습니다. 모든 jobQueue 항목이 이미 예약되었습니다.");
  } else {
    console.log(`\n✅ ${jobCount}개의 예약 작업이 완료되었습니다.`);
  }
};

실행 결과는 post-log.json에 기록되며, 성공/실패 여부와 함께 로그로 남는다. 스크립트는 내부적으로 child_process.spawn을 사용해 각 index.js 실행 단위를 처리하며, 이때 --scheduledAt 파라미터를 포함해 예약 발행 시간을 지정한다. 예약된 시간에 실제 포스트를 발행하는 것은 워드프레스에서 처리하며, 실패한 작업도 로그에 남기기 때문에 어떤 도시가 언제 실패했는지 추적할 수 있다.

  {
    "city": "홍콩",
    "type": "가족",
    "scheduledAt": "2025-05-12T03:00:00.000Z",
    "status": "success"
  },
  {
    "city": "가오슝",
    "type": "추천",
    "scheduledAt": "2025-05-12T06:00:00.000Z",
    "status": "success"
  },
  {
    "city": "후쿠오카",
    "type": "나홀로",
    "scheduledAt": "2025-05-12T09:00:00.000Z",
    "status": "success"
  },

결과

예약된 글

발행된 글

최종 Main 파일

const run = async () => {
  //0. 커맨드 입력
  const args = minimist(process.argv.slice(2), {
    alias: {
      c: "city",
      t: "type",
    },
    default: {
      type: "추천",
    },
  });

  const cityName = args.city;
  const type = args.type;
  const scheduledAt = args.scheduledAt;

  if (!cityName) {
    console.error("❌ 도시 이름을 입력해주세요. 예: -c 서울");
    process.exit(1);
  }

  try {
    //1. 도시 이름 → cityId 찾기
    const cityId = getCityId(cityName);

    //2. 호텔 리스트 조회
    let hotels = await getHotelList(cityId, type);

    if (hotels.length === 0) {
      console.warn(`⚠️ ${cityName}에 대한 호텔이 없습니다. 다음 도시로 넘어갑니다.`);
      return;
    }

    console.log(
      hotels.map((item) => {
        return [item.landingURL, item.hotelName];
      })
    );

    //3. 크롤링
    hotels = await crawlHotelDetails(cityName, hotels);

    if (hotels.length === 0) {
      console.warn(`⚠️ ${cityName}에 대한 호텔 상세 정보를 가져오지 못했습니다. 다음 도시로 넘어갑니다.`);
      return;
    }

    //4. 어필리에이트 링크 추가
    hotels = addAffiliateLink(hotels);

    //5. GPT로 글 제목, 도시설명, 인트로 숙소설명 얻기
    const title = await generatePostTitle({
      city: cityName,
      topic: "숙소",
      concept: type,
    });

    const { cityIntro, ResultHotelsData } = await getTextByGPT(cityName, title, hotels);

    //6. pixabay로 도시 사진 얻기 및 워드프레스 저장
    const cityImageUrl = await getCityImage(cityName);
    const mediaId = await uploadImageFromUrl(cityImageUrl);
    const uploadedCityImageUrl = await getImageUrlFromMediaId(mediaId);

    //7. html 생성
    const html = compileTemplate({
      title,
      cityIntro,
      ResultHotelsData,
      cityImageUrl: uploadedCityImageUrl,
    });

    const fileName = `${title}_post.html`;
    fs.writeFileSync(`./output/${fileName}`, html, "utf-8");

    //8. 블로그 포스팅
    await postToWordpress({
      html,
      title,
      imageUrl: cityImageUrl,
      cityName,
      scheduledAt,
    });
  } catch (error) {
    console.error(`${cityName} 처리 중 오류 발생:`, error.message);
    throw error;
  }
};
run();

느낀점

전체 흐름을 설계하는 과정이 꽤 재밌었다. 좀 더 고도화 할 수 있지 않을까? 하는 욕심이 있지만 하다보면 끝이 없을 것 같다. 단순히 기능 하나를 만드는 게 아니라, 입력부터 출력까지 어떤 순서로 어떤 도구를 써야 할지 고민하고 정리하는 일이 생각보다 잘 맞았다. 특히 Playwright를 써서 실제 사용자처럼 페이지를 탐색하고, 광고나 팝업처럼 예외 상황을 제어하면서 필요한 데이터를 추출하는 부분이 인상 깊었다. 생성형 AI는 활용 가능성은 많지만, 여전히 원하는 결과를 얻는 게 쉽지 않았다. 프롬프트를 조정해도 어딘가 어색한 문장이 반복되고, 결국 사람 손이 필요한 지점이 자주 보였다. 그럼에도 자동화 자체는 굉장히 재미있었고, 다른 주제나 도메인에도 확장해보고 싶은 마음이 들었다.

github

https://github.com/qudgus21/TravelPilot

profile
즐겁게하자

0개의 댓글